diff --git a/.changeset/xterm-basic-color-palette.md b/.changeset/xterm-basic-color-palette.md new file mode 100644 index 0000000000..7a3a2de59f --- /dev/null +++ b/.changeset/xterm-basic-color-palette.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix unreadable text and wrong colors in the terminal UI on 16-color terminals such as `TERM=xterm`, where dim text and borders collapsed to black on dark backgrounds. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 221b39ecfb..68eb92da0d 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -23,7 +23,7 @@ import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config'; import type { ThemeName } from '#/tui/theme'; -import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; +import { currentTheme, inferBuiltInResolvedTheme, isBuiltInTheme, loadCustomThemeMerged } from '#/tui/theme'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import { thinkingEffortToConfig } from '../utils/thinking-config'; @@ -741,7 +741,7 @@ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promi } const resolved = theme === 'auto' - ? (currentTheme.palette === lightColors ? 'light' : 'dark') + ? (inferBuiltInResolvedTheme(currentTheme.palette) ?? 'dark') : undefined; await host.applyTheme(theme, resolved); host.refreshTerminalThemeTracking(); diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 15dc411651..728186146f 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -1,6 +1,6 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; -import { currentTheme, lightColors } from '#/tui/theme'; +import { currentTheme, inferBuiltInResolvedTheme } from '#/tui/theme'; import { loadTuiConfig, type TuiConfig } from '../config'; import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; @@ -56,7 +56,7 @@ export async function applyReloadedTuiConfig( config: TuiConfig, ): Promise { const resolved = config.theme === 'auto' - ? (currentTheme.palette === lightColors ? 'light' : 'dark') + ? (inferBuiltInResolvedTheme(currentTheme.palette) ?? 'dark') : undefined; await host.applyTheme(config.theme, resolved); host.refreshTerminalThemeTracking(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 845c48bf7e..481ab05497 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -123,7 +123,7 @@ 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 { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; +import { currentTheme, getColorPalette, getBuiltInPaletteForTerminal, isBuiltInTheme } from './theme'; import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; import { createTUIState, type TUIState } from './tui-state'; import { @@ -2972,7 +2972,7 @@ export class KimiTUI { private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise { if (this.state.appState.theme !== 'auto') return; - const palette = getBuiltInPalette(resolved); + const palette = getBuiltInPaletteForTerminal(resolved); if (currentTheme.palette === palette) return; currentTheme.setPalette(palette); this.updateEditorBorderHighlight(); diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 66f9819c3e..e24d38be78 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -133,9 +133,95 @@ export const lightColors: ColorPalette = { shellMode: '#7C3AED', }; +/** + * Fallback palettes for ANSI-16 terminals (chalk level 1, e.g. `TERM=xterm`). + * + * At that color depth `chalk.hex()` quantizes aggressively: mid grays below + * `#808080` collapse to black (SGR 30, invisible on a dark background), + * desaturated hues map to white, and gray never maps to bright black — so + * several `darkColors` tokens become unreadable or lose their hue entirely. + * These variants keep every token on a hex that quantizes to a readable, + * hue-correct basic ANSI code; hue hierarchy is intentionally flatter than + * the full palettes because 16 colors cannot express it. + * + * Values were verified against chalk's level-1 rgb→ansi16 conversion: + * dark — neutrals land on SGR 37/97 (never 30), primary 94, accent 36, + * success/diffAdded 32, error/diffRemoved 91, warning/roleUser 93, + * shellMode 95; light — text tokens stay on 30, success/diffAdded 32, + * warning/borderFocus/roleUser 33, error 31, shellMode 35. + */ +export const basicDarkColors: ColorPalette = { + primary: '#5C5CFF', // → bright blue (dark #4FA8FF lands on bright cyan) + accent: '#5BC0BE', // → cyan + + text: '#E0E0E0', // → white + textStrong: '#FFFFFF', // → bright white + textDim: '#C0C0C0', // → white + textMuted: '#808080', // → white (#6B6B6B quantizes to black) + + border: '#808080', // → white (#5A5A5A quantizes to black) + borderFocus: '#E8A838', // → bright yellow + + success: '#4EC87E', // → green + warning: '#E8A838', // → bright yellow + error: '#E85454', // → bright red + + diffAdded: '#4EC87E', // → green + diffRemoved: '#E85454', // → bright red + diffAddedStrong: '#00FF00', // → bright green (#7AD99B lands on cyan) + diffRemovedStrong: '#FF5555', // → bright red (#F08585 lands on white) + diffGutter: '#808080', // → white (#6B6B6B quantizes to black) + diffMeta: '#888888', // → white + + roleUser: '#FFCB6B', // → bright yellow + shellMode: '#FF00FF', // → bright magenta (#BD93F9 lands on white) +}; + +export const basicLightColors: ColorPalette = { + primary: '#1565C0', // → blue + accent: '#00838F', // → cyan + + text: '#1A1A1A', // → black + textStrong: '#1A1A1A', // → black + textDim: '#454545', // → black + textMuted: '#5F5F5F', // → black + + border: '#737373', // → black + borderFocus: '#808000', // → yellow (#92660A lands on red) + + success: '#008000', // → green (#0E7A38 quantizes to black) + warning: '#808000', // → yellow (#92660A lands on red) + error: '#B91C1C', // → red + + diffAdded: '#008000', // → green + diffRemoved: '#B91C1C', // → red + diffAddedStrong: '#008000', // → green + diffRemovedStrong: '#B91C1C', // → red + diffGutter: '#737373', // → black + diffMeta: '#5F5F5F', // → black + + roleUser: '#808000', // → yellow (#9A4A00 lands on red) + shellMode: '#800080', // → magenta (#7C3AED lands on bright blue) +}; + export type ResolvedTheme = 'dark' | 'light'; /** Synchronous palette lookup for built-in themes only. */ export function getBuiltInPalette(resolved: ResolvedTheme): ColorPalette { return resolved === 'dark' ? darkColors : lightColors; } + +/** Synchronous ANSI-16 fallback palette lookup for built-in themes only. */ +export function getBasicPalette(resolved: ResolvedTheme): ColorPalette { + return resolved === 'dark' ? basicDarkColors : basicLightColors; +} + +/** + * Infer the resolved theme from a built-in palette object identity, covering + * the ANSI-16 `basic*` variants. Returns `null` for custom-theme palettes. + */ +export function inferBuiltInResolvedTheme(palette: ColorPalette): ResolvedTheme | null { + if (palette === darkColors || palette === basicDarkColors) return 'dark'; + if (palette === lightColors || palette === basicLightColors) return 'light'; + return null; +} diff --git a/apps/kimi-code/src/tui/theme/detect.ts b/apps/kimi-code/src/tui/theme/detect.ts index 0f4d409532..591ea500dd 100644 --- a/apps/kimi-code/src/tui/theme/detect.ts +++ b/apps/kimi-code/src/tui/theme/detect.ts @@ -13,11 +13,25 @@ * the OSC reply gets eaten by the input loop. */ +import chalk from 'chalk'; + import { OSC11_QUERY, TERMINAL_THEME_DETECT_TIMEOUT_MS } from "#/tui/constant/terminal"; import type { ResolvedTheme } from "./colors"; import { parseOsc11BackgroundTheme } from "./terminal-background"; +/** + * True when the terminal advertises only ANSI-16 color support (chalk level 1, + * e.g. `TERM=xterm`). Hex palette values quantize badly at that depth — mid + * grays collapse to black and off-hue colors to white — so built-in themes + * swap in the `basic*` palette variants. Level 0 needs no fallback (chalk + * strips all color), and `FORCE_COLOR=2/3` deliberately overrides the TERM + * detection, so only level 1 qualifies. + */ +export function isBasicColorTerminal(): boolean { + return chalk.level === 1; +} + export interface DetectOptions { readonly timeoutMs?: number; } diff --git a/apps/kimi-code/src/tui/theme/index.ts b/apps/kimi-code/src/tui/theme/index.ts index e016def5d9..23c48bc740 100644 --- a/apps/kimi-code/src/tui/theme/index.ts +++ b/apps/kimi-code/src/tui/theme/index.ts @@ -2,16 +2,16 @@ * Theme system public API. */ -import { getBuiltInPalette } from './colors'; +import { getBasicPalette, getBuiltInPalette } from './colors'; import type { ColorPalette, ResolvedTheme } from './colors'; import { loadCustomThemeMerged } from './custom-theme-loader'; -import { detectTerminalTheme } from './detect'; +import { detectTerminalTheme, isBasicColorTerminal } from './detect'; export { currentTheme, Theme } from './theme'; export type { ColorToken } from './theme'; -export { darkColors, lightColors, getBuiltInPalette } from './colors'; +export { darkColors, lightColors, getBuiltInPalette, inferBuiltInResolvedTheme } from './colors'; export type { ColorPalette, ResolvedTheme } from './colors'; -export { detectTerminalTheme } from './detect'; +export { detectTerminalTheme, isBasicColorTerminal } from './detect'; export { loadCustomTheme, loadCustomThemeMerged, listCustomThemes } from './custom-theme-loader'; /** @@ -39,17 +39,21 @@ export function isThemeName(_value: string): _value is ThemeName { * - `'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. + * + * Built-in palettes are swapped for their ANSI-16 `basic*` variants on + * terminals that only support basic colors (`isBasicColorTerminal`); custom + * themes always load as written. */ export async function getColorPalette(theme: ThemeName): Promise { - if (theme === 'light') return getBuiltInPalette('light'); - if (theme === 'dark') return getBuiltInPalette('dark'); + if (theme === 'light') return getBuiltInPaletteForTerminal('light'); + if (theme === 'dark') return getBuiltInPaletteForTerminal('dark'); if (theme === 'auto') { const detected = await detectTerminalTheme(); - return getBuiltInPalette(detected); + return getBuiltInPaletteForTerminal(detected); } // custom theme const custom = await loadCustomThemeMerged(theme); - return custom ?? getBuiltInPalette('dark'); + return custom ?? getBuiltInPaletteForTerminal('dark'); } /** @@ -58,6 +62,11 @@ export async function getColorPalette(theme: ThemeName): Promise { * Custom themes are not supported here — falls back to dark. */ export function getColorPaletteSync(theme: ThemeName): ColorPalette { - if (theme === 'light') return getBuiltInPalette('light'); - return getBuiltInPalette('dark'); + if (theme === 'light') return getBuiltInPaletteForTerminal('light'); + return getBuiltInPaletteForTerminal('dark'); +} + +/** Built-in palette lookup with the ANSI-16 fallback applied. */ +export function getBuiltInPaletteForTerminal(resolved: ResolvedTheme): ColorPalette { + return isBasicColorTerminal() ? getBasicPalette(resolved) : getBuiltInPalette(resolved); } diff --git a/apps/kimi-code/test/tui/terminal-theme.test.ts b/apps/kimi-code/test/tui/terminal-theme.test.ts index 3159f80256..b190788cd1 100644 --- a/apps/kimi-code/test/tui/terminal-theme.test.ts +++ b/apps/kimi-code/test/tui/terminal-theme.test.ts @@ -1,8 +1,20 @@ -import { describe, expect, it, vi } from "vitest"; +import chalk from "chalk"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { TUIState } from "#/tui/kimi-tui"; -import { darkColors, lightColors } from "#/tui/theme/colors"; -import { getBuiltInPalette } from "#/tui/theme"; +import { + basicDarkColors, + basicLightColors, + darkColors, + lightColors, +} from "#/tui/theme/colors"; +import { + getBuiltInPalette, + getBuiltInPaletteForTerminal, + getColorPaletteSync, + inferBuiltInResolvedTheme, + isBasicColorTerminal, +} from "#/tui/theme"; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -173,3 +185,82 @@ describe('ColorPalette warning token', () => { expect(getBuiltInPalette('light')).toBe(lightColors); }); }); + +describe('ANSI-16 basic palette fallback', () => { + const originalLevel = chalk.level; + + afterEach(() => { + chalk.level = originalLevel; + }); + + const sgrCode = (hex: string): string => { + const match = /\u001B\[(\d+)m/.exec(chalk.hex(hex)("X")); + if (match === null) throw new Error(`chalk emitted no SGR code for ${hex}`); + return match[1]!; + }; + + it('detects basic-color terminals from chalk level 1 only', () => { + chalk.level = 1; + expect(isBasicColorTerminal()).toBe(true); + chalk.level = 0; + expect(isBasicColorTerminal()).toBe(false); + chalk.level = 2; + expect(isBasicColorTerminal()).toBe(false); + chalk.level = 3; + expect(isBasicColorTerminal()).toBe(false); + }); + + it('swaps built-in palettes for the basic variants at level 1', () => { + chalk.level = 1; + expect(getBuiltInPaletteForTerminal("dark")).toBe(basicDarkColors); + expect(getBuiltInPaletteForTerminal("light")).toBe(basicLightColors); + expect(getColorPaletteSync("dark")).toBe(basicDarkColors); + chalk.level = 3; + expect(getBuiltInPaletteForTerminal("dark")).toBe(darkColors); + expect(getBuiltInPaletteForTerminal("light")).toBe(lightColors); + expect(getColorPaletteSync("dark")).toBe(darkColors); + }); + + it('keeps every basic dark token off black (SGR 30) at level 1', () => { + chalk.level = 1; + for (const [token, hex] of Object.entries(basicDarkColors)) { + expect(sgrCode(hex), `basicDarkColors.${token}`).not.toBe("30"); + } + }); + + it('keeps every basic light token off white (SGR 37/97) at level 1', () => { + chalk.level = 1; + for (const [token, hex] of Object.entries(basicLightColors)) { + expect(sgrCode(hex), `basicLightColors.${token}`).not.toBe("37"); + expect(sgrCode(hex), `basicLightColors.${token}`).not.toBe("97"); + } + }); + + it('infers the resolved theme from built-in and basic palette identities', () => { + expect(inferBuiltInResolvedTheme(darkColors)).toBe("dark"); + expect(inferBuiltInResolvedTheme(basicDarkColors)).toBe("dark"); + expect(inferBuiltInResolvedTheme(lightColors)).toBe("light"); + expect(inferBuiltInResolvedTheme(basicLightColors)).toBe("light"); + // Custom-theme palettes are new objects and must not be inferred. + expect(inferBuiltInResolvedTheme({ ...lightColors })).toBeNull(); + }); + + it('keeps basic dark hues on their intended ANSI codes', () => { + chalk.level = 1; + expect(sgrCode(basicDarkColors.primary)).toBe("94"); + expect(sgrCode(basicDarkColors.diffAddedStrong)).toBe("92"); + expect(sgrCode(basicDarkColors.diffRemovedStrong)).toBe("91"); + expect(sgrCode(basicDarkColors.shellMode)).toBe("95"); + expect(sgrCode(basicDarkColors.warning)).toBe("93"); + }); + + it('keeps basic light hues on their intended ANSI codes', () => { + chalk.level = 1; + expect(sgrCode(basicLightColors.success)).toBe("32"); + expect(sgrCode(basicLightColors.diffAdded)).toBe("32"); + expect(sgrCode(basicLightColors.warning)).toBe("33"); + expect(sgrCode(basicLightColors.roleUser)).toBe("33"); + expect(sgrCode(basicLightColors.shellMode)).toBe("35"); + expect(sgrCode(basicLightColors.error)).toBe("31"); + }); +});