mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
9b909115fc
commit
b8bf368b02
2 changed files with 106 additions and 8 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue