fix(tui): anchor markdown body prose to the theme foreground

Plain prose rendered with no color at all, so it fell through to the
terminal's default foreground while inline tokens on the same line
(code, links, math, muted markers) carried a theme tone. One rendered
line therefore mixed two foregrounds — and because an inline token can
match mid-word (`re-render_terminal_output` trips underscore-italic),
so could a single word.

MdInline now takes an optional color and the body-prose callers
(paragraphs, bullets, numbered items, definitions) pass t.color.text.
Callers that already wrap it in a colored parent — headings, quotes,
footnotes — keep inheriting and are untouched.
This commit is contained in:
Brooklyn Nicholson 2026-07-25 22:59:58 -05:00
parent 9b909115fc
commit b8bf368b02
2 changed files with 106 additions and 8 deletions

View file

@ -1,12 +1,13 @@
import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import chalk from 'chalk'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
import { DEFAULT_THEME, LIGHT_THEME } from '../theme.js'
const matches = (text: string) => [...text.matchAll(INLINE_RE)].map(m => m[0])
const BEL = String.fromCharCode(7)
@ -329,3 +330,89 @@ describe('renderTable CJK width alignment', () => {
expect(qwenCol2).toBe(headerCol2)
})
})
describe('body prose stays in the theme palette', () => {
// Prose used to render in the terminal's DEFAULT foreground while inline
// tokens beside it carried a theme color, so one line mixed two inks.
// Because an inline token can match mid-word, so could a single word.
// LIGHT_THEME is the vehicle here because every tone in it is hex, so
// emitted SGR maps back to palette entries without format juggling.
const foregroundRuns = (text: string): string[] => {
// chalk is a singleton and defaults to level 0 under vitest (no TTY),
// which would emit no SGR at all and make every assertion here vacuous.
const savedLevel = chalk.level
chalk.level = 3
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 80, isTTY: true, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
React.createElement(Box, { width: 70 }, React.createElement(Md, { cols: 68, t: LIGHT_THEME, text })),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
chalk.level = savedLevel
return [...output.matchAll(new RegExp(`${ESC}\\[38;2;(\\d+);(\\d+);(\\d+)m`, 'g'))].map(
m =>
'#' +
m
.slice(1, 4)
.map(v => Number(v).toString(16).padStart(2, '0'))
.join('')
)
}
const PALETTE = new Set(
Object.values(LIGHT_THEME.color)
.filter((v): v is string => typeof v === 'string' && v.startsWith('#'))
.map(v => v.toLowerCase())
)
const INK = LIGHT_THEME.color.text.toLowerCase()
it('opens a paragraph with the theme ink, not the terminal default', () => {
expect(foregroundRuns('plain prose line')[0]).toBe(INK)
})
it('keeps every foreground on a mixed-token line inside the palette', () => {
// `render_terminal_output` trips the underscore-italic token mid-word —
// the exact shape that split one word across two inks.
const fg = foregroundRuns('set the `flag` and re-render_terminal_output for the run')
expect(fg.length).toBeGreaterThan(0)
for (const c of fg) {
expect(PALETTE.has(c)).toBe(true)
}
})
it('returns to the theme ink after an inline token, not to the terminal default', () => {
const fg = foregroundRuns('before `code` after')
expect(fg[0]).toBe(INK)
expect(fg.at(-1)).toBe(INK)
})
it('themes list-item prose too', () => {
for (const text of ['- a bullet item', '1. a numbered item']) {
expect(foregroundRuns(text)).toContain(INK)
}
})
})

View file

@ -539,7 +539,14 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
)
}
function MdInline({ t, text }: { t: Theme; text: string }) {
// `color` anchors the prose runs to a palette tone. Block callers that
// already wrap MdInline in a colored <Text> (headings, quotes, footnotes)
// leave it unset and inherit that parent; body-prose callers pass
// `t.color.text` so plain words are themed instead of falling through to
// the terminal's default foreground. Without it a single line mixes
// themed spans (code, links, math) with unthemed prose — and because an
// inline token can match mid-word, so can a single word.
function MdInline({ color, t, text }: { color?: string; t: Theme; text: string }) {
const parts: ReactNode[] = []
let last = 0
@ -650,7 +657,11 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
parts.push(<Text key={parts.length}>{text.slice(last)}</Text>)
}
return <Text wrap="wrap-trim">{parts.length ? parts : text}</Text>
return (
<Text {...(color ? { color } : {})} wrap="wrap-trim">
{parts.length ? parts : text}
</Text>
)
}
// Cross-instance parsed-children cache: useMemo's per-instance cache dies
@ -881,7 +892,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
if (closeIdx < 0) {
start('paragraph')
nodes.push(<MdInline key={key} t={t} text={line} />)
nodes.push(<MdInline color={t.color.text} key={key} t={t} text={line} />)
i++
continue
@ -998,7 +1009,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
nodes.push(
<Text key={`${key}-def-${i}`} wrap="wrap-trim">
<Text color={t.color.muted}> · </Text>
<MdInline t={t} text={def} />
<MdInline color={t.color.text} t={t} text={def} />
</Text>
)
i++
@ -1019,7 +1030,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
<Box key={key} paddingLeft={indentDepth(bullet[1]!) * 2}>
<Text wrap="wrap-trim">
<Text color={t.color.muted}>{marker} </Text>
<MdInline t={t} text={task ? task[2]! : bullet[2]!} />
<MdInline color={t.color.text} t={t} text={task ? task[2]! : bullet[2]!} />
</Text>
</Box>
)
@ -1036,7 +1047,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
<Box key={key} paddingLeft={indentDepth(numbered[1]!) * 2}>
<Text wrap="wrap-trim">
<Text color={t.color.muted}>{numbered[2]}. </Text>
<MdInline t={t} text={numbered[3]!} />
<MdInline color={t.color.text} t={t} text={numbered[3]!} />
</Text>
</Box>
)
@ -1141,7 +1152,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
}
start('paragraph')
nodes.push(<MdInline key={key} t={t} text={line} />)
nodes.push(<MdInline color={t.color.text} key={key} t={t} text={line} />)
i++
}