Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/xterm-basic-color-palette.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -56,7 +56,7 @@ export async function applyReloadedTuiConfig(
config: TuiConfig,
): Promise<void> {
const resolved = config.theme === 'auto'
? (currentTheme.palette === lightColors ? 'light' : 'dark')
? (inferBuiltInResolvedTheme(currentTheme.palette) ?? 'dark')
: undefined;
await host.applyTheme(config.theme, resolved);
host.refreshTerminalThemeTracking();
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2972,7 +2972,7 @@ export class KimiTUI {

private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise<void> {
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();
Expand Down
86 changes: 86 additions & 0 deletions apps/kimi-code/src/tui/theme/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 14 additions & 0 deletions apps/kimi-code/src/tui/theme/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
29 changes: 19 additions & 10 deletions apps/kimi-code/src/tui/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<ColorPalette> {
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');
}

/**
Expand All @@ -58,6 +62,11 @@ export async function getColorPalette(theme: ThemeName): Promise<ColorPalette> {
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve light-theme identity for ANSI-16 palettes

When chalk.level === 1, this returns basicLightColors rather than lightColors, but both applyThemeChoice in commands/config.ts and applyReloadedTuiConfig in commands/reload.ts still infer the current resolved theme using currentTheme.palette === lightColors. Consequently, selecting auto from an explicit light theme—or reloading while auto has resolved light—initially applies the dark palette; on terminals that do not answer the subsequent theme-report queries, it remains dark indefinitely. Recognize basicLightColors in that inference or track the resolved theme independently of palette object identity.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in c755faa: added inferBuiltInResolvedTheme(), which recognizes all four built-in palette identities (dark/light plus both ANSI-16 basic variants) and returns null for custom-theme palettes. Both call sites (applyThemeChoice in config.ts, applyReloadedTuiConfig in reload.ts) now use it with a ?? 'dark' fallback, preserving the previous semantics for custom themes. Covered by a new test including the basic variants and a custom-palette negative case.

}
97 changes: 94 additions & 3 deletions apps/kimi-code/test/tui/terminal-theme.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");
});
});