Configuração, personalização, ajustes de plataforma e referências de API para Pi.

TUI Componentes

pi pode criar componentes TUI. Peça para criar um para o seu caso de uso.

Extensions e ferramentas personalizadas podem renderizar componentes TUI personalizados para interfaces de usuário interativas. Esta página aborda o sistema de componentes e os blocos de construção disponíveis.

Fonte: @earendil-works/pi-tui

Interface de componentes

Todos os componentes implementam:

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  wantsKeyRelease?: boolean;
  invalidate(): void;
}
Método Descrição
render(width) Retorna uma matriz de strings (uma por linha). Cada linha não deve exceder width.
handleInput?(data) Receba entrada do teclado quando o componente estiver em foco.
wantsKeyRelease? Se verdadeiro, o componente recebe eventos de liberação de chave (protocolo Kitty). Padrão: falso.
invalidate() Limpe o estado de renderização em cache. Solicitado mudanças de tema.

O TUI anexa uma redefinição completa do SGR e uma redefinição do OSC 8 no final de cada linha renderizada. Os estilos não atravessam as linhas. Se você emitir texto multilinha com estilo, reaplique estilos por linha ou use wrapTextWithAnsi() para que os estilos sejam preservados para cada linha quebrada.

Interface Focável (Suporte IME)

Componentes que exibem um cursor de texto e precisam de suporte IME (Input Method Editor) devem implementar a interface Focusable:

import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui";

class MyInput implements Component, Focusable {
  focused: boolean = false;  // Set by TUI when focus changes
  
  render(width: number): string[] {
    const marker = this.focused ? CURSOR_MARKER : "";
    // Emit marker right before the fake cursor
    return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
  }
}

Quando um componente Focusable está em foco, TUI:

  1. Define focused = true no componente
  2. Verifica a saída renderizada para CURSOR_MARKER (uma sequência de escape APC de largura zero)
  3. Posiciona o cursor do terminal de hardware nesse local
  4. Mostra o cursor de hardware apenas quando showHardwareCursor está habilitado

O cursor permanece oculto por padrão. Isso mantém a renderização falsa do cursor, enquanto ainda posiciona o cursor de hardware para terminais que rastreiam janelas candidatas a IME com cursores ocultos. Alguns terminais requerem um cursor de hardware visível para posicionamento do IME; habilite-o com showHardwareCursor, setShowHardwareCursor(true) ou PI_HARDWARE_CURSOR=1. Os componentes integrados Editor e Input já implementam esta interface.

Componentes de contêiner com entradas incorporadas

Quando um componente contêiner (diálogo, seletor, etc.) contém um filho Input ou Editor, o contêiner deve implementar Focusable e propagar o estado de foco para o filho. Caso contrário, o cursor de hardware não será posicionado corretamente para 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);
  }
}

Sem esta propagação, digitar com um IME (chinês, japonês, coreano, etc.) mostrará a janela candidata na posição errada na tela.

Usando componentes

Em extensões via ctx.ui.custom():

pi.on("session_start", async (_event, ctx) => {
  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
    new MyComponent({
      theme,
      keybindings,
      onChange: () => tui.requestRender(),
      onSelect: (value) => done(value),
      onCancel: () => done(null),
    })
  );
});

Em ferramentas personalizadas via ctx.ui.custom():

async execute(toolCallId, params, signal, onUpdate, ctx) {
  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
    new MyComponent({
      theme,
      keybindings,
      onChange: () => tui.requestRender(),
      onSelect: (value) => done(value),
      onCancel: () => done(null),
    })
  );
  // Use result...
}

Sobreposições

As sobreposições renderizam componentes sobre o conteúdo existente sem limpar a tela. Passe { overlay: true } para ctx.ui.custom():

const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
  { overlay: true }
);

Para posicionamento e dimensionamento, 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
    },
  }
);

Foco de sobreposição

Uma sobreposição visível focada mantém a propriedade de entrada em uma interface de usuário temporária sem sobreposição. Se uma sobreposição abrir outro componente ctx.ui.custom() sem { overlay: true }, essa UI substituta receberá entrada enquanto estiver ativa; quando fecha, a sobreposição focada pode recuperar a entrada.

