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++ }