Skip to content

Ink

Ink is “React for the terminal.” It is a React custom renderer (a reconciler, like react-dom or react-native, but targeting a cell grid instead of a DOM or native views) that lets you build command-line interfaces with the full React component model: function components, hooks, props, JSX, and composition. Output is laid out with Flexbox via yoga-layout (the same C layout engine Facebook ships for React Native), so a CLI is described declaratively — flexDirection, justifyContent, alignItems, padding, margin, width/height — and Ink computes the cell positions.

The primitive vocabulary is deliberately tiny:

  • <Box> — the flexbox container. Borders, padding, margin, dimensions, flex props. Every layout is nested Boxes.
  • <Text> — the only thing that actually renders glyphs. Color, dim, bold, italic, underline, strikethrough, inverse, background color, wrapping/truncation.
  • <Static> — renders output once, above the live region, and never repaints it. This is how Ink streams permanent log lines (e.g. completed test results) while a spinner animates below — the permanent lines scroll up into terminal scrollback and are never part of the diff again.
  • <Transform> — wraps children and transforms the final string output (e.g. apply a gradient, a per-character effect).

The hooks are the runtime surface:

  • useInput((input, key) => …) — raw keypress stream with a parsed key object (upArrow, return, ctrl, meta, etc.).
  • useFocus() / useFocusManager() — a focus-management system: components register as focusable, Ink tracks which one is active, Tab/Shift-Tab cycle focus, and focusNext/focusPrevious/focus(id) move it programmatically. This is the part most hand-rolled TUIs get wrong.
  • useApp() — lifecycle (exit()), useStdout() / useStderr() — escape hatches to write raw, useStdin() — raw-mode access.

Under the hood: a React reconciler builds a tree of Ink host nodes → yoga computes the layout → Ink renders to a string buffer → a diff against the previous frame writes only the changed region to stdout (it uses Yoga’s dirty tracking + its own output diffing, and log-update to rewrite the live region in place). It is, in other words, a virtual-DOM-style reconciler whose render target is terminal cells.

Ink is the dominant “serious CLI” framework in the JS ecosystem: Gatsby, Prisma, Shopify CLI, GitHub Copilot CLI, Cloudflare Wrangler, Jest (the watch-mode UI), Terraform’s CDK, and many others build their interactive output on it.

Why this is a deep architecture entry for KN-86

Section titled “Why this is a deep architecture entry for KN-86”

This is the most architecturally provocative entry in Batch 8 because it poses a question KN-86’s runtime has already half-answered and should answer deliberately: should the KEC Lisp UI layer be a declarative, reactive, composable component model — a “Lisp-native Ink” — or should cartridges keep drawing imperatively cell-by-cell?

ADR-0027 (ratified 2026-06-07) settled the substrate: termbox2 owns the cell grid in C, the canonical grid is 128×75 with a 128×150 half-block pseudo-pixel canvas, and Fe cartridges see a constrained cell API(cell-set col row ch fg bg), (cell-print …), (cell-clear-cart-region), (half-block-set …), plus semantic input callbacks (on-key …), (on-tick frame-num), etc. (See “CLAUDE.md Canonical Hardware Specification” and ADR-0027 §Decision.) That is an imperative immediate-mode surface: a cart’s (on-tick) handler clears its region and redraws every cell it wants, every frame. ADR-0027 explicitly modeled cart authoring on the cl-termbox2 snake listing — “loop, render, yield.” Ink is the opposite pole: you never write a cell, you describe a tree and a reconciler computes the cells.

Three Ink mechanisms are worth analyzing as patterns for a future KEC Lisp UI DSL layered on top of the ADR-0027 cell API (not replacing it):

1. Virtual-tree → terminal-cell reconciliation (the diff)

Section titled “1. Virtual-tree → terminal-cell reconciliation (the diff)”

Ink’s reconciler keeps a previous frame and writes only the delta to stdout. KN-86 already wants this independently — it surfaces as the Dirty Flag pattern in game-programming-patterns.md and the per-box dirty bit in tuibox.md. On a Pi Zero 2 W rendering to a Linux-console tty1 (ADR-0027 §7), full 128×75 cell rewrites every frame at 30 Hz are affordable but not free, and termbox2 itself already diffs its back buffer against the front buffer on tb_present. So the cell-level diff is already handled by termbox2 below the cell API — KN-86 does not need to re-implement Ink’s string-diff. What a KEC Lisp component layer would add is a higher diff: “this component’s props didn’t change, so don’t re-run its render lambda at all.” That is the real win of a reconciler — skipping work, not skipping bytes. For a Fe VM with arena discipline (ADR-0004), avoiding re-running render lambdas is the scarce resource, more than avoiding cell writes.

Pattern verdict: the cell-byte diff is termbox2’s job (already solved); the component-memoization diff is the only part of Ink’s reconciler that would earn its complexity in Fe — and only if cart UIs become tree-shaped enough to benefit. For bespoke full-screen gameplay carts that repaint wholesale anyway, it buys little. For chrome-heavy, form-heavy, list-heavy surfaces (mission board, REPL, nEmacs, bare-deck tabs) it could buy a lot.