Use handle.unfocus() quando uma sobreposição visível deixar de possuir a entrada e deixar TUI voltar para outra sobreposição de captura visível ou para o alvo de foco anterior. Use handle.unfocus({ target }) quando um componente específico deve receber entrada enquanto a sobreposição permanece visível. Passar { target: null } intencionalmente não deixa nenhum componente em foco até que o foco seja definido novamente.

Ciclo de vida da sobreposição

Os componentes de sobreposição são descartados quando fechados. Não reutilize referências – crie novas instâncias:

// 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 exemplos abrangentes que abrangem âncoras, margens, empilhamento, visibilidade responsiva e animação.

Componentes integrados

Importar de @earendil-works/pi-tui:

import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";

Texto

Texto multilinha com quebra automática de linha.

const text = new Text(
  "Hello World",    // content
  1,                // paddingX (default: 1)
  1,                // paddingY (default: 1)
  (s) => bgGray(s)  // optional background function
);
text.setText("Updated");

Caixa

Container com preenchimento e cor de fundo.

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 componentes filhos verticalmente.

const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);

Espaçador

Espaço vertical vazio.

const spacer = new Spacer(2);  // 2 empty lines

Markdown

Renderiza markdown com destaque de sintaxe.

const md = new Markdown(
  "# Title\n\nSome **bold** text",
  1,        // paddingX
  1,        // paddingY
  theme     // MarkdownTheme (see below)
);
md.setText("Updated markdown");

Imagem

Renderiza imagens em terminais suportados (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

Use matchesKey() para detecção de chave:

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 chave (use Key.* para preenchimento automático ou literais de string):

  • Teclas básicas: Key.enter, Key.escape, Key.tab, Key.space, Key.backspace, Key.delete, Key.home, Key.end
  • Teclas de seta: Key.up, Key.down, Key.left, Key.right
  • Com modificadores: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
  • O formato de string também funciona: "enter", "ctrl+c", "shift+tab", "ctrl+shift+p"

Largura da linha

Crítico: Cada linha de render() não deve exceder o parâmetro width.

import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";

render(width: number): string[] {
  // Truncate long lines
  return [truncateToWidth(this.text, width)];
}

Utilitários:

  • visibleWidth(str) - Obtenha largura de exibição (ignora códigos ANSI)
  • truncateToWidth(str, width, ellipsis?) - Truncar com reticências opcionais
  • wrapTextWithAnsi(str, width) - Quebra de linha preservando códigos ANSI

Criando componentes personalizados

Exemplo: seletor interativo

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 em uma extensão:

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

Tema

Os componentes aceitam objetos de tema para estilização.

Em renderCall/renderResult, use o 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"));
}

Cores de primeiro plano (theme.fg(color, text)):

Categoria Cores
Em geral text, accent, muted, dim
Status success, error, warning
Fronteiras border, borderAccent, borderMuted
Mensagens userMessageText, customMessageText, customMessageLabel
Ferramentas toolTitle, toolOutput
Diferenças toolDiffAdded, toolDiffRemoved, toolDiffContext
Markdown mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
Sintaxe syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
Pensamento thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax
Modos bashMode

Cores de fundo (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 sua própria interface de tema:

interface MyTheme {
  selected: (s: string) => string;
  normal: (s: string) => string;
}

Registro de depuração

Defina PI_TUI_WRITE_LOG para capturar o fluxo ANSI bruto gravado em stdout.

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

Desempenho

Armazene em cache a saída renderizada quando possível:

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

Chame invalidate() quando o estado mudar e use o tui.requestRender() injetado para acionar a nova renderização.

Invalidação e alterações de tema

Quando o tema muda, TUI chama invalidate() em todos os componentes para limpar seus caches. Os componentes devem implementar invalidate() adequadamente para garantir que as alterações do tema entrem em vigor.

O problema

Se um componente pré-incorpora as cores do tema em strings (via theme.fg(), theme.bg(), etc.) e as armazena em cache, as strings armazenadas em cache contêm códigos de escape ANSI do tema antigo. Simplesmente limpar o cache de renderização não é suficiente se o componente armazena o conteúdo temático separadamente.

Abordagem errada (as cores do tema não serão atualizadas):

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
}

A solução

Os componentes que criam conteúdo com cores de tema devem reconstruir esse conteúdo quando invalidate() for chamado:

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

Padrão: reconstruir ao invalidar

