Clase TUI: centro de scheduling de renderizado diferencial
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:
- Scheduling de render:
requestRenderno 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%. Verpackages/tui/src/tui.ts:495-522. - Diff:
doRendercompara las nuevas líneas dethis.render(width)conpreviousLineslínea a línea, encuentrafirstChanged/lastChangedy sólo repinta ese intervalo. Verpackages/tui/src/tui.ts:953-1011. - Composición de overlay: los overlays se sintetizan en
newLinesantes del diff, de modo que los cambios del base y los del overlay participan juntos. Verpackages/tui/src/tui.ts:758-808. - 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. Verpackages/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
packages/tui/src/tui.ts:39-63— InterfazComponent:render(width)devuelve array de líneas,handleInputopcional,wantsKeyReleasecontrola si kitty release llega.packages/tui/src/tui.ts:200-234— Implementación deContainer:renderconcatena las líneas de los children, base de la composición del árbol.packages/tui/src/tui.ts:239-272— Declaración declass TUI extends Container: campos internos comopreviousLines,overlayStack,hardwareCursorRow.packages/tui/src/tui.ts:758-808—compositeOverlayscoloca los overlays según anchor/margin sobre las líneas base.packages/tui/src/tui.ts:832-874— Reciclaje de kitty image IDs: los IDs en el intervalo diff se recolectan y se borran condeleteKittyImages, evitando imágenes fantasma.packages/tui/src/tui.ts:953-1011— Flujo principal dedoRender: render → composite → extract cursor → applyLineResets → fullRender o diff render.packages/tui/src/tui.ts:1145-1230— Construcción del buffer en la ruta diff, desde\x1b[?2026hhasta\x1b[?2026l.
requestRender usa doble scheduling process.nextTick + setTimeout, fusionando múltiples peticiones del mismo 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());
}doRender, tras obtener las nuevas líneas, primero compone overlays, luego extrae el cursor marker y luego va por fullRender o por diff:
// 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:
// 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.logystop(), porque seguir dibujando rompería las líneas siguientes. Verpackages/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
deleteChangedKittyImagesantes de dibujar las nuevas líneas, si no la imagen vieja se queda pegada en el panel de imágenes de la terminal. forcese salta el throttle: en resize, cambio de tema, etc. se llamarequestRender(true), que vacíapreviousLinesy dibuja en el nextTick inmediatamente.- Guard de
stopped:doRender,scheduleRender,requestRendercompruebanthis.stopped, previniendo que trasstop()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.