Saltearse al contenido

Componentes TUI

pi puede crear componentes TUI. Pídele que construya uno para tu caso de uso.

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

Todos los componentes implementan:

interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}
MétodoDescripció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.

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:

  1. Establece focused = true en el componente
  2. Busca CURSOR_MARKER (secuencia de escape APC de ancho cero) en la salida renderizada
  3. Posiciona el cursor hardware del terminal en esa ubicación
  4. Muestra el cursor hardware solo cuando showHardwareCursor está 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.

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();
}

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
},
}
);

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.

Los componentes de superposición se eliminan al cerrarse. No reutilices referencias: crea 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 again

Consulta overlay-qa-tests.ts para ejemplos completos de anclas, márgenes, apilamiento, visibilidad responsive y animación.

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));

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 lines

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 }
);

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"

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 opcional
  • wrapTextWithAnsi(str, width) — Ajusta de línea preservando códigos ANSI

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íaColores
Generaltext, accent, muted, dim
Estadosuccess, error, warning
Bordesborder, borderAccent, borderMuted
MensajesuserMessageText, customMessageText, customMessageLabel
HerramientastoolTitle, toolOutput
DiffstoolDiffAdded, toolDiffRemoved, toolDiffContext
MarkdownmdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
SintaxissyntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
PensamientothinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh
ModosbashMode

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;
}

Establece PI_TUI_WRITE_LOG para capturar el flujo ANSI crudo escrito en stdout.

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

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.

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.

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
}

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
}
}

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();
}
}

Este patrón es necesario cuando:

  1. Pre-incrustas colores del tema — Usas theme.fg() o theme.bg() para crear cadenas estilizadas almacenadas en componentes hijos
  2. Resaltado de sintaxis — Usas highlightCode() que aplica colores de sintaxis basados en el tema
  3. Diseños complejos — Construyes árboles de componentes hijos que incrustan colores del tema

Este patrón NO es necesario cuando:

  1. Usas callbacks de tema — Pasas funciones como (text) => theme.fg("accent", text) que se invocan durante el renderizado
  2. Contenedores simples — Solo agrupas otros componentes sin añadir contenido temático
  3. Renderizado sin estado — Calculas la salida temática de nuevo en cada llamada a render() (sin caché)

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");
}
},
});

Ejemplos: preset.ts, tools.ts

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

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 status
ctx.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 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 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

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 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.ts

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 default

Las 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 el Editor base) 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: setEditorComponent recibe una función de fábrica que obtiene tui, theme y keybindings
  • Pasa undefined para restaurar el editor predeterminado: ctx.ui.setEditorComponent(undefined)

Ejemplos: modal-editor.ts

  1. Usa siempre theme del callback — No importes theme directamente. Usa theme del callback ctx.ui.custom((tui, theme, keybindings, done) => ...).

  2. Tipa siempre el parámetro de color de DynamicBorder — Escribe (s: string) => theme.fg("accent", s), no (s) => theme.fg("accent", s).

  3. Llama a tui.requestRender() tras cambios de estado — En handleInput, llama a tui.requestRender() después de actualizar el estado.

  4. Devuelve el objeto de tres métodos — Los componentes personalizados necesitan { render, invalidate, handleInput }.

  5. Usa componentes existentesSelectList, SettingsList, BorderedLoader cubren el 90% de los casos. No los reconstruyas.