Konfiguration, Anpassung, Plattform-Einrichtung und API-Referenzen für Pi.

TUI Komponenten

pi kann TUI Komponenten erstellen. Bitten Sie es, eines für Ihren Anwendungsfall zu erstellen.

Extensions und benutzerdefinierte Tools können benutzerdefinierte TUI Komponenten für interaktive Benutzeroberflächen rendern. Auf dieser Seite werden das Komponentensystem und die verfügbaren Bausteine ​​behandelt.

Quelle: @earendil-works/pi-tui

Komponentenschnittstelle

Alle Komponenten implementieren:

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  wantsKeyRelease?: boolean;
  invalidate(): void;
}
Verfahren Beschreibung
render(width) Gibt ein Array von Zeichenfolgen zurück (eine pro Zeile). Jede Zeile darf width nicht überschreiten.
handleInput?(data) Empfangen Sie Tastatureingaben, wenn die Komponente den Fokus hat.
wantsKeyRelease? Wenn „true“, empfängt die Komponente Schlüsselfreigabeereignisse (Kitty-Protokoll). Standard: false.
invalidate() Zwischengespeicherten Renderstatus löschen. Bei Themenänderungen aufgerufen.

Die TUI fügt am Ende jeder gerenderten Zeile einen vollständigen SGR-Reset und einen OSC 8-Reset hinzu. Stile werden nicht über Zeilen hinweg übertragen. Wenn Sie mehrzeiligen Text mit Stil ausgeben, wenden Sie die Stile pro Zeile erneut an oder verwenden Sie wrapTextWithAnsi(), damit die Stile für jede umbrochene Zeile erhalten bleiben.

Fokussierbare Schnittstelle (IME-Unterstützung)

Komponenten, die einen Textcursor anzeigen und IME-Unterstützung (Input Method Editor) benötigen, sollten die Focusable-Schnittstelle implementieren:

import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui";

class MyInput implements Component, Focusable {
  focused: boolean = false;  // Set by TUI when focus changes
  
  render(width: number): string[] {
    const marker = this.focused ? CURSOR_MARKER : "";
    // Emit marker right before the fake cursor
    return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
  }
}

Wenn eine Focusable-Komponente den Fokus hat, TUI:

  1. Setzt focused = true für die Komponente
  2. Durchsucht die gerenderte Ausgabe nach CURSOR_MARKER (einer APC-Escape-Sequenz mit der Breite Null)
  3. Positioniert den Hardware-Terminal-Cursor an dieser Stelle
  4. Zeigt den Hardware-Cursor nur an, wenn showHardwareCursor aktiviert ist

Der Cursor bleibt standardmäßig ausgeblendet. Dadurch bleibt die Wiedergabe des gefälschten Cursors erhalten, während der Hardware-Cursor weiterhin für Terminals positioniert wird, die IME-Kandidatenfenster mit versteckten Cursorn verfolgen. Einige Terminals erfordern einen sichtbaren Hardware-Cursor für die IME-Positionierung. Aktivieren Sie es mit showHardwareCursor, setShowHardwareCursor(true) oder PI_HARDWARE_CURSOR=1. Die eingebauten Komponenten Editor und Input implementieren diese Schnittstelle bereits.

Containerkomponenten mit eingebetteten Eingaben

Wenn eine Containerkomponente (Dialog, Selektor usw.) ein untergeordnetes Element vom Typ Input oder Editor enthält, muss der Container Focusable implementieren und den Fokusstatus an das untergeordnete Element weitergeben. Andernfalls wird der Hardware-Cursor für die IME-Eingabe nicht richtig positioniert.

import { Container, type Focusable, Input } from "@earendil-works/pi-tui";

class SearchDialog extends Container implements Focusable {
  private searchInput: Input;

  // Focusable implementation - propagate to child input for IME cursor positioning
  private _focused = false;
  get focused(): boolean {
    return this._focused;
  }
  set focused(value: boolean) {
    this._focused = value;
    this.searchInput.focused = value;
  }

  constructor() {
    super();
    this.searchInput = new Input();
    this.addChild(this.searchInput);
  }
}

