mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
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:
parent
b72ac77783
commit
a572a1eae4
3 changed files with 71 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
67
ui-tui-opentui-v2/src/view/markdown.tsx
Normal file
67
ui-tui-opentui-v2/src/view/markdown.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue