Skip to content

Chapter 2: Twinki — The Terminal Rendering Engine

In Chapter 1: TUI, we saw how the TUI mounts React components to build the interface you interact with — the chat view, the input buffer, the tool-approval dialogs. But how do those components actually become characters in your terminal? When you type a message and the LLM streams back a response, what turns that React state change into colored text on screen — without the whole display flickering?

That's Twinki's job.


Motivation: Why Not Just Use Ink?

Ink is the most popular React-for-terminals library. It works, but it has a fundamental design choice that doesn't scale to a complex, streaming AI assistant:

Ink repaints the entire screen on every frame, up to 60 times per second.

Imagine a theater where the stagehands tear down every prop and rebuild the entire set from scratch between each line of dialogue — even if only one actor moved. That's Ink. It works for small apps, but when you have a chat history with hundreds of lines, a streaming markdown response, and a status bar all updating at different rates, the result is visible flicker and wasted work.

Twinki takes the opposite approach: differential line-based rendering. It's like a stagehand who watches the script, notices that only the spotlight position changed, and adjusts just that one thing. Everything else stays untouched.

Ink Twinki
Rendering Full repaint every frame Only changed lines
Frame rate Fixed 60fps timer Event-driven (render on change)
Screen mode Alternate screen (loses scrollback) Inline (preserves scrollback)
Flicker Common with complex layouts Zero (synchronized output)

Use Case: Streaming an LLM Response

Here's a concrete scenario. The agent is writing a long code block, streaming token by token. Each new token appends to the last line of the response.

With Ink's approach, every single token triggers a full repaint of the entire chat history — hundreds of lines rewritten to stdout. The terminal struggles to keep up; you see tearing.

With Twinki, the renderer compares the new output against its shadow buffer (the previous frame). It finds that only line 47 changed — a few characters were appended. It moves the cursor to line 47, overwrites just that line, and returns. One line written instead of hundreds.

The user sees a smooth, flicker-free stream. The terminal does minimal work.


Key Concepts

1. React Reconciler

React doesn't know about terminals. It knows about a reconciler — a pluggable layer that translates React's virtual tree operations (create node, update props, remove child) into real mutations on a host environment.

React DOM is a reconciler for browsers. React Native is a reconciler for mobile. Twinki is a reconciler for terminals.

The reconciler lives in host-config.ts and implements the interface that react-reconciler expects:

// packages/twinki/packages/twinki/src/reconciler/host-config.ts
export const reconciler = ReactReconciler({
  supportsMutation: true,
  createInstance(type, props) { return createNode(type, props); },
  commitUpdate(instance, _type, oldProps, newProps) {
    instance.props = newProps;
    applyYogaProps(instance.yogaNode, newProps);
    instance.rootContainer.onRender(); // trigger re-render
  },
  // ... appendChild, removeChild, insertBefore, etc.
});

When you call setState in a component, React walks its virtual tree, figures out what changed, and calls these methods. Twinki turns those calls into mutations on its own node tree — TwinkiNode objects with Yoga layout nodes attached.

2. Yoga Flexbox Layout

Terminal UIs need layout — boxes side by side, text wrapping, padding, alignment. Twinki uses Yoga, Facebook's cross-platform flexbox engine (the same one React Native uses).

Every <Box> and <Text> component gets a Yoga node. When the tree changes, Yoga recalculates positions and sizes in a single pass:

// packages/twinki/packages/twinki/src/layout/yoga.ts
export function applyYogaProps(node: YogaNode, props: ComponentProps) {
  if (props.width !== undefined) node.setWidth(props.width);
  if (props.flexDirection) node.setFlexDirection(/* ... */);
  if (props.padding) node.setPadding(/* ... */);
  // ... dimensions, flex, alignment, spacing, borders
}

The result: a layout tree where every node knows its exact (x, y, width, height) in terminal coordinates.

3. Tree-to-Lines Rendering

After layout, the tree renderer walks the node tree and converts it into an array of terminal lines — plain strings with ANSI escape codes for color and style:

// packages/twinki/packages/twinki/src/renderer/tree-renderer.ts
export function renderNode(node: TwinkiNode, maxWidth: number): string[] {
  if (node.type === 'twinki-text') return renderText(node, width);
  if (node.type === 'twinki-box')  return renderBox(node, width, height);
  // ...
}

A <Text color="green">Hello</Text> becomes "\x1b[32mHello\x1b[39m" — the ANSI escape sequence for green text followed by a reset. The renderer handles wrapping, padding, borders, and compositing nested boxes into flat line arrays.

4. Differential Rendering (The Core Innovation)

This is where Twinki earns its name. The TUI class maintains a shadow buffer — the array of lines from the previous frame. On each render, it compares new lines against old ones and picks one of four strategies:

Strategy When What it does
First render No previous frame Write all lines, no cursor movement
Width changed Terminal resized Clear screen, full redraw
Shrink clear Content got shorter Clear and redraw (optional)
Differential Normal update Rewrite only changed lines

Strategy 4 is the common case and the key to flicker-free output:

// packages/twinki/packages/twinki/src/renderer/tui.ts (simplified)
// Strategy 4: Differential
let firstChanged = -1;
let lastChanged = -1;
for (let i = 0; i < maxLen; i++) {
  if (previousLines[i] !== newLines[i]) {
    if (firstChanged === -1) firstChanged = i;
    lastChanged = i;
  }
}
// Move cursor to firstChanged, rewrite only firstChanged..lastChanged

The entire output is wrapped in synchronized output escape sequences (CSI ?2026h / CSI ?2026l). This tells the terminal: "buffer everything between these markers and paint it all at once." No partial frames ever reach the screen.


How It All Fits Together

Here's the full pipeline from a React state change to pixels on your terminal:

