Skip to content

TUI-Klasse: Scheduler-Zentrale für differentielles Rendering

源码版本v0.73.1

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:

  1. Render-Scheduling: requestRender zeichnet nicht sofort, sondern fasst Anfragen im nächsten Frame zusammen und erzwingt 16ms Abstand, damit ein Spinner die CPU nicht auf 100% treibt. Siehe packages/tui/src/tui.ts:495-522.
  2. Differentialer Vergleich: doRender vergleicht die neuen Zeilen aus this.render(width) Zeile für Zeile mit previousLines, ermittelt firstChanged/lastChanged und zeichnet nur den veränderten Bereich neu. Siehe packages/tui/src/tui.ts:953-1011.
  3. Overlay-Komposition: Overlays werden vor dem Diff in newLines eingemischt, sodass Änderungen an der Basis und an der Overlay-Position gemeinsam difft werden. Siehe packages/tui/src/tui.ts:758-808.
  4. Synchronisierte Ausgabe: Jeder Schreibvorgang wird in \x1b[?2026h ... \x1b[?2026l eingepackt, damit das Terminal den Frame atomar darstellt und kein halber Frame flackert. Siehe packages/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

requestRender nutzt process.nextTick + setTimeout als zweistufigen Scheduler und fasst mehrere Anfragen im selben Tick zusammen:

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 holt sich nach den neuen Zeilen zuerst die Overlays, extrahiert danach den Cursor-Marker und geht dann durch den fullRender- oder Diff-Pfad:

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

Der Diff-Pfad schreibt nur im Intervall [firstChanged, lastChanged] und scrollt neue Zeilen mit \r\n rein; alles eingepackt in die synchronisierte Ausgabe:

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

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.log und ruft stop() auf, denn weiterzuzeichnen würde die nachfolgenden Zeilen zerreißen. Siehe packages/tui/src/tui.ts:1180-1200.
  • Breitenänderung löst fullRender aus: Bei previousWidth !== width wird 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 deleteChangedKittyImages freigegeben 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 wird requestRender(true) aufgerufen, previousLines geleert und im nextTick sofort gezeichnet.
  • stopped-Guard: doRender, scheduleRender und requestRender prüfen alle this.stopped, damit nach stop() 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.