Skip to content

TUI Class: The Diff-Rendering Scheduler

源码版本v0.73.1

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:

  1. Render scheduling: requestRender does not draw immediately. It coalesces into the next frame and enforces a 16ms minimum interval so a spinner cannot peg the CPU at 100%. See packages/tui/src/tui.ts:495-522.
  2. Diffing: doRender compares the new lines from this.render(width) against previousLines line by line, finds firstChanged/lastChanged, and only repaints the changed range. See packages/tui/src/tui.ts:953-1011.
  3. Overlay compositing: overlays are merged into newLines before the diff, so base content changes and overlay position changes both participate in the same diff. See packages/tui/src/tui.ts:758-808.
  4. Synchronized output: every write is wrapped in \x1b[?2026h ... \x1b[?2026l so the terminal presents the frame atomically instead of flashing a half-drawn frame. See packages/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

requestRender uses a two-layer process.nextTick + setTimeout schedule to coalesce multiple requests within the same tick:

typescript
// 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:

typescript
// 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:

typescript
// 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.log and stop() is called, because continuing to draw would tear the following lines. See packages/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 deleteChangedKittyImages before drawing new lines, otherwise old images stay pinned on the terminal's graphics layer.
  • The force path bypasses throttling: resize and theme-switch call requestRender(true), clearing previousLines and drawing immediately on the next tick.
  • stopped guard: doRender, scheduleRender, and requestRender all check this.stopped, preventing async callbacks from writing to the terminal after stop().

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.