Files
hermes-agent/ui-tui-opentui-v2/src/view/messageLine.tsx
T
alt-glitch 6cefb7c5b5 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

71 lines
2.7 KiB
TypeScript

/**
* MessageLine — renders one transcript row (spec v4 §2 / §7). An assistant turn
* is ONE ordered `parts[]` dispatched by `<Switch>`/`<Match>` on `part.type`, so
* text / reasoning / tool interleave INLINE (the §7 fix for "tools dump below").
* User/system rows (and settled/resumed assistant rows with no parts) render flat
* `text`. Fully themed; rich text via <b>/<span>, never an attributes bitmask (§8 #1).
*
* Stable `id` per part as the <For> key so a new tool part below a streaming text
* part doesn't remount it. Native <markdown> for text parts lands in 2b-ii.
*/
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'
const GUTTER = 2
export function MessageLine(props: { message: Message }) {
const theme = useTheme()
const m = () => props.message
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·')
const glyphFg = () =>
m().role === 'assistant' ? theme().color.accent : m().role === 'user' ? theme().color.prompt : theme().color.muted
const hasParts = () => (m().parts?.length ?? 0) > 0
return (
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: m().role === 'user' ? 1 : 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text>
<span style={{ fg: glyphFg() }}>{glyph()}</span>
</text>
</box>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Show
when={m().role === 'assistant' && hasParts()}
fallback={
<text>
<span style={{ fg: theme().color.text }}>{m().text}</span>
</text>
}
>
<For each={m().parts ?? []}>
{part => (
<Switch>
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
<Match when={part.type === 'reasoning' && part}>
{r => (
<text>
<span style={{ fg: theme().color.muted }}>{r().text}</span>
</text>
)}
</Match>
<Match when={part.type === 'text' && part}>
{t => <Markdown text={t().text} streaming={m().streaming ?? false} />}
</Match>
</Switch>
)}
</For>
</Show>
<Show when={m().streaming && !hasParts()}>
<text>
<span style={{ fg: theme().color.muted }}></span>
</text>
</Show>
</box>
</box>
)
}