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
2 changes: 2 additions & 0 deletions .agents/skills/write-tui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ Themes are managed centrally under `src/tui/theme/`:
- `bundle.ts` — packs `colors`, `styles`, `markdownTheme` into a `KimiTUIThemeBundle`.
- `index.ts` / `detect.ts` — theme type and auto/dark/light resolution.

> **Keep the color-token set in sync.** `ColorPalette` in `colors.ts` is the source of truth for color tokens. When you add, rename, or remove one, update its mirrors in the same change: the custom-theme JSON schema (`apps/kimi-code/src/tui/theme/theme-schema.json`), the token tables in the custom-theme docs (`docs/en/customization/themes.md` and `docs/zh/customization/themes.md`), and the token table in the `custom-theme` built-in skill (`packages/agent-core/src/skill/builtin/custom-theme.md`).

Apply / switch flow:

- UI entry: `ThemeSelectorComponent` → `handleThemeCommand` → `applyThemeChoice`.
Expand Down
5 changes: 5 additions & 0 deletions .changeset/custom-theme-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add custom color themes. Define your own palette as a JSON file in `~/.kimi-code/themes/`, generate one with the built-in `/custom-theme` skill, or just ask Kimi to set up a theme for you.
43 changes: 43 additions & 0 deletions apps/kimi-code/hello.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* 🚀 A not-so-boring hello-world file.
*/

const EMOJIS = ['👋', '🎉', '✨', '🚀', '🔥', '🐱', '🌈', '🍕', '💡', '🦄'];

const SASSY_QUOTES = [
"Look who it is! It's",
"Oh, great. You again,",
"The legend themself,",
"Breaking news:",
"Plot twist:",
"My favorite human,",
];

export function hello(name: string = 'World'): string {
const emoji = EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
const quote = SASSY_QUOTES[Math.floor(Math.random() * SASSY_QUOTES.length)];
return `${emoji} ${quote} ${name}!`;
}

export function add(a: number, b: number): number {
if (Math.random() < 0.1) {
// 🤫 10% chance to lie, because chaos is fun
return a + b + 1;
}
return a + b;
}

export function roastMe(codeQuality: number): string {
if (codeQuality >= 90) return "Perfect code? Sus. I'm watching you. 👀";
if (codeQuality >= 70) return "Not bad, but I've seen better copy-paste jobs.";
if (codeQuality >= 50) return "This code walks into a bar... and segfaults.";
return "This code is what nightmares are made of. 🔥 (please refactor)";
}