Ohne diese Weitergabe wird bei der Eingabe mit einem IME (Chinesisch, Japanisch, Koreanisch usw.) das Kandidatenfenster an der falschen Position auf dem Bildschirm angezeigt.

Komponenten verwenden

In Erweiterungen über ctx.ui.custom():

pi.on("session_start", async (_event, ctx) => {
  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
    new MyComponent({
      theme,
      keybindings,
      onChange: () => tui.requestRender(),
      onSelect: (value) => done(value),
      onCancel: () => done(null),
    })
  );
});

In benutzerdefinierten Tools über ctx.ui.custom():

async execute(toolCallId, params, signal, onUpdate, ctx) {
  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
    new MyComponent({
      theme,
      keybindings,
      onChange: () => tui.requestRender(),
      onSelect: (value) => done(value),
      onCancel: () => done(null),
    })
  );
  // Use result...
}

Überlagerungen

Overlays rendern Komponenten über vorhandenen Inhalten, ohne den Bildschirm zu leeren. Übergeben Sie { overlay: true } an ctx.ui.custom():

const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
  { overlay: true }
);

Verwenden Sie zur Positionierung und Größenanpassung overlayOptions:

const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new SidePanel({ onClose: done }),
  {
    overlay: true,
    overlayOptions: {
      // Size: number or percentage string
      width: "50%",          // 50% of terminal width
      minWidth: 40,          // minimum 40 columns
      maxHeight: "80%",      // max 80% of terminal height

      // Position: anchor-based (default: "center")
      anchor: "right-center", // 9 positions: center, top-left, top-center, etc.
      offsetX: -2,            // offset from anchor
      offsetY: 0,

      // Or percentage/absolute positioning
      row: "25%",            // 25% from top
      col: 10,               // column 10

      // Margins
      margin: 2,             // all sides, or { top, right, bottom, left }

      // Responsive: hide on narrow terminals
      visible: (termWidth, termHeight) => termWidth >= 80,
    },
    // Get handle for programmatic focus and visibility control
    onHandle: (handle) => {
      // handle.focus() - focus this overlay and bring it to the visual front
      // handle.unfocus() - release input to normal fallback
      // handle.unfocus({ target }) - release input to a specific component or null
      // handle.setHidden(true/false) - toggle visibility
      // handle.hide() - permanently remove
    },
  }
);

Overlay-Fokus

Ein fokussiertes sichtbares Overlay behält die Eingabeverantwortung über die temporäre Nicht-Overlay-Benutzeroberfläche hinweg. Wenn ein Overlay eine andere ctx.ui.custom()-Komponente ohne { overlay: true } öffnet, empfängt diese Ersatz-UI Eingaben, während sie aktiv ist; Wenn es geschlossen wird, kann das fokussierte Overlay Eingaben zurückfordern.

Verwenden Sie handle.unfocus(), wenn ein sichtbares Overlay keine Eingaben mehr besitzen soll, und lassen Sie TUI auf ein anderes sichtbares Erfassungs-Overlay oder das vorherige Fokusziel zurückgreifen. Verwenden Sie handle.unfocus({ target }), wenn eine bestimmte Komponente Eingaben empfangen soll, während das Overlay sichtbar bleibt. Das Bestehen von { target: null } lässt absichtlich keine fokussierte Komponente zurück, bis der Fokus erneut gesetzt wird.

Overlay-Lebenszyklus

Overlay-Komponenten werden im geschlossenen Zustand entsorgt. Referenzen nicht wiederverwenden – neue Instanzen erstellen:

// Wrong - stale reference
let menu: MenuComponent;
await ctx.ui.custom((_, __, ___, done) => {
  menu = new MenuComponent(done);
  return menu;
}, { overlay: true });
setActiveComponent(menu);  // Disposed

// Correct - re-call to re-show
const showMenu = () => ctx.ui.custom((_, __, ___, done) => 
  new MenuComponent(done), { overlay: true });

await showMenu();  // First show
await showMenu();  // "Back" = just call again

Unter overlay-qa-tests.ts finden Sie umfassende Beispiele zu Ankern, Rändern, Stapelung, reaktionsfähiger Sichtbarkeit und Animation.

Integrierte Komponenten

Import aus @earendil-works/pi-tui:

import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";

Text

