diff --git a/.changeset/five-status-lines.md b/.changeset/five-status-lines.md new file mode 100644 index 0000000000..2294eaf1a4 --- /dev/null +++ b/.changeset/five-status-lines.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow custom status line commands to render up to five output lines while keeping the built-in context readout. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 4bb8a75f9b..1afbd9d9aa 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -17,6 +17,7 @@ import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; import { + STATUS_LINE_MAX_RENDER_LINES, StatusLineCommandRunner, type StatusLinePayload, } from '#/tui/utils/status-line-command'; @@ -276,15 +277,21 @@ export class FooterComponent implements Component { // ── Line 1: slots composed per status_line.items, or a user command ── let line1: string; - let customLine: string | null = null; + let customOutput: string | null = null; if (this.statusLineRunner !== null) { this.statusLineRunner.maybeRefresh(this.statusLinePayload()); - customLine = this.statusLineRunner.current(); + customOutput = this.statusLineRunner.current(); } - if (customLine !== null) { - // status_line.command: the first stdout line takes over line 1. - line1 = chalk.hex(colors.text)(customLine); + const customLines = + customOutput === null + ? null + : customOutput + .split(/\r?\n/) + .slice(0, STATUS_LINE_MAX_RENDER_LINES) + .map((line) => chalk.hex(colors.text)(line)); + if (customLines !== null) { + line1 = customLines[0] ?? ''; } else { const slots = this.buildSlots(colors); const configured = this.state.statusLine?.items ?? null; @@ -350,7 +357,11 @@ export class FooterComponent implements Component { line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); } - return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + const renderedStatusLines = customLines ?? [line1]; + return [ + ...renderedStatusLines.map((line) => truncateToWidth(line, width)), + truncateToWidth(line2, width), + ]; } /** diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 36f43ba07b..6ab1dac2af 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -41,7 +41,7 @@ export const StatusLineFileConfigSchema = z.object({ 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. */ + /** User command whose first five stdout lines precede the context readout; null disables. */ command: z.string().nullable(), }); export type StatusLineConfig = z.infer; @@ -224,7 +224,7 @@ export function renderTuiConfig(config: TuiConfig): string { : `# [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. +# Or render your own: up to five stdout lines, followed by the built-in context readout. # 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/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts index 6482a4ac7c..0510d89321 100644 --- a/apps/kimi-code/src/tui/utils/status-line-command.ts +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -2,10 +2,10 @@ * 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 + * stdout. 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. + * JSON in, stdout out, 300ms ceiling. */ import { spawn } from 'node:child_process'; @@ -13,6 +13,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 const STATUS_LINE_MAX_RENDER_LINES = 5; export interface StatusLinePayload { model: string; @@ -81,17 +82,8 @@ 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); - } + if (stdout.length >= STATUS_LINE_MAX_CAPTURE_BYTES) return; + stdout += chunk.slice(0, STATUS_LINE_MAX_CAPTURE_BYTES - stdout.length); }); child.on('error', () => { clearTimeout(timer); @@ -103,8 +95,8 @@ export function runStatusLineCommand( finish(null); return; } - const firstLine = (stdout.split('\n')[0] ?? '').trimEnd(); - finish(firstLine.length > 0 ? firstLine : null); + const output = stdout.trimEnd(); + finish(output.length > 0 ? output : null); }); child.stdin?.on('error', () => { 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 a6be39adfb..cbfda743b4 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 @@ -134,7 +134,7 @@ describe('FooterComponent status_line items', () => { }); describe('runStatusLineCommand', () => { - it('passes the payload as JSON on stdin and returns the first stdout line', async () => { + it('passes the payload as JSON on stdin and returns stdout', async () => { const line = await runStatusLineCommand('cat', payload); expect(line).not.toBeNull(); @@ -156,10 +156,10 @@ describe('runStatusLineCommand', () => { expect(await runStatusLineCommand('sleep 2', payload, 100)).toBeNull(); }); - it('trims the line and ignores later lines', async () => { + it('preserves stdout lines', async () => { const line = await runStatusLineCommand('printf "first\\nsecond\\n"', payload); - expect(line).toBe('first'); + expect(line).toBe('first\nsecond'); }); it('caps the captured output instead of accumulating an unending stream', async () => { @@ -201,6 +201,46 @@ describe('FooterComponent status_line command', () => { expect(plain(footer.render(120)[0]!)).toContain('kimi-k2'); }); + + it('renders command output as separate lines before the context readout', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'printf "model\\nbranch\\nquota"' }, + }; + const footer = new FooterComponent(state); + footer.render(120); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(footer.render(120).map(plain)).toEqual([ + 'model', + 'branch', + 'quota', + expect.stringContaining('context: 0%'), + ]); + }); + + it('caps command output lines without dropping the context readout', async () => { + const output = 'line-1\\nline-2\\nline-3\\nline-4\\nline-5\\nline-6'; + const state: AppState = { + ...baseState, + statusLine: { items: null, command: `printf "${output}"` }, + }; + const footer = new FooterComponent(state); + footer.render(120); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const lines = footer.render(120).map(plain); + expect(lines).toEqual([ + 'line-1', + 'line-2', + 'line-3', + 'line-4', + 'line-5', + expect.stringContaining('context: 0%'), + ]); + }); }); describe('StatusLineCommandRunner', () => { diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 664fda616f..c9d5f2e8d3 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -35,6 +35,7 @@ describe('TUI config', () => { expect(text).toContain('Client preferences for kimi-code.'); expect(text).toContain('theme = "auto"'); expect(text).toContain('command = ""'); + expect(text).toContain('up to five stdout lines, followed by the built-in context readout'); expect(text).toContain('[upgrade]'); expect(text).toContain('auto_install = true'); expect(text).toContain('[notifications]'); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index b671922578..4ada9b8e52 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -393,7 +393,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | `[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 | +| `[status_line].command` | `string` | `""` | Custom status line command. Up to five stdout lines replace the first footer line, followed by the built-in context readout. A JSON snapshot (model, cwd, git branch, permission mode, plan mode, context usage, session id, version) is 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 diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 478959acf6..c1501b0f63 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -393,7 +393,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | `[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、每秒最多一次;失败时回退内置布局 | +| `[status_line].command` | `string` | `""` | 自定义状态栏命令。最多五行 stdout 会替换状态栏第一行,之后仍显示内置上下文读数。stdin 会收到 JSON 快照(model、cwd、git 分支、permission 模式、plan 模式、上下文用量、session id、版本)。运行上限 300ms、每秒最多一次;失败时回退内置布局 | ```toml # ~/.kimi-code/tui.toml