Skip to content
Merged
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-config.md
Original file line number Diff line number Diff line change
@@ -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`.
9 changes: 7 additions & 2 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import type { SlashCommandHost } from './dispatch';
import { setExperimentalFeatures } from './experimental-flags';

export async function handleReloadTuiCommand(host: SlashCommandHost): Promise<void> {
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<void> {
const tuiConfig = await loadTuiConfig();
const tuiConfig = await loadTuiConfig(undefined, (message) =>
host.showStatus(message, 'warning'),
);
const session = host.session;

if (session !== undefined) {
Expand Down Expand Up @@ -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);
}
Expand Down
219 changes: 155 additions & 64 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,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,
Expand All @@ -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;

Expand Down Expand Up @@ -191,6 +197,7 @@ export class FooterComponent implements Component {
private goalSnapshotKey: string | null = null;
private goalObservedAtMs = Date.now();
private goalTimer: ReturnType<typeof setInterval> | 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
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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<string, string[]> {
const state = this.state;
const slots: Record<string, string[]> = {
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;
Expand All @@ -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 {
Expand Down
Loading
Loading