Para componentes com conteúdo complexo:

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

Quando isso importa

Este padrão é necessário quando:

  1. Cores do tema pré-preparado - Usando theme.fg() ou theme.bg() para criar strings estilizadas armazenadas em componentes filhos
  2. Destaque de sintaxe - Usando highlightCode() que aplica cores de sintaxe baseadas em tema
  3. Layouts complexos – Criação de árvores de componentes filhos que incorporam cores de tema

Este padrão NÃO é necessário quando:

  1. Usando callbacks de tema - Passando funções como (text) => theme.fg("accent", text) que são chamadas durante a renderização
  2. Contêineres simples - Apenas agrupando outros componentes sem adicionar conteúdo temático
  3. Renderização sem estado - Computando saída temática atualizada em cada chamada render() (sem cache)

Padrões Comuns

Esses padrões cobrem as necessidades de UI mais comuns em extensões. Copie esses padrões em vez de criar do zero.

Padrão 1: Caixa de diálogo de seleção (SelectList)

Para permitir que os usuários escolham em uma lista de opções. Use SelectList de @earendil-works/pi-tui com DynamicBorder para enquadramento.

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

Exemplos: preset.ts, tools.ts

Padrão 2: Operação Assíncrona com Cancel (BorderedLoader)

Para operações que demoram e devem ser canceláveis. BorderedLoader mostra um botão giratório e controla o 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);
    }
  },
});

Exemplos: qna.ts, handoff.ts

Padrão 3: Configurações/Alterações (SettingsList)

Para alternar várias configurações. Use SettingsList de @earendil-works/pi-tui com 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),
      };
    });
  },
});

Exemplos: tools.ts

Padrão 4: Indicador de Status Persistente

Mostra o status no rodapé que persiste nas renderizações. Bom 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);

Exemplos: status-line.ts, plan-mode/index.ts, preset.ts

Padrão 4b: Personalização do Indicador de Trabalho

Personalize o indicador de trabalho embutido mostrado enquanto pi está transmitindo uma resposta.

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

Isso afeta apenas o indicador normal de funcionamento do streaming. Os carregadores de compactação e nova tentativa mantêm seu estilo integrado. Os quadros personalizados são renderizados literalmente, portanto as extensões devem adicionar suas próprias cores quando necessário.

Exemplos: working-indicator.ts

Padrão 5: Widgets acima/abaixo do editor

Mostre conteúdo persistente acima ou abaixo do editor de entrada. Bom para listas de tarefas, progresso.

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

Exemplos: plan-mode/index.ts

Padrão 6: rodapé personalizado

Substitua o rodapé. footerData expõe dados que de outra forma não seriam acessíveis às extensões.

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

Estatísticas de token disponíveis via ctx.sessionManager.getBranch() e ctx.model.

Exemplos: custom-footer.ts

Padrão 7: Editor Personalizado (modo vim, etc.)

Substitua o editor de entrada principal por uma implementação customizada. Útil para edição modal (vim), diferentes combinações de teclas (emacs) ou manipulação de entrada especializada.

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

Pontos principais:

  • Estenda CustomEditor (não base Editor) para obter atalhos de teclado do aplicativo (escape para abortar, ctrl+d para sair, troca de modelo, etc.)
  • Ligue para super.handleInput(data) para chaves que você não manuseia
  • Padrão de fábrica: setEditorComponent recebe uma função de fábrica que obtém tui, theme e keybindings
  • Passe undefined para restaurar o editor padrão: ctx.ui.setEditorComponent(undefined)

Exemplos: modal-editor.ts

Regras principais

  1. Sempre use o tema do retorno de chamada - Não importe o tema diretamente. Use theme no retorno de chamada ctx.ui.custom((tui, theme, keybindings, done) =>...).

  2. Sempre digite parâmetro de cor DynamicBorder - Escreva (s: string) => theme.fg("accent", s), não (s) => theme.fg("accent", s).

  3. Chame tui.requestRender() após mudanças de estado - Em handleInput, chame tui.requestRender() após atualizar o estado.

  4. Retorne o objeto de três métodos - Os componentes personalizados precisam de { render, invalidate, handleInput }.

  5. Use componentes existentes - SelectList, SettingsList, BorderedLoader cobrem 90% dos casos. Não os reconstrua.

Exemplos