diff --git a/.changeset/status-line-config.md b/.changeset/status-line-config.md new file mode 100644 index 0000000000..6aa1f81702 --- /dev/null +++ b/.changeset/status-line-config.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': minor +--- + +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 6f28a16da5..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) { @@ -48,6 +52,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..4bb8a75f9b 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,25 @@ 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?.dispose(); + this.statusLineRunner = null; + return; + } + 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); + } + } + /** * 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 +274,123 @@ 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 they were given an + // inline slot in items (rendered above at their configured position) + // or the user dropped 'tips' from items. + let tipText = ''; + const tipsInline = order.includes('tips'); + const showTips = !tipsInline && (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: [], + 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')); 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 +405,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..36f43ba07b 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, }); /** @@ -100,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; @@ -108,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( @@ -131,8 +163,26 @@ 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); + const statusLineItems = + config.status_line?.items + ?.filter((item) => { + const known = knownItems.has(item); + if (!known) { + 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,10 +195,39 @@ 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, + }, }); } 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. @@ -165,7 +244,8 @@ notification_condition = "${config.notifications.condition}" # "unfocused" | "al [upgrade] auto_install = ${String(config.upgrade.autoInstall)} # true | false -`; + +${statusSection}`; } function escapeTomlBasicString(value: string): string { 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..6482a4ac7c --- /dev/null +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -0,0 +1,185 @@ +/** + * 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 const STATUS_LINE_MAX_CAPTURE_BYTES = 65_536; + +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); + }; + + const isWin = process.platform === 'win32'; + let child; + try { + 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(() => { + killTree(); + finish(null); + }, timeoutMs); + timer.unref?.(); + + 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); + 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; + private pendingPayload: StatusLinePayload | null = null; + private trailingTimer: ReturnType | null = null; + + constructor( + 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) { + // 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) => { + this.inFlight = false; + if (line !== null) { + 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 new file mode 100644 index 0000000000..a6be39adfb --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -0,0 +1,262 @@ +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'; +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('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, + 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'); + }); + + 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', () => { + 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'); + }); + + 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 c4c5035cdb..664fda616f 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,92 @@ 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('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] +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'); + }); +}); + +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 ='); + }); +}); 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 13ee90ceba..b46eab17bf 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', 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`。