// 🎲 Interactive self-test
if (import.meta.main) {
console.log(hello('TypeScript'));
console.log('2 + 3 =', add(2, 3), '(maybe...)');
console.log(roastMe(85));
console.log(roastMe(42));
}
1 change: 1 addition & 0 deletions apps/kimi-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
],
"type": "module",
"imports": {
"#/tui/theme": "./src/tui/theme/index.ts",
"#/*": [
"./src/*.ts",
"./src/*/index.ts"
Expand Down
9 changes: 4 additions & 5 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { TuiConfig } from '#/tui/config';
import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
import { CHROME_GUTTER } from '#/tui/constant/rendering';
import { KimiTUI } from '#/tui/index';
import { detectTerminalTheme } from '#/tui/theme/detect';
import { currentTheme, getColorPalette } from '#/tui/theme';

import type { CLIOptions } from './options';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
Expand All @@ -45,9 +45,9 @@ export async function runShell(
configWarning = error.message;
}

// Resolve `theme = "auto"` against the live terminal once, before pi-tui
// grabs stdin. Explicit `dark` / `light` skip detection.
const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme;
// Initialise the global Theme singleton before pi-tui grabs stdin.
const palette = await getColorPalette(tuiConfig.theme);
currentTheme.setPalette(palette);

const workDir = process.cwd();
const telemetryBootstrap = createCliTelemetryBootstrap();
Expand Down Expand Up @@ -98,7 +98,6 @@ export async function runShell(
version,
workDir,
startupNotice: configWarning,
resolvedTheme,
migrationPlan,
migrateOnly: runOptions.migrateOnly,
});
Expand Down
9 changes: 5 additions & 4 deletions apps/kimi-code/src/migration/migration-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@ea
import chalk from 'chalk';

import type { ColorPalette } from '#/tui/theme/colors';
import { currentTheme } from '#/tui/theme';
import {
resolveMigrationScope,
runMigration as realRunMigration,
Expand Down Expand Up @@ -46,7 +47,7 @@ export interface MigrationScreenOptions {
readonly plan: MigrationPlan;
readonly sourceHome: string;
readonly targetHome: string;
readonly colors: ColorPalette;
readonly colors?: ColorPalette;
/** Called once the screen is finished; the host then restores the editor. */
readonly onComplete: (result: MigrationScreenResult) => void;
/** Triggers a re-render; the host wires this to `ui.requestRender()`. */
Expand Down Expand Up @@ -276,7 +277,7 @@ export class MigrationScreenComponent extends Container implements Focusable {
}

private renderResult(width: number): string[] {
const { colors } = this.opts;
const colors = this.opts.colors ?? currentTheme.palette;
const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))];
if (this.migrationFailed) {
lines.push(chalk.hex(colors.error).bold(' Migration failed'));
Expand Down Expand Up @@ -422,7 +423,7 @@ export class MigrationScreenComponent extends Container implements Focusable {
}

private renderProgress(width: number): string[] {
const { colors } = this.opts;
const colors = this.opts.colors ?? currentTheme.palette;
const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0];
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
Expand Down Expand Up @@ -452,7 +453,7 @@ export class MigrationScreenComponent extends Container implements Focusable {
}

private renderAsk(width: number): string[] {
const { colors } = this.opts;
const colors = this.opts.colors ?? currentTheme.palette;
const step = this.currentStep();
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
Expand Down
39 changes: 18 additions & 21 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { SettingsSelectorComponent, type SettingsSelection } from '../components
import { ThemeSelectorComponent } from '../components/dialogs/theme-selector';
import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector';
import { saveTuiConfig } from '../config';
import type { Theme } from '../theme';
import type { ThemeName } from '#/tui/theme';
import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme';
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import { isTheme } from '../theme/index';
import { formatErrorMessage } from '../utils/event-payload';
import { showUsage } from './info';
import { setExperimentalFeatures } from './experimental-flags';
Expand Down Expand Up @@ -186,9 +186,12 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string):
showThemePicker(host);
return;
}
if (!isTheme(theme)) {
host.showError(`Unknown theme: ${theme}`);
return;
if (!isBuiltInTheme(theme)) {
const custom = await loadCustomThemeMerged(theme);
if (custom === null) {
host.showError(`Unknown theme: ${theme}`);
return;
}
}
await applyThemeChoice(host, theme);
}
Expand All @@ -215,7 +218,6 @@ function showEditorPicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new EditorSelectorComponent({
currentValue,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
void applyEditorChoice(host, value);
Expand Down Expand Up @@ -245,7 +247,7 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise
} catch (error) {
host.showStatus(
`Failed to save editor: ${formatErrorMessage(error)}`,
host.state.theme.colors.error,
'error',
);
return;
}
Expand Down Expand Up @@ -273,7 +275,6 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
currentValue: host.state.appState.model,
selectedValue,
currentThinking: host.state.appState.thinking,
colors: host.state.theme.colors,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();
void performModelSwitch(host, alias, thinking);
Expand Down Expand Up @@ -338,7 +339,7 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, thinkin
: persisted
? `Saved ${alias} with thinking ${level} as default.`
: `Already using ${alias} with thinking ${level}.`;
host.showStatus(status, host.state.theme.colors.success);
host.showStatus(status, 'success');
}

async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise<boolean> {
Expand All @@ -357,7 +358,6 @@ function showThemePicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new ThemeSelectorComponent({
currentValue: host.state.appState.theme,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
void applyThemeChoice(host, value);
Expand All @@ -369,7 +369,7 @@ function showThemePicker(host: SlashCommandHost): void {
);
}

async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise<void> {
async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promise<void> {
if (theme === host.state.appState.theme) {
if (theme === 'auto') host.refreshTerminalThemeTracking();
host.showStatus(`Theme unchanged: "${theme}".`);
Expand All @@ -386,12 +386,14 @@ async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise<v
} catch (error) {
host.showStatus(
`Failed to save theme: ${formatErrorMessage(error)}`,
host.state.theme.colors.error,
'error',
);
return;
}

const resolved = theme === 'auto' ? host.state.theme.resolvedTheme : theme;
const resolved = theme === 'auto'
? (currentTheme.palette === lightColors ? 'light' : 'dark')
: undefined;
host.applyTheme(theme, resolved);
host.refreshTerminalThemeTracking();
host.track('theme_switch', { theme });
Expand All @@ -403,7 +405,6 @@ export function showPermissionPicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new PermissionSelectorComponent({
currentValue: host.state.appState.permissionMode,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
void applyPermissionChoice(host, value);
Expand All @@ -419,7 +420,6 @@ export function showUpdatePreferencePicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new UpdatePreferenceSelectorComponent({
currentValue: host.state.appState.upgrade.autoInstall,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
void applyUpdatePreferenceChoice(host, value);
Expand Down Expand Up @@ -449,7 +449,7 @@ export async function applyExperimentalFeatureChanges(
if (changes.length === 0) {
host.showStatus(
'No experimental feature changes to apply.',
host.state.theme.colors.textMuted,
'textMuted',
);
return;
}
Expand All @@ -472,7 +472,7 @@ export async function applyExperimentalFeatureChanges(
'Experimental features updated. Session reloaded.',
);
} else {
host.showStatus('Experimental features updated.', host.state.theme.colors.success);
host.showStatus('Experimental features updated.', 'success');
}
host.track('experimental_features_apply', { changed: changes.length });
} catch (error) {
Expand All @@ -487,7 +487,6 @@ function mountExperimentsPanel(
host.mountEditorReplacement(
new ExperimentsSelectorComponent({
features,
colors: host.state.theme.colors,
onApply: (changes) => {
void applyExperimentalFeatureChanges(host, changes);
},
Expand All @@ -504,7 +503,6 @@ type UpdatePreferenceHost = {
SlashCommandHost['state']['appState'],
'theme' | 'editorCommand' | 'notifications' | 'upgrade'
>;
readonly theme: Pick<SlashCommandHost['state']['theme'], 'colors'>;
};
setAppState(patch: Pick<SlashCommandHost['state']['appState'], 'upgrade'>): void;
showStatus(msg: string, color?: string): void;
Expand All @@ -531,7 +529,7 @@ export async function applyUpdatePreferenceChoice(
} catch (error) {
host.showStatus(
`Failed to save automatic update setting: ${formatErrorMessage(error)}`,
host.state.theme.colors.error,
'error',
);
return;
}
Expand Down Expand Up @@ -562,7 +560,6 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod
export function showSettingsSelector(host: SlashCommandHost): void {
host.mountEditorReplacement(
new SettingsSelectorComponent({
colors: host.state.theme.colors,
onSelect: (value) => {
handleSettingsSelection(host, value);
},
Expand Down
6 changes: 3 additions & 3 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Component, Focusable } from '@earendil-works/pi-tui';
import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth';
import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';

import type { Theme } from '../theme';
import type { ColorToken, ThemeName } from '#/tui/theme';
import type { ResolvedTheme } from '../theme/colors';
import {
LLM_NOT_SET_MESSAGE,
Expand Down Expand Up @@ -105,7 +105,7 @@ export interface SlashCommandHost {
setAppState(patch: Partial<AppState>): void;
resetLivePane(): void;
showError(msg: string): void;
showStatus(msg: string, color?: string): void;
showStatus(msg: string, color?: ColorToken): void;
showNotice(title: string, detail?: string): void;
track(event: string, props?: Record<string, unknown>): void;
mountEditorReplacement(panel: Component & Focusable): void;
Expand All @@ -128,7 +128,7 @@ export interface SlashCommandHost {
showProgressSpinner(label: string): LoginProgressSpinnerHandle;

// Theme
applyTheme(theme: Theme, resolved?: ResolvedTheme): void;
applyTheme(theme: ThemeName, resolved?: ResolvedTheme): void;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Await theme application before refreshing auto tracking

When the user switches from a non-auto theme to auto, KimiTUI.applyTheme now returns a promise and only updates appState.theme after loading the palette, but the host contract still exposes it as void. Callers such as /theme and /reload-tui therefore call refreshTerminalThemeTracking() immediately while appState.theme still contains the old non-auto value, so the auto terminal-theme watcher is not installed and the UI stops following terminal background changes until another reload/restart. Please make this return a Promise<void> and await it before refreshing tracking.

Useful? React with 👍 / 👎.

refreshTerminalThemeTracking(): void;

// Dispatch
Expand Down
9 changes: 3 additions & 6 deletions apps/kimi-code/src/tui/commands/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ async function queueNextGoal(
host.track('goal_queue_append');
if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.();
host.state.transcriptContainer.addChild(
new UpcomingGoalAddedMessageComponent(host.state.theme.colors),
new UpcomingGoalAddedMessageComponent(),
);
host.state.ui.requestRender();
}
Expand All @@ -228,7 +228,6 @@ async function showGoalQueueManager(
new GoalQueueManagerComponent({
goals: snapshot.goals,
selectedGoalId,
colors: host.state.theme.colors,
onAction: async (action) => {
try {
return await handleGoalQueueManagerAction(host, action);
Expand Down Expand Up @@ -291,7 +290,6 @@ async function showGoalQueueEditDialog(
host.mountEditorReplacement(
new GoalQueueEditDialogComponent({
goal,
colors: host.state.theme.colors,
onDone: (result) => {
void handleGoalQueueEditResult(host, result).catch((error: unknown) => {
host.showError(`Failed to update upcoming goal: ${formatErrorMessage(error)}`);
Expand Down Expand Up @@ -354,7 +352,6 @@ function showGoalStartPermissionPrompt(
};
host.mountEditorReplacement(
new GoalStartPermissionPromptComponent({
colors: host.state.theme.colors,
mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual',
onSelect: (choice) => {
if (choice === 'cancel') {
Expand Down Expand Up @@ -416,7 +413,7 @@ async function startGoal(
return false;
}
host.track('goal_create', { replace: parsed.replace });
host.state.transcriptContainer.addChild(new GoalSetMessageComponent(host.state.theme.colors));
host.state.transcriptContainer.addChild(new GoalSetMessageComponent());
host.state.ui.requestRender();
if (options.sendInput !== undefined) {
options.sendInput(parsed.objective);
Expand Down Expand Up @@ -489,7 +486,7 @@ async function showGoalStatus(host: SlashCommandHost): Promise<void> {
return;
}
host.state.transcriptContainer.addChild(
new GoalStatusMessageComponent(goal, host.state.theme.colors),
new GoalStatusMessageComponent(goal),
);
host.state.ui.requestRender();
}
Expand Down
Loading
Loading