Componentes TUI
pi puede crear componentes TUI. Pídele que construya uno para tu caso de uso.
Componentes TUI
Sección titulada «Componentes TUI»Las extensiones y las herramientas personalizadas pueden renderizar 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 componentes
Sección titulada «Interfaz de componentes»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 un array de cadenas (una por línea). Cada línea no debe superar width. |
handleInput?(data) | Recibe entrada de teclado cuando el componente tiene el foco. |
wantsKeyRelease? | Si es true, el componente recibe eventos de liberación de tecla (protocolo Kitty). Predeterminado: false. |
invalidate() | Borra el estado de renderizado en caché. Se llama al cambiar el tema. |
La TUI añade un reinicio SGR completo y un reinicio OSC 8 al final de cada línea renderizada. Los estilos no se propagan entre líneas. Si emites texto multilínea con estilos, vuelve a aplicar los estilos por línea o usa wrapTextWithAnsi() para conservarlos en cada línea envuelta.
Interfaz enfocable (soporte IME)
Sección titulada «Interfaz enfocable (soporte IME)»Los componentes que muestran un cursor de texto y necesitan soporte IME (Editor de Método 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 el foco, la TUI:
- Establece
focused = trueen el componente - Busca
CURSOR_MARKER(secuencia de escape APC de ancho cero) en la salida renderizada - Posiciona el cursor hardware del terminal en esa ubicación
- Muestra el cursor hardware solo cuando
showHardwareCursorestá habilitado
El cursor permanece oculto por defecto. Esto mantiene el renderizado del cursor falso y aun así posiciona el cursor hardware para terminales que rastrean ventanas de candidatos IME con cursores ocultos. Algunos terminales requieren un cursor hardware visible para el posicionamiento IME; habilítalo con showHardwareCursor, setShowHardwareCursor(true) o PI_HARDWARE_CURSOR=1. Los componentes integrados Editor e Input ya implementan esta interfaz.
Componentes contenedor con entradas incrustadas
Sección titulada «Componentes contenedor con entradas incrustadas»Cuando un componente contenedor (diálogo, selector, etc.) contiene un hijo Input o Editor, el contenedor debe implementar Focusable y propagar el estado de foco al hijo. De lo contrario, el cursor hardware no se posicionará correctamente para la entrada 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, al escribir con un IME (chino, japonés, coreano, etc.) la ventana de candidatos aparecerá en la posición incorrecta en pantalla.
Uso de componentes
Sección titulada «Uso de componentes»En extensiones mediante ctx.ui.custom():
pi.on("session_start", async (_event, ctx) => { const handle = ctx.ui.custom(myComponent); // handle.requestRender() - trigger re-render // handle.close() - restore normal UI});En herramientas personalizadas mediante pi.ui.custom():
async execute(toolCallId, params, onUpdate, ctx, signal) { const handle = pi.ui.custom(myComponent); // ... handle.close();}Superposiciones
Sección titulada «Superposiciones»Las superposiciones renderizan componentes encima del contenido existente sin limpiar la pantalla. Pasa { 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 posición y tamaño, usa 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 }, });Foco de superposición
Sección titulada «Foco de superposición»Una superposición visible enfocada mantiene la propiedad de la entrada durante UI temporal no superpuesta. Si una superposición abre otro componente ctx.ui.custom() sin { overlay: true }, esa UI de reemplazo recibe la entrada mientras está activa; al cerrarse, la superposición enfocada puede recuperar la entrada.
Usa handle.unfocus() cuando una superposición visible deba dejar de poseer la entrada y dejar que la TUI recurra a otra superposición capturadora visible o al objetivo de foco anterior. Usa handle.unfocus({ target }) cuando un componente específico deba recibir la entrada mientras la superposición sigue visible. Pasar { target: null } deja intencionalmente ningún componente enfocado hasta que se establezca el foco de nuevo.
Ciclo de vida de superposición
Sección titulada «Ciclo de vida de superposición»Los componentes de superposición se eliminan al cerrarse. No reutilices referencias: crea instancias nuevas:
// Wrong - stale referencelet menu: MenuComponent;await ctx.ui.custom((_, __, ___, done) => { menu = new MenuComponent(done); return menu;}, { overlay: true });setActiveComponent(menu); // Disposed
// Correct - re-call to re-showconst showMenu = () => ctx.ui.custom((_, __, ___, done) => new MenuComponent(done), { overlay: true });
await showMenu(); // First showawait showMenu(); // "Back" = just call againConsulta overlay-qa-tests.ts para ejemplos completos de anclas, márgenes, apilamiento, visibilidad responsive y animación.
Componentes integrados
Sección titulada «Componentes integrados»Importa desde @earendil-works/pi-tui:
import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";Texto multilínea 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");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));Container
Sección titulada «Container»Agrupa componentes hijos verticalmente.
const container = new Container();container.addChild(component1);container.addChild(component2);container.removeChild(component1);Espacio vertical vacío.
const spacer = new Spacer(2); // 2 empty linesMarkdown
Sección titulada «Markdown»Renderiza markdown 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");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
Sección titulada «Entrada de teclado»Usa matchesKey() para detectar teclas:
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 tecla (usa Key.* para autocompletado o literales de cadena):
- Teclas básicas:
Key.enter,Key.escape,Key.tab,Key.space,Key.backspace,Key.delete,Key.home,Key.end - Flechas:
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
Sección titulada «Ancho de línea»Crítico: Cada línea de render() no debe superar 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 códigos ANSI)truncateToWidth(str, width, ellipsis?)— Trunca con elipsis opcionalwrapTextWithAnsi(str, width)— Ajusta de línea preservando códigos ANSI
Creación de componentes personalizados
Sección titulada «Creación de 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 selector = new MySelector(items);
let handle: { close: () => void; requestRender: () => void };
await new Promise<void>((resolve) => { selector.onSelect = (item) => { ctx.ui.notify(`Selected: ${item}`, "info"); handle.close(); resolve(); }; selector.onCancel = () => { handle.close(); resolve(); }; handle = ctx.ui.custom(selector); }); }});Los componentes aceptan objetos de tema para el estilo.
En renderCall/renderResult, usa 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 | Colores |
|---|---|
| General | text, accent, muted, dim |
| Estado | success, error, warning |
| Bordes | border, borderAccent, borderMuted |
| Mensajes | userMessageText, customMessageText, customMessageLabel |
| Herramientas | toolTitle, toolOutput |
| Diffs | 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 |
| Modos | bashMode |
Colores de fondo (theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
Para Markdown, usa 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, define tu propia interfaz de tema:
interface MyTheme { selected: (s: string) => string; normal: (s: string) => string;}Registro de depuración
Sección titulada «Registro de depuración»Establece PI_TUI_WRITE_LOG para capturar el flujo ANSI crudo escrito en stdout.
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.tsRendimiento
Sección titulada «Rendimiento»Cachea la 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; }}Llama a invalidate() cuando cambie el estado y luego handle.requestRender() para provocar el re-renderizado.
Invalidación y cambios de tema
Sección titulada «Invalidación y cambios de tema»Cuando cambia el tema, la TUI llama a invalidate() en todos los componentes para borrar sus cachés. Los componentes deben implementar correctamente invalidate() para que los cambios de tema surtan efecto.
El problema
Sección titulada «El problema»Si un componente pre-incrusta colores del tema en cadenas (mediante theme.fg(), theme.bg(), etc.) y las cachea, las cadenas en caché contienen códigos de escape ANSI del tema anterior. Limpiar solo la caché de renderizado no basta 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
Sección titulada «La solución»Los componentes que construyen contenido con colores del 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 en invalidate
Sección titulada «Patrón: reconstruir en invalidate»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(); }}Cuándo importa
Sección titulada «Cuándo importa»Este patrón es necesario cuando:
- Pre-incrustas colores del tema — Usas
theme.fg()otheme.bg()para crear cadenas estilizadas almacenadas en componentes hijos - Resaltado de sintaxis — Usas
highlightCode()que aplica colores de sintaxis basados en el tema - Diseños complejos — Construyes árboles de componentes hijos que incrustan colores del tema
Este patrón NO es necesario cuando:
- Usas callbacks de tema — Pasas funciones como
(text) => theme.fg("accent", text)que se invocan durante el renderizado - Contenedores simples — Solo agrupas otros componentes sin añadir contenido temático
- Renderizado sin estado — Calculas la salida temática de nuevo en cada llamada a
render()(sin caché)
Patrones comunes
Sección titulada «Patrones comunes»Estos patrones cubren las necesidades de UI más habituales en extensiones. Copia estos patrones en lugar de construir desde cero.
Patrón 1: Diálogo de selección (SelectList)
Sección titulada «Patrón 1: Diálogo de selección (SelectList)»Para que los usuarios elijan de una lista de opciones. Usa SelectList de @earendil-works/pi-tui con DynamicBorder para el marco.
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)
Sección titulada «Patrón 2: Operación asíncrona con cancelación (BorderedLoader)»Para operaciones que tardan y deben poder cancelarse. BorderedLoader muestra un spinner y maneja 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: Ajustes/Interruptores (SettingsList)
Sección titulada «Patrón 3: Ajustes/Interruptores (SettingsList)»Para alternar varios ajustes. Usa 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
Sección titulada «Patrón 4: Indicador de estado persistente»Muestra estado en el pie de página que persiste entre renderizados. Bueno para indicadores de modo.
// Set status (shown in footer)ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
// Clear statusctx.ui.setStatus("my-ext", undefined);Ejemplos: status-line.ts, plan-mode.ts, preset.ts
Patrón 4b: Personalización del indicador de trabajo
Sección titulada «Patrón 4b: Personalización del indicador de trabajo»Personaliza el indicador de trabajo en línea mostrado mientras pi transmite una respuesta.
// Static indicatorctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
// Custom animated indicatorctx.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 entirelyctx.ui.setWorkingIndicator({ frames: [] });
// Restore pi's default spinnerctx.ui.setWorkingIndicator();Esto solo afecta al indicador de trabajo de transmisión normal. Los cargadores de compactación y reintento mantienen su estilo integrado. Los fotogramas personalizados se renderizan literalmente, así que las extensiones deben añadir sus propios colores cuando haga falta.
Ejemplos: working-indicator.ts
Patrón 5: Widgets arriba/abajo del editor
Sección titulada «Patrón 5: Widgets arriba/abajo del editor»Muestra contenido persistente encima o debajo del editor de entrada. Bueno para listas de tareas, progreso.
// Simple string array (above editor by default)ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
// Render below the editorctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
// Or with themectx.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: () => {}, };});
// Clearctx.ui.setWidget("my-widget", undefined);Ejemplos: plan-mode.ts
Patrón 6: Pie de página personalizado
Sección titulada «Patrón 6: Pie de página personalizado»Reemplaza el pie de página. footerData expone datos 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 defaultLas estadísticas de tokens están disponibles mediante ctx.sessionManager.getBranch() y ctx.model.
Ejemplos: custom-footer.ts
Patrón 7: Editor personalizado (modo vim, etc.)
Sección titulada «Patrón 7: Editor personalizado (modo vim, etc.)»Reemplaza el editor de entrada principal con una implementación personalizada. Útil para edición modal (vim), atajos distintos (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 theme and keybindings from the app ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(theme, keybindings) ); });}Puntos clave:
- Extiende
CustomEditor(no elEditorbase) para obtener los atajos de la app (escape para abortar, ctrl+d para salir, cambio de modelo, etc.) - Llama a
super.handleInput(data)para teclas que no manejes - Patrón de fábrica:
setEditorComponentrecibe una función de fábrica que obtienetui,themeykeybindings - Pasa
undefinedpara restaurar el editor predeterminado:ctx.ui.setEditorComponent(undefined)
Ejemplos: modal-editor.ts
Reglas clave
Sección titulada «Reglas clave»-
Usa siempre theme del callback — No importes theme directamente. Usa
themedel callbackctx.ui.custom((tui, theme, keybindings, done) => ...). -
Tipa siempre el parámetro de color de DynamicBorder — Escribe
(s: string) => theme.fg("accent", s), no(s) => theme.fg("accent", s). -
Llama a tui.requestRender() tras cambios de estado — En
handleInput, llama atui.requestRender()después de actualizar el estado. -
Devuelve el objeto de tres métodos — Los componentes personalizados necesitan
{ render, invalidate, handleInput }. -
Usa componentes existentes —
SelectList,SettingsList,BorderedLoadercubren el 90% de los casos. No los reconstruyas.
Ejemplos
Sección titulada «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
- Interruptores de ajustes: examples/extensions/tools.ts — SettingsList para habilitar/deshabilitar herramientas
- Indicadores de estado: examples/extensions/plan-mode.ts — setStatus y setWidget
- Indicador de trabajo: examples/extensions/working-indicator.ts — setWorkingIndicator
- Pie personalizado: examples/extensions/custom-footer.ts — setFooter con estadísticas
- Editor personalizado: examples/extensions/modal-editor.ts — edición modal tipo Vim
- Juego Snake: examples/extensions/snake.ts — juego completo con entrada de teclado y bucle de juego
- Renderizado de herramienta personalizada: examples/extensions/todo.ts — renderCall y renderResult