feat(opentui): add OpenTUI renderer - #335
Conversation
Render Markdown into OpenTUI renderables so content participates in terminal flexbox layout, scrolling and syntax highlighting, instead of being flattened to an ANSI string. Adds the package with block, inline, flow, table, pre and math components, a themeable default palette, Shiki-token reuse with a tree-sitter fallback, and unit plus paint-based snapshot tests. Also ships the opentui-gallery example (theme cycling and a streaming replay), rendering docs, and small fixes in @comark/react to the caret utils and Markdown/MarkdownDocument wrappers uncovered while sharing the streaming path, with tests to cover them.
|
@Tahul is attempting to deploy a commit to the NuxtLabs Team on Vercel. A member of the Team first needs to authorize it. |
|
Thank you for the PR @Tahul (also love to see you here ❤️ ) I tried your demo, it looks great, but clicking on the comark link is broken, could you have a look please? CleanShot.2026-08-12.at.17.08.34.mp4 |
The gallery declines OpenTUI's mouse capture so hyperlinks stay clickable, but that leaves the wheel dead in terminals which follow xterm and default DEC private mode 1007 off (e.g. Ghostty). Write the enable sequence by hand at startup and restore it on process exit, since OpenTUI does its own terminal setup and exposes no option for it. Document the mouse capture / hyperlink trade-off and the workaround in the comark-opentui README.
|
Hey @atinux! super happy to contribute here, really loving Comark and actively trying to use it on a TUI project I'm working on lately :) I've been able to reproduce your issue in Ghostty, but it was working fine in VSCode's terminal and iTerm2, I suppose you are using a xterm-based terminal? The last commit I just pushed should fix the issue but feel free to tell me if it still reproduces for you, and include the terminal that you use. I feel that issue is more of a OpenTUI issue than an issue about what I'm trying to ship here, but hopefully the tweak I shipped is enough for that to work on your end and can stay as a note in the package's documentation. |
🔗 Linked issue
Related to #124 (
@comark/cli). This is the renderer, not the CLI.❓ Type of change
📚 Description
Adds
@comark/opentui, which renders Markdown into OpenTUI renderables instead of a string.@comark/ansiis the right tool for printing to a terminal. It's the wrong shape when the Markdown lives inside a terminal app: an ANSI string can't take part in flexbox layout, reflow to the terminal width, or scroll. This produces renderables, so Markdown behaves like any other widget in the tree.Built on
@comark/reactrather than a second walker. OpenTUI drives a React reconciler, andrenderNodeis already host-agnostic (const Component = customComponent || tag).Changes to
@comark/reactMarkdownDocumentgained awrapperprop. The output root was a hardcoded<div>, which custom reconcilers reject (OpenTUI throwsUnknown component type: div). Accepts a component, orfalsefor a fragment. Default output is byte-identical, with a test asserting omitted andundefinedmatch. Threaded throughMarkdown→MarkdownClient→MarkdownLive. Unblocks any non-DOM React host, not just terminals.Caret injection no longer mutates the document.
findLastTextNodeAndAppendNodepushed into the node it was handed, andMarkdownDocumentonly shallow-copiesdocument.nodes, so the caret landed in the parsed document itself. Repeated calls stacked up:Its guard checked
parent[1]?.key !== 'stream-caret', which is the parent's own props, not whether a caret was already appended. So every re-render with the same document appended another one, each with the same React key. Replaced with a pureappendCaretToLastTextNodethat returns a copy and shares the untouched subtrees. This affects React DOM today, not just this renderer.MarkdownProps.componentsManifestnow usesComponentManifest. It was re-declared narrowly as(name: string) => Promise<{ default: ComponentType }>while the siblingMarkdownDocumentPropsalready used core's type, and the runtime branches onisPromiseLike. Type-only.Notes on the renderer
OpenTUI enforces a rule the DOM doesn't: a string or span-like throws unless its parent is a text node. Markdown mixes both in one container (a tight
liholds bare text, a loose one holds paragraphs), so containers group runs of inline children into atextand let block children through.Proseexposes that to::componentauthors and follows the#defaultslot.Some tags need explicit mapping because OpenTUI hosts of the same name mean something else: native
codeis a block-level highlighted panel, not an inline chip, and nativeinputis an interactive field, not a task-list checkbox.strong,em,b,i,u,a,br,spanare left to OpenTUI's own text-node renderables, including OSC 8 links fora. Unmapped tags (thedivfrom the html plugin, unregistered::components) resolve to a passthrough container instead of throwing, which matters when the Markdown comes from a model.Highlighting reuses the Shiki plugin's token colours when present, falling back to
CodeRenderable(tree-sitter). The fallback only colours languages whose grammar the host registered via OpenTUI'saddDefaultParsers, and OpenTUI ships none, so reusing Shiki's output is what makes highlighting work out of the box.Streaming parses in an effect and holds the previous document only while the source keeps growing.
MarkdownClient'suse()-behind-Suspense path doesn't commit under OpenTUI's reconciler: a suspended subtree stays hidden after its promise resolves. Out of scope here, but worth a look.Example
examples/3.cli/opentui-galleryrenders every supported construct on one scrollable page, mirroring the ANSI demo's sample plus task lists, nested lists, raw HTML and an unregistered component. Theme cycling and a streaming replay:pnpm smokethere renders it headlessly and checks 32 content markers plus chrome stability across a streaming toggle.One layout gotcha that hit the example and will hit any consumer: a flex child won't shrink below its content height, so a long document makes a scroll region hold its ground and Yoga takes the rows out of the surrounding chrome instead.
minHeight={0}on the scroll region andflexShrink={0}on the chrome. Documented.Runtime
Rendering needs native FFI, via
bun:ffior, from Node 26.1,node:ffibehind--experimental-ffi. Parsing and the component map don't.The suite splits along that line, so nothing here changes what CI can run:
pnpm test: tag coverage and layout logic, any supported Node (35 tests)pnpm test:paint: rendered frames, needs Node >= 26.1 (50 tests)test:paintreports and exits 0 on older Node rather than failing, so it's inert on the current Node 24 CI. It self-heals to a newer Node if one is installed;COMARK_NODEoverrides the search.📝 Checklist
pnpm verifyand it passes.pnpm verifyis green except@comark/svelte, which fails locally onbrowserType.launch: Executable doesn't existuntilpnpm exec playwright installruns. Reproducible on cleanmain, unrelated to this branch. CI installs chromium explicitly.Files
packages/comark-opentui/(renderer, theme, 85 tests)examples/3.cli/opentui-gallery/(pnpm dev:opentui)docs/content/3.rendering/9.opentui.mdpackages/comark-react/test/{wrapper,caret}.test.tspackages/comark-react/src/components/{MarkdownDocument,Markdown,MarkdownClient}.tsxpackages/comark-react/src/utils/caret.tsscripts/sync-plugins.mjs(register the package)test/bundle.test.ts(size snapshot)package.json(dev:opentui)39 files, +4254 / -22.
Tests
@comark/react@comark/opentuiFollow-ups
comark-vueandcomark-angulareach carry their own copy of the caret helper with the same mutation bug (src/utils/caret.ts). Angular's tests assert the mutating behaviour. I only fixed react since that's what this renderer uses. Happy to do all three.README.md,1.getting-started/1.installation.md,5.api/3.reference.md,7.kb/0.why-comark.md,6.compare/*. Left alone: OpenTUI fits neither the "UI Frameworks" nor "String Renderers" grouping cleanly, so placement is your call.plugins/mathhas a renderer-side component.mermaidandbindinghave no obvious terminal equivalent.@comark/ansidoes the same (handlers/table.ts), and OpenTUI exports no width helper.