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:
- Setzt
focused = truefür die Komponente - Durchsucht die gerenderte Ausgabe nach
CURSOR_MARKER(einer APC-Escape-Sequenz mit der Breite Null) - Positioniert den Hardware-Terminal-Cursor an dieser Stelle
- Zeigt den Hardware-Cursor nur an, wenn
showHardwareCursoraktiviert 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 againUnter 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 linesMarkdown
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 abschneidenwrapTextWithAnsi(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.tsLeistung
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:
- Designfarben vor dem Backen – Verwenden Sie
theme.fg()odertheme.bg(), um gestaltete Zeichenfolgen zu erstellen, die in untergeordneten Komponenten gespeichert sind - Syntaxhervorhebung – Verwendung von
highlightCode(), das themenbasierte Syntaxfarben anwendet - Komplexe Layouts – Erstellen von untergeordneten Komponentenbäumen, die Themenfarben einbetten
Dieses Muster wird NICHT benötigt, wenn:
- Themenrückrufe verwenden – Übergeben von Funktionen wie
(text) => theme.fg("accent", text), die während des Renderns aufgerufen werden - Einfache Container – Nur andere Komponenten gruppieren, ohne thematische Inhalte hinzuzufügen
- 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 defaultToken-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 BasisEditor), 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:
setEditorComponentempfängt eine Factory-Funktion, dietui,themeundkeybindingserhält - Übergeben Sie
undefined, um den Standardeditor wiederherzustellen:ctx.ui.setEditorComponent(undefined)
Beispiele: modal-editor.ts
Schlüsselregeln
Theme immer aus Rückruf verwenden – Theme nicht direkt importieren. Verwenden Sie
themeaus demctx.ui.custom((tui, theme, keybindings, done) =>...)-Rückruf.Geben Sie immer den DynamicBorder-Farbparameter ein – Schreiben Sie
(s: string) => theme.fg("accent", s), nicht(s) => theme.fg("accent", s).Tui.requestRender() nach Statusänderungen aufrufen – Rufen Sie in
handleInputtui.requestRender()auf, nachdem Sie den Status aktualisiert haben.Gibt das Drei-Methoden-Objekt zurück – Benutzerdefinierte Komponenten benötigen
{ render, invalidate, handleInput }.Vorhandene Komponenten nutzen –
SelectList,SettingsList,BorderedLoaderdecken 90 % der Fälle ab. Bauen Sie sie nicht wieder auf.
Beispiele
- Auswahl-Benutzeroberfläche: examples/extensions/preset.ts – SelectList mit DynamicBorder-Rahmen
- Asynchron mit Abbrechen: examples/extensions/qna.ts – BorderedLoader für LLM-Aufrufe
- Einstellungen umschalten: examples/extensions/tools.ts – Einstellungsliste zum Aktivieren/Deaktivieren des Tools
- Statusanzeigen: examples/extensions/plan-mode/index.ts – setStatus und setWidget
- Arbeitsindikator: examples/extensions/working-indicator.ts – setWorkingIndicator
- Benutzerdefinierte Fußzeile: examples/extensions/custom-footer.ts – setFooter mit Statistiken
- Benutzerdefinierter Editor: examples/extensions/modal-editor.ts – Vim-ähnliche modale Bearbeitung
- Schlangenspiel: examples/extensions/snake.ts – Vollständiges Spiel mit Tastatureingabe, Spielschleife
- Benutzerdefiniertes Tool-Rendering: examples/extensions/todo.ts – renderCall und renderResult