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:
- Define
focused = trueno componente - Verifica a saída renderizada para
CURSOR_MARKER(uma sequência de escape APC de largura zero) - Posiciona o cursor do terminal de hardware nesse local
- Mostra o cursor de hardware apenas quando
showHardwareCursorestá 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 againConsulte 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 linesMarkdown
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 opcionaiswrapTextWithAnsi(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.tsDesempenho
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:
- Cores do tema pré-preparado - Usando
theme.fg()outheme.bg()para criar strings estilizadas armazenadas em componentes filhos - Destaque de sintaxe - Usando
highlightCode()que aplica cores de sintaxe baseadas em tema - Layouts complexos – Criação de árvores de componentes filhos que incorporam cores de tema
Este padrão NÃO é necessário quando:
- Usando callbacks de tema - Passando funções como
(text) => theme.fg("accent", text)que são chamadas durante a renderização - Contêineres simples - Apenas agrupando outros componentes sem adicionar conteúdo temático
- 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");
}
},
});
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 defaultEstatí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 baseEditor) 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:
setEditorComponentrecebe uma função de fábrica que obtémtui,themeekeybindings - Passe
undefinedpara restaurar o editor padrão:ctx.ui.setEditorComponent(undefined)
Exemplos: modal-editor.ts
Regras principais
Sempre use o tema do retorno de chamada - Não importe o tema diretamente. Use
themeno retorno de chamadactx.ui.custom((tui, theme, keybindings, done) =>...).Sempre digite parâmetro de cor DynamicBorder - Escreva
(s: string) => theme.fg("accent", s), não(s) => theme.fg("accent", s).Chame tui.requestRender() após mudanças de estado - Em
handleInput, chametui.requestRender()após atualizar o estado.Retorne o objeto de três métodos - Os componentes personalizados precisam de
{ render, invalidate, handleInput }.Use componentes existentes -
SelectList,SettingsList,BorderedLoadercobrem 90% dos casos. Não os reconstrua.
Exemplos
- IU de seleção: examples/extensions/preset.ts - SelectList com enquadramento DynamicBorder
- Assíncrono com cancelamento: examples/extensions/qna.ts - BorderedLoader para chamadas LLM
- Alterações de configurações: examples/extensions/tools.ts - Lista de configurações para ativar/desativar ferramentas
- Indicadores de status: examples/extensions/plan-mode/index.ts - setStatus e setWidget
- Indicador de funcionamento: examples/extensions/working-indicator.ts - setWorkingIndicator
- Rodapé personalizado: examples/extensions/custom-footer.ts - setFooter com estatísticas
- Editor personalizado: examples/extensions/modal-editor.ts - Edição modal semelhante ao Vim
- Jogo Snake: examples/extensions/snake.ts - Jogo completo com entrada de teclado, loop de jogo
- Renderização de ferramenta personalizada: examples/extensions/todo.ts - renderCall e renderResult