Mehrzeiliger Text mit Zeilenumbruch.

const text = new Text(
  "Hello World",    // content
  1,                // paddingX (default: 1)
  1,                // paddingY (default: 1)
  (s) => bgGray(s)  // optional background function
);
text.setText("Updated");

Kasten

Behälter mit Polsterung und Hintergrundfarbe.

const box = new Box(
  1,                // paddingX
  1,                // paddingY
  (s) => bgGray(s)  // background function
);
box.addChild(new Text("Content", 0, 0));
box.setBgFn((s) => bgBlue(s));

Container

Gruppiert untergeordnete Komponenten vertikal.

const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);

Abstandshalter

Leerer vertikaler Raum.

const spacer = new Spacer(2);  // 2 empty lines

Markdown

Rendert Markdown mit Syntaxhervorhebung.

const md = new Markdown(
  "# Title\n\nSome **bold** text",
  1,        // paddingX
  1,        // paddingY
  theme     // MarkdownTheme (see below)
);
md.setText("Updated markdown");

Bild

Rendert Bilder in unterstützten Terminals (Kitty, iTerm2, Ghostty, WezTerm, Warp).

const image = new Image(
  base64Data,   // base64-encoded image
  "image/png",  // MIME type
  theme,        // ImageTheme
  { maxWidthCells: 80, maxHeightCells: 24 }
);

Tastatureingabe

Verwenden Sie matchesKey() zur Schlüsselerkennung:

import { matchesKey, Key } from "@earendil-works/pi-tui";

handleInput(data: string) {
  if (matchesKey(data, Key.up)) {
    this.selectedIndex--;
  } else if (matchesKey(data, Key.enter)) {
    this.onSelect?.(this.selectedIndex);
  } else if (matchesKey(data, Key.escape)) {
    this.onCancel?.();
  } else if (matchesKey(data, Key.ctrl("c"))) {
    // Ctrl+C
  }
}

Schlüsselbezeichner (verwenden Sie Key.* für die automatische Vervollständigung oder Zeichenfolgenliterale):

  • Grundtasten: Key.enter, Key.escape, Key.tab, Key.space, Key.backspace, Key.delete, Key.home, Key.end
  • Pfeiltasten: Key.up, Key.down, Key.left, Key.right
  • Mit Modifikatoren: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
  • Das String-Format funktioniert auch: "enter", "ctrl+c", "shift+tab", "ctrl+shift+p"

Linienbreite

Kritisch: Jede Zeile ab render() darf den Parameter width nicht überschreiten.

import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";

render(width: number): string[] {
  // Truncate long lines
  return [truncateToWidth(this.text, width)];
}

Dienstprogramme:

  • visibleWidth(str) – Anzeigebreite abrufen (ignoriert ANSI-Codes)
  • truncateToWidth(str, width, ellipsis?) – Mit optionalen Auslassungspunkten abschneiden
  • wrapTextWithAnsi(str, width) – Zeilenumbruch unter Beibehaltung von ANSI-Codes

Erstellen benutzerdefinierter Komponenten

Beispiel: Interaktiver Selektor

import {
  matchesKey, Key,
  truncateToWidth, visibleWidth
} from "@earendil-works/pi-tui";

class MySelector {
  private items: string[];
  private selected = 0;
  private cachedWidth?: number;
  private cachedLines?: string[];
  
  public onSelect?: (item: string) => void;
  public onCancel?: () => void;

  constructor(items: string[]) {
    this.items = items;
  }

  handleInput(data: string): void {
    if (matchesKey(data, Key.up) && this.selected > 0) {
      this.selected--;
      this.invalidate();
    } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {
      this.selected++;
      this.invalidate();
    } else if (matchesKey(data, Key.enter)) {
      this.onSelect?.(this.items[this.selected]);
    } else if (matchesKey(data, Key.escape)) {
      this.onCancel?.();
    }
  }

  render(width: number): string[] {
    if (this.cachedLines && this.cachedWidth === width) {
      return this.cachedLines;
    }

    this.cachedLines = this.items.map((item, i) => {
      const prefix = i === this.selected ? "> " : "  ";
      return truncateToWidth(prefix + item, width);
    });
    this.cachedWidth = width;
    return this.cachedLines;
  }

