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.
This commit is contained in:
alt-glitch 2026-06-08 14:49:52 +00:00
parent b72ac77783
commit a572a1eae4
3 changed files with 71 additions and 7 deletions

View file

@ -29,7 +29,7 @@ describe('App render (Phase 1, themed)', () => {
<App store={store} />
</ThemeProvider>
),
{ width: 60, height: 16 }
{ until: 'Hi there, glitch!', width: 60, height: 16 }
)
expect(frame).toContain('Hermes Agent') // default brand.name
@ -74,7 +74,7 @@ describe('App render (Phase 1, themed)', () => {
<App store={store} />
</ThemeProvider>
),
{ width: 60, height: 16 }
{ until: 'Listing files:', width: 60, height: 16 }
)
expect(frame).toContain('Listing files:') // text part

View file

@ -0,0 +1,67 @@
/**
* 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}
/>
)
}

View file

@ -11,6 +11,7 @@
import { For, Match, Show, Switch } from 'solid-js'
import type { Message } from '../logic/store.ts'
import { Markdown } from './markdown.tsx'
import { useTheme } from './theme.tsx'
import { ToolPart } from './toolPart.tsx'
@ -52,11 +53,7 @@ export function MessageLine(props: { message: Message }) {
)}
</Match>
<Match when={part.type === 'text' && part}>
{t => (
<text>
<span style={{ fg: theme().color.text }}>{t().text}</span>
</text>
)}
{t => <Markdown text={t().text} streaming={m().streaming ?? false} />}
</Match>
</Switch>
)}