TUI Composants
pi peut créer TUI composants. Demandez-lui d'en créer un pour votre cas d'utilisation.
Extensions et les outils personnalisés peuvent restituer des composants TUI personnalisés pour les interfaces utilisateur interactives. Cette page couvre le système de composants et les blocs de construction disponibles.
Source: @earendil-works/pi-tui
Interface des composants
Tous les composants implémentent:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}| Méthode | Description |
|---|---|
render(width) |
Renvoie un tableau de chaînes (une par ligne). Chaque ligne ne doit pas dépasser width. |
handleInput?(data) |
Recevoir une entrée au clavier lorsque le composant a le focus. |
wantsKeyRelease? |
Si c'est vrai, le composant reçoit les événements de version de clé (protocole Kitty). Par défaut: faux. |
invalidate() |
Effacer l'état de rendu mis en cache. Appelé aux changements de thème. |
Le TUI ajoute une réinitialisation complète SGR et une réinitialisation OSC 8 à la fin de chaque ligne rendue. Les styles ne s’étendent pas sur les lignes. Si vous émettez du texte multiligne avec style, réappliquez les styles par ligne ou utilisez wrapTextWithAnsi() afin que les styles soient préservés pour chaque ligne renvoyée à la ligne.
Interface focalisable (prise en charge IME)
Les composants qui affichent un curseur de texte et nécessitent la prise en charge IME (Input Method Editor) doivent implémenter l'interface Focusable:
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}`];
}
}Lorsqu'un composant Focusable a le focus, TUI:
- Définit
focused = truesur le composant - Analyse la sortie rendue pour
CURSOR_MARKER(une séquence d'échappement APC de largeur nulle) - Positionne le curseur du terminal matériel à cet emplacement
- Affiche le curseur matériel uniquement lorsque
showHardwareCursorest activé
Le curseur reste masqué par défaut. Cela conserve le faux rendu du curseur, tout en positionnant le curseur matériel pour les terminaux qui suivent les fenêtres candidates IME avec des curseurs cachés. Certains terminaux nécessitent un curseur matériel visible pour le positionnement IME; activez-le avec showHardwareCursor, setShowHardwareCursor(true) ou PI_HARDWARE_CURSOR=1. Les composants intégrés Editor et Input implémentent déjà cette interface.
Composants de conteneur avec entrées intégrées
Lorsqu'un composant conteneur (boîte de dialogue, sélecteur, etc.) contient un enfant Input ou Editor, le conteneur doit implémenter Focusable et propager l'état de focus à l'enfant. Sinon, le curseur matériel ne sera pas positionné correctement pour la saisie IME.
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);
}
}Sans cette propagation, taper avec un IME (chinois, japonais, coréen, etc.) affichera la fenêtre candidat dans la mauvaise position à l'écran.
Utilisation de composants
Dans les extensions via 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),
})
);
});Dans les outils personnalisés via 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...
}Superpositions
Les superpositions affichent les composants au-dessus du contenu existant sans effacer l'écran. Passez { overlay: true } à ctx.ui.custom():
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
{ overlay: true }
);Pour le positionnement et le dimensionnement, utilisez 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
},
}
);Mise au point de superposition
Une superposition visible et ciblée conserve la propriété des entrées dans l'interface utilisateur temporaire sans superposition. Si une superposition ouvre un autre composant ctx.ui.custom() sans { overlay: true }, cette interface utilisateur de remplacement reçoit une entrée pendant qu'elle est active; lorsqu'elle se ferme, la superposition ciblée peut récupérer l'entrée.
Utilisez handle.unfocus() lorsqu'une superposition visible ne doit plus posséder d'entrée et laissez TUI revenir à une autre superposition de capture visible ou à la cible de focus précédente. Utilisez handle.unfocus({ target }) lorsqu'un composant spécifique doit recevoir une entrée tandis que la superposition reste visible. Passer { target: null } intentionnellement ne laisse aucun composant focalisé jusqu'à ce que le focus soit à nouveau défini.
Cycle de vie de la superposition
Les composants de superposition sont éliminés une fois fermés. Ne réutilisez pas les références - créez de nouvelles instances:
// 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 againVoir overlay-qa-tests.ts pour des exemples complets couvrant les ancres, les marges, l'empilement, la visibilité réactive et l'animation.
Composants intégrés
Importer depuis @earendil-works/pi-tui:
import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";Texte
Texte multiligne avec retour à la ligne.
const text = new Text(
"Hello World", // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s) // optional background function
);
text.setText("Updated");Boîte
Conteneur avec rembourrage et couleur d'arrière-plan.
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));Récipient
Regroupe les composants enfants verticalement.
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);Entretoise
Espace vertical vide.
const spacer = new Spacer(2); // 2 empty linesMarkdown
Rend le démarque avec la coloration syntaxique.
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");Image
Rend les images dans les terminaux pris en charge (Kitty, iTerm2, Ghostty, WezTerm, Warp).
const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);Entrée au clavier
Utilisez matchesKey() pour la détection de clé:
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
}
}Identifiants de clé (utilisez Key.* pour la saisie semi-automatique ou les chaînes littérales):
- Touches de base:
Key.enter,Key.escape,Key.tab,Key.space,Key.backspace,Key.delete,Key.home,Key.end - Touches fléchées:
Key.up,Key.down,Key.left,Key.right - Avec modificateurs:
Key.ctrl("c"),Key.shift("tab"),Key.alt("left"),Key.ctrlShift("p") - Le format de chaîne fonctionne également:
"enter","ctrl+c","shift+tab","ctrl+shift+p"
Largeur de ligne
Critique: Chaque ligne de render() ne doit pas dépasser le paramètre width.
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}Utilitaires:
visibleWidth(str)- Obtenir la largeur d'affichage (ignore les codes ANSI)truncateToWidth(str, width, ellipsis?)- Tronquer avec des points de suspension facultatifswrapTextWithAnsi(str, width)- Retour à la ligne préservant les codes ANSI
Création de composants personnalisés
Exemple: sélecteur interactif
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;
}
}Utilisation dans une extension:
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");
}
}
});Thématisation
Les composants acceptent les objets de thème pour le style.
Dans renderCall/renderResult, utilisez le paramètre 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"));
}Couleurs de premier plan (theme.fg(color, text)):
| Catégorie | Couleurs |
|---|---|
| Général | text, accent, muted, dim |
| Statut | success, error, warning |
| Frontières | border, borderAccent, borderMuted |
| Messages | userMessageText, customMessageText, customMessageLabel |
| Outils | toolTitle, toolOutput |
| Différences | toolDiffAdded, toolDiffRemoved, toolDiffContext |
| Markdown | mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet |
| Syntaxe | syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation |
| Pensée | thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax |
| Modes | bashMode |
Couleurs de fond (theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
Pour Markdown, utilisez 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);
}Pour les composants personnalisés, définissez votre propre interface de thème:
interface MyTheme {
selected: (s: string) => string;
normal: (s: string) => string;
}Journalisation du débogage
Définissez PI_TUI_WRITE_LOG pour capturer le flux ANSI brut écrit sur stdout.
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.tsPerformance
Cacher la sortie rendue lorsque cela est possible:
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;
}
}Appelez invalidate() lorsque l'état change, puis utilisez le tui.requestRender() injecté pour déclencher un nouveau rendu.
Invalidation et changements de thème
Lorsque le thème change, le TUI appelle invalidate() sur tous les composants pour vider leurs caches. Les composants doivent implémenter correctement invalidate() pour garantir que les changements de thème prennent effet.
Le problème
Si un composant pré-prépare les couleurs du thème dans des chaînes (via theme.fg(), theme.bg(), etc.) et les met en cache, les chaînes mises en cache contiennent les codes d'échappement ANSI de l'ancien thème. Il ne suffit pas de vider le cache de rendu si le composant stocke le contenu thématique séparément.
Mauvaise approche (les couleurs du thème ne seront pas mises à jour):
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
}La solution
Les composants qui créent du contenu avec des couleurs de thème doivent reconstruire ce contenu lorsque invalidate() est appelé:
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
}
}Modèle: Reconstruire en cas d'invalidation
Pour les composants au contenu complexe:
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();
}
}Quand c'est important
Ce modèle est nécessaire lorsque:
- Couleurs du thème de pré-cuisson - Utilisation de
theme.fg()outheme.bg()pour créer des chaînes stylisées stockées dans les composants enfants - Mise en évidence de la syntaxe - Utilisation de
highlightCode()qui applique des couleurs de syntaxe basées sur un thème - Mises en page complexes - Création d'arborescences de composants enfants qui intègrent des couleurs de thème
Ce modèle n'est PAS nécessaire lorsque:
- Utilisation des rappels de thème - Passage de fonctions telles que
(text) => theme.fg("accent", text)qui sont appelées lors du rendu - Conteneurs simples - Regrouper simplement d'autres composants sans ajouter de contenu thématique
- Rendu sans état - Sortie thématique informatique à chaque appel
render()(pas de mise en cache)
Modèles courants
Ces modèles couvrent les besoins d’interface utilisateur les plus courants dans les extensions. Copiez ces modèles au lieu de créer à partir de zéro.
Modèle 1: boîte de dialogue de sélection (SelectList)
Pour permettre aux utilisateurs de choisir parmi une liste d'options. Utilisez SelectList de @earendil-works/pi-tui avec DynamicBorder pour le cadrage.
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");
}
},
});
Modèle 2: opération asynchrone avec annulation (BorderedLoader)
Pour les opérations qui prennent du temps et doivent être annulables. BorderedLoader affiche une roulette et gère l'échappement pour annuler.
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);
}
},
});Exemples: qna.ts, handoff.ts
Modèle 3: Paramètres/Bascules (Liste des paramètres)
Pour basculer plusieurs paramètres. Utilisez SettingsList de @earendil-works/pi-tui avec 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),
};
});
},
});Exemples: tools.ts
Modèle 4: indicateur d'état persistant
Afficher l'état dans le pied de page qui persiste dans les rendus. Bon pour les indicateurs de mode.
// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
// Clear status
ctx.ui.setStatus("my-ext", undefined);Exemples: status-line.ts, plan-mode/index.ts, preset.ts
Modèle 4b: personnalisation des indicateurs de travail
Personnalisez l'indicateur de travail en ligne affiché pendant que pi diffuse une réponse.
// 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();Cela n’affecte que l’indicateur de fonctionnement normal du streaming. Les chargeurs de compactage et de nouvelle tentative conservent leur style intégré. Les cadres personnalisés sont rendus textuellement, les extensions doivent donc ajouter leurs propres couleurs si nécessaire.
Exemples: working-indicator.ts
Modèle 5: Éditeur de widgets au-dessus/en dessous
Afficher le contenu persistant au-dessus ou en dessous de l'éditeur d'entrée. Bon pour les listes de tâches, les progrès.
// 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);Exemples: plan-mode/index.ts
Modèle 6: pied de page personnalisé
Remplacez le pied de page. footerData expose des données qui ne seraient autrement pas accessibles aux extensions.
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 defaultStatistiques des jetons disponibles via ctx.sessionManager.getBranch() et ctx.model.
Exemples: custom-footer.ts
Modèle 7: Éditeur personnalisé (mode vim, etc.)
Remplacez l'éditeur d'entrée principal par une implémentation personnalisée. Utile pour l'édition modale (vim), différentes combinaisons de touches (emacs) ou la gestion des entrées spécialisées.
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)
);
});
}Points clés:
- Étendez
CustomEditor(pas la baseEditor) pour obtenir les raccourcis clavier de l'application (échappement pour abandonner, ctrl+d pour quitter, changement de modèle, etc.) - Appelez le
super.handleInput(data)pour les clés que vous ne gérez pas - Modèle d'usine:
setEditorComponentreçoit une fonction d'usine qui obtienttui,themeetkeybindings - Passez
undefinedpour restaurer l'éditeur par défaut:ctx.ui.setEditorComponent(undefined)
Exemples: modal-editor.ts
Règles clés
Toujours utiliser le thème du rappel - N'importez pas le thème directement. Utilisez
themeà partir du rappelctx.ui.custom((tui, theme, keybindings, done) =>...).Toujours taper le paramètre de couleur DynamicBorder - Écrivez
(s: string) => theme.fg("accent", s), pas(s) => theme.fg("accent", s).Appelez tui.requestRender() après un changement d'état - Dans
handleInput, appeleztui.requestRender()après la mise à jour de l'état.Renvoyer l'objet à trois méthodes - Les composants personnalisés ont besoin de
{ render, invalidate, handleInput }.Utiliser les composants existants -
SelectList,SettingsList,BorderedLoadercouvrent 90 % des cas. Ne les reconstruisez pas.
Exemples
- UI de sélection: examples/extensions/preset.ts - SelectList avec cadrage DynamicBorder
- Async avec annulation: examples/extensions/qna.ts - BorderedLoader pour les appels LLM
- Les paramètres basculent: examples/extensions/tools.ts - Liste des paramètres pour l'activation/la désactivation de l'outil
- Indicateurs d'état: examples/extensions/plan-mode/index.ts - setStatus et setWidget
- Indicateur de fonctionnement: examples/extensions/working-indicator.ts - setWorkingIndicator
- Pied de page personnalisé: examples/extensions/custom-footer.ts - setFooter avec statistiques
- Éditeur personnalisé: examples/extensions/modal-editor.ts - Édition modale de type Vim
- Jeu de serpent: examples/extensions/snake.ts - Jeu complet avec saisie au clavier, boucle de jeu
- Rendu d'outil personnalisé: examples/extensions/todo.ts - renderCall et renderResult