Configuración, personalización, ajustes de plataforma y referencias de API para Pi.

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:

  1. Establece focused = true en el componente
  2. Los escaneos generaron resultados para CURSOR_MARKER (una secuencia de escape APC de ancho cero)
  3. Coloca el cursor del terminal de hardware en esa ubicación
  4. Muestra el cursor de hardware solo cuando showHardwareCursor está 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 again

Consulte 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 lines

Markdown

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 opcionales
  • wrapTextWithAnsi(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.ts

Actuació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:

  1. Colores del tema previo al horneado: uso de theme.fg() o theme.bg() para crear cadenas con estilo almacenadas en componentes secundarios
  2. Resaltado de sintaxis: uso de highlightCode(), que aplica colores de sintaxis basados ​​en temas
  3. Diseños complejos - Creación de árboles de componentes secundarios que incorporan colores de temas

Este patrón NO es necesario cuando:

  1. Usar devoluciones de llamadas de temas - Pasar funciones como (text) => theme.fg("accent", text) que se llaman durante el renderizado
  2. Contenedores simples: simplemente agrupa otros componentes sin agregar contenido temático
  3. 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");
    }
  },
});

Ejemplos: preset.ts, tools.ts

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 default

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

Ejemplos: modal-editor.ts

Reglas clave

  1. Utilice siempre el tema de la devolución de llamada - No importe el tema directamente. Utilice theme de la devolución de llamada ctx.ui.custom((tui, theme, keybindings, done) =>...).

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

  3. Llame a tui.requestRender() después de cambios de estado - En handleInput, llame a tui.requestRender() después de actualizar el estado.

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

  5. Utilice componentes existentes: SelectList, SettingsList, BorderedLoader cubren el 90 % de los casos. No los reconstruyas.

Ejemplos