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/fix-tui-markdown-bold-uses-textstrong.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix TUI markdown bold rendering as dark-on-dark. The pi-tui `MarkdownTheme` adapter emitted `chalk.bold(text)` — only the SGR bold code, no foreground colour — so bold spans in assistant messages inherited whatever fg the surrounding style left set and most terminals rendered them as a dim gray on dark backgrounds, nearly unreadable and unresponsive to theme changes. Route bold through `chalk.bold.hex(currentTheme.color('textStrong'))`, matching the token's documented role ("emphasised / bold text"). No behaviour change for callers that already override foreground colour before applying bold (e.g. diff-preview intra-line highlights).
12 changes: 12 additions & 0 deletions apps/kimi-code/src/tui/theme/pi-tui-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,19 @@ export function createMarkdownTheme(options?: { transient?: boolean }): Markdown
// prefix. Ordered lists arrive as "1. " / "2. " and are left
// untouched by the leading-dash anchor.
listBullet: (text) => chalk.hex(currentTheme.color('text'))(text.replace(/^-/, '•')),
// Structural bold — used by pi-tui's heading composition and inline style
// contexts. This carries the SGR bold code only; a caller wrapping other
// themed elements around it (e.g. `heading`) then supplies the fg colour.
// Do NOT pin a foreground here — an outer `heading` wraps its own text
// colour, and pinning here would let the inner bold's fg override the
// heading colour. See #1872 for the split between structural bold and
// emphasised strong spans.
bold: (text) => chalk.bold(text),
// Emphasised markdown text (`**text**`). Route through `textStrong` so
// theme-driven bold is actually visible on dark backgrounds — before the
// split, this path shared `bold` above and inherited only the SGR bold
// code, which most terminals render as a dim gray. Fixes #1872.
strong: (text) => chalk.bold.hex(currentTheme.color('textStrong'))(text),
italic: (text) => chalk.italic(text),
strikethrough: (text) => chalk.strikethrough(text),
underline: (text) => chalk.underline(text),
Expand Down
82 changes: 82 additions & 0 deletions apps/kimi-code/test/tui/theme/pi-tui-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import chalk from 'chalk';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { darkColors, lightColors } from '#/tui/theme';
import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme';
import { currentTheme } from '#/tui/theme/theme';

// The pi-tui theme adapters wrap output with `chalk`. Under vitest chalk
// defaults to level 0 (no color) because there's no TTY, which would strip
// every SGR from the output we want to assert on. Force truecolor for the
// duration of this file, matching the pattern used in banner/footer/server
// tests elsewhere.
const previousChalkLevel = chalk.level;
beforeAll(() => {
chalk.level = 3;
});
afterAll(() => {
chalk.level = previousChalkLevel;
});

describe('createMarkdownTheme', () => {
it('emits the textStrong hex for strong (**bold**) spans so themes govern them (#1872)', () => {
// Regression for #1872. Before the fix, strong spans ran through `bold`,
// which was `chalk.bold(text)` — SGR bold code only, no fg — so most
// terminals rendered them as a dim gray on dark backgrounds, ignoring
// the theme's `textStrong` token. Split emphasised text into its own
// `strong` handler and assert it carries the token's hex.
const previousPalette = currentTheme.palette;
try {
currentTheme.setPalette(darkColors);
const theme = createMarkdownTheme();
const output = theme.strong?.('hello') ?? '';
// chalk.hex('#F5F5F5') → foreground truecolor SGR `[38;2;245;245;245m`.
expect(output).toContain('[38;2;245;245;245m');
// Still bold — no regression on the SGR bold code.
expect(output).toContain('[1m');
expect(output).toContain('hello');
} finally {
currentTheme.setPalette(previousPalette);
}
});

it('follows the active palette when the theme changes (#1872)', () => {
// Guards against a future refactor that reads `textStrong` once at
// theme-factory time instead of at each call — `createMarkdownTheme` must
// stay reactive to `currentTheme.setPalette()` so `/reload-tui` picks up
// custom themes.
const previousPalette = currentTheme.palette;
try {
const theme = createMarkdownTheme();
currentTheme.setPalette(darkColors);
const dark = theme.strong?.('x') ?? '';
currentTheme.setPalette(lightColors);
const light = theme.strong?.('x') ?? '';
// Different textStrong on the two palettes → different foreground SGR.
expect(dark).not.toBe(light);
} finally {
currentTheme.setPalette(previousPalette);
}
});

it('keeps structural bold uncoloured so heading composition preserves heading colour (#1872)', () => {
// pi-tui composes headings as `theme.heading(theme.bold(text))` — if
// `bold` pinned `textStrong`, every heading would render with the strong
// fg (dim gray on custom themes that use `textStrong` for a subtle
// emphasis colour), silently regressing heading rendering. Pin the
// separation: `bold` must emit ONLY the SGR bold code, no fg SGR, so an
// outer wrapper's fg can win.
const previousPalette = currentTheme.palette;
try {
currentTheme.setPalette(darkColors);
const theme = createMarkdownTheme();
const output = theme.bold('hello');
expect(output).toContain('[1m');
expect(output).toContain('hello');
// No 38;2 truecolor fg — the fg must come from an outer wrapper.
expect(output).not.toMatch(/\[38;2;/);
} finally {
currentTheme.setPalette(previousPalette);
}
});
});
18 changes: 17 additions & 1 deletion packages/pi-tui/src/components/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,18 @@ export interface MarkdownTheme {
quoteBorder: (text: string) => string;
hr: (text: string) => string;
listBullet: (text: string) => string;
/** Structural bold wrapper — used by headings and inline style contexts to
* carry the SGR bold code, and by the "strong" span path when `strong` is
* not provided. Callers wrapping other themed elements (e.g. `heading`)
* around this expect it to *not* pin a foreground colour, so the outer
* wrapper's fg can win. Use `strong` for emphasised text spans. */
bold: (text: string) => string;
/** Optional emphasised-text handler for markdown `**strong**` spans. When
* absent, the renderer falls back to `bold`. Separating the two lets a
* theme give strong spans a distinct colour (e.g. `textStrong`) without
* bleeding that colour into headings, which compose `bold` inside
* `heading` and would otherwise inherit the strong fg. */
strong?: (text: string) => string;
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
Expand Down Expand Up @@ -520,7 +531,12 @@ export class Markdown implements Component {

case "strong": {
const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.bold(boldContent) + stylePrefix;
// Prefer `theme.strong` for emphasised spans; fall back to
// `theme.bold` so themes without a dedicated strong handler keep
// working. Structural bold (headings, inline style contexts)
// deliberately keeps `theme.bold` so an outer wrapper's fg can win.
const strongFn = this.theme.strong ?? this.theme.bold;
result += strongFn(boldContent) + stylePrefix;
break;
}

Expand Down