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/package.json b/apps/kimi-code/package.json index eca7f97eb..606454d21 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 6d1f89e18..b1ebfecfd 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()`. */ @@ -278,7 +279,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')); @@ -428,7 +429,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)), @@ -458,7 +459,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..d95c02e35 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,13 +369,24 @@ 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}".`); return; } + // Validate custom themes up front so a missing / malformed file reports an + // error instead of silently persisting a name that resolves to the dark + // fallback. + if (!isBuiltInTheme(theme)) { + const palette = await loadCustomThemeMerged(theme); + if (palette === null) { + host.showStatus(`Theme "${theme}" could not be loaded.`, 'error'); + return; + } + } + try { await saveTuiConfig({ theme, @@ -386,13 +397,15 @@ async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { host.restoreEditor(); void applyPermissionChoice(host, value); @@ -419,7 +431,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 +460,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 +483,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 +498,6 @@ function mountExperimentsPanel( host.mountEditorReplacement( new ExperimentsSelectorComponent({ features, - colors: host.state.theme.colors, onApply: (changes) => { void applyExperimentalFeatureChanges(host, changes); }, @@ -504,7 +514,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 +540,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 +571,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 b3e4593ac..1a50f510b 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, @@ -107,7 +107,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; @@ -130,7 +130,7 @@ export interface SlashCommandHost { showProgressSpinner(label: string): LoginProgressSpinnerHandle; // Theme - applyTheme(theme: Theme, resolved?: ResolvedTheme): void; + applyTheme(theme: ThemeName, resolved?: ResolvedTheme): Promise; 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..73cc99824 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -87,8 +87,7 @@ interface ManagedUsageResult { 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, + const reportArgs = { sessionUsage: sessionUsage.usage, sessionUsageError: sessionUsage.error, contextUsage: host.state.appState.contextUsage, @@ -96,8 +95,8 @@ export async function showUsage(host: SlashCommandHost): Promise { maxContextTokens: host.state.appState.maxContextTokens, managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary); + }; + const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary'); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -108,8 +107,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise { loadManagedUsageReport(host), ]); const appState = host.state.appState; - const lines = buildStatusReportLines({ - colors: host.state.theme.colors, + const reportArgs = { version: appState.version, model: appState.model, workDir: appState.workDir, @@ -126,8 +124,8 @@ export async function showStatusReport(host: SlashCommandHost): Promise { statusError: runtimeStatus.error, managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status '); + }; + const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status '); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -141,12 +139,12 @@ export async function showMcpServers(host: SlashCommandHost): Promise { return; } - 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( + () => buildMcpStatusReportLines({ servers }), + '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..bbcd883a0 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'); @@ -375,20 +371,23 @@ async function renderPluginsList( plugins?: readonly PluginSummary[], ): 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( + () => buildPluginsListLines({ plugins: currentPlugins }), + '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 panel = new UsagePanelComponent( + () => buildPluginsInfoLines({ info }), + '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..a8700d95e 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -1,13 +1,14 @@ 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'; export async function handleReloadTuiCommand(host: SlashCommandHost): Promise { const tuiConfig = await loadTuiConfig(); - applyReloadedTuiConfig(host, tuiConfig); - host.showStatus('TUI config reloaded.', host.state.theme.colors.success); + await applyReloadedTuiConfig(host, tuiConfig); + host.showStatus('TUI config reloaded.', 'success'); } export async function handleReloadCommand(host: SlashCommandHost): Promise { @@ -23,22 +24,24 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise setExperimentalFeatures(await host.harness.getExperimentalFeatures()); host.refreshSlashCommandAutocomplete(); applyRuntimeConfig(host, config); - applyReloadedTuiConfig(host, tuiConfig); + await applyReloadedTuiConfig(host, tuiConfig); if (session === undefined) { host.showStatus( 'Runtime and TUI config reloaded; no active session.', - host.state.theme.colors.success, + 'success', ); } } -export function applyReloadedTuiConfig( +export async function applyReloadedTuiConfig( host: SlashCommandHost, config: TuiConfig, -): void { - const resolved = config.theme === 'auto' ? host.state.theme.resolvedTheme : config.theme; - host.applyTheme(config.theme, resolved); +): Promise { + const resolved = config.theme === 'auto' + ? (currentTheme.palette === lightColors ? 'light' : 'dark') + : undefined; + await host.applyTheme(config.theme, resolved); host.refreshTerminalThemeTracking(); host.setAppState({ editorCommand: config.editorCommand, diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index 65baa2fcc..01e3ae012 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -57,7 +57,6 @@ function showSwarmStartPermissionPrompt( }; host.mountEditorReplacement( new SwarmStartPermissionPromptComponent({ - colors: host.state.theme.colors, onSelect: (choice) => { host.restoreEditor(); void onSelect(choice); @@ -149,7 +148,7 @@ function swarmModeSubcommand(input: string): boolean | undefined { function renderSwarmModeMarker(host: SlashCommandHost, state: SwarmModeMarkerState): void { host.state.transcriptContainer.addChild( - new SwarmModeMarkerComponent(state, host.state.theme.colors), + new SwarmModeMarkerComponent(state), ); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 03c3b025e..196e27c2f 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -185,6 +185,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 b6e84d703..33fd1ceea 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -11,6 +11,7 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; import { @@ -206,7 +207,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)( @@ -217,7 +218,6 @@ export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): s export class FooterComponent implements Component { private state: AppState; - private colors: ColorPalette; private readonly onRefresh: () => void; private gitCache: GitStatusCache; private gitCacheWorkDir: string; @@ -235,9 +235,8 @@ export class FooterComponent implements Component { private backgroundBashTaskCount = 0; private backgroundAgentCount = 0; - constructor(state: AppState, colors: ColorPalette, onRefresh: () => void = () => {}) { + constructor(state: AppState, onRefresh: () => void = () => {}) { this.state = state; - this.colors = colors; this.onRefresh = onRefresh; this.gitCacheWorkDir = state.workDir; this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); @@ -255,10 +254,6 @@ export class FooterComponent implements Component { this.state = state; } - setColors(colors: ColorPalette): void { - this.colors = colors; - } - /** * Short-lived hint that replaces the rotating toolbar tips on line 1. * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C @@ -282,7 +277,7 @@ export class FooterComponent implements Component { invalidate(): void {} render(width: number): string[] { - const colors = this.colors; + const colors = currentTheme.palette; const state = this.state; // ── Line 1: mode badges + model + [N task(s) running] + [N agent(s) running] + cwd + git + hints ── @@ -303,7 +298,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); } @@ -325,7 +320,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/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts index 45ed12c83..9e02e2fbf 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -13,6 +13,7 @@ import type { Component } from '@earendil-works/pi-tui'; import { truncateToWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; export type TodoStatus = 'pending' | 'in_progress' | 'done'; @@ -98,11 +99,6 @@ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { export class TodoPanelComponent implements Component { private todos: readonly TodoItem[] = []; - private colors: ColorPalette; - - constructor(colors: ColorPalette) { - this.colors = colors; - } setTodos(todos: readonly TodoItem[]): void { this.todos = todos.map((t) => ({ title: t.title, status: t.status })); @@ -120,15 +116,11 @@ export class TodoPanelComponent implements Component { return this.todos.length === 0; } - setColors(colors: ColorPalette): void { - this.colors = colors; - } - invalidate(): void {} render(width: number): string[] { if (this.todos.length === 0) return []; - const c = this.colors; + const c = currentTheme.palette; const { rows, hidden } = selectVisibleTodos(this.todos); const lines: string[] = [ chalk.hex(c.border)('─'.repeat(width)), 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..f2ef1a75c 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,42 @@ 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 { + // Repaint the header with the active palette (it caches ANSI codes). + this.headerText.setText(this.buildHeader()); + // 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 +92,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 ade2ba69e..c7cc39923 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 @@ -1,5 +1,3 @@ -import type { ColorPalette } from '#/tui/theme/colors'; - import { StartPermissionPromptComponent, type StartPermissionOption, @@ -8,7 +6,6 @@ import { export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; export interface GoalStartPermissionPromptOptions { - readonly colors: ColorPalette; readonly mode: 'manual' | 'yolo'; readonly onSelect: (choice: GoalStartPermissionChoice) => void; readonly onCancel: () => void; @@ -75,7 +72,6 @@ const YOLO_NOTICE_LINES = [ export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { constructor(opts: GoalStartPermissionPromptOptions) { super({ - colors: opts.colors, title: opts.mode === 'yolo' ? 'Start a goal in YOLO mode?' 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/start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts index 875905a61..38c1da2bb 100644 --- a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/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 StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; @@ -22,7 +21,6 @@ export interface StartPermissionOption { - readonly colors: ColorPalette; readonly title: string; readonly noticeLines: readonly string[]; readonly options: readonly StartPermissionOption[]; @@ -59,19 +57,18 @@ export class StartPermissionPromptComponent { - 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/swarm-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts index cbb910b04..b4da0d851 100644 --- a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts @@ -1,5 +1,3 @@ -import type { ColorPalette } from '#/tui/theme/colors'; - import { StartPermissionPromptComponent, type StartPermissionOption, @@ -8,7 +6,6 @@ import { export type SwarmStartPermissionChoice = 'auto' | 'manual'; export interface SwarmStartPermissionPromptOptions { - readonly colors: ColorPalette; readonly onSelect: (choice: SwarmStartPermissionChoice) => void; readonly onCancel: () => void; } @@ -37,7 +34,6 @@ const NOTICE_LINES = [ export class SwarmStartPermissionPromptComponent extends StartPermissionPromptComponent { constructor(opts: SwarmStartPermissionPromptOptions) { super({ - colors: opts.colors, title: 'Start a swarm task with approvals on?', noticeLines: NOTICE_LINES, options: OPTIONS, 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..ecd3953fd 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,7 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; -import type { Theme } from '#/tui/theme/index'; +import { listCustomThemesSync } from '#/tui/theme/custom-theme-loader'; +import type { ThemeName } from '#/tui/theme/index'; const THEME_OPTIONS: readonly ChoiceOption[] = [ { value: 'auto', label: 'Auto (match terminal)' }, @@ -9,26 +9,25 @@ 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, }); 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 501d1c936..47decf64e 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; } @@ -206,6 +201,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); @@ -218,27 +223,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/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index c7b8cae9c..173be9951 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -6,6 +6,7 @@ import { type AgentSwarmProgressEstimatorPhase, } from '#/tui/components/messages/agent-swarm-progress-estimator'; import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import { gradientText } from '#/tui/theme/gradient-text'; @@ -169,7 +170,6 @@ export interface AgentSwarmGridLayout { export interface AgentSwarmProgressOptions { readonly description: string; - readonly colors: ColorPalette; readonly requestRender?: () => void; readonly availableGridHeight?: () => number | undefined; } @@ -188,7 +188,6 @@ export class AgentSwarmProgressComponent implements Component { private members: AgentSwarmMember[]; private readonly progressEstimator = new AgentSwarmProgressEstimator(); private description: string; - private readonly colors: ColorPalette; private readonly requestRender: (() => void) | undefined; private readonly availableGridHeight: (() => number | undefined) | undefined; private inputComplete = false; @@ -202,12 +201,16 @@ export class AgentSwarmProgressComponent implements Component { constructor(options: AgentSwarmProgressOptions) { this.description = options.description; - this.colors = options.colors; this.requestRender = options.requestRender; this.availableGridHeight = options.availableGridHeight; this.members = []; } + /** Live palette, read on each render so a theme switch recolors the panel. */ + private get colors(): ColorPalette { + return currentTheme.palette; + } + dispose(): void { if (this.timer === undefined) return; clearInterval(this.timer); 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..5f67efad7 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,30 @@ 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 panel = new UsagePanelComponent( + () => buildGoalReportLines(this.goal), + '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 +143,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 +178,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 7f7ba6003..416cf6a33 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); } } @@ -97,11 +94,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')]; @@ -129,7 +125,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/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts index f24cac6b6..b54c378af 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -1,23 +1,19 @@ import type { Component } 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'; export type SwarmModeMarkerState = 'active' | 'inactive' | 'ended'; export class SwarmModeMarkerComponent implements Component { - constructor( - private readonly state: SwarmModeMarkerState, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly state: SwarmModeMarkerState) {} invalidate(): void {} render(_width: number): string[] { - const color = this.state === 'inactive' ? this.colors.textDim : this.colors.success; - const marker = chalk.hex(color).bold(STATUS_BULLET); - const label = chalk.hex(color).bold(swarmMarkerLabel(this.state)); + const token = this.state === 'inactive' ? 'textDim' : 'success'; + const marker = currentTheme.boldFg(token, STATUS_BULLET); + const label = currentTheme.boldFg(token, swarmMarkerLabel(this.state)); return ['', marker + label]; } } 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 649c4ffd1..b56bc2518 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 { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } 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'; @@ -475,9 +473,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 @@ -556,17 +552,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)); @@ -581,6 +573,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; @@ -1198,24 +1196,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; } @@ -1225,7 +1223,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; } @@ -1241,8 +1239,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()) { @@ -1253,13 +1251,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}`; @@ -1270,8 +1268,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 { @@ -1315,10 +1313,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)); } @@ -1341,19 +1339,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, ), @@ -1362,26 +1359,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)); } } @@ -1389,7 +1386,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)); } } @@ -1397,7 +1394,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)); } } } @@ -1413,7 +1410,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 'queued': @@ -1426,7 +1422,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 = @@ -1436,13 +1432,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 { @@ -1480,40 +1476,39 @@ 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}`; } private formatSingleSubagentStatus(phase: SubagentPhase | undefined): 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 'queued': - return chalk.hex(this.colors.primary)('Queued'); + return currentTheme.fg('primary', 'Queued'); case 'spawning': case undefined: - return chalk.hex(this.colors.primary)('Starting'); + return currentTheme.fg('primary', 'Starting'); } } @@ -1543,10 +1538,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)); } @@ -1556,9 +1551,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), ), ); } @@ -1569,15 +1564,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), ), ); } @@ -1591,8 +1586,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}`; } @@ -1605,7 +1600,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, ), @@ -1631,13 +1626,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, @@ -1650,7 +1645,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 } : {}), }); @@ -1692,7 +1687,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; @@ -1710,7 +1705,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') { @@ -1719,7 +1714,6 @@ export class ToolCallComponent extends Container { this.addChild( new ShellExecutionComponent({ command: cmd, - colors: this.colors, showCommand: true, commandPreviewLines: COMMAND_PREVIEW_LINES, }), @@ -1737,17 +1731,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 { @@ -1776,13 +1766,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 { @@ -1815,7 +1805,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)); @@ -1848,7 +1838,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); @@ -1857,23 +1846,23 @@ export class ToolCallComponent extends Container { private buildAgentSwarmResultSummary(result: ToolResultBlockData): void { const summary = agentSwarmResultSummaryFromOutput(result.output); - const dim = chalk.hex(this.colors.textDim); + const dim = (s: string): string => currentTheme.fg('textDim', s); const segments: string[] = []; if (summary.completed > 0) { - segments.push(chalk.hex(this.colors.success)( - `${SUCCESS_MARK.trimEnd()} ${String(summary.completed)} completed`, - )); + segments.push( + currentTheme.fg('success', `${SUCCESS_MARK.trimEnd()} ${String(summary.completed)} completed`), + ); } if (summary.failed > 0) { - segments.push(chalk.hex(this.colors.error)( - `${FAILURE_MARK.trimEnd()} ${String(summary.failed)} failed`, - )); + segments.push( + currentTheme.fg('error', `${FAILURE_MARK.trimEnd()} ${String(summary.failed)} failed`), + ); } if (summary.aborted > 0) { - segments.push(chalk.hex(this.colors.warning)( - `${ABORTED_MARK} ${String(summary.aborted)} aborted`, - )); + segments.push( + currentTheme.fg('warning', `${ABORTED_MARK} ${String(summary.aborted)} aborted`), + ); } if (segments.length > 0) { @@ -1882,17 +1871,13 @@ export class ToolCallComponent extends Container { } const isAborted = result.is_error === true && /\b(?:aborted|cancelled)\b/i.test(result.output); - const color = isAborted - ? this.colors.warning - : result.is_error === true - ? this.colors.error - : this.colors.success; + const colorToken = isAborted ? 'warning' : result.is_error === true ? 'error' : 'success'; const label = isAborted ? `${ABORTED_MARK} Aborted.` : result.is_error === true ? `${FAILURE_MARK.trimEnd()} Failed.` : `${SUCCESS_MARK.trimEnd()} Completed.`; - this.addChild(new Text(`${dim('Agent swarm: ')}${chalk.hex(color)(label)}`, 2, 0)); + this.addChild(new Text(`${dim('Agent swarm: ')}${currentTheme.fg(colorToken, label)}`, 2, 0)); } /** @@ -1909,9 +1894,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; @@ -1922,13 +1905,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..71579e1c5 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, }); @@ -208,16 +201,25 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { } export class UsagePanelComponent implements Component { + /** Cached coloured lines; rebuilt from `buildLines` on every invalidate. */ + private lines: readonly string[]; + constructor( - private readonly lines: readonly string[], - private readonly borderHex: string, + private readonly buildLines: () => readonly string[], + private readonly borderToken: ColorToken, private readonly title: string = ' Usage ', - ) {} + ) { + this.lines = buildLines(); + } - invalidate(): void {} + invalidate(): void { + // Report bodies embed palette colours, so a theme switch must re-run the + // builder to repaint the cached lines (the data itself is captured). + this.lines = this.buildLines(); + } 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..fdcd8714e 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']); @@ -149,7 +149,7 @@ export function renderTuiConfig(config: TuiConfig): string { # Client preferences for kimi-code. # Agent/runtime settings stay in ~/.kimi-code/config.toml. -theme = "${config.theme}" # "auto" | "dark" | "light" +theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index 7ebe79118..b045f8701 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 24f866ae1..c02435521 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, @@ -66,6 +65,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'; @@ -96,7 +97,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; updateActivityPane(): void; track(event: string, props?: Record): void; @@ -389,7 +390,7 @@ export class SessionEventHandler { if (reason === 'error') return; if (reason === 'aborted' || reason === undefined || reason === '') { this.markActiveAgentSwarmsCancelled(); - this.host.showStatus('Interrupted by user', this.host.state.theme.colors.error); + this.host.showStatus('Interrupted by user', 'error'); return; } this.host.showError( @@ -561,7 +562,7 @@ export class SessionEventHandler { private renderSwarmModeMarker(state: SwarmModeMarkerState): void { this.host.state.transcriptContainer.addChild( - new SwarmModeMarkerComponent(state, this.host.state.theme.colors), + new SwarmModeMarkerComponent(state), ); this.host.state.ui.requestRender(); } @@ -599,7 +600,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(); @@ -775,11 +776,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); @@ -787,29 +787,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': @@ -826,14 +825,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) { @@ -841,7 +840,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 35d0f6a9b..2903628c0 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -553,10 +553,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); @@ -584,7 +581,6 @@ export class StreamingUIController { this._pendingReadGroup = null; this._activeThinkingComponent = new ThinkingComponent( fullText, - state.theme.colors, true, 'live', state.ui, @@ -611,9 +607,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); @@ -658,9 +652,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); @@ -686,7 +678,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(); @@ -782,7 +774,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) { @@ -839,7 +831,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/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 5f774991d..1fb2d71c5 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -523,7 +523,6 @@ export class SubAgentEventHandler { const progress = new AgentSwarmProgressComponent({ description: agentSwarmDescriptionFromArgs(args), - colors: this.host.state.theme.colors, availableGridHeight: () => this.agentSwarmGridHeight(), requestRender: () => { this.requestRender(); 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 991b09a49..c8b1a336d 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; @@ -261,7 +258,6 @@ export class KimiTUI { model: startupInput.cliOptions.model, startupNotice: startupInput.startupNotice, }, - resolvedTheme: startupInput.resolvedTheme, }; this.options = tuiOptions; this.migrationPlan = startupInput.migrationPlan ?? null; @@ -438,7 +434,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 { @@ -461,7 +457,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(); @@ -476,7 +472,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 { @@ -515,7 +511,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`, @@ -1179,7 +1175,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); } @@ -1207,7 +1203,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); } @@ -1256,11 +1252,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; } @@ -1270,34 +1262,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; } @@ -1306,9 +1290,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); @@ -1316,24 +1298,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: @@ -1386,7 +1362,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); } @@ -1411,22 +1387,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 { @@ -1434,7 +1410,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); @@ -1442,9 +1418,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(); }, }; @@ -1458,7 +1434,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(); @@ -1513,7 +1488,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.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( @@ -1570,7 +1545,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, @@ -1606,29 +1580,31 @@ 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(); + // 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); }); } @@ -1637,10 +1613,16 @@ 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(); + // Repaint already-rendered transcript entries (status/markdown caches hold + // old ANSI codes), matching applyTheme()'s behaviour. + this.state.transcriptContainer.invalidate(); + this.state.ui.requestRender(true); } private shouldShowTerminalProgress(effectiveMode: EffectiveActivityPaneMode): boolean { @@ -1730,7 +1712,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(); @@ -1763,7 +1744,6 @@ export class KimiTUI { this.mountEditorReplacement( new HelpPanelComponent({ commands: this.getSlashCommands(), - colors: this.state.theme.colors, onClose: () => { this.hideHelpPanel(); }, @@ -1803,7 +1783,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) { @@ -1827,7 +1806,6 @@ export class KimiTUI { (response: ApprovalPanelResponse) => { this.approvalController.respond(adaptPanelResponse(response)); }, - this.state.theme.colors, () => { this.toggleToolOutputExpansion(); }, @@ -1862,7 +1840,6 @@ export class KimiTUI { const viewer = new ApprovalPreviewViewer( { block, - colors: this.state.theme.colors, onClose: () => { this.closeApprovalPreview(); }, @@ -1899,8 +1876,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..cc0b07cbf --- /dev/null +++ b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts @@ -0,0 +1,97 @@ +/** + * Custom theme loader — reads JSON files from `~/.kimi-code/themes/`. + */ + +import { readdirSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { z } from 'zod'; + +import { getDataDir } from '#/utils/paths'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { getBuiltInPalette } from './colors'; + +export const CustomThemeSchema = z.object({ + name: z.string().min(1), + displayName: z.string().optional(), + /** Built-in palette that unspecified tokens fall back to. Defaults to `dark`. */ + base: z.enum(['dark', 'light']).optional(), + colors: z.record(z.string(), z.string()).optional(), +}); + +export type CustomThemeDefinition = z.infer; + +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +/** + * Names reserved for built-in themes. A `dark.json` / `light.json` / + * `auto.json` file would collide with the built-in value, so it can never be + * selected as a custom theme — hide it from listings. + */ +const RESERVED_THEME_NAMES: ReadonlySet = new Set(['dark', 'light', 'auto']); + +export function getCustomThemesDir(): string { + return join(getDataDir(), 'themes'); +} + +interface ParsedCustomTheme { + readonly base: ResolvedTheme; + readonly colors: Partial; +} + +async function readCustomTheme(name: string): Promise { + try { + const content = await readFile(join(getCustomThemesDir(), `${name}.json`), 'utf-8'); + const parsed = CustomThemeSchema.parse(JSON.parse(content)); + + // Invalid hex values are dropped (the token falls back to the base + // palette). We intentionally do not print here: this loader can run while + // pi-tui owns the terminal, where raw stdout/stderr writes corrupt the + // rendered screen. Authoring-time validation lives in the JSON schema. + const colors = Object.fromEntries( + Object.entries(parsed.colors ?? {}).filter(([, v]) => HEX_COLOR_REGEX.test(v)), + ) as Partial; + + return { base: parsed.base ?? 'dark', colors }; + } catch { + return null; + } +} + +export async function loadCustomTheme(name: string): Promise | null> { + return (await readCustomTheme(name))?.colors ?? null; +} + +/** Load a custom theme and merge it onto its base palette (dark unless `base` says otherwise). */ +export async function loadCustomThemeMerged(name: string): Promise { + const parsed = await readCustomTheme(name); + if (parsed === null) return null; + return { ...getBuiltInPalette(parsed.base), ...parsed.colors }; +} + +function toThemeNames(files: readonly string[]): string[] { + return files + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .filter((name) => !RESERVED_THEME_NAMES.has(name)); +} + +export async function listCustomThemes(): Promise { + try { + const entries = await readdir(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} + +/** Synchronous variant for UI paths (e.g. the `/theme` picker) that cannot await. */ +export function listCustomThemesSync(): string[] { + try { + const entries = readdirSync(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/tui/theme/index.ts b/apps/kimi-code/src/tui/theme/index.ts index 5cd97384f..e016def5d 100644 --- a/apps/kimi-code/src/tui/theme/index.ts +++ b/apps/kimi-code/src/tui/theme/index.ts @@ -2,46 +2,62 @@ * Theme system public API. */ -import type { ResolvedTheme } from './colors'; +import { getBuiltInPalette } from './colors'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { loadCustomThemeMerged } from './custom-theme-loader'; 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 { gradientText } from './gradient-text'; -export { createMarkdownTheme, createEditorTheme } from './pi-tui-theme'; export { detectTerminalTheme } from './detect'; +export { loadCustomTheme, loadCustomThemeMerged, listCustomThemes } from './custom-theme-loader'; /** - * 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..5e320992d --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme-schema.json @@ -0,0 +1,58 @@ +{ + "$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" + }, + "base": { + "type": "string", + "enum": ["dark", "light"], + "description": "Built-in palette that unspecified tokens fall back to (default: dark)" + }, + "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 bc1c3214b..2ee19be4b 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; @@ -56,7 +56,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); @@ -64,12 +64,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(); 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 }, () => { ui.requestRender(); }); @@ -83,8 +83,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 350b62d52..a7be70be5 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; @@ -27,7 +26,7 @@ export interface AppState { isReplaying: boolean; streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; streamingStartTime: number; - theme: Theme; + theme: ThemeName; version: string; editorCommand: string | null; notifications: NotificationsConfig; @@ -128,7 +127,7 @@ export interface TranscriptEntry { turnId?: string; renderMode: 'markdown' | 'plain' | 'notice'; content: string; - color?: string; + color?: ColorToken; detail?: string; toolCallData?: ToolCallBlockData; backgroundAgentStatus?: BackgroundAgentStatusData; @@ -186,7 +185,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 f450b099c..da70d5309 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 c8e13a305..2b12a76ee 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -35,7 +35,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..221dd4bf7 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', ); }); @@ -85,6 +86,31 @@ auto_install = false fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, }); }); + + it('awaits the async theme application before refreshing terminal tracking', async () => { + await writeTuiConfig('theme = "auto"\n'); + const host = makeHost(); + const mutable = host as unknown as { + applyTheme: (theme: string) => Promise; + refreshTerminalThemeTracking: () => void; + state: { appState: { theme: string } }; + }; + + let themeWhenTracked: string | undefined; + // Theme application resolves on a later microtask, mirroring the real + // async palette load; tracking must observe the *new* theme. + mutable.applyTheme = vi.fn(async (theme: string) => { + await Promise.resolve(); + mutable.state.appState.theme = theme; + }); + mutable.refreshTerminalThemeTracking = vi.fn(() => { + themeWhenTracked = mutable.state.appState.theme; + }); + + await handleReloadTuiCommand(host); + + expect(themeWhenTracked).toBe('auto'); + }); }); async function writeTuiConfig(text: string): Promise { @@ -110,8 +136,7 @@ function makeHost({ availableProviders: {}, }, theme: { - resolvedTheme: 'dark', - colors: { + palette: { success: '#00ff00', }, }, diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 3c9418989..c9427a710 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { handleSwarmCommand } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { getColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; const ENTER = '\r'; const ESCAPE = '\u001B'; @@ -36,7 +36,7 @@ function makeHost( permissionMode: overrides.permissionMode ?? 'auto', swarmMode: overrides.swarmMode ?? false, }, - theme: { colors: getColorPalette('dark') }, + theme: currentTheme, transcriptContainer: { addChild: vi.fn() }, ui: { requestRender: vi.fn() }, }, 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/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index e5d56b0ed..ab0878d6b 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { AppState } from '#/tui/types'; const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; @@ -71,7 +71,7 @@ describe('FooterComponent', () => { it('paints the model name in rainbow while colored', () => { setDanceView(true, 0); - const footer = new FooterComponent(appState, darkColors); + const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); @@ -82,11 +82,25 @@ describe('FooterComponent', () => { }); it('renders the model name in its normal color when not dancing', () => { - const footer = new FooterComponent(appState, darkColors); + const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); expect(codes.has(RAINBOW_CYAN)).toBe(false); expect(codes.has(RAINBOW_GREEN)).toBe(false); }); + + it('repaints from the active palette on the next render (no setColors needed)', () => { + const footer = new FooterComponent(appState); + const before = footer.render(120).join('\n'); + + currentTheme.setPalette(lightColors); + try { + const after = footer.render(120).join('\n'); + // Reads currentTheme live, so a palette swap changes the emitted colours. + expect(after).not.toBe(before); + } finally { + currentTheme.setPalette(darkColors); + } + }); }); 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 530f9281e..4233934db 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -71,7 +71,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); @@ -79,15 +79,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..80aaa6e79 100644 --- a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import chalk from 'chalk'; +import { afterEach, describe, expect, it } from 'vitest'; import { CompactionComponent } from '#/tui/components/dialogs/compaction'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +afterEach(() => { + currentTheme.setPalette(darkColors); +}); function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -9,7 +14,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 +28,7 @@ describe('CompactionComponent', () => { }); it('renders a cancelled terminal state', () => { - const component = new CompactionComponent(darkColors); + const component = new CompactionComponent(); try { component.markCanceled(); @@ -36,4 +41,32 @@ describe('CompactionComponent', () => { component.dispose(); } }); + + it('repaints the header with the active palette on invalidate', () => { + // Force truecolor so palette differences surface as ANSI codes even when + // the test runner has no TTY. + const previousLevel = chalk.level; + chalk.level = 3; + const component = new CompactionComponent(); + + try { + const headerOf = (): string => { + const line = component.render(120).find((l) => strip(l).includes('Compacting context...')); + if (line === undefined) throw new Error('header line not found'); + return line; + }; + const before = headerOf(); + + currentTheme.setPalette(lightColors); + component.invalidate(); + const after = headerOf(); + + // Same visible text, different ANSI colour codes. + expect(strip(after)).toBe(strip(before)); + expect(after).not.toBe(before); + } finally { + chalk.level = previousLevel; + component.dispose(); + } + }); }); 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/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts index c89e77a36..485a171ba 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; import { AgentSwarmProgressComponent, @@ -12,7 +13,7 @@ import { calculateAgentSwarmGridLayout, } from '#/tui/components/messages/agent-swarm-progress'; import { AgentSwarmProgressEstimator } from '#/tui/components/messages/agent-swarm-progress-estimator'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; const DEFAULT_DESCRIPTION = 'Review changed files'; @@ -25,7 +26,6 @@ function createComponent( ): AgentSwarmProgressComponent { return new AgentSwarmProgressComponent({ description: options.description ?? DEFAULT_DESCRIPTION, - colors: options.colors ?? darkColors, requestRender: options.requestRender, availableGridHeight: options.availableGridHeight, }); @@ -57,6 +57,7 @@ function startSubagents(component: AgentSwarmProgressComponent, count: number): afterEach(() => { vi.useRealTimers(); + currentTheme.setPalette(darkColors); }); describe('calculateAgentSwarmGridLayout', () => { @@ -166,6 +167,29 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('01'); }); + it('repaints from the active palette when the theme changes', () => { + const previousLevel = chalk.level; + chalk.level = 3; // force truecolor so palette differences surface as ANSI + try { + const component = createComponent(); + const titleOf = (): string => { + const line = component.render(100).find((l) => strip(l).includes('Agent Swarm')); + if (line === undefined) throw new Error('title line not found'); + return line; + }; + const before = titleOf(); + + currentTheme.setPalette(lightColors); + const after = titleOf(); + + // Same visible text, different ANSI colours (reads currentTheme live). + expect(strip(after)).toBe(strip(before)); + expect(after).not.toBe(before); + } finally { + chalk.level = previousLevel; + } + }); + it('renders blank padding around the block without a bottom divider', () => { const component = createComponent(); 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/mcp-status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts index b62c6df33..63b716208 100644 --- a/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildMcpStatusReportLines } from '#/tui/components/messages/mcp-status-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\[[0-9;]*m/g, ''); @@ -10,7 +9,6 @@ function strip(text: string): string { describe('buildMcpStatusReportLines', () => { it('folds a multi-line server error onto one row so the panel box stays intact', () => { const lines = buildMcpStatusReportLines({ - colors: darkColors, servers: [ { name: 'ghidra', @@ -37,7 +35,6 @@ describe('buildMcpStatusReportLines', () => { it('trims and keeps a single-line error intact', () => { const lines = buildMcpStatusReportLines({ - colors: darkColors, servers: [ { name: 'ida', 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 88cc967e2..4ee4985eb 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')); @@ -151,7 +145,6 @@ describe('ToolCallComponent', () => { output, is_error: false, }, - darkColors, ); const out = strip(component.render(120).join('\n')); @@ -174,7 +167,6 @@ describe('ToolCallComponent', () => { output: 'provider request failed', is_error: true, }, - darkColors, ); const out = strip(component.render(120).join('\n')); @@ -195,7 +187,6 @@ describe('ToolCallComponent', () => { output: 'first line\nnope', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -217,12 +208,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'); }); @@ -235,9 +225,7 @@ describe('ToolCallComponent', () => { args: {}, }, undefined, - darkColors, undefined, - createMarkdownTheme(darkColors), ); // A fresh tool card only shows the 'Current plan' title; no plan box renders yet. @@ -264,9 +252,7 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); const collapsed = strip(component.render(100).join('\n')); @@ -288,9 +274,7 @@ describe('ToolCallComponent', () => { args: { command: 'echo hi' }, }, undefined, - darkColors, undefined, - createMarkdownTheme(darkColors), ); expect(component.setPlanExpanded(true)).toBe(false); @@ -310,9 +294,7 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); component.setExpanded(true); const out = strip(component.render(100).join('\n')); @@ -335,7 +317,6 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# Plan body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; @@ -359,7 +340,6 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; @@ -378,9 +358,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')); @@ -401,9 +379,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')); @@ -432,7 +408,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')); @@ -454,7 +429,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')); @@ -477,7 +451,6 @@ describe('ToolCallComponent', () => { }), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -503,7 +476,6 @@ describe('ToolCallComponent', () => { ].join('\n'), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -524,7 +496,6 @@ describe('ToolCallComponent', () => { output: '1\tfoo\n2\tbar\n3\tbaz', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -542,7 +513,6 @@ describe('ToolCallComponent', () => { args: { path: longPath }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -563,8 +533,6 @@ describe('ToolCallComponent', () => { output: '1\tcontent', is_error: false, }, - darkColors, - undefined, undefined, '/tmp/proj-a', ); @@ -583,8 +551,6 @@ describe('ToolCallComponent', () => { args: { path: '/tmp/proj-ab/src/main.ts' }, }, undefined, - darkColors, - undefined, undefined, '/tmp/proj-a', ); @@ -602,7 +568,6 @@ describe('ToolCallComponent', () => { args: { path: 'foo.ts' }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -620,7 +585,6 @@ describe('ToolCallComponent', () => { args: { description: 'explore project xxx' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ @@ -683,7 +647,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -723,7 +686,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -767,7 +729,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect wrapping' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_wrapped', @@ -801,7 +762,6 @@ describe('ToolCallComponent', () => { args: { description: 'check failure' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_failed', @@ -849,7 +809,6 @@ describe('ToolCallComponent', () => { }, }, spawnSuccessResult, - darkColors, ); component.onSubagentSpawned({ agentId: 'agent-0', @@ -914,7 +873,6 @@ describe('ToolCallComponent', () => { args: { description: 'background agent A', run_in_background: true }, }, undefined, - darkColors, ); component.setBackgroundTaskTerminalStatus('lost'); // Now the spawn-success result lands. @@ -965,7 +923,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. @@ -983,7 +940,6 @@ describe('ToolCallComponent', () => { args: { description: 'X', run_in_background: true }, }, spawnSuccessResult, - darkColors, ); component.setSubagentMeta('agent-explicit', 'coder'); expect(component.getSubagentAgentId()).toBe('agent-explicit'); @@ -1001,7 +957,6 @@ describe('ToolCallComponent', () => { output: 'agent_id: agent-fake\nstatus: running', is_error: false, }, - darkColors, ); expect(component.getSubagentAgentId()).toBeUndefined(); }); @@ -1052,7 +1007,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"foo.ts","content":"${escaped}`, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1081,7 +1035,6 @@ describe('ToolCallComponent', () => { truncated: true, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1120,7 +1073,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1154,7 +1106,6 @@ describe('ToolCallComponent', () => { // No streamingArguments → finalized args; no result yet. }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); expect(out).toContain('line1'); @@ -1176,7 +1127,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"big.txt","content":"${escaped}"}`, }, undefined, - darkColors, ); expect(strip(component.render(100).join('\n'))).toContain('line25'); @@ -1202,7 +1152,6 @@ describe('ToolCallComponent', () => { streamingArguments: '{', }, undefined, - darkColors, ); const before = strip(component.render(100).join('\n')); expect(before).toContain('Using Write'); @@ -1229,7 +1178,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); @@ -1256,7 +1204,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); @@ -1285,7 +1232,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); @@ -1312,7 +1258,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); ui.requestRender.mockClear(); @@ -1335,7 +1280,6 @@ describe('ToolCallComponent', () => { output: 'Wrote big.txt', is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -1366,7 +1310,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..fb47e3d14 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 @@ -1,8 +1,12 @@ import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +afterEach(() => { + currentTheme.setPalette(darkColors); +}); function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -11,7 +15,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 +49,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 +58,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); @@ -64,4 +67,20 @@ describe('UsagePanelComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } }); + + it('rebuilds its body from the active palette on invalidate', () => { + // Emit the resolved palette value as visible text so the assertion holds + // regardless of chalk's colour level in the test environment. + const component = new UsagePanelComponent(() => [`text=${currentTheme.color('text')}`], 'primary'); + const bodyOf = (): string => { + const line = component.render(80).map(strip).find((l) => l.includes('text=')); + if (line === undefined) throw new Error('body line not found'); + return line; + }; + + expect(bodyOf()).toContain(darkColors.text); + currentTheme.setPalette(lightColors); + component.invalidate(); + expect(bodyOf()).toContain(lightColors.text); + }); }); 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-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index bb846c7eb..a9cc544fe 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; -import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -35,7 +34,7 @@ function baseState(overrides: Partial = {}): AppState { describe('FooterComponent — background task / agent badges', () => { it('omits both badges when counts are 0', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); const [line1] = footer.render(120); expect(line1).toBeDefined(); expect(strip(line1!)).not.toMatch(/tasks? running/); @@ -43,7 +42,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders the task badge alone when only bash tasks are running', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); @@ -51,7 +50,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders the agent badge alone when only agent tasks are running', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 agent running\]/); @@ -59,7 +58,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders both badges side by side when both are non-zero', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 3 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[2 tasks running\]/); @@ -69,7 +68,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('pluralizes correctly across both badges', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); @@ -77,7 +76,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('updates badges live via setBackgroundCounts', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 1 }); expect(strip(footer.render(120)[0]!)).toMatch(/\[2 tasks running\]/); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); @@ -87,7 +86,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('clamps negative counts to 0', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: -5, agentTasks: -2 }); const out = strip(footer.render(120)[0]!); expect(out).not.toMatch(/tasks? running/); @@ -95,7 +94,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('drops the badges when terminal is too narrow to fit them', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 4, agentTasks: 3 }); // Extremely narrow width: footer primary content fills the line, so leftLine wins. const [line1] = footer.render(20); 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..fb1cfd5fe 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 @@ -44,7 +44,7 @@ function baseState(overrides: Partial = {}): AppState { describe('FooterComponent — context NaN resilience', () => { it('NaN usage → renders 0.0% (never literal "NaN%")', () => { - const fc = new FooterComponent(baseState({ contextUsage: Number.NaN }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: Number.NaN })); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); expect(out).toMatch(/context: 0\.0%/); @@ -53,7 +53,6 @@ describe('FooterComponent — context NaN resilience', () => { it('undefined-ish (coerced) usage → renders 0.0%', () => { const fc = new FooterComponent( baseState({ contextUsage: undefined as unknown as number }), - darkColors, ); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); @@ -61,13 +60,13 @@ describe('FooterComponent — context NaN resilience', () => { }); it('clamps ratios above 1.0 → renders 100.0%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 1.5 }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: 1.5 })); const out = strip(fc.render(120).join('')); expect(out).toMatch(/context: 100\.0%/); }); it('ratio 0.427 → renders 42.7%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 0.427 }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: 0.427 })); const out = strip(fc.render(200).join('')); expect(out).toMatch(/context: 42\.7%/); }); @@ -75,7 +74,6 @@ describe('FooterComponent — context NaN resilience', () => { it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => { const fc = new FooterComponent( baseState({ contextUsage: 0, contextTokens: 500, maxContextTokens: 0 }), - darkColors, ); const out = strip(fc.render(200).join('')); expect(out).not.toMatch(/Infinity|NaN/); @@ -85,7 +83,7 @@ describe('FooterComponent — context NaN resilience', () => { }); it('setState updates visible model and context values', () => { - const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 }), darkColors); + const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 })); footer.setState(baseState({ model: 'kimi-k2-5', contextUsage: 0.5 })); @@ -96,15 +94,15 @@ describe('FooterComponent — context NaN resilience', () => { }); it('shows "thinking" label when thinking is enabled, hides it when disabled', () => { - const on = new FooterComponent(baseState({ model: 'k2', thinking: true }), darkColors); - const off = new FooterComponent(baseState({ model: 'k2', thinking: false }), darkColors); + const on = new FooterComponent(baseState({ model: 'k2', thinking: true })); + const off = new FooterComponent(baseState({ model: 'k2', thinking: false })); expect(strip(on.render(120)[0]!)).toContain('thinking'); expect(strip(off.render(120)[0]!)).not.toContain('thinking'); }); it('renders transient hints on the context line', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setTransientHint('Press Ctrl-C again to exit'); @@ -134,7 +132,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/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index 59ec24331..d04c9c279 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; -import { darkColors } from '#/tui/theme/colors'; import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; @@ -57,12 +56,12 @@ describe('FooterComponent — goal badge', () => { }); it('omits the badge when there is no goal', () => { - const footer = new FooterComponent(baseState({ goal: null }), darkColors); + const footer = new FooterComponent(baseState({ goal: null })); expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); }); it('shows status, elapsed, and a raw turn count for an unbounded active goal', () => { - const footer = new FooterComponent(baseState({ goal: goal() }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal() })); const out = strip(footer.render(160)[0]!); expect(out).toContain('[goal'); expect(out).toContain('active'); @@ -78,7 +77,6 @@ describe('FooterComponent — goal badge', () => { const footer = new FooterComponent( baseState({ goal: goal({ wallClockMs: 0, turnsUsed: 0 }) }), - darkColors, ); expect(strip(footer.render(160)[0]!)).toContain('0s'); @@ -90,7 +88,7 @@ describe('FooterComponent — goal badge', () => { vi.useFakeTimers(); const onRefresh = vi.fn(); - new FooterComponent(baseState({ goal: goal({ wallClockMs: 0 }) }), darkColors, onRefresh); + new FooterComponent(baseState({ goal: goal({ wallClockMs: 0 }) }), onRefresh); vi.advanceTimersByTime(1_000); expect(onRefresh).toHaveBeenCalledTimes(1); @@ -99,30 +97,29 @@ describe('FooterComponent — goal badge', () => { it('shows used/limit turns only when a turn budget is set', () => { const footer = new FooterComponent( baseState({ goal: goal({ budget: { turnBudget: 20, tokenBudget: null, wallClockBudgetMs: null } } as Partial) }), - darkColors, ); expect(strip(footer.render(160)[0]!)).toContain('7/20 turns'); }); it('shows a paused badge', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'paused' }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'paused' }) })); expect(strip(footer.render(160)[0]!)).toContain('paused'); }); it('shows a blocked badge (resumable, still present)', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'blocked' }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'blocked' }) })); const out = strip(footer.render(160)[0]!); expect(out).toContain('[goal'); expect(out).toContain('blocked'); }); it('hides the badge for a completed goal', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) })); expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); }); it('singularizes a single turn', () => { - const footer = new FooterComponent(baseState({ goal: goal({ turnsUsed: 1 }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ turnsUsed: 1 }) })); const out = strip(footer.render(160)[0]!); expect(out).toContain('1 turn'); expect(out).not.toContain('1 turns'); 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/panels/todo-panel.test.ts b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts index 2382c0a60..04e3f1b88 100644 --- a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts @@ -5,7 +5,6 @@ import { selectVisibleTodos, type TodoItem, } from '#/tui/components/chrome/todo-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -13,13 +12,13 @@ function strip(text: string): string { describe('TodoPanelComponent', () => { it('returns no lines when empty (so the layout slot collapses)', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); expect(panel.render(80)).toEqual([]); expect(panel.isEmpty()).toBe(true); }); it('renders a Todo header + one row per entry', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 'Investigate parser', status: 'done' }, { title: 'Add tests', status: 'in_progress' }, @@ -34,7 +33,7 @@ describe('TodoPanelComponent', () => { }); it('setTodos replaces the list (not appends)', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([{ title: 'old', status: 'pending' }]); panel.setTodos([{ title: 'new', status: 'in_progress' }]); const out = strip(panel.render(80).join('\n')); @@ -43,7 +42,7 @@ describe('TodoPanelComponent', () => { }); it('clear() wipes the list and reverts to empty', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([{ title: 'x', status: 'pending' }]); panel.clear(); expect(panel.isEmpty()).toBe(true); @@ -51,7 +50,7 @@ describe('TodoPanelComponent', () => { }); it('defensive copy: external mutation does not leak into the panel', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); const source: TodoItem[] = [{ title: 'foo', status: 'pending' }]; panel.setTodos(source); source[0] = { title: 'hacked', status: 'done' }; @@ -61,7 +60,7 @@ describe('TodoPanelComponent', () => { }); it('renders all todos and no overflow footer when count <= 5', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 'a', status: 'done' }, { title: 'b', status: 'in_progress' }, @@ -76,7 +75,7 @@ describe('TodoPanelComponent', () => { }); it('appends "+N more" footer when count > 5', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 't0', status: 'done' }, { title: 't1', status: 'in_progress' }, 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/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 82bef3010..21c4f4b6c 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -118,4 +118,19 @@ command = " " upgrade: { autoInstall: false }, }); }); + + it('escapes special characters in a custom theme name so the TOML round-trips', async () => { + const theme = 'weird"name\\with-quote'; + await saveTuiConfig( + { + theme, + editorCommand: null, + notifications: DEFAULT_TUI_CONFIG.notifications, + upgrade: DEFAULT_TUI_CONFIG.upgrade, + }, + filePath, + ); + + expect((await loadTuiConfig(filePath)).theme).toBe(theme); + }); }); 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 04bdfacd2..eb9dbd986 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -56,8 +56,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 c7266fae2..df5d0316f 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 @@ -106,7 +106,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 9d21b927e..02aa81f1d 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -51,7 +51,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/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts b/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts new file mode 100644 index 000000000..226901338 --- /dev/null +++ b/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts @@ -0,0 +1,86 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getCustomThemesDir, + listCustomThemes, + listCustomThemesSync, + loadCustomTheme, + loadCustomThemeMerged, +} from '#/tui/theme/custom-theme-loader'; +import { darkColors, lightColors } from '#/tui/theme'; + +let home: string; +const originalHome = process.env['KIMI_CODE_HOME']; + +beforeEach(() => { + home = join(tmpdir(), `kimi-themes-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(home, 'themes'), { recursive: true }); + process.env['KIMI_CODE_HOME'] = home; +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); + if (originalHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = originalHome; + } +}); + +function writeTheme(name: string, body: unknown): void { + writeFileSync(join(getCustomThemesDir(), `${name}.json`), JSON.stringify(body), 'utf-8'); +} + +describe('custom theme loader', () => { + it('excludes reserved built-in names from the listing', async () => { + writeTheme('dark', { name: 'dark', colors: {} }); + writeTheme('light', { name: 'light', colors: {} }); + writeTheme('auto', { name: 'auto', colors: {} }); + writeTheme('solarized', { name: 'solarized', colors: { primary: '#268bd2' } }); + + expect(await listCustomThemes()).toEqual(['solarized']); + expect(listCustomThemesSync()).toEqual(['solarized']); + }); + + it('filters invalid hex values without writing to the terminal', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + writeTheme('mixed', { + name: 'mixed', + colors: { primary: '#268bd2', text: 'not-a-hex', accent: '#ff0000' }, + }); + + const loaded = await loadCustomTheme('mixed'); + expect(loaded).toEqual({ primary: '#268bd2', accent: '#ff0000' }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('returns null for a missing theme file', async () => { + expect(await loadCustomTheme('does-not-exist')).toBeNull(); + }); + + it('falls back to the dark palette for unspecified tokens by default', async () => { + writeTheme('solar-dark', { name: 'solar-dark', colors: { primary: '#268bd2' } }); + const merged = await loadCustomThemeMerged('solar-dark'); + expect(merged?.primary).toBe('#268bd2'); + expect(merged?.text).toBe(darkColors.text); + }); + + it('falls back to the light palette when base is "light"', async () => { + writeTheme('solar-light', { + name: 'solar-light', + base: 'light', + colors: { primary: '#268bd2' }, + }); + const merged = await loadCustomThemeMerged('solar-light'); + expect(merged?.primary).toBe('#268bd2'); + expect(merged?.text).toBe(lightColors.text); + }); +}); 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 1a414f8cb..2d905320f 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..f4ea93ed9 --- /dev/null +++ b/docs/en/customization/themes.md @@ -0,0 +1,101 @@ +# 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 the **base palette** (`dark` by default): + +```json +{ + "name": "ember", + "colors": { + "primary": "#83A598", + "accent": "#FE8019" + } +} +``` + +Fields: + +- `name` (required): the theme identifier. +- `displayName` (optional): a human-readable name. +- `base` (optional): the built-in palette that unspecified tokens inherit — `"dark"` (default) or `"light"`. Set `"base": "light"` when you are building a **light** theme so the tokens you leave out stay readable on a light background (otherwise they fall back to the dark palette). +- `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 silently skipped (it falls back to the `dark` default); 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 15b71e4ca..686282eb7 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..c2867ac07 --- /dev/null +++ b/docs/zh/customization/themes.md @@ -0,0 +1,101 @@ +# 自定义主题 + +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`(可选):人类可读的名字。 +- `base`(可选):未指定的 token 沿用哪个内置调色板——`"dark"`(默认)或 `"light"`。做**浅色**主题时设为 `"base": "light"`,这样你没写的 token 在浅色背景上仍然可读(否则会回退到 dark 调色板)。 +- `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 位十六进制):静默跳过这一项(回退到 `dark` 默认值),其余颜色照常生效。 +- **写了无法识别的 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..1f4fe9716 --- /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), `base` (optional: `"dark"` default, or `"light"`), `colors` (each value a 6-digit hex `#RRGGBB`). +- **Partial themes are fine**: any token you leave out falls back to the **base** palette (`dark` by default; set `"base": "light"` for a light theme), 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. For a light theme, set `"base": "light"` so the tokens you leave out inherit the light palette instead of dark. + - **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 silently skipped at load (they fall back to the base palette; 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); + }); +});