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.
68 lines
2.5 KiB
TypeScript
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}
|
|
/>
|
|
)
|
|
}
|