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

Add a TUI status line command for custom footer output. Set `[status_line].command` in `tui.toml` to use it.
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 @@ -42,6 +42,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
129 changes: 117 additions & 12 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ 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 { DEFAULT_TUI_CONFIG, type StatusLineConfig } from '#/tui/config';
import {
runStatusLineCommand,
type StatusLineCommandPayload,
} from '#/tui/utils/status-line-command';
import {
createGitStatusCache,
formatGitBadgeBase,
Expand All @@ -27,6 +32,7 @@ import { safeUsageRatio } from '#/utils/usage/usage-format';

const MAX_CWD_SEGMENTS = 3;
const GOAL_TIMER_INTERVAL_MS = 1_000;
const STATUS_LINE_REFRESH_INTERVAL_MS = 1_000;

// Toolbar tips — rotates every 10s. Most tips are short and pair up (two
// joined by " | ") when space allows; tips flagged `solo` are long or
Expand Down Expand Up @@ -188,6 +194,12 @@ export class FooterComponent implements Component {
private gitCache: GitStatusCache;
private gitCacheWorkDir: string;
private transientHint: string | null = null;
private statusLineText: string | null = null;
private statusLineInFlight = false;
private statusLineTimer: ReturnType<typeof setInterval> | null = null;
private statusLineCommand: string | null = null;
private statusLineGeneration = 0;
private disposed = false;
private goalSnapshotKey: string | null = null;
private goalObservedAtMs = Date.now();
private goalTimer: ReturnType<typeof setInterval> | null = null;
Expand All @@ -208,6 +220,7 @@ export class FooterComponent implements Component {
this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh });
this.syncGoalClock(state.goal);
this.syncGoalTimer(state.goal);
this.syncStatusLineTimer(statusLineConfig(state).command);
}

setState(state: AppState): void {
Expand All @@ -218,6 +231,7 @@ export class FooterComponent implements Component {
this.syncGoalClock(state.goal);
this.syncGoalTimer(state.goal);
this.state = state;
this.syncStatusLineTimer(statusLineConfig(state).command);
}

/**
Expand Down Expand Up @@ -332,29 +346,30 @@ export class FooterComponent implements Component {
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);
// ── Line 2: transient hint (bottom-left) + status line (right) ──
const statusLineText =
this.statusLineText ??
formatContextStatus(state.contextUsage, state.contextTokens, state.maxContextTokens);
const renderedStatusLine =
this.statusLineText === null ? chalk.hex(colors.text)(statusLineText) : statusLineText;
let line2: string;
if (this.transientHint) {
const maxHintWidth = Math.max(0, width - contextWidth - 1);
const statusLineWidth = visibleWidth(statusLineText);
const maxHintWidth = Math.max(0, width - statusLineWidth - 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);
const pad = Math.max(0, width - hintWidth - statusLineWidth);
line2 =
chalk.hex(colors.warning).bold(shownHint) +
' '.repeat(pad) +
chalk.hex(colors.text)(contextText);
renderedStatusLine;
} else {
const leftPad = Math.max(0, width - contextWidth);
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
const statusLineWidth = visibleWidth(statusLineText);
const leftPad = Math.max(0, width - statusLineWidth);
line2 = ' '.repeat(leftPad) + renderedStatusLine;
}

return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
Expand Down Expand Up @@ -383,11 +398,97 @@ export class FooterComponent implements Component {
}
}

private syncStatusLineTimer(command: string | null): void {
const commandChanged = command !== this.statusLineCommand;
if (command !== this.statusLineCommand) {
this.statusLineCommand = command;
this.statusLineGeneration += 1;
this.statusLineText = null;
}

if (command !== null) {
const timerCreated = this.statusLineTimer === null;
if (this.statusLineTimer === null) {
this.statusLineTimer = setInterval(() => {
void this.refreshStatusLine();
}, STATUS_LINE_REFRESH_INTERVAL_MS);
this.statusLineTimer.unref?.();
}
if (commandChanged || timerCreated) {
void this.refreshStatusLine();
}
return;
}

if (this.statusLineTimer !== null) {
clearInterval(this.statusLineTimer);
this.statusLineTimer = null;
}
}

private async refreshStatusLine(): Promise<void> {
const { command, timeoutMs } = statusLineConfig(this.state);
if (command === null || this.statusLineInFlight) return;
const generation = this.statusLineGeneration;
this.statusLineInFlight = true;
let text: string | null = null;
try {
text = await runStatusLineCommand({
command,
timeoutMs,
payload: this.createStatusLinePayload(),
});
} catch {
text = null;
} finally {
this.statusLineInFlight = false;
}

if (
this.disposed ||
generation !== this.statusLineGeneration ||
statusLineConfig(this.state).command !== command
) {
if (!this.disposed && statusLineConfig(this.state).command !== null) {
void this.refreshStatusLine();
}
return;
}

this.statusLineText = text;
this.onRefresh();
}

private createStatusLinePayload(): StatusLineCommandPayload {
const state = this.state;
return {
session_id: state.sessionId,
model: state.model,
display_model: modelDisplayName(state),
cwd: state.workDir,
permission_mode: state.permissionMode,
plan_mode: state.planMode,
input_mode: state.inputMode,
swarm_mode: state.swarmMode,
thinking_effort: state.thinkingEffort,
context: {
usage: safeUsage(state.contextUsage),
tokens: state.contextTokens,
max_tokens: state.maxContextTokens,
},
};
}

dispose(): void {
this.disposed = true;
if (this.goalTimer !== null) {
clearInterval(this.goalTimer);
this.goalTimer = null;
}
if (this.statusLineTimer !== null) {
clearInterval(this.statusLineTimer);
this.statusLineTimer = null;
}
}

private goalWallClockMs(goal: AppState['goal']): number | undefined {
Expand All @@ -411,3 +512,7 @@ function goalSnapshotKey(goal: AppState['goal']): string | null {
String(goal.budget.wallClockBudgetMs),
].join('\u0000');
}

function statusLineConfig(state: AppState): StatusLineConfig {
return (state as Partial<AppState>).statusLine ?? DEFAULT_TUI_CONFIG.statusLine;
}
31 changes: 31 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,11 @@ export const UpgradePreferencesSchema = z.object({
autoInstall: z.boolean(),
});

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

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

export const TuiConfigSchema = z.object({
Expand All @@ -57,12 +68,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 +86,18 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = {
autoInstall: true,
};

export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = {
command: null,
timeoutMs: 200,
};

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

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

export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig {
const command = config.editor?.command?.trim();
const statusLineCommand = config.status_line?.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 +165,13 @@ 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,
timeoutMs: config.status_line?.timeout_ms ?? DEFAULT_STATUS_LINE_CONFIG.timeoutMs,
},
});
}

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

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

[status_line]
command = "${escapeTomlBasicString(config.statusLine.command ?? '')}" # External command; empty disables custom status line
timeout_ms = ${String(config.statusLine.timeoutMs)} # Command timeout in milliseconds
`;
}

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 @@ -223,6 +223,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
3 changes: 2 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,7 @@ export interface AppState {
disablePasteBurst?: boolean;
notifications: NotificationsConfig;
upgrade: UpgradePreferences;
statusLine: StatusLineConfig;
availableModels: Record<string, ModelAlias>;
availableProviders: Record<string, ProviderConfig>;
sessionTitle: string | null;
Expand Down
Loading