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/tui-statusline-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add a scriptable statusline to the TUI footer. Set `[statusline] command` in `tui.toml` to a shell command and the CLI runs it periodically (`interval_ms`, `timeout_ms` are configurable), passing the session context as JSON on stdin — session id, model, working directory, permission mode, plan mode, and context token usage. The first stdout line (ANSI SGR colors included) is rendered as a third footer line. An empty command disables the feature; failed runs silently keep the last successful output.
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig {
disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
statusLine: host.state.appState.statusLine,
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
73 changes: 72 additions & 1 deletion apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* Layout:
* Line 1: [yolo] [plan] <model> <cwd> <git-badge> <shortcut hints>
* Line 2: context: N% (tokens/max)
* Line 3: optional statusline — first stdout line of the user-configured
* `[statusline] command`, ANSI SGR passed through
*/

import type { Component } from '@moonshot-ai/pi-tui';
Expand All @@ -15,6 +17,7 @@ import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips';
import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance';
import { currentTheme } from '#/tui/theme';
import type { ColorPalette } from '#/tui/theme/colors';
import type { StatusLineConfig } from '#/tui/config';
import type { AppState } from '#/tui/types';
import {
createGitStatusCache,
Expand All @@ -23,6 +26,7 @@ import {
type GitStatus,
type GitStatusCache,
} from '#/utils/git/git-status';
import { StatusLineRunner } from '#/utils/statusline/status-line-runner';
import {
formatTokenCount,
usagePercent,
Expand Down Expand Up @@ -141,6 +145,26 @@ function modelDisplayName(state: AppState): string {
return effective?.displayName ?? effective?.model ?? state.model;
}

/**
* Session context passed to the statusline command on stdin. Field names
* follow Claude Code's statusline contract so community scripts port over.
*/
function buildStatusLineInput(state: AppState): Record<string, unknown> {
return {
session_id: state.sessionId,
version: state.version,
model: { id: state.model, display_name: modelDisplayName(state) },
workspace: { current_dir: state.workDir },
permission_mode: state.permissionMode,
plan_mode: state.planMode,
context: {
used_tokens: state.contextTokens,
max_tokens: state.maxContextTokens,
percent: usagePercent(state.contextTokens, state.maxContextTokens),
},
};
}

function shortenCwd(path: string): string {
if (!path) return path;
const home = process.env['HOME'] ?? '';
Expand Down Expand Up @@ -200,6 +224,8 @@ export class FooterComponent implements Component {
*/
private backgroundBashTaskCount = 0;
private backgroundAgentCount = 0;
private statusLineRunner: StatusLineRunner | null = null;
private statusLineRunnerKey: string | null = null;

constructor(state: AppState, onRefresh: () => void = () => {}) {
this.state = state;
Expand All @@ -208,6 +234,7 @@ export class FooterComponent implements Component {
this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh });
this.syncGoalClock(state.goal);
this.syncGoalTimer(state.goal);
this.syncStatusLineRunner(state.statusLine);
}

setState(state: AppState): void {
Expand All @@ -218,6 +245,10 @@ export class FooterComponent implements Component {
this.syncGoalClock(state.goal);
this.syncGoalTimer(state.goal);
this.state = state;
// Sync after `this.state` is updated: the runner's getInput closure
// reads it for the stdin payload. Restarts only on config change
// (e.g. /reload-tui), leaving a running interval untouched otherwise.
this.syncStatusLineRunner(state.statusLine);
}

/**
Expand Down Expand Up @@ -357,7 +388,13 @@ export class FooterComponent implements Component {
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
}

return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
// ── Line 3 (optional): statusline command output, ANSI passed through ──
const lines = [truncateToWidth(line1, width), truncateToWidth(line2, width)];
const statusLineOutput = this.statusLineRunner?.getOutput();
if (statusLineOutput) {
lines.push(truncateToWidth(statusLineOutput, width));
}
return lines;
}

private syncGoalClock(goal: AppState['goal']): void {
Expand Down Expand Up @@ -388,6 +425,40 @@ export class FooterComponent implements Component {
clearInterval(this.goalTimer);
this.goalTimer = null;
}
if (this.statusLineRunner !== null) {
this.statusLineRunner.stop();
this.statusLineRunner = null;
this.statusLineRunnerKey = null;
}
}

/**
* Keeps the statusline runner in sync with the configured command.
* A null/empty command stops the runner; any field change restarts it.
*/
private syncStatusLineRunner(config: StatusLineConfig): void {
const command = config.command;
if (command === null || command.length === 0) {
if (this.statusLineRunner !== null) {
this.statusLineRunner.stop();
this.statusLineRunner = null;
this.statusLineRunnerKey = null;
}
return;
}

const key = [command, String(config.intervalMs), String(config.timeoutMs)].join('\u0000');
if (key === this.statusLineRunnerKey) return;
this.statusLineRunner?.stop();
this.statusLineRunnerKey = key;
this.statusLineRunner = new StatusLineRunner({
command,
intervalMs: config.intervalMs,
timeoutMs: config.timeoutMs,
getInput: () => buildStatusLineInput(this.state),
onChange: this.onRefresh,
});
this.statusLineRunner.start();
}

private goalWallClockMs(goal: AppState['goal']): number | undefined {
Expand Down
47 changes: 47 additions & 0 deletions apps/kimi-code/src/tui/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export const UpgradePreferencesSchema = z.object({
autoInstall: z.boolean(),
});

/** Lower bound for the statusline refresh period; tighter loops just fork spam. */
export const STATUSLINE_MIN_INTERVAL_MS = 300;

export const StatusLineConfigSchema = z.object({
command: z.string().nullable(),
intervalMs: z.number().int().min(STATUSLINE_MIN_INTERVAL_MS),
timeoutMs: z.number().int().positive(),
});

export const TuiConfigFileSchema = z.object({
theme: TuiThemeSchema.optional(),
disable_paste_burst: z.boolean().optional(),
Expand All @@ -49,6 +58,13 @@ export const TuiConfigFileSchema = z.object({
auto_install: z.boolean().optional(),
})
.optional(),
statusline: z
.object({
command: z.string().optional(),
interval_ms: z.number().int().optional(),
timeout_ms: z.number().int().optional(),
})
.optional(),
});

export const TuiConfigSchema = z.object({
Expand All @@ -57,12 +73,14 @@ export const TuiConfigSchema = z.object({
editorCommand: z.string().nullable(),
notifications: NotificationsConfigSchema,
upgrade: UpgradePreferencesSchema,
statusLine: StatusLineConfigSchema,
});

export type TuiConfigFileShape = z.infer<typeof TuiConfigFileSchema>;
export type TuiConfig = z.infer<typeof TuiConfigSchema>;
export type NotificationsConfig = z.infer<typeof NotificationsConfigSchema>;
export type UpgradePreferences = z.infer<typeof UpgradePreferencesSchema>;
export type StatusLineConfig = z.infer<typeof StatusLineConfigSchema>;

export const DEFAULT_NOTIFICATIONS_CONFIG: NotificationsConfig = {
enabled: true,
Expand All @@ -73,12 +91,19 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = {
autoInstall: true,
};

export const DEFAULT_STATUSLINE_CONFIG: StatusLineConfig = {
command: null,
intervalMs: 2_000,
timeoutMs: 5_000,
};

export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({
theme: 'auto',
disablePasteBurst: false,
editorCommand: null,
notifications: DEFAULT_NOTIFICATIONS_CONFIG,
upgrade: DEFAULT_UPGRADE_PREFERENCES,
statusLine: DEFAULT_STATUSLINE_CONFIG,
});

/**
Expand Down Expand Up @@ -133,6 +158,7 @@ export async function saveTuiConfig(

export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig {
const command = config.editor?.command?.trim();
const statusLineCommand = config.statusline?.command?.trim();
return TuiConfigSchema.parse({
theme: config.theme ?? DEFAULT_TUI_CONFIG.theme,
disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
Expand All @@ -145,6 +171,22 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig {
upgrade: {
autoInstall: config.upgrade?.auto_install ?? DEFAULT_UPGRADE_PREFERENCES.autoInstall,
},
statusLine: {
command:
statusLineCommand === undefined || statusLineCommand.length === 0
? null
: statusLineCommand,
// Clamp instead of rejecting: a typo in one preference should not
// discard the whole config file.
intervalMs: Math.max(
STATUSLINE_MIN_INTERVAL_MS,
config.statusline?.interval_ms ?? DEFAULT_STATUSLINE_CONFIG.intervalMs,
),
timeoutMs: Math.max(
1,
config.statusline?.timeout_ms ?? DEFAULT_STATUSLINE_CONFIG.timeoutMs,
),
},
});
}

Expand All @@ -165,6 +207,11 @@ notification_condition = "${config.notifications.condition}" # "unfocused" | "al

[upgrade]
auto_install = ${String(config.upgrade.autoInstall)} # true | false

[statusline]
command = "${escapeTomlBasicString(config.statusLine.command ?? '')}" # Empty disables the statusline
interval_ms = ${String(config.statusLine.intervalMs)} # Refresh period; clamped to >= ${String(STATUSLINE_MIN_INTERVAL_MS)}
timeout_ms = ${String(config.statusLine.timeoutMs)} # Per-run timeout; the process is killed past it
`;
}

Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -52,6 +52,8 @@ export interface AppState {
disablePasteBurst?: boolean;
notifications: NotificationsConfig;
upgrade: UpgradePreferences;
/** External statusline command config; `command: null` disables the feature. */
statusLine: StatusLineConfig;
availableModels: Record<string, ModelAlias>;
availableProviders: Record<string, ProviderConfig>;
sessionTitle: string | null;
Expand Down
Loading