From a572a1eae4a6cd2ef0d86242f8449e01156b0da2 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 14:49:52 +0000 Subject: [PATCH] =?UTF-8?q?feat(opentui-v2):=20Phase=202b-ii=20=E2=80=94?= =?UTF-8?q?=20native=20markdown=20for=20assistant=20text=20(Phase=202=20do?= =?UTF-8?q?ne)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: `` (CodeRenderable — opencode's v2 AssistantText path; `` + 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 instead of . - 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. --- ui-tui-opentui-v2/src/test/render.test.tsx | 4 +- ui-tui-opentui-v2/src/view/markdown.tsx | 67 ++++++++++++++++++++++ ui-tui-opentui-v2/src/view/messageLine.tsx | 7 +-- 3 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 ui-tui-opentui-v2/src/view/markdown.tsx diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index 54a454c856b..a1f8b24ac68 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -29,7 +29,7 @@ describe('App render (Phase 1, themed)', () => { ), - { 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)', () => { ), - { width: 60, height: 16 } + { until: 'Listing files:', width: 60, height: 16 } ) expect(frame).toContain('Listing files:') // text part diff --git a/ui-tui-opentui-v2/src/view/markdown.tsx b/ui-tui-opentui-v2/src/view/markdown.tsx new file mode 100644 index 00000000000..1f560830670 --- /dev/null +++ b/ui-tui-opentui-v2/src/view/markdown.tsx @@ -0,0 +1,67 @@ +/** + * Markdown — assistant/reasoning text rendered with the NATIVE renderable, never + * a hand-rolled parser (spec v4 §7). Uses `` + * (`CodeRenderable`) — opencode's v2 text path (`session-v2.tsx:358` AssistantText) + * — backed by the same markdown tokenizer + Tree-sitter as ``, 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 ( + + ) +} diff --git a/ui-tui-opentui-v2/src/view/messageLine.tsx b/ui-tui-opentui-v2/src/view/messageLine.tsx index f96d0d8c047..34d559db186 100644 --- a/ui-tui-opentui-v2/src/view/messageLine.tsx +++ b/ui-tui-opentui-v2/src/view/messageLine.tsx @@ -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 }) { )} - {t => ( - - {t().text} - - )} + {t => } )}