Ink’s headline feature — yoga flexbox — is the part that does not straightforwardly translate. The KN-86 panel is a fixed 128×75 grid; there is no responsive reflow, no resize on device (ADR-0027 notes on-resize is “mostly relevant to the desktop emulator; the device panel is fixed”). A full flexbox engine is overkill for a fixed grid, and yoga is a C++ dependency KN-86 would never vendor into a C11 + Fe runtime. But the idea — declare nesting + alignment intent and let a layout pass compute cell rectangles — is exactly what spares cart authors from hardcoding column math, which ADR-0027 called out as a chronic pain (carts “must reason about pixel padding, cell bytes, and Row 0/Row 24 contracts”). A KN-86-scale layout helper would be far smaller than yoga: a handful of combinators (hbox, vbox, pad, align, fixed, fill) computing integer cell rectangles inside the cart-usable region (rows 1..73 — chrome is runtime-owned). This is the JoinHorizontal / JoinVertical / Place model already noted for Lip Gloss in termui.md, expressed as Lisp combinators. No flexbox engine; just box-composition primitives that resolve to cell coordinates.

Pattern verdict: borrow the box-composition mental model, not yoga. A minimal Fe layout combinator library (resolving to integer cell rects in the 128-col cart region) gives 80% of Ink’s authoring ergonomics at ~1% of the implementation cost.

This is the most directly transplantable Ink pattern. useFocus/useFocusManager solves a problem KN-86 absolutely has: with 31 physical keys and no mouse, navigation between interactive elements is the whole interaction model. Ink’s contract — focusable elements register, exactly one is active, a directional/Tab input moves focus, the active element gets the key stream — is the right shape for KN-86 surfaces that have multiple selectable regions (mission cards on the mission board, fields in an intake form, items in a list). ADR-0027 routes already-classified semantic events (:up :down :left :right :enter …) to the cart’s (on-key); a focus manager is the natural layer that consumes those events and decides which sub-widget they act on. This belongs in the Fe-side UI library, sitting between (on-key) and per-widget handlers.

Pattern verdict: adopt the focus-manager pattern outright as a Fe-side library on top of the ADR-0027 semantic event callbacks. It is the single highest-value Ink idea for a keyboard-only device.

ADR-0027 deliberately kept the cart FFI imperative and constrained — and that boundary is correct and should not be re-litigated (carts must not be able to break chrome or seize input policy). The open architecture question is one layer up: does KN-86 ship a KEC Lisp UI DSL — a declarative/composable layer that compiles down to the imperative cell API — so that cart authors describe UI as data and the library does the cell writes? Ink is the proof that the component model is pleasant and productive for exactly this class of fixed-output, keyboard-driven, monospace UI. The recommendation developed in tui-library-shortlist.md is: borrow Ink’s three patterns (component memoization where trees are deep, box-composition combinators, focus management) into an optional Fe UI library, layered on the ADR-0027 cell API — do not import React, yoga, or a reconciler. A “Lisp-native Ink” is a worthwhile internal library for chrome-heavy surfaces; it is not a substrate and not a cart-facing requirement. Bespoke gameplay carts keep drawing cells directly; structured surfaces opt into the component layer.

No image downloaded — Ink is captured as an architecture/pattern reference, not a visual source. Its README GIFs (the Jest watch UI, gradient/border demos) are worth a glance for the feel of component-composed terminal output.

  • Cross-link tui-library-shortlist.md — the Task-3 architect’s evaluation that carries this entry’s recommendation into a verdict (component-model patterns yes; React/yoga/reconciler no).
  • Cross-link ink-web.md — Ink rendered into a browser xterm.js with an actual component catalog (status-bar, tab-bar, table, select). That entry is “what a complete Ink component library looks like,” and its status-bar is the Row-74 keybind-hint-bar reference.
  • Cross-link game-programming-patterns.md — Ink’s reconciler is the Dirty Flag pattern; its hooks model is Observer; useFocusManager is a small State machine. Nystrom’s “don’t over-apply a pattern” doctrine is the right lens for deciding how much of Ink to borrow.
  • Cross-link tuibox.md — tuibox’s UI→Screen→Box hierarchy is the C-side version of the same composition idea; Ink is the declarative-React version. KN-86’s answer (per ADR-0027) is termbox2 below + an optional Fe composition library above.
  • Cross-link termui.md — its JoinHorizontal/JoinVertical/Place note is the box-composition primitive set this entry recommends expressing as Fe combinators.
  • Supersession note: earlier batches recommended Bubble Tea + Lip Gloss (Go) as the primary stack. ADR-0027 supersedes that — the substrate is now termbox2 in C with a constrained Fe cell API. Ink’s value here is patterns for an optional Fe UI library, not a framework adoption.