Skip to content

feat(opentui): add OpenTUI renderer - #335

Open
Tahul wants to merge 1 commit into
comarkdown:mainfrom
Tahul:feat/opentui-renderer
Open

feat(opentui): add OpenTUI renderer#335
Tahul wants to merge 1 commit into
comarkdown:mainfrom
Tahul:feat/opentui-renderer

Conversation

@Tahul

@Tahul Tahul commented Aug 6, 2026

Copy link
Copy Markdown

🔗 Linked issue

Related to #124 (@comark/cli). This is the renderer, not the CLI.

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Adds @comark/opentui, which renders Markdown into OpenTUI renderables instead of a string.

@comark/ansi is 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/react rather than a second walker. OpenTUI drives a React reconciler, and renderNode is already host-agnostic (const Component = customComponent || tag).

Changes to @comark/react

MarkdownDocument gained a wrapper prop. The output root was a hardcoded <div>, which custom reconcilers reject (OpenTUI throws Unknown component type: div). Accepts a component, or false for a fragment. Default output is byte-identical, with a test asserting omitted and undefined match. Threaded through MarkdownMarkdownClientMarkdownLive. Unblocks any non-DOM React host, not just terminals.

Caret injection no longer mutates the document. findLastTextNodeAndAppendNode pushed into the node it was handed, and MarkdownDocument only shallow-copies document.nodes, so the caret landed in the parsed document itself. Repeated calls stacked up:

after call 1: carets=1
after call 2: carets=2
after call 3: carets=3

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 pure appendCaretToLastTextNode that returns a copy and shares the untouched subtrees. This affects React DOM today, not just this renderer.

MarkdownProps.componentsManifest now uses ComponentManifest. It was re-declared narrowly as (name: string) => Promise<{ default: ComponentType }> while the sibling MarkdownDocumentProps already used core's type, and the runtime branches on isPromiseLike. 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 li holds bare text, a loose one holds paragraphs), so containers group runs of inline children into a text and let block children through. Prose exposes that to ::component authors and follows the #default slot.

Some tags need explicit mapping because OpenTUI hosts of the same name mean something else: native code is a block-level highlighted panel, not an inline chip, and native input is an interactive field, not a task-list checkbox. strong, em, b, i, u, a, br, span are left to OpenTUI's own text-node renderables, including OSC 8 links for a. Unmapped tags (the div from 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's addDefaultParsers, 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's use()-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-gallery renders 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 dev:opentui

pnpm smoke there 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 and flexShrink={0} on the chrome. Documented.

Runtime

Rendering needs native FFI, via bun:ffi or, from Node 26.1, node:ffi behind --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:paint reports 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_NODE overrides the search.

📝 Checklist

  • I have linked an issue or discussion.
  • I have run pnpm verify and it passes.
  • I have updated the documentation accordingly.

pnpm verify is green except @comark/svelte, which fails locally on browserType.launch: Executable doesn't exist until pnpm exec playwright install runs. Reproducible on clean main, unrelated to this branch. CI installs chromium explicitly.


Files

New packages/comark-opentui/ (renderer, theme, 85 tests)
examples/3.cli/opentui-gallery/ (pnpm dev:opentui)
docs/content/3.rendering/9.opentui.md
packages/comark-react/test/{wrapper,caret}.test.ts
Modified packages/comark-react/src/components/{MarkdownDocument,Markdown,MarkdownClient}.tsx
packages/comark-react/src/utils/caret.ts
scripts/sync-plugins.mjs (register the package)
test/bundle.test.ts (size snapshot)
package.json (dev:opentui)

39 files, +4254 / -22.

Tests

suite before after
@comark/react 51 69
@comark/opentui - 35 unit + 50 paint
everything else unchanged unchanged

Follow-ups

  • comark-vue and comark-angular each 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.
  • The renderer set is listed in 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.
  • Only plugins/math has a renderer-side component. mermaid and binding have no obvious terminal equivalent.
  • Table column widths are counted in code points, so a double-width glyph under-measures a column. @comark/ansi does the same (handlers/table.ts), and OpenTUI exports no width helper.
  • Comark appends the streaming caret to the last top-level node holding text, and pushes it as a top-level node when there is none. That puts an inline node at document root, harmless in HTML but every renderer separating inline from block has to handle it. Handled here; maybe worth fixing upstream.

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
Tahul requested a review from farnabaz as a code owner August 6, 2026 18:37
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

@Tahul is attempting to deploy a commit to the NuxtLabs Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant