Skip to content

Clase TUI: centro de scheduling de renderizado diferencial

源码版本v0.73.1

La clase TUI de @mariozechner/pi-tui es el scheduler de renderizado de la UI de terminal. Hereda de Container, así que es también raíz del árbol de componentes, pero además hace tres cosas: renderizar el árbol a líneas, hacer diff contra las líneas del frame anterior, y envolver la escritura a la terminal con DECSET 2026 (synchronized output). También gestiona focus, overlays y reciclaje de kitty image IDs. Toda la UI TUI de pi-mono corre sobre el bucle doRender de esta clase.

Responsabilidades

TUI hace cuatro cosas:

  1. Scheduling de render: requestRender no dibuja inmediatamente, sino que se combina en el siguiente frame, forzando un intervalo mínimo de 16ms para evitar que un spinner suba el CPU al 100%. Ver packages/tui/src/tui.ts:495-522.
  2. Diff: doRender compara las nuevas líneas de this.render(width) con previousLines línea a línea, encuentra firstChanged/lastChanged y sólo repinta ese intervalo. Ver packages/tui/src/tui.ts:953-1011.
  3. Composición de overlay: los overlays se sintetizan en newLines antes del diff, de modo que los cambios del base y los del overlay participan juntos. Ver packages/tui/src/tui.ts:758-808.
  4. Salida sincronizada: toda escritura se envuelve en \x1b[?2026h ... \x1b[?2026l, para que la terminal presente el frame atómicamente y evitar parpadeo de medio frame. Ver packages/tui/src/tui.ts:1145-1230.

Motivación de diseño

¿Por qué no hacer terminal.write(component.render(width).join("\n")) directamente? Porque por defecto la terminal escribe en modo línea streaming y cualquier estado intermedio se dibuja: redibujar toda la pantalla parpadea, y dibujar sólo las líneas cambiadas obliga a seguir la posición del cursor y el scroll a mano. TUI se carga todo ese trabajo sucio: previousLines hace diff línea a línea, hardwareCursorRow sigue la fila real del cursor de la terminal, y viewportTop gestiona el scroll cuando el contenido excede el viewport. DECSET 2026 (synchronized output) es un acuerdo entre fabricantes de terminales de "las salidas en este rango refrescan atómicamente"; TUI envuelve cada frame, y el coste es que las terminales viejas que no lo reconocen lo ignoran y el efecto degenera a streaming normal.

Archivos clave

requestRender usa doble scheduling process.nextTick + setTimeout, fusionando múltiples peticiones del mismo 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());
}

doRender, tras obtener las nuevas líneas, primero compone overlays, luego extrae el cursor marker y luego va por fullRender o por diff:

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);

La ruta diff sólo reescribe el intervalo [firstChanged, lastChanged] y usa \r\n para bajar a la nueva línea, todo envuelto en 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];
}

Flujo de datos

App externa muta estado del componente → el componente llama requestRender → siguiente frame doRender:

Límites y fallos

  • Línea más ancha que el viewport, crash: si visibleWidth(line) > width, escribe un crash log a ~/.pi/agent/pi-crash.log y stop(), porque seguir dibujando rompería las líneas siguientes. Ver packages/tui/src/tui.ts:1180-1200.
  • Cambio de ancho dispara fullRender: si previousWidth !== width, redibuja toda la pantalla y limpia el scrollback para evitar restos.
  • Imagen kitty residual: los IDs en el intervalo diff deben borrarse con deleteChangedKittyImages antes de dibujar las nuevas líneas, si no la imagen vieja se queda pegada en el panel de imágenes de la terminal.
  • force se salta el throttle: en resize, cambio de tema, etc. se llama requestRender(true), que vacía previousLines y dibuja en el nextTick inmediatamente.
  • Guard de stopped: doRender, scheduleRender, requestRender comprueban this.stopped, previniendo que tras stop() un callback async vuelva a escribir a la terminal.

Resumen

TUI encadena "árbol de componentes → líneas → diff → salida sincronizada" en una sola tubería, y de paso gestiona focus, overlay y GC de imágenes kitty. Hacia arriba, los componentes implementan la interfaz Component; ver biblioteca de componentes: Box/Input/Markdown/SelectList. La entrada del usuario en parseo de teclado: protocolo kitty. El componente más pesado es el editor, que se ocupa de IME, kill-ring y undo.