sequenceDiagram
    participant React
    participant Reconciler
    participant Yoga
    participant TreeRenderer
    participant TUI

    React->>Reconciler: setState triggers commit
    Reconciler->>Yoga: Update node props, mark dirty
    Yoga->>Yoga: Recalculate flexbox layout
    Reconciler->>TreeRenderer: renderTree(root, width)
    TreeRenderer->>TreeRenderer: Walk nodes → line array
    TreeRenderer->>TUI: New lines ready
    TUI->>TUI: Diff against shadow buffer
    TUI->>TUI: Write only changed lines (synchronized)
  1. React detects a state change and commits updates through the reconciler
  2. Reconciler mutates TwinkiNode objects and updates their Yoga layout nodes
  3. Yoga recalculates positions and sizes for the affected subtree
  4. Tree Renderer walks the node tree and produces an array of ANSI-styled terminal lines
  5. TUI diffs the new lines against its shadow buffer and writes the minimal set of changes, wrapped in synchronized output to prevent tearing

The ReactBridge: Connecting React to the TUI

The glue between React's reconciler and Twinki's rendering engine is the ReactBridge class. It implements the Component interface that the TUI expects, and internally holds the React root container:

// packages/twinki/packages/twinki/src/reconciler/render.ts
class ReactBridge implements Component {
  private container: RootContainer;
  private dirty = true;
  private cachedLines: string[] = [];

  render(width: number): string[] {
    if (this.dirty) {
      const result = renderTree(this.container, width);
      this.cachedLines = result.liveLines;
      this.dirty = false;
    }
    return this.cachedLines;
  }
}

When React commits changes, the bridge marks itself dirty. On the next render tick, the TUI calls bridge.render(width), which triggers the tree-to-lines conversion. The bridge also handles static content — lines that scroll up into the terminal's scrollback buffer (like completed chat messages) versus live content that stays in the interactive area.


The render() Entry Point

The public API is a single function — drop-in compatible with Ink:

import { render, Text, Box } from 'twinki';

const App = () => (
  <Box flexDirection="column" padding={1}>
    <Text color="green">Hello from Twinki!</Text>
  </Box>
);

const instance = render(<App />);
await instance.waitUntilExit();

Under the hood, render() creates a ProcessTerminal, a TUI, a ReactBridge, and wires them together. It sets up input handling (including Ctrl+C), mouse event dispatch, and the React context that hooks like useApp() and useInput() consume.


Internal Implementation

Here's where the key pieces live in the codebase:

File Role
packages/twinki/packages/twinki/src/reconciler/host-config.ts React reconciler — creates/updates/removes TwinkiNodes
packages/twinki/packages/twinki/src/reconciler/render.ts ReactBridge + public render() function
packages/twinki/packages/twinki/src/layout/yoga.ts Yoga flexbox integration — maps CSS-like props to layout
packages/twinki/packages/twinki/src/renderer/tree-renderer.ts Walks node tree → produces line arrays
packages/twinki/packages/twinki/src/renderer/tui.ts The 4-strategy differential renderer + shadow buffer
packages/twinki/packages/twinki/src/renderer/box-renderer.ts Renders <Box> nodes with borders, padding, compositing
packages/twinki/packages/twinki/src/renderer/text-renderer.ts Renders <Text> nodes with ANSI styling and wrapping
packages/twinki/packages/twinki/src/terminal/process-terminal.ts Real terminal I/O — writes to stdout, reads from stdin
packages/twinki/packages/twinki/src/components/ All built-in components: Text, Box, TextInput, Select, EditorInput, Markdown, etc.
packages/twinki/packages/twinki/src/hooks/ All hooks: useInput, useApp, useFocus, useMouse, etc.

The package also includes a testing framework (@twinki/testing) with a VirtualTerminal that captures frames and can detect flicker — ensuring the differential rendering actually works.


Beyond Ink: What Twinki Adds

Twinki isn't just a faster Ink. It adds capabilities the TUI relies on:

  • Mouse supportonClick, onMouseEnter, onMouseLeave on any <Box> or <Text>, plus a useMouse() hook for raw events. Hit testing maps terminal coordinates back to the Yoga layout tree.
  • Rich input componentsTextInput (single-line with undo/redo and kill ring), Select (scrollable list with filtering), EditorInput (multi-line editor with word wrap and autocomplete).
  • Region caching<Region> components cache their rendered output and skip re-rendering when their subtree hasn't changed. Critical for performance when only one part of a complex layout updates.
  • Static content — The <Static> component writes lines into the terminal's scrollback buffer. Once written, they scroll up naturally and are never re-rendered. This is how completed chat messages leave the interactive area.
  • Overlay system — Modal dialogs and popups render as overlays composited on top of the main content, with proper focus management.

Summary

Twinki is the paint engine that makes kiro-cli's TUI feel responsive. It replaces Ink's "tear down and rebuild everything" approach with surgical, line-level updates:

  1. React's reconciler translates state changes into node tree mutations
  2. Yoga calculates flexbox layout in terminal coordinates
  3. The tree renderer converts nodes into ANSI-styled line arrays
  4. The TUI diffs against its shadow buffer and writes only what changed
  5. Synchronized output ensures the terminal paints atomically — zero flicker

The result: a streaming LLM response updates one line at a time, a spinner animates without redrawing the chat history, and the terminal's scrollback buffer is preserved throughout.


What's Next?

The TUI renders beautifully, but it's just a frontend. It needs to talk to the Rust backend that actually runs the agent loop, dispatches tools, and streams LLM responses. That communication happens over a JSON-RPC protocol called ACP.

In Chapter 3: Agent Client Protocol, we'll trace how a keystroke in the TUI becomes a prompt that reaches the LLM — and how the response streams back token by token through the same pipe.