These docs describe the main branch, including unreleased changes. Read the 0.8.0 docs.

Markdown

Markdown renders document structure and can use Tree-sitter highlighting in fenced code blocks. Use Code when the full content is source code.

Availability

FieldAvailability
Package@opentui/core
Core renderableMarkdownRenderable
React<markdown> (automatic)
Solid<markdown> (automatic)
StatusBuilt in

Basic usage

Renderable API

import { MarkdownRenderable, SyntaxStyle, RGBA, createCliRenderer } from "@opentui/core"

const renderer = await createCliRenderer()

const syntaxStyle = SyntaxStyle.fromStyles(
  {
    "markup.heading.1": { fg: RGBA.fromHex("#58A6FF"), bold: true },
    "markup.list": { fg: RGBA.fromHex("#FF7B72") },
    "markup.raw": { fg: RGBA.fromHex("#A5D6FF") },
    default: { fg: RGBA.fromHex("#E6EDF3") },
  },
  renderer.nativeScene,
)

const markdown = new MarkdownRenderable(renderer, {
  id: "readme",
  width: 60,
  content: "# Hello\n\n- One\n- Two\n\n```ts\nconst x = 1\n```",
  syntaxStyle,
})

renderer.root.add(markdown)

Fenced language normalization

Markdown normalizes each code fence info string to a file type before Tree-sitter highlighting:

  • tsx -> typescriptreact

  • .jsx -> javascriptreact

  • TSX title=Button.tsx -> typescriptreact

  • Dockerfile -> dockerfile

Markdown uses infoStringToFiletype() for this step. To add or change mappings at runtime, edit the maps that it reads:

import { extensionToFiletype, basenameToFiletype } from "@opentui/core"

extensionToFiletype.set("templ", "html")
basenameToFiletype.set("mytoolrc", "yaml")

Concealment

Set conceal: true to hide Markdown markers, such as backticks and emphasis markers:

const markdown = new MarkdownRenderable(renderer, {
  content: "**bold** and `code`",
  syntaxStyle,
  conceal: true,
})

concealCode controls concealment inside fenced code blocks separately. Its default is false.

Streaming updates

Use streaming mode when content arrives in chunks. Keep streaming: true while you append chunks. When the content is complete, set markdown.streaming = false to finish parsing the trailing block. Tables show trailing partial rows, with missing cells shown empty.

const markdown = new MarkdownRenderable(renderer, {
  content: "",
  syntaxStyle,
  streaming: true,
})

markdown.content += "# Live log\n"
markdown.content += "- line 1\n"
markdown.streaming = false

Stable block prefix

parseMarkdownIncremental reports how many parsed tokens at the head of the stream are unlikely to change after an append. The renderable exposes this value as a count of top-level render blocks. You can commit those blocks while the unstable tail changes.

To use it, set internalBlockMode: "top-level". The renderable then keeps each top-level Markdown block as a separate child renderable. These blocks include headings, paragraphs, lists, tables, and fenced code. markdown._stableBlockCount gives the current stable prefix length. In the default mode, it is always 0. This matches the shape that ScrollbackSurface expects for row-by-row commits.

import { MarkdownRenderable, RGBA, SyntaxStyle } from "@opentui/core"

const syntaxStyle = SyntaxStyle.fromStyles(
  {
    default: { fg: RGBA.fromHex("#E6EDF3") },
  },
  renderer.nativeScene,
)

const md = new MarkdownRenderable(renderer, {
  content: "",
  syntaxStyle,
  streaming: true,
  internalBlockMode: "top-level",
})

md.content = "# Title\n\nPara 1"
// md._stableBlockCount is 0. While streaming, the newest parsed tokens stay unstable.

md.content = "# Title\n\nPara 1\n\nPara 2"
// Still 0.

md.content = "# Title\n\nPara 1\n\nPara 2\n\nPara 3"
// md._stableBlockCount is 2: "# Title" and "Para 1" are stable.

internalBlockMode is an internal, experimental flag for the built-in scrollback streaming demo. This status does not apply to Markdown or to its other options. For normal rendering, keep the default value, "coalesced". This value combines sibling blocks into larger render blocks and keeps the existing layout.

Markdown tables

Use tableOptions to set the style, sizing, wrapping, borders, and selection of Markdown tables.

const markdown = new MarkdownRenderable(renderer, {
  content: "| Service | Status |\n| --- | --- |\n| api | ok |",
  syntaxStyle,
  tableOptions: {
    style: "grid",
    widthMode: "full",
    columnFitter: "balanced",
    wrapMode: "word",
    cellPadding: 1,
    cellPaddingX: 2,
    cellPaddingY: 0,
    borders: true,
    outerBorder: true,
    borderStyle: "rounded",
    borderColor: "#6b7280",
    selectable: true,
  },
})

tableOptions

