Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/five-status-lines.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 17 additions & 6 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
];
}

/**
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/src/tui/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof StatusLineConfigSchema>;
Expand Down Expand Up @@ -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"
`;
Expand Down
22 changes: 7 additions & 15 deletions apps/kimi-code/src/tui/utils/status-line-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@
* 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';

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;
Expand Down Expand Up @@ -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);
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
Expand Down
2 changes: 1 addition & 1 deletion docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down