MDX.org.ai
Executable CodemdxeRuntimes

@mdxe/ink

Ink 7 terminal viewer over the @mdxe/tui seam

@mdxe/ink

The Ink viewer for the @mdxe/tui seam. It displays rendered terminal bytes and handles input; it never renders MDX itself, never invents or changes a byte, and never attaches to a pipe.

Ink is the no-native-binary viewer: Node ≥ 22 and Bun, with ink ^7 and react ^19.2 as peer dependencies.

Installation

pnpm add @mdxe/ink ink react

Usage

import { createInkViewer } from '@mdxe/ink'
 
const viewer = createInkViewer()
 
const handle = await viewer.mount(frames, {
  terminal: { stdout: process.stdout, stdin: process.stdin },
})
 
handle.onAction((action) => {
  // quit | scroll | page | jump | select | back | search | insert | resize
  // The host decides what each action means.
})
 
await viewer.unmount()

frames is a FrameStream — an AsyncIterable<Uint8Array | string> where each chunk is one complete frame from the renderer (@mdxui/text). Input is decoded from stdin by the seam's shared createInputSource and mapped through defaultKeymap; pass events and/or keymap on mount to supply your own.

Choosing a viewer at runtime

inkViewer(options) is a ViewerFactory that imports even this package's viewer module lazily:

import { inkViewer } from '@mdxe/ink'
 
const factory = inkViewer()
const viewer = await factory()

Guarantees

  • Lazy — importing @mdxe/ink loads neither ink nor react; mount() does, after the TTY guard has passed.
  • Never on pipes — when stdout or stdin is not a TTY, mount() rejects with a ViewerError whose code is the stable string NOT_A_TTY, writes nothing, and never touches raw mode. Route agents to the plain bytes instead.
  • strip(paint(bytes)) === bytes — each frame is laid out with Ink's renderToString and compared to the register bytes before it is written. Whenever Ink would alter a byte (it trims trailing whitespace per line and wraps lines wider than its layout width) the frame is written verbatim instead, so Ink never wraps a line — the terminal does.
  • Terminal-width layout — each paint lays out at terminal.stdout.columns (a resize is honoured; DEFAULT_COLUMNS = 80 when unreported). Ink's layout cost is linear in the width, so wrapping is prevented by the verbatim fallback, not by a huge width.
  • Unmount releases — raw mode is released, the cursor restored, and nothing is written afterwards. unmount() is idempotent; a quit action unmounts.

Options

createInkViewer({
  /** Fixed Ink layout width. Default: terminal.stdout.columns, else DEFAULT_COLUMNS (80). */
  columns?: number,
})

paint(frame, runtime, columns?) is exported as a pure function for hosts that want the laid-out bytes without a terminal; it reports which engine produced them (ink or verbatim).

Conformance and benchmark

The @mdxe/tui/conformance suite runs against this viewer in its test suite. pnpm --filter @mdxe/ink bench runs the shared benchmark harness and prints JSON (startupMs, installBytes, repaint p50Ms/p95Ms/maxMs).

  • @mdxe/tui — the seam: Viewer, input abstraction, conformance suite, benchmark harness
  • ink — React for CLIs

On this page