Skip to content

Classe TUI : hub de scheduling du rendu différentiel

源码版本v0.73.1

La classe TUI de @mariozechner/pi-tui est le scheduler de rendu de l'UI terminal. Elle hérite de Container : elle est elle-même la racine de l'arbre des composants, mais prend en charge trois choses en plus : rendre l'arbre des composants en lignes, faire le diff avec les lignes de la frame précédente, et envelopper le tout dans une sortie synchronisée DECSET 2026 avant d'écrire sur le terminal. Elle gère aussi le focus, les overlays (couches flottantes) et la récupération des kitty image IDs. Toute l'interface TUI de pi-mono tourne sur sa boucle doRender.

Responsabilités

TUI fait quatre choses :

  1. Scheduling du rendu : requestRender ne dessine pas immédiatement, il fusionne dans la frame suivante, en forçant un intervalle de 16 ms pour éviter qu'un spinner ne sature le CPU à 100 %. Voir packages/tui/src/tui.ts:495-522.
  2. Comparaison différentielle : doRender compare ligne par ligne les nouvelles lignes produites par this.render(width) avec previousLines, repère firstChanged/lastChanged et ne redessine que la zone modifiée. Voir packages/tui/src/tui.ts:953-1011.
  3. Composition des overlays : les couches flottantes sont fusionnées dans newLines avant le diff, de sorte que les changements du contenu base et ceux de la position de l'overlay participent ensemble au diff. Voir packages/tui/src/tui.ts:758-808.
  4. Sortie synchronisée : toutes les écritures sont enveloppées dans \x1b[?2026h\x1b[?2026l, pour que le terminal présente la frame atomiquement et évite le clignotement de demi-frame. Voir packages/tui/src/tui.ts:1145-1230.

Motifs de conception

Pourquoi ne pas faire directement terminal.write(component.render(width).join("\n")) ? Parce que le terminal écrit par défaut en flux ligne à ligne, tout état intermédiaire est dessiné : redessiner tout l'écran provoque un clignotement full-screen, et ne dessiner que les lignes modifiées implique de gérer soi-même la position du curseur et le scroll. TUI prend en charge tout ce travail ingrat : previousLines pour le diff ligne à ligne, hardwareCursorRow pour suivre la ligne réelle du curseur dans le terminal, viewportTop pour gérer le scroll quand le contenu dépasse la zone visible. DECSET 2026 (synchronized output) est un protocole convenu entre fabricants de terminaux qui dit « les sorties dans cet intervalle doivent être rafraîchies atomiquement » ; TUI enveloppe chaque frame, au prix que les terminaux anciens qui ne reconnaissent pas la séquence l'ignorent et retombent sur un flux normal.

Fichiers clés

  • packages/tui/src/tui.ts:39-63 — interface Component : render(width) renvoie un tableau de lignes, handleInput optionnel, wantsKeyRelease contrôle si le release kitty est délivré.
  • packages/tui/src/tui.ts:200-234 — implémentation de Container : render concatène dans l'ordre les lignes des children, base de la composition de l'arbre des composants.
  • packages/tui/src/tui.ts:239-272 — déclaration class TUI extends Container : champs d'état internes previousLines, overlayStack, hardwareCursorRow, etc.
  • packages/tui/src/tui.ts:758-808compositeOverlays positionne les lignes d'overlay selon anchor/margin et les superpose aux lignes base.
  • packages/tui/src/tui.ts:832-874 — récupération des kitty image IDs : les IDs présents dans la zone diff sont collectés puis nettoyés via deleteKittyImages, pour éviter que des images fantômes ne subsistent.
  • packages/tui/src/tui.ts:953-1011 — flux principal doRender : render → composite → extract cursor → applyLineResets → fullRender ou diff render.
  • packages/tui/src/tui.ts:1145-1230 — construction du buffer sur le chemin diff, de \x1b[?2026h à \x1b[?2026l.

requestRender utilise une double planification process.nextTick + setTimeout pour fusionner les multiples requêtes du même 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 obtient les nouvelles lignes, compose les overlays, extrait le marker curseur, puis passe par fullRender ou par le chemin 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);

Le chemin diff ne réécrit que la zone [firstChanged, lastChanged], fait défiler avec \r\n pour sortir de nouvelles lignes, et enveloppe tout le segment dans la sortie synchronisée :

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];
}

Flux de données

L'app externe modifie l'état d'un composant → le composant appelle requestRender → frame suivante doRender :

Limites et cas d'échec

  • Ligne trop large = crash direct : quand visibleWidth(line) > width, on écrit un crash log dans ~/.pi/agent/pi-crash.log et on stop(), parce que continuer à dessiner déchire les lignes suivantes. Voir packages/tui/src/tui.ts:1180-1200.
  • Changement de largeur déclenche fullRender : quand previousWidth !== width, on redessine l'écran entier et on nettoie le scrollback pour éviter que du contenu ancien ne subsiste.
  • Images kitty résiduelles : les image IDs dans la zone diff doivent d'abord passer par deleteChangedKittyImages avant de dessiner les nouvelles lignes, sinon les anciennes images restent collées au panneau d'images du terminal.
  • Le chemin force outrepasse le throttle : pour les scénarios type resize, switch de thème, on appelle requestRender(true), ce qui vide previousLines et dessine immédiatement au prochain nextTick.
  • Garde stopped : doRender, scheduleRender, requestRender vérifient tous this.stopped, pour empêcher qu'un callback async n'écrive dans le terminal après stop().

Synthèse

TUI enchaîne « arbre de composants → lignes → diff → sortie synchronisée » en un pipeline, et gère au passage le focus, les overlays et le GC des images kitty. En amont, les composants implémentent tous l'interface Component, voir bibliothèque de composants : Box/Input/Markdown/SelectList ; côté entrée utilisateur, voir parsing clavier : protocole kitty ; le composant le plus lourd est le composant éditeur, qui prend en charge IME, kill-ring et undo.