  invalidate(): void {
    this.cachedWidth = undefined;
    this.cachedLines = undefined;
  }
}

Verwendung in einer Erweiterung:

pi.registerCommand("pick", {
  description: "Pick an item",
  handler: async (_args, ctx) => {
    const items = ["Option A", "Option B", "Option C"];
    const selected = await ctx.ui.custom<string | null>((tui, _theme, _keybindings, done) => {
      const selector = new MySelector(items);
      selector.onSelect = done;
      selector.onCancel = () => done(null);

      return {
        render: (width) => selector.render(width),
        handleInput: (data) => {
          selector.handleInput(data);
          tui.requestRender();
        },
        invalidate: () => selector.invalidate(),
      };
    });

    if (selected !== null) {
      ctx.ui.notify(`Selected: ${selected}`, "info");
    }
  }
});

Thematisierung

Komponenten akzeptieren Designobjekte für die Gestaltung.

In renderCall/renderResult verwenden Sie den Parameter theme:

renderResult(result, options, theme, context) {
  // Use theme.fg() for foreground colors
  return new Text(theme.fg("success", "Done!"), 0, 0);
  
  // Use theme.bg() for background colors
  const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}

Vordergrundfarben (theme.fg(color, text)):

Kategorie Farben
Allgemein text, accent, muted, dim
Status success, error, warning
Grenzen border, borderAccent, borderMuted
Nachrichten userMessageText, customMessageText, customMessageLabel
Werkzeuge toolTitle, toolOutput
Unterschiede toolDiffAdded, toolDiffRemoved, toolDiffContext
Markdown mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
Syntax syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
Denken thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax
Modi bashMode

Hintergrundfarben (theme.bg(color, text)):

selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg

Für Markdown verwenden Sie getMarkdownTheme():

import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
import { Markdown } from "@earendil-works/pi-tui";

renderResult(result, options, theme, context) {
  const mdTheme = getMarkdownTheme();
  return new Markdown(result.details.markdown, 0, 0, mdTheme);
}

Für benutzerdefinierte Komponenten definieren Sie Ihre eigene Designoberfläche:

interface MyTheme {
  selected: (s: string) => string;
  normal: (s: string) => string;
}

Debug-Protokollierung

Legen Sie PI_TUI_WRITE_LOG fest, um den rohen ANSI-Stream zu erfassen, der in stdout geschrieben wird.

PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts

Leistung

Zwischenspeichern der gerenderten Ausgabe, wenn möglich:

class CachedComponent {
  private cachedWidth?: number;
  private cachedLines?: string[];

  render(width: number): string[] {
    if (this.cachedLines && this.cachedWidth === width) {
      return this.cachedLines;
    }
    // ... compute lines ...
    this.cachedWidth = width;
    this.cachedLines = lines;
    return lines;
  }

  invalidate(): void {
    this.cachedWidth = undefined;
    this.cachedLines = undefined;
  }
}

Rufen Sie invalidate() auf, wenn sich der Status ändert, und verwenden Sie dann das injizierte tui.requestRender(), um ein erneutes Rendern auszulösen.

Ungültigmachung und Themenänderungen

Wenn sich das Thema ändert, ruft TUI invalidate() für alle Komponenten auf, um deren Caches zu leeren. Komponenten müssen invalidate() ordnungsgemäß implementieren, um sicherzustellen, dass Designänderungen wirksam werden.

Das Problem

Wenn eine Komponente Theme-Farben vorab in Strings speichert (über theme.fg(), theme.bg() usw.) und diese zwischenspeichert, enthalten die zwischengespeicherten Strings ANSI-Escape-Codes aus dem alten Theme. Das bloße Leeren des Rendercaches reicht nicht aus, wenn die Komponente den thematischen Inhalt separat speichert.

Falscher Ansatz (Designfarben werden nicht aktualisiert):

class BadComponent extends Container {
  private content: Text;

  constructor(message: string, theme: Theme) {
    super();
    // Pre-baked theme colors stored in Text component
    this.content = new Text(theme.fg("accent", message), 1, 0);
    this.addChild(this.content);
  }
  // No invalidate override - parent's invalidate only clears
  // child render caches, not the pre-baked content
}

