How the TUI Differential Rendering System Works in pi-ai
The TUI differential rendering system in pi-ai minimizes terminal output by comparing each new frame against the previous one and emitting only the escape sequences necessary to update changed lines, rather than redrawing the entire screen.
The terminal UI (TUI) powering pi-ai—found in the badlogic/pi-mono repository—implements a single-pass, differential renderer designed for sub-millisecond latency. By tracking the previous frame state, the system avoids the flickering and bandwidth overhead associated with full-screen clears, making it ideal for interactive command-line interfaces with rich text, images, and IME support.
Architecture Overview
The rendering pipeline is orchestrated by the TUI class, which manages a tree of Component instances and coordinates the differential update cycle. Key modules include:
| Component | Responsibility | Source |
|---|---|---|
TUI (extends Container) |
Holds the component tree, triggers render passes, manages overlay stacking, tracks cursor position, and determines when full redraws are necessary. | packages/tui/src/tui.ts |
Component interface |
Defines the contract for any renderable element; implementations return an array of strings representing lines for a given viewport width. | packages/tui/src/tui.ts |
utils.ts |
Provides width-aware string manipulation functions (visibleWidth, sliceByColumn, extractSegments) that correctly handle ANSI SGR codes, OSC hyperlinks, APC markers, wide Unicode characters, and image sequences. |
packages/tui/src/utils.ts |
Terminal |
A thin wrapper around the underlying terminal device (or @xterm/headless in tests) that handles raw escape sequence I/O, cursor visibility, and size queries. |
packages/tui/src/terminal.ts |
| Overlay stack | A stack of modal components (pop-ups, tooltips) that are composited onto the base UI before the differential comparison runs. | TUI.showOverlay, TUI.compositeOverlays |
The Rendering Cycle
The differential renderer operates in a strict 10-step pipeline each time requestRender() is invoked. This ensures that only the minimal set of terminal updates is emitted.
Component Collection and Overlay Compositing
First, TUI.render(width) walks the component tree via Container.render and aggregates each child’s output into a string array called newLines.
If overlays are active, compositeOverlays calculates each overlay’s screen position and merges its lines into the base content using compositeLineAt. This happens before the differential pass, ensuring that pop-ups participate in the same efficiency optimizations as base components.
Source: packages/tui/src/tui.ts lines 1899–1904 (tree render), lines 78–84 (layout), and lines 79–90 (compositing).
Cursor Tracking and Line Resets
The system supports precise hardware cursor placement for IME (Input Method Editor) candidate windows. Components inject a special APC sequence—\x1b_pi:c\x07 (defined as CURSOR_MARKER)—to mark the desired cursor location.
extractCursorPosition scans the bottom-visible lines, locates the marker, strips it from the output, and returns {row, col} coordinates.
Before comparison, applyLineResets appends \x1b[0m to every non-image line to prevent SGR attribute bleeding between lines.
Source: packages/tui/src/tui.ts lines 282–289 (cursor extraction), lines 58–65 (line resets).
Differential Comparison and Update Buffer
If no full-redraw condition is met (width unchanged, content not shrinking with clearOnShrink disabled, and not the first frame), the renderer performs a differential comparison.
It walks previousLines and newLines simultaneously to find firstChanged and lastChanged indices. Only the range between these indices is processed.
computeLineDiff builds the output buffer:
- Moves the cursor to the first changed line.
- Emits
\x1b[2K(erase line) for each modified line. - Writes the new line content, truncated to terminal width if necessary.
If new content extends beyond the previous line count, whole lines are appended without clearing.
Source: packages/tui/src/tui.ts lines 133–146 (diff detection), lines 158–215 (buffer building).
Safety Guards and Hardware Cursor Placement
A hard-stop safety guard prevents terminal corruption: before emitting any line, the renderer checks visibleWidth(line) > width. If a component produces a line exceeding the terminal width, the system writes a crash log and throws an error.
After the update buffer is flushed, positionHardwareCursor moves the physical cursor to the coordinates extracted earlier (or hides it if no CURSOR_MARKER was found). This ensures IME pop-ups appear at the correct location relative to focused input components.
Finally, state variables (previousLines, previousWidth, maxLinesRendered, hardwareCursorRow) are updated for the next frame.
Source: packages/tui/src/tui.ts lines 250–267 (width guard), lines 514–527 (cursor placement), lines 534–545 (state update).
Core Helper Functions
The differential renderer relies on ANSI-aware string utilities implemented in packages/tui/src/utils.ts:
| Function | Purpose | Source |
|---|---|---|
visibleWidth(str) |
Calculates column width accounting for ANSI SGR, OSC hyperlinks, APC markers, wide Unicode, and image sequences. | utils.ts lines 81–86 |
extractSegments(line, beforeEnd, afterStart, afterLen, strictAfter) |
Single-pass extraction of line segments while preserving SGR state; used by the overlay compositor to splice overlay lines into base content. | utils.ts lines 818–888 |
sliceByColumn(line, start, len, strict) / sliceWithWidth |
Safe column-based slicing that respects ANSI codes and optional strictness (prevents wide characters from crossing slice boundaries). | utils.ts lines 558–572 |
compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) |
Inserts an overlay line into a base line, handling image lines, padding, and final width clamping. | tui.ts lines 71–90 |
Practical Example
Below is a complete TypeScript example demonstrating how to instantiate the TUI, attach a custom Component, and trigger differential updates:
import { TUI, Component } from "@badlogic/pi-mono/packages/tui";
import { VirtualTerminal } from "@badlogic/pi-mono/packages/tui/test/virtual-terminal";
// 1️⃣ Minimal component that renders a mutable string.
class Message implements Component {
private text = "";
render(_w: number): string[] { return [this.text]; }
invalidate() {} // No caching in this toy example.
handleInput?(data: string) {} // No interactivity needed.
}
// 2️⃣ Create a headless terminal (useful for tests or scripts).
const term = new VirtualTerminal(80, 24);
const ui = new TUI(term);
// 3️⃣ Wire the component into the UI tree.
const msg = new Message();
ui.addChild(msg);
// 4️⃣ Start the UI loop.
ui.start();
// 5️⃣ Update the UI – only the changed line is sent.
msg["text"] = "Hello, pi‑ai! 🎉";
ui.requestRender(); // Triggers a differential render.
// 6️⃣ Later we change just a part of the line.
msg["text"] = "Hello, pi‑ai! 🎉 (v2)";
ui.requestRender(); // Only the changed portion is sent.
// 7️⃣ Clean up.
ui.stop();
When executed against a real terminal, this script updates the line without full-screen flicker. Inspecting the raw output reveals a single \x1b[2K (erase line) followed by the new text, rather than a complete screen clear.
Summary
- Single-pass differential rendering compares the new frame against
previousLinesand emits only the escape sequences needed to update changed rows, minimizing terminal bandwidth and eliminating flicker. - Overlay compositing occurs before the diff pass, allowing modal pop-ups to benefit from the same efficiency optimizations as base components.
- Hardware cursor tracking via the
CURSOR_MARKERAPC sequence enables precise IME support while maintaining the diff state machine. - Safety guards enforce strict width constraints; any component producing lines wider than the terminal triggers a hard error to prevent display corruption.
- ANSI-aware utilities in
utils.tshandle complex text measurements (SGR codes, wide Unicode, images) that make accurate differential updates possible.
Frequently Asked Questions
How does pi-ai handle terminal resizing?
When the terminal width changes, TUI detects previousWidth !== width and triggers a full redraw. This clears the entire screen and redraws all lines because line-wrapping behavior may have changed, making simple line-by-line diffs unsafe. The same full-clear logic applies when content shrinks and clearOnShrink is enabled.
What happens if a component generates a line wider than the terminal?
The renderer implements a hard-stop safety guard in packages/tui/src/tui.ts (lines 250–267). Before emitting any line, it checks visibleWidth(line) > width. If true, the system writes a crash log and throws an error. This prevents terminal corruption caused by overflow or misaligned escape sequences.
How are overlays like pop-ups rendered without breaking the diff algorithm?
Overlays are composited before the differential comparison runs. The compositeOverlays method calculates each overlay's screen position and merges its lines into the base content using compositeLineAt (lines 71–90 in tui.ts). Because overlays become part of the newLines array before the diff pass, the renderer treats them as ordinary content, ensuring that closing an overlay automatically restores the underlying UI via the standard differential update.
Why does pi-ai use a special APC sequence for cursor tracking?
The CURSOR_MARKER (\x1b_pi:c\x07) allows any Focusable component to request a specific hardware cursor position without knowing its absolute screen coordinates. During the render pass, extractCursorPosition (lines 282–289) scans the output for this marker, removes it from the visible text, and returns the row/column. After the differential buffer is flushed, positionHardwareCursor moves the physical cursor to this location, enabling IME candidate windows to appear exactly where text is being edited while keeping the rendering pipeline stateless and diff-friendly.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →