TUI-Klasse: Scheduler-Zentrale für differentielles Rendering
Die TUI-Klasse aus @mariozechner/pi-tui ist der Render-Scheduler der Terminal-UI. Sie erbt von Container und ist selbst Wurzel eines Komponentenbaums (component tree), übernimmt aber zusätzlich drei Dinge: den Komponentenbaum in Zeilen rendern, Zeilen mit dem vorherigen Frame difffen und alles in eine DECSET 2026-Synchronisierungshülle packen, bevor es ans Terminal geht. Daneben verwaltet sie Fokus, Overlays (Overlay-Ebenen) und das Recycling der kitty image IDs. Die gesamte TUI-Oberfläche von pi-mono läuft über ihre doRender-Schleife.
Zuständigkeiten
TUI macht vier Dinge:
- Render-Scheduling:
requestRenderzeichnet nicht sofort, sondern fasst Anfragen im nächsten Frame zusammen und erzwingt 16ms Abstand, damit ein Spinner die CPU nicht auf 100% treibt. Siehepackages/tui/src/tui.ts:495-522. - Differentialer Vergleich:
doRendervergleicht die neuen Zeilen austhis.render(width)Zeile für Zeile mitpreviousLines, ermitteltfirstChanged/lastChangedund zeichnet nur den veränderten Bereich neu. Siehepackages/tui/src/tui.ts:953-1011. - Overlay-Komposition: Overlays werden vor dem Diff in
newLineseingemischt, sodass Änderungen an der Basis und an der Overlay-Position gemeinsam difft werden. Siehepackages/tui/src/tui.ts:758-808. - Synchronisierte Ausgabe: Jeder Schreibvorgang wird in
\x1b[?2026h...\x1b[?2026leingepackt, damit das Terminal den Frame atomar darstellt und kein halber Frame flackert. Siehepackages/tui/src/tui.ts:1145-1230.
Designmotivation
Warum nicht einfach terminal.write(component.render(width).join("\n"))? Weil ein Terminal standardmäßig zeilenweise streamt und jeder Zwischenzustand gezeichnet wird — ein Vollbild-Redraw flackert, ein selektives Neuzeichnen der veränderten Zeilen erfordert manuelles Tracking von Cursor-Position und Scroll-Zustand. TUI übernimmt diese Drecksarbeit: previousLines für den zeilenweisen Diff, hardwareCursorRow für die echte Terminal-Cursor-Zeile, viewportTop für den Scroll, wenn der Inhalt den sichtbaren Bereich überschreitet. DECSET 2026 (synchronized output) ist eine von Terminal-Herstellern vereinbarte Vereinbarung: „Ausgaben in diesem Bereich bitte atomar refreshen". TUI packt jeden Frame ein; der Nachteil ist, dass alte Terminal das Sequenz nicht kennen, sie ignorieren und das Verhalten auf normales Streamen zurückfällt.
Wichtige Dateien
packages/tui/src/tui.ts:39-63—Component-Interface:render(width)gibt ein Zeilen-Array zurück,handleInputoptional,wantsKeyReleasesteuert, ob kitty release geliefert wird.packages/tui/src/tui.ts:200-234—Container-Implementierung,renderverkettet die Zeilen der Children nacheinander; Grundlage der Komponentenbaum-Komposition.packages/tui/src/tui.ts:239-272—class TUI extends Container-Deklaration:previousLines,overlayStack,hardwareCursorRowund weitere interne Zustandsfelder.packages/tui/src/tui.ts:758-808—compositeOverlaysplatziert Overlay-Zeilen anhand von anchor/margin über den Basiszeilen.packages/tui/src/tui.ts:832-874— kitty image ID Recycling: image IDs im Diff-Bereich werden gesammelt und mitdeleteKittyImagesfreigegeben, damit keine Geisterbilder übrig bleiben.packages/tui/src/tui.ts:953-1011—doRender-Hauptfluss: render → composite → extract cursor → applyLineResets → fullRender oder diff render.packages/tui/src/tui.ts:1145-1230— Buffer-Aufbau im Diff-Pfad, von\x1b[?2026hbis\x1b[?2026l.
requestRender nutzt process.nextTick + setTimeout als zweistufigen Scheduler und fasst mehrere Anfragen im selben Tick zusammen:
// 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 holt sich nach den neuen Zeilen zuerst die Overlays, extrahiert danach den Cursor-Marker und geht dann durch den fullRender- oder Diff-Pfad:
// 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);Der Diff-Pfad schreibt nur im Intervall [firstChanged, lastChanged] und scrollt neue Zeilen mit \r\n rein; alles eingepackt in die synchronisierte Ausgabe:
// 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];
}Datenfluss
Eine externe App ändert Komponentenzustand → die Komponente ruft requestRender → nächster Frame doRender:
Randbedingungen und Fehler
- Zeile zu breit crasht direkt: Wenn
visibleWidth(line) > width, schreibt die Klasse einen Crash-Log nach~/.pi/agent/pi-crash.logund ruftstop()auf, denn weiterzuzeichnen würde die nachfolgenden Zeilen zerreißen. Siehepackages/tui/src/tui.ts:1180-1200. - Breitenänderung löst fullRender aus: Bei
previousWidth !== widthwird das ganze Bild neu gezeichnet und der Scrollback gelöscht, damit keine alten Inhalte stehen bleiben. - kitty-Bildreste: Image IDs im Diff-Bereich müssen zuerst über
deleteChangedKittyImagesfreigegeben werden, bevor die neuen Zeilen gezeichnet werden; sonst bleibt das alte Bild in der Terminal-Grafikebene kleben. force-Pfad umgeht Throttling: Bei resize oder Themenwechsel wirdrequestRender(true)aufgerufen,previousLinesgeleert und im nextTick sofort gezeichnet.stopped-Guard:doRender,scheduleRenderundrequestRenderprüfen allethis.stopped, damit nachstop()keine asynchronen Callbacks mehr ans Terminal schreiben.
Zusammenfassung
TUI verdrahtet „Komponentenbaum → Zeilen → Diff → synchronisierte Ausgabe" zu einer Pipeline und verwaltet nebenbei Fokus, Overlays und kitty image GC. Nach oben hin implementieren alle Komponenten das Component-Interface, siehe Komponentenbibliothek: Box/Input/Markdown/SelectList; für die Eingabeseite siehe Tastatur-Parsing: kitty-Protokoll; die schwerste Komponente ist der Editor-Komponente, der IME, kill-ring und undo übernimmt.