Files
hermes-agent/ui-tui-opentui-v2/src/view/markdown.tsx
T
alt-glitch a572a1eae4 feat(opentui-v2): Phase 2b-ii — native markdown for assistant text (Phase 2 done)
Assistant text parts now render through the NATIVE markdown renderable instead of
plain spans — bold/headings/lists/fences render, raw `**`/backtick markup is
concealed (spec §7; never hand-roll a parser).

- view/markdown.tsx: `<code filetype="markdown" streaming conceal drawUnstyledText>`
  (CodeRenderable — opencode's v2 AssistantText path; `<markdown>` +
  internalBlockMode="top-level" deferred paint headlessly). SyntaxStyle.fromStyles
  is derived from the theme (markup.* → theme.color.*, non-hex colors guarded) and
  cached by theme-object identity so all text parts share one instance, rebuilt
  only on skin change. drawUnstyledText paints raw text immediately while
  Tree-sitter highlighting settles (and makes it headless-capturable).
- view/messageLine.tsx: text-part Match renders <Markdown> instead of <text>.
- test/lib/render.ts: settle async markdown via flush(); captureFrame gains an
  `until` option (waitForFrame) for content that paints after the first pass.

Verified: bun run check green (23 tests / 5 files). Live tmux: a markdown reply
(heading + bold word + 2-item list) rendered with `**` concealed (grep -c '**' = 0);
Ctrl+C clean, no orphan. Phase 2 complete (2a shell + 2b-i parts/tools + 2b-ii
markdown) — smoke steps 1–4 run live. Next: Phase 3 blocking prompts.
2026-06-08 14:49:52 +00:00

68 lines
2.5 KiB
TypeScript

/**
* Markdown — assistant/reasoning text rendered with the NATIVE renderable, never
* a hand-rolled parser (spec v4 §7). Uses `<code filetype="markdown" streaming>`
* (`CodeRenderable`) — opencode's v2 text path (`session-v2.tsx:358` AssistantText)
* — backed by the same markdown tokenizer + Tree-sitter as `<markdown>`, but it
* paints reliably (incl. headless): `drawUnstyledText` draws the raw text
* immediately while highlighting settles, `conceal` hides the `**`/backtick
* markers, `streaming` feeds incremental deltas.
*
* The `SyntaxStyle` is derived from the active theme (no hardcoded styles — §7.5)
* and cached by theme-object identity, so all text parts share ONE instance and
* it's rebuilt only when the skin changes (a new `Theme` object).
*/
import { RGBA, SyntaxStyle } from '@opentui/core'
import type { Theme } from '../logic/theme.ts'
import { useTheme } from './theme.tsx'
const FALLBACK = RGBA.fromHex('#E6EDF3')
const HEX6 = /^#[0-9a-fA-F]{6}$/
/** Theme colors are usually hex but may be `ansi256(n)`/`rgb(...)` after light-mode
* normalization — only hand hex to RGBA.fromHex, else fall back. */
function rgba(color: string): RGBA {
return HEX6.test(color) ? RGBA.fromHex(color) : FALLBACK
}
function buildSyntaxStyle(theme: Theme): SyntaxStyle {
const c = theme.color
return SyntaxStyle.fromStyles({
default: { fg: rgba(c.text) },
'markup.heading': { bold: true, fg: rgba(c.primary) },
'markup.heading.1': { bold: true, fg: rgba(c.primary) },
'markup.heading.2': { bold: true, fg: rgba(c.accent) },
'markup.heading.3': { bold: true, fg: rgba(c.accent) },
'markup.bold': { bold: true, fg: rgba(c.text) },
'markup.italic': { fg: rgba(c.text), italic: true },
'markup.list': { fg: rgba(c.accent) },
'markup.quote': { fg: rgba(c.muted) },
'markup.link': { fg: rgba(c.accent) },
'markup.raw': { fg: rgba(c.label) },
'markup.raw.block': { fg: rgba(c.label) }
})
}
let cache: { theme: Theme; style: SyntaxStyle } | undefined
function syntaxStyleFor(theme: Theme): SyntaxStyle {
if (cache && cache.theme === theme) return cache.style
const style = buildSyntaxStyle(theme)
cache = { style, theme }
return style
}
export function Markdown(props: { text: string; streaming?: boolean }) {
const theme = useTheme()
return (
<code
filetype="markdown"
content={props.text}
syntaxStyle={syntaxStyleFor(theme())}
streaming={props.streaming ?? false}
drawUnstyledText
conceal
fg={theme().color.text}
/>
)
}