Die Lösung

Komponenten, die Inhalte mit Designfarben erstellen, müssen diesen Inhalt neu erstellen, wenn invalidate() aufgerufen wird:

class GoodComponent extends Container {
  private message: string;
  private content: Text;

  constructor(message: string) {
    super();
    this.message = message;
    this.content = new Text("", 1, 0);
    this.addChild(this.content);
    this.updateDisplay();
  }

  private updateDisplay(): void {
    // Rebuild content with current theme
    this.content.setText(theme.fg("accent", this.message));
  }

  override invalidate(): void {
    super.invalidate();  // Clear child caches
    this.updateDisplay(); // Rebuild with new theme
  }
}

Muster: Bei Invalidierung neu erstellen

Für Komponenten mit komplexem Inhalt:

class ComplexComponent extends Container {
  private data: SomeData;

  constructor(data: SomeData) {
    super();
    this.data = data;
    this.rebuild();
  }

  private rebuild(): void {
    this.clear();  // Remove all children

    // Build UI with current theme
    this.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));
    this.addChild(new Spacer(1));

    for (const item of this.data.items) {
      const color = item.active ? "success" : "muted";
      this.addChild(new Text(theme.fg(color, item.label), 1, 0));
    }
  }

  override invalidate(): void {
    super.invalidate();
    this.rebuild();
  }
}

Wenn es darauf ankommt

Dieses Muster wird benötigt, wenn:

  1. Designfarben vor dem Backen – Verwenden Sie theme.fg() oder theme.bg(), um gestaltete Zeichenfolgen zu erstellen, die in untergeordneten Komponenten gespeichert sind
  2. Syntaxhervorhebung – Verwendung von highlightCode(), das themenbasierte Syntaxfarben anwendet
  3. Komplexe Layouts – Erstellen von untergeordneten Komponentenbäumen, die Themenfarben einbetten

Dieses Muster wird NICHT benötigt, wenn:

  1. Themenrückrufe verwenden – Übergeben von Funktionen wie (text) => theme.fg("accent", text), die während des Renderns aufgerufen werden
  2. Einfache Container – Nur andere Komponenten gruppieren, ohne thematische Inhalte hinzuzufügen
  3. Zustandsloses Rendern – Die thematische Ausgabe wird bei jedem render()-Aufruf frisch berechnet (kein Caching)

Gemeinsame Muster

Diese Muster decken die häufigsten UI-Anforderungen in Erweiterungen ab. Kopieren Sie diese Muster, anstatt sie von Grund auf neu zu erstellen.

Muster 1: Auswahldialog (SelectList)

Damit Benutzer aus einer Liste von Optionen auswählen können. Verwenden Sie SelectList von @earendil-works/pi-tui mit DynamicBorder zum Einrahmen.

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";

pi.registerCommand("pick", {
  handler: async (_args, ctx) => {
    const items: SelectItem[] = [
      { value: "opt1", label: "Option 1", description: "First option" },
      { value: "opt2", label: "Option 2", description: "Second option" },
      { value: "opt3", label: "Option 3" },  // description is optional
    ];

    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
      const container = new Container();

      // Top border
      container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));

      // Title
      container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0));

      // SelectList with theme
      const selectList = new SelectList(items, Math.min(items.length, 10), {
        selectedPrefix: (t) => theme.fg("accent", t),
        selectedText: (t) => theme.fg("accent", t),
        description: (t) => theme.fg("muted", t),
        scrollInfo: (t) => theme.fg("dim", t),
        noMatch: (t) => theme.fg("warning", t),
      });
      selectList.onSelect = (item) => done(item.value);
      selectList.onCancel = () => done(null);
      container.addChild(selectList);

      // Help text
      container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));

      // Bottom border
      container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));

      return {
        render: (w) => container.render(w),
        invalidate: () => container.invalidate(),
        handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
      };
    });

    if (result) {
      ctx.ui.notify(`Selected: ${result}`, "info");
    }
  },
});

Beispiele: preset.ts, tools.ts

Muster 2: Asynchroner Vorgang mit Abbrechen (BorderedLoader)

