diff --git a/.changeset/fix-tui-markdown-bold-uses-textstrong.md b/.changeset/fix-tui-markdown-bold-uses-textstrong.md new file mode 100644 index 0000000000..ba0256939d --- /dev/null +++ b/.changeset/fix-tui-markdown-bold-uses-textstrong.md @@ -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). 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 1b53bce0c0..0a7bafb1fa 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -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), diff --git a/apps/kimi-code/test/tui/theme/pi-tui-theme.test.ts b/apps/kimi-code/test/tui/theme/pi-tui-theme.test.ts new file mode 100644 index 0000000000..9b8ef7721b --- /dev/null +++ b/apps/kimi-code/test/tui/theme/pi-tui-theme.test.ts @@ -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); + } + }); +}); diff --git a/packages/pi-tui/src/components/markdown.ts b/packages/pi-tui/src/components/markdown.ts index 34a0df9223..d156dd5a28 100644 --- a/packages/pi-tui/src/components/markdown.ts +++ b/packages/pi-tui/src/components/markdown.ts @@ -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; @@ -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; }