diff --git a/.agents/skills/write-tui/SKILL.md b/.agents/skills/write-tui/SKILL.md index be3885a31..3952b84b5 100644 --- a/.agents/skills/write-tui/SKILL.md +++ b/.agents/skills/write-tui/SKILL.md @@ -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`. diff --git a/.changeset/custom-theme-support.md b/.changeset/custom-theme-support.md new file mode 100644 index 000000000..1c64e64fc --- /dev/null +++ b/.changeset/custom-theme-support.md @@ -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. diff --git a/apps/kimi-code/hello.ts b/apps/kimi-code/hello.ts new file mode 100644 index 000000000..47aa7c7d9 --- /dev/null +++ b/apps/kimi-code/hello.ts @@ -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)); +} diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 7dc7bc555..6be158dda 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -33,6 +33,7 @@ ], "type": "module", "imports": { + "#/tui/theme": "./src/tui/theme/index.ts", "#/*": [ "./src/*.ts", "./src/*/index.ts" diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 21e23602b..b3443ee99 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -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'; @@ -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(); @@ -98,7 +98,6 @@ export async function runShell( version, workDir, startupNotice: configWarning, - resolvedTheme, migrationPlan, migrateOnly: runOptions.migrateOnly, }); diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index a0800d84c..cc00d28ac 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -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, @@ -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()`. */ @@ -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')); @@ -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)), @@ -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)), diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 3e652217e..69d277d88 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -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'; @@ -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); } @@ -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); @@ -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; } @@ -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); @@ -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 { @@ -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); @@ -369,7 +369,7 @@ function showThemePicker(host: SlashCommandHost): void { ); } -async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { +async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promise { if (theme === host.state.appState.theme) { if (theme === 'auto') host.refreshTerminalThemeTracking(); host.showStatus(`Theme unchanged: "${theme}".`); @@ -386,12 +386,14 @@ async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { host.restoreEditor(); void applyPermissionChoice(host, value); @@ -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); @@ -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; } @@ -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) { @@ -487,7 +487,6 @@ function mountExperimentsPanel( host.mountEditorReplacement( new ExperimentsSelectorComponent({ features, - colors: host.state.theme.colors, onApply: (changes) => { void applyExperimentalFeatureChanges(host, changes); }, @@ -504,7 +503,6 @@ type UpdatePreferenceHost = { SlashCommandHost['state']['appState'], 'theme' | 'editorCommand' | 'notifications' | 'upgrade' >; - readonly theme: Pick; }; setAppState(patch: Pick): void; showStatus(msg: string, color?: string): void; @@ -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; } @@ -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); }, diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index e3c1616d2..63fd81769 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -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, @@ -105,7 +105,7 @@ export interface SlashCommandHost { setAppState(patch: Partial): 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): void; mountEditorReplacement(panel: Component & Focusable): void; @@ -128,7 +128,7 @@ export interface SlashCommandHost { showProgressSpinner(label: string): LoginProgressSpinnerHandle; // Theme - applyTheme(theme: Theme, resolved?: ResolvedTheme): void; + applyTheme(theme: ThemeName, resolved?: ResolvedTheme): void; refreshTerminalThemeTracking(): void; // Dispatch diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index 79c7efdd4..4b1565687 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -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(); } @@ -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); @@ -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)}`); @@ -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') { @@ -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); @@ -489,7 +486,7 @@ async function showGoalStatus(host: SlashCommandHost): Promise { return; } host.state.transcriptContainer.addChild( - new GoalStatusMessageComponent(goal, host.state.theme.colors), + new GoalStatusMessageComponent(goal), ); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index f49b8d22f..a150d98a6 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -88,7 +88,6 @@ export async function showUsage(host: SlashCommandHost): Promise { const sessionUsage = await loadSessionUsageReport(host); const managedUsage = await loadManagedUsageReport(host); const lines = buildUsageReportLines({ - colors: host.state.theme.colors, sessionUsage: sessionUsage.usage, sessionUsageError: sessionUsage.error, contextUsage: host.state.appState.contextUsage, @@ -97,7 +96,7 @@ export async function showUsage(host: SlashCommandHost): Promise { managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary); + const panel = new UsagePanelComponent(lines, 'primary'); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -109,7 +108,6 @@ export async function showStatusReport(host: SlashCommandHost): Promise { ]); const appState = host.state.appState; const lines = buildStatusReportLines({ - colors: host.state.theme.colors, version: appState.version, model: appState.model, workDir: appState.workDir, @@ -127,7 +125,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise { managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status '); + const panel = new UsagePanelComponent(lines, 'primary', ' Status '); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -142,11 +140,10 @@ export async function showMcpServers(host: SlashCommandHost): Promise { } const lines = buildMcpStatusReportLines({ - colors: host.state.theme.colors, servers, }); const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + const panel = new UsagePanelComponent(lines, 'primary', title); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 0420e07a8..0f0a45a95 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -154,7 +154,6 @@ async function showPluginsPicker( plugins, selectedId: options?.selectedId, pluginHint: options?.pluginHint, - colors: host.state.theme.colors, onSelect: (selection) => { // Each branch of the handler either mounts the next view or restores // the editor itself, so do not pre-restore here — that would flash the @@ -181,7 +180,6 @@ async function showPluginMarketplacePicker(host: SlashCommandHost, source?: stri entries: marketplace.plugins, installedIds: new Set(installed.map((plugin) => plugin.id)), source: marketplace.source, - colors: host.state.theme.colors, onSelect: (selection) => { // Every marketplace action re-mounts a picker, so let the handler do // the mounting — pre-restoring the editor here would flash. @@ -218,7 +216,6 @@ async function showPluginMcpPicker( info, selectedServer: options?.selectedServer, serverHint: options?.serverHint, - colors: host.state.theme.colors, onSelect: (selection) => { // Every MCP action re-mounts a picker, so let the handler do the // mounting — pre-restoring the editor here would flash on toggle. @@ -247,7 +244,6 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< new PluginRemoveConfirmComponent({ id, displayName, - colors: host.state.theme.colors, onDone: (result: PluginRemoveConfirmResult) => { host.restoreEditor(); resolveConfirmed(result.kind === 'confirm'); @@ -376,19 +372,18 @@ async function renderPluginsList( ): Promise { const currentPlugins = plugins ?? (await host.requireSession().listPlugins()); const lines = buildPluginsListLines({ - colors: host.state.theme.colors, plugins: currentPlugins, }); const title = ` Plugins (${currentPlugins.length}) `; - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + const panel = new UsagePanelComponent(lines, 'primary', title); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { const info = await host.requireSession().getPluginInfo(id); - const lines = buildPluginsInfoLines({ colors: host.state.theme.colors, info }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ` ${info.id} `); + const lines = buildPluginsInfoLines({ info }); + const panel = new UsagePanelComponent(lines, 'primary', ` ${info.id} `); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index a6b7a8c80..67fd89a29 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -21,7 +21,6 @@ import type { SlashCommandHost } from './dispatch'; export function promptPlatformSelection(host: SlashCommandHost): Promise { return new Promise((resolve) => { const selector = new PlatformSelectorComponent({ - colors: host.state.theme.colors, onSelect: (platformId) => { host.restoreEditor(); resolve(platformId); @@ -45,7 +44,6 @@ export function promptLogoutProviderSelection( title: 'Select a provider to log out', options, currentValue, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); resolve(value); @@ -64,7 +62,7 @@ export function promptFeedbackInput(host: SlashCommandHost): Promise { host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); - }, host.state.theme.colors); + }); host.mountEditorReplacement(dialog); }); } @@ -82,7 +80,6 @@ export function promptApiKey( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, - host.state.theme.colors, ); host.mountEditorReplacement(dialog); }); @@ -109,7 +106,6 @@ export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: const picker = new ChoicePickerComponent({ title: 'Select a provider', options, - colors: host.state.theme.colors, searchable: true, onSelect: (value) => { host.restoreEditor(); @@ -172,7 +168,6 @@ export function runModelSelector( models: modelDict, currentValue: firstAlias, currentThinking: initialThinking, - colors: host.state.theme.colors, searchable: true, onSelect: ({ alias, thinking }) => { host.restoreEditor(); diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index e8c4edfc1..55f9817fa 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -49,7 +49,6 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt return { providers: host.state.appState.availableProviders, activeProviderId, - colors: host.state.theme.colors, onAdd: () => { void handleProviderAdd(host); }, @@ -132,7 +131,6 @@ function promptProviderAddSource( { value: 'known', label: 'Known third-party provider' }, { value: 'custom', label: 'Custom registry (api.json)' }, ], - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); resolve(value === 'known' || value === 'custom' ? value : undefined); @@ -232,7 +230,6 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { currentValue: host.state.appState.model, selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, initialTabId: providerId, onSelect: ({ alias, thinking }) => { host.restoreEditor(); @@ -304,7 +301,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise count === 1 ? 'Imported 1 provider from registry.' : `Imported ${String(count)} providers from registry.`, - host.state.theme.colors.success, + 'success', ); // Offer the model selector so the user can pick a default, just like the @@ -321,7 +318,6 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise currentValue: host.state.appState.model, selectedValue: firstNewAlias, currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, initialTabId: firstNewProvider, onSelect: ({ alias, thinking }) => { host.restoreEditor(); @@ -344,7 +340,6 @@ function promptCustomRegistryImport( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, - host.state.theme.colors, ); host.mountEditorReplacement(dialog); }); diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a93d7b6ec..d0eaac5ce 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -1,5 +1,6 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; +import { currentTheme, lightColors } from '#/tui/theme'; import { loadTuiConfig, type TuiConfig } from '../config'; import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; @@ -7,7 +8,7 @@ import { setExperimentalFeatures } from './experimental-flags'; export async function handleReloadTuiCommand(host: SlashCommandHost): Promise { const tuiConfig = await loadTuiConfig(); applyReloadedTuiConfig(host, tuiConfig); - host.showStatus('TUI config reloaded.', host.state.theme.colors.success); + host.showStatus('TUI config reloaded.', 'success'); } export async function handleReloadCommand(host: SlashCommandHost): Promise { @@ -28,7 +29,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise if (session === undefined) { host.showStatus( 'Runtime and TUI config reloaded; no active session.', - host.state.theme.colors.success, + 'success', ); } } @@ -37,7 +38,9 @@ export function applyReloadedTuiConfig( host: SlashCommandHost, config: TuiConfig, ): void { - const resolved = config.theme === 'auto' ? host.state.theme.resolvedTheme : config.theme; + const resolved = config.theme === 'auto' + ? (currentTheme.palette === lightColors ? 'light' : 'dark') + : undefined; host.applyTheme(config.theme, resolved); host.refreshTerminalThemeTracking(); host.setAppState({ diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 752fd27aa..f385d7ac7 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -183,6 +183,6 @@ function renderWelcome(host: SlashCommandHost): void { return; } host.state.transcriptContainer.addChild( - new WelcomeComponent(host.state.appState, host.state.theme.colors), + new WelcomeComponent(host.state.appState), ); } diff --git a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts index de2704228..ad9044d7d 100644 --- a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts +++ b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts @@ -8,16 +8,14 @@ import type { Component } from '@earendil-works/pi-tui'; import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface DeviceCodeBoxParams { readonly title: string; readonly url: string; readonly code: string; readonly hint?: string; - readonly colors: ColorPalette; } export class DeviceCodeBoxComponent implements Component { @@ -30,28 +28,28 @@ export class DeviceCodeBoxComponent implements Component { invalidate(): void {} render(width: number): string[] { - const { title, url, code, hint, colors } = this.params; - const border = (s: string): string => chalk.hex(colors.primary)(s); + const { title, url, code, hint } = this.params; + const border = (s: string): string => currentTheme.fg('primary', s); const safeWidth = Math.max(28, width); const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const titleLine = truncateToWidth(chalk.bold.hex(colors.textStrong)(title), innerWidth, '…'); + const titleLine = truncateToWidth(currentTheme.boldFg('textStrong', title), innerWidth, '…'); const promptLine = truncateToWidth( - chalk.hex(colors.textDim)('Visit the URL below in your browser to authorize:'), + currentTheme.fg('textDim', 'Visit the URL below in your browser to authorize:'), innerWidth, '…', ); - const urlLine = truncateToWidth(chalk.hex(colors.primary)(url), innerWidth, '…'); + const urlLine = truncateToWidth(currentTheme.fg('primary', url), innerWidth, '…'); - const codeLabel = chalk.bold.hex(colors.textDim)('Verification code: '); - const codeValue = chalk.bold.hex(colors.accent)(code); + const codeLabel = currentTheme.boldFg('textDim', 'Verification code: '); + const codeValue = currentTheme.boldFg('accent', code); const codeLine = truncateToWidth(`${codeLabel}${codeValue}`, innerWidth, '…'); const contentLines: string[] = [titleLine, '', promptLine, urlLine, '', codeLine]; if (hint !== undefined && hint.length > 0) { contentLines.push(''); - contentLines.push(truncateToWidth(chalk.hex(colors.textDim)(hint), innerWidth, '…')); + contentLines.push(truncateToWidth(currentTheme.fg('textDim', hint), innerWidth, '…')); } const lines: string[] = [ diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 350da47ce..af4eac057 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -206,7 +206,7 @@ function formatContextStatus(usage: number, tokens?: number, maxTokens?: number) } export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { - const base = chalk.hex(colors.status)(formatGitBadgeBase(status)); + const base = chalk.hex(colors.textDim)(formatGitBadgeBase(status)); if (status.pullRequest === null) return base; const pullRequest = chalk.hex(colors.primary)( @@ -300,7 +300,7 @@ export class FooterComponent implements Component { const modelLabel = `${model}${thinkingLabel}`; let renderedModelLabel = chalk.hex(colors.text)(modelLabel); if (isRainbowDancing()) { - renderedModelLabel = renderDanceFooterModel(modelLabel, colors); + renderedModelLabel = renderDanceFooterModel(modelLabel); } left.push(renderedModelLabel); } @@ -322,7 +322,7 @@ export class FooterComponent implements Component { } const cwd = shortenCwd(state.workDir); - if (cwd) left.push(chalk.hex(colors.status)(cwd)); + if (cwd) left.push(chalk.hex(colors.textDim)(cwd)); const git = this.gitCache.getStatus(); if (git !== null) { diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 0f8a0b05e..65d17d860 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -8,22 +8,20 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; -import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; +import { currentTheme } from '#/tui/theme'; export class WelcomeComponent implements Component { private state: AppState; - private colors: ColorPalette; - constructor(state: AppState, colors: ColorPalette) { + constructor(state: AppState) { this.state = state; - this.colors = colors; } invalidate(): void {} render(width: number): string[] { - const primary = (s: string): string => chalk.hex(this.colors.primary)(s); + const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s); const innerWidth = Math.max(10, width - 4); const pad = ' '; @@ -34,13 +32,13 @@ export class WelcomeComponent implements Component { const textWidth = Math.max(4, innerWidth - logoWidth - gap.length); const rightRow0 = truncateToWidth( - chalk.bold.hex(this.colors.primary)('Welcome to Kimi Code!'), + chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'), textWidth, '…', ); const isLoggedOut = !this.state.model; - const dim = chalk.hex(this.colors.textDim); - const labelStyle = chalk.bold.hex(this.colors.textDim); + const dim = chalk.hex(currentTheme.palette.textDim); + const labelStyle = chalk.bold.hex(currentTheme.palette.textDim); const rightRow1 = truncateToWidth( dim(isLoggedOut ? 'Run /login or /provider to get started.' : 'Send /help for help information.'), textWidth, @@ -52,12 +50,12 @@ export class WelcomeComponent implements Component { primary(logo[1].padEnd(logoWidth)) + gap + rightRow1, ]; if (isRainbowDancing()) { - renderedHeaderLines = renderDanceWelcomeHeader(this.colors, logo, textWidth, rightRow1); + renderedHeaderLines = renderDanceWelcomeHeader(logo, textWidth, rightRow1); } const activeModel = this.state.availableModels[this.state.model]; const modelValue = isLoggedOut - ? chalk.hex(this.colors.warning)('not set, run /login or /provider') + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); const infoLines = [ diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index 2a06a4278..a8c0ceb14 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -7,9 +7,8 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type ApiKeyInputResult = | { readonly kind: 'ok'; readonly value: string } @@ -47,7 +46,6 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly input = new Input(); private readonly onDone: (result: ApiKeyInputResult) => void; - private readonly colors: ColorPalette; private readonly title: string; private readonly subtitleLines: readonly string[]; private done = false; @@ -57,11 +55,9 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { platformName: string, subtitleLines: readonly string[], onDone: (result: ApiKeyInputResult) => void, - colors: ColorPalette, ) { super(); this.onDone = onDone; - this.colors = colors; this.title = `Enter API key for ${platformName}`; this.subtitleLines = subtitleLines; this.input.onSubmit = (value) => { @@ -97,13 +93,13 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(this.title); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', this.title); const subtitleSource = this.emptyHinted ? ['API key cannot be empty.'] : this.subtitleLines; const subtitleLines = subtitleSource.map((line) => - truncateToWidth(chalk.hex(this.colors.textDim)(line), innerWidth, '…'), + truncateToWidth(currentTheme.fg('textDim', line), innerWidth, '…'), ); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index 51aea329a..57b6795ad 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -14,8 +14,7 @@ import { truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +import { currentTheme } from '#/tui/theme'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { @@ -25,7 +24,6 @@ import type { FileContentDisplayBlock, PendingApproval, } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; export interface ApprovalPanelResponse { readonly response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; @@ -49,24 +47,23 @@ interface BlockStyles { errorBold: (s: string) => string; } -function makeBlockStyles(colors: ColorPalette): BlockStyles { +function makeBlockStyles(): BlockStyles { return { - strong: (s) => chalk.hex(colors.textStrong)(s), - dim: (s) => chalk.hex(colors.textDim)(s), - accent: (s) => chalk.hex(colors.accent)(s), - gutter: (s) => chalk.hex(colors.diffGutter)(s), - errorBold: (s) => chalk.bold.hex(colors.error)(s), + strong: (s) => currentTheme.fg('textStrong', s), + dim: (s) => currentTheme.fg('textDim', s), + accent: (s) => currentTheme.fg('accent', s), + gutter: (s) => currentTheme.fg('diffGutter', s), + errorBold: (s) => currentTheme.boldFg('error', s), }; } function renderDisplayBlock( block: DisplayBlock, s: BlockStyles, - colors: ColorPalette, ): string[] { switch (block.type) { case 'diff': - return renderDiffLinesClustered(block.old_text, block.new_text, block.path, colors, { + return renderDiffLinesClustered(block.old_text, block.new_text, block.path, { contextLines: 3, expandKeyHint: 'ctrl+e to preview', maxLines: DIFF_SUMMARY_MAX_LINES, @@ -187,7 +184,6 @@ export class ApprovalPanelComponent extends Container implements Focusable { private readonly feedbackInput = new Input(); private onResponse: (response: ApprovalPanelResponse) => void; private request: PendingApproval; - private readonly colors: ColorPalette; private readonly onToggleToolOutput: (() => void) | undefined; private readonly onTogglePlanExpand: (() => void) | undefined; private readonly onOpenPreview: @@ -197,7 +193,6 @@ export class ApprovalPanelComponent extends Container implements Focusable { constructor( request: PendingApproval, onResponse: (response: ApprovalPanelResponse) => void, - colors: ColorPalette, onToggleToolOutput?: () => void, onTogglePlanExpand?: () => void, onOpenPreview?: (block: DiffDisplayBlock | FileContentDisplayBlock) => void, @@ -205,7 +200,6 @@ export class ApprovalPanelComponent extends Container implements Focusable { super(); this.request = request; this.onResponse = onResponse; - this.colors = colors; this.onToggleToolOutput = onToggleToolOutput; this.onTogglePlanExpand = onTogglePlanExpand; this.onOpenPreview = onOpenPreview; @@ -305,12 +299,12 @@ export class ApprovalPanelComponent extends Container implements Focusable { this.ensureValidSelection(); this.feedbackInput.focused = this.focused && this.feedbackMode; const { data } = this.request; - const blockStyles = makeBlockStyles(this.colors); - const borderColor = chalk.hex(this.colors.borderFocus); - const borderColorBold = chalk.bold.hex(this.colors.borderFocus); - const selectColorBold = chalk.bold.hex(this.colors.accent); - const dim = chalk.hex(this.colors.textDim); - const strong = chalk.hex(this.colors.textStrong); + const blockStyles = makeBlockStyles(); + const borderColor = (text: string) => currentTheme.fg('borderFocus', text); + const borderColorBold = (text: string) => currentTheme.boldFg('borderFocus', text); + const selectColorBold = (text: string) => currentTheme.boldFg('accent', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const strong = (text: string) => currentTheme.fg('textStrong', text); const horizontalBar = borderColor('─'.repeat(width)); const indent = (s: string): string => ` ${s}`; @@ -331,7 +325,7 @@ export class ApprovalPanelComponent extends Container implements Focusable { if (visibleBlocks.length > 0) { lines.push(''); for (const block of visibleBlocks) { - const blockLines = renderDisplayBlock(block, blockStyles, this.colors); + const blockLines = renderDisplayBlock(block, blockStyles); for (const line of blockLines) { lines.push(indent(line)); } @@ -405,8 +399,7 @@ export class ApprovalPanelComponent extends Container implements Focusable { } private renderInlineFeedbackLine(width: number, labelWithNum: string): string { - const selectColorBold = chalk.bold.hex(this.colors.accent); - const prefix = `${selectColorBold('▶')} ${selectColorBold(labelWithNum)} `; + const prefix = `${currentTheme.boldFg('accent', '▶')} ${currentTheme.boldFg('accent', labelWithNum)} `; const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); const inputLine = this.feedbackInput.render(inputWidth)[0] ?? '> '; const inlineInput = inputLine.startsWith('> ') ? inputLine.slice(2) : inputLine; diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts index 7853f7e23..15974959f 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -25,12 +25,11 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLines } from '#/tui/components/media/diff-preview'; import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -39,7 +38,6 @@ export type ApprovalPreviewBlock = DiffDisplayBlock | FileContentDisplayBlock; export interface ApprovalPreviewViewerProps { readonly block: ApprovalPreviewBlock; - readonly colors: ColorPalette; readonly onClose: () => void; } @@ -62,9 +60,9 @@ export class ApprovalPreviewViewer extends Container implements Focusable { private readonly props: ApprovalPreviewViewerProps; private readonly terminal: Terminal; /** Pre-rendered body lines (ANSI-styled, no border / no gutter). */ - private readonly bodyLines: string[]; + private bodyLines: string[]; /** Title shown in the header (path + diff stats / "Write" label). */ - private readonly headerTitle: string; + private headerTitle: string; /** Index of the topmost visible line. */ private scrollTop = 0; @@ -72,7 +70,7 @@ export class ApprovalPreviewViewer extends Container implements Focusable { super(); this.props = props; this.terminal = terminal; - const built = buildBody(props.block, props.colors); + const built = buildBody(props.block); this.bodyLines = built.lines; this.headerTitle = built.title; } @@ -98,11 +96,11 @@ export class ApprovalPreviewViewer extends Container implements Focusable { this.scrollBy(1); return; } - if (matchesKey(data, Key.pageUp) || k === ' ' || data === '') { + if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\x02') { this.scrollBy(-Math.max(1, visible - 1)); return; } - if (matchesKey(data, Key.pageDown) || data === '') { + if (matchesKey(data, Key.pageDown) || data === '\x06') { this.scrollBy(Math.max(1, visible - 1)); return; } @@ -120,9 +118,15 @@ export class ApprovalPreviewViewer extends Container implements Focusable { this.scrollTo(this.scrollTop + delta); } + override invalidate(): void { + const built = buildBody(this.props.block); + this.bodyLines = built.lines; + this.headerTitle = built.title; + } + private scrollTo(target: number): void { this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); - this.invalidate(); + super.invalidate(); } private maxScroll(): number { @@ -146,14 +150,11 @@ export class ApprovalPreviewViewer extends Container implements Focusable { } private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' Preview '); + const title = currentTheme.boldFg('primary', ' Preview '); return fitExactly(title + this.headerTitle, width); } private renderBody(width: number, bodyHeight: number): string[] { - const colors = this.props.colors; - const stroke = colors.primary; const innerWidth = Math.max(1, width - 4); const max = this.maxScroll(); @@ -161,23 +162,22 @@ export class ApprovalPreviewViewer extends Container implements Focusable { if (this.scrollTop < 0) this.scrollTop = 0; const viewRows = bodyHeight - 2; - const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); const out: string[] = [top]; for (let i = 0; i < viewRows; i++) { const lineIndex = this.scrollTop + i; const raw = this.bodyLines[lineIndex] ?? ''; - out.push(chalk.hex(stroke)('│ ') + fitExactly(raw, innerWidth) + chalk.hex(stroke)(' │')); + out.push(currentTheme.fg('primary', '│ ') + fitExactly(raw, innerWidth) + currentTheme.fg('primary', ' │')); } out.push(bottom); return out; } private renderFooter(width: number, bodyHeight: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); const total = this.bodyLines.length; const viewRows = Math.max(1, bodyHeight - 2); @@ -186,7 +186,8 @@ export class ApprovalPreviewViewer extends Container implements Focusable { const lineFrom = total === 0 ? 0 : this.scrollTop + 1; const lineTo = Math.min(total, this.scrollTop + viewRows); - const position = chalk.hex(colors.textMuted)( + const position = currentTheme.fg( + 'textMuted', ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, ); const keys = @@ -209,14 +210,14 @@ interface BuiltBody { title: string; } -function buildBody(block: ApprovalPreviewBlock, colors: ColorPalette): BuiltBody { +function buildBody(block: ApprovalPreviewBlock): BuiltBody { if (block.type === 'diff') { - return buildDiffBody(block, colors); + return buildDiffBody(block); } - return buildFileContentBody(block, colors); + return buildFileContentBody(block); } -function buildDiffBody(block: DiffDisplayBlock, colors: ColorPalette): BuiltBody { +function buildDiffBody(block: DiffDisplayBlock): BuiltBody { // renderDiffLines emits a `+N -M path` header on its first line followed // by every changed line. We pull the header out into the viewer chrome so // the body is purely scrollable diff content; this also means we don't @@ -225,7 +226,6 @@ function buildDiffBody(block: DiffDisplayBlock, colors: ColorPalette): BuiltBody block.old_text, block.new_text, block.path, - colors, false, block.old_start ?? 1, block.new_start ?? 1, @@ -234,14 +234,13 @@ function buildDiffBody(block: DiffDisplayBlock, colors: ColorPalette): BuiltBody return { lines: rest, title: stripLeadingSpace(header) }; } -function buildFileContentBody(block: FileContentDisplayBlock, colors: ColorPalette): BuiltBody { +function buildFileContentBody(block: FileContentDisplayBlock): BuiltBody { const lang = block.language ?? langFromPath(block.path); const highlighted = highlightLines(block.content, lang); - const gutter = chalk.hex(colors.diffGutter); const lines = highlighted.map( - (line, i) => gutter(String(i + 1).padStart(4) + ' ') + line, + (line, i) => currentTheme.fg('diffGutter', String(i + 1).padStart(4) + ' ') + line, ); - const title = chalk.hex(colors.textStrong)(block.path); + const title = currentTheme.fg('textStrong', block.path); return { lines, title }; } diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index d5375d776..c03444f9b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -16,10 +16,8 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -37,11 +35,10 @@ export interface ChoiceOption { export interface ChoicePickerOptions { readonly title: string; readonly hint?: string; - readonly formatHint?: (text: string, colors: ColorPalette) => string; + readonly formatHint?: (text: string) => string; readonly notice?: string; readonly options: readonly ChoiceOption[]; readonly currentValue?: string; - readonly colors: ColorPalette; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate. */ @@ -118,7 +115,6 @@ export class ChoicePickerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const searchable = this.opts.searchable === true; const view = this.list.view(); const options = view.items; @@ -132,41 +128,41 @@ export class ChoicePickerComponent extends Container implements Focusable { const hint = this.opts.hint ?? navParts.join(' · '); const titleSuffix = - searchable && view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(` ${this.opts.title}`) + titleSuffix, + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix, this.opts.formatHint === undefined - ? chalk.hex(colors.textMuted)(` ${hint}`) - : this.opts.formatHint(` ${hint}`, colors), + ? currentTheme.fg('textMuted', ` ${hint}`) + : this.opts.formatHint(` ${hint}`), ]; if (this.opts.notice !== undefined) { - lines.push(chalk.hex(colors.success)(` ${this.opts.notice}`)); + lines.push(currentTheme.fg('success', ` ${this.opts.notice}`)); } lines.push(''); if (searchable && view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query)); + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); } if (options.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } for (let i = view.page.start; i < view.page.end; i++) { const opt = options[i]!; const isSelected = i === view.selectedIndex; const isCurrent = opt.value === this.opts.currentValue; const pointer = isSelected ? SELECT_POINTER : ' '; - const labelStyle = optionLabelStyle(opt, isSelected, colors); - let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); + const labelStyle = optionLabelStyle(opt, isSelected); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); line += labelStyle(opt.label); if (isCurrent) { - line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); + line += ' ' + currentTheme.fg('success', CURRENT_MARK); } lines.push(line); if (opt.description !== undefined && opt.description.length > 0) { const descriptionWidth = Math.max(1, width - 4); for (const descLine of wrapDescription(opt.description, descriptionWidth)) { - lines.push(chalk.hex(colors.textMuted)(` ${descLine}`)); + lines.push(currentTheme.fg('textMuted', ` ${descLine}`)); } } } @@ -174,12 +170,12 @@ export class ChoicePickerComponent extends Container implements Focusable { lines.push(''); if (view.page.pageCount > 1) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg('textMuted', ` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`, ), ); } - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } } @@ -187,10 +183,13 @@ export class ChoicePickerComponent extends Container implements Focusable { function optionLabelStyle( option: ChoiceOption, selected: boolean, - colors: ColorPalette, ): (text: string) => string { if (option.tone === 'danger') { - return selected ? chalk.hex(colors.error).bold : chalk.hex(colors.error); + return selected + ? (text) => currentTheme.boldFg('error', text) + : (text) => currentTheme.fg('error', text); } - return selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + return selected + ? (text) => currentTheme.boldFg('primary', text) + : (text) => currentTheme.fg('text', text); } diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index 6a55ede98..158afbbd4 100644 --- a/apps/kimi-code/src/tui/components/dialogs/compaction.ts +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -15,17 +15,16 @@ import { Container, Text, Spacer } from '@earendil-works/pi-tui'; import type { TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; const BLINK_INTERVAL = 500; export class CompactionComponent extends Container { - private readonly colors: ColorPalette; private readonly ui: TUI | undefined; private readonly headerText: Text; + private readonly instruction: string | undefined; private blinkOn = true; private blinkTimer: ReturnType | null = null; private done = false; @@ -33,23 +32,40 @@ export class CompactionComponent extends Container { private tokensBefore: number | undefined; private tokensAfter: number | undefined; - constructor(colors: ColorPalette, ui?: TUI, instruction?: string | undefined) { + constructor(ui?: TUI, instruction?: string | undefined) { super(); - this.colors = colors; this.ui = ui; + this.instruction = instruction; // Top margin so the block isn't glued to the previous transcript // entry (status line, tool result, etc.). this.addChild(new Spacer(1)); this.headerText = new Text(this.buildHeader(), 0, 0); this.addChild(this.headerText); - if (instruction !== undefined) { - this.addChild(new Text(chalk.dim(` ${instruction}`), 0, 0)); - } + this.addInstructionChild(); this.startBlink(); } + private addInstructionChild(): void { + if (this.instruction !== undefined) { + this.addChild(new Text(currentTheme.dim(` ${this.instruction}`), 0, 0)); + } + } + + override invalidate(): void { + // Rebuild instruction line with fresh theme colours. + if (this.instruction !== undefined) { + // Remove the last child if it is the instruction line (it is always + // added after headerText and Spacer). + if (this.children.length > 2) { + this.children.pop(); + } + this.addInstructionChild(); + } + super.invalidate(); + } + markDone(tokensBefore?: number, tokensAfter?: number): void { if (this.done || this.canceled) return; this.done = true; @@ -74,21 +90,21 @@ export class CompactionComponent extends Container { private buildHeader(): string { if (this.done) { - const bullet = chalk.hex(this.colors.success)(STATUS_BULLET); - const label = chalk.hex(this.colors.success).bold('Compaction complete'); + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('success', 'Compaction complete'); const detail = this.tokensBefore !== undefined && this.tokensAfter !== undefined - ? chalk.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) + ? currentTheme.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) : ''; return `${bullet}${label}${detail}`; } if (this.canceled) { - const bullet = chalk.hex(this.colors.warning)(STATUS_BULLET); - const label = chalk.hex(this.colors.warning).bold('Compaction cancelled'); + const bullet = currentTheme.fg('warning', STATUS_BULLET); + const label = currentTheme.boldFg('warning', 'Compaction cancelled'); return `${bullet}${label}`; } - const bullet = this.blinkOn ? chalk.hex(this.colors.roleAssistant)(STATUS_BULLET) : ' '; - const label = chalk.hex(this.colors.primary).bold('Compacting context...'); + const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; + const label = currentTheme.boldFg('primary', 'Compacting context...'); return `${bullet}${label}`; } diff --git a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts index ec2f1d389..d85b80170 100644 --- a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -18,9 +18,8 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface CustomRegistryImportValue { readonly url: string; @@ -54,7 +53,7 @@ function maskInputLine(raw: string): string { // Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.) // while masking every other visible character. - const parts = content.split(/((?:\[[0-9;]*m|_pi:c))/); + const parts = content.split(/(\x1B(?:\[[0-9;]*m|_pi:c\x07))/); const maskedContent = parts .map((part, index) => { if (index % 2 === 1) return part; // ANSI sequence @@ -71,19 +70,16 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo private readonly urlInput = new Input(); private readonly tokenInput = new Input(); private readonly onDone: (result: CustomRegistryImportResult) => void; - private readonly colors: ColorPalette; private activeField: FieldId = 'url'; private done = false; private hint: 'none' | 'url-empty' | 'token-empty' = 'none'; constructor( onDone: (result: CustomRegistryImportResult) => void, - colors: ColorPalette, defaultUrl: string = '', ) { super(); this.onDone = onDone; - this.colors = colors; if (defaultUrl.length > 0) this.urlInput.setValue(defaultUrl); // Enter on the URL field advances to the token field; Enter on the token // (last) field submits. @@ -145,16 +141,17 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); const subtitleText = this.hint === 'url-empty' ? SUBTITLE_URL_EMPTY : this.hint === 'token-empty' ? SUBTITLE_TOKEN_EMPTY : SUBTITLE_DEFAULT; - const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); - const footerStyled = chalk.hex(this.colors.textDim)( + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg( + 'textDim', this.activeField === 'url' ? FOOTER_NOT_LAST : FOOTER_LAST, ); @@ -162,12 +159,12 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo const tokenLabelText = 'Bearer token'; const urlLabelStyled = this.activeField === 'url' - ? chalk.bold.hex(this.colors.accent)(urlLabelText) - : chalk.hex(this.colors.textDim)(urlLabelText); + ? currentTheme.boldFg('accent', urlLabelText) + : currentTheme.fg('textDim', urlLabelText); const tokenLabelStyled = this.activeField === 'token' - ? chalk.bold.hex(this.colors.accent)(tokenLabelText) - : chalk.hex(this.colors.textDim)(tokenLabelText); + ? currentTheme.boldFg('accent', tokenLabelText) + : currentTheme.fg('textDim', tokenLabelText); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); diff --git a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts index 24467dd05..9e98a457b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const EDITOR_OPTIONS: readonly ChoiceOption[] = [ { value: 'code --wait', label: 'VS Code (code --wait)' }, { value: 'vim', label: 'Vim' }, @@ -12,7 +10,6 @@ const EDITOR_OPTIONS: readonly ChoiceOption[] = [ export interface EditorSelectorOptions { readonly currentValue: string; - readonly colors: ColorPalette; readonly onSelect: (value: string) => void; readonly onCancel: () => void; } @@ -23,7 +20,6 @@ export class EditorSelectorComponent extends ChoicePickerComponent { title: 'Select external editor', options: [...EDITOR_OPTIONS], currentValue: opts.currentValue, - colors: opts.colors, onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts index c7dcb8da6..44042f057 100644 --- a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -7,10 +7,9 @@ import { type Focusable, } from '@earendil-works/pi-tui'; import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -23,7 +22,6 @@ export interface ExperimentalFeatureDraftChange { export interface ExperimentsSelectorOptions { readonly features: readonly ExperimentalFeatureState[]; - readonly colors: ColorPalette; readonly onApply: (changes: readonly ExperimentalFeatureDraftChange[]) => void; readonly onCancel: () => void; } @@ -66,28 +64,27 @@ export class ExperimentsSelectorComponent extends Container implements Focusable } override render(width: number): string[] { - const { colors } = this.opts; const view = this.list.view(); const titleSuffix = - view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; const hintParts = ['↑↓ navigate']; if (view.page.pageCount > 1) hintParts.push('PgUp/PgDn page'); hintParts.push('Space toggle', 'Enter apply', 'Esc cancel'); if (view.query.length > 0) hintParts.push('Backspace clear'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Experimental features') + titleSuffix, - chalk.hex(colors.textMuted)(` ${hintParts.join(' · ')}`), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Experimental features') + titleSuffix, + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), '', ]; if (view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query)); + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); } if (view.items.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } for (let i = view.page.start; i < view.page.end; i++) { @@ -99,19 +96,21 @@ export class ExperimentsSelectorComponent extends Container implements Focusable lines.push(''); if (view.query.length > 0) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` ${String(view.items.length)} / ${String(this.opts.features.length)}`, ), ); } else if (view.page.end < view.items.length) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` ▼ ${String(view.items.length - view.page.end)} more`, ), ); } lines.push(this.renderApplyButton()); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } @@ -145,15 +144,18 @@ export class ExperimentsSelectorComponent extends Container implements Focusable } private renderApplyButton(): string { - const { colors } = this.opts; const changes = this.draftChanges(); const count = changes.length; const label = '[ Apply changes and reload ]'; const summary = count === 0 ? 'no changes' : `${String(count)} ${count === 1 ? 'change' : 'changes'}`; - const buttonStyle = count === 0 ? chalk.hex(colors.textDim) : chalk.hex(colors.primary).bold; - const summaryStyle = count === 0 ? chalk.hex(colors.textMuted) : chalk.hex(colors.success); - return ` ${buttonStyle(label)} ${summaryStyle(summary)}`; + const button = count === 0 + ? currentTheme.fg('textDim', label) + : currentTheme.boldFg('primary', label); + const summaryText = count === 0 + ? currentTheme.fg('textMuted', summary) + : currentTheme.fg('success', summary); + return ` ${button} ${summaryText}`; } private renderFeature( @@ -161,23 +163,22 @@ export class ExperimentsSelectorComponent extends Container implements Focusable selected: boolean, width: number, ): string[] { - const { colors } = this.opts; const pointer = selected ? SELECT_POINTER : ' '; - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); + const label = selected ? currentTheme.boldFg('primary', feature.title) : currentTheme.fg('text', feature.title); const enabled = this.effectiveEnabled(feature); const status = enabled ? 'enabled' : 'disabled'; - const statusStyle = enabled ? chalk.hex(colors.success) : chalk.hex(colors.textDim); + const statusText = enabled ? currentTheme.fg('success', status) : currentTheme.fg('textDim', status); const detail = this.isDraftChanged(feature) ? `${featureDetail(feature)} · modified` : featureDetail(feature); const lines = [ - `${prefix}${labelStyle(feature.title)} ${statusStyle(status)}`, - chalk.hex(colors.textMuted)(` ${detail}`), + `${prefix}${label} ${statusText}`, + currentTheme.fg('textMuted', ` ${detail}`), ]; const descriptionWidth = Math.max(1, width - 4); for (const line of wrapText(feature.description, descriptionWidth)) { - lines.push(chalk.hex(colors.textMuted)(` ${line}`)); + lines.push(currentTheme.fg('textMuted', ` ${line}`)); } return lines; } diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts index 1fb963625..8680b4a03 100644 --- a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -16,9 +16,7 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type FeedbackInputDialogResult = | { readonly kind: 'ok'; readonly value: string } @@ -34,14 +32,12 @@ export class FeedbackInputDialogComponent extends Container implements Focusable private readonly input = new Input(); private readonly onDone: (result: FeedbackInputDialogResult) => void; - private readonly colors: ColorPalette; private done = false; private emptyHinted = false; - constructor(onDone: (result: FeedbackInputDialogResult) => void, colors: ColorPalette) { + constructor(onDone: (result: FeedbackInputDialogResult) => void) { super(); this.onDone = onDone; - this.colors = colors; this.input.onSubmit = (value) => { this.submit(value); }; @@ -75,11 +71,11 @@ export class FeedbackInputDialogComponent extends Container implements Focusable const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); const subtitleText = this.emptyHinted ? SUBTITLE_EMPTY : SUBTITLE_DEFAULT; - const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts index bf5f72356..e2df47676 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -15,7 +15,7 @@ import type { GoalQueueSnapshot, UpcomingGoal, } from '#/tui/goal-queue-store'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -42,7 +42,6 @@ export type GoalQueueManagerAction = export interface GoalQueueManagerOptions { readonly goals: readonly UpcomingGoal[]; readonly selectedGoalId?: string; - readonly colors: ColorPalette; readonly pageSize?: number; readonly onAction: ( action: GoalQueueManagerAction, @@ -56,7 +55,6 @@ export type GoalQueueEditResult = export interface GoalQueueEditDialogOptions { readonly goal: UpcomingGoal; - readonly colors: ColorPalette; readonly onDone: (result: GoalQueueEditResult) => void; } @@ -115,20 +113,19 @@ export class GoalQueueManagerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const view = this.list.view(); const hint = this.movingGoalId === undefined ? '↑↓ navigate · Space select · E edit · D delete · Esc cancel' : '↑↓ reorder · Space done · E edit · D delete · Esc cancel'; const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Upcoming goals'), - chalk.hex(colors.textMuted)(` ${hint}`), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Upcoming goals'), + currentTheme.fg('textMuted', ` ${hint}`), '', ]; if (this.goals.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No upcoming goals.')); + lines.push(currentTheme.fg('textMuted', ' No upcoming goals.')); } else { for (let i = view.page.start; i < view.page.end; i++) { const goal = view.items[i]; @@ -139,20 +136,19 @@ export class GoalQueueManagerComponent extends Container implements Focusable { const below = view.items.length - view.page.end; if (below > 0) { lines.push(''); - lines.push(chalk.hex(colors.textMuted)(` ▼ ${String(below)} more`)); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); } } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderGoal(goal: UpcomingGoal, index: number, selected: boolean, width: number): string { - const { colors } = this.opts; const moving = goal.id === this.movingGoalId; const pointer = selected ? SELECT_POINTER : ' '; - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); const labelPrefix = `${String(index + 1)}. `; const stateLabel = moving ? ' selected' : ''; const labelWidth = visibleWidth(labelPrefix); @@ -163,9 +159,11 @@ export class GoalQueueManagerComponent extends Container implements Focusable { objectiveWidth, ELLIPSIS, ); - const textStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const textStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); let line = prefix + textStyle(labelPrefix + objective); - if (moving) line += chalk.hex(colors.success)(stateLabel); + if (moving) line += currentTheme.fg('success', stateLabel); return line; } @@ -246,15 +244,15 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable const safeWidth = Math.max(28, width); const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const { colors } = this.opts; - const border = (s: string): string => chalk.hex(colors.primary)(s); + const border = (s: string): string => currentTheme.fg('primary', s); const title = truncateToWidth( - chalk.hex(colors.textStrong).bold('Edit upcoming goal'), + currentTheme.boldFg('textStrong', 'Edit upcoming goal'), innerWidth, ELLIPSIS, ); const subtitle = truncateToWidth( - chalk.hex(this.error === undefined ? colors.textDim : colors.warning)( + currentTheme.fg( + this.error === undefined ? 'textDim' : 'warning', this.error ?? 'Update the queued objective.', ), innerWidth, @@ -262,7 +260,7 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable ); const inputLines = this.input.render(innerWidth); const footer = truncateToWidth( - chalk.hex(colors.textDim)('Enter submit · Shift-Enter/Ctrl-J newline · Esc cancel'), + currentTheme.fg('textDim', 'Enter submit · Shift-Enter/Ctrl-J newline · Esc cancel'), innerWidth, ELLIPSIS, ); diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index 636a6baa4..5023759bd 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -6,10 +6,9 @@ import { type Component, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; @@ -20,7 +19,6 @@ interface GoalStartOption { } export interface GoalStartPermissionPromptOptions { - readonly colors: ColorPalette; readonly mode: 'manual' | 'yolo'; readonly onSelect: (choice: GoalStartPermissionChoice) => void; readonly onCancel: () => void; @@ -111,19 +109,18 @@ export class GoalStartPermissionPromptComponent implements Component, Focusable } render(width: number): string[] { - const { colors } = this.opts; - const rule = chalk.hex(colors.primary)('─'.repeat(width)); + const rule = currentTheme.fg('primary', '─'.repeat(width)); const lines = [ rule, - chalk.hex(colors.primary).bold(` ${this.title}`), - chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter select · Esc cancel'), + currentTheme.boldFg('primary', ` ${this.title}`), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc return to input box'), '', ]; const textWidth = Math.max(20, width - 2); for (const paragraph of this.noticeLines) { for (const line of wrapPlain(paragraph, textWidth)) { - lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); + lines.push(` ${styleModeNames(line, 'textMuted')}`); } lines.push(''); } @@ -133,11 +130,11 @@ export class GoalStartPermissionPromptComponent implements Component, Focusable const selected = i === this.selectedIndex; const pointer = selected ? SELECT_POINTER : ' '; lines.push( - chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `) + - styleLabel(option.label, selected, colors), + currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + + styleLabel(option.label, selected), ); for (const line of wrapPlain(option.description, Math.max(20, width - 4))) { - lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); + lines.push(` ${styleModeNames(line, 'textMuted')}`); } lines.push(''); } @@ -161,19 +158,17 @@ export class GoalStartPermissionPromptComponent implements Component, Focusable } } -function styleLabel(label: string, selected: boolean, colors: ColorPalette): string { - if (selected) return chalk.hex(colors.primary).bold(label); - return styleModeNames(label, colors, colors.text); +function styleLabel(label: string, selected: boolean): string { + if (selected) return currentTheme.boldFg('primary', label); + return styleModeNames(label, 'text'); } -function styleModeNames(text: string, colors: ColorPalette, baseHex: string): string { - const base = chalk.hex(baseHex); - const strong = chalk.hex(colors.textStrong).bold; +function styleModeNames(text: string, baseToken: 'text' | 'textMuted'): string { return text .split(/(\b(?:Manual|Auto|YOLO)\b)/g) .map((part) => { - if (part === 'Manual' || part === 'Auto' || part === 'YOLO') return strong(part); - return base(part); + if (part === 'Manual' || part === 'Auto' || part === 'YOLO') return currentTheme.boldFg('textStrong', part); + return currentTheme.fg(baseToken, part); }) .join(''); } diff --git a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts index 9b219a0c4..1bbb743a0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -16,9 +16,7 @@ import { type Focusable, truncateToWidth, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface KeyboardShortcut { readonly keys: string; @@ -48,7 +46,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ export interface HelpPanelOptions { readonly commands: readonly HelpPanelCommand[]; readonly shortcuts?: readonly KeyboardShortcut[]; - readonly colors: ColorPalette; readonly onClose: () => void; /** Terminal height — used to decide whether to show the hint tail. */ readonly maxVisible?: number; @@ -93,12 +90,11 @@ export class HelpPanelComponent extends Container implements Focusable { } override render(width: number): string[] { - const colors = this.opts.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const muted = chalk.hex(colors.textMuted); - const kbdColor = chalk.hex(colors.warning); - const slashColor = chalk.hex(colors.primary); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const muted = (text: string) => currentTheme.fg('textMuted', text); + const kbdColor = (text: string) => currentTheme.fg('warning', text); + const slashColor = (text: string) => currentTheme.fg('primary', text); const shortcuts = this.opts.shortcuts ?? DEFAULT_KEYBOARD_SHORTCUTS; const kbdWidth = Math.max(8, ...shortcuts.map((s) => s.keys.length)); @@ -110,17 +106,17 @@ export class HelpPanelComponent extends Container implements Focusable { const cmdWidth = Math.max(12, ...cmdLabels.map((l) => l.length)); const lines: string[] = [ accent('─'.repeat(width)), - accent.bold(' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), + currentTheme.boldFg('primary', ' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), '', // Greeting ` ${dim('Sure, Kimi is ready to help! Just send a message to get started.')}`, '', // Section: keyboard shortcuts - ` ${chalk.bold('Keyboard shortcuts')}`, + ` ${currentTheme.bold('Keyboard shortcuts')}`, ...shortcuts.map((s) => ` ${kbdColor(s.keys.padEnd(kbdWidth))} ${dim(s.description)}`), '', // Section: slash commands - ` ${chalk.bold('Slash commands')}`, + ` ${currentTheme.bold('Slash commands')}`, ...sortedCmds.map((cmd, i) => { const label = cmdLabels[i] ?? `/${cmd.name}`; return ` ${slashColor(label.padEnd(cmdWidth))} ${dim(cmd.description)}`; diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index e9ba8c64d..b6a9ec6ea 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -7,11 +7,10 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { SearchableList } from '#/tui/utils/searchable-list'; import type { ChoiceOption } from './choice-picker'; @@ -58,7 +57,6 @@ export interface ModelSelectorOptions { readonly currentValue: string; readonly selectedValue?: string; readonly currentThinking: boolean; - readonly colors: ColorPalette; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ @@ -166,14 +164,13 @@ export class ModelSelectorComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const searchable = this.opts.searchable === true; const view = this.list.view(); const totalCount = Object.keys(this.opts.models).length; const titleSuffix = searchable && view.query.length === 0 - ? chalk.hex(colors.textMuted)(' (type to search)') + ? currentTheme.fg('textMuted', ' (type to search)') : ''; // "type to search" already lives in the title suffix, so the hint only @@ -185,18 +182,18 @@ export class ModelSelectorComponent extends Container implements Focusable { hintParts.push('Enter select', 'Esc cancel'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Select a model') + titleSuffix, - chalk.hex(colors.textMuted)(' ' + hintParts.join(' · ')), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Select a model') + titleSuffix, + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), '', ]; if (searchable && view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(' Search: ') + chalk.hex(colors.text)(view.query)); + lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); } if (view.items.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } else { // Column width for model names so the provider column lines up. Capped so // the provider + "← current" marker still fit on normal terminal widths. @@ -214,14 +211,13 @@ export class ModelSelectorComponent extends Container implements Focusable { const isSelected = i === view.selectedIndex; const isCurrent = choice.alias === this.opts.currentValue; const pointer = isSelected ? SELECT_POINTER : ' '; - const nameStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const truncatedName = truncateToWidth(choice.name, nameWidth, '…'); const namePad = ' '.repeat(Math.max(0, nameWidth - visibleWidth(truncatedName))); - let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); - line += nameStyle(truncatedName) + namePad; - line += ' ' + chalk.hex(colors.textMuted)(choice.provider); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + line += (isSelected ? currentTheme.boldFg('primary', truncatedName) : currentTheme.fg('text', truncatedName)) + namePad; + line += ' ' + currentTheme.fg('textMuted', choice.provider); if (isCurrent) { - line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); + line += ' ' + currentTheme.fg('success', CURRENT_MARK); } lines.push(line); } @@ -231,13 +227,13 @@ export class ModelSelectorComponent extends Container implements Focusable { if (view.query.length > 0) { lines.push(''); lines.push( - chalk.hex(colors.textMuted)(` ${String(view.items.length)} / ${String(totalCount)}`), + currentTheme.fg('textMuted', ` ${String(view.items.length)} / ${String(totalCount)}`), ); } else { const below = view.items.length - view.page.end; if (below > 0) { lines.push(''); - lines.push(chalk.hex(colors.textMuted)(` ▼ ${String(below)} more`)); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); } } @@ -246,11 +242,11 @@ export class ModelSelectorComponent extends Container implements Focusable { if (selected !== undefined) { const availability = thinkingAvailability(selected.model); const thinkingHeader = availability === 'toggle' ? ' Thinking (←→ to switch)' : ' Thinking'; - lines.push(chalk.hex(colors.textMuted)(thinkingHeader)); + lines.push(currentTheme.fg('textMuted', thinkingHeader)); lines.push(this.renderThinkingControl(selected)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } @@ -259,18 +255,17 @@ export class ModelSelectorComponent extends Container implements Focusable { } private renderThinkingControl(choice: ModelChoice): string { - const { colors } = this.opts; const segment = (label: string, active: boolean): string => active - ? chalk.hex(colors.primary).bold(`[ ${label} ]`) - : chalk.hex(colors.text)(` ${label} `); + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); const availability = thinkingAvailability(choice.model); if (availability === 'always-on') { return ` ${segment('Always on', true)}`; } if (availability === 'unsupported') { - return ` ${segment('Off', true)} ${chalk.hex(colors.textMuted)('unsupported')}`; + return ` ${segment('Off', true)} ${currentTheme.fg('textMuted', 'unsupported')}`; } const draft = this.draftFor(choice); return ` ${segment('On', draft)} ${segment('Off', !draft)}`; diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts index 6445206d3..91668b4f0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -2,8 +2,6 @@ import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ { value: 'manual', @@ -31,7 +29,6 @@ function isPermissionModeChoice(value: string): value is PermissionMode { export interface PermissionSelectorOptions { readonly currentValue: PermissionMode; - readonly colors: ColorPalette; readonly onSelect: (mode: PermissionMode) => void; readonly onCancel: () => void; } @@ -42,7 +39,6 @@ export class PermissionSelectorComponent extends ChoicePickerComponent { title: 'Select permission mode', options: [...PERMISSION_OPTIONS], currentValue: opts.currentValue, - colors: opts.colors, onSelect: (value) => { if (isPermissionModeChoice(value)) opts.onSelect(value); }, diff --git a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts index c1d8a1467..a332f70af 100644 --- a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts @@ -2,15 +2,12 @@ import { OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const PLATFORM_OPTIONS: readonly ChoiceOption[] = [ { value: 'kimi-code', label: 'Kimi Code (OAuth)' }, ...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })), ]; export interface PlatformSelectorOptions { - readonly colors: ColorPalette; readonly onSelect: (platformId: string) => void; readonly onCancel: () => void; } @@ -20,7 +17,6 @@ export class PlatformSelectorComponent extends ChoicePickerComponent { super({ title: 'Select a platform', options: [...PLATFORM_OPTIONS], - colors: opts.colors, onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 49091009e..daf1155a9 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -7,10 +7,9 @@ import { type Focusable, } from '@earendil-works/pi-tui'; import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; import type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; @@ -51,7 +50,6 @@ export interface PluginsOverviewSelectorOptions { readonly id: string; readonly text: string; }; - readonly colors: ColorPalette; readonly onSelect: (selection: PluginsOverviewSelection) => void; readonly onCancel: () => void; } @@ -121,21 +119,21 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus } override render(width: number): string[] { - const { colors, plugins } = this.opts; + const { plugins } = this.opts; const hint = '↑↓ navigate · Space toggle · M MCP servers · D remove · Enter details · Esc cancel'; const pluginItems = this.items.filter((item) => item.kind === 'plugin'); const actionItems = this.items.filter((item) => item.kind === 'action'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Plugins'), - mutedHintLine(` ${hint}`, colors), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Plugins'), + mutedHintLine(` ${hint}`), '', - sectionLabel(`Installed plugins (${plugins.length})`, colors), + sectionLabel(`Installed plugins (${plugins.length})`), ]; if (pluginItems.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No plugins installed.')); + lines.push(currentTheme.fg('textMuted', ' No plugins installed.')); } else { let absoluteIndex = 0; for (const item of pluginItems) { @@ -145,35 +143,36 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus } lines.push(''); - lines.push(sectionLabel('Actions', colors)); + lines.push(sectionLabel('Actions')); for (let i = 0; i < actionItems.length; i++) { lines.push(...this.renderItem(actionItems[i]!, pluginItems.length + i, width)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; const selected = index === this.selectedIndex; const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const labelStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); let line = prefix + labelStyle(item.label); if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); + line += ' ' + statusStyle(item)(item.status); } const pluginId = overviewItemPluginId(item); if (pluginId !== undefined && this.opts.pluginHint?.id === pluginId) { - line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); + line += ' ' + currentTheme.fg('warning', this.opts.pluginHint.text); } const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`)); } return lines; } @@ -187,7 +186,6 @@ export interface PluginMarketplaceSelectorOptions { readonly entries: readonly PluginMarketplaceEntry[]; readonly installedIds: ReadonlySet; readonly source: string; - readonly colors: ColorPalette; readonly onSelect: (selection: PluginMarketplaceSelection) => void; readonly onCancel: () => void; } @@ -232,20 +230,19 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc } override render(width: number): string[] { - const { colors } = this.opts; const entries = this.items.filter((item) => item.kind === 'plugin'); const actions = this.items.filter((item) => item.kind === 'action'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Official plugins'), - mutedHintLine(' ↑↓ navigate · Enter install/update · Esc cancel', colors), - chalk.hex(colors.textMuted)(` Source: ${this.opts.source}`), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Official plugins'), + mutedHintLine(' ↑↓ navigate · Enter install/update · Esc cancel'), + currentTheme.fg('textMuted', ` Source: ${this.opts.source}`), '', - sectionLabel(`Marketplace (${entries.length})`, colors), + sectionLabel(`Marketplace (${entries.length})`), ]; if (entries.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No marketplace plugins found.')); + lines.push(currentTheme.fg('textMuted', ' No marketplace plugins found.')); } else { for (let i = 0; i < entries.length; i++) { lines.push(...this.renderItem(entries[i]!, i, width)); @@ -253,30 +250,31 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc } lines.push(''); - lines.push(sectionLabel('Actions', colors)); + lines.push(sectionLabel('Actions')); for (let i = 0; i < actions.length; i++) { lines.push(...this.renderItem(actions[i]!, entries.length + i, width)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; const selected = index === this.selectedIndex; const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const labelStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); let line = prefix + labelStyle(item.label); if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); + line += ' ' + statusStyle(item)(item.status); } const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`)); } return lines; } @@ -293,7 +291,6 @@ export interface PluginMcpSelectorOptions { readonly server: string; readonly text: string; }; - readonly colors: ColorPalette; readonly onSelect: (selection: PluginMcpSelection) => void; readonly onCancel: () => void; } @@ -349,19 +346,19 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors, info } = this.opts; + const { info } = this.opts; const serverItems = this.items.filter((item) => item.kind === 'plugin'); const actionItems = this.items.filter((item) => item.kind === 'action'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`), - mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel', colors), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` MCP servers · ${info.displayName}`), + mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel'), '', - sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors), + sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`), ]; if (serverItems.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No MCP servers declared.')); + lines.push(currentTheme.fg('textMuted', ' No MCP servers declared.')); } else { for (let i = 0; i < serverItems.length; i++) { lines.push(...this.renderItem(serverItems[i]!, i, width)); @@ -369,34 +366,35 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } lines.push(''); - lines.push(sectionLabel('Actions', colors)); + lines.push(sectionLabel('Actions')); for (let i = 0; i < actionItems.length; i++) { lines.push(...this.renderItem(actionItems[i]!, serverItems.length + i, width)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; const selected = index === this.selectedIndex; const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const labelStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); let line = prefix + labelStyle(item.label); if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); + line += ' ' + statusStyle(item)(item.status); } const serverName = mcpItemServerName(item); if (serverName !== undefined && this.opts.serverHint?.server === serverName) { - line += ' ' + chalk.hex(colors.warning)(this.opts.serverHint.text); + line += ' ' + currentTheme.fg('warning', this.opts.serverHint.text); } const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`)); } return lines; } @@ -409,7 +407,6 @@ export type PluginRemoveConfirmResult = export interface PluginRemoveConfirmOptions { readonly id: string; readonly displayName: string; - readonly colors: ColorPalette; readonly onDone: (result: PluginRemoveConfirmResult) => void; } @@ -432,7 +429,6 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { description: 'Remove only the install record; plugin files are left in place.', }, ], - colors: opts.colors, onSelect: (value) => { opts.onDone(value === REMOVE_CONFIRM_REMOVE ? { kind: 'confirm' } : { kind: 'cancel' }); }, @@ -579,24 +575,23 @@ function installStatus(entry: PluginMarketplaceEntry): string { return entry.version === undefined ? 'install' : `install v${entry.version}`; } -function sectionLabel(label: string, colors: ColorPalette): string { - return chalk.hex(colors.textDim).bold(` ${label}`); +function sectionLabel(label: string): string { + return currentTheme.boldFg('textDim', ` ${label}`); } function statusStyle( item: PluginsOverviewItem, - colors: ColorPalette, ): (text: string) => string { - if (item.kind === 'action') return chalk.hex(colors.textDim); - if (item.status === 'enabled' || item.status === 'installed') return chalk.hex(colors.success); - if (item.status?.startsWith('install')) return chalk.hex(colors.primary); - if (item.status === 'disabled') return chalk.hex(colors.textDim); - if (item.status !== undefined && /^\d/.test(item.status)) return chalk.hex(colors.textDim); - return chalk.hex(colors.warning); + if (item.kind === 'action') return (text) => currentTheme.fg('textDim', text); + if (item.status === 'enabled' || item.status === 'installed') return (text) => currentTheme.fg('success', text); + if (item.status?.startsWith('install')) return (text) => currentTheme.fg('primary', text); + if (item.status === 'disabled') return (text) => currentTheme.fg('textDim', text); + if (item.status !== undefined && /^\d/.test(item.status)) return (text) => currentTheme.fg('textDim', text); + return (text) => currentTheme.fg('warning', text); } -function mutedHintLine(text: string, colors: ColorPalette): string { - return chalk.hex(colors.textMuted)(text); +function mutedHintLine(text: string): string { + return currentTheme.fg('textMuted', text); } function wrapOverviewDescription(text: string, width: number): string[] { diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts index 2cc7fcccf..8805c24a9 100644 --- a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -43,11 +43,10 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { pageView, type PageView } from '#/tui/utils/paging'; @@ -61,7 +60,6 @@ export interface ProviderManagerOptions { readonly providers: Record; /** Provider id of the currently active model. */ readonly activeProviderId?: string; - readonly colors: ColorPalette; readonly onAdd: () => void; /** Delete all providers under a source (Open Platform / custom-registry * fetch / standalone). Passed the full provider-id list so the host @@ -355,27 +353,26 @@ export class ProviderManagerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const lines: string[] = []; // Header shape mirrors the model dialog (see model-selector.ts): a single // top border, the title, the keymap hint, then a blank line. No inner // border under the title. - const border = chalk.hex(colors.primary)('─'.repeat(width)); + const border = currentTheme.fg('primary', '─'.repeat(width)); lines.push(border); - lines.push(chalk.hex(colors.primary).bold(' Providers')); - lines.push(chalk.hex(colors.textMuted)(' ' + HEADER_HINT)); + lines.push(currentTheme.boldFg('primary', ' Providers')); + lines.push(currentTheme.fg('textMuted', ' ' + HEADER_HINT)); lines.push(''); const rows = this.rows; if (rows.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No providers configured.')); + lines.push(currentTheme.fg('textMuted', ' No providers configured.')); } else { const view = this.page(); for (let i = view.start; i < view.end; i++) { const row = rows[i]; if (row === undefined) continue; - for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width, colors })) { + for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width })) { lines.push(line); } } @@ -389,7 +386,8 @@ export class ProviderManagerComponent extends Container implements Focusable { const view = this.page(); if (view.pageCount > 1) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` Page ${String(view.page + 1)}/${String(view.pageCount)}`, ), ); @@ -401,10 +399,9 @@ export class ProviderManagerComponent extends Container implements Focusable { } private renderConfirmLine(width: number): string { - const { colors } = this.opts; const confirm = this.confirm; const prompt = confirm?.label ?? ''; - const styled = chalk.hex(colors.warning).bold(` ${prompt} [y/N]`); + const styled = currentTheme.boldFg('warning', ` ${prompt} [y/N]`); return truncateToWidth(styled, width, '…'); } } @@ -412,19 +409,21 @@ export class ProviderManagerComponent extends Container implements Focusable { function renderRow( row: Row, - ctx: { isSelected: boolean; width: number; colors: ColorPalette }, + ctx: { isSelected: boolean; width: number }, ): string[] { - const { isSelected, width, colors } = ctx; + const { isSelected, width } = ctx; const pointer = isSelected ? SELECT_POINTER : ' '; - const pointerStyle = isSelected ? chalk.hex(colors.primary) : chalk.hex(colors.textDim); + const pointerStyle = (text: string) => + isSelected ? currentTheme.fg('primary', text) : currentTheme.fg('textDim', text); // The synthetic "Add New Platform" row is an action/CTA: keep it in the brand // color so it never reads as disabled, and bold it when selected (matching // the other rows' selected treatment). - const labelStyle = isSelected - ? chalk.hex(colors.primary).bold - : row.kind === 'add' - ? chalk.hex(colors.primary) - : chalk.hex(colors.text); + const labelStyle = (text: string) => + isSelected + ? currentTheme.boldFg('primary', text) + : row.kind === 'add' + ? currentTheme.fg('primary', text) + : currentTheme.fg('text', text); // The active provider is flagged with a trailing "← current" (success), // matching the model selector's current-item marker — see .agents/skills/write-tui/DESIGN.md. @@ -435,13 +434,13 @@ function renderRow( const labelWidth = Math.max(0, width - 4 - visibleWidth(marker)); const labelText = truncateToWidth(row.label, labelWidth, '…'); let line = ` ${pointerStyle(`${pointer} `)}${labelStyle(labelText)}`; - if (isActive) line += chalk.hex(colors.success)(marker); + if (isActive) line += currentTheme.fg('success', marker); const lines: string[] = [line]; if (row.kind === 'source' && row.baseUrl !== undefined && row.baseUrl.length > 0) { const urlText = truncateToWidth(row.baseUrl, Math.max(0, width - 6), '…'); - lines.push(chalk.hex(colors.textMuted)(` ${urlText}`)); + lines.push(currentTheme.fg('textMuted', ` ${urlText}`)); } return lines; diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index a21989ebe..cdf979f0f 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -16,14 +16,13 @@ import { visibleWidth, wrapTextWithAnsi, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { currentTheme } from '#/tui/theme'; import type { PendingQuestion, QuestionPanelResponse, QuestionSubmissionMethod, } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; const NUMBER_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; const MAX_BODY_LINES = 12; @@ -74,7 +73,6 @@ export class QuestionDialogComponent extends Container implements Focusable { focused = false; private readonly request: PendingQuestion; - private readonly colors: ColorPalette; private readonly onAnswer: (response: QuestionPanelResponse) => void; private readonly maxVisibleOptions: number; private readonly otherInput = new Input(); @@ -104,7 +102,6 @@ export class QuestionDialogComponent extends Container implements Focusable { constructor( request: PendingQuestion, onAnswer: (response: QuestionPanelResponse) => void, - colors: ColorPalette, maxVisibleOptions = 6, onToggleToolOutput?: () => void, onTogglePlanExpand?: () => void, @@ -112,7 +109,6 @@ export class QuestionDialogComponent extends Container implements Focusable { super(); this.request = request; this.onAnswer = onAnswer; - this.colors = colors; this.maxVisibleOptions = maxVisibleOptions; this.onToggleToolOutput = onToggleToolOutput; this.onTogglePlanExpand = onTogglePlanExpand; @@ -453,13 +449,12 @@ export class QuestionDialogComponent extends Container implements Focusable { const question = this.request.data.questions[questionIdx]; if (question === undefined) return []; - const colors = this.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const success = chalk.hex(colors.success); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const success = (text: string) => currentTheme.fg('success', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; this.pushTabs(lines); lines.push(''); @@ -509,13 +504,13 @@ export class QuestionDialogComponent extends Container implements Focusable { if (question.multi_select) { const checked = isSelected ? '✓' : ' '; prefix = ` [${checked}] `; - if (isSelected && isCursor) tone = (s) => success.bold(s); + if (isSelected && isCursor) tone = (s) => currentTheme.boldFg('success', s); else if (isSelected) tone = success; else if (isCursor) tone = accent; else tone = dim; } else if (isSelected && this.isAnswered(questionIdx)) { prefix = isCursor ? ` → [${String(num)}] ` : ` [${String(num)}] `; - tone = isCursor ? (s) => success.bold(s) : success; + tone = isCursor ? (s) => currentTheme.boldFg('success', s) : success; } else if (isCursor) { prefix = ` → [${String(num)}] `; tone = accent; @@ -551,17 +546,16 @@ export class QuestionDialogComponent extends Container implements Focusable { } private renderSubmitTab(width: number): string[] { - const colors = this.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const text = chalk.hex(colors.text); - const warning = chalk.hex(colors.warning); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const text = (t: string) => currentTheme.fg('text', t); + const warning = (text: string) => currentTheme.fg('warning', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; this.pushTabs(lines); lines.push(''); - lines.push(text.bold(` ${REVIEW_TITLE}`)); + lines.push(currentTheme.boldFg('text', ` ${REVIEW_TITLE}`)); const reviewWarning = this.reviewMessage ?? (this.hasUnansweredQuestions() ? UNANSWERED_WARNING : undefined); if (reviewWarning !== undefined) { @@ -616,8 +610,9 @@ export class QuestionDialogComponent extends Container implements Focusable { } private pushTabs(lines: string[]): void { - const dim = chalk.hex(this.colors.textDim); - const active = chalk.bgHex(this.colors.primary).hex(this.colors.text).bold; + const dim = (text: string) => currentTheme.fg('textDim', text); + const active = (text: string) => + currentTheme.bg('primary', currentTheme.boldFg('text', text)); const tabs: string[] = []; for (let i = 0; i < this.request.data.questions.length; i++) { @@ -628,7 +623,7 @@ export class QuestionDialogComponent extends Container implements Focusable { ? question.header : `Q${String(i + 1)}`; if (i === this.currentTab) tabs.push(active(` ${label} `)); - else if (this.isAnswered(i)) tabs.push(chalk.hex(this.colors.success)(`(✓) ${label}`)); + else if (this.isAnswered(i)) tabs.push(currentTheme.fg('success', `(✓) ${label}`)); else tabs.push(dim(`(○) ${label}`)); } @@ -758,14 +753,14 @@ export class QuestionDialogComponent extends Container implements Focusable { const checked = isSelected ? '✓' : ' '; const body = ` [${checked}] ${option.label}: `; prefix = isSelected - ? chalk.hex(this.colors.success).bold(body) - : chalk.hex(this.colors.primary)(body); + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); } else { const body = ` → [${String(num)}] ${option.label}: `; prefix = isSelected && this.isAnswered(questionIdx) - ? chalk.hex(this.colors.success).bold(body) - : chalk.hex(this.colors.primary)(body); + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); } const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index a8524cb6f..3c65c4b89 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -10,11 +10,9 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface SessionRow { readonly id: string; @@ -78,7 +76,6 @@ function singleLine(text: string): string { export class SessionPickerComponent extends Container implements Focusable { private sessions: SessionRow[]; private currentSessionId: string; - private colors: ColorPalette; private onSelect: (sessionId: string) => void; private onCancel: () => void; private maxVisibleSessions: number; @@ -91,7 +88,6 @@ export class SessionPickerComponent extends Container implements Focusable { sessions: SessionRow[]; loading: boolean; currentSessionId: string; - colors: ColorPalette; onSelect: (sessionId: string) => void; onCancel: () => void; maxVisibleSessions?: number; @@ -100,7 +96,6 @@ export class SessionPickerComponent extends Container implements Focusable { this.sessions = opts.sessions; this.loading = opts.loading; this.currentSessionId = opts.currentSessionId; - this.colors = opts.colors; this.onSelect = opts.onSelect; this.onCancel = opts.onCancel; this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; @@ -136,26 +131,26 @@ export class SessionPickerComponent extends Container implements Focusable { // the clamp in `render()` is what guarantees the renderer's invariant and // prevents the "Rendered line exceeds terminal width" crash (issue #240). private renderLines(width: number): string[] { - const colors = this.colors; - const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; + const lines: string[] = [currentTheme.fg('primary', '─'.repeat(width))]; if (this.loading) { - lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); + lines.push(currentTheme.boldFg('primary', truncateToWidth('Sessions', width, ELLIPSIS))); lines.push( - chalk.hex(colors.textMuted)(truncateToWidth('Loading sessions...', width, ELLIPSIS)), + currentTheme.fg('textMuted', truncateToWidth('Loading sessions...', width, ELLIPSIS)), ); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } if (this.sessions.length === 0) { - lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); + lines.push(currentTheme.boldFg('primary', truncateToWidth('Sessions', width, ELLIPSIS))); lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', truncateToWidth('No sessions found. Press Escape to close.', width, ELLIPSIS), ), ); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } @@ -165,7 +160,7 @@ export class SessionPickerComponent extends Container implements Focusable { const hintBudget = Math.max(0, width - labelWidth); const shownHint = truncateToWidth(headerHint, hintBudget, ELLIPSIS); lines.push( - chalk.hex(colors.primary).bold(headerLabel) + chalk.hex(colors.textMuted)(shownHint), + currentTheme.boldFg('primary', headerLabel) + currentTheme.fg('textMuted', shownHint), ); lines.push(''); @@ -193,10 +188,10 @@ export class SessionPickerComponent extends Container implements Focusable { if (this.sessions.length > visibleSessions.length) { lines.push(''); const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${String(this.sessions.length)} sessions`; - lines.push(chalk.hex(colors.textMuted)(truncateToWidth(footer, width, ELLIPSIS))); + lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); } - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } @@ -206,12 +201,12 @@ export class SessionPickerComponent extends Container implements Focusable { isSelected: boolean, isCurrent: boolean, ): string[] { - const colors = this.colors; const pointer = isSelected ? SELECT_POINTER : ' '; const indent = ' '; const indentWidth = visibleWidth(indent); - const titleColor = isSelected ? colors.primary : colors.text; - const titleStyle = isSelected ? chalk.hex(titleColor).bold : chalk.hex(titleColor); + const titleColor: 'primary' | 'text' = isSelected ? 'primary' : 'text'; + const titleStyle = (text: string) => + isSelected ? currentTheme.boldFg(titleColor, text) : currentTheme.fg(titleColor, text); const time = formatRelativeTime(session.updated_at); const badge = isCurrent ? CURRENT_MARK : ''; @@ -226,10 +221,10 @@ export class SessionPickerComponent extends Container implements Focusable { const titleBudget = Math.max(8, width - headerPrefixWidth - trailingWidth); const shownTitle = truncateToWidth(singleLine(titleSource), titleBudget, ELLIPSIS); - let header = chalk.hex(isSelected ? colors.primary : colors.textDim)(pointer + ' '); + let header = currentTheme.fg(isSelected ? 'primary' : 'textDim', pointer + ' '); header += titleStyle(shownTitle); - if (time.length > 0) header += ' ' + chalk.hex(colors.textDim)(time); - if (badge.length > 0) header += ' ' + chalk.hex(colors.success)(badge); + if (time.length > 0) header += ' ' + currentTheme.fg('textDim', time); + if (badge.length > 0) header += ' ' + currentTheme.fg('success', badge); const card: string[] = [header]; // Session id is rendered in full at normal widths (the final clamp in @@ -246,22 +241,23 @@ export class SessionPickerComponent extends Container implements Focusable { if (idLineWidth + metaGapWidth + dirWidth <= width) { card.push( indent + - chalk.hex(colors.textMuted)(fullId) + - chalk.hex(colors.textDim)(metaGap) + - chalk.hex(colors.textMuted)(aliasedDir), + currentTheme.fg('textMuted', fullId) + + currentTheme.fg('textDim', metaGap) + + currentTheme.fg('textMuted', aliasedDir), ); } else { // Not enough room for both on one line — keep the id intact and put the // directory on the next line (left-truncated only if it still doesn't fit). card.push( indent + - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', truncateToWidth(fullId, Math.max(idWidth, width - indentWidth), ELLIPSIS), ), ); const dirBudget = Math.max(8, width - indentWidth); const dir = truncatePathLeft(aliasedDir, dirBudget); - card.push(indent + chalk.hex(colors.textMuted)(dir)); + card.push(indent + currentTheme.fg('textMuted', dir)); } const rawPrompt = session.last_prompt?.trim(); @@ -270,7 +266,7 @@ export class SessionPickerComponent extends Container implements Focusable { const promptMarkerWidth = visibleWidth(promptMarker); const promptBudget = Math.max(8, width - indentWidth - promptMarkerWidth); const promptText = truncateToWidth(singleLine(rawPrompt), promptBudget, ELLIPSIS); - const promptLine = indent + chalk.hex(colors.textDim)(promptMarker + promptText); + const promptLine = indent + currentTheme.fg('textDim', promptMarker + promptText); card.push(promptLine); } diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts index 3e6e42691..81e4b8d12 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - export type SettingsSelection = | 'model' | 'theme' @@ -62,7 +60,6 @@ function isSettingsSelection(value: string): value is SettingsSelection { } export interface SettingsSelectorOptions { - readonly colors: ColorPalette; readonly onSelect: (value: SettingsSelection) => void; readonly onCancel: () => void; } @@ -72,7 +69,6 @@ export class SettingsSelectorComponent extends ChoicePickerComponent { super({ title: 'Settings', options: [...SETTINGS_OPTIONS], - colors: opts.colors, onSelect: (value) => { if (isSettingsSelection(value)) opts.onSelect(value); }, diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 62cd2afec..747072a5c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -22,9 +22,8 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { ModelSelectorComponent, @@ -41,7 +40,6 @@ export interface TabbedModelSelectorOptions { readonly currentValue: string; readonly selectedValue?: string; readonly currentThinking: boolean; - readonly colors: ColorPalette; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; @@ -133,15 +131,13 @@ export class TabbedModelSelectorComponent extends Container implements Focusable * (matching the AskUserQuestion dialog); inactive tabs are muted. Both have * the same visible width so switching never shifts the layout. */ private styleTab(label: string, isActive: boolean): string { - const { colors } = this.opts; const cell = ` ${label} `; return isActive - ? chalk.bgHex(colors.primary).hex(colors.text).bold(cell) - : chalk.hex(colors.textMuted)(cell); + ? currentTheme.bg('primary', currentTheme.boldFg('text', cell)) + : currentTheme.fg('textMuted', cell); } private renderTabStrip(width: number): string { - const { colors } = this.opts; const segments: string[] = []; for (let i = 0; i < this.tabs.length; i++) { const tab = this.tabs[i]!; @@ -198,10 +194,10 @@ export class TabbedModelSelectorComponent extends Container implements Focusable const hasLeft = start > 0; const hasRight = end < segments.length; - let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' '; + let strip = hasLeft ? currentTheme.fg('textMuted', '< ') : ' '; strip += segments.slice(start, end).join(' '); if (hasRight) { - strip += chalk.hex(colors.textMuted)(' >'); + strip += currentTheme.fg('textMuted', ' >'); } return strip; } @@ -251,7 +247,6 @@ function makeSelector( currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), currentThinking: opts.currentThinking, - colors: opts.colors, searchable: true, providerSwitchHint: true, onSelect: opts.onSelect, diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index b9a525ff5..a1718a5eb 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -19,9 +19,8 @@ import { type Focusable, } from '@earendil-works/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '@/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -30,7 +29,6 @@ export interface TaskOutputViewerProps { readonly taskId: string; readonly info: BackgroundTaskInfo | undefined; readonly output: string; - readonly colors: ColorPalette; readonly onClose: () => void; } @@ -43,17 +41,17 @@ const STATUS_LABEL: Record = { lost: 'lost', }; -function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { +function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': - return colors.success; + return 'success'; case 'completed': - return colors.textMuted; + return 'textMuted'; case 'failed': case 'timed_out': case 'killed': case 'lost': - return colors.error; + return 'error'; } } @@ -183,18 +181,17 @@ export class TaskOutputViewer extends Container implements Focusable { } private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' Task output '); - const id = chalk.hex(colors.text).bold(this.props.taskId); + const title = currentTheme.boldFg('primary', ' Task output '); + const id = currentTheme.boldFg('text', this.props.taskId); const info = this.props.info; const segments: string[] = []; if (info !== undefined) { - segments.push(chalk.hex(statusColor(colors, info.status))(STATUS_LABEL[info.status])); + segments.push(currentTheme.fg(statusColor(info.status), STATUS_LABEL[info.status])); if (info.kind === 'process' && info.exitCode !== null) { - segments.push(chalk.hex(colors.textMuted)(`exit ${String(info.exitCode)}`)); + segments.push(currentTheme.fg('textMuted', `exit ${String(info.exitCode)}`)); } if (info.description && info.description.length > 0) { - segments.push(chalk.hex(colors.textMuted)(info.description)); + segments.push(currentTheme.fg('textMuted', info.description)); } } const composed = title + id + (segments.length > 0 ? ' ' + segments.join(' ') : ''); @@ -202,9 +199,6 @@ export class TaskOutputViewer extends Container implements Focusable { } private renderBody(width: number, bodyHeight: number): string[] { - const colors = this.props.colors; - const stroke = colors.primary; - // Reserve 1 col for left/right border each, 1 col for left padding. const innerWidth = Math.max(1, width - 4); @@ -214,24 +208,23 @@ export class TaskOutputViewer extends Container implements Focusable { if (this.scrollTop < 0) this.scrollTop = 0; const viewRows = bodyHeight - 2; // inside top + bottom border - const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); const out: string[] = [top]; for (let i = 0; i < viewRows; i++) { const lineIndex = this.scrollTop + i; const raw = this.lines[lineIndex] ?? ''; - const inner = fitExactly(chalk.hex(colors.text)(raw), innerWidth); - out.push(chalk.hex(stroke)('│ ') + inner + chalk.hex(stroke)(' │')); + const inner = fitExactly(currentTheme.fg('text', raw), innerWidth); + out.push(currentTheme.fg('primary', '│ ') + inner + currentTheme.fg('primary', ' │')); } out.push(bottom); return out; } private renderFooter(width: number, bodyHeight: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); const total = this.lines.length; const viewRows = Math.max(1, bodyHeight - 2); @@ -241,7 +234,8 @@ export class TaskOutputViewer extends Container implements Focusable { const lineFrom = this.scrollTop + 1; const lineTo = Math.min(total, this.scrollTop + viewRows); - const position = chalk.hex(colors.textMuted)( + const position = currentTheme.fg( + 'textMuted', ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, ); const keys = diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 44c9f192b..7863c493c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -23,10 +23,9 @@ import { type Focusable, } from '@earendil-works/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { SELECT_POINTER } from '@/tui/constant/symbols'; -import type { ColorPalette } from '@/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -40,7 +39,6 @@ export interface TasksBrowserProps { readonly tailOutput: string | undefined; readonly tailLoading: boolean; readonly flashMessage: string | undefined; - readonly colors: ColorPalette; readonly onSelect: (taskId: string) => void; readonly onToggleFilter: () => void; readonly onRefresh: () => void; @@ -74,17 +72,17 @@ const LIST_COL_MIN = 28; const LIST_COL_MAX = 44; const LIST_COL_RATIO = 0.32; -function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { +function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': - return colors.success; + return 'success'; case 'completed': - return colors.textMuted; + return 'textMuted'; case 'failed': case 'timed_out': case 'killed': case 'lost': - return colors.error; + return 'error'; } } @@ -330,36 +328,35 @@ export class TasksBrowserApp extends Container implements Focusable { // ── header / footer ────────────────────────────────────────────────── private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' TASK BROWSER '); - const filterText = chalk.hex(colors.textMuted)( + const title = currentTheme.boldFg('primary', ' TASK BROWSER '); + const filterText = currentTheme.fg( + 'textMuted', ` filter=${this.props.filter === 'all' ? 'ALL' : 'ACTIVE'} `, ); const counts = countByStatus(this.props.tasks); const countSegments: string[] = []; if (counts.running > 0) - countSegments.push(chalk.hex(colors.success)(` ${String(counts.running)} running `)); + countSegments.push(currentTheme.fg('success', ` ${String(counts.running)} running `)); if (counts.completed > 0) - countSegments.push(chalk.hex(colors.textDim)(` ${String(counts.completed)} completed `)); + countSegments.push(currentTheme.fg('textDim', ` ${String(counts.completed)} completed `)); if (counts.terminalFailed > 0) countSegments.push( - chalk.hex(colors.error)(` ${String(counts.terminalFailed)} interrupted `), + currentTheme.fg('error', ` ${String(counts.terminalFailed)} interrupted `), ); - const totals = chalk.hex(colors.textMuted)(` ${String(this.props.tasks.length)} total `); + const totals = currentTheme.fg('textMuted', ` ${String(this.props.tasks.length)} total `); const composed = title + filterText + countSegments.join('') + totals; return fitExactly(composed, width); } private renderFooter(width: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); if (this.pendingStopTaskId !== undefined) { - const warn = (text: string): string => chalk.hex(colors.warning).bold(text); + const warn = (text: string): string => currentTheme.boldFg('warning', text); const line = - ` ${warn('Stop')} ${chalk.hex(colors.text)(this.pendingStopTaskId)}? ` + + ` ${warn('Stop')} ${currentTheme.fg('text', this.pendingStopTaskId)}? ` + `${key('Y')} ${dim('confirm')} ${key('N')}${dim('/')}${key('esc')} ${dim('cancel')} `; return fitExactly(line, width); } @@ -375,7 +372,7 @@ export class TasksBrowserApp extends Container implements Focusable { const left = parts.join(' '); const flash = this.props.flashMessage; if (flash !== undefined && flash.length > 0) { - const flashStyled = chalk.hex(colors.warning)(` ${flash} `); + const flashStyled = currentTheme.fg('warning', ` ${flash} `); const total = visibleWidth(left) + visibleWidth(flashStyled); if (total <= width) { return left + ' '.repeat(width - total) + flashStyled; @@ -402,29 +399,28 @@ export class TasksBrowserApp extends Container implements Focusable { for (let i = 0; i < height; i++) out.push(' '.repeat(width)); return out; } - const stroke = this.props.colors.primary; const innerWidth = width - 2; const innerHeight = height - 2; - const titleStyled = chalk.hex(this.props.colors.textStrong).bold(title); + const titleStyled = currentTheme.boldFg('textStrong', title); const titleWidth = visibleWidth(titleStyled); const titleSegment = `─ ${titleStyled} `; const titleSegmentWidth = visibleWidth(titleSegment); const remainingDashes = Math.max(0, innerWidth - titleSegmentWidth); const topMid = titleWidth > 0 && titleSegmentWidth <= innerWidth - ? chalk.hex(stroke)('─ ') + + ? currentTheme.fg('primary', '─ ') + titleStyled + ' ' + - chalk.hex(stroke)('─'.repeat(remainingDashes)) - : chalk.hex(stroke)('─'.repeat(innerWidth)); - const top = chalk.hex(stroke)('┌') + topMid + chalk.hex(stroke)('┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(innerWidth) + '┘'); + currentTheme.fg('primary', '─'.repeat(remainingDashes)) + : currentTheme.fg('primary', '─'.repeat(innerWidth)); + const top = currentTheme.fg('primary', '┌') + topMid + currentTheme.fg('primary', '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(innerWidth) + '┘'); const lines: string[] = [top]; for (let i = 0; i < innerHeight; i++) { const inner = content[i] ?? ''; - lines.push(chalk.hex(stroke)('│') + fitExactly(inner, innerWidth) + chalk.hex(stroke)('│')); + lines.push(currentTheme.fg('primary', '│') + fitExactly(inner, innerWidth) + currentTheme.fg('primary', '│')); } lines.push(bottom); return lines; @@ -441,7 +437,7 @@ export class TasksBrowserApp extends Container implements Focusable { this.props.filter === 'active' ? 'No active tasks. Tab = show all.' : 'No background tasks in this session.'; - const lines: string[] = [chalk.hex(this.props.colors.textMuted)(empty)]; + const lines: string[] = [currentTheme.fg('textMuted', empty)]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame(title, lines, width, height); } @@ -462,24 +458,23 @@ export class TasksBrowserApp extends Container implements Focusable { } private renderListRow(task: BackgroundTaskInfo, selected: boolean, innerWidth: number): string { - const colors = this.props.colors; const pointer = selected ? `${SELECT_POINTER} ` : ' '; - const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer); + const pointerStyled = currentTheme.fg(selected ? 'primary' : 'textDim', pointer); const idColor = selected - ? colors.primary + ? 'primary' : task.kind === 'agent' - ? colors.success + ? 'success' : task.kind === 'question' - ? colors.warning - : colors.accent; + ? 'warning' + : 'accent'; const idText = selected - ? chalk.hex(idColor).bold(task.taskId) - : chalk.hex(idColor)(task.taskId); + ? currentTheme.boldFg(idColor, task.taskId) + : currentTheme.fg(idColor, task.taskId); const idPad = ' '.repeat(Math.max(0, 17 - task.taskId.length)); const status = STATUS_LABEL[task.status]; - const statusBadge = chalk.hex(statusColor(colors, task.status))(status); + const statusBadge = currentTheme.fg(statusColor(task.status), status); const prefix = `${pointerStyled}${idText}${idPad} ${statusBadge}`; const prefixWidth = visibleWidth(prefix); @@ -491,7 +486,7 @@ export class TasksBrowserApp extends Container implements Focusable { (task.kind === 'process' ? singleLine(task.command) : '') || '(no description)'; const desc = truncateToWidth(description, descBudget, ELLIPSIS); - return fitExactly(`${prefix} ${chalk.hex(colors.text)(desc)}`, innerWidth); + return fitExactly(`${prefix} ${currentTheme.fg('text', desc)}`, innerWidth); } private adjustScroll(visibleRows: number): void { @@ -523,22 +518,21 @@ export class TasksBrowserApp extends Container implements Focusable { } private renderDetailFrame(width: number, height: number): string[] { - const colors = this.props.colors; const innerHeight = Math.max(0, height - 2); const task = this.sortedVisible[this.selectedIndex]; if (task === undefined) { - const empty = chalk.hex(colors.textMuted)('Select a task from the list.'); + const empty = currentTheme.fg('textMuted', 'Select a task from the list.'); const lines: string[] = [empty]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Detail', lines, width, height); } - const label = (text: string): string => chalk.hex(colors.textMuted)(text.padEnd(14)); - const value = (text: string): string => chalk.hex(colors.text)(text); + const label = (text: string): string => currentTheme.fg('textMuted', text.padEnd(14)); + const value = (text: string): string => currentTheme.fg('text', text); const lines: string[] = [ `${label('Task ID:')}${value(task.taskId)}`, - `${label('Status:')}${chalk.hex(statusColor(colors, task.status))(STATUS_LABEL[task.status])}`, + `${label('Status:')}${currentTheme.fg(statusColor(task.status), STATUS_LABEL[task.status])}`, `${label('Description:')}${value(singleLine(task.description) || '—')}`, ]; if (task.kind === 'process' && task.command && task.command !== task.description) { @@ -551,9 +545,9 @@ export class TasksBrowserApp extends Container implements Focusable { lines.push(`${label('Agent type:')}${value(task.subagentType)}`); } if (task.kind === 'question') { - lines.push(`${label('Questions:')}${chalk.hex(colors.textMuted)(String(task.questionCount))}`); + lines.push(`${label('Questions:')}${currentTheme.fg('textMuted', String(task.questionCount))}`); if (task.toolCallId !== undefined) { - lines.push(`${label('Tool call:')}${chalk.hex(colors.textMuted)(task.toolCallId)}`); + lines.push(`${label('Tool call:')}${currentTheme.fg('textMuted', task.toolCallId)}`); } } const timing = @@ -562,26 +556,25 @@ export class TasksBrowserApp extends Container implements Focusable { : task.endedAt !== null && task.endedAt !== undefined ? `finished ${formatRelativeTime(task.endedAt)}` : ''; - if (timing.length > 0) lines.push(`${label('Time:')}${chalk.hex(colors.textMuted)(timing)}`); + if (timing.length > 0) lines.push(`${label('Time:')}${currentTheme.fg('textMuted', timing)}`); if (task.kind === 'process' && task.pid > 0) { - lines.push(`${label('Pid:')}${chalk.hex(colors.textMuted)(String(task.pid))}`); + lines.push(`${label('Pid:')}${currentTheme.fg('textMuted', String(task.pid))}`); } if (task.kind === 'process' && task.exitCode !== null) { - lines.push(`${label('Exit code:')}${chalk.hex(colors.textMuted)(String(task.exitCode))}`); + lines.push(`${label('Exit code:')}${currentTheme.fg('textMuted', String(task.exitCode))}`); } if (task.stopReason !== undefined && task.stopReason.length > 0) { - lines.push(`${label('Reason:')}${chalk.hex(colors.textMuted)(task.stopReason)}`); + lines.push(`${label('Reason:')}${currentTheme.fg('textMuted', task.stopReason)}`); } while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Detail', lines, width, height); } private renderPreviewFrame(width: number, height: number): string[] { - const colors = this.props.colors; const innerHeight = Math.max(0, height - 2); const task = this.sortedVisible[this.selectedIndex]; if (task === undefined) { - const lines: string[] = [chalk.hex(colors.textMuted)('No task selected.')]; + const lines: string[] = [currentTheme.fg('textMuted', 'No task selected.')]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Preview Output', lines, width, height); } @@ -594,7 +587,7 @@ export class TasksBrowserApp extends Container implements Focusable { const rawLines = body.split('\n'); const tailLines = rawLines.slice(-innerHeight); - const styled = tailLines.map((line) => chalk.hex(colors.textDim)(line)); + const styled = tailLines.map((line) => currentTheme.fg('textDim', line)); while (styled.length < innerHeight) styled.push(''); return this.renderFrame('Preview Output', styled, width, height); } @@ -603,7 +596,8 @@ export class TasksBrowserApp extends Container implements Focusable { private renderTooSmall(width: number, rows: number): string[] { const lines: string[] = []; - const msg = chalk.hex(this.props.colors.error)( + const msg = currentTheme.fg( + 'error', `Terminal too small (need ≥ ${String(MIN_WIDTH)} × ${String(MIN_HEIGHT)})`, ); lines.push(fitExactly(msg, width)); diff --git a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts index 8d6381c61..80732f198 100644 --- a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts @@ -1,7 +1,6 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; -import type { Theme } from '#/tui/theme/index'; +import type { ThemeName } from '#/tui/theme/index'; const THEME_OPTIONS: readonly ChoiceOption[] = [ { value: 'auto', label: 'Auto (match terminal)' }, @@ -9,28 +8,42 @@ const THEME_OPTIONS: readonly ChoiceOption[] = [ { value: 'light', label: 'Light' }, ]; -function isThemeChoice(value: string): value is Theme { - return value === 'auto' || value === 'dark' || value === 'light'; -} - export interface ThemeSelectorOptions { - readonly currentValue: Theme; - readonly colors: ColorPalette; - readonly onSelect: (theme: Theme) => void; + readonly currentValue: ThemeName; + readonly onSelect: (theme: ThemeName) => void; readonly onCancel: () => void; } export class ThemeSelectorComponent extends ChoicePickerComponent { constructor(opts: ThemeSelectorOptions) { + const customThemes = listCustomThemesSync(); + const options: ChoiceOption[] = [ + ...THEME_OPTIONS, + ...customThemes.map((name) => ({ value: name, label: `Custom: ${name}` })), + ]; super({ title: 'Select theme', - options: [...THEME_OPTIONS], + options, currentValue: opts.currentValue, - colors: opts.colors, onSelect: (value) => { - if (isThemeChoice(value)) opts.onSelect(value); + opts.onSelect(value); }, onCancel: opts.onCancel, }); } } + +// Synchronous fallback — reads dir synchronously for the picker. +import { readdirSync } from 'node:fs'; +import { getCustomThemesDir } from '#/tui/theme/custom-theme-loader'; + +function listCustomThemesSync(): string[] { + try { + const entries = readdirSync(getCustomThemesDir(), { withFileTypes: true }); + return entries + .filter((e) => e.isFile() && e.name.endsWith('.json')) + .map((e) => e.name.replace(/\.json$/, '')); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts index a57ca7b86..35055e084 100644 --- a/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ { value: 'on', @@ -17,7 +15,6 @@ const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ export interface UpdatePreferenceSelectorOptions { readonly currentValue: boolean; - readonly colors: ColorPalette; readonly onSelect: (value: boolean) => void; readonly onCancel: () => void; } @@ -28,7 +25,6 @@ export class UpdatePreferenceSelectorComponent extends ChoicePickerComponent { title: 'Automatic updates', options: [...UPDATE_PREFERENCE_OPTIONS], currentValue: opts.currentValue ? 'on' : 'off', - colors: opts.colors, onSelect: (value) => { opts.onSelect(value === 'on'); }, diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index a7dfb87fb..2556554be 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -3,9 +3,8 @@ */ import { Editor, isKeyRelease, matchesKey, Key, type TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences @@ -128,26 +127,13 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; - /** - * `colors` is the live `ColorPalette` reference — the host mutates it - * in place on theme switch (`Object.assign(state.theme.colors, ...)`), so - * reading `this.colors.` at render time always sees the - * current theme without any setter plumbing. The `EditorTheme` that - * pi-tui's `Editor` requires is derived from the same palette, and - * `paddingX: 2` reserves the two leading columns where `render()` - * paints the terminal-style `> ` prompt — both are implementation - * details, not caller knobs. - */ - constructor( - tui: TUI, - private readonly colors: ColorPalette, - ) { + constructor(tui: TUI) { // paddingX: 4 reserves column 0 for the left vertical border (│), // column 1 as a single space between border and prompt, column 2 for // the `>` prompt token, and column 3 as the space between prompt and // content. The right side mirrors with 3 padding columns and the right // border at the last column. - super(tui, createEditorTheme(colors), { paddingX: 4 }); + super(tui, createEditorTheme(), { paddingX: 4 }); } private expandPasteMarkerAtCursor(): boolean { @@ -199,7 +185,7 @@ export class CustomEditor extends Editor { // are not a thing in practice. const original = lines[firstContentIdx]; if (original !== undefined) { - const highlighted = highlightFirstSlashToken(original, this.colors.primary); + const highlighted = highlightFirstSlashToken(original, 'primary'); if (highlighted !== undefined) { lines[firstContentIdx] = highlighted; } @@ -351,7 +337,7 @@ export class CustomEditor extends Editor { * locate `/` via visible-index math so ANSI pass-through survives. * Returns `undefined` if no token is found. */ -export function highlightFirstSlashToken(line: string, hex: string): string | undefined { +export function highlightFirstSlashToken(line: string, token: 'primary'): string | undefined { const visible = stripSgr(line); const slashIdx = visible.indexOf('/'); if (slashIdx < 0) return undefined; @@ -373,7 +359,7 @@ export function highlightFirstSlashToken(line: string, hex: string): string | un if (visibleToken === '/goal') { ranges.push(...goalCommandPathRanges(visible, endVisible)); } - return highlightVisibleRanges(line, ranges, hex); + return highlightVisibleRanges(line, ranges, token); } function goalCommandPathRanges( @@ -411,7 +397,7 @@ function isTokenSpace(ch: string | undefined): boolean { function highlightVisibleRanges( line: string, ranges: Array<{ start: number; end: number }>, - hex: string, + token: 'primary', ): string { let out = ''; let rawCursor = 0; @@ -419,7 +405,7 @@ function highlightVisibleRanges( const rawStart = mapVisibleIdxToRaw(line, range.start); const rawEnd = mapVisibleIdxToRaw(line, range.end); out += line.slice(rawCursor, rawStart); - out += chalk.hex(hex).bold(line.slice(rawStart, rawEnd)); + out += currentTheme.boldFg(token, line.slice(rawStart, rawEnd)); rawCursor = rawEnd; } return out + line.slice(rawCursor); diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts index 5a978723d..00ede4a26 100644 --- a/apps/kimi-code/src/tui/components/media/diff-preview.ts +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -7,7 +7,7 @@ import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type DiffLineKind = 'context' | 'add' | 'delete'; @@ -20,14 +20,15 @@ interface DiffStyles { meta: (s: string) => string; } -function makeDiffStyles(colors: ColorPalette): DiffStyles { +function makeDiffStyles(): DiffStyles { + const palette = currentTheme.palette; return { - add: (s) => chalk.hex(colors.diffAdded)(s), - del: (s) => chalk.hex(colors.diffRemoved)(s), - addBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), - delBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), - gutter: (s) => chalk.hex(colors.diffGutter)(s), - meta: (s) => chalk.hex(colors.diffMeta)(s), + add: (s) => chalk.hex(palette.diffAdded)(s), + del: (s) => chalk.hex(palette.diffRemoved)(s), + addBold: (s) => chalk.bold.hex(palette.diffAddedStrong)(s), + delBold: (s) => chalk.bold.hex(palette.diffRemovedStrong)(s), + gutter: (s) => chalk.hex(palette.diffGutter)(s), + meta: (s) => chalk.hex(palette.diffMeta)(s), }; } @@ -108,13 +109,12 @@ export function renderDiffLines( oldText: string, newText: string, path: string, - colors: ColorPalette, isIncomplete: boolean = false, oldStart?: number, newStart?: number, maxLines?: number, ): string[] { - const s = makeDiffStyles(colors); + const s = makeDiffStyles(); const diffLines = computeDiffLines(oldText, newText, oldStart ?? 1, newStart ?? 1, isIncomplete); const changedLines = diffLines.filter((l) => l.kind !== 'context'); const added = changedLines.filter((l) => l.kind === 'add').length; @@ -234,10 +234,9 @@ export function renderDiffLinesClustered( oldText: string, newText: string, path: string, - colors: ColorPalette, opts: ClusteredDiffOptions = {}, ): string[] { - const s = makeDiffStyles(colors); + const s = makeDiffStyles(); const contextLines = opts.contextLines ?? 3; const maxLines = opts.maxLines; const diffLines = computeDiffLines(oldText, newText, 1, 1, opts.isIncomplete ?? false); diff --git a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts index 86253582f..d95eeb3f9 100644 --- a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts +++ b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts @@ -13,43 +13,54 @@ */ import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; const MAX_IMAGE_ROWS = 12; const MAX_IMAGE_WIDTH = 40; export class ImageThumbnail extends Container { - constructor(attachment: ImageAttachment, colors: ColorPalette) { + private readonly attachment: ImageAttachment; + + constructor(attachment: ImageAttachment) { super(); + this.attachment = attachment; + this.buildChildren(); + } + private buildChildren(): void { + this.clear(); const caps = getCapabilities(); const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2'; if (!supportsInline) { - // Non-graphic terminal — show the placeholder text in dim cyan so + // Non-graphic terminal — show the placeholder text in accent colour so // it's clearly an attachment reference but doesn't shout. - this.addChild(new Text(chalk.hex(colors.accent)(attachment.placeholder), 0, 0)); + this.addChild(new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0)); return; } const theme: ImageTheme = { - fallbackColor: (s: string) => chalk.hex(colors.textDim)(s), + fallbackColor: (s: string) => currentTheme.fg('textDim', s), }; - const base64 = Buffer.from(attachment.bytes).toString('base64'); + const base64 = Buffer.from(this.attachment.bytes).toString('base64'); const image = new Image( base64, - attachment.mime, + this.attachment.mime, theme, { maxHeightCells: MAX_IMAGE_ROWS, maxWidthCells: MAX_IMAGE_WIDTH, - filename: attachment.placeholder, + filename: this.attachment.placeholder, }, - { widthPx: attachment.width, heightPx: attachment.height }, + { widthPx: this.attachment.width, heightPx: this.attachment.height }, ); this.addChild(image); } + + override invalidate(): void { + this.buildChildren(); + super.invalidate(); + } } diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index d936d01b8..3b4a90468 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -17,10 +17,9 @@ import type { TUI } from '@earendil-works/pi-tui'; import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call'; @@ -37,11 +36,9 @@ export class AgentGroupComponent extends Container { private readonly bodyContainer: Container; private throttleTimer: ReturnType | null = null; private lastFlushPhases = new Map(); + private _invalidating = false; - constructor( - private readonly colors: ColorPalette, - private readonly ui: TUI | undefined, - ) { + constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); this.headerText = new Text('', 0, 0); @@ -136,15 +133,14 @@ export class AgentGroupComponent extends Container { } private buildHeader(snapshots: readonly ToolCallSubagentSnapshot[]): string { - const colors = this.colors; const total = snapshots.length; const done = snapshots.filter((s) => s.phase === 'done').length; const failed = snapshots.filter((s) => s.phase === 'failed').length; const finished = done + failed; const allDone = finished === total; const bullet = allDone - ? chalk.hex(colors.success)(STATUS_BULLET) - : chalk.hex(colors.roleAssistant)(STATUS_BULLET); + ? currentTheme.fg('success', STATUS_BULLET) + : currentTheme.fg('text', STATUS_BULLET); if (allDone) { const types = new Set(snapshots.map((s) => s.agentName).filter((n) => n !== undefined)); @@ -155,7 +151,7 @@ export class AgentGroupComponent extends Container { const totalTools = snapshots.reduce((acc, s) => acc + s.toolCount, 0); const totalTokens = snapshots.reduce((acc, s) => acc + s.tokens, 0); const tail = formatHeaderTail(totalTools, totalTokens); - return `${bullet}${chalk.hex(colors.primary).bold(headerLabel)}${tail}`; + return `${bullet}${currentTheme.boldFg('primary', headerLabel)}${tail}`; } let headerText = `Running ${String(total)} agents`; @@ -168,19 +164,18 @@ export class AgentGroupComponent extends Container { if (running > 0) parts.push(`${String(running)} running`); headerText = `Running ${String(total)} agents (${parts.join(', ')})`; } - return `${bullet}${chalk.hex(colors.primary).bold(headerText)}`; + return `${bullet}${currentTheme.boldFg('primary', headerText)}`; } private appendLines(snap: ToolCallSubagentSnapshot, isLast: boolean): void { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string) => currentTheme.dim(text); // First-level branch line. const branch1 = isLast ? '└─' : '├─'; const agentType = snap.agentName ?? 'agent'; const desc = snap.toolCallDescription || '(no description)'; - const tail = formatLineTail(snap, colors); - const namePart = chalk.hex(colors.primary)(agentType); + const tail = formatLineTail(snap); + const namePart = currentTheme.fg('primary', agentType); const descPart = dim(`· ${desc}`); const stats = formatStats(snap); const line1 = ` ${branch1} ${namePart} ${descPart}${stats}${tail}`; @@ -191,7 +186,7 @@ export class AgentGroupComponent extends Container { if (snap.phase === 'failed') { // Show one error line; error messages can be long. const errLine = (snap.errorText ?? 'Failed').split('\n').at(0) ?? 'Failed'; - const errStr = chalk.hex(colors.error)(`Error: ${errLine}`); + const errStr = currentTheme.fg('error', `Error: ${errLine}`); this.bodyContainer.addChild(new Text(` ${branch2} ${errStr}`, 0, 0)); return; } @@ -205,6 +200,16 @@ export class AgentGroupComponent extends Container { } /** Releases throttle timers so destroyed components cannot refresh later. */ + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + dispose(): void { if (this.throttleTimer !== null) { clearTimeout(this.throttleTimer); @@ -217,27 +222,28 @@ export class AgentGroupComponent extends Container { } function formatStats(snap: ToolCallSubagentSnapshot): string { - const dim = chalk.dim; + const dim = (text: string) => currentTheme.dim(text); const tools = ` · ${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`; const tokens = snap.tokens > 0 ? ` · ${formatTokens(snap.tokens)}` : ''; return dim(`${tools}${tokens}`); } -function formatLineTail(snap: ToolCallSubagentSnapshot, colors: ColorPalette): string { +function formatLineTail(snap: ToolCallSubagentSnapshot): string { + const dim = (text: string) => currentTheme.dim(text); if (snap.phase === 'done') { - return chalk.dim(' · ') + chalk.hex(colors.success)('✓ Completed'); + return dim(' · ') + currentTheme.fg('success', '✓ Completed'); } if (snap.phase === 'failed') { - return chalk.dim(' · ') + chalk.hex(colors.error)('✗ Failed'); + return dim(' · ') + currentTheme.fg('error', '✗ Failed'); } if (snap.phase === 'backgrounded') { - return chalk.dim(' · ◐ backgrounded'); + return dim(' · ◐ backgrounded'); } return ''; } function formatHeaderTail(toolCount: number, tokens: number): string { - const dim = chalk.dim; + const dim = (text: string) => currentTheme.dim(text); const parts: string[] = []; if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`); if (tokens > 0) parts.push(formatTokens(tokens)); diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 1be89b2ca..cb062b575 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -5,24 +5,20 @@ * to align after the bullet. */ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import type { Component } from '@earendil-works/pi-tui'; import { Container, Markdown, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; export class AssistantMessageComponent implements Component { private contentContainer: Container; - private markdownTheme: MarkdownTheme; - private bulletColor: string; private lastText = ''; private showBullet: boolean; - constructor(markdownTheme: MarkdownTheme, colors: ColorPalette, showBullet: boolean = true) { - this.markdownTheme = markdownTheme; - this.bulletColor = colors.roleAssistant; + constructor(showBullet: boolean = true) { this.showBullet = showBullet; this.contentContainer = new Container(); } @@ -37,12 +33,20 @@ export class AssistantMessageComponent implements Component { this.lastText = displayText; this.contentContainer.clear(); if (displayText.trim().length > 0) { - this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, this.markdownTheme)); + this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, createMarkdownTheme())); } } invalidate(): void { - this.contentContainer.invalidate?.(); + // Markdown caches ANSI colour codes keyed on (text, width). When the + // theme changes the cached strings contain stale colours, so we rebuild + // the Markdown child with the new theme. + this.contentContainer.clear(); + if (this.lastText.trim().length > 0) { + this.contentContainer.addChild( + new Markdown(this.lastText.trim(), 0, 0, createMarkdownTheme()), + ); + } } render(width: number): string[] { @@ -55,7 +59,7 @@ export class AssistantMessageComponent implements Component { const lines: string[] = ['']; for (let i = 0; i < contentLines.length; i++) { const p = - i === 0 && this.showBullet ? chalk.hex(this.bulletColor)(STATUS_BULLET) : MESSAGE_INDENT; + i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } return lines; diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts index c1086f805..aaadd1514 100644 --- a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -1,34 +1,31 @@ import type { Component } from '@earendil-works/pi-tui'; import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { BackgroundAgentStatusData } from '#/tui/types'; export class BackgroundAgentStatusComponent implements Component { - constructor( - private readonly data: BackgroundAgentStatusData, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly data: BackgroundAgentStatusData) {} invalidate(): void {} render(width: number): string[] { - const tone = + const tone: keyof ColorPalette = this.data.phase === 'started' - ? this.colors.primary + ? 'primary' : this.data.phase === 'completed' - ? this.colors.success - : this.colors.error; + ? 'success' + : 'error'; const bullet = - this.data.phase === 'failed' ? chalk.hex(tone)(FAILURE_MARK) : chalk.hex(tone)(STATUS_BULLET); + this.data.phase === 'failed' ? currentTheme.fg(tone, FAILURE_MARK) : currentTheme.fg(tone, STATUS_BULLET); const text = - chalk.hex(tone)(this.data.headline) + + currentTheme.fg(tone, this.data.headline) + (this.data.detail !== undefined && this.data.detail.length > 0 - ? chalk.hex(this.colors.textDim)(` (${this.data.detail})`) + ? currentTheme.fg('textDim', ` (${this.data.detail})`) : ''); const textComponent = new Text(text, 0, 0); diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts index 075ce3378..0fa31794a 100644 --- a/apps/kimi-code/src/tui/components/messages/cron-message.ts +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -1,36 +1,40 @@ import type { Component } from '@earendil-works/pi-tui'; import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { CronTranscriptData } from '#/tui/types'; export class CronMessageComponent implements Component { private readonly spacer = new Spacer(1); + private readonly data: CronTranscriptData; private readonly title: string; private readonly detail: string | undefined; - private readonly titleColor: string; private readonly promptText: Text; + private readonly prompt: string; constructor( prompt: string, data: CronTranscriptData, - private readonly colors: ColorPalette, ) { const missed = data.missedCount !== undefined; + this.data = data; this.title = missed ? 'Missed scheduled reminders' : 'Scheduled reminder fired'; this.detail = cronDetail(data); - this.titleColor = data.stale === true || missed ? colors.warning : colors.accent; - this.promptText = new Text(chalk.hex(colors.text)(prompt), 0, 0); + this.prompt = prompt; + this.promptText = new Text(currentTheme.fg('text', prompt), 0, 0); } invalidate(): void { + this.promptText.setText(currentTheme.fg('text', this.prompt)); this.promptText.invalidate(); } render(width: number): string[] { - const bullet = chalk.hex(this.titleColor).bold(STATUS_BULLET); + const missed = this.data.missedCount !== undefined; + const titleToken: keyof ColorPalette = this.data.stale === true || missed ? 'warning' : 'accent'; + const bullet = currentTheme.boldFg(titleToken, STATUS_BULLET); const bulletWidth = visibleWidth(bullet); const contentWidth = Math.max(1, width - bulletWidth); const lines: string[] = []; @@ -39,11 +43,11 @@ export class CronMessageComponent implements Component { lines.push(line); } - const title = chalk.hex(this.titleColor).bold(this.title); + const title = currentTheme.boldFg(titleToken, this.title); lines.push(`${bullet}${title}`); if (this.detail !== undefined) { - lines.push(`${' '.repeat(bulletWidth)}${chalk.hex(this.colors.textDim)(this.detail)}`); + lines.push(`${' '.repeat(bulletWidth)}${currentTheme.fg('textDim', this.detail)}`); } const promptLines = this.promptText.render(contentWidth); diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts index 3a02c18f7..3c3036d63 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -9,9 +9,9 @@ import type { Component } from '@earendil-works/pi-tui'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; const HEAD_INDENT = ' '; const DETAIL_INDENT = ' '; @@ -22,8 +22,7 @@ export class GoalMarkerComponent implements Component { constructor( private readonly headline: string, private readonly detail: string | undefined, - private readonly colors: ColorPalette, - private readonly accentHex: string, + private readonly accentToken: ColorToken, ) {} invalidate(): void {} @@ -33,18 +32,18 @@ export class GoalMarkerComponent implements Component { } render(width: number): string[] { - const dot = chalk.hex(this.accentHex)('◦'); - const head = chalk.hex(this.colors.textDim)(this.headline); + const dot = currentTheme.fg(this.accentToken, '◦'); + const head = currentTheme.fg('textDim', this.headline); const hasDetail = this.detail !== undefined && this.detail.length > 0; if (!hasDetail) return [`${HEAD_INDENT}${dot} ${head}`]; if (!this.expanded) { - return [`${HEAD_INDENT}${dot} ${head} ${chalk.hex(this.colors.textMuted)('(ctrl+o)')}`]; + return [`${HEAD_INDENT}${dot} ${head} ${currentTheme.fg('textMuted', '(ctrl+o)')}`]; } const out = [`${HEAD_INDENT}${dot} ${head}`]; const wrapWidth = Math.max(20, width - DETAIL_INDENT.length); for (const line of wrap(this.detail!, wrapWidth)) { - out.push(DETAIL_INDENT + chalk.hex(this.colors.textDim)(line)); + out.push(DETAIL_INDENT + currentTheme.fg('textDim', line)); } return out; } @@ -57,29 +56,27 @@ export class GoalMarkerComponent implements Component { */ export function buildGoalMarker( change: GoalChange, - colors: ColorPalette, expanded: boolean, ): GoalMarkerComponent | null { - const spec = markerSpec(change, colors); + const spec = markerSpec(change); if (spec === null) return null; - const marker = new GoalMarkerComponent(spec.headline, change.reason, colors, spec.accentHex); + const marker = new GoalMarkerComponent(spec.headline, change.reason, spec.accentToken); marker.setExpanded(expanded); return marker; } function markerSpec( change: GoalChange, - colors: ColorPalette, -): { headline: string; accentHex: string } | null { +): { headline: string; accentToken: ColorToken } | null { if (change.kind === 'lifecycle') { switch (change.status) { case 'paused': - return { headline: 'Goal paused', accentHex: colors.textDim }; + return { headline: 'Goal paused', accentToken: 'textDim' }; case 'active': - return { headline: 'Goal resumed', accentHex: colors.primary }; + return { headline: 'Goal resumed', accentToken: 'primary' }; case 'blocked': // The system stopped pursuing the goal; resumable via `/goal resume`. - return { headline: 'Goal blocked', accentHex: colors.warning }; + return { headline: 'Goal blocked', accentToken: 'warning' }; default: return null; } diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index f5914e779..92ec0987a 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -16,11 +16,11 @@ import type { Component } from '@earendil-works/pi-tui'; import { Text, visibleWidth } from '@earendil-works/pi-tui'; import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; import { formatTokenCount } from '#/utils/usage/usage-format'; import { UsagePanelComponent } from './usage-panel'; @@ -29,9 +29,9 @@ const MAX_OBJECTIVE_LINES = 6; const MAX_CRITERION_LINES = 3; const LABEL_WIDTH = 11; -function renderLifecycleLine(label: string, colors: ColorPalette): string[] { - const marker = chalk.hex(colors.primary).bold(STATUS_BULLET); - const text = chalk.hex(colors.primary).bold(label); +function renderLifecycleLine(label: string): string[] { + const marker = currentTheme.boldFg('primary', STATUS_BULLET); + const text = currentTheme.boldFg('primary', label); return ['', marker + text]; } @@ -41,33 +41,25 @@ function renderLifecycleLine(label: string, colors: ColorPalette): string[] { * change in the transcript. */ export class GoalSetMessageComponent implements Component { - constructor(private readonly colors: ColorPalette) {} - invalidate(): void {} render(_width: number): string[] { - return renderLifecycleLine('Goal set', this.colors); + return renderLifecycleLine('Goal set'); } } export class UpcomingGoalAddedMessageComponent implements Component { - constructor(private readonly colors: ColorPalette) {} - invalidate(): void {} render(_width: number): string[] { return renderLifecycleLine( 'Upcoming goal added. It will start after the current goal is complete.', - this.colors, ); } } export class GoalCompletionMessageComponent implements Component { - constructor( - private readonly message: string, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly message: string) {} invalidate(): void {} @@ -75,12 +67,12 @@ export class GoalCompletionMessageComponent implements Component { const [headline = '', ...details] = this.message.trim().split(/\r?\n/); if (headline.length === 0) return []; - const bullet = chalk.hex(this.colors.success).bold(STATUS_BULLET); + const bullet = currentTheme.boldFg('success', STATUS_BULLET); const bulletWidth = visibleWidth(STATUS_BULLET); const contentWidth = Math.max(1, width - bulletWidth); const lines: string[] = ['']; - const headlineText = new Text(chalk.hex(this.colors.success).bold(headline), 0, 0); + const headlineText = new Text(currentTheme.boldFg('success', headline), 0, 0); const headlineLines = headlineText.render(contentWidth); for (let i = 0; i < headlineLines.length; i += 1) { lines.push((i === 0 ? bullet : MESSAGE_INDENT) + headlineLines[i]); @@ -88,7 +80,7 @@ export class GoalCompletionMessageComponent implements Component { const detailText = details.join('\n').trim(); if (detailText.length > 0) { - const detailLines = new Text(chalk.hex(this.colors.textDim)(detailText), 0, 0).render( + const detailLines = new Text(currentTheme.fg('textDim', detailText), 0, 0).render( contentWidth, ); for (const line of detailLines) { @@ -101,35 +93,27 @@ export class GoalCompletionMessageComponent implements Component { } export class GoalStatusMessageComponent implements Component { - constructor( - private readonly goal: GoalSnapshot, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly goal: GoalSnapshot) {} invalidate(): void {} render(width: number): string[] { - const lines = buildGoalReportLines({ colors: this.colors, goal: this.goal }); - const panel = new UsagePanelComponent(lines, this.colors.primary, goalPanelTitle(this.goal)); + const lines = buildGoalReportLines(this.goal); + const panel = new UsagePanelComponent(lines, 'primary', goalPanelTitle(this.goal)); return ['', ...panel.render(width)]; } } -export interface GoalReportOptions { - readonly colors: ColorPalette; - readonly goal: GoalSnapshot; -} - /** Box title, e.g. ` Goal · active `. */ export function goalPanelTitle(goal: GoalSnapshot): string { return ` Goal · ${goal.status} `; } -export function buildGoalReportLines(options: GoalReportOptions): string[] { - const { colors, goal } = options; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const bar = chalk.hex(statusHex(goal.status, colors)); +export function buildGoalReportLines(goal: GoalSnapshot): string[] { + const statusColor = statusToken(goal.status); + const bar = (s: string) => currentTheme.fg(statusColor, s); + const value = (s: string) => currentTheme.fg('text', s); + const muted = (s: string) => currentTheme.fg('textDim', s); // `complete` is the terminal outcome (the completion card); everything else // (active / paused / blocked) is a persisted, resumable goal that still shows // its stop condition. A reason is worth surfacing for stopped / complete states. @@ -156,7 +140,7 @@ export function buildGoalReportLines(options: GoalReportOptions): string[] { lines.push( row( 'Status', - chalk.hex(statusHex(goal.status, colors))(goal.status) + + currentTheme.fg(statusColor, goal.status) + (reason !== undefined ? muted(` — ${reason}`) : ''), ), ); @@ -191,16 +175,16 @@ function formatStopRow(goal: GoalSnapshot): string | null { return parts.length > 0 ? parts.join(', ') : null; } -function statusHex(status: GoalStatus, colors: ColorPalette): string { +function statusToken(status: GoalStatus): ColorToken { switch (status) { case 'active': - return colors.primary; + return 'primary'; case 'complete': - return colors.success; + return 'success'; case 'blocked': - return colors.warning; + return 'warning'; case 'paused': - return colors.textDim; + return 'textDim'; } } diff --git a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts index 6aee0066b..646240197 100644 --- a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts @@ -1,10 +1,8 @@ import type { McpServerInfo } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface McpStatusReportOptions { - readonly colors: ColorPalette; readonly servers: readonly McpServerInfo[]; } @@ -34,18 +32,17 @@ const SUMMARY_ORDER: readonly McpServerInfo['status'][] = [ function statusPainter( status: McpServerInfo['status'], - colors: ColorPalette, ): (text: string) => string { switch (status) { case 'connected': - return chalk.hex(colors.success); + return (text) => currentTheme.fg('success', text); case 'failed': - return chalk.hex(colors.error); + return (text) => currentTheme.fg('error', text); case 'needs-auth': case 'pending': - return chalk.hex(colors.warning); + return (text) => currentTheme.fg('warning', text); case 'disabled': - return chalk.hex(colors.textDim); + return (text) => currentTheme.fg('textDim', text); } } @@ -84,11 +81,10 @@ function buildSummary(servers: readonly McpServerInfo[]): string { export function buildMcpStatusReportLines(options: McpStatusReportOptions): string[] { const servers = sortedServers(options.servers); - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const muted = chalk.hex(colors.textDim); - const value = chalk.hex(colors.text); - const error = chalk.hex(colors.error); + const accent = (text: string) => currentTheme.boldFg('primary', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const error = (text: string) => currentTheme.fg('error', text); const lines: string[] = [accent('Servers')]; @@ -116,7 +112,6 @@ export function buildMcpStatusReportLines(options: McpStatusReportOptions): stri for (const server of servers) { const status = statusPainter( server.status, - colors, )(STATUS_LABEL[server.status].padEnd(statusWidth)); lines.push( ` ${value(server.name.padEnd(nameWidth))} ${status} ${muted( diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index c1cafd48d..18dbe6848 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -7,10 +7,12 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import type { Component } from '@earendil-works/pi-tui'; import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; const LEFT_MARGIN = 2; // two-space indent matching other tool call children @@ -23,7 +25,7 @@ export interface PlanBoxOptions { expanded?: boolean; status?: { readonly label: string; - readonly colorHex: string; + readonly colorToken: ColorToken; }; } @@ -37,8 +39,7 @@ export class PlanBoxComponent implements Component { constructor( plan: string, - markdownTheme: MarkdownTheme, - private readonly borderHex: string, + private readonly borderToken: ColorToken, private readonly planPath?: string, opts?: PlanBoxOptions, ) { @@ -46,7 +47,7 @@ export class PlanBoxComponent implements Component { // parse + wrap output keyed on (text, width), so reusing the same // instance means repeated render() calls from the parent Container // hit the cache instead of re-parsing on every frame. - this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme); + this.markdown = new Markdown(plan.trim(), 0, 0, createMarkdownTheme()); this.maxContentLines = opts?.maxContentLines; this.expanded = opts?.expanded ?? false; this.status = opts?.status; @@ -71,7 +72,7 @@ export class PlanBoxComponent implements Component { const horzLen = Math.max(2, width - LEFT_MARGIN - 2); const contentWidth = Math.max(1, horzLen - 2 * SIDE_PADDING); - const paint = (s: string): string => chalk.hex(this.borderHex)(s); + const paint = (s: string): string => currentTheme.fg(this.borderToken, s); const indent = ' '.repeat(LEFT_MARGIN); const title = this.buildTitle(horzLen); @@ -89,7 +90,7 @@ export class PlanBoxComponent implements Component { lines.push(indent + paint('│') + ' ' + raw + ' '.repeat(pad) + ' ' + paint('│')); } if (hiddenCount > 0) { - const footer = chalk.dim( + const footer = currentTheme.dim( `... (${String(hiddenCount)} more line${hiddenCount === 1 ? '' : 's'}, ctrl+e to expand)`, ); const pad = Math.max(0, contentWidth - visibleWidth(footer)); @@ -132,6 +133,6 @@ export class PlanBoxComponent implements Component { private buildStatusSuffix(): string { const status = this.status; if (status === undefined || status.label.length === 0) return ''; - return ` · ${chalk.hex(status.colorHex)(status.label)}`; + return ` · ${currentTheme.fg(status.colorToken, status.label)}`; } } diff --git a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts index 2158aecd0..4654ee82d 100644 --- a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts @@ -1,7 +1,6 @@ import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; import { CURATED_BADGE, OFFICIAL_BADGE, @@ -12,16 +11,15 @@ import { } from '../../utils/plugin-source-label'; export interface PluginsListPanelInput { - readonly colors: ColorPalette; readonly plugins: readonly PluginSummary[]; } export function buildPluginsListLines(input: PluginsListPanelInput): readonly string[] { - const muted = chalk.hex(input.colors.textDim); - const value = chalk.hex(input.colors.text); - const success = chalk.hex(input.colors.success); - const primary = chalk.hex(input.colors.primary); - const warning = chalk.hex(input.colors.warning); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const primary = (text: string) => currentTheme.fg('primary', text); + const warning = (text: string) => currentTheme.fg('warning', text); if (input.plugins.length === 0) { return [ muted('No plugins installed.'), @@ -56,18 +54,17 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st export interface PluginsInfoPanelInput { - readonly colors: ColorPalette; readonly info: PluginInfo; } export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly string[] { const { info } = input; - const muted = chalk.hex(input.colors.textDim); - const value = chalk.hex(input.colors.text); - const success = chalk.hex(input.colors.success); - const warning = chalk.hex(input.colors.warning); - const error = chalk.hex(input.colors.error); - const primary = chalk.hex(input.colors.primary); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const warning = (text: string) => currentTheme.fg('warning', text); + const error = (text: string) => currentTheme.fg('error', text); + const primary = (text: string) => currentTheme.fg('primary', text); const status = info.enabled ? success('enabled') : muted('disabled'); const trustLine = (() => { const label = pluginTrustLabel(info); @@ -81,7 +78,7 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st })(); const lines: string[] = [ `${value(info.displayName)} (${muted(info.id)}) ${muted(info.version ?? '')}`.trim(), - `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state, input.colors)}`, + `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state)}`, trustLine, `${muted('Source:')} ${value(info.source)}`, `${muted('Root:')} ${value(info.root)}`, @@ -164,7 +161,7 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st return lines; } -function stateText(state: PluginInfo['state'], colors: ColorPalette): string { - if (state === 'ok') return chalk.hex(colors.success)(state); - return chalk.hex(colors.error)(state); +function stateText(state: PluginInfo['state']): string { + if (state === 'ok') return currentTheme.fg('success', state); + return currentTheme.fg('error', state); } diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 1d48f3c37..3910be1ab 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -22,10 +22,9 @@ import type { TUI } from '@earendil-works/pi-tui'; import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; @@ -42,11 +41,9 @@ export class ReadGroupComponent extends Container { private readonly bodyContainer: Container; private throttleTimer: ReturnType | null = null; private lastFlushPhases = new Map(); + private _invalidating = false; - constructor( - private readonly colors: ColorPalette, - private readonly ui: TUI | undefined, - ) { + constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); this.headerText = new Text('', 0, 0); @@ -134,47 +131,55 @@ export class ReadGroupComponent extends Container { } private buildHeader(total: number, pending: number, failed: number, totalLines: number): string { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string): string => currentTheme.dim(text); if (pending > 0) { - const bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); - const label = chalk.hex(colors.primary).bold(`Reading ${String(total)} files…`); + const bullet = currentTheme.fg('text', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Reading ${String(total)} files…`); return `${bullet}${label}`; } // All reads have finished, either successfully or with failures. if (failed === total) { - const bullet = chalk.hex(colors.error)('✗ '); - const label = chalk.hex(colors.error).bold(`Read ${String(total)} files`); - return `${bullet}${label}${chalk.hex(colors.error)(' · failed')}`; + const bullet = currentTheme.fg('error', '✗ '); + const label = currentTheme.boldFg('error', `Read ${String(total)} files`); + return `${bullet}${label}${currentTheme.fg('error', ' · failed')}`; } - const bullet = chalk.hex(colors.success)(STATUS_BULLET); - const label = chalk.hex(colors.primary).bold(`Read ${String(total)} files`); + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Read ${String(total)} files`); const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`); - const failPart = failed > 0 ? chalk.hex(colors.error)(` · ${String(failed)} failed`) : ''; + const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : ''; return `${bullet}${label}${linesPart}${failPart}`; } private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string): string => currentTheme.dim(text); const branch = isLast ? '└─' : '├─'; const path = snap.filePath ?? ''; - const pathPart = chalk.hex(colors.text)(path); + const pathPart = currentTheme.fg('text', path); let tail: string; if (snap.phase === 'pending') { tail = dim(' · reading…'); } else if (snap.phase === 'failed') { - tail = chalk.hex(colors.error)(' · failed'); + tail = currentTheme.fg('error', ' · failed'); } else { tail = dim(` · ${String(snap.lines)} ${snap.lines === 1 ? 'line' : 'lines'}`); } return ` ${branch} ${pathPart}${tail}`; } + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + /** Releases throttle timers so destroyed components cannot refresh later. */ dispose(): void { if (this.throttleTimer !== null) { diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index bb5ecd292..06023d55d 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -1,8 +1,7 @@ import type { Component } from '@earendil-works/pi-tui'; import { Container, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { ResultRenderer } from './tool-renderers/types'; @@ -12,7 +11,6 @@ import { TruncatedOutputComponent } from './tool-renderers/truncated'; export interface ShellExecutionOptions { readonly command?: string; readonly result?: ToolResultBlockData; - readonly colors: ColorPalette; readonly expanded?: boolean; readonly showCommand?: boolean; /** @@ -35,7 +33,6 @@ export class ShellExecutionComponent extends Container { if (options.result !== undefined) { this.addResultPreview( options.result, - options.colors, options.expanded ?? false, options.resultPreviewLines ?? PREVIEW_LINES, ); @@ -48,13 +45,12 @@ export class ShellExecutionComponent extends Container { const lines = previewLines === undefined ? allLines : allLines.slice(0, previewLines); for (const [i, line] of lines.entries()) { const prefix = i === 0 ? '$ ' : ' '; - this.addChild(new Text(chalk.dim(prefix + line), 2, 0)); + this.addChild(new Text(currentTheme.dim(prefix + line), 2, 0)); } } private addResultPreview( result: ToolResultBlockData, - colors: ColorPalette, expanded: boolean, previewLines: number, ): void { @@ -63,7 +59,6 @@ export class ShellExecutionComponent extends Container { new TruncatedOutputComponent(result.output, { expanded, isError: result.is_error ?? false, - colors, maxLines: previewLines, }), ); @@ -78,7 +73,6 @@ export const shellExecutionResultRenderer: ResultRenderer = ( new ShellExecutionComponent({ command: typeof toolCall.args['command'] === 'string' ? toolCall.args['command'] : '', result, - colors: ctx.colors, expanded: ctx.expanded, // Header truncates long bash commands to 60 chars. When the user expands // the card with ctrl+o, reveal the full command (no line cap) so they diff --git a/apps/kimi-code/src/tui/components/messages/skill-activation.ts b/apps/kimi-code/src/tui/components/messages/skill-activation.ts index 4526328c2..907e91e9d 100644 --- a/apps/kimi-code/src/tui/components/messages/skill-activation.ts +++ b/apps/kimi-code/src/tui/components/messages/skill-activation.ts @@ -13,30 +13,52 @@ */ import { Container, Text, Spacer } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { SkillActivationTrigger } from '#/tui/types'; const ARGS_PREVIEW_MAX = 200; export class SkillActivationComponent extends Container { + private headText: Text; + private previewText?: Text; + private name: string; + private args?: string; + constructor( name: string, args: string | undefined, - colors: ColorPalette, readonly trigger?: SkillActivationTrigger, ) { super(); + this.name = name; + this.args = args; this.addChild(new Spacer(1)); const head = - chalk.hex(colors.primary).bold('▶ Activated skill: ') + chalk.hex(colors.roleUser).bold(name); - this.addChild(new Text(head, 0, 0)); + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', name); + this.headText = new Text(head, 0, 0); + this.addChild(this.headText); const trimmed = args?.trim() ?? ''; if (trimmed.length > 0) { const preview = trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; - this.addChild(new Text(' ' + chalk.hex(colors.textDim)(preview), 0, 0)); + this.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); + } + } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', this.name); + this.headText.setText(head); + if (this.previewText !== undefined && this.args !== undefined) { + const trimmed = this.args.trim(); + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText.setText(' ' + currentTheme.fg('textDim', preview)); } + super.invalidate(); } } diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts index be2292358..7377a3f52 100644 --- a/apps/kimi-code/src/tui/components/messages/status-message.ts +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -1,23 +1,57 @@ import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; export class StatusMessageComponent extends Container { - constructor(content: string, colors: ColorPalette, color?: string) { + private textComponent: Text; + private content: string; + private color?: ColorToken; + + constructor(content: string, color?: ColorToken) { super(); - const text = color === undefined ? chalk.hex(colors.textDim)(content) : chalk.hex(color)(content); - this.addChild(new Text(` ${text}`, 0, 0)); + this.content = content; + this.color = color; + const text = color === undefined + ? currentTheme.fg('textDim', content) + : currentTheme.fg(color, content); + this.textComponent = new Text(` ${text}`, 0, 0); + this.addChild(this.textComponent); + } + + override invalidate(): void { + const text = this.color === undefined + ? currentTheme.fg('textDim', this.content) + : currentTheme.fg(this.color, this.content); + this.textComponent.setText(` ${text}`); + super.invalidate(); } } export class NoticeMessageComponent extends Container { - constructor(title: string, detail: string | undefined, colors: ColorPalette) { + private titleText: Text; + private detailText?: Text; + private title: string; + private detail?: string; + + constructor(title: string, detail: string | undefined) { super(); + this.title = title; + this.detail = detail; this.addChild(new Spacer(1)); - this.addChild(new Text(` ${chalk.hex(colors.textStrong)(title)}`, 0, 0)); + this.titleText = new Text(` ${currentTheme.fg('textStrong', title)}`, 0, 0); + this.addChild(this.titleText); if (detail !== undefined && detail.length > 0) { - this.addChild(new Text(` ${chalk.hex(colors.textDim)(detail)}`, 0, 0)); + this.detailText = new Text(` ${currentTheme.fg('textDim', detail)}`, 0, 0); + this.addChild(this.detailText); + } + } + + override invalidate(): void { + this.titleText.setText(` ${currentTheme.fg('textStrong', this.title)}`); + if (this.detailText !== undefined && this.detail !== undefined) { + this.detailText.setText(` ${currentTheme.fg('textDim', this.detail)}`); } + super.invalidate(); } } diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index c60e2f546..9007b8f97 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -6,10 +6,9 @@ */ import type { ModelAlias, PermissionMode, SessionStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { PRODUCT_NAME } from '#/constant/app'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { formatTokenCount, ratioSeverity, @@ -26,7 +25,6 @@ interface FieldRow { } export interface StatusReportOptions { - readonly colors: ColorPalette; readonly version: string; readonly model: string; readonly workDir: string; @@ -89,13 +87,12 @@ function contextValues(options: StatusReportOptions): { } export function buildStatusReportLines(options: StatusReportOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + const severityToken = (sev: 'ok' | 'warn' | 'danger'): 'error' | 'warning' | 'success' => + sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const permission = options.status?.permission ?? options.permissionMode; const planMode = options.status?.planMode ?? options.planMode; @@ -125,7 +122,7 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { if (maxTokens > 0) { const safeRatio = safeUsageRatio(ratio); const bar = renderProgressBar(safeRatio, 20); - const barColoured = chalk.hex(severityHex(ratioSeverity(safeRatio)))(bar); + const barColoured = currentTheme.fg(severityToken(ratioSeverity(safeRatio)), bar); lines.push( ` ${barColoured} ${value(`${(safeRatio * 100).toFixed(1)}%`.padStart(6, ' '))} ` + muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`), @@ -135,7 +132,6 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { } const managedSection = buildManagedUsageReportLines({ - colors, managedUsage: options.managedUsage, managedUsageError: options.managedUsageError, }); diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index 0fee3669a..fe6486374 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -7,7 +7,6 @@ import type { Component, TUI } from '@earendil-works/pi-tui'; import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { BRAILLE_SPINNER_FRAMES, @@ -16,13 +15,12 @@ import { THINKING_PREVIEW_LINES, } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type ThinkingRenderMode = 'live' | 'finalized'; export class ThinkingComponent implements Component { private text: string; - private color: string; private showMarker: boolean; private mode: ThinkingRenderMode; private expanded = false; @@ -37,13 +35,11 @@ export class ThinkingComponent implements Component { constructor( text: string, - colors: ColorPalette, showMarker: boolean = true, mode: ThinkingRenderMode = 'finalized', ui?: TUI, ) { this.text = text; - this.color = colors.roleThinking; this.showMarker = showMarker; this.mode = mode; this.ui = ui; @@ -53,7 +49,9 @@ export class ThinkingComponent implements Component { } } - invalidate(): void {} + invalidate(): void { + this.textComponent.setText(this.styled(this.text)); + } setText(text: string): void { if (this.text === text) return; @@ -62,7 +60,7 @@ export class ThinkingComponent implements Component { } private styled(text: string): string { - return chalk.hex(this.color).italic(text); + return currentTheme.italicFg('textDim', text); } finalize(): void { @@ -88,19 +86,20 @@ export class ThinkingComponent implements Component { contentLines.length > THINKING_PREVIEW_LINES ? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES) : contentLines; - const spinner = chalk.hex(this.color)( + const spinner = currentTheme.fg( + 'textDim', `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, ); return [ '', - spinner + chalk.hex(this.color)('thinking...'), + spinner + currentTheme.fg('textDim', 'thinking...'), ...visibleLines.map((line) => MESSAGE_INDENT + line), ]; } const rendered: string[] = ['']; for (let i = 0; i < contentLines.length; i++) { - const p = i === 0 && this.showMarker ? chalk.hex(this.color)(STATUS_BULLET) : MESSAGE_INDENT; + const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; rendered.push(p + contentLines[i]); } @@ -112,7 +111,7 @@ export class ThinkingComponent implements Component { const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES); const remaining = contentLines.length - THINKING_PREVIEW_LINES; truncated.push( - MESSAGE_INDENT + chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), + MESSAGE_INDENT + currentTheme.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), ); return truncated; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 72f3b7dd6..047389633 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -6,9 +6,7 @@ import { isAbsolute, relative, sep } from 'node:path'; import { Container, Text, Spacer, visibleWidth } from '@earendil-works/pi-tui'; -import type { Component, MarkdownTheme, TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +import type { Component, TUI } from '@earendil-works/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import { COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering'; @@ -17,7 +15,7 @@ import { STREAMING_ARGS_PREVIEW_MAX_CHARS, } from '#/tui/constant/streaming'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; @@ -473,9 +471,7 @@ export class ToolCallComponent extends Container { private planExpanded = false; private toolCall: ToolCallBlockData; private result: ToolResultBlockData | undefined; - private colors: ColorPalette; private ui: TUI | undefined; - private markdownTheme: MarkdownTheme | undefined; private planPath: string | undefined; /** * Fallback plan body used when the LLM uses plan-file mode and @@ -554,17 +550,13 @@ export class ToolCallComponent extends Container { constructor( toolCall: ToolCallBlockData, result: ToolResultBlockData | undefined, - colors: ColorPalette, ui?: TUI, - markdownTheme?: MarkdownTheme, private readonly workspaceDir?: string, ) { super(); this.toolCall = toolCall; this.result = result; - this.colors = colors; this.ui = ui; - this.markdownTheme = markdownTheme; this.applySubagentReplay(toolCall.subagent); this.addChild(new Spacer(1)); @@ -579,6 +571,12 @@ export class ToolCallComponent extends Container { this.syncSubagentElapsedTimer(); } + override invalidate(): void { + this.headerText.setText(this.buildHeader()); + this.rebuildBody(); + super.invalidate(); + } + setExpanded(expanded: boolean): void { if (this.expanded === expanded) return; this.expanded = expanded; @@ -1160,24 +1158,24 @@ export class ToolCallComponent extends Container { } private buildHeader(): string { - const { toolCall, result, colors } = this; + const { toolCall, result } = this; const isFinished = result !== undefined; const isError = result?.is_error ?? false; const isTruncated = toolCall.truncated === true && !isFinished; let bullet: string; if (isFinished) { - bullet = isError ? chalk.hex(colors.error)('✗ ') : chalk.hex(colors.success)(STATUS_BULLET); + bullet = isError ? currentTheme.fg('error', '✗ ') : currentTheme.fg('success', STATUS_BULLET); } else if (isTruncated) { - bullet = chalk.hex(colors.error)('✗ '); + bullet = currentTheme.fg('error', '✗ '); } else { // Solid bullet for in-flight tools — the previous marker ↔ blank // toggle caused visible flicker on every re-render. - bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); + bullet = currentTheme.fg('text', STATUS_BULLET); } if (toolCall.name === 'ExitPlanMode') { - const label = chalk.hex(colors.primary).bold('Current plan'); + const label = currentTheme.boldFg('primary', 'Current plan'); if (!isFinished || result === undefined || result.is_error === true) { return label; } @@ -1187,7 +1185,7 @@ export class ToolCallComponent extends Container { outcome.chosen !== undefined && outcome.chosen.length > 0 ? `Approved: ${outcome.chosen}` : 'Approved'; - return `${label}${chalk.hex(colors.success)(` · ${chipText}`)}`; + return `${label}${currentTheme.fg('success', ` · ${chipText}`)}`; } return label; } @@ -1203,8 +1201,8 @@ export class ToolCallComponent extends Container { : isBackgroundAsk ? 'Starting background question' : 'Waiting for your input'; - const tone = isError ? chalk.hex(colors.error) : chalk.hex(colors.primary); - return `${bullet}${tone.bold(label)}`; + const tone = isError ? 'error' : 'primary'; + return `${bullet}${currentTheme.boldFg(tone, label)}`; } if (this.isSingleSubagentView()) { @@ -1215,13 +1213,13 @@ export class ToolCallComponent extends Container { const keyArg = extractKeyArgument(toolCall.name, toolCall.args, this.workspaceDir); const decoded = decodeMcpToolName(toolCall.name); const verbStyled = isTruncated - ? chalk.hex(colors.error)(verb) + ? currentTheme.fg('error', verb) : verb; const toolLabel = decoded !== null - ? `${chalk.hex(colors.primary).bold(decoded.toolName)}${chalk.dim(` · MCP/${decoded.serverName}`)}` - : chalk.hex(colors.primary).bold(toolCall.name); - const argStr = keyArg ? chalk.dim(` (${keyArg})`) : ''; + ? `${currentTheme.boldFg('primary', decoded.toolName)}${currentTheme.dim(` · MCP/${decoded.serverName}`)}` + : currentTheme.boldFg('primary', toolCall.name); + const argStr = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; let chipStr = ''; if (isFinished && result) chipStr = this.buildHeaderChip(result); return `${bullet}${verbStyled} ${toolLabel}${argStr}${chipStr}`; @@ -1232,8 +1230,8 @@ export class ToolCallComponent extends Container { if (provider === undefined) return ''; const text = provider(this.toolCall, result); if (text.length === 0) return ''; - const tone = result.is_error ? chalk.hex(this.colors.error) : chalk.dim; - return tone(` · ${text}`); + if (result.is_error) return currentTheme.fg('error', ` · ${text}`); + return currentTheme.dim(` · ${text}`); } private rebuildContent(): void { @@ -1277,10 +1275,10 @@ export class ToolCallComponent extends Container { PROGRESS_URL_RE.lastIndex = 0; const styled = PROGRESS_URL_RE.test(raw) ? raw.replace(PROGRESS_URL_RE, (url) => { - const visible = chalk.hex(this.colors.warning).underline(url); + const visible = currentTheme.underlineFg('warning', url); return `\u001B]8;;${url}\u001B\\${visible}\u001B]8;;\u001B\\`; }) - : chalk.dim(raw); + : currentTheme.dim(raw); PROGRESS_URL_RE.lastIndex = 0; this.addChild(new Text(styled, 2, 0)); } @@ -1303,19 +1301,18 @@ export class ToolCallComponent extends Container { return; } - const dim = chalk.dim; const phaseChip = this.formatPhaseChip(); const headerLabel = this.subagentAgentName !== undefined ? `subagent ${this.subagentAgentName} (${this.formatAgentId()})` : `subagent (${this.formatAgentId()})`; - this.addChild(new Text(` ${dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); if (this.hiddenSubCallCount > 0) { const suffix = this.hiddenSubCallCount > 1 ? 's' : ''; this.addChild( new Text( - dim.italic(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`), + currentTheme.italic(currentTheme.dim(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`)), 0, 0, ), @@ -1324,26 +1321,26 @@ export class ToolCallComponent extends Container { for (const sub of this.finishedSubCalls) { const mark = sub.isError - ? chalk.hex(this.colors.error)('✗') - : chalk.hex(this.colors.success)('•'); + ? currentTheme.fg('error', '✗') + : currentTheme.fg('success', '•'); const keyArg = extractKeyArgument(sub.name, sub.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(sub.name); - const argCol = keyArg ? dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', sub.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; this.addChild(new Text(` ${mark} Used ${nameCol}${argCol}`, 0, 0)); } for (const [id, call] of this.ongoingSubCalls) { const keyArg = extractKeyArgument(call.name, call.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(call.name); - const argCol = keyArg ? dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', call.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; void id; - this.addChild(new Text(` ${dim('…')} Using ${nameCol}${argCol}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('…')} Using ${nameCol}${argCol}`, 0, 0)); } if (this.subagentText.length > 0) { const tailLines = this.subagentText.split('\n').slice(-3); for (const line of tailLines) { - this.addChild(new Text(` ${dim(line)}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim(line)}`, 0, 0)); } } @@ -1351,7 +1348,7 @@ export class ToolCallComponent extends Container { if (this.subagentPhase === 'done' && this.subagentResultSummary !== undefined) { const summaryLines = this.subagentResultSummary.split('\n').slice(0, 2); for (const line of summaryLines) { - this.addChild(new Text(` ${dim('└')} ${line}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('└')} ${line}`, 0, 0)); } } @@ -1359,7 +1356,7 @@ export class ToolCallComponent extends Container { if (this.subagentPhase === 'failed' && this.subagentError !== undefined) { const errLines = this.subagentError.split('\n'); for (const line of errLines) { - this.addChild(new Text(` ${chalk.hex(this.colors.error)('└')} ${line}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.fg('error', '└')} ${line}`, 0, 0)); } } } @@ -1374,7 +1371,6 @@ export class ToolCallComponent extends Container { */ private formatPhaseChip(): string { if (this.subagentPhase === undefined) return ''; - const dim = chalk.dim; const parts: string[] = []; switch (this.subagentPhase) { case 'spawning': @@ -1384,7 +1380,7 @@ export class ToolCallComponent extends Container { parts.push('↻ running'); break; case 'done': { - parts.push(chalk.hex(this.colors.success)('✓ done')); + parts.push(currentTheme.fg('success', '✓ done')); const toolCount = this.finishedSubCalls.length + this.hiddenSubCallCount; if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount > 1 ? 's' : ''}`); const tokens = @@ -1394,13 +1390,13 @@ export class ToolCallComponent extends Container { break; } case 'failed': - parts.push(chalk.hex(this.colors.error)('✗ failed')); + parts.push(currentTheme.fg('error', '✗ failed')); break; case 'backgrounded': parts.push('◐ backgrounded'); break; } - return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; } private formatAgentId(): string { @@ -1444,22 +1440,21 @@ export class ToolCallComponent extends Container { const isFailed = phase === 'failed'; const isDone = phase === 'done'; const bullet = isFailed - ? chalk.hex(this.colors.error)('✗ ') + ? currentTheme.fg('error', '✗ ') : isDone - ? chalk.hex(this.colors.success)(STATUS_BULLET) - : chalk.hex(this.colors.roleAssistant)(STATUS_BULLET); + ? currentTheme.fg('success', STATUS_BULLET) + : currentTheme.fg('text', STATUS_BULLET); const labelText = formatSubagentLabel(this.subagentAgentName); - const label = chalk.hex(this.colors.primary).bold(labelText); + const label = currentTheme.boldFg('primary', labelText); const status = this.formatSingleSubagentStatus(phase); const description = str(this.toolCall.args['description']); const descriptionPlain = description.length > 0 ? ` (${description})` : ''; - const descriptionText = descriptionPlain.length > 0 ? chalk.dim(descriptionPlain) : ''; + const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : ''; const statsText = this.formatSingleSubagentStatsText(); if (isDone) { - const success = chalk.hex(this.colors.success); - return `${bullet}${success.bold(labelText)} ${success(`Completed${descriptionPlain}${statsText}`)}`; + return `${bullet}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; } - const stats = chalk.dim(statsText); + const stats = currentTheme.dim(statsText); return `${bullet}${label} ${status}${descriptionText}${stats}`; } @@ -1468,16 +1463,16 @@ export class ToolCallComponent extends Container { ): string { switch (phase) { case 'done': - return chalk.hex(this.colors.success)('Completed'); + return currentTheme.fg('success', 'Completed'); case 'failed': - return chalk.hex(this.colors.error)('Failed'); + return currentTheme.fg('error', 'Failed'); case 'running': - return chalk.hex(this.colors.primary)('Running'); + return currentTheme.fg('primary', 'Running'); case 'backgrounded': return 'Backgrounded'; case 'spawning': case undefined: - return chalk.hex(this.colors.primary)('Starting'); + return currentTheme.fg('primary', 'Starting'); } } @@ -1507,10 +1502,10 @@ export class ToolCallComponent extends Container { for (const activity of this.getRecentSubToolActivities()) { const mark = activity.phase === 'failed' - ? chalk.hex(this.colors.error)('✗') + ? currentTheme.fg('error', '✗') : activity.phase === 'done' - ? chalk.hex(this.colors.success)('•') - : chalk.hex(this.colors.text)('•'); + ? currentTheme.fg('success', '•') + : currentTheme.fg('text', '•'); const verb = activity.phase === 'ongoing' ? 'Using' : 'Used'; this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0)); } @@ -1520,9 +1515,9 @@ export class ToolCallComponent extends Container { if (errorLine !== undefined) { this.addChild( new PrefixedWrappedLine( - ` ${chalk.hex(this.colors.error)('└')} `, + ` ${currentTheme.fg('error', '└')} `, ' ', - chalk.hex(this.colors.error)(errorLine), + currentTheme.fg('error', errorLine), ), ); } @@ -1533,15 +1528,15 @@ export class ToolCallComponent extends Container { const thinkingLine = tailNonEmptyLines(this.subagentThinkingText, 1).at(-1); if (this.getDerivedSubagentPhase() !== 'done' && thinkingLine !== undefined) { this.addChild( - new PrefixedWrappedLine(` ${chalk.dim('◌')} `, ' ', chalk.dim(thinkingLine)), + new PrefixedWrappedLine(` ${currentTheme.dim('◌')} `, ' ', currentTheme.dim(thinkingLine)), ); } if (outputLine !== undefined) { this.addChild( new PrefixedWrappedLine( - ` ${chalk.hex(this.colors.text)('└')} `, + ` ${currentTheme.fg('text', '└')} `, ' ', - chalk.hex(this.colors.text)(outputLine), + currentTheme.fg('text', outputLine), ), ); } @@ -1555,8 +1550,8 @@ export class ToolCallComponent extends Container { private formatSubToolActivity(verb: string, activity: SubToolActivity): string { const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(activity.name); - const argCol = keyArg ? chalk.dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', activity.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; return `${verb} ${nameCol}${argCol}`; } @@ -1569,7 +1564,7 @@ export class ToolCallComponent extends Container { if (this.result === undefined && this.toolCall.truncated === true) { this.addChild( new Text( - chalk.dim('Tool call arguments truncated by max_tokens — call never executed.'), + currentTheme.dim('Tool call arguments truncated by max_tokens — call never executed.'), 2, 0, ), @@ -1595,13 +1590,13 @@ export class ToolCallComponent extends Container { const shown = writeShouldCap ? allLines.slice(0, COMMAND_PREVIEW_LINES) : allLines; const remaining = allLines.length - shown.length; for (const [i, line] of shown.entries()) { - const lineNum = chalk.dim(String(i + 1).padStart(4) + ' '); + const lineNum = currentTheme.dim(String(i + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } if (writeShouldCap && remaining > 0) { this.addChild( new Text( - chalk.dim( + currentTheme.dim( `... (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, ), 2, @@ -1614,7 +1609,7 @@ export class ToolCallComponent extends Container { const newStr = str(this.toolCall.args['new_string']); if (oldStr.length === 0 && newStr.length === 0) return; const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); - const lines = renderDiffLinesClustered(oldStr, newStr, filePath, this.colors, { + const lines = renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3, ...(shouldCap ? { maxLines: COMMAND_PREVIEW_LINES } : {}), }); @@ -1656,7 +1651,7 @@ export class ToolCallComponent extends Container { allLines.length > maxLines ? allLines.length - maxLines + i : i; - const lineNum = chalk.dim(String(originalLineNumber + 1).padStart(4) + ' '); + const lineNum = currentTheme.dim(String(originalLineNumber + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } return; @@ -1674,7 +1669,7 @@ export class ToolCallComponent extends Container { const progress = `Preparing changes${target}... ${formatByteSize(bytes)} · ${formatElapsed( elapsedSeconds, )} elapsed`; - this.addChild(new Text(chalk.dim(progress), 2, 0)); + this.addChild(new Text(currentTheme.dim(progress), 2, 0)); return; } if (name === 'Bash') { @@ -1683,7 +1678,6 @@ export class ToolCallComponent extends Container { this.addChild( new ShellExecutionComponent({ command: cmd, - colors: this.colors, showCommand: true, commandPreviewLines: COMMAND_PREVIEW_LINES, }), @@ -1701,17 +1695,13 @@ export class ToolCallComponent extends Container { const plan = this.resolvePlanForPreview(); if (plan.length === 0) return; const path = this.resolvePlanPath(); - if (this.markdownTheme !== undefined) { - this.addChild( - new PlanBoxComponent(plan, this.markdownTheme, this.colors.success, path, { - maxContentLines: this.computePlanBoxMaxContentLines(), - expanded: this.planExpanded, - status: this.resolvePlanBoxStatus(), - }), - ); - } else { - this.addChild(new Text(chalk.dim(plan), 2, 0)); - } + this.addChild( + new PlanBoxComponent(plan, 'success', path, { + maxContentLines: this.computePlanBoxMaxContentLines(), + expanded: this.planExpanded, + status: this.resolvePlanBoxStatus(), + }), + ); } private computePlanBoxMaxContentLines(): number | undefined { @@ -1740,13 +1730,13 @@ export class ToolCallComponent extends Container { return this.planPath; } - private resolvePlanBoxStatus(): { label: string; colorHex: string } | undefined { + private resolvePlanBoxStatus(): { label: string; colorToken: 'error' } | undefined { const result = this.result; if (this.toolCall.name !== 'ExitPlanMode' || result === undefined) return undefined; if (!isExitPlanModeOutcomeOutput(result.output)) return undefined; const outcome = interpretExitPlanModeOutcome(result.output); if (outcome.kind !== 'rejected') return undefined; - return { label: 'Rejected', colorHex: this.colors.error }; + return { label: 'Rejected', colorToken: 'error' }; } private buildContent(): void { @@ -1772,7 +1762,7 @@ export class ToolCallComponent extends Container { if (outcome.kind === 'rejected' && outcome.feedback !== undefined) { const trimmed = outcome.feedback.trim(); if (trimmed.length > 0) { - const labelTone = chalk.hex(this.colors.warning).bold; + const labelTone = (text: string) => currentTheme.boldFg('warning', text); this.addChild(new Text(labelTone('↪ Suggestion'), 2, 0)); for (const line of trimmed.split('\n')) { this.addChild(new Text(line, 4, 0)); @@ -1805,7 +1795,6 @@ export class ToolCallComponent extends Container { const renderer = pickResultRenderer(this.toolCall.name); const components = renderer(this.toolCall, result, { expanded: this.expanded, - colors: this.colors, }); for (const component of components) { this.addChild(component); @@ -1826,9 +1815,7 @@ export class ToolCallComponent extends Container { } if (typeof parsed !== 'object' || parsed === null) return false; - const colors = this.colors; - const dim = chalk.dim; - const accent = chalk.hex(colors.primary); + const accent = (text: string) => currentTheme.fg('primary', text); const answers = (parsed as { answers?: unknown }).answers; const note = (parsed as { note?: unknown }).note; @@ -1839,13 +1826,13 @@ export class ToolCallComponent extends Container { if (!hasAnswers) { const noteText = typeof note === 'string' && note.length > 0 ? note : 'User dismissed the question.'; - this.addChild(new Text(dim(` ${noteText}`), 0, 0)); + this.addChild(new Text(currentTheme.dim(` ${noteText}`), 0, 0)); return true; } for (const [question, answer] of Object.entries(answers as Record)) { const answerText = typeof answer === 'string' ? answer : JSON.stringify(answer); - this.addChild(new Text(` ${dim('Q')} ${question}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('Q')} ${question}`, 0, 0)); this.addChild(new Text(` ${accent('→')} ${answerText}`, 0, 0)); } return true; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index ffd353dff..80a240e70 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -1,8 +1,7 @@ import type { Component } from '@earendil-works/pi-tui'; import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; @@ -24,27 +23,37 @@ export function trimTrailingEmptyLines(lines: string[]): string[] { * JSON blobs) that would otherwise wrap to dozens of visual rows. */ export class TruncatedOutputComponent implements Component { - private readonly textComponent: Text; + private textComponent: Text; private readonly expanded: boolean; private readonly maxLines: number; + private readonly output: string; + private readonly isError: boolean | undefined; constructor( output: string, options: { expanded: boolean; isError: boolean | undefined; - colors: ColorPalette; maxLines?: number; }, ) { this.expanded = options.expanded; this.maxLines = options.maxLines ?? PREVIEW_LINES; - const tint = options.isError ? chalk.hex(options.colors.error) : chalk.dim; + this.output = output; + this.isError = options.isError; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); - this.textComponent = new Text(tint(cleaned), 2, 0); + this.textComponent = new Text( + options.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned), + 2, + 0, + ); } invalidate(): void { + const cleaned = trimTrailingEmptyLines(this.output.split('\n')).join('\n'); + this.textComponent.setText( + this.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned), + ); this.textComponent.invalidate(); } @@ -59,7 +68,7 @@ export class TruncatedOutputComponent implements Component { const remaining = contentLines.length - this.maxLines; return [ ...shown, - chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), + currentTheme.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), ]; } } @@ -70,7 +79,6 @@ export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { new TruncatedOutputComponent(result.output, { expanded: ctx.expanded, isError: result.is_error ?? false, - colors: ctx.colors, }), ]; }; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts index 462b53a44..94161d1a8 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts @@ -1,12 +1,10 @@ import type { Component } from '@earendil-works/pi-tui'; import { RESULT_PREVIEW_LINES } from '#/tui/constant/rendering'; -import type { ColorPalette } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; export interface RendererContext { readonly expanded: boolean; - readonly colors: ColorPalette; } export type ResultRenderer = ( diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 0e4401a11..0f9f86665 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -7,7 +7,6 @@ import type { Component } from '@earendil-works/pi-tui'; import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { formatTokenCount, @@ -15,7 +14,8 @@ import { renderProgressBar, safeUsageRatio, } from '#/utils/usage/usage-format'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; const LEFT_MARGIN = 2; const SIDE_PADDING = 1; @@ -36,7 +36,6 @@ export interface ManagedUsageReport { } export interface UsageReportOptions { - readonly colors: ColorPalette; readonly sessionUsage?: SessionUsage; readonly sessionUsageError?: string; readonly contextUsage: number; @@ -47,7 +46,6 @@ export interface UsageReportOptions { } export interface ManagedUsageReportLineOptions { - readonly colors: ColorPalette; readonly managedUsage?: ManagedUsageReport; readonly managedUsageError?: string; } @@ -108,7 +106,6 @@ function buildManagedUsageSection( value: Colorize, muted: Colorize, errorStyle: Colorize, - severityHex: (sev: 'ok' | 'warn' | 'danger') => string, ): string[] { if (error !== undefined) return [accent('Plan usage'), errorStyle(` ${error}`)]; if (usage === undefined) return []; @@ -124,12 +121,14 @@ function buildManagedUsageSection( r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); + const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => + sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const out: string[] = [accent('Plan usage')]; for (const row of rows) { const ratioUsed = usedRatio(row); const bar = renderProgressBar(ratioUsed, 20); const pct = `${Math.round(ratioUsed * 100)}% used`; - const barColoured = chalk.hex(severityHex(ratioSeverity(ratioUsed)))(bar); + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratioUsed)), bar); const label = row.label.padEnd(labelWidth, ' '); const resetStr = row.resetHint ? ` ${muted(row.resetHint)}` : ''; out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`); @@ -138,13 +137,10 @@ function buildManagedUsageSection( } export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); return buildManagedUsageSection( options.managedUsage, @@ -153,18 +149,16 @@ export function buildManagedUsageReportLines(options: ManagedUsageReportLineOpti value, muted, errorStyle, - severityHex, ); } export function buildUsageReportLines(options: UsageReportOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => + sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const lines: string[] = [ accent('Session usage'), @@ -181,7 +175,7 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const ratio = safeUsageRatio(options.contextUsage); const bar = renderProgressBar(ratio, 20); const pct = `${(ratio * 100).toFixed(1)}%`; - const barColoured = chalk.hex(severityHex(ratioSeverity(ratio)))(bar); + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); lines.push(''); lines.push(accent('Context window')); lines.push( @@ -195,7 +189,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { } const managedSection = buildManagedUsageReportLines({ - colors, managedUsage: options.managedUsage, managedUsageError: options.managedUsageError, }); @@ -210,14 +203,14 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { export class UsagePanelComponent implements Component { constructor( private readonly lines: readonly string[], - private readonly borderHex: string, + private readonly borderToken: ColorToken, private readonly title: string = ' Usage ', ) {} invalidate(): void {} render(width: number): string[] { - const paint = (s: string): string => chalk.hex(this.borderHex)(s); + const paint = (s: string): string => currentTheme.fg(this.borderToken, s); const indent = ' '.repeat(LEFT_MARGIN); const availableInterior = Math.max( diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index 8617598a0..dd99d3c26 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -4,35 +4,31 @@ import type { Component } from '@earendil-works/pi-tui'; import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; export class UserMessageComponent implements Component { - private color: string; - private textComponent: Text; + private text: string; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; - constructor(text: string, colors: ColorPalette, images?: ImageAttachment[]) { - this.color = colors.roleUser; - this.textComponent = new Text(chalk.hex(colors.roleUser).bold(text), 0, 0); + constructor(text: string, images?: ImageAttachment[]) { + this.text = text; this.spacerComponent = new Spacer(1); - this.imageThumbnails = images?.map((img) => new ImageThumbnail(img, colors)) ?? []; + this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; } invalidate(): void { - this.textComponent.invalidate(); for (const img of this.imageThumbnails) { img.invalidate?.(); } } render(width: number): string[] { - const bullet = chalk.hex(this.color).bold(USER_MESSAGE_BULLET); + const bullet = currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET); const bulletWidth = visibleWidth(bullet); const contentWidth = Math.max(1, width - bulletWidth); @@ -43,8 +39,9 @@ export class UserMessageComponent implements Component { lines.push(line); } - // Text - const textLines = this.textComponent.render(contentWidth); + // Text — re-dye on every render so theme switches are reflected + const coloredText = currentTheme.boldFg('roleUser', this.text); + const textLines = new Text(coloredText, 0, 0).render(contentWidth); for (let i = 0; i < textLines.length; i++) { const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth); lines.push(prefix + textLines[i]); diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index 990f6b59a..f86f93c93 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -8,7 +8,7 @@ import { import chalk from 'chalk'; import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '../../theme'; type BtwPanelPhase = 'running' | 'done' | 'failed'; @@ -28,7 +28,6 @@ interface BtwBodyRender { } export interface BtwPanelOptions { - readonly colors: ColorPalette; readonly markdownTheme: MarkdownTheme; readonly canUseScrollKeys: () => boolean; readonly onPrompt: (prompt: string) => void; @@ -119,14 +118,14 @@ export class BtwPanelComponent implements Component { } private renderTopBorder(width: number, truncated: boolean): string { - const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); const hint = truncated && this.options.canUseScrollKeys() ? 'Esc close · ↑↓ scroll ' : 'Esc close '; const title = - chalk.hex(this.options.colors.accent).bold(' BTW ') + + chalk.hex(currentTheme.palette.accent).bold(' BTW ') + paint('─ ') + - chalk.hex(this.options.colors.textMuted)(hint); + chalk.hex(currentTheme.palette.textMuted)(hint); const innerWidth = Math.max(1, width - 2); const clippedTitle = visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; @@ -141,7 +140,7 @@ export class BtwPanelComponent implements Component { lines.push(...this.renderTurn(turn, width)); } if (this.turns.length === 0) { - lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); + lines.push(chalk.hex(currentTheme.palette.textDim)('Ready for a side question...')); } lines.push(...this.renderTransientNotices(width)); return this.fitBodyLines(lines); @@ -150,7 +149,7 @@ export class BtwPanelComponent implements Component { private renderTransientNotices(width: number): string[] { const lines: string[] = []; for (const notice of this.transientNotices) { - lines.push(...new Text(chalk.hex(this.options.colors.textDim)(notice), 0, 0).render(width)); + lines.push(...new Text(chalk.hex(currentTheme.palette.textDim)(notice), 0, 0).render(width)); } return lines; } @@ -191,14 +190,14 @@ export class BtwPanelComponent implements Component { } private renderTurn(turn: BtwTurn, width: number): string[] { - const prompt = chalk.hex(this.options.colors.accent)(`Q: ${turn.prompt}`); + const prompt = chalk.hex(currentTheme.palette.accent)(`Q: ${turn.prompt}`); const lines = [...new Text(prompt, 0, 0).render(width)]; const answer = turn.answer.trim(); const thinking = turn.thinking.trim(); if (answer.length > 0) { lines.push(...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width)); } else if (thinking.length > 0) { - const thinkingLines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render( + const thinkingLines = new Text(chalk.hex(currentTheme.palette.textDim)(thinking), 0, 0).render( width, ); const visibleThinking = @@ -207,17 +206,17 @@ export class BtwPanelComponent implements Component { : thinkingLines; lines.push(...visibleThinking); } else if (turn.error === undefined) { - lines.push(chalk.hex(this.options.colors.textDim)('Waiting for answer...')); + lines.push(chalk.hex(currentTheme.palette.textDim)('Waiting for answer...')); } if (turn.error !== undefined) { - const error = chalk.hex(this.options.colors.error)(turn.error); + const error = chalk.hex(currentTheme.palette.error)(turn.error); lines.push(...new Text(error, 0, 0).render(width)); } return lines; } private renderBodyLine(line: string, width: number): string { - const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); const contentWidth = Math.max(1, width - 4); const clipped = visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth, '…') : line; diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 852216263..69169e2f1 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -1,36 +1,46 @@ import { Container, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { SELECT_POINTER } from '../../constant/symbols'; import type { QueuedMessage } from '../../types'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface QueuePaneOptions { readonly messages: readonly QueuedMessage[]; - readonly colors: ColorPalette; readonly isCompacting: boolean; readonly isStreaming: boolean; readonly canSteerImmediately: boolean; } export class QueuePaneComponent extends Container { + private readonly options: QueuePaneOptions; + constructor(options: QueuePaneOptions) { super(); + this.options = options; + this.rebuildChildren(); + } + + override invalidate(): void { + this.rebuildChildren(); + super.invalidate(); + } - const accent = chalk.hex(options.colors.accent); - const dim = chalk.hex(options.colors.textDim); + private rebuildChildren(): void { + this.clear(); + const accent = (text: string) => currentTheme.fg('accent', text); + const dim = (text: string) => currentTheme.fg('textDim', text); - for (const item of options.messages) { + for (const item of this.options.messages) { this.addChild(new Text(accent(` ${SELECT_POINTER} ${item.text}`), 0, 0)); } - if (options.messages.length > 0) { + if (this.options.messages.length > 0) { const hint = - options.isCompacting && !options.isStreaming + this.options.isCompacting && !this.options.isStreaming ? ' ↑ to edit · will send after compaction' - : !options.canSteerImmediately + : !this.options.canSteerImmediately ? ' ↑ to edit · will send after current task' - : ' ↑ to edit · ctrl-s to steer immediately'; + : ' ↑ to edit · ctrl-s to steer immediately'; this.addChild(new Text(dim(hint), 0, 0)); } } diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 54d4a8763..d5949c34d 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -17,7 +17,7 @@ import { getDataDir } from '#/utils/paths'; export const INVALID_TUI_CONFIG_MESSAGE = 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.'; -export const TuiThemeSchema = z.enum(['dark', 'light', 'auto']); +export const TuiThemeSchema = z.string(); export const NotificationConditionSchema = z.enum(['unfocused', 'always']); diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index a1fc1e140..e3f425637 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -10,6 +10,7 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { BtwPanelComponent } from '../components/panes/btw-panel'; import { formatErrorMessage } from '../utils/event-payload'; import { formatHookResultPlain } from '../utils/hook-result-format'; +import { createMarkdownTheme } from '../theme/pi-tui-theme'; import type { TUIState } from '../tui-state'; const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.'; @@ -36,8 +37,7 @@ export class BtwPanelController { open(agentId: string, initialPrompt: string): void { let panel: BtwPanelComponent; panel = new BtwPanelComponent({ - colors: this.host.state.theme.colors, - markdownTheme: this.host.state.theme.markdownTheme, + markdownTheme: createMarkdownTheme(), canUseScrollKeys: () => this.host.state.editor.getText().length === 0, terminalRows: () => this.host.state.terminal.rows, onPrompt: (prompt) => { diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index ee7f8977e..ca19b71a4 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -1,4 +1,3 @@ -import chalk from 'chalk'; import type { Component, Focusable } from '@earendil-works/pi-tui'; import type { AgentStatusUpdatedEvent, @@ -67,6 +66,8 @@ import { selectMcpStartupStatusRows, } from '../utils/mcp-server-status'; import { openUrl } from '#/utils/open-url'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; @@ -97,7 +98,7 @@ export interface SessionEventHost { patchLivePane(patch: Partial): 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): void; mountEditorReplacement(panel: Component & Focusable): void; @@ -444,7 +445,7 @@ export class SessionEventHandler { const reason = event.reason; if (reason === 'error') return; if (reason === 'aborted' || reason === undefined || reason === '') { - this.host.showStatus('Interrupted by user', this.host.state.theme.colors.error); + this.host.showStatus('Interrupted by user', 'error'); return; } this.host.showError( @@ -619,7 +620,7 @@ export class SessionEventHandler { if (change.kind === 'lifecycle' && change.status === 'blocked') { void this.notifyQueuedGoalWaitingOnBlocked(); } - const marker = buildGoalMarker(change, state.theme.colors, state.toolOutputExpanded); + const marker = buildGoalMarker(change, state.toolOutputExpanded); if (marker !== null) { state.transcriptContainer.addChild(marker); state.ui.requestRender(); @@ -795,11 +796,10 @@ export class SessionEventHandler { } private handleSessionWarning(event: WarningEvent): void { - this.host.showStatus(`Warning: ${event.message}`, this.host.state.theme.colors.warning); + this.host.showStatus(`Warning: ${event.message}`, 'warning'); } private renderMcpServerStatus(server: McpServerStatusSnapshot): void { - const { state } = this.host; const key = mcpServerStatusKey(server); if (this.renderedMcpServerStatusKeys.get(server.name) === key) return; this.renderedMcpServerStatusKeys.set(server.name, key); @@ -807,29 +807,28 @@ export class SessionEventHandler { const summary = formatMcpStartupStatusSummary([...this.mcpServers.values()]); this.host.setAppState({ mcpServersSummary: summary || null }); - const colors = state.theme.colors; switch (server.status) { case 'connected': { const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; - this.finalizeMcpServerStatusRow(server.name, message, colors.success); + this.finalizeMcpServerStatusRow(server.name, message, 'success'); return; } case 'failed': { const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.error); + this.finalizeMcpServerStatusRow(server.name, message, 'error'); return; } case 'needs-auth': { const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.warning); + this.finalizeMcpServerStatusRow(server.name, message, 'warning'); return; } case 'disabled': this.finalizeMcpServerStatusRow( server.name, `MCP server "${server.name}" disabled`, - colors.textMuted, + 'textMuted', ); return; case 'pending': @@ -846,14 +845,14 @@ export class SessionEventHandler { existing.setLabel(label); return; } - const tint = (s: string): string => chalk.hex(state.theme.colors.textMuted)(s); + const tint = (s: string): string => currentTheme.fg('textMuted', s); const spinner = new MoonLoader(state.ui, 'braille', tint, label); state.transcriptContainer.addChild(spinner); this.mcpServerStatusSpinners.set(name, spinner); state.ui.requestRender(); } - private finalizeMcpServerStatusRow(name: string, message: string, color: string): void { + private finalizeMcpServerStatusRow(name: string, message: string, color: ColorToken): void { const { state } = this.host; const spinner = this.mcpServerStatusSpinners.get(name); if (spinner === undefined) { @@ -861,7 +860,7 @@ export class SessionEventHandler { return; } spinner.stop(); - const status = new StatusMessageComponent(message, state.theme.colors, color); + const status = new StatusMessageComponent(message, color); const children = state.transcriptContainer.children; const idx = children.indexOf(spinner); if (idx >= 0) { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 095fe2b68..ff8b37fc7 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -540,10 +540,7 @@ export class StreamingUIController { renderMode: 'markdown' as const, content: '', }; - const component = new AssistantMessageComponent( - state.theme.markdownTheme, - state.theme.colors, - ); + const component = new AssistantMessageComponent(); this._streamingBlock = { component, entry }; this.host.pushTranscriptEntry(entry); state.transcriptContainer.addChild(component); @@ -571,7 +568,6 @@ export class StreamingUIController { this._pendingReadGroup = null; this._activeThinkingComponent = new ThinkingComponent( fullText, - state.theme.colors, true, 'live', state.ui, @@ -598,9 +594,7 @@ export class StreamingUIController { const tc = new ToolCallComponent( toolCall, undefined, - state.theme.colors, state.ui, - state.theme.markdownTheme, state.appState.workDir, ); if (state.toolOutputExpanded) tc.setExpanded(true); @@ -645,9 +639,7 @@ export class StreamingUIController { const completed = new ToolCallComponent( matchedCall, result, - state.theme.colors, state.ui, - state.theme.markdownTheme, state.appState.workDir, ); if (state.toolOutputExpanded) completed.setExpanded(true); @@ -673,7 +665,7 @@ export class StreamingUIController { this._activeCompactionBlock.markDone(); this._activeCompactionBlock = undefined; } - const block = new CompactionComponent(state.theme.colors, state.ui, instruction); + const block = new CompactionComponent(state.ui, instruction); this._activeCompactionBlock = block; state.transcriptContainer.addChild(block); state.ui.requestRender(); @@ -769,7 +761,7 @@ export class StreamingUIController { private upgradeSoloAgentToGroup(solo: ToolCallComponent): AgentGroupComponent { const { state } = this.host; - const group = new AgentGroupComponent(state.theme.colors, state.ui); + const group = new AgentGroupComponent(state.ui); const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { @@ -826,7 +818,7 @@ export class StreamingUIController { private upgradeSoloReadToGroup(solo: ToolCallComponent): ReadGroupComponent { const { state } = this.host; - const group = new ReadGroupComponent(state.theme.colors, state.ui); + const group = new ReadGroupComponent(state.ui); const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index a7a0c2053..90ed3c99e 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -3,13 +3,13 @@ import type { Component, ProcessTerminal, TUI } from '@earendil-works/pi-tui'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; -import type { ColorPalette } from '../theme'; +import type { Theme } from '#/tui/theme'; import type { CustomEditor } from '../components/editor/custom-editor'; export interface TasksBrowserHost { readonly state: { readonly tasksBrowser: TasksBrowserState | undefined; - readonly theme: { readonly colors: ColorPalette }; + readonly theme: Theme; readonly terminal: ProcessTerminal; readonly ui: TUI; readonly editor: CustomEditor; @@ -77,7 +77,6 @@ export class TasksBrowserController { tailOutput: undefined, tailLoading: false, flashMessage: undefined, - colors: state.theme.colors, ...this.buildCallbacks(), }, state.terminal, @@ -167,7 +166,6 @@ export class TasksBrowserController { taskId: viewer.taskId, info, output, - colors: state.theme.colors, onClose: () => { this.closeOutputViewer(); }, @@ -229,7 +227,6 @@ export class TasksBrowserController { tailOutput: browser.tailOutput, tailLoading: browser.tailLoading, flashMessage: browser.flashMessage, - colors: this.host.state.theme.colors, ...this.buildCallbacks(), }); this.host.state.ui.requestRender(); @@ -343,7 +340,6 @@ export class TasksBrowserController { taskId, info, output, - colors: state.theme.colors, onClose: () => { this.closeOutputViewer(); }, diff --git a/apps/kimi-code/src/tui/easter-eggs/dance.ts b/apps/kimi-code/src/tui/easter-eggs/dance.ts index fe08d17dc..6f638aba0 100644 --- a/apps/kimi-code/src/tui/easter-eggs/dance.ts +++ b/apps/kimi-code/src/tui/easter-eggs/dance.ts @@ -14,7 +14,7 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import type { SlashCommandHost } from '../commands/dispatch'; import type { ParsedSlashInput } from '../commands/types'; -import type { ColorPalette } from '../theme/colors'; +import { currentTheme } from '../theme'; /** Frame interval for the rainbow flow animation. */ export const DANCE_FRAME_MS = 110; @@ -44,8 +44,8 @@ const LIGHT_RAINBOW = [ '#354CB5', ] as const; -function getDanceRainbowPalette(colors: ColorPalette): readonly [string, ...string[]] { - return colors.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; +function getDanceRainbowPalette(): readonly [string, ...string[]] { + return currentTheme.palette.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; } /** Paint a string character-by-character through a palette, skipping spaces. */ @@ -110,13 +110,12 @@ export function isRainbowDancing(): boolean { } export function renderDanceWelcomeHeader( - colors: ColorPalette, logo: readonly [string, string], textWidth: number, rightRow1: string, ): string[] { const phase = currentDanceView?.phase ?? 0; - const palette = getDanceRainbowPalette(colors); + const palette = getDanceRainbowPalette(); const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); const gap = ' '; const rightRow0 = truncateToWidth( @@ -131,8 +130,8 @@ export function renderDanceWelcomeHeader( ]; } -export function renderDanceFooterModel(modelLabel: string, colors: ColorPalette): string { - return rainbowText(modelLabel, getDanceRainbowPalette(colors), currentDanceView?.phase ?? 0); +export function renderDanceFooterModel(modelLabel: string): string { + return rainbowText(modelLabel, getDanceRainbowPalette(), currentDanceView?.phase ?? 0); } /** @@ -233,7 +232,7 @@ export function tryHandleDanceCommand(host: SlashCommandHost, parsed: ParsedSlas // The status line dims the whole message, which buried the command in the // hint. Paint just the command in the brand color (bold) so it reads as a // command; chalk nesting resumes the dim run right after it. - const cmd = (text: string): string => chalk.hex(host.state.theme.colors.primary).bold(text); + const cmd = (text: string): string => currentTheme.boldFg('primary', text); const sub = parsed.args.trim().toLowerCase(); if (sub === 'off') { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b1b5f7643..0a9760dfe 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -21,7 +21,6 @@ import type { PromptPart, Session, } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import type { CLIOptions } from '#/cli/options'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; @@ -97,9 +96,8 @@ import { registerReverseRPCHandlers } from './reverse-rpc/index'; import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; -import { createKimiTUIThemeBundle } from './theme/bundle'; -import type { ResolvedTheme } from './theme/colors'; -import type { Theme } from './theme/index'; +import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; +import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; import { INITIAL_LIVE_PANE, type AppState, @@ -142,7 +140,6 @@ export interface KimiTUIStartupInput { readonly version: string; readonly workDir: string; readonly startupNotice?: string; - readonly resolvedTheme?: ResolvedTheme; readonly migrationPlan?: MigrationPlan | null; /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ readonly migrateOnly?: boolean; @@ -260,7 +257,6 @@ export class KimiTUI { model: startupInput.cliOptions.model, startupNotice: startupInput.startupNotice, }, - resolvedTheme: startupInput.resolvedTheme, }; this.options = tuiOptions; this.migrationPlan = startupInput.migrationPlan ?? null; @@ -437,7 +433,7 @@ export class KimiTUI { for (const f of result.failed) { this.showStatus( `Skipped refreshing ${f.provider}: ${f.reason}`, - this.state.theme.colors.warning, + 'warning', ); } } catch { @@ -460,7 +456,7 @@ export class KimiTUI { } const resumeState = this.session?.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } if (this.session !== undefined) { this.sessionEventHandler.startSubscription(); @@ -475,7 +471,7 @@ export class KimiTUI { private async showTmuxKeyboardWarningIfNeeded(): Promise { const warning = await detectTmuxKeyboardWarning(); if (warning === undefined || this.aborted) return; - this.showStatus(warning, this.state.theme.colors.warning); + this.showStatus(warning, 'warning'); } private async init(): Promise { @@ -514,7 +510,7 @@ export class KimiTUI { if (target.workDir !== workDir) { this.state.ui.stop(); process.stderr.write( - `${chalk.hex(this.state.theme.colors.warning)( + `${currentTheme.fg('warning', `Session "${startup.sessionFlag}" was created under a different directory.\n` + ` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`, )}\n\n`, @@ -1175,7 +1171,7 @@ export class KimiTUI { } const resumeState = session.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } this.showStatus(statusMessage); } @@ -1203,7 +1199,7 @@ export class KimiTUI { this.sessionEventHandler.startSubscription(); const resumeState = session.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } this.showStatus(statusMessage); } @@ -1252,11 +1248,7 @@ export class KimiTUI { private createTranscriptComponent(entry: TranscriptEntry): Component | null { if (entry.compactionData !== undefined) { const data = entry.compactionData; - const block = new CompactionComponent( - this.state.theme.colors, - this.state.ui, - data.instruction, - ); + const block = new CompactionComponent(this.state.ui, data.instruction); block.markDone(data.tokensBefore, data.tokensAfter); return block; } @@ -1266,34 +1258,26 @@ export class KimiTUI { const images = entry.imageAttachmentIds ?.map((id) => this.imageStore.get(id)) .filter((a): a is ImageAttachment => a?.kind === 'image'); - return new UserMessageComponent(entry.content, this.state.theme.colors, images); + return new UserMessageComponent(entry.content, images); } case 'skill_activation': return new SkillActivationComponent( entry.skillName ?? entry.content, entry.skillArgs, - this.state.theme.colors, entry.skillTrigger, ); case 'cron': - return new CronMessageComponent( - entry.content, - entry.cronData ?? {}, - this.state.theme.colors, - ); + return new CronMessageComponent(entry.content, entry.cronData ?? {}); case 'assistant': { if (entry.content.trimStart().startsWith('✓ Goal complete')) { - return new GoalCompletionMessageComponent(entry.content, this.state.theme.colors); + return new GoalCompletionMessageComponent(entry.content); } - const component = new AssistantMessageComponent( - this.state.theme.markdownTheme, - this.state.theme.colors, - ); + const component = new AssistantMessageComponent(); component.updateContent(entry.content); return component; } case 'thinking': { - const thinking = new ThinkingComponent(entry.content, this.state.theme.colors, true); + const thinking = new ThinkingComponent(entry.content, true); if (this.state.toolOutputExpanded) thinking.setExpanded(true); return thinking; } @@ -1302,9 +1286,7 @@ export class KimiTUI { const tc = new ToolCallComponent( entry.toolCallData, entry.toolCallData.result, - this.state.theme.colors, this.state.ui, - this.state.theme.markdownTheme, this.state.appState.workDir, ); if (this.state.toolOutputExpanded) tc.setExpanded(true); @@ -1312,24 +1294,18 @@ export class KimiTUI { return tc; } if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent( - entry.backgroundAgentStatus, - this.state.theme.colors, - ); + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' - ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) - : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); case 'status': if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent( - entry.backgroundAgentStatus, - this.state.theme.colors, - ); + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' - ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) - : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); case 'welcome': return null; default: @@ -1382,7 +1358,7 @@ export class KimiTUI { ) { return; } - const welcome = new WelcomeComponent(this.state.appState, this.state.theme.colors); + const welcome = new WelcomeComponent(this.state.appState); this.state.transcriptContainer.addChild(welcome); } @@ -1407,22 +1383,22 @@ export class KimiTUI { this.renderWelcome(); } - showStatus(message: string, color?: string): void { + showStatus(message: string, color?: ColorToken): void { this.state.transcriptContainer.addChild( - new StatusMessageComponent(message, this.state.theme.colors, color), + new StatusMessageComponent(message, color), ); this.state.ui.requestRender(); } showNotice(title: string, detail?: string): void { this.state.transcriptContainer.addChild( - new NoticeMessageComponent(title, detail, this.state.theme.colors), + new NoticeMessageComponent(title, detail), ); this.state.ui.requestRender(); } showError(message: string): void { - this.showStatus(`Error: ${message}`, this.state.theme.colors.error); + this.showStatus(`Error: ${message}`, 'error'); } showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { @@ -1430,7 +1406,7 @@ export class KimiTUI { } showProgressSpinner(label: string): LoginProgressSpinnerHandle { - const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s); + const tint = (s: string): string => currentTheme.fg('primary', s); const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); this.state.transcriptContainer.addChild(new Spacer(1)); this.state.transcriptContainer.addChild(spinner); @@ -1438,9 +1414,9 @@ export class KimiTUI { return { stop: ({ ok, label: finalLabel }) => { spinner.stop(); - const tone = ok ? this.state.theme.colors.success : this.state.theme.colors.error; + const tone = ok ? 'success' : 'error'; const symbol = ok ? '✓' : '✗'; - spinner.setText(chalk.hex(tone)(`${symbol} ${finalLabel}`)); + spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`)); this.state.ui.requestRender(); }, }; @@ -1454,7 +1430,6 @@ export class KimiTUI { url: auth.verificationUriComplete, code: auth.userCode, hint: 'Press Ctrl-C to cancel', - colors: this.state.theme.colors, }), ); this.state.ui.requestRender(); @@ -1500,7 +1475,7 @@ export class KimiTUI { } case 'composing': { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => - chalk.hex(this.state.theme.colors.primary)(s), + currentTheme.fg('primary', s), ); this.state.activityContainer.addChild( new ActivityPaneComponent({ @@ -1553,7 +1528,6 @@ export class KimiTUI { this.state.queueContainer.addChild( new QueuePaneComponent({ messages: queued, - colors: this.state.theme.colors, isCompacting: this.state.appState.isCompacting, isStreaming: this.state.appState.streamingPhase !== 'idle', canSteerImmediately: !this.deferUserMessages, @@ -1589,29 +1563,33 @@ export class KimiTUI { updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); const highlighted = this.state.appState.planMode || trimmed.startsWith('/'); - const colorToken = highlighted ? this.state.theme.colors.primary : this.state.theme.colors.border; this.state.editor.borderHighlighted = highlighted; - this.state.editor.borderColor = (s: string) => chalk.hex(colorToken)(s); + this.state.editor.borderColor = (s: string) => + currentTheme.fg(highlighted ? 'primary' : 'border', s); this.state.ui.requestRender(); } - applyTheme(theme: Theme, resolved?: ResolvedTheme): void { - const nextTheme = createKimiTUIThemeBundle(theme, resolved); - Object.assign(this.state.theme.colors, nextTheme.colors); - this.state.theme.resolvedTheme = nextTheme.resolvedTheme; - this.state.theme.styles = nextTheme.styles; - this.state.theme.markdownTheme = nextTheme.markdownTheme; - this.setAppState({ theme }); + async applyTheme(themeName: ThemeName, resolved?: ResolvedTheme): Promise { + const palette = await getColorPalette( + themeName === 'auto' ? (resolved ?? 'dark') : themeName, + ); + currentTheme.setPalette(palette); + this.setAppState({ theme: themeName }); this.updateEditorBorderHighlight(); + this.state.footer.setColors(palette); + this.state.todoPanel.setColors(palette); + // Force every historical message to re-render so Markdown/Text caches + // (which hold old ANSI colour codes) are cleared. + this.state.transcriptContainer.invalidate(); this.state.ui.requestRender(true); } refreshTerminalThemeTracking(): void { this.stopTerminalThemeTracking(); - if (this.state.appState.theme !== 'auto') return; + if (!isBuiltInTheme(this.state.appState.theme) || this.state.appState.theme !== 'auto') return; this.terminalThemeTrackingDispose = installTerminalThemeTracking(this.state, (resolved) => { - this.applyResolvedAutoTheme(resolved); + void this.applyResolvedAutoTheme(resolved); }); } @@ -1620,10 +1598,15 @@ export class KimiTUI { this.terminalThemeTrackingDispose = undefined; } - private applyResolvedAutoTheme(resolved: ResolvedTheme): void { + private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise { if (this.state.appState.theme !== 'auto') return; - if (this.state.theme.resolvedTheme === resolved) return; - this.applyTheme('auto', resolved); + const palette = getBuiltInPalette(resolved); + if (currentTheme.palette === palette) return; + currentTheme.setPalette(palette); + this.updateEditorBorderHighlight(); + this.state.footer.setColors(palette); + this.state.todoPanel.setColors(palette); + this.state.ui.requestRender(true); } private shouldShowTerminalProgress(effectiveMode: EffectiveActivityPaneMode): boolean { @@ -1702,7 +1685,6 @@ export class KimiTUI { plan, sourceHome: plan.sourceHome, targetHome: this.harness.homeDir, - colors: this.state.theme.colors, skipDecisionStep: this.migrateOnly, requestRender: () => { this.state.ui.requestRender(); @@ -1735,7 +1717,6 @@ export class KimiTUI { this.mountEditorReplacement( new HelpPanelComponent({ commands: this.getSlashCommands(), - colors: this.state.theme.colors, onClose: () => { this.hideHelpPanel(); }, @@ -1775,7 +1756,6 @@ export class KimiTUI { sessions: this.state.sessions, loading: this.state.loadingSessions, currentSessionId: this.state.appState.sessionId, - colors: this.state.theme.colors, onSelect: (sessionId: string) => { void this.resumeSession(sessionId).then((switched) => { if (switched) { @@ -1799,7 +1779,6 @@ export class KimiTUI { (response: ApprovalPanelResponse) => { this.approvalController.respond(adaptPanelResponse(response)); }, - this.state.theme.colors, () => { this.toggleToolOutputExpansion(); }, @@ -1834,7 +1813,6 @@ export class KimiTUI { const viewer = new ApprovalPreviewViewer( { block, - colors: this.state.theme.colors, onClose: () => { this.closeApprovalPreview(); }, @@ -1871,8 +1849,7 @@ export class KimiTUI { (response) => { this.questionController.respond(response); }, - this.state.theme.colors, - undefined, + 6, () => { this.toggleToolOutputExpansion(); }, diff --git a/apps/kimi-code/src/tui/theme/bundle.ts b/apps/kimi-code/src/tui/theme/bundle.ts deleted file mode 100644 index 8cfd0e921..000000000 --- a/apps/kimi-code/src/tui/theme/bundle.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { MarkdownTheme } from '@earendil-works/pi-tui'; - -import { getColorPalette, type ColorPalette, type ResolvedTheme } from './colors'; -import { createMarkdownTheme } from './pi-tui-theme'; -import { createThemeStyles, type ThemeStyles } from './styles'; -import { resolveThemeSync, type Theme } from './index'; - -export interface KimiTUIThemeBundle { - resolvedTheme: ResolvedTheme; - colors: ColorPalette; - styles: ThemeStyles; - markdownTheme: MarkdownTheme; -} - -export function createKimiTUIThemeBundle( - theme: Theme, - resolvedTheme?: ResolvedTheme, -): KimiTUIThemeBundle { - const actualTheme = resolvedTheme ?? resolveThemeSync(theme); - const colors = { ...getColorPalette(actualTheme) }; - return { - resolvedTheme: actualTheme, - colors, - styles: createThemeStyles(colors), - markdownTheme: createMarkdownTheme(colors), - }; -} diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 4a9b37bad..215c54bbb 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -1,148 +1,133 @@ /** * Color palette definitions for dark and light themes. * - * Two layers: - * - private `dark` / `light` raw palettes — unsemantic constants reused - * across multiple semantic tokens to avoid hex literal duplication. - * - exported `darkColors` / `lightColors` — the semantic `ColorPalette` - * consumed by every UI component via chalk.hex(...). + * `darkColors` / `lightColors` are the semantic `ColorPalette` consumed by + * every UI component via the global Theme singleton. Each token holds its hex + * value directly — see the per-token docs on `ColorPalette` for what each one + * controls. * * Light palette values are tuned for ≥ 4.5:1 contrast against #FFFFFF * for text tokens and ≥ 3:1 for chrome (border / large text), matching * WCAG AA. */ -const dark = { - blue400: '#4FA8FF', - cyan400: '#5BC0BE', - gray50: '#F5F5F5', - gray100: '#E0E0E0', - gray500: '#888888', - gray600: '#6B6B6B', - gray700: '#5A5A5A', - green400: '#4EC87E', - green300: '#7AD99B', - red400: '#E85454', - red300: '#F08585', - amber400: '#E8A838', - orange300: '#FFCB6B', -} as const; - -const light = { - blue600: '#1565C0', - cyan700: '#00838F', - gray900: '#1A1A1A', - gray700: '#454545', - gray600: '#5F5F5F', - gray500: '#737373', - green700: '#0E7A38', - red700: '#B91C1C', - amber800: '#92660A', - orange700: '#9A4A00', -} as const; - +// Each token below documents where it is actually consumed, so theme authors +// know what changing it affects. "Widely" means the token is read across most +// dialogs/messages rather than in one specific place. export interface ColorPalette { - // Brand + // ── Brand ── + /** Dominant interactive/brand colour: links & inline code, the selected item + * in nearly every dialog, the focused editor border, plan/"running" badges, + * spinners. The most widely used token. */ primary: string; + /** Secondary highlight: approval "▶" prefix, device-code box, image + * placeholder, BTW / queue panes, custom-registry import. */ accent: string; - // Text + // ── Text ── + /** Default body text: dialog bodies, todo titles, footer model label, + * markdown headings, tool/read output, and assistant-side message bullets + * (assistant / tool / agent / read) plus markdown list bullets. */ text: string; + /** Emphasised / bold text: input dialogs, status messages. */ textStrong: string; + /** Secondary, dimmed text (the most widely used dim shade): thinking blocks, + * hints, descriptions, completed todos, markdown quotes, and the footer + * status bar (cwd path, git badge). */ textDim: string; + /** Faintest text: counters, scroll info, descriptions, markdown link URLs, + * code-block borders. */ textMuted: string; - // Surface + // ── Surface ── + /** Borders: pane & editor borders, markdown horizontal rule. */ border: string; + /** Focus / attention border — currently only the approval panel. */ borderFocus: string; - // State + // ── State ── + /** Success: ✓ marks, "enabled", completed states. */ success: string; + /** Warning: auto/yolo badges, stale markers, plan-mode hint. */ warning: string; + /** Error: error messages, failed tool output. */ error: string; - // Diff + // ── Diff (all consumed by components/media/diff-preview.ts) ── + /** Added lines. */ diffAdded: string; + /** Removed lines. */ diffRemoved: string; + /** Added lines — intra-line changed words (bold). */ diffAddedStrong: string; + /** Removed lines — intra-line changed words (bold). */ diffRemovedStrong: string; + /** Line-number gutter (also approval panel/preview). */ diffGutter: string; + /** Meta / hunk headers. */ diffMeta: string; - // Roles + // ── Roles ── + /** User message: bullet & text, skill-activation name. The one role colour + * with its own hue — assistant/thinking/status bullets reuse text/textDim. */ roleUser: string; - roleAssistant: string; - roleThinking: string; - roleTool: string; - - // Status - status: string; } export const darkColors: ColorPalette = { - primary: dark.blue400, - accent: dark.cyan400, - - text: dark.gray100, - textStrong: dark.gray50, - textDim: dark.gray500, - textMuted: dark.gray600, - - border: dark.gray700, - borderFocus: dark.amber400, - - success: dark.green400, - warning: dark.amber400, - error: dark.red400, - - diffAdded: dark.green400, - diffRemoved: dark.red400, - diffAddedStrong: dark.green300, - diffRemovedStrong: dark.red300, - diffGutter: dark.gray600, - diffMeta: dark.gray500, - - roleUser: dark.orange300, - roleAssistant: dark.gray100, - roleThinking: dark.gray500, - roleTool: dark.amber400, - - status: dark.gray500, + primary: '#4FA8FF', + accent: '#5BC0BE', + + text: '#E0E0E0', + textStrong: '#F5F5F5', + textDim: '#888888', + textMuted: '#6B6B6B', + + border: '#5A5A5A', + borderFocus: '#E8A838', + + success: '#4EC87E', + warning: '#E8A838', + error: '#E85454', + + diffAdded: '#4EC87E', + diffRemoved: '#E85454', + diffAddedStrong: '#7AD99B', + diffRemovedStrong: '#F08585', + diffGutter: '#6B6B6B', + diffMeta: '#888888', + + roleUser: '#FFCB6B', }; export const lightColors: ColorPalette = { - primary: light.blue600, - accent: light.cyan700, - - text: light.gray900, - textStrong: light.gray900, - textDim: light.gray700, - textMuted: light.gray600, - - border: light.gray500, - borderFocus: light.amber800, - - success: light.green700, - warning: light.amber800, - error: light.red700, - - diffAdded: light.green700, - diffRemoved: light.red700, - diffAddedStrong: light.green700, - diffRemovedStrong: light.red700, - diffGutter: light.gray500, - diffMeta: light.gray600, - - roleUser: light.orange700, - roleAssistant: light.gray900, - roleThinking: light.gray700, - roleTool: light.amber800, - - status: light.gray700, + primary: '#1565C0', + accent: '#00838F', + + text: '#1A1A1A', + textStrong: '#1A1A1A', + textDim: '#454545', + textMuted: '#5F5F5F', + + border: '#737373', + borderFocus: '#92660A', + + success: '#0E7A38', + warning: '#92660A', + error: '#B91C1C', + + diffAdded: '#0E7A38', + diffRemoved: '#B91C1C', + diffAddedStrong: '#0E7A38', + diffRemovedStrong: '#B91C1C', + diffGutter: '#737373', + diffMeta: '#5F5F5F', + + roleUser: '#9A4A00', }; export type ResolvedTheme = 'dark' | 'light'; -export function getColorPalette(theme: ResolvedTheme): ColorPalette { - return theme === 'dark' ? darkColors : lightColors; +/** Synchronous palette lookup for built-in themes only. */ +export function getBuiltInPalette(resolved: ResolvedTheme): ColorPalette { + return resolved === 'dark' ? darkColors : lightColors; } diff --git a/apps/kimi-code/src/tui/theme/custom-theme-loader.ts b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts new file mode 100644 index 000000000..648de4be3 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts @@ -0,0 +1,69 @@ +/** + * Custom theme loader — reads JSON files from `~/.kimi-code/themes/`. + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { z } from 'zod'; + +import { getDataDir } from '#/utils/paths'; +import type { ColorPalette } from './colors'; +import { darkColors } from './colors'; + +export const CustomThemeSchema = z.object({ + name: z.string().min(1), + displayName: z.string().optional(), + colors: z.record(z.string(), z.string()).optional(), +}); + +export type CustomThemeDefinition = z.infer; + +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +export function getCustomThemesDir(): string { + return join(getDataDir(), 'themes'); +} + +export async function loadCustomTheme(name: string): Promise | null> { + try { + const content = await readFile(join(getCustomThemesDir(), `${name}.json`), 'utf-8'); + const parsed = CustomThemeSchema.parse(JSON.parse(content)); + + const errors: string[] = []; + for (const [key, value] of Object.entries(parsed.colors ?? {})) { + if (!HEX_COLOR_REGEX.test(value)) { + errors.push(`colors.${key}: "${value}" is not a valid 6-digit hex color`); + } + } + if (errors.length > 0) { + // eslint-disable-next-line no-console + console.warn(`Theme "${name}" has invalid colors:\n${errors.join('\n')}`); + } + + const validColors = Object.fromEntries( + Object.entries(parsed.colors ?? {}).filter(([, v]) => HEX_COLOR_REGEX.test(v)), + ); + + return validColors as Partial; + } catch { + return null; + } +} + +/** Load a custom theme and merge with darkColors fallback. */ +export async function loadCustomThemeMerged(name: string): Promise { + const custom = await loadCustomTheme(name); + if (custom === null) return null; + return { ...darkColors, ...custom }; +} + +export async function listCustomThemes(): Promise { + try { + const entries = await readdir(getCustomThemesDir(), { withFileTypes: true }); + return entries + .filter((e) => e.isFile() && e.name.endsWith('.json')) + .map((e) => e.name.replace(/\.json$/, '')); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/tui/theme/index.ts b/apps/kimi-code/src/tui/theme/index.ts index a9a143633..67a486b12 100644 --- a/apps/kimi-code/src/tui/theme/index.ts +++ b/apps/kimi-code/src/tui/theme/index.ts @@ -2,45 +2,62 @@ * Theme system public API. */ -import type { ResolvedTheme } from './colors'; -import { detectTerminalTheme } from './detect'; - -export { darkColors, lightColors, getColorPalette } from './colors'; +export { currentTheme, Theme } from './theme'; +export type { ColorToken } from './theme'; +export { darkColors, lightColors, getBuiltInPalette } from './colors'; export type { ColorPalette, ResolvedTheme } from './colors'; -export { createThemeStyles } from './styles'; -export type { ThemeStyles } from './styles'; -export { createMarkdownTheme, createEditorTheme } from './pi-tui-theme'; export { detectTerminalTheme } from './detect'; +export { loadCustomTheme, loadCustomThemeMerged, listCustomThemes } from './custom-theme-loader'; + +import { detectTerminalTheme } from './detect'; +import { getBuiltInPalette } from './colors'; +import { loadCustomThemeMerged } from './custom-theme-loader'; +import type { ColorPalette, ResolvedTheme } from './colors'; /** - * User-facing theme preference. `'auto'` defers to terminal background - * detection at startup; `'dark'` / `'light'` are explicit overrides that - * never trigger detection. The persisted value in `tui.toml` is always - * one of these three; the detected `ResolvedTheme` is computed at - * startup and held only in memory. + * User-facing theme preference. + * `'auto'` defers to terminal background detection at startup. + * `'dark'` / `'light'` are explicit built-in overrides. + * Any other string is treated as a custom theme name looked up in + * `~/.kimi-code/themes/.json`. */ -export type Theme = 'dark' | 'light' | 'auto'; +export type BuiltInTheme = 'dark' | 'light' | 'auto'; +export type ThemeName = BuiltInTheme | (string & {}); -export function isTheme(value: string): value is Theme { +export function isBuiltInTheme(value: string): value is BuiltInTheme { return value === 'dark' || value === 'light' || value === 'auto'; } +export function isThemeName(_value: string): _value is ThemeName { + return true; // any string is a valid theme name (custom themes) +} + /** - * Resolve a user preference to a concrete palette key. `'auto'` triggers - * terminal background detection (OSC 11 with COLORFGBG / dark fallback); - * explicit choices pass through. + * Resolve a user preference to a concrete palette. + * + * - `'auto'` triggers terminal background detection. + * - `'dark'` / `'light'` return the built-in palette. + * - Any other string loads a custom theme from `~/.kimi-code/themes/`; + * missing / invalid files fall back to dark palette. */ -export async function resolveTheme(theme: Theme): Promise { - if (theme === 'auto') return detectTerminalTheme(); - return theme; +export async function getColorPalette(theme: ThemeName): Promise { + if (theme === 'light') return getBuiltInPalette('light'); + if (theme === 'dark') return getBuiltInPalette('dark'); + if (theme === 'auto') { + const detected = await detectTerminalTheme(); + return getBuiltInPalette(detected); + } + // custom theme + const custom = await loadCustomThemeMerged(theme); + return custom ?? getBuiltInPalette('dark'); } /** - * Synchronous fallback used by paths that cannot wait on terminal probes - * (initial state construction, in-TUI theme switches). `'auto'` collapses - * to `'dark'`; explicit choices pass through. + * Synchronous fallback used by paths that cannot wait on terminal probes. + * `'auto'` collapses to `'dark'`; explicit choices pass through. + * Custom themes are not supported here — falls back to dark. */ -export function resolveThemeSync(theme: Theme): ResolvedTheme { - if (theme === 'auto') return 'dark'; - return theme; +export function getColorPaletteSync(theme: ThemeName): ColorPalette { + if (theme === 'light') return getBuiltInPalette('light'); + return getBuiltInPalette('dark'); } diff --git a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts index dc3b1b9ad..dec6ab253 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -1,15 +1,18 @@ /** - * Pi-tui theme adapters — MarkdownTheme and EditorTheme from our ColorPalette. + * Pi-tui theme adapters — MarkdownTheme and EditorTheme backed by the + * global `currentTheme` singleton. * - * All chalk calls route through `ColorPalette` tokens so themes flip - * cleanly. No raw `chalk.gray` / `chalk.dim` / `chalk.white` here. + * All colour lookups route through `currentTheme.color(token)` so that + * switching themes is instantaneous: old components hold old + * MarkdownTheme/EditorTheme instances, but every method call on those + * instances reads the *current* palette via the singleton. */ import type { MarkdownTheme, EditorTheme } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { highlight, supportsLanguage } from 'cli-highlight'; -import type { ColorPalette } from './colors'; +import { currentTheme } from './theme'; // pi-tui's renderer emits literal "### " / "#### " / ... markers for h3-h6 // headings (h1/h2 are rendered without the `#` prefix). The prefix arrives @@ -19,25 +22,23 @@ import type { ColorPalette } from './colors'; // eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences. const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/; -export function createMarkdownTheme(colors: ColorPalette): MarkdownTheme { +export function createMarkdownTheme(): MarkdownTheme { const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); - const muted = chalk.hex(colors.textMuted); - const dim = chalk.hex(colors.textDim); - const border = chalk.hex(colors.border); + return { - heading: (text) => chalk.bold.hex(colors.text)(stripHash(text)), - link: (text) => chalk.hex(colors.primary)(text), - linkUrl: (text) => muted(text), - code: (text) => chalk.hex(colors.primary)(text), + heading: (text) => chalk.bold.hex(currentTheme.color('text'))(stripHash(text)), + link: (text) => chalk.hex(currentTheme.color('primary'))(text), + linkUrl: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + code: (text) => chalk.hex(currentTheme.color('primary'))(text), codeBlock: (text) => text, - codeBlockBorder: (text) => muted(text), - quote: (text) => dim(text), - quoteBorder: (text) => dim(text), - hr: (text) => border(text), + codeBlockBorder: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + quote: (text) => chalk.hex(currentTheme.color('textDim'))(text), + quoteBorder: (text) => chalk.hex(currentTheme.color('textDim'))(text), + hr: (text) => chalk.hex(currentTheme.color('border'))(text), // Match the assistant-message bullet so list markers read like a reply - // prefix. Ordered lists arrive as `"1. "` / `"2. "` and are left + // prefix. Ordered lists arrive as "1. " / "2. " and are left // untouched by the leading-dash anchor. - listBullet: (text) => chalk.hex(colors.roleAssistant)(text.replace(/^-/, '•')), + listBullet: (text) => chalk.hex(currentTheme.color('text'))(text.replace(/^-/, '•')), bold: (text) => chalk.bold(text), italic: (text) => chalk.italic(text), strikethrough: (text) => chalk.strikethrough(text), @@ -56,16 +57,15 @@ export function createMarkdownTheme(colors: ColorPalette): MarkdownTheme { }; } -export function createEditorTheme(colors: ColorPalette): EditorTheme { - const muted = chalk.hex(colors.textMuted); +export function createEditorTheme(): EditorTheme { return { - borderColor: (s) => chalk.hex(colors.border)(s), + borderColor: (s) => chalk.hex(currentTheme.color('border'))(s), selectList: { - selectedPrefix: (s) => chalk.hex(colors.primary)(s), - selectedText: (s) => chalk.hex(colors.primary)(s), - description: (s) => muted(s), - scrollInfo: (s) => muted(s), - noMatch: (s) => muted(s), + selectedPrefix: (s) => chalk.hex(currentTheme.color('primary'))(s), + selectedText: (s) => chalk.hex(currentTheme.color('primary'))(s), + description: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + scrollInfo: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + noMatch: (s) => chalk.hex(currentTheme.color('textMuted'))(s), }, }; } diff --git a/apps/kimi-code/src/tui/theme/styles.ts b/apps/kimi-code/src/tui/theme/styles.ts deleted file mode 100644 index 625302e45..000000000 --- a/apps/kimi-code/src/tui/theme/styles.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Theme-aware style helpers built on chalk. Components hold a reference - * to a `ThemeStyles` instance via `state.theme.styles` and never reach into - * raw chalk color names — that keeps theme switches consistent and lets - * every visual token route through `ColorPalette`. - */ - -import chalk from 'chalk'; - -import type { ColorPalette } from './colors'; - -export interface ThemeStyles { - colors: ColorPalette; - - /** Brand primary (links, focus, slash highlight). */ - primary(text: string): string; - /** Secondary brand accent (command operators, approval labels). */ - accent(text: string): string; - /** Dimmed text — secondary but still readable. */ - dim(text: string): string; - /** Muted text — most faded; for unchanged-line counters, scroll info. */ - muted(text: string): string; - /** Body text — same color as default but explicit for theming. */ - text(text: string): string; - /** Strong / emphasized text — paths, URLs, command bodies. */ - strong(text: string): string; - - error(text: string): string; - warning(text: string): string; - success(text: string): string; - - /** Bold + dim, for label cells. */ - label(text: string): string; - /** Body color, for value cells. */ - value(text: string): string; - - diffAdd(text: string): string; - diffDel(text: string): string; - diffAddBold(text: string): string; - diffDelBold(text: string): string; - diffGutter(text: string): string; - diffMeta(text: string): string; -} - -export function createThemeStyles(colors: ColorPalette): ThemeStyles { - return { - colors, - primary: (s) => chalk.hex(colors.primary)(s), - accent: (s) => chalk.hex(colors.accent)(s), - dim: (s) => chalk.hex(colors.textDim)(s), - muted: (s) => chalk.hex(colors.textMuted)(s), - text: (s) => chalk.hex(colors.text)(s), - strong: (s) => chalk.hex(colors.textStrong)(s), - error: (s) => chalk.hex(colors.error)(s), - warning: (s) => chalk.hex(colors.warning)(s), - success: (s) => chalk.hex(colors.success)(s), - label: (s) => chalk.bold.hex(colors.textDim)(s), - value: (s) => chalk.hex(colors.text)(s), - diffAdd: (s) => chalk.hex(colors.diffAdded)(s), - diffDel: (s) => chalk.hex(colors.diffRemoved)(s), - diffAddBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), - diffDelBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), - diffGutter: (s) => chalk.hex(colors.diffGutter)(s), - diffMeta: (s) => chalk.hex(colors.diffMeta)(s), - }; -} diff --git a/apps/kimi-code/src/tui/theme/theme-schema.json b/apps/kimi-code/src/tui/theme/theme-schema.json new file mode 100644 index 000000000..d3f097433 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme-schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/moonshot-ai/kimi-code/blob/main/apps/kimi-code/src/tui/theme/theme-schema.json", + "title": "Kimi Code Custom Theme", + "description": "Schema for Kimi Code TUI custom theme definitions", + "type": "object", + "required": ["name"], + "properties": { + "$schema": { + "type": "string", + "description": "URL to this JSON Schema" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Machine-readable theme identifier (kebab-case recommended)" + }, + "displayName": { + "type": "string", + "description": "Human-readable theme name shown in UI" + }, + "colors": { + "type": "object", + "description": "Color overrides. Omitted tokens fall back to the dark theme defaults.", + "properties": { + "primary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Primary brand color" }, + "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Accent / highlight color" }, + "text": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Default text color" }, + "textStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Bold / emphasized text" }, + "textDim": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Secondary / muted text" }, + "textMuted": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Most faded text; for counters, scroll info" }, + "border": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Border color" }, + "borderFocus": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Focused border color" }, + "success": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Success state color" }, + "warning": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Warning state color" }, + "error": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Error state color" }, + "diffAdded": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines" }, + "diffRemoved": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines" }, + "diffAddedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines (strong)" }, + "diffRemovedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines (strong)" }, + "diffGutter": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff gutter color" }, + "diffMeta": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff meta color" }, + "roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" } + }, + "additionalProperties": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Any valid ColorPalette token" + } + } + }, + "additionalProperties": false +} diff --git a/apps/kimi-code/src/tui/theme/theme.ts b/apps/kimi-code/src/tui/theme/theme.ts new file mode 100644 index 000000000..3b7a377fe --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme.ts @@ -0,0 +1,93 @@ +/** + * Theme class + global singleton. + * + * Components import `currentTheme` and call methods like + * `currentTheme.fg('primary', text)` at render time. When the user switches + * themes we call `currentTheme.setPalette(newPalette)` — the same singleton + * instance stays alive, so every component (including already-rendered + * transcript entries) sees the new colours on the next render frame. + */ + +import chalk from 'chalk'; + +import type { ColorPalette } from './colors'; +import { darkColors } from './colors'; + +export type ColorToken = keyof ColorPalette; + +export class Theme { + private _palette: ColorPalette; + + constructor(palette: ColorPalette) { + this._palette = palette; + } + + get palette(): ColorPalette { + return this._palette; + } + + setPalette(palette: ColorPalette): void { + this._palette = palette; + } + + color(token: ColorToken): string { + return this._palette[token]; + } + + /* ── Foreground helpers ── */ + + fg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token])(text); + } + + boldFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).bold(text); + } + + dimFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).dim(text); + } + + italicFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).italic(text); + } + + underlineFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).underline(text); + } + + strikethroughFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).strikethrough(text); + } + + /* ── Background helpers ── */ + + bg(token: ColorToken, text: string): string { + return chalk.bgHex(this._palette[token])(text); + } + + /* ── Standalone style helpers ── */ + + bold(text: string): string { + return chalk.bold(text); + } + + dim(text: string): string { + return chalk.dim(text); + } + + italic(text: string): string { + return chalk.italic(text); + } + + underline(text: string): string { + return chalk.underline(text); + } + + strikethrough(text: string): string { + return chalk.strikethrough(text); + } +} + +/** Global singleton. Initialise with dark palette; switch via `setPalette`. */ +export const currentTheme = new Theme(darkColors); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 41c714639..d187dc93b 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -12,7 +12,7 @@ import type { SessionRow } from './components/dialogs/session-picker'; import { CustomEditor } from './components/editor/custom-editor'; import { CHROME_GUTTER } from './constant/rendering'; import type { TasksBrowserState } from './controllers/tasks-browser'; -import { createKimiTUIThemeBundle, type KimiTUIThemeBundle } from './theme/bundle'; +import { currentTheme, type Theme } from './theme'; import { createTerminalState, type TerminalState } from './utils/terminal-state'; import { INITIAL_LIVE_PANE, @@ -36,7 +36,7 @@ export interface TUIState { editorContainer: Container; footer: FooterComponent; editor: CustomEditor; - theme: KimiTUIThemeBundle; + theme: Theme; appState: AppState; startupState: TUIStartupState; livePane: LivePaneState; @@ -55,7 +55,7 @@ export interface TUIState { export function createTUIState(options: KimiTUIOptions): TUIState { const initialAppState = options.initialAppState; - const theme = createKimiTUIThemeBundle(initialAppState.theme, options.resolvedTheme); + const theme = currentTheme; const terminal = new ProcessTerminal(); const ui = new TUI(terminal); @@ -63,12 +63,12 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const todoPanel = new TodoPanelComponent(theme.colors); + const todoPanel = new TodoPanelComponent(theme.palette); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const editor = new CustomEditor(ui, theme.colors); - const footer = new FooterComponent({ ...initialAppState }, theme.colors, () => { + const editor = new CustomEditor(ui); + const footer = new FooterComponent({ ...initialAppState }, theme.palette, () => { ui.requestRender(); }); @@ -82,8 +82,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { queueContainer, btwPanelContainer, editorContainer, - footer, editor, + footer, theme, appState: { ...initialAppState }, startupState: 'pending', diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index c88216538..8237f5523 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -9,8 +9,7 @@ import type { import type { NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; -import type { Theme } from './theme'; -import type { ResolvedTheme } from './theme/colors'; +import type { ColorToken, ThemeName } from './theme'; export interface AppState { model: string; @@ -26,7 +25,7 @@ export interface AppState { isReplaying: boolean; streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; streamingStartTime: number; - theme: Theme; + theme: ThemeName; version: string; editorCommand: string | null; notifications: NotificationsConfig; @@ -127,7 +126,7 @@ export interface TranscriptEntry { turnId?: string; renderMode: 'markdown' | 'plain' | 'notice'; content: string; - color?: string; + color?: ColorToken; detail?: string; toolCallData?: ToolCallBlockData; backgroundAgentStatus?: BackgroundAgentStatusData; @@ -185,7 +184,6 @@ export type TUIStartupState = 'pending' | 'ready' | 'picker'; export interface KimiTUIOptions { initialAppState: AppState; startup: TUIStartupOptions; - resolvedTheme?: ResolvedTheme; } export interface PendingExit { diff --git a/apps/kimi-code/test/cli/doctor.test.ts b/apps/kimi-code/test/cli/doctor.test.ts index b6404c97d..afbda67c0 100644 --- a/apps/kimi-code/test/cli/doctor.test.ts +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -207,7 +207,7 @@ max_context_size = 0 `, 'utf-8', ); - await writeFile(join(dir, 'tui.toml'), 'theme = "blue"\n', 'utf-8'); + await writeFile(join(dir, 'tui.toml'), 'editor = 123\n', 'utf-8'); const { deps, stdout, stderr } = makeDeps(); const code = await handleDoctor(deps, {}); @@ -219,14 +219,14 @@ max_context_size = 0 expect(err).toContain(`ERROR config.toml ${join(dir, 'config.toml')}`); expect(err).toContain('max_context_size'); expect(err).toContain(`ERROR tui.toml ${join(dir, 'tui.toml')}`); - expect(err).toContain('theme'); + expect(err).toContain('editor'); }); it('formats Zod validation issues with field paths for tui.toml', async () => { await writeFile( join(dir, 'tui.toml'), ` -theme = "blue" +editor = 123 [notifications] enabled = "yes" @@ -240,7 +240,7 @@ enabled = "yes" expect(code).toBe(1); const err = stderr.join(''); expect(err).toContain('Validation issues:'); - expect(err).toContain('theme:'); + expect(err).toContain('editor:'); expect(err).toContain('notifications.enabled:'); }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index c55a5e590..b61641dd0 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -224,7 +224,6 @@ describe('runShell', () => { }, version: '1.2.3-test', workDir: process.cwd(), - resolvedTheme: 'dark', }); expect(mocks.tuiStart).toHaveBeenCalledOnce(); expect(mocks.harnessTrack).not.toHaveBeenCalledWith('started', expect.anything()); @@ -476,7 +475,6 @@ describe('runShell', () => { const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; expect(startupInput).toMatchObject({ startupNotice: 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.', - resolvedTheme: 'light', tuiConfig: { theme: 'auto', editorCommand: 'vim', diff --git a/apps/kimi-code/test/migration/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts index 240cea9a5..a49eec8e7 100644 --- a/apps/kimi-code/test/migration/migration-screen.test.ts +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -35,7 +35,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); const out = render(c); @@ -55,7 +54,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); const out = render(c); @@ -69,7 +67,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { result = r; }, @@ -85,7 +82,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, runMigration: async (input) => { captured = input; return makeReport(); @@ -104,7 +100,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, runMigration: async (input) => { captured = input; return makeReport(); @@ -123,7 +118,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan({ totalSessions: 1365 }), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c.handleInput('\r'); // ask1: Migrate now -> ask2 @@ -140,7 +134,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan({ totalSessions: 0 }), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c.handleInput('\r'); // ask1 -> ask2 @@ -155,7 +148,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, onComplete: () => {}, }); @@ -171,7 +163,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, runMigration: async (input) => { captured = input; @@ -191,7 +182,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); // expose progress rendering via the test hook (see Step 5.2) @@ -211,7 +201,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, // A migration that never settles keeps the screen in the progress // phase so the spinner animation can be observed. @@ -236,7 +225,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testEnterProgress(); @@ -312,7 +300,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport()); @@ -327,7 +314,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -361,7 +347,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { result = r; }, @@ -377,7 +362,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); // config skipped (e.g. a malformed legacy config.toml). @@ -413,7 +397,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -454,7 +437,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -496,7 +478,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport({ sessionsSkippedEmpty: 3 })); @@ -511,7 +492,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -544,7 +524,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport({}, {}, { mcpOauthServersRequiringReauth: ['srv-a', 'srv-b'] })); @@ -561,7 +540,6 @@ describe('MigrationScreenComponent — execution wiring', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { onCompleteResult = r; }, @@ -583,7 +561,6 @@ describe('MigrationScreenComponent — execution wiring', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, runMigration: async () => { throw new Error('boom'); diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index 7b657d7c1..5a9eba9a7 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -28,7 +28,6 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts index 6e823f995..b41aaf1c6 100644 --- a/apps/kimi-code/test/tui/commands/experiments.test.ts +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -34,7 +34,7 @@ function makeHost() { }; const host = { state: { - theme: { colors: darkColors }, + theme: { palette: darkColors }, ui: { requestRender: vi.fn() }, }, harness: { @@ -110,7 +110,7 @@ describe('experimental feature command handlers', () => { expect(host.harness.setConfig).not.toHaveBeenCalled(); expect(host.showStatus).toHaveBeenCalledWith( 'No experimental feature changes to apply.', - darkColors.textMuted, + 'textMuted', ); }); }); diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 9b55fbe3b..2522d8e78 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -16,7 +16,7 @@ import { updateGoalQueueItem, } from '#/tui/goal-queue-store'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { getColorPalette } from '#/tui/theme/colors'; +import { getBuiltInPalette } from '#/tui/theme'; vi.mock('#/tui/goal-queue-store', () => ({ appendGoalQueueItem: vi.fn(async () => ({ @@ -112,7 +112,7 @@ function makeHost( }, transcriptContainer, ui: { requestRender: vi.fn() }, - theme: { colors: getColorPalette('dark') }, + theme: { palette: getBuiltInPalette('dark') }, }, session: hasSession ? session : undefined, skillCommandMap: new Map(), diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 5d6b41f55..2eed7b2de 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -8,6 +8,7 @@ import { handleReloadCommand, handleReloadTuiCommand, } from '#/tui/commands/reload'; +import { currentTheme } from '#/tui/theme'; import type { SlashCommandHost } from '#/tui/commands'; import { isExperimentalFlagEnabled, @@ -60,7 +61,7 @@ auto_install = false }); expect(host.showStatus).toHaveBeenCalledWith( 'TUI config reloaded.', - host.state.theme.colors.success, + 'success', ); }); @@ -110,8 +111,7 @@ function makeHost({ availableProviders: {}, }, theme: { - resolvedTheme: 'dark', - colors: { + palette: { success: '#00ff00', }, }, diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index 910fc4a58..fdb64ce46 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -30,7 +30,7 @@ describe('update preference commands', () => { notifications: { enabled: true, condition: 'unfocused' as const }, upgrade: { autoInstall: true }, }, - theme: { colors: darkColors }, + theme: { palette: darkColors }, }, setAppState, showStatus, diff --git a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts index f7cea9b92..9dda8ff28 100644 --- a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts @@ -19,7 +19,6 @@ describe('DeviceCodeBoxComponent', () => { url, code, hint, - colors: darkColors, }); const lines = component.render(80).map(strip); @@ -42,7 +41,6 @@ describe('DeviceCodeBoxComponent', () => { title, url, code, - colors: darkColors, }); const lines = component.render(40).map(strip); @@ -57,7 +55,6 @@ describe('DeviceCodeBoxComponent', () => { title, url, code, - colors: darkColors, }); const joined = component.render(80).map(strip).join('\n'); diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 163b3b53b..a4bd49fc4 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -70,7 +70,7 @@ describe('WelcomeComponent', () => { }); it('renders the banner in a single brand color by default', () => { - const codes = truecolorCodes(headerOf(new WelcomeComponent(appState, darkColors).render(80))); + const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); // No rainbow by default — just the brand primary (plus the dim tagline). expect(codes.size).toBeLessThanOrEqual(2); @@ -78,15 +78,15 @@ describe('WelcomeComponent', () => { it('paints the banner in rainbow while colored', () => { setDanceView(true, 0); - const codes = truecolorCodes(headerOf(new WelcomeComponent(appState, darkColors).render(80))); + const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); expect(codes.size).toBeGreaterThanOrEqual(5); }); it('renders exactly the default banner when not colored', () => { - const base = headerOf(new WelcomeComponent(appState, darkColors).render(80)); + const base = headerOf(new WelcomeComponent(appState).render(80)); setDanceView(false, 5); - const off = headerOf(new WelcomeComponent(appState, darkColors).render(80)); + const off = headerOf(new WelcomeComponent(appState).render(80)); expect(off).toBe(base); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index 0b844ee7c..f16113d9c 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -7,12 +7,9 @@ import type { FileContentDisplayBlock, PendingApproval, } from '#/tui/reverse-rpc/types'; -import { getColorPalette } from '#/tui/theme/colors'; import { captureProcessWrite } from '../../../helpers/process'; -const COLORS = getColorPalette('dark'); - function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -52,7 +49,6 @@ function makeDialog(): { const dialog = new ApprovalPanelComponent( makePending(), (response) => responses.push(response), - COLORS, ); return { dialog, responses }; } @@ -84,7 +80,7 @@ describe('ApprovalPanelComponent', () => { choices: [{ label: 'Approve once', response: 'approved' }], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const out = strip(dialog.render(80).join('\n')); expect(out).toContain('Dangerous: recursive delete'); @@ -184,7 +180,7 @@ describe('ApprovalPanelComponent', () => { ], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const out = strip(dialog.render(80).join('\n')); expect(out).toContain('Ready to build with this plan?'); @@ -233,7 +229,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (r) => responses.push(r), - COLORS, () => toolOutputToggles++, () => planToggles++, (block) => previewCalls.push(block), @@ -278,7 +273,7 @@ describe('ApprovalPanelComponent', () => { }, }; let globalToggleCalls = 0; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS, () => globalToggleCalls++); + const dialog = new ApprovalPanelComponent(pending, () => {}, () => globalToggleCalls++); dialog.handleInput('\u000F'); // Ctrl+O @@ -308,7 +303,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, () => {}, - COLORS, undefined, () => planToggles++, (block) => previewCalls.push(block), @@ -343,7 +337,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (r) => responses.push(r), - COLORS, undefined, undefined, (block) => previewCalls.push(block), @@ -386,7 +379,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, () => {}, - COLORS, undefined, undefined, (block) => previewCalls.push(block), @@ -430,7 +422,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (response) => responses.push(response), - COLORS, ); dialog.handleInput('2'); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts index e3f216a1e..d4ba77fa9 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts @@ -5,10 +5,6 @@ import { ApprovalPreviewViewer, type ApprovalPreviewBlock, } from '#/tui/components/dialogs/approval-preview'; -import { getColorPalette } from '#/tui/theme/colors'; - -const COLORS = getColorPalette('dark'); - const ANSI_SGR = /\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); @@ -49,7 +45,6 @@ function makeViewer(opts: { return new ApprovalPreviewViewer( { block: opts.block, - colors: COLORS, onClose: opts.onClose ?? (() => {}), }, fakeTerminal(opts.rows ?? 24, opts.columns ?? 100), diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index bc119edb4..367a85578 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -22,7 +22,6 @@ describe('ChoicePickerComponent', () => { { value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }, ], - colors: darkColors, searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -61,7 +60,6 @@ describe('ChoicePickerComponent', () => { }, ], currentValue: 'manual', - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -79,7 +77,6 @@ describe('ChoicePickerComponent', () => { const editor = new EditorSelectorComponent({ currentValue: 'vim', - colors: darkColors, onSelect, onCancel, }); @@ -87,7 +84,6 @@ describe('ChoicePickerComponent', () => { const theme = new ThemeSelectorComponent({ currentValue: 'light', - colors: darkColors, onSelect, onCancel, }); @@ -95,14 +91,12 @@ describe('ChoicePickerComponent', () => { const permission = new PermissionSelectorComponent({ currentValue: 'manual', - colors: darkColors, onSelect, onCancel, }); expect(permission.render(120).map(strip)).toContain(' ❯ Manual ← current'); const settings = new SettingsSelectorComponent({ - colors: darkColors, onSelect, onCancel, }); @@ -113,7 +107,6 @@ describe('ChoicePickerComponent', () => { const upgradePreference = new UpdatePreferenceSelectorComponent({ currentValue: true, - colors: darkColors, onSelect, onCancel, }); @@ -131,7 +124,6 @@ describe('ChoicePickerComponent', () => { { value: 'azure', label: 'Azure OpenAI' }, ], searchable: true, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -145,7 +137,6 @@ describe('ChoicePickerComponent', () => { const picker = new ChoicePickerComponent({ title: 'Pick one', options: [{ value: 'a', label: 'Alpha' }], - colors: darkColors, onSelect, onCancel: vi.fn(), }); diff --git a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts index b49501978..0a680b02b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -9,7 +9,7 @@ function strip(text: string): string { describe('CompactionComponent', () => { it('renders the custom instruction below the compacting label', () => { - const component = new CompactionComponent(darkColors, undefined, 'keep the recent files only'); + const component = new CompactionComponent(undefined, 'keep the recent files only'); try { const lines = component.render(120).map(strip); @@ -23,7 +23,7 @@ describe('CompactionComponent', () => { }); it('renders a cancelled terminal state', () => { - const component = new CompactionComponent(darkColors); + const component = new CompactionComponent(); try { component.markCanceled(); diff --git a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts index 66bc3badd..848c8f5e1 100644 --- a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts @@ -23,7 +23,6 @@ function makeDialog(defaultUrl = 'https://example.com/api.json'): { const onDone = vi.fn(); const dialog = new CustomRegistryImportDialogComponent( onDone as unknown as (r: CustomRegistryImportResult) => void, - darkColors, defaultUrl, ); dialog.focused = true; diff --git a/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts index 96fd9526b..f2e208f25 100644 --- a/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts @@ -50,7 +50,6 @@ describe('ExperimentsSelectorComponent', () => { source: 'env', }), ], - colors: darkColors, onApply: vi.fn(), onCancel: vi.fn(), }); @@ -77,7 +76,6 @@ describe('ExperimentsSelectorComponent', () => { }); const selector = new ExperimentsSelectorComponent({ features: [first, second], - colors: darkColors, onApply, onCancel: vi.fn(), }); @@ -110,7 +108,6 @@ describe('ExperimentsSelectorComponent', () => { source: 'env', }), ], - colors: darkColors, onApply, onCancel: vi.fn(), }); @@ -134,7 +131,6 @@ describe('ExperimentsSelectorComponent', () => { env: 'KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK', }), ], - colors: darkColors, onApply: vi.fn(), onCancel, }); diff --git a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts index 13fcdd589..5b0fdeeb1 100644 --- a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -26,7 +26,7 @@ function makeDialog(): { const collected: FeedbackInputDialogResult[] = []; const dialog = new FeedbackInputDialogComponent((result) => { collected.push(result); - }, darkColors); + }); dialog.focused = true; return { dialog, collected }; } diff --git a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts index 23062ea2b..258f044d2 100644 --- a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -6,7 +6,6 @@ import { GoalQueueManagerComponent, type GoalQueueManagerAction, } from '#/tui/components/dialogs/goal-queue-manager'; -import { darkColors } from '#/tui/theme/colors'; import type { GoalQueueSnapshot, UpcomingGoal } from '#/tui/goal-queue-store'; const ANSI = /\u001B\[[0-9;]*m/g; @@ -39,7 +38,6 @@ describe('GoalQueueManagerComponent', () => { it('renders the upcoming goals and the management hint', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'Ship queued goal')], - colors: darkColors, onAction: vi.fn(), onCancel: vi.fn(), }); @@ -59,7 +57,6 @@ describe('GoalQueueManagerComponent', () => { }); const manager = new GoalQueueManagerComponent({ goals: [first, second], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -85,7 +82,6 @@ describe('GoalQueueManagerComponent', () => { }); const manager = new GoalQueueManagerComponent({ goals: [first, second], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -112,7 +108,6 @@ describe('GoalQueueManagerComponent', () => { ); const manager = new GoalQueueManagerComponent({ goals: [first, second], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -130,7 +125,6 @@ describe('GoalQueueManagerComponent', () => { const onAction = vi.fn(); const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'First queued goal')], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -144,7 +138,6 @@ describe('GoalQueueManagerComponent', () => { const onCancel = vi.fn(); const manager = new GoalQueueManagerComponent({ goals: [], - colors: darkColors, onAction: vi.fn(), onCancel, }); @@ -157,7 +150,6 @@ describe('GoalQueueManagerComponent', () => { it('never renders a line wider than the terminal', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'A very long queued goal objective that should be truncated cleanly')], - colors: darkColors, onAction: vi.fn(), onCancel: vi.fn(), }); @@ -172,7 +164,6 @@ describe('GoalQueueManagerComponent', () => { it('renders multiline objectives as a single selectable row', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'First line\nSecond line')], - colors: darkColors, onAction: vi.fn(), onCancel: vi.fn(), }); @@ -189,7 +180,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -207,7 +197,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -226,7 +215,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -245,7 +233,6 @@ describe('GoalQueueEditDialogComponent', () => { it('renders multiline edits inside the dialog width', () => { const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'First line\nSecond line'), - colors: darkColors, onDone: vi.fn(), }); @@ -262,7 +249,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -276,7 +262,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', ''), - colors: darkColors, onDone, }); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 0a59030f6..dd4780e64 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -33,7 +33,6 @@ describe('ModelSelectorComponent', () => { models: { kimi: model('Kimi K2') }, currentValue: 'kimi', currentThinking: true, - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -51,7 +50,6 @@ describe('ModelSelectorComponent', () => { models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', currentThinking: true, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -77,7 +75,6 @@ describe('ModelSelectorComponent', () => { models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', currentThinking: false, - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -93,7 +90,6 @@ describe('ModelSelectorComponent', () => { }, currentValue: 'always', currentThinking: false, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -117,7 +113,6 @@ describe('ModelSelectorComponent', () => { }, currentValue: 'plain', currentThinking: false, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -140,7 +135,6 @@ describe('ModelSelectorComponent', () => { }, currentValue: 'current', currentThinking: false, // thinking deliberately off on the active model - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -160,7 +154,6 @@ describe('ModelSelectorComponent', () => { models: { k2: model('Kimi K2'), turbo: model('Kimi Turbo') }, currentValue: 'k2', currentThinking: false, - colors: darkColors, searchable: true, onSelect: vi.fn(), onCancel, @@ -188,7 +181,6 @@ describe('ModelSelectorComponent', () => { models, currentValue: 'm0', currentThinking: false, - colors: darkColors, searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -206,7 +198,6 @@ describe('ModelSelectorComponent', () => { }, currentValue: 'long', currentThinking: false, - colors: darkColors, searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 5a033a2cf..34d7358e7 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -109,7 +109,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -148,7 +147,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel, }); @@ -175,7 +173,6 @@ describe('plugins selector dialogs', () => { ], installedIds: new Set(), source: '/tmp/marketplace.json', - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -214,7 +211,6 @@ describe('plugins selector dialogs', () => { ], installedIds: new Set(), source: '/tmp/marketplace.json', - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -244,7 +240,6 @@ describe('plugins selector dialogs', () => { ], installedIds: new Set(), source: '/tmp/marketplace.json', - colors: darkColors, onSelect: vi.fn(), onCancel, }); @@ -265,7 +260,6 @@ describe('plugins selector dialogs', () => { ], installedIds: new Set(['superpowers']), source: '/tmp/marketplace.json', - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -298,7 +292,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -329,7 +322,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -356,7 +348,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -396,7 +387,6 @@ describe('plugins selector dialogs', () => { ], diagnostics: [], }, - colors: darkColors, onSelect: (selection) => { selections.push(selection); }, @@ -434,7 +424,6 @@ describe('plugins selector dialogs', () => { ], selectedId: 'kimi-datasource', pluginHint: { id: 'kimi-datasource', text: 'pending /new' }, - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -449,7 +438,6 @@ describe('plugins selector dialogs', () => { const picker = new PluginRemoveConfirmComponent({ id: 'kimi-datasource', displayName: 'Kimi Datasource', - colors: darkColors, onDone: (result) => { results.push(result); }, @@ -470,7 +458,6 @@ describe('plugins selector dialogs', () => { const picker = new PluginRemoveConfirmComponent({ id: 'kimi-datasource', displayName: 'Kimi Datasource', - colors: darkColors, onDone: (result) => { results.push(result); }, diff --git a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts index 858289f42..6596cf705 100644 --- a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts @@ -24,7 +24,6 @@ function rendered(component: ProviderManagerComponent, width = 120): string { function makeComponent(overrides: Partial = {}): ProviderManagerComponent { return new ProviderManagerComponent({ providers: {} as Record, - colors: darkColors, onAdd: vi.fn(), onDeleteSource: vi.fn(), onClose: vi.fn(), diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index a015e4176..2192b4cb6 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { QuestionDialogComponent } from '#/tui/components/dialogs/question-dialog'; import type { PendingQuestion } from '#/tui/reverse-rpc/types'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -50,7 +50,6 @@ function makeDialog( collected.push(response.answers); methods.push(response.method); }, - darkColors, 6, onToggleToolOutput, onTogglePlanExpand, @@ -88,9 +87,9 @@ describe('QuestionDialogComponent', () => { expect(review).not.toContain('? Ready to submit your answers?'); expect(review).not.toContain('Please answer all questions before submitting.'); expect(reviewRaw).toContain( - chalk.hex(darkColors.text).bold(' Review your answer before submit'), + currentTheme.boldFg('text', ' Review your answer before submit'), ); - expect(reviewRaw).toContain(chalk.hex(darkColors.text)(' Ready to submit your answers?')); + expect(reviewRaw).toContain(currentTheme.fg('text', ' Ready to submit your answers?')); expect(review).toContain('B1'); expect(review).toContain('A2'); @@ -295,8 +294,8 @@ describe('QuestionDialogComponent', () => { dialog.handleInput('\u001B[D'); const out = dialog.render(80).join('\n'); - expect(out).toContain(chalk.hex(darkColors.success).bold(' → [1] A')); - expect(out).not.toContain(chalk.hex(darkColors.primary)(' → [1] A')); + expect(out).toContain(currentTheme.boldFg('success', ' → [1] A')); + expect(out).not.toContain(currentTheme.fg('primary', ' → [1] A')); }); it('stretches the border to the full available width', () => { @@ -356,7 +355,9 @@ describe('QuestionDialogComponent', () => { const { dialog } = makeDialog(pending); const out = dialog.render(80).join('\n'); - expect(out).toContain(chalk.bgHex(darkColors.primary).hex(darkColors.text).bold(' First ')); + expect(out).toContain( + chalk.bgHex(currentTheme.color('primary')).hex(currentTheme.color('text')).bold(' First '), + ); expect(out).not.toContain('(●) First'); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 5f5845316..491522242 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -2,7 +2,6 @@ import { visibleWidth } from '@earendil-works/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SessionPickerComponent } from '#/tui/components/dialogs/session-picker'; -import { getColorPalette } from '#/tui/theme/colors'; function stripAnsi(text: string): string { return text.replaceAll(/\[[0-?]*[ -/]*[@-~]/g, ''); @@ -38,7 +37,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -66,7 +64,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -96,7 +93,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -123,7 +119,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -157,7 +152,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_current', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -184,7 +178,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -220,7 +213,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -249,7 +241,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_cjk_long_session_id_value', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -284,7 +275,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: id, - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index d545103af..b1a2baf0c 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -35,7 +35,6 @@ function make(): { }, currentValue: 'k2', currentThinking: false, - colors: darkColors, onSelect, onCancel: vi.fn(), }); diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 6545af266..2f82aabea 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -7,13 +7,12 @@ import type { import { describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; -import { getColorPalette } from '#/tui/theme/index'; function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), } as unknown as TUI; - return new CustomEditor(tui, { ...getColorPalette('dark') }); + return new CustomEditor(tui); } async function flushAutocomplete(): Promise { diff --git a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts index 1ac26c70e..d47f29b56 100644 --- a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts +++ b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts @@ -20,7 +20,7 @@ function expectHighlighted(out: string, token: string): void { describe('highlightFirstSlashToken', () => { it('colours /cmd when line starts with a slash', () => { - const out = highlightFirstSlashToken(' /help rest of input', '#ff00aa'); + const out = highlightFirstSlashToken(' /help rest of input', 'primary'); expect(out).toBeDefined(); // Visible text unchanged expect(strip(out!)).toBe(' /help rest of input'); @@ -29,7 +29,7 @@ describe('highlightFirstSlashToken', () => { }); it('colours next in /goal next', () => { - const out = highlightFirstSlashToken('/goal next Ship feature X', '#ff00aa'); + const out = highlightFirstSlashToken('/goal next Ship feature X', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/goal next Ship feature X'); expectHighlighted(out!, '/goal'); @@ -38,7 +38,7 @@ describe('highlightFirstSlashToken', () => { }); it('colours manage in /goal next manage', () => { - const out = highlightFirstSlashToken('/goal next manage', '#ff00aa'); + const out = highlightFirstSlashToken('/goal next manage', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/goal next manage'); expectHighlighted(out!, '/goal'); @@ -47,19 +47,19 @@ describe('highlightFirstSlashToken', () => { }); it('returns undefined when the line has no slash', () => { - expect(highlightFirstSlashToken('hello world', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken('hello world', 'primary')).toBeUndefined(); }); it('returns undefined when slash is not at the leading position', () => { - expect(highlightFirstSlashToken(' hello /not-cmd', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken(' hello /not-cmd', 'primary')).toBeUndefined(); }); it('returns undefined for path-like slash tokens', () => { - expect(highlightFirstSlashToken('/user/desktop/ foo', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken('/user/desktop/ foo', 'primary')).toBeUndefined(); }); it('handles /token at end of line (no trailing whitespace)', () => { - const out = highlightFirstSlashToken('/exit', '#ff00aa'); + const out = highlightFirstSlashToken('/exit', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/exit'); }); @@ -68,7 +68,7 @@ describe('highlightFirstSlashToken', () => { // Simulate pi-tui Editor inserting an inverse-video cursor marker // somewhere after the slash token. const line = '/help x\u001B[7m \u001B[0m'; - const out = highlightFirstSlashToken(line, '#ff00aa'); + const out = highlightFirstSlashToken(line, 'primary'); expect(out).toBeDefined(); // Stripped visible content unchanged expect(strip(out!)).toBe(strip(line)); @@ -77,11 +77,11 @@ describe('highlightFirstSlashToken', () => { }); it('only paints the first token, not other slashes further along', () => { - const out = highlightFirstSlashToken('/a /b', '#ff00aa'); + const out = highlightFirstSlashToken('/a /b', 'primary'); expect(out).toBeDefined(); // Count the SGR opens — should be exactly one for /a. const opens = (out!.match(/\u001B\[[0-9;]+m/g) ?? []).length; - expect(opens).toBeGreaterThanOrEqual(2); // chalk hex+bold open and reset(s) + expect(opens).toBeGreaterThanOrEqual(2); // chalk bold+fg open and reset(s) // /b should remain plain — the substring " /b" exists verbatim. expect(out!).toContain(' /b'); }); diff --git a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts index f2a43aabd..8e7214e11 100644 --- a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts +++ b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts @@ -5,9 +5,6 @@ import { renderDiffLines, renderDiffLinesClustered, } from '#/tui/components/media/diff-preview'; -import { getColorPalette } from '#/tui/theme/colors'; - -const COLORS = getColorPalette('dark'); function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -46,7 +43,7 @@ describe('computeDiffLines', () => { describe('renderDiffLines', () => { it('does not show removed count for suppressed trailing deletes', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, true, 1, 1); + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', true, 1, 1); const text = stripAnsi(output.join('\n')); expect(text).toContain('test.ts'); expect(text).not.toContain('-2'); @@ -59,7 +56,7 @@ describe('renderDiffLines', () => { }); it('shows removed count for complete diffs', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, false, 1, 1); + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', false, 1, 1); const text = stripAnsi(output.join('\n')); expect(text).toContain('-2'); expect(text).toContain('C'); @@ -69,7 +66,7 @@ describe('renderDiffLines', () => { describe('renderDiffLinesClustered', () => { it('renders header with file path and counts', () => { - const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts', COLORS); + const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts'); const text = stripAnsi(out[0]!); expect(text).toContain('+1'); expect(text).toContain('-1'); @@ -77,7 +74,7 @@ describe('renderDiffLinesClustered', () => { }); it('returns header only when there are no changes', () => { - const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts', COLORS); + const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts'); expect(out).toHaveLength(1); expect(stripAnsi(out[0]!)).toContain('foo.ts'); }); @@ -87,7 +84,7 @@ describe('renderDiffLinesClustered', () => { const oldText = ['L1', 'L2', 'L3', 'L4', 'L5'].join('\n'); const newText = ['L1', 'L2', 'L3X', 'L4', 'L5'].join('\n'); const text = stripAnsi( - renderDiffLinesClustered(oldText, newText, 'f.ts', COLORS, { contextLines: 1 }).join('\n'), + renderDiffLinesClustered(oldText, newText, 'f.ts', { contextLines: 1 }).join('\n'), ); expect(text).toContain('L2'); expect(text).toContain('L3'); @@ -104,7 +101,7 @@ describe('renderDiffLinesClustered', () => { newLines[1] = 'L2X'; // change near top newLines[28] = 'L29X'; // change near bottom const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, }).join('\n'), ); @@ -121,7 +118,7 @@ describe('renderDiffLinesClustered', () => { const newLines = oldLines.slice(); newLines[2] = 'L3X'; newLines[5] = 'L6X'; // gap of 2 lines between change indices 2 and 5 → merges with contextLines=2 (mergeGap=4) - const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, }).join('\n'); const text = stripAnsi(out); @@ -144,7 +141,6 @@ describe('renderDiffLinesClustered', () => { oldLines.join('\n'), newLines.join('\n'), 'big.ts', - COLORS, { contextLines: 3, maxLines: 10, @@ -167,7 +163,7 @@ describe('renderDiffLinesClustered', () => { newLines[20] = 'L21X'; newLines[40] = 'L41X'; const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, maxLines: 6, }).join('\n'), diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index 47d0836a9..929a02e37 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; @@ -19,7 +18,7 @@ describe('AssistantMessageComponent', () => { }); it('uses the stable status bullet without stealing content width', () => { - const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + const component = new AssistantMessageComponent(); component.updateContent('abcdef'); @@ -31,7 +30,7 @@ describe('AssistantMessageComponent', () => { it('renders unknown markdown fence languages as plain text without stderr noise', () => { const stderr = captureProcessWrite('stderr'); try { - const theme = createMarkdownTheme(darkColors); + const theme = createMarkdownTheme(); expect(theme.highlightCode?.('hello\nworld', 'abcxyz')).toEqual(['hello', 'world']); expect(stderr.text()).not.toContain('Could not find the language'); } finally { @@ -40,7 +39,7 @@ describe('AssistantMessageComponent', () => { }); it('preserves literal hook result XML in normal assistant text', () => { - const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + const component = new AssistantMessageComponent(); component.updateContent('\n{}\n'); diff --git a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts index 89def6690..7a81f6f27 100644 --- a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,30 +9,21 @@ function strip(text: string): string { describe('BackgroundAgentStatusComponent', () => { it('renders started/completed with the shared bullet and failed with a red x marker', () => { - const started = new BackgroundAgentStatusComponent( - { - phase: 'started', - headline: 'explore agent started in background', - detail: 'Explore project structure', - }, - darkColors, - ); - const completed = new BackgroundAgentStatusComponent( - { - phase: 'completed', - headline: 'explore agent completed in background', - detail: 'Explore project structure', - }, - darkColors, - ); - const failed = new BackgroundAgentStatusComponent( - { - phase: 'failed', - headline: 'explore agent failed in background', - detail: 'Explore project structure · boom', - }, - darkColors, - ); + const started = new BackgroundAgentStatusComponent({ + phase: 'started', + headline: 'explore agent started in background', + detail: 'Explore project structure', + }); + const completed = new BackgroundAgentStatusComponent({ + phase: 'completed', + headline: 'explore agent completed in background', + detail: 'Explore project structure', + }); + const failed = new BackgroundAgentStatusComponent({ + phase: 'failed', + headline: 'explore agent failed in background', + detail: 'Explore project structure · boom', + }); const startedLines = started.render(120).map((line) => strip(line).trimEnd()); const completedLines = completed.render(120).map((line) => strip(line).trimEnd()); diff --git a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts index 05d919180..a5a2102c7 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers'; -import { darkColors } from '#/tui/theme/colors'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -11,9 +10,9 @@ function strip(lines: string[]): string { describe('buildGoalMarker', () => { it('builds lifecycle markers for paused / resumed / blocked', () => { - const paused = buildGoalMarker({ kind: 'lifecycle', status: 'paused' } as GoalChange, darkColors, false); - const resumed = buildGoalMarker({ kind: 'lifecycle', status: 'active' } as GoalChange, darkColors, false); - const blocked = buildGoalMarker({ kind: 'lifecycle', status: 'blocked' } as GoalChange, darkColors, false); + const paused = buildGoalMarker({ kind: 'lifecycle', status: 'paused' } as GoalChange, false); + const resumed = buildGoalMarker({ kind: 'lifecycle', status: 'active' } as GoalChange, false); + const blocked = buildGoalMarker({ kind: 'lifecycle', status: 'blocked' } as GoalChange, false); expect(strip(paused!.render(80))).toContain('Goal paused'); expect(strip(resumed!.render(80))).toContain('Goal resumed'); expect(strip(blocked!.render(80))).toContain('Goal blocked'); @@ -21,14 +20,14 @@ describe('buildGoalMarker', () => { it('returns null for a completion change (it posts its own message)', () => { expect( - buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, darkColors, false), + buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, false), ).toBeNull(); }); }); describe('GoalMarkerComponent', () => { it('hides the reason until expanded, with a ctrl+o hint', () => { - const marker = new GoalMarkerComponent('Goal: no progress', 'still spinning', darkColors, darkColors.warning); + const marker = new GoalMarkerComponent('Goal: no progress', 'still spinning', 'warning'); const collapsed = strip(marker.render(80)); expect(collapsed).toContain('Goal: no progress'); expect(collapsed).toContain('(ctrl+o)'); @@ -41,7 +40,7 @@ describe('GoalMarkerComponent', () => { }); it('renders a single line when there is no reason', () => { - const marker = new GoalMarkerComponent('Goal paused', undefined, darkColors, darkColors.textDim); + const marker = new GoalMarkerComponent('Goal paused', undefined, 'textDim'); expect(marker.render(80)).toHaveLength(1); expect(strip(marker.render(80))).not.toContain('(ctrl+o)'); }); diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index e56bde8c2..0436e6681 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -44,7 +44,7 @@ function goal(overrides: Partial = {}): GoalSnapshot { } function lines(g: GoalSnapshot): string { - return strip(buildGoalReportLines({ colors: darkColors, goal: g })); + return strip(buildGoalReportLines(g)); } describe('buildGoalReportLines', () => { @@ -101,14 +101,14 @@ describe('buildGoalReportLines', () => { describe('GoalSetMessageComponent', () => { it('renders a marker-style lifecycle line without repeating the objective', () => { - const rendered = new GoalSetMessageComponent(darkColors).render(60); + const rendered = new GoalSetMessageComponent().render(60); // Leading blank line separates it from the line above. expect(rendered[0]).toBe(''); expect(strip(rendered)).toBe('\n● Goal set'); }); it('renders the marker and label in the primary accent', () => { - const rendered = new GoalSetMessageComponent(darkColors).render(60); + const rendered = new GoalSetMessageComponent().render(60); expect(rendered[1]).toBe( chalk.hex(darkColors.primary).bold(STATUS_BULLET) + @@ -119,7 +119,7 @@ describe('GoalSetMessageComponent', () => { describe('UpcomingGoalAddedMessageComponent', () => { it('renders the upcoming-goal confirmation like the goal-set lifecycle line', () => { - const rendered = new UpcomingGoalAddedMessageComponent(darkColors).render(80); + const rendered = new UpcomingGoalAddedMessageComponent().render(80); expect(strip(rendered)).toBe( '\n● Upcoming goal added. It will start after the current goal is complete.', @@ -135,7 +135,7 @@ describe('UpcomingGoalAddedMessageComponent', () => { describe('GoalStatusMessageComponent', () => { it('adds a blank line before the status box', () => { - const rendered = new GoalStatusMessageComponent(goal(), darkColors).render(80); + const rendered = new GoalStatusMessageComponent(goal()).render(80); expect(rendered[0]).toBe(''); expect(strip([rendered[1]!])).toContain('╭ Goal · active '); @@ -145,7 +145,7 @@ describe('GoalStatusMessageComponent', () => { describe('GoalCompletionMessageComponent', () => { it('renders the completion headline in green and keeps the stats line indented', () => { const message = '✓ Goal complete.\nWorked 1 turn over 2m28s, using 766.9k tokens.'; - const rendered = new GoalCompletionMessageComponent(message, darkColors).render(80); + const rendered = new GoalCompletionMessageComponent(message).render(80); expect(rendered[0]).toBe(''); expect(rendered[1]?.trimEnd()).toBe( diff --git a/apps/kimi-code/test/tui/components/messages/notice.test.ts b/apps/kimi-code/test/tui/components/messages/notice.test.ts index a7a9f05e3..0003ac01c 100644 --- a/apps/kimi-code/test/tui/components/messages/notice.test.ts +++ b/apps/kimi-code/test/tui/components/messages/notice.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { NoticeMessageComponent } from '#/tui/components/messages/status-message'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -12,7 +11,6 @@ describe('NoticeComponent', () => { const component = new NoticeMessageComponent( 'Plan mode: ON', 'Plan will be created here: /tmp/plans/test-plan.md', - darkColors, ); const lines = component.render(120).map((line) => strip(line)); diff --git a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts index f59121c4b..128aa2cdd 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts @@ -4,7 +4,6 @@ import { ShellExecutionComponent, shellExecutionResultRenderer, } from '#/tui/components/messages/shell-execution'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -14,7 +13,6 @@ describe('ShellExecutionComponent', () => { it('renders shell command previews with prompt indentation', () => { const component = new ShellExecutionComponent({ command: 'printf hello\nprintf world', - colors: darkColors, showCommand: true, }); @@ -31,7 +29,6 @@ describe('ShellExecutionComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - colors: darkColors, }); const collapsedOutput = collapsed.render(100).map(strip).join('\n'); @@ -46,7 +43,6 @@ describe('ShellExecutionComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - colors: darkColors, expanded: true, }); @@ -60,7 +56,6 @@ describe('ShellExecutionComponent', () => { const cmd = Array.from({ length: 20 }, (_, i) => `step${String(i + 1)}`).join('\n'); const component = new ShellExecutionComponent({ command: cmd, - colors: darkColors, showCommand: true, commandPreviewLines: undefined, }); @@ -77,7 +72,6 @@ describe('ShellExecutionComponent', () => { output: 'hello\n\n\n', // 1 content line + 2 trailing empty lines is_error: false, }, - colors: darkColors, }); const output = component.render(100).map(strip).join('\n'); @@ -92,7 +86,6 @@ describe('ShellExecutionComponent', () => { output: 'a\n\nb\n\n\n', // 1 internal empty line + 2 trailing empty lines is_error: false, }, - colors: darkColors, }); const output = component.render(100).map(strip).join('\n'); @@ -108,7 +101,6 @@ describe('ShellExecutionComponent', () => { output: 'x'.repeat(500), is_error: false, }, - colors: darkColors, }); const out = strip(component.render(20).join('\n')); @@ -132,7 +124,7 @@ describe('ShellExecutionComponent', () => { output: 'ok', is_error: false, }, - { expanded: false, colors: darkColors }, + { expanded: false }, ); const rendered = components @@ -155,7 +147,7 @@ describe('ShellExecutionComponent', () => { output: 'ok', is_error: false, }, - { expanded: true, colors: darkColors }, + { expanded: true }, ); const rendered = components diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 994fbf525..ca67aded7 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildStatusReportLines } from '#/tui/components/messages/status-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,7 +9,6 @@ function strip(text: string): string { describe('status panel report lines', () => { it('formats runtime status, context, and managed usage without account or AGENTS.md rows', () => { const lines = buildStatusReportLines({ - colors: darkColors, version: '1.2.3', model: 'k2', workDir: '/tmp/project', @@ -72,7 +70,6 @@ describe('status panel report lines', () => { it('falls back to app state and shows status load errors as warnings', () => { const lines = buildStatusReportLines({ - colors: darkColors, version: '1.2.3', model: '', workDir: '/tmp/project', diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index 64c1b6955..7f1385ffe 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from 'vitest'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -13,7 +12,7 @@ const longThinking = ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'lin describe('ThinkingComponent', () => { it('shows the live spinner header before thinking content', () => { - const component = new ThinkingComponent('working it out', darkColors, true, 'live'); + const component = new ThinkingComponent('working it out', true, 'live'); const out = strip(component.render(80).join('\n')); expect(out).toContain('⠋ thinking...'); @@ -23,7 +22,7 @@ describe('ThinkingComponent', () => { }); it('keeps live thinking height-limited to the tail', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); const out = strip(component.render(80).join('\n')); expect(out).not.toContain('line1'); @@ -37,7 +36,7 @@ describe('ThinkingComponent', () => { it('animates the live spinner and stops on finalize', () => { vi.useFakeTimers(); const requestRender = vi.fn(); - const component = new ThinkingComponent('step', darkColors, true, 'live', { + const component = new ThinkingComponent('step', true, 'live', { requestRender, } as unknown as TUI); @@ -55,7 +54,7 @@ describe('ThinkingComponent', () => { }); it('finalizes in place into a collapsed preview', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); component.finalize(); @@ -68,7 +67,7 @@ describe('ThinkingComponent', () => { }); it('expands and collapses after finalization', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); component.finalize(); component.setExpanded(true); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index b48f2eff5..7c76b63ab 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -3,8 +3,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; -import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; @@ -41,7 +39,6 @@ describe('ToolCallComponent', () => { output: 'content', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -62,7 +59,6 @@ describe('ToolCallComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -94,7 +90,6 @@ describe('ToolCallComponent', () => { output: reminderOutput, is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -120,7 +115,6 @@ describe('ToolCallComponent', () => { output: 'do not show', is_error: true, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -140,7 +134,6 @@ describe('ToolCallComponent', () => { output: 'first line\nnope', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -162,12 +155,11 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# File Plan\n\n1. Do the focused fix.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); expect(out).toContain('Current plan'); - expect(out).toContain('# File Plan'); + expect(out).toContain('File Plan'); expect(out).toContain('1. Do the focused fix.'); expect(out).not.toContain('Plan saved to: /tmp/plan.md'); }); @@ -180,9 +172,7 @@ describe('ToolCallComponent', () => { args: {}, }, undefined, - darkColors, undefined, - createMarkdownTheme(darkColors), ); // A fresh tool card only shows the 'Current plan' title; no plan box renders yet. @@ -209,9 +199,7 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); const collapsed = strip(component.render(100).join('\n')); @@ -233,9 +221,7 @@ describe('ToolCallComponent', () => { args: { command: 'echo hi' }, }, undefined, - darkColors, undefined, - createMarkdownTheme(darkColors), ); expect(component.setPlanExpanded(true)).toBe(false); @@ -255,9 +241,7 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); component.setExpanded(true); const out = strip(component.render(100).join('\n')); @@ -280,7 +264,6 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# Plan body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; @@ -304,7 +287,6 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; @@ -323,9 +305,7 @@ describe('ToolCallComponent', () => { output: 'User rejected the plan. Feedback:\n\nplease rethink step 2', is_error: false, }, - darkColors, undefined, - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -346,9 +326,7 @@ describe('ToolCallComponent', () => { output: 'Plan rejected by user. Plan mode remains active.', is_error: true, }, - darkColors, undefined, - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -377,7 +355,6 @@ describe('ToolCallComponent', () => { 'Do NOT edit files other than the plan file while plan mode is active.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -399,7 +376,6 @@ describe('ToolCallComponent', () => { output: 'Plan mode is already active. Use ExitPlanMode when the plan is ready.', is_error: true, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -422,7 +398,6 @@ describe('ToolCallComponent', () => { }), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -448,7 +423,6 @@ describe('ToolCallComponent', () => { ].join('\n'), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -469,7 +443,6 @@ describe('ToolCallComponent', () => { output: '1\tfoo\n2\tbar\n3\tbaz', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -487,7 +460,6 @@ describe('ToolCallComponent', () => { args: { path: longPath }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -508,8 +480,6 @@ describe('ToolCallComponent', () => { output: '1\tcontent', is_error: false, }, - darkColors, - undefined, undefined, '/tmp/proj-a', ); @@ -528,8 +498,6 @@ describe('ToolCallComponent', () => { args: { path: '/tmp/proj-ab/src/main.ts' }, }, undefined, - darkColors, - undefined, undefined, '/tmp/proj-a', ); @@ -547,7 +515,6 @@ describe('ToolCallComponent', () => { args: { path: 'foo.ts' }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -565,7 +532,6 @@ describe('ToolCallComponent', () => { args: { description: 'explore project xxx' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ @@ -628,7 +594,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -668,7 +633,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -712,7 +676,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect wrapping' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_wrapped', @@ -746,7 +709,6 @@ describe('ToolCallComponent', () => { args: { description: 'check failure' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_failed', @@ -794,7 +756,6 @@ describe('ToolCallComponent', () => { }, }, spawnSuccessResult, - darkColors, ); component.onSubagentSpawned({ agentId: 'agent-0', @@ -859,7 +820,6 @@ describe('ToolCallComponent', () => { args: { description: 'background agent A', run_in_background: true }, }, undefined, - darkColors, ); component.setBackgroundTaskTerminalStatus('lost'); // Now the spawn-success result lands. @@ -910,7 +870,6 @@ describe('ToolCallComponent', () => { args: { description: 'background agent 1', run_in_background: true }, }, spawnSuccessResult, - darkColors, ); // No spawn metadata was wired in — exactly the resume / backgrounded // case we are guarding against. @@ -928,7 +887,6 @@ describe('ToolCallComponent', () => { args: { description: 'X', run_in_background: true }, }, spawnSuccessResult, - darkColors, ); component.setSubagentMeta('agent-explicit', 'coder'); expect(component.getSubagentAgentId()).toBe('agent-explicit'); @@ -946,7 +904,6 @@ describe('ToolCallComponent', () => { output: 'agent_id: agent-fake\nstatus: running', is_error: false, }, - darkColors, ); expect(component.getSubagentAgentId()).toBeUndefined(); }); @@ -997,7 +954,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"foo.ts","content":"${escaped}`, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1026,7 +982,6 @@ describe('ToolCallComponent', () => { truncated: true, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1065,7 +1020,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1099,7 +1053,6 @@ describe('ToolCallComponent', () => { // No streamingArguments → finalized args; no result yet. }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); expect(out).toContain('line1'); @@ -1121,7 +1074,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"big.txt","content":"${escaped}"}`, }, undefined, - darkColors, ); expect(strip(component.render(100).join('\n'))).toContain('line25'); @@ -1147,7 +1099,6 @@ describe('ToolCallComponent', () => { streamingArguments: '{', }, undefined, - darkColors, ); const before = strip(component.render(100).join('\n')); expect(before).toContain('Using Write'); @@ -1174,7 +1125,6 @@ describe('ToolCallComponent', () => { streamingArguments: '{"file_path":"foo.ts","content":"a\\nb', }, undefined, - darkColors, ); // While streaming, body is rendered live from streamingArguments. expect(strip(component.render(100).join('\n'))).toMatch(/^\s*1\s+a\s*$/m); @@ -1201,7 +1151,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: Date.now(), }, undefined, - darkColors, ); expect(strip(component.render(100).join('\n'))).toContain('Preparing changes'); expect(strip(component.render(100).join('\n'))).not.toMatch(/^\s*\d+\s+[+-]\s/m); @@ -1230,7 +1179,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); @@ -1257,7 +1205,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); ui.requestRender.mockClear(); @@ -1280,7 +1227,6 @@ describe('ToolCallComponent', () => { output: 'Wrote big.txt', is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -1311,7 +1257,6 @@ describe('ToolCallComponent', () => { output: 'Wrote demo.abcxyz', is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 5e3883ca9..f7e5b5e3c 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -2,7 +2,6 @@ import { visibleWidth } from '@earendil-works/pi-tui'; import { describe, expect, it } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -11,7 +10,6 @@ function strip(text: string): string { describe('UsagePanelComponent', () => { it('formats session, context, and managed usage sections', () => { const lines = buildUsageReportLines({ - colors: darkColors, sessionUsage: { byModel: { kimi: { @@ -46,7 +44,7 @@ describe('UsagePanelComponent', () => { }); it('wraps preformatted usage lines in a bordered panel', () => { - const component = new UsagePanelComponent(['Session usage'], darkColors.primary); + const component = new UsagePanelComponent(['Session usage'], 'primary'); const output = component.render(80).map(strip); expect(output[0]).toContain(' Usage '); @@ -55,7 +53,7 @@ describe('UsagePanelComponent', () => { it('truncates lines wider than the terminal so the panel never overflows', () => { const longLine = 'error: ' + 'x'.repeat(200); - const component = new UsagePanelComponent([longLine], darkColors.primary); + const component = new UsagePanelComponent([longLine], 'primary'); const width = 60; const output = component.render(width); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index ab1d5752b..60029b40c 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -11,7 +11,6 @@ describe('UserMessageComponent', () => { it('renders video placeholders as plain text, not inline image escapes', () => { const component = new UserMessageComponent( 'please inspect [video #1 sample.mov]', - darkColors, [], ); diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index db34ee895..9816d352b 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -134,7 +134,7 @@ describe('FooterComponent — context NaN resilience', () => { ); const primaryIndex = out.indexOf(hexToSgr(darkColors.primary)); - const statusIndex = out.indexOf(hexToSgr(darkColors.status)); + const statusIndex = out.indexOf(hexToSgr(darkColors.textDim)); const badgeIndex = out.indexOf('[PR#6]'); expect(statusIndex).toBeGreaterThanOrEqual(0); expect(primaryIndex).toBeGreaterThanOrEqual(0); diff --git a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts index d9692366a..52f94430b 100644 --- a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { KimiSlashCommand } from '#/tui/commands/index'; import { HelpPanelComponent } from '#/tui/components/dialogs/help-panel'; -import { darkColors } from '#/tui/theme/colors'; function cmd(name: string, description: string, aliases: string[] = []): KimiSlashCommand { return { @@ -20,7 +19,6 @@ describe('HelpPanelComponent', () => { it('renders keyboard shortcuts + slash commands sections', () => { const panel = new HelpPanelComponent({ commands: [cmd('exit', 'Exit', ['quit', 'q'])], - colors: darkColors, onClose: () => {}, }); const out = strip(panel.render(80).join('\n')); @@ -42,7 +40,6 @@ describe('HelpPanelComponent', () => { cmd('alpha', 'A'), cmd('mcp-config', 'M'), ], - colors: darkColors, onClose: () => {}, }); const out = strip(panel.render(80).join('\n')); @@ -60,7 +57,6 @@ describe('HelpPanelComponent', () => { const onClose = vi.fn(); const panel = new HelpPanelComponent({ commands: [], - colors: darkColors, onClose, }); panel.handleInput('\u001B'); // Esc @@ -71,7 +67,6 @@ describe('HelpPanelComponent', () => { const onClose = vi.fn(); const panel = new HelpPanelComponent({ commands: [], - colors: darkColors, onClose, }); panel.handleInput('q'); @@ -83,7 +78,6 @@ describe('HelpPanelComponent', () => { const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, `Desc ${String(i)}`)); const panel = new HelpPanelComponent({ commands: many, - colors: darkColors, onClose: () => {}, maxVisible: 6, }); @@ -95,7 +89,6 @@ describe('HelpPanelComponent', () => { const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, 'd')); const panel = new HelpPanelComponent({ commands: many, - colors: darkColors, onClose: () => {}, maxVisible: 6, }); diff --git a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts index 9b35a481f..e5efdba04 100644 --- a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts +++ b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest'; import { PlanBoxComponent } from '#/tui/components/messages/plan-box'; -import { darkColors } from '#/tui/theme/colors'; -import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; const ESC = String.fromCodePoint(0x1b); const BEL = String.fromCodePoint(0x07); @@ -15,11 +13,9 @@ function strip(text: string): string { .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); } -const theme = createMarkdownTheme(darkColors); - describe('PlanBoxComponent', () => { it('falls back to bare " plan " title when no path is provided', () => { - const box = new PlanBoxComponent('# Hello', theme, darkColors.success); + const box = new PlanBoxComponent('# Hello', 'success'); const out = strip(box.render(60).join('\n')); const top = out.split('\n')[0]!; expect(top).toContain('┌ plan '); @@ -29,8 +25,7 @@ describe('PlanBoxComponent', () => { it('renders " plan: " in the top border without the directory prefix', () => { const box = new PlanBoxComponent( '# Hello', - theme, - darkColors.success, + 'success', '/tmp/projects/foo/.kimi-code/plans/very-long-slug-name.md', ); const out = strip(box.render(80).join('\n')); @@ -41,8 +36,8 @@ describe('PlanBoxComponent', () => { }); it('renders a status chip in the top border', () => { - const box = new PlanBoxComponent('# Hello', theme, darkColors.success, undefined, { - status: { label: 'Rejected', colorHex: darkColors.error }, + const box = new PlanBoxComponent('# Hello', 'success', undefined, { + status: { label: 'Rejected', colorToken: 'error' }, }); const out = strip(box.render(60).join('\n')); const top = out.split('\n')[0]!; @@ -52,11 +47,10 @@ describe('PlanBoxComponent', () => { it('keeps path status title to the basename without leaking directories', () => { const box = new PlanBoxComponent( '# Hello', - theme, - darkColors.success, + 'success', '/tmp/projects/foo/.kimi-code/plans/rejected-plan.md', { - status: { label: 'Rejected', colorHex: darkColors.error }, + status: { label: 'Rejected', colorToken: 'error' }, }, ); const out = strip(box.render(80).join('\n')); @@ -67,7 +61,7 @@ describe('PlanBoxComponent', () => { }); it('wraps the basename in an OSC 8 hyperlink targeting file://', () => { - const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md'); + const box = new PlanBoxComponent('# Hello', 'success', '/tmp/plan.md'); const top = box.render(60)[0]!; expect(top).toContain(`${ESC}]8;;file:///tmp/plan.md${BEL}plan.md${ESC}]8;;${BEL}`); // After stripping OSC + CSI, visible width must respect the requested render width. @@ -75,14 +69,14 @@ describe('PlanBoxComponent', () => { }); it('skips the hyperlink for non-absolute paths but still shows the basename', () => { - const box = new PlanBoxComponent('# Hello', theme, darkColors.success, 'relative/plan.md'); + const box = new PlanBoxComponent('# Hello', 'success', 'relative/plan.md'); const top = box.render(60)[0]!; expect(top).not.toContain(`${ESC}]8;`); expect(strip(top)).toContain(' plan: plan.md '); }); it('degrades to bare " plan " when even the basename does not fit', () => { - const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md'); + const box = new PlanBoxComponent('# Hello', 'success', '/tmp/plan.md'); const out = strip(box.render(14).join('\n')); const top = out.split('\n')[0]!; expect(top).toContain(' plan '); @@ -91,7 +85,7 @@ describe('PlanBoxComponent', () => { it('renders all lines when content fits under maxContentLines', () => { const plan = Array.from({ length: 5 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); - const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, { + const box = new PlanBoxComponent(plan, 'success', undefined, { maxContentLines: 20, }); const out = strip(box.render(80).join('\n')); @@ -102,7 +96,7 @@ describe('PlanBoxComponent', () => { it('truncates content over maxContentLines with a footer inside the box', () => { const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); - const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, { + const box = new PlanBoxComponent(plan, 'success', undefined, { maxContentLines: 10, }); const rendered = box.render(80); @@ -121,7 +115,7 @@ describe('PlanBoxComponent', () => { it('renders the full plan when expanded is true, ignoring maxContentLines', () => { const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); - const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, { + const box = new PlanBoxComponent(plan, 'success', undefined, { maxContentLines: 10, expanded: true, }); diff --git a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts index fbb5e4979..e3776e71c 100644 --- a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { QueuePaneComponent } from '#/tui/components/panes/queue-pane'; -import { darkColors } from '#/tui/theme/colors'; function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,7 +9,6 @@ function stripAnsi(text: string): string { describe('QueuePaneComponent', () => { it('renders queued messages with the steer hint', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: true, @@ -29,7 +27,6 @@ describe('QueuePaneComponent', () => { it('renders compaction hint when waiting for compaction', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: true, isStreaming: false, canSteerImmediately: true, @@ -43,7 +40,6 @@ describe('QueuePaneComponent', () => { it('omits the steer hint when immediate steering is disabled', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: false, diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index e3fa47500..87a150115 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; -import { getColorPalette } from '#/tui/theme/colors'; +import { getBuiltInPalette } from '#/tui/theme'; import { readGoalQueue, removeGoalQueueItem, restoreGoalQueueItem } from '#/tui/goal-queue-store'; vi.mock('#/tui/goal-queue-store', () => ({ @@ -58,7 +58,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { permissionMode: 'auto', }, queuedMessages: [], - theme: { colors: getColorPalette('dark') }, + theme: { palette: getBuiltInPalette('dark') }, toolOutputExpanded: false, todoPanel: { getTodos: vi.fn(() => []) }, transcriptContainer: { addChild: vi.fn() }, diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 25a552631..73b0b6451 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -55,8 +55,7 @@ describe('createTUIState', () => { expect(state.editor).toBeDefined(); expect(state.footer).toBeDefined(); expect(state.todoPanel).toBeDefined(); - expect(state.theme.colors).toBeDefined(); - expect(state.theme.markdownTheme).toBeDefined(); + expect(state.theme.palette).toBeDefined(); // App state is cloned from initialAppState, not reused by reference. expect(state.appState).not.toBe(opts.initialAppState); diff --git a/apps/kimi-code/test/tui/easter-eggs/dance.test.ts b/apps/kimi-code/test/tui/easter-eggs/dance.test.ts index 9373018f2..5406a2998 100644 --- a/apps/kimi-code/test/tui/easter-eggs/dance.test.ts +++ b/apps/kimi-code/test/tui/easter-eggs/dance.test.ts @@ -168,7 +168,7 @@ describe('installRainbowDance', () => { const dispose = installRainbowDance(requestRender); const host = { showStatus: vi.fn(), - state: { theme: { colors: darkColors } }, + state: { theme: { palette: darkColors } }, } as unknown as SlashCommandHost; tryHandleDanceCommand(host, { name: 'dance', args: 'on' }); @@ -202,7 +202,7 @@ function makeHost(): { host: SlashCommandHost; calls: DanceCall[]; status: strin setRainbowDance(rainbowDance); const host = { showStatus: (msg: string) => status.push(msg), - state: { theme: { colors: darkColors } }, + state: { theme: { palette: darkColors } }, } as unknown as SlashCommandHost; return { host, calls, status }; } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index f9d20f7ed..1b7daeac7 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -102,7 +102,6 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 3b5a0c14a..b7809ffb0 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -63,7 +63,6 @@ const MIGRATION_PLAN: MigrationPlan = { function makeStartupInput( cliOptions: Partial = {}, tuiConfig: Partial = {}, - resolvedTheme: KimiTUIStartupInput["resolvedTheme"] = "dark", ): KimiTUIStartupInput { return { cliOptions: { @@ -87,7 +86,6 @@ function makeStartupInput( }, version: "0.0.0-test", workDir: "/tmp/proj-a", - resolvedTheme, }; } @@ -365,7 +363,7 @@ describe("KimiTUI startup", () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }, "dark"), + makeStartupInput({}, { theme: "auto" }), ) as unknown as ThemeTrackingDriver; const { listeners, write, addInputListener } = captureInputListeners(driver); @@ -381,17 +379,14 @@ describe("KimiTUI startup", () => { expect(listeners[0]?.(TERMINAL_THEME_LIGHT)).toEqual({ consume: true }); expect(write).toHaveBeenCalledWith(OSC11_QUERY); expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("dark"); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(DARK_OSC11_REPORT)).toEqual({ consume: true }); expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("dark"); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(LIGHT_OSC11_REPORT)).toEqual({ consume: true }); expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("light"); expect(driver.state.ui.requestRender).toHaveBeenCalled(); }); @@ -410,7 +405,7 @@ describe("KimiTUI startup", () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }, "dark"), + makeStartupInput({}, { theme: "auto" }), ) as unknown as ThemeTrackingDriver; const { write, removeInputListener } = captureInputListeners(driver); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index ee8f57849..ceda673a0 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -47,7 +47,6 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index d565e160e..9d630a26d 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -31,7 +31,6 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-signals', - resolvedTheme: 'dark', }; } diff --git a/apps/kimi-code/test/tui/task-output-viewer.test.ts b/apps/kimi-code/test/tui/task-output-viewer.test.ts index 8c4879369..a7cbfe0df 100644 --- a/apps/kimi-code/test/tui/task-output-viewer.test.ts +++ b/apps/kimi-code/test/tui/task-output-viewer.test.ts @@ -63,7 +63,6 @@ function makeViewer(opts: { taskId: opts.taskInfo?.taskId ?? 'bash-aaaaaaaa', info: opts.taskInfo ?? info(), output: opts.output, - colors: darkColors, onClose: opts.onClose ?? (() => {}), }, fakeTerminal(opts.rows ?? 30, opts.columns ?? 120), @@ -219,7 +218,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: makeOutput(50), - colors: darkColors, onClose: () => {}, }); const out = strip(viewer.render(120).join('\n')); @@ -235,7 +233,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: makeOutput(200), - colors: darkColors, onClose: () => {}, }); const out = strip(viewer.render(120).join('\n')); @@ -252,7 +249,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: same, - colors: darkColors, onClose: () => {}, }); const after = strip(viewer.render(120).join('\n')); diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index a27c495cf..3f0e95fa0 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -64,7 +64,6 @@ function makeProps(overrides: Partial = {}): TasksBrowserProp tailOutput: undefined, tailLoading: false, flashMessage: undefined, - colors: darkColors, onSelect: vi.fn(), onToggleFilter: vi.fn(), onRefresh: vi.fn(), diff --git a/apps/kimi-code/test/tui/terminal-theme.test.ts b/apps/kimi-code/test/tui/terminal-theme.test.ts index 055378376..1f1888223 100644 --- a/apps/kimi-code/test/tui/terminal-theme.test.ts +++ b/apps/kimi-code/test/tui/terminal-theme.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { TUIState } from "#/tui/kimi-tui"; -import { darkColors, lightColors, getColorPalette } from "#/tui/theme/colors"; -import { createThemeStyles } from "#/tui/theme/styles"; +import { darkColors, lightColors } from "#/tui/theme/colors"; +import { getBuiltInPalette } from "#/tui/theme"; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -169,29 +169,9 @@ describe('ColorPalette warning token', () => { }); it('resolves the correct palette by theme name', () => { - expect(getColorPalette('dark')).toBe(darkColors); - expect(getColorPalette('light')).toBe(lightColors); + expect(getBuiltInPalette('dark')).toBe(darkColors); + expect(getBuiltInPalette('light')).toBe(lightColors); }); }); -describe('ThemeStyles warning helper', () => { - it('wraps text and includes the input', () => { - const styles = createThemeStyles(darkColors); - const result = styles.warning('test'); - expect(result).toContain('test'); - }); - - it('is a function that returns a string', () => { - const darkStyles = createThemeStyles(darkColors); - expect(typeof darkStyles.warning).toBe('function'); - expect(typeof darkStyles.warning('hello')).toBe('string'); - }); - it('creates independent style sets per palette', () => { - const darkStyles = createThemeStyles(darkColors); - const lightStyles = createThemeStyles(lightColors); - expect(darkStyles.colors.warning).toBe(darkColors.warning); - expect(lightStyles.colors.warning).toBe(lightColors.warning); - expect(darkStyles.colors.warning).not.toBe(lightStyles.colors.warning); - }); -}); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e2b6f114f..8ecc9304e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -69,6 +69,7 @@ const config = withMermaid(defineConfig({ { text: 'Kimi Datasource', link: '/zh/customization/datasource' }, { text: 'Agent 与子 Agent', link: '/zh/customization/agents' }, { text: 'Hooks', link: '/zh/customization/hooks' }, + { text: '自定义主题', link: '/zh/customization/themes' }, ], }, ], @@ -146,6 +147,7 @@ const config = withMermaid(defineConfig({ { text: 'Kimi Datasource', link: '/en/customization/datasource' }, { text: 'Agents and Subagents', link: '/en/customization/agents' }, { text: 'Hooks', link: '/en/customization/hooks' }, + { text: 'Custom Themes', link: '/en/customization/themes' }, ], }, ], diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index bf38aa8ac..0f69e6a2e 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -249,7 +249,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | Field | Type | Default | Description | | --- | --- | --- | --- | -| `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, or `light` | +| `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes) | | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent | | `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` | @@ -257,7 +257,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c ```toml # ~/.kimi-code/tui.toml -theme = "auto" # "auto" | "dark" | "light" +theme = "auto" # "auto" | "dark" | "light" | custom theme name [editor] command = "" # empty uses $VISUAL / $EDITOR diff --git a/docs/en/customization/themes.md b/docs/en/customization/themes.md new file mode 100644 index 000000000..2dd4c78c3 --- /dev/null +++ b/docs/en/customization/themes.md @@ -0,0 +1,100 @@ +# Custom Themes + +Kimi Code CLI ships with three built-in color schemes: `dark`, `light`, and `auto` (picks light/dark by detecting the terminal background). Beyond those, you can define your own colors in a JSON file — drop it into the themes directory and it shows up in `/theme` alongside the built-in ones. + +## Create a theme + +Add a `.json` file to the themes directory: + +- `~/.kimi-code/themes/` +- or `$KIMI_CODE_HOME/themes/` when the `KIMI_CODE_HOME` environment variable is set + +Create the directory if it does not exist. **The filename is the theme name**: `ember.json` appears in `/theme` as `Custom: ember`. + +A minimal theme only sets the colors you want to change; the rest fall back to `dark`: + +```json +{ + "name": "ember", + "colors": { + "primary": "#83A598", + "accent": "#FE8019" + } +} +``` + +Fields: + +- `name` (required): the theme identifier. +- `displayName` (optional): a human-readable name. +- `colors` (optional): the color tokens to override, each a 6-digit hex value (e.g. `#FE8019`). + +> Tip: copying a full example like the one below and tweaking it is the fastest way to start. + +## Color tokens + +These are the tokens you can set under `colors`. Each note says where the token is actually used in the UI, so you can predict what a change affects: + +| Token | What it controls | +| --- | --- | +| `primary` | The most-used color. Links, inline code, the selected item in nearly every dialog, the focused editor border, plan/"running" badges, spinners | +| `accent` | Secondary highlight. Approval `▶` prefix, device-code box, image placeholder, BTW / queue panes, registry import | +| `text` | Body text. Dialog bodies, todo titles, footer model label, Markdown headings, assistant/tool message bullets, list bullets | +| `textStrong` | Emphasized / bold text. Input dialogs, status messages | +| `textDim` | Secondary, dimmed text (the most widely used dim shade). Thinking, hints, descriptions, completed todos, Markdown quotes, footer status bar (cwd, git badge) | +| `textMuted` | Faintest text. Counters, scroll info, descriptions, Markdown link URLs, code-block borders | +| `border` | Borders. Pane and editor borders, Markdown horizontal rule | +| `borderFocus` | Focus / attention border (currently only the approval panel) | +| `success` | Success state. `✓`, "enabled", completed | +| `warning` | Warning state. auto/yolo badges, stale markers, plan-mode hint | +| `error` | Error state. Error messages, failed tool output | +| `diffAdded` | Diff added lines | +| `diffRemoved` | Diff removed lines | +| `diffAddedStrong` | Diff intra-line changed words, added (bold) | +| `diffRemovedStrong` | Diff intra-line changed words, removed (bold) | +| `diffGutter` | Diff line-number gutter | +| `diffMeta` | Diff meta / hunk headers | +| `roleUser` | User message bullet and text, skill-activation name | + +Any token you omit falls back to its `dark` value, so partial themes are fine: + +```json +{ + "name": "just-blue", + "colors": { + "primary": "#3B82F6", + "roleUser": "#3B82F6" + } +} +``` + +## Select a theme + +Two ways: + +1. **The `/theme` command** (recommended): opens the theme picker, where custom themes appear as `Custom: `. The picker **re-scans the themes directory every time it opens**, so a theme file you just added shows up **without a restart**. +2. **`tui.toml`**: set `theme` to your theme name: + + ```toml + # ~/.kimi-code/tui.toml + theme = "ember" + ``` + +## What happens on errors + +Custom themes are designed to never get in your way: + +- **An invalid color value** (not `#` followed by 6 hex digits): that one entry is skipped with a warning; the rest of the colors still apply. +- **An unrecognized token**: ignored, with no effect on other colors. +- **A missing file or malformed JSON**: silently falls back to `dark`. + +## Editing the active theme + +If you edit the theme file that is **currently active**, the change is not reloaded automatically. To apply the new colors: + +- run `/reload-tui` — it reloads `tui.toml` and re-applies the current theme (including re-reading the theme file); or +- switch to another theme in `/theme` and back. + +::: warning Note +Re-selecting the **same** theme in `/theme` does not reload it (you get a "Theme unchanged" message). To reload changes to the active theme, use one of the two methods above. +::: diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 8fd50d9a1..00f737ef1 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -249,7 +249,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light` | +| `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light`,或[自定义主题](../customization/themes)的名字 | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | @@ -257,7 +257,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod ```toml # ~/.kimi-code/tui.toml -theme = "auto" # "auto" | "dark" | "light" +theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 [editor] command = "" # 留空则使用 $VISUAL / $EDITOR diff --git a/docs/zh/customization/themes.md b/docs/zh/customization/themes.md new file mode 100644 index 000000000..f4ff5c33a --- /dev/null +++ b/docs/zh/customization/themes.md @@ -0,0 +1,100 @@ +# 自定义主题 + +Kimi Code CLI 内置了 `dark`、`light` 和 `auto`(跟随终端背景自动选择明暗)三种配色。除此之外,你还可以用一个 JSON 文件定义自己的配色——把它放进主题目录,就能在 `/theme` 里像内置主题一样选用。 + +## 创建一个主题 + +在主题目录下新建一个 `.json` 文件即可。主题目录是: + +- `~/.kimi-code/themes/` +- 如果设置了 `KIMI_CODE_HOME` 环境变量,则是 `$KIMI_CODE_HOME/themes/` + +目录不存在就自己建一个。**文件名就是主题名**:`ember.json` 会在 `/theme` 里显示为 `Custom: ember`。 + +一个最小的主题只需要写你想改的颜色,其余自动沿用 `dark`: + +```json +{ + "name": "ember", + "colors": { + "primary": "#83A598", + "accent": "#FE8019" + } +} +``` + +字段说明: + +- `name`(必填):主题的标识名。 +- `displayName`(可选):人类可读的名字。 +- `colors`(可选):要覆盖的颜色 token,值是 6 位十六进制色值(如 `#FE8019`)。 + +> 提示:复制一份下面这样的完整示例来改,是最快的起点。 + +## 颜色 token 一览 + +`colors` 里可以设置下面这些 token。每个都标注了它实际控制 UI 的哪些地方,方便你预判改了会影响什么: + +| Token | 控制什么 | +| --- | --- | +| `primary` | 最常用色。链接、行内代码、几乎所有对话框的选中项、编辑器聚焦边框、plan/运行中徽章、spinner | +| `accent` | 次级强调。审批 `▶` 前缀、设备码框、图片占位、BTW/队列面板、注册表导入 | +| `text` | 正文。对话框正文、todo 标题、footer 模型名、Markdown 标题、助手/工具消息子弹头、列表符号 | +| `textStrong` | 加粗强调文字。输入类对话框、状态消息 | +| `textDim` | 次级、变暗文字(用得最广)。思考、提示、描述、已完成 todo、Markdown 引用、footer 状态栏(cwd、git 徽章) | +| `textMuted` | 最浅文字。计数、滚动信息、描述、Markdown 链接 URL、代码块边框 | +| `border` | 边框。面板与编辑器的普通边框、Markdown 分隔线 | +| `borderFocus` | 聚焦/注意边框(目前仅审批面板使用) | +| `success` | 成功态。`✓`、已启用、完成 | +| `warning` | 警告态。auto/yolo 徽章、过期标记、plan 模式提示 | +| `error` | 错误态。错误信息、失败的工具输出 | +| `diffAdded` | diff 新增行 | +| `diffRemoved` | diff 删除行 | +| `diffAddedStrong` | diff 行内改动的新增词(加粗高亮) | +| `diffRemovedStrong` | diff 行内改动的删除词(加粗高亮) | +| `diffGutter` | diff 行号槽 | +| `diffMeta` | diff 元信息 / hunk 头 | +| `roleUser` | 用户消息的子弹头与文字、技能激活名 | + +没有写到的 token 会自动回退到 `dark` 的对应值,所以你完全可以只覆盖一部分: + +```json +{ + "name": "just-blue", + "colors": { + "primary": "#3B82F6", + "roleUser": "#3B82F6" + } +} +``` + +## 选用主题 + +两种方式: + +1. **`/theme` 命令**(推荐):打开主题选择器,自定义主题会以 `Custom: <文件名>` 出现。选择器**每次打开都会重新扫描主题目录**,所以你新加的主题文件**无需重启**就能看到。 +2. **`tui.toml`**:把 `theme` 设成你的主题名: + + ```toml + # ~/.kimi-code/tui.toml + theme = "ember" + ``` + +## 出错时会怎样 + +自定义主题的设计原则是"尽量别打断你": + +- **某个色值不合法**(不是 `#` 加 6 位十六进制):跳过这一项并打印一条警告,其余颜色照常生效。 +- **写了无法识别的 token**:忽略,不影响其它颜色。 +- **文件不存在或 JSON 损坏**:静默回退到 `dark`。 + +## 编辑正在使用的主题 + +如果你修改的是**当前正在生效**的那个主题文件,改动不会自动重新加载。让新颜色生效有两种办法: + +- 运行 `/reload-tui`——它会重新读取 `tui.toml` 并重新应用当前主题(包括重新读取主题文件); +- 或者在 `/theme` 里先切到另一个主题,再切回来。 + +::: warning 注意 +在 `/theme` 里**重新选中同一个主题**不会触发重载(只会提示 “Theme unchanged”)。要重载已激活主题的改动,用上面两种办法之一。 +::: diff --git a/packages/agent-core/src/skill/builtin/custom-theme.md b/packages/agent-core/src/skill/builtin/custom-theme.md new file mode 100644 index 000000000..e05ff66bd --- /dev/null +++ b/packages/agent-core/src/skill/builtin/custom-theme.md @@ -0,0 +1,100 @@ +--- +name: custom-theme +description: Create or edit a kimi-code custom color theme — a JSON file under ~/.kimi-code/themes/ that recolors the TUI. Use when the user wants their own theme, asks for a specific palette or mood, or wants to tweak an existing custom theme's colors. +--- + +# Create a kimi-code custom theme (custom-theme) + +Help the user design, write, and apply a custom color theme for the kimi-code TUI. A theme is a single JSON file; the TUI ships with `dark`, `light`, and `auto`, and any file the user adds becomes selectable alongside them. + +## What a theme is + +- A theme lives at `~/.kimi-code/themes/.json` (or `$KIMI_CODE_HOME/themes/.json` when that variable is set). Create the `themes/` directory if it doesn't exist. +- **The filename is the theme name**: `ember.json` shows up in the `/theme` picker as `Custom: ember`. +- Shape: + + ```json + { + "name": "ember", + "displayName": "Ember", + "colors": { + "primary": "#83A598", + "accent": "#FE8019" + } + } + ``` + + - `name` (required), `displayName` (optional), `colors` (each value a 6-digit hex `#RRGGBB`). +- **Partial themes are fine**: any token you leave out falls back to the built-in `dark` value, so you can recolor just a few tokens or all of them. + +## Source of truth: the docs token reference + +Before choosing colors, use **FetchURL** to fetch the official custom-theme docs as the authoritative list of tokens and what each controls: + +``` +https://moonshotai.github.io/kimi-code/en/customization/themes.html +``` + +Only set tokens from this set — unknown keys are silently ignored at load. If FetchURL is unavailable or the fetch fails, fall back to the embedded reference below (it mirrors the same tokens) and tell the user you're working from the built-in list rather than the live docs. + +## Color tokens (what each controls) + +| Token | Controls | +| --- | --- | +| `primary` | The most-used color: links, inline code, the selected item in nearly every dialog, the focused editor border, plan/"running" badges, spinners | +| `accent` | Secondary highlight: approval `▶` prefix, device-code box, image placeholder, BTW / queue panes, registry import | +| `text` | Body text: dialog bodies, todo titles, footer model label, Markdown headings, assistant/tool message bullets, list bullets | +| `textStrong` | Emphasized / bold text: input dialogs, status messages | +| `textDim` | Secondary, dimmed text (the most widely used dim shade): thinking, hints, descriptions, completed todos, Markdown quotes, footer status bar | +| `textMuted` | Faintest text: counters, scroll info, descriptions, Markdown link URLs, code-block borders | +| `border` | Pane and editor borders, Markdown horizontal rule | +| `borderFocus` | Focus / attention border (currently only the approval panel) | +| `success` | Success state: `✓`, "enabled", completed | +| `warning` | Warning state: auto/yolo badges, stale markers, plan-mode hint | +| `error` | Error state: error messages, failed tool output | +| `diffAdded` | Diff added lines | +| `diffRemoved` | Diff removed lines | +| `diffAddedStrong` | Diff intra-line changed words, added (bold) | +| `diffRemovedStrong` | Diff intra-line changed words, removed (bold) | +| `diffGutter` | Diff line-number gutter | +| `diffMeta` | Diff meta / hunk headers | +| `roleUser` | User message bullet and text, skill-activation name (the one role color with its own hue) | + +## Workflow + +1. **Ask the user what they want first — before choosing any colors.** Clarify, in one short exchange: + - **Light or dark?** A light theme (dark text on a light background) or a dark theme (light text on a dark background). This sets the whole direction, so settle it first. + - **What style / mood?** e.g. warm vs cool, vivid vs muted, high vs low contrast, a named vibe ("nord", "solarized", "sunset"), or a base to start from (an existing theme, or `dark` / `light`). + - **Any specific colors?** Whether they have exact hex values to anchor on (a brand color, a preferred `primary`, etc.). + + Use **AskUserQuestion** for the discrete choices (light vs dark, a few style options); use a plain question for free-form input like specific hex values. Don't start picking colors until you at least know light-vs-dark and the rough style. +2. **Pick a starting point.** + - Tweaking an existing custom theme: **Read** `~/.kimi-code/themes/.json` first — never overwrite a theme you haven't read. + - Starting fresh: build a `colors` object from the token table. You can `ls ~/.kimi-code/themes/` and Read one of the user's existing themes as a reference for the format. +3. **Choose colors deliberately.** + - Every value is a 6-digit hex `#RRGGBB` (not 3-digit, not a named color). + - Keep contrast usable against the user's terminal background: don't let `text` / `textDim` sit too close to the background, and keep `success` / `warning` / `error` clearly distinguishable from each other. + - `primary` is the most-seen color (links, selection, focus) — make it readable and distinct from `text`. + - `roleUser` is the one role color meant to stand on its own — give it a distinct hue. +4. **Write the file** to `~/.kimi-code/themes/.json` with **Write** for a new theme (pick a short kebab-case filename). When editing an existing theme, prefer **Edit** on just the color(s) that change so the rest stays intact — and back it up first (see Don'ts). +5. **Validate.** Confirm the file is valid JSON and every `colors` value matches `^#[0-9a-fA-F]{6}$`. A quick check with **Bash**: + + ```bash + node -e 'const p=require("os").homedir()+"/.kimi-code/themes/.json";const c=(require(p).colors)||{};const bad=Object.entries(c).filter(([,v])=>!/^#[0-9a-fA-F]{6}$/.test(v));console.log(bad.length?["invalid:",...bad]:"all hex valid")' + ``` + + Invalid values are skipped with a warning at load (not fatal), but fix them so the theme renders as intended. +6. **Tell the user how to apply it** (next section). + +## Applying the theme + +- The `/theme` picker re-scans the themes directory every time it opens, so a newly added file shows up **without restarting** — tell the user to run `/theme` and choose `Custom: `. +- Or set it in `tui.toml`: `theme = ""`. +- **Editing the active theme**: changes to the theme that's *currently in use* are not auto-reloaded. Tell the user to run **`/reload-tui`** (or switch to another theme and back). Re-selecting the **same** theme in `/theme` is a no-op ("Theme unchanged"). + +## Don'ts + +- Don't invent token names — only use the documented set; unknown keys are silently ignored. +- Don't write 3-digit hex or named colors — use full `#RRGGBB`. +- Before overwriting an existing theme file, **read it and back it up** (e.g. `cp .json ".json.$(date +%Y%m%d-%H%M%S).bak"`) so the user can recover. +- Don't tell the user to restart the app to apply a theme — `/theme` or `/reload-tui` is enough. diff --git a/packages/agent-core/src/skill/builtin/custom-theme.ts b/packages/agent-core/src/skill/builtin/custom-theme.ts new file mode 100644 index 000000000..d89b6cf41 --- /dev/null +++ b/packages/agent-core/src/skill/builtin/custom-theme.ts @@ -0,0 +1,22 @@ +import { parseSkillText } from '../parser'; +import type { SkillDefinition } from '../types'; +import CUSTOM_THEME_BODY from './custom-theme.md'; + +const PSEUDO_PATH = 'builtin://custom-theme'; + +const parsed = parseSkillText({ + skillMdPath: '/builtin/skills/custom-theme.md', + skillDirName: 'custom-theme', + source: 'builtin', + text: CUSTOM_THEME_BODY, +}); + +export const CUSTOM_THEME_SKILL: SkillDefinition = { + ...parsed, + path: PSEUDO_PATH, + dir: PSEUDO_PATH, + metadata: { + ...parsed.metadata, + type: parsed.metadata.type ?? 'inline', + }, +}; diff --git a/packages/agent-core/src/skill/builtin/index.ts b/packages/agent-core/src/skill/builtin/index.ts index 36a28eca8..33370b889 100644 --- a/packages/agent-core/src/skill/builtin/index.ts +++ b/packages/agent-core/src/skill/builtin/index.ts @@ -1,5 +1,6 @@ import { flags } from '../../flags/resolver'; import type { SkillRegistry } from '../registry'; +import { CUSTOM_THEME_SKILL } from './custom-theme'; import { MCP_CONFIG_SKILL } from './mcp-config'; import { SUB_SKILL_CONSOLIDATE, @@ -19,6 +20,7 @@ export function registerBuiltinSkills( const experimentalFlags = options.experimentalFlags ?? flags; registry.registerBuiltinSkill(MCP_CONFIG_SKILL); registry.registerBuiltinSkill(UPDATE_CONFIG_SKILL); + registry.registerBuiltinSkill(CUSTOM_THEME_SKILL); if (experimentalFlags.enabled('sub_skill')) { registry.registerBuiltinSkill(SUB_SKILL_PARENT); registry.registerBuiltinSkill(SUB_SKILL_REVIEW); @@ -27,6 +29,7 @@ export function registerBuiltinSkills( } export { + CUSTOM_THEME_SKILL, MCP_CONFIG_SKILL, SUB_SKILL_CONSOLIDATE, SUB_SKILL_PARENT, diff --git a/packages/agent-core/test/skill/builtin-custom-theme.test.ts b/packages/agent-core/test/skill/builtin-custom-theme.test.ts new file mode 100644 index 000000000..cf76674a1 --- /dev/null +++ b/packages/agent-core/test/skill/builtin-custom-theme.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { CUSTOM_THEME_SKILL, SkillRegistry, registerBuiltinSkills } from '../../src/skill'; + +describe('builtin skill: custom-theme', () => { + it('has the expected identity and inline metadata', () => { + expect(CUSTOM_THEME_SKILL.name).toBe('custom-theme'); + expect(CUSTOM_THEME_SKILL.source).toBe('builtin'); + expect(CUSTOM_THEME_SKILL.description.length).toBeGreaterThan(0); + expect(CUSTOM_THEME_SKILL.metadata.type).toBe('inline'); + }); + + it('is model-invocable (does not disable model invocation)', () => { + expect(CUSTOM_THEME_SKILL.metadata.disableModelInvocation).not.toBe(true); + }); + + it('pins the docs token reference and points users at ~/.kimi-code/themes and /theme', () => { + const content = CUSTOM_THEME_SKILL.content; + expect(content).toContain('customization/themes.html'); + expect(content).toContain('FetchURL'); + expect(content).toContain('~/.kimi-code/themes'); + expect(content).toContain('/theme'); + // every documented token should be named so the model knows the full set + for (const token of [ + 'primary', + 'accent', + 'text', + 'textStrong', + 'textDim', + 'textMuted', + 'border', + 'borderFocus', + 'success', + 'warning', + 'error', + 'diffAdded', + 'diffRemoved', + 'diffAddedStrong', + 'diffRemovedStrong', + 'diffGutter', + 'diffMeta', + 'roleUser', + ]) { + expect(content).toContain(`\`${token}\``); + } + }); + + it('registers through registerBuiltinSkills and shows up as model-invocable', () => { + const registry = new SkillRegistry(); + registerBuiltinSkills(registry); + + expect(registry.getSkill('custom-theme')).toBeDefined(); + expect( + registry.listInvocableSkills().some((skill) => skill.name === 'custom-theme'), + ).toBe(true); + }); +});