TUI Class: The Diff-Rendering Scheduler
The TUI class in @mariozechner/pi-tui is the render scheduler for the terminal UI. It extends Container, so it is itself the root of a component tree, but it also takes on three extra jobs: rendering the tree into lines, diffing those lines against the previous frame, and wrapping every write in DECSET 2026 synchronized output before flushing to the terminal. It also manages focus, overlay layers, and kitty image ID recycling. The entire pi-mono TUI surface runs on its doRender loop.
Responsibilities
TUI does four things:
- Render scheduling:
requestRenderdoes not draw immediately. It coalesces into the next frame and enforces a 16ms minimum interval so a spinner cannot peg the CPU at 100%. Seepackages/tui/src/tui.ts:495-522. - Diffing:
doRendercompares the new lines fromthis.render(width)againstpreviousLinesline by line, findsfirstChanged/lastChanged, and only repaints the changed range. Seepackages/tui/src/tui.ts:953-1011. - Overlay compositing: overlays are merged into
newLinesbefore the diff, so base content changes and overlay position changes both participate in the same diff. Seepackages/tui/src/tui.ts:758-808. - Synchronized output: every write is wrapped in
\x1b[?2026h...\x1b[?2026lso the terminal presents the frame atomically instead of flashing a half-drawn frame. Seepackages/tui/src/tui.ts:1145-1230.
Design Motivation
Why not just call terminal.write(component.render(width).join("\n"))? Because the terminal writes in line-streamed fashion by default, and any intermediate state gets drawn. Repainting the whole screen causes full-screen flicker; repainting only changed lines means you have to track cursor position and scrolling yourself. TUI takes all that on: previousLines for line-level diff, hardwareCursorRow to track the terminal's real cursor row, and viewportTop to handle scrolling when content exceeds the viewport. DECSET 2026 (synchronized output) is a vendor convention meaning "refresh the output inside this range atomically." TUI wraps every frame in it. The cost: older terminals that do not recognize the sequence just ignore it, and behavior degrades to plain streaming.
Key Files
packages/tui/src/tui.ts:39-63—Componentinterface:render(width)returns an array of lines,handleInputis optional,wantsKeyReleasecontrols whether kitty release events are delivered.packages/tui/src/tui.ts:200-234—Containerimplementation;renderconcatenates children's lines, the basis for composing a component tree.packages/tui/src/tui.ts:239-272—class TUI extends Containerdeclaration: internal state fields likepreviousLines,overlayStack,hardwareCursorRow.packages/tui/src/tui.ts:758-808—compositeOverlayspositions overlay lines by anchor/margin and stamps them onto the base lines.packages/tui/src/tui.ts:832-874— kitty image ID recycling: image IDs in the diff range are collected and cleaned up withdeleteKittyImagesso ghost images do not linger.packages/tui/src/tui.ts:953-1011—doRendermain flow: render -> composite -> extract cursor -> applyLineResets -> fullRender or diff render.packages/tui/src/tui.ts:1145-1230— buffer construction on the diff path, from\x1b[?2026hdown through\x1b[?2026l.
requestRender uses a two-layer process.nextTick + setTimeout schedule to coalesce multiple requests within the same tick:
// packages/tui/src/tui.ts:495-522
requestRender(force = false): void {
if (force) {
this.previousLines = [];
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
// ...
this.renderRequested = true;
process.nextTick(() => { /* ... doRender() */ });
return;
}
if (this.renderRequested) return;
this.renderRequested = true;
process.nextTick(() => this.scheduleRender());
}After doRender has the new lines, it composes overlays, extracts the cursor marker, then takes either the fullRender or diff path:
// packages/tui/src/tui.ts:970-980
let newLines = this.render(width);
// Composite overlays into the rendered lines (before differential compare)
if (this.overlayStack.length > 0) {
newLines = this.compositeOverlays(newLines, width, height);
}
const cursorPos = this.extractCursorPosition(newLines, height);
newLines = this.applyLineResets(newLines);The diff path only rewrites the [firstChanged, lastChanged] range, scrolls new lines in with \r\n, and wraps the whole segment in synchronized output:
// packages/tui/src/tui.ts:1145-1175
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
// ...move cursor, scroll if needed...
for (let i = firstChanged; i <= renderEnd; i++) {
if (i > firstChanged) buffer += "\r\n";
buffer += "\x1b[2K"; // Clear current line
buffer += newLines[i];
}Data Flow
An external app mutates component state -> the component calls requestRender -> the next frame's doRender:
Edges and Failures
- Line too wide crashes: when
visibleWidth(line) > width, a crash log is written to~/.pi/agent/pi-crash.logandstop()is called, because continuing to draw would tear the following lines. Seepackages/tui/src/tui.ts:1180-1200. - Width change triggers fullRender: when
previousWidth !== width, the whole screen is repainted and scrollback is cleared to avoid stale content. - kitty image residue: image IDs in the diff range must be passed to
deleteChangedKittyImagesbefore drawing new lines, otherwise old images stay pinned on the terminal's graphics layer. - The
forcepath bypasses throttling: resize and theme-switch callrequestRender(true), clearingpreviousLinesand drawing immediately on the next tick. stoppedguard:doRender,scheduleRender, andrequestRenderall checkthis.stopped, preventing async callbacks from writing to the terminal afterstop().
Summary
TUI strings "component tree -> lines -> diff -> synchronized output" into one pipeline and incidentally manages focus, overlays, and kitty image GC. Upstream, every component implements the Component interface; see Component Library: Box/Input/Markdown/SelectList. For user input, see Keyboard Parsing: The Kitty Protocol. The heaviest component is the Editor Component, which takes over IME, kill-ring, and undo.