TUI Componentes
pi puede crear componentes TUI. Pídale que cree uno para su caso de uso.
Extensions y las herramientas personalizadas pueden representar componentes TUI personalizados para interfaces de usuario interactivas. Esta página cubre el sistema de componentes y los bloques de construcción disponibles.
Fuente: @earendil-works/pi-tui
Interfaz de componente
Todos los componentes implementan:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}| Método | Descripción |
|---|---|
render(width) |
Devuelve una matriz de cadenas (una por línea). Cada línea no debe exceder width. |
handleInput?(data) |
Reciba entradas del teclado cuando el componente esté enfocado. |
wantsKeyRelease? |
Si es verdadero, el componente recibe eventos de liberación de claves (protocolo Kitty). Valor predeterminado: falso. |
invalidate() |
Borrar el estado de renderizado en caché. Pidió cambios de tema. |
El TUI agrega un reinicio completo de SGR y un reinicio de OSC 8 al final de cada línea renderizada. Los estilos no cruzan líneas. Si emite texto de varias líneas con estilo, vuelva a aplicar estilos por línea o use wrapTextWithAnsi() para que los estilos se conserven para cada línea ajustada.
Interfaz enfocable (soporte IME)
Los componentes que muestran un cursor de texto y necesitan compatibilidad con IME (Editor de métodos de entrada) deben implementar la interfaz 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}`];
}
}Cuando un componente Focusable tiene foco, TUI:
- Establece
focused = trueen el componente - Los escaneos generaron resultados para
CURSOR_MARKER(una secuencia de escape APC de ancho cero) - Coloca el cursor del terminal de hardware en esa ubicación
- Muestra el cursor de hardware solo cuando
showHardwareCursorestá habilitado
El cursor permanece oculto de forma predeterminada. Esto mantiene la representación del cursor falso y al mismo tiempo posiciona el cursor de hardware para terminales que rastrean ventanas candidatas de IME con cursores ocultos. Algunos terminales requieren un cursor de hardware visible para el posicionamiento de IME; habilítelo con showHardwareCursor, setShowHardwareCursor(true) o PI_HARDWARE_CURSOR=1. Los componentes integrados Editor y Input ya implementan esta interfaz.
Componentes de contenedor con entradas integradas
Cuando un componente contenedor (diálogo, selector, etc.) contiene un hijo Input o Editor, el contenedor debe implementar Focusable y propagar el estado de enfoque al hijo. De lo contrario, el cursor de hardware no se colocará correctamente para la entrada de 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);
}
}Sin esta propagación, escribir con un IME (chino, japonés, coreano, etc.) mostrará la ventana del candidato en la posición incorrecta en la pantalla.
Usando componentes
En extensiones vía 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),
})
);
});En herramientas personalizadas vía 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...
}Superposiciones
Las superposiciones representan componentes sobre el contenido existente sin borrar la pantalla. Pase { overlay: true } a ctx.ui.custom():
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
{ overlay: true }
);Para posicionamiento y tamaño, use 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
},
}
);Enfoque de superposición
Una superposición visible enfocada mantiene la propiedad de las entradas en la interfaz de usuario temporal sin superposición. Si una superposición abre otro componente ctx.ui.custom() sin { overlay: true }, esa interfaz de usuario de reemplazo recibe información mientras está activa; cuando se cierra, la superposición enfocada puede recuperar la entrada.
Utilice handle.unfocus() cuando una superposición visible deba dejar de poseer entradas y dejar que TUI vuelva a otra superposición de captura visible o al objetivo de enfoque anterior. Utilice handle.unfocus({ target }) cuando un componente específico deba recibir información mientras la superposición permanece visible. Pasar { target: null } intencionalmente no deja ningún componente enfocado hasta que se vuelva a establecer el enfoque.
Ciclo de vida de superposición
Los componentes superpuestos se eliminan cuando están cerrados. No reutilice referencias: cree instancias nuevas:
// 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 againConsulte overlay-qa-tests.ts para obtener ejemplos completos que cubren anclajes, márgenes, apilamiento, visibilidad receptiva y animación.
Componentes incorporados
Importar desde @earendil-works/pi-tui:
import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";Texto
Texto de varias líneas con ajuste de palabras.
const text = new Text(
"Hello World", // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s) // optional background function
);
text.setText("Updated");Caja
Contenedor con relleno y color de fondo.
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));Recipiente
Agrupa los componentes secundarios verticalmente.
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);Espaciador
Espacio vertical vacío.
const spacer = new Spacer(2); // 2 empty linesMarkdown
Representa la reducción con resaltado de sintaxis.
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");Imagen
Renderiza imágenes en terminales compatibles (Kitty, iTerm2, Ghostty, WezTerm, Warp).
const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);Entrada de teclado
Utilice matchesKey() para la detección de claves:
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
}
}Identificadores de clave (use Key.* para autocompletar o literales de cadena):
- Teclas básicas:
Key.enter,Key.escape,Key.tab,Key.space,Key.backspace,Key.delete,Key.home,Key.end - Teclas de flecha:
Key.up,Key.down,Key.left,Key.right - Con modificadores:
Key.ctrl("c"),Key.shift("tab"),Key.alt("left"),Key.ctrlShift("p") - El formato de cadena también funciona:
"enter","ctrl+c","shift+tab","ctrl+shift+p"
Ancho de línea
Crítico: Cada línea desde render() no debe exceder el parámetro width.
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}Utilidades:
visibleWidth(str): obtiene el ancho de visualización (ignora los códigos ANSI)truncateToWidth(str, width, ellipsis?)- Truncar con puntos suspensivos opcionaleswrapTextWithAnsi(str, width)- Ajuste de texto que conserva los códigos ANSI
Crear componentes personalizados
Ejemplo: selector interactivo
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;
}
}Uso en una extensión:
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");
}
}
});Tematización
Los componentes aceptan objetos temáticos para diseñar.
En renderCall/renderResult, use el parámetro 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"));
}Colores de primer plano (theme.fg(color, text)):
| Categoría | Bandera |
|---|---|
| General | text, accent, muted, dim |
| Estado | success, error, warning |
| Fronteras | border, borderAccent, borderMuted |
| Mensajes | userMessageText, customMessageText, customMessageLabel |
| Herramientas | toolTitle, toolOutput |
| diferencias | toolDiffAdded, toolDiffRemoved, toolDiffContext |
| Markdown | mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet |
| Sintaxis | syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation |
| Pensamiento | thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax |
| Modos | bashMode |
Colores de fondo (theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
Para Markdown, use 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);
}Para componentes personalizados, defina su propia interfaz de tema:
interface MyTheme {
selected: (s: string) => string;
normal: (s: string) => string;
}Registro de depuración
Configure PI_TUI_WRITE_LOG para capturar la secuencia ANSI sin procesar escrita en stdout.
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.tsActuación
Caché de salida renderizada cuando sea posible:
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;
}
}Llame a invalidate() cuando cambie el estado, luego use el tui.requestRender() inyectado para activar la nueva renderización.
Invalidación y cambios de tema
Cuando el tema cambia, TUI llama a invalidate() a todos los componentes para borrar sus cachés. Los componentes deben implementar correctamente invalidate() para garantizar que los cambios del tema surtan efecto.
El problema
Si un componente convierte previamente los colores del tema en cadenas (a través de theme.fg(), theme.bg(), etc.) y los almacena en caché, las cadenas almacenadas en caché contienen códigos de escape ANSI del tema anterior. Simplemente borrar el caché de renderizado no es suficiente si el componente almacena el contenido temático por separado.
Enfoque incorrecto (los colores del tema no se actualizarán):
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 solución
Los componentes que crean contenido con colores de tema deben reconstruir ese contenido cuando se llama a invalidate():
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
}
}Patrón: reconstruir al invalidar
Para componentes con contenido complejo:
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();
}
}Cuando esto importa
Este patrón es necesario cuando:
- Colores del tema previo al horneado: uso de
theme.fg()otheme.bg()para crear cadenas con estilo almacenadas en componentes secundarios - Resaltado de sintaxis: uso de
highlightCode(), que aplica colores de sintaxis basados en temas - Diseños complejos - Creación de árboles de componentes secundarios que incorporan colores de temas
Este patrón NO es necesario cuando:
- Usar devoluciones de llamadas de temas - Pasar funciones como
(text) => theme.fg("accent", text)que se llaman durante el renderizado - Contenedores simples: simplemente agrupa otros componentes sin agregar contenido temático
- Renderizado sin estado: Computación de salida temática nueva en cada llamada
render()(sin almacenamiento en caché)
Patrones comunes
Estos patrones cubren las necesidades de interfaz de usuario más comunes en las extensiones. Copia estos patrones en lugar de construir desde cero.
Patrón 1: Diálogo de selección (SelectList)
Para permitir a los usuarios elegir de una lista de opciones. Utilice SelectList de @earendil-works/pi-tui con DynamicBorder para enmarcar.
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");
}
},
});
Patrón 2: operación asíncrona con cancelación (BorderedLoader)
Para operaciones que toman tiempo y deberían ser cancelables. BorderedLoader muestra una ruleta y maneja el escape para cancelar.
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);
}
},
});Ejemplos: qna.ts, handoff.ts
Patrón 3: Configuración/Alternancia (Lista de configuración)
Para alternar múltiples configuraciones. Utilice SettingsList de @earendil-works/pi-tui con 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),
};
});
},
});Ejemplos: tools.ts
Patrón 4: Indicador de estado persistente
Muestra el estado en el pie de página que persiste en todos los renderizados. Bueno para indicadores de modo.
// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
// Clear status
ctx.ui.setStatus("my-ext", undefined);Ejemplos: status-line.ts, plan-mode/index.ts, preset.ts
Patrón 4b: Personalización del indicador de trabajo
Personalice el indicador de trabajo en línea que se muestra mientras pi transmite una respuesta.
// 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();Esto sólo afecta al indicador de funcionamiento normal de la transmisión. Los cargadores de compactación y reintento mantienen su estilo incorporado. Los marcos personalizados se representan palabra por palabra, por lo que las extensiones deben agregar sus propios colores cuando sea necesario.
Ejemplos: working-indicator.ts
Patrón 5: Editor de widgets arriba/abajo
Muestra contenido persistente encima o debajo del editor de entrada. Bueno para listas de tareas pendientes, progreso.
// 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);Ejemplos: plan-mode/index.ts
Patrón 6: pie de página personalizado
Reemplace el pie de página. footerData expone datos a los que las extensiones no pueden acceder de otro modo.
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 defaultEstadísticas de tokens disponibles a través de ctx.sessionManager.getBranch() y ctx.model.
Ejemplos: custom-footer.ts
Patrón 7: Editor personalizado (modo vim, etc.)
Reemplace el editor de entrada principal con una implementación personalizada. Útil para edición modal (vim), diferentes combinaciones de teclas (emacs) o manejo de entrada especializado.
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)
);
});
}Puntos clave:
- Extienda
CustomEditor(no la baseEditor) para obtener combinaciones de teclas de la aplicación (escape para cancelar, Ctrl+d para salir, cambio de modelo, etc.) - Llame al
super.handleInput(data)para llaves que no maneja - Patrón de fábrica:
setEditorComponentrecibe una función de fábrica que obtienetui,themeykeybindings - Pase
undefinedpara restaurar el editor predeterminado:ctx.ui.setEditorComponent(undefined)
Ejemplos: modal-editor.ts
Reglas clave
Utilice siempre el tema de la devolución de llamada - No importe el tema directamente. Utilice
themede la devolución de llamadactx.ui.custom((tui, theme, keybindings, done) =>...).Escriba siempre el parámetro de color DynamicBorder - Escriba
(s: string) => theme.fg("accent", s), no(s) => theme.fg("accent", s).Llame a tui.requestRender() después de cambios de estado - En
handleInput, llame atui.requestRender()después de actualizar el estado.Devolver el objeto de tres métodos - Los componentes personalizados necesitan
{ render, invalidate, handleInput }.Utilice componentes existentes:
SelectList,SettingsList,BorderedLoadercubren el 90 % de los casos. No los reconstruyas.
Ejemplos
- UI de selección: examples/extensions/preset.ts - SelectList con marco DynamicBorder
- Asíncrono con cancelación: examples/extensions/qna.ts - BorderedLoader para llamadas LLM
- Configuración alterna: examples/extensions/tools.ts - Lista de configuraciones para habilitar/deshabilitar herramientas
- Indicadores de estado: examples/extensions/plan-mode/index.ts - setStatus y setWidget
- Indicador de trabajo: examples/extensions/working-indicator.ts - setWorkingIndicator
- Pie de página personalizado: examples/extensions/custom-footer.ts - setFooter con estadísticas
- Editor personalizado: examples/extensions/modal-editor.ts - Edición modal similar a Vim
- Juego de serpiente: examples/extensions/snake.ts - Juego completo con entrada de teclado, bucle de juego
- Herramienta de renderizado personalizada: examples/extensions/todo.ts - renderCall y renderResult