From b8bf368b02c67f3b4e28299f908860dab809bfb6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 22:59:58 -0500 Subject: [PATCH] fix(tui): anchor markdown body prose to the theme foreground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/__tests__/markdown.test.ts | 89 ++++++++++++++++++++++++++- ui-tui/src/components/markdown.tsx | 25 +++++--- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index 0c2b2c5d28e1..214fd5e7ca03 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -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) + } + }) +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index fb7fafd73c58..65067179b4bf 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -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 (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.slice(last)}) } - return {parts.length ? parts : text} + return ( + + {parts.length ? parts : 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() + nodes.push() i++ continue @@ -998,7 +1009,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { nodes.push( · - + ) i++ @@ -1019,7 +1030,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { {marker} - + ) @@ -1036,7 +1047,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { {numbered[2]}. - + ) @@ -1141,7 +1152,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { } start('paragraph') - nodes.push() + nodes.push() i++ }