OptionTypeDefaultDescription
style"grid" | "columns"depends on block modeVisual preset (see Table styles)
widthMode"content" | "full"depends on style"full" expands columns to fill available width
columnFitter"proportional" | "balanced""proportional"How columns shrink when space is constrained
wrapMode"none" | "char" | "word""word"Wrapping mode inside each table cell
cellPaddingnumber0Padding on all sides of each cell
cellPaddingXnumbercellPaddingHorizontal padding on each side
cellPaddingYnumbercellPaddingVertical padding on each side
bordersbooleandepends on styleEnable inner and outer borders
outerBorderbooleanbordersOverride outer border visibility
borderStyleBorderStyle"single"Table border character set
borderColorColorInputconceal fg or #888888Border color for markdown tables
selectablebooleantrueEnable table cell text selection

Table styles

tableOptions.style picks a preset. The preset sets the defaults for borders, outerBorder, and widthMode:

  • "grid": a boxed table with visible borders. Defaults to borders: true and widthMode: "full". This is the normal table rendering.

  • "columns": borderless columns with a 2-column gap. Defaults to borders: false and widthMode: "content". Use it for append-only output where a full-width grid is too heavy.

This Name/Status sample shows both presets:

Grid:

┌────────┬────────┐
│ Name   │ Status │
├────────┼────────┤
│ api    │ ready  │
├────────┼────────┤
│ worker │ paused │
└────────┴────────┘

Columns:

Name    Status
api     ready
worker  paused

If you do not pass style, it is "columns" when internalBlockMode is "top-level", and "grid" otherwise. Individual fields, such as borders: true, override the preset defaults.

Custom node rendering

Use renderNode to replace the renderable for a block. Return undefined or null to use the default renderable. Call context.defaultRender() to create the default renderable yourself:

const markdown = new MarkdownRenderable(renderer, {
  content: "# Title\n\nHello",
  syntaxStyle,
  renderNode: (token, context) => {
    if (token.type === "heading") {
      return context.defaultRender()
    }
    return undefined
  },
})

Custom fenced-code languages

Use createMarkdownCodeBlockRenderer to replace only specific fenced-code languages. It matches each key against the normalized info string. A tsx fence matches the key typescriptreact. A custom name such as taskflow matches itself.

import {
  BoxRenderable,
  MarkdownRenderable,
  SyntaxStyle,
  TextRenderable,
  createMarkdownCodeBlockRenderer,
  type CliRenderer,
  type MarkdownCodeBlockRenderer,
} from "@opentui/core"

const syntaxStyle = SyntaxStyle.fromStyles({ default: {} }, renderer.nativeScene)

const renderTaskFlow =
  (renderer: CliRenderer): MarkdownCodeBlockRenderer =>
  (token) => {
    const steps = token.text
      .split("\n")
      .filter((line) => line.startsWith("step "))
      .map((line) => line.slice("step ".length))

    const card = new BoxRenderable(renderer, {
      border: true,
      borderStyle: "rounded",
      borderColor: "#38BDF8",
      paddingX: 1,
      flexDirection: "column",
      width: "100%",
    })

    for (const step of steps) {
      card.add(new TextRenderable(renderer, { content: `- ${step}`, width: "100%" }))
    }

    return card
  }

const markdown = new MarkdownRenderable(renderer, {
  content: "```taskflow\nstep Parse markdown done\nstep Render widget active\n```",
  syntaxStyle,
  renderNode: createMarkdownCodeBlockRenderer({
    taskflow: renderTaskFlow(renderer),
  }),
})

Properties

PropertyTypeDefaultDescription
contentstring""Markdown source
syntaxStyleSyntaxStylerequiredStyle definitions for tokens
fgColorInput-Base foreground color, also used by inner code blocks
bgColorInput-Base background color, also used by inner code blocks
concealbooleantrueHide markdown markers in markdown text
concealCodebooleanfalseHide markers inside fenced code blocks
streamingbooleanfalseIncremental mode. Set false to finalize
tableOptionsMarkdownTableOptions-Options for markdown table rendering
internalBlockMode"coalesced" | "top-level""coalesced"Experimental: expose top-level blocks as separate renderables
treeSitterClientTreeSitterClient-Custom Tree-sitter client for code blocks
renderNode(token: Token, context: RenderNodeContext) => Renderable | null | undefined-Custom renderer for each Markdown block

Markdown rebuilds its blocks at once when you set content. It applies syntaxStyle, fg, bg, conceal, and concealCode changes later, in its lifecycle pass (onLifecyclePass). Core runs lifecycle passes after the frame callbacks and before layout, so the new styles appear in that frame. These five setters do not request a frame. If nothing else requests one, call markdown.requestRender().

If you assign onLifecyclePass on a Markdown renderable, even to null, Markdown no longer applies these changes by itself. Call refreshStyles() after each change. refreshStyles() rebuilds the blocks at once and requests a frame.

OpenTUI bundles a limited parser set. See the Tree-sitter reference before you highlight other fenced-code languages.

Use Code for source-only content. Add a Line number gutter to a compatible code or editor renderable. Use Diff for patches. Use TextTable for table data that does not start as Markdown.

Changes0.8.0, 0.7.0
0.8.0
core: A change to syntaxStyle or fg of a MarkdownRenderable restyles list markers, and with internalBlockMode: "top-level" it also restyles list items and table cells. Before, they kept the old theme’s colors. (#1841)
0.7.0
core: detectLinks() styles bare URLs with markup.link.label from the new syntaxStyle context field and keeps their other attributes. Bare URLs in Markdown prose now look like link labels. (#1765)