feat(opentui): add OpenTUI renderer - #335
Open
Tahul wants to merge 1 commit into
Open
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔗 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.