Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions docs/content/3.rendering/9.opentui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
---
title: OpenTUI Terminal Rendering
description: Render Markdown as a terminal UI layout tree with OpenTUI, for CLIs and agent interfaces that need real layout, scrolling and syntax highlighting.
navigation:
title: OpenTUI (Terminal UI)
icon: i-lucide-square-terminal
links:
- label: ANSI Renderer
icon: i-lucide-terminal
to: /rendering/ansi
color: neutral
variant: soft
- label: Streaming API
icon: i-lucide-radio
to: /api/auto-close
color: neutral
variant: soft
---

The `@comark/opentui` package renders Markdown into [OpenTUI](https://github.com/sst/opentui) renderables β€” a real layout tree rather than a string.

Use it over [`@comark/ansi`](/rendering/ansi) when the Markdown lives inside a terminal *application*: content takes part in flexbox layout, reflows to the terminal width, scrolls, and sits alongside your other widgets. Use `@comark/ansi` when you just want to print.

## Installation

::code-group

```bash [pnpm]
pnpm add @comark/opentui @opentui/core @opentui/react
```

```bash [npm]
npm install @comark/opentui @opentui/core @opentui/react
```

```bash [yarn]
yarn add @comark/opentui @opentui/core @opentui/react
```

```bash [bun]
bun add @comark/opentui @opentui/core @opentui/react
```

::

## `<Markdown>`

```tsx
/** @jsxImportSource @opentui/react */
import { Markdown } from '@comark/opentui'

export function Answer({ text, isStreaming }: { text: string, isStreaming: boolean }) {
return (
<Markdown streaming={isStreaming} caret={isStreaming}>
{text}
</Markdown>
)
}
```

| Prop | Type | Description |
| ---- | ---- | ----------- |
| `children` / `value` | `string \| MarkdownDocument` | Markdown source, or an already-parsed document. |
| `streaming` | `boolean` | Re-parse as the source grows, closing unterminated constructs. |
| `caret` | `boolean \| { class: string }` | Append a cursor to the last text node. |
| `components` | `Record<string, ComponentType>` | Component overrides, merged over the defaults. |
| `plugins` | `ComarkPlugin[]` | Parser plugins. Must be a stable reference. |
| `options` | `ParserOptions` | Parser options. Must be a stable reference. |
| `theme` | `Partial<MarkdownTheme>` | Colour and glyph overrides, merged over `defaultTheme`. |
| `data` | `Record<string, unknown>` | Runtime data for `:`-prefixed props. |

### Pre-parsed documents

`MarkdownDocument` skips the parser entirely, for hosts that parse in a worker, another process, or a build step:

```tsx
import { MarkdownDocument } from '@comark/opentui'

<MarkdownDocument value={document} />
```

## Streaming

`streaming` re-parses as the source grows and auto-closes dangling constructs, so a half-arrived `**bold` renders bold instead of showing its asterisks until the closer lands. The previous frame is held while a new parse is in flight, so output never blanks between deltas.

## Theming

```tsx
<Markdown theme={{ codeFg: '#a5d6ff', bullet: 'β–Έ', alert: { note: '#58a6ff' } }}>
{text}
</Markdown>
```

Every field is optional and merges over `defaultTheme`. Pass `syntaxStyle` to make the tree-sitter fallback follow the host application's theme.

## Layout

`<Markdown>` renders a normal box tree, so it participates in the surrounding flex layout. One flexbox detail is worth knowing when embedding it in a scroll region:

```tsx
<box flexDirection="column" flexGrow={1}>
<box flexShrink={0}>{/* header */}</box>

<scrollbox flexGrow={1} flexShrink={1} minHeight={0}>
<Markdown>{text}</Markdown>
</scrollbox>

<box flexShrink={0}>{/* footer */}</box>
</box>
```

A flex child will not shrink below its content height by default. A long document therefore makes the scroll region hold its ground, and Yoga takes the rows out of the surrounding chrome instead β€” collapsing a header and drawing its border through its own text. `minHeight={0}` on the scroll region and `flexShrink={0}` on the chrome keeps everything where you put it, whatever the document does.

## Syntax highlighting

Fenced code takes one of two paths:

1. **Shiki tokens**, when [the Shiki plugin](/plugins/shiki) is registered β€” the colours already in the document are reused, so nothing extra has to be installed. Where Shiki emits both light and dark variants, the dark one wins.
2. Otherwise OpenTUI's `CodeRenderable`, which highlights with tree-sitter. That path only produces colour for languages whose grammar the host registered through OpenTUI's `addDefaultParsers`, and OpenTUI ships none.

Shiki registers a fixed language set (vue, tsx, svelte, typescript, javascript, bash, json, yaml, astro) and does **not** load languages on demand, so anything else needs its grammar passed in:

```ts
import shiki from 'comark/plugins/shiki'
import python from 'shiki/dist/langs/python.mjs'

const plugins = [shiki({ languages: [python] })]
```

A `language [filename]` info string renders as a dimmed header above the block.

## Components

`::components` and tag overrides go through `components`, as in every other renderer:

```tsx
import { Markdown, Prose, withNode } from '@comark/opentui'

const Alert = withNode(({ children, __node }) => (
<box border borderStyle="rounded" borderColor="#f0883e" paddingLeft={1}>
<Prose node={__node}>{children}</Prose>
</box>
))

<Markdown components={{ alert: Alert }}>{text}</Markdown>
```

::warning
Wrap a component's children in `Prose`, not a bare `box`. A one-paragraph component body is auto-unwrapped by the parser, so `children` can be loose strings β€” and OpenTUI throws if a string is not inside a text node. `Prose` sorts children into text hosts and blocks, and follows the `#default` slot when the component uses named slots.
::

`withNode` opts a component into receiving the raw Comark node on `__node`. Named slots arrive as `slotTitle`, `slotFooter`, and so on; `#default` arrives as `children`.

## GitHub alerts

`> [!NOTE]` and friends parse to a blockquote carrying `as: "note"`, and Comark resolves components from `as` β€” so `note`, `tip`, `important`, `warning` and `caution` are registered under those names. Colours come from `theme.alert`; override a kind through `components` to change its layout.

## Runtime

Rendering needs native FFI, which OpenTUI reaches through `bun:ffi` or, from Node 26.1, `node:ffi` behind `--experimental-ffi`:

```bash
node --experimental-ffi --import tsx app.tsx
```

Parsing and the component map carry no such requirement, so a host that only builds documents runs anywhere.

## Example

`examples/3.cli/opentui-gallery` in the repository renders every supported construct on one scrollable page, with theme cycling and a streaming replay:

```bash
pnpm dev:opentui
```
27 changes: 27 additions & 0 deletions examples/3.cli/opentui-gallery/alternate-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Alternate scroll mode (DEC private mode 1007).
*
* While the alternate screen is up, a terminal in this mode turns the wheel
* into cursor up/down instead of scrolling its own scrollback β€” which is what
* lets an app scroll with the wheel without capturing the mouse.
*
* The gallery needs it because it declines OpenTUI's mouse capture, so links
* stay clickable (see `gallery.tsx`). Terminals differ on the default: iTerm2
* has it on, Ghostty follows xterm and leaves it off, so without this the wheel
* does nothing there. Written by hand because OpenTUI performs terminal setup
* itself and exposes no option for this mode.
*/
const ENABLE = '\x1b[?1007h'
const DISABLE = '\x1b[?1007l'

/**
* Ask the terminal to send cursor keys on wheel, and give the mode back when
* the process ends.
*
* Restoring on `exit` rather than from the quit handler covers ctrl-c, which
* OpenTUI's `exitOnCtrlC` handles inside the renderer.
*/
export function enableAlternateScroll(): void {
process.stdout.write(ENABLE)
process.on('exit', () => process.stdout.write(DISABLE))
}
Loading