Für Vorgänge, die Zeit in Anspruch nehmen und stornierbar sein sollen. BorderedLoader zeigt einen Spinner und verarbeitet Escape zum Abbrechen.

import { BorderedLoader } from "@earendil-works/pi-coding-agent";

pi.registerCommand("fetch", {
  handler: async (_args, ctx) => {
    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
      const loader = new BorderedLoader(tui, theme, "Fetching data...");
      loader.onAbort = () => done(null);

      // Do async work
      fetchData(loader.signal)
        .then((data) => done(data))
        .catch(() => done(null));

      return loader;
    });

    if (result === null) {
      ctx.ui.notify("Cancelled", "info");
    } else {
      ctx.ui.setEditorText(result);
    }
  },
});

Beispiele: qna.ts, handoff.ts

Muster 3: Einstellungen/Umschaltungen (SettingsList)

Zum Umschalten mehrerer Einstellungen. Verwenden Sie SettingsList von @earendil-works/pi-tui mit getSettingsListTheme().

import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";

pi.registerCommand("settings", {
  handler: async (_args, ctx) => {
    const items: SettingItem[] = [
      { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] },
      { id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] },
    ];

    await ctx.ui.custom((_tui, theme, _kb, done) => {
      const container = new Container();
      container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1));

      const settingsList = new SettingsList(
        items,
        Math.min(items.length + 2, 15),
        getSettingsListTheme(),
        (id, newValue) => {
          // Handle value change
          ctx.ui.notify(`${id} = ${newValue}`, "info");
        },
        () => done(undefined),  // On close
        { enableSearch: true }, // Optional: enable fuzzy search by label
      );
      container.addChild(settingsList);

      return {
        render: (w) => container.render(w),
        invalidate: () => container.invalidate(),
        handleInput: (data) => settingsList.handleInput?.(data),
      };
    });
  },
});

Beispiele: tools.ts

Muster 4: Permanente Statusanzeige

Zeigt den Status in der Fußzeile an, der über alle Renderings hinweg bestehen bleibt. Gut für Modusanzeigen.

// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));

// Clear status
ctx.ui.setStatus("my-ext", undefined);

Beispiele: status-line.ts, plan-mode/index.ts, preset.ts

Muster 4b: Anpassung des Arbeitsindikators

Passen Sie die Inline-Arbeitsanzeige an, die angezeigt wird, während Pi eine Antwort streamt.

// Static indicator
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });

// Custom animated indicator
ctx.ui.setWorkingIndicator({
  frames: [
    ctx.ui.theme.fg("dim", "·"),
    ctx.ui.theme.fg("muted", "•"),
    ctx.ui.theme.fg("accent", "●"),
    ctx.ui.theme.fg("muted", "•"),
  ],
  intervalMs: 120,
});

// Hide the indicator entirely
ctx.ui.setWorkingIndicator({ frames: [] });

// Restore pi's default spinner
ctx.ui.setWorkingIndicator();

Dies betrifft nur die normale Streaming-Arbeitsanzeige. Komprimierungs- und Wiederholungslader behalten ihr integriertes Design. Benutzerdefinierte Rahmen werden wörtlich gerendert, sodass Erweiterungen bei Bedarf ihre eigenen Farben hinzufügen müssen.

Beispiele: working-indicator.ts

Muster 5: Widgets über/unter dem Editor

Zeigen Sie persistenten Inhalt über oder unter dem Eingabeeditor an. Gut für To-do-Listen, Fortschritt.

// Simple string array (above editor by default)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);

// Render below the editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });

// Or with theme
ctx.ui.setWidget("my-widget", (_tui, theme) => {
  const lines = items.map((item, i) =>
    item.done
      ? theme.fg("success", "✓ ") + theme.fg("muted", item.text)
      : theme.fg("dim", "○ ") + item.text
  );
  return {
    render: () => lines,
    invalidate: () => {},
  };
});

// Clear
ctx.ui.setWidget("my-widget", undefined);

Beispiele: plan-mode/index.ts

Muster 6: Benutzerdefinierte Fußzeile

Ersetzen Sie die Fußzeile. footerData macht Daten verfügbar, auf die Erweiterungen ansonsten nicht zugreifen können.

ctx.ui.setFooter((tui, theme, footerData) => ({
  invalidate() {},
  render(width: number): string[] {
    // footerData.getGitBranch(): string | null
    // footerData.getExtensionStatuses(): ReadonlyMap<string, string>
    return [`${ctx.model?.id} (${footerData.getGitBranch() || "no git"})`];
  },
  dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive
}));

ctx.ui.setFooter(undefined); // restore default

Token-Statistiken verfügbar über ctx.sessionManager.getBranch() und ctx.model.

Beispiele: custom-footer.ts

Muster 7: Benutzerdefinierter Editor (VIM-Modus usw.)

Ersetzen Sie den Haupteingabeeditor durch eine benutzerdefinierte Implementierung. Nützlich für modale Bearbeitung (vim), verschiedene Tastenkombinationen (emacs) oder spezielle Eingabeverarbeitung.

import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";

type Mode = "normal" | "insert";

class VimEditor extends CustomEditor {
  private mode: Mode = "insert";

  handleInput(data: string): void {
    // Escape: switch to normal mode, or pass through for app handling
    if (matchesKey(data, "escape")) {
      if (this.mode === "insert") {
        this.mode = "normal";
        return;
      }
      // In normal mode, escape aborts agent (handled by CustomEditor)
      super.handleInput(data);
      return;
    }

    // Insert mode: pass everything to CustomEditor
    if (this.mode === "insert") {
      super.handleInput(data);
      return;
    }

    // Normal mode: vim-style navigation
    switch (data) {
      case "i": this.mode = "insert"; return;
      case "h": super.handleInput("\x1b[D"); return; // Left
      case "j": super.handleInput("\x1b[B"); return; // Down
      case "k": super.handleInput("\x1b[A"); return; // Up
      case "l": super.handleInput("\x1b[C"); return; // Right
    }
    // Pass unhandled keys to super (ctrl+c, etc.), but filter printable chars
    if (data.length === 1 && data.charCodeAt(0) >= 32) return;
    super.handleInput(data);
  }

  render(width: number): string[] {
    const lines = super.render(width);
    // Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)
    if (lines.length > 0) {
      const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
      const lastLine = lines[lines.length - 1]!;
      // Pass "" as ellipsis to avoid adding "..." when truncating
      lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, "") + label;
    }
    return lines;
  }
}

export default function (pi: ExtensionAPI) {
  pi.on("session_start", (_event, ctx) => {
    // Factory receives the TUI, theme, and keybindings from the app
    ctx.ui.setEditorComponent((tui, theme, keybindings) =>
      new VimEditor(tui, theme, keybindings)
    );
  });
}

Wichtige Punkte:

  • Erweitern Sie CustomEditor (nicht Basis Editor), um App-Tastenkombinationen zu erhalten (Escape zum Abbrechen, Strg+D zum Beenden, Modellwechsel usw.)
  • Rufen Sie super.handleInput(data) an, wenn Sie Schlüssel benötigen, die Sie nicht verwalten
  • Factory-Muster: setEditorComponent empfängt eine Factory-Funktion, die tui, theme und keybindings erhält
  • Übergeben Sie undefined, um den Standardeditor wiederherzustellen: ctx.ui.setEditorComponent(undefined)

Beispiele: modal-editor.ts

Schlüsselregeln

  1. Theme immer aus Rückruf verwenden – Theme nicht direkt importieren. Verwenden Sie theme aus dem ctx.ui.custom((tui, theme, keybindings, done) =>...)-Rückruf.

  2. Geben Sie immer den DynamicBorder-Farbparameter ein – Schreiben Sie (s: string) => theme.fg("accent", s), nicht (s) => theme.fg("accent", s).

  3. Tui.requestRender() nach Statusänderungen aufrufen – Rufen Sie in handleInput tui.requestRender() auf, nachdem Sie den Status aktualisiert haben.

  4. Gibt das Drei-Methoden-Objekt zurück – Benutzerdefinierte Komponenten benötigen { render, invalidate, handleInput }.

  5. Vorhandene Komponenten nutzenSelectList, SettingsList, BorderedLoader decken 90 % der Fälle ab. Bauen Sie sie nicht wieder auf.

Beispiele