TUI Компоненты
pi может создавать компоненты TUI. Попросите его создать его для вашего варианта использования.
Extensions и специальные инструменты могут отображать пользовательские компоненты TUI для интерактивных пользовательских интерфейсов. На этой странице описана система компонентов и доступные строительные блоки.
Источник: @earendil-works/pi-tui
Компонентный интерфейс
Все компоненты реализуют:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}| Метод | Описание |
|---|---|
render(width) |
Возвращает массив строк (по одной на строку). Каждая строка не должна превышать width. |
handleInput?(data) |
Получать ввод с клавиатуры, когда компонент находится в фокусе. |
wantsKeyRelease? |
Если это правда, компонент получает события выпуска ключа (протокол Kitty). По умолчанию: ложь. |
invalidate() |
Очистить кэшированное состояние рендеринга. Позвонил по поводу изменения темы. |
TUI добавляет полный сброс SGR и сброс OSC 8 в конце каждой отображаемой строки. Стили не переносятся через строки. Если вы создаете многострочный текст со стилями, повторно примените стили для каждой строки или используйте wrapTextWithAnsi(), чтобы стили сохранялись для каждой перенесенной строки.
Фокусируемый интерфейс (поддержка IME)
Компоненты, отображающие текстовый курсор и нуждающиеся в поддержке IME (редактор метода ввода), должны реализовывать интерфейс 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}`];
}
}Когда компонент Focusable имеет фокус, TUI:
- Устанавливает
focused = trueна компоненте. - Сканирует визуализированный вывод на наличие
CURSOR_MARKER(Escape-последовательность APC нулевой ширины). - Помещает курсор аппаратного терминала в это место.
- Показывает аппаратный курсор только тогда, когда
showHardwareCursorвключено.
По умолчанию курсор остается скрытым. Это сохраняет отрисовку поддельного курсора, но при этом позиционирует аппаратный курсор для терминалов, которые отслеживают окна-кандидаты IME со скрытыми курсорами. Некоторым терминалам требуется видимый аппаратный курсор для позиционирования IME; включите его с помощью showHardwareCursor, setShowHardwareCursor(true) или PI_HARDWARE_CURSOR=1. Встроенные компоненты Editor и Input уже реализуют этот интерфейс.
Компоненты контейнера со встроенными входными данными
Когда компонент контейнера (диалог, селектор и т. д.) содержит дочерний элемент Input или Editor, контейнер должен реализовать Focusable и передать состояние фокуса дочернему элементу. В противном случае аппаратный курсор не будет расположен правильно для ввода 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);
}
}Без этого распространения при вводе с использованием IME (китайского, японского, корейского и т. д.) окно-кандидат будет отображаться в неправильном положении на экране.
Использование компонентов
В расширениях через 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),
})
);
});В пользовательских инструментах через 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...
}Наложения
Наложения отображают компоненты поверх существующего контента, не очищая экран. Передайте от { overlay: true } до ctx.ui.custom():
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
{ overlay: true }
);Для позиционирования и размера используйте 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
},
}
);Наложение фокуса
Сфокусированное видимое наложение сохраняет право собственности на входные данные во временном пользовательском интерфейсе без наложения. Если наложение открывает другой компонент ctx.ui.custom() без { overlay: true }, этот замещающий пользовательский интерфейс получает входные данные, пока он активен; когда он закрывается, сфокусированное наложение может вернуть ввод.
Используйте handle.unfocus(), когда видимое наложение должно перестать владеть входными данными и позволить TUI вернуться к другому видимому наложению захвата или предыдущей цели фокуса. Используйте handle.unfocus({ target }), когда определенный компонент должен получать входные данные, в то время как наложение остается видимым. Умышленная передача { target: null } не оставляет сфокусированного компонента до тех пор, пока фокус не будет установлен снова.
Жизненный цикл наложения
Компоненты наложения удаляются при закрытии. Не используйте ссылки повторно — создавайте новые экземпляры:
// 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См. overlay-qa-tests.ts для подробных примеров, охватывающих привязки, поля, размещение, адаптивную видимость и анимацию.
Встроенные компоненты
Импорт из @earendil-works/pi-tui:
import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";Текст
Многострочный текст с переносом слов.
const text = new Text(
"Hello World", // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s) // optional background function
);
text.setText("Updated");Коробка
Контейнер с отступом и цветом фона.
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));Контейнер
Группирует дочерние компоненты по вертикали.
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);Проставка
Пустое вертикальное пространство.
const spacer = new Spacer(2); // 2 empty linesMarkdown
Отображает уценку с подсветкой синтаксиса.
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");Изображение
Отрисовывает изображения в поддерживаемых терминалах (Kitty, iTerm2, Ghostty, WezTerm, Warp).
const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);Ввод с клавиатуры
Используйте matchesKey() для обнаружения ключей:
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
}
}Идентификаторы ключей (используйте Key.* для автозаполнения или строковые литералы):
- Основные клавиши:
Key.enter,Key.escape,Key.tab,Key.space,Key.backspace,Key.delete,Key.home,Key.end - Клавиши со стрелками:
Key.up,Key.down,Key.left,Key.right - С модификаторами:
Key.ctrl("c"),Key.shift("tab"),Key.alt("left"),Key.ctrlShift("p") - Строковый формат также работает:
"enter","ctrl+c","shift+tab","ctrl+shift+p".
Ширина линии
Критический: Каждая строка от render() не должна превышать параметр width.
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}Утилиты:
visibleWidth(str)— Получить ширину дисплея (игнорирует коды ANSI)truncateToWidth(str, width, ellipsis?)– усечение с необязательным многоточием.wrapTextWithAnsi(str, width)— перенос слов с сохранением кодов ANSI.
Создание пользовательских компонентов
Пример: интерактивный селектор
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;
}
}Использование в расширении:
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");
}
}
});Тематика
Компоненты принимают объекты темы для стилизации.
В renderCall/renderResult используйте параметр 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"));
}Цвета переднего плана (theme.fg(color, text)):
| Категория | Цвета |
|---|---|
| Общий | text, accent, muted, dim |
| Статус | success, error, warning |
| Границы | border, borderAccent, borderMuted |
| Сообщения | userMessageText, customMessageText, customMessageLabel |
| Инструменты | toolTitle, toolOutput |
| Различия | toolDiffAdded, toolDiffRemoved, toolDiffContext |
| Markdown | mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet |
| Синтаксис | syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation |
| мышление | thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax |
| Режимы | bashMode |
Цвета фона (theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
Для Markdown используйте 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);
}Для пользовательских компонентов определите собственный интерфейс темы:
interface MyTheme {
selected: (s: string) => string;
normal: (s: string) => string;
}Ведение журнала отладки
Установите PI_TUI_WRITE_LOG, чтобы захватить необработанный поток ANSI, записанный в stdout.
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.tsПроизводительность
Кэшируйте отображаемый вывод, если это возможно:
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;
}
}Вызовите invalidate() при изменении состояния, затем используйте введенный tui.requestRender(), чтобы запустить повторный рендеринг.
Аннулирование и изменение темы
Когда тема меняется, TUI вызывает invalidate() для всех компонентов, чтобы очистить их кеши. Компоненты должны правильно реализовать invalidate(), чтобы изменения темы вступили в силу.
Проблема
Если компонент предварительно преобразует цвета темы в строки (через theme.fg(), theme.bg() и т. д.) и кэширует их, кэшированные строки содержат escape-коды ANSI из старой темы. Простой очистки кэша рендеринга недостаточно, если компонент хранит тематический контент отдельно.
Неправильный подход (цвета темы не обновляются):
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
}Решение
Компоненты, которые создают контент с использованием цветов темы, должны перестроить этот контент при вызове 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
}
}Шаблон: восстановление при признании недействительным
Для компонентов со сложным содержимым:
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();
}
}Когда это важно
Этот шаблон необходим, когда:
- Цвета темы предварительной обработки – использование
theme.fg()илиtheme.bg()для создания стилизованных строк, хранящихся в дочерних компонентах. - Подсветка синтаксиса – использование
highlightCode(), которое применяет цвета синтаксиса на основе темы. - Сложные макеты – создание деревьев дочерних компонентов со встроенными цветами темы.
Этот шаблон НЕ нужен, если:
- Использование обратных вызовов темы – передача функций типа
(text) => theme.fg("accent", text), которые вызываются во время рендеринга. - Простые контейнеры. Просто группируйте другие компоненты без добавления тематического контента.
- Рендеринг без сохранения состояния – вычисление новых тематических результатов при каждом вызове
render()(без кэширования).
Общие шаблоны
Эти шаблоны охватывают наиболее распространенные потребности пользовательского интерфейса в расширениях. Скопируйте эти шаблоны вместо того, чтобы создавать их с нуля.
Шаблон 1: Диалоговое окно выбора (SelectList)
Чтобы позволить пользователям выбирать из списка опций. Используйте SelectList от @earendil-works/pi-tui с DynamicBorder для кадрирования.
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");
}
},
});
Шаблон 2. Асинхронная операция с отменой (BorderedLoader)
Для операций, которые требуют времени и должны быть отменены. BorderedLoader показывает счетчик и обрабатывает escape для отмены.
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);
}
},
});Примеры: qna.ts, handoff.ts
Шаблон 3: Настройки/переключатели (SettingsList)
Для переключения нескольких настроек. Используйте SettingsList от @earendil-works/pi-tui с 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),
};
});
},
});Примеры: tools.ts
Схема 4: постоянный индикатор состояния
Показывать статус в нижнем колонтитуле, который сохраняется при рендеринге. Хорошо подходит для индикаторов режима.
// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
// Clear status
ctx.ui.setStatus("my-ext", undefined);Примеры: status-line.ts, plan-mode/index.ts, preset.ts
Схема 4б: Настройка рабочего индикатора
Настройте встроенный рабочий индикатор, отображаемый во время потоковой передачи ответа pi.
// 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();Это влияет только на индикатор нормальной работы потоковой передачи. Загрузчики сжатия и повтора сохраняют свой встроенный стиль. Пользовательские фреймы отображаются дословно, поэтому расширения должны добавлять свои собственные цвета при необходимости.
Примеры: working-indicator.ts
Схема 5: виджеты выше/ниже редактора
Показывать постоянный контент над или под редактором ввода. Хорошо подходит для списков дел и прогресса.
// 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);Примеры: plan-mode/index.ts
Шаблон 6: Пользовательский нижний колонтитул
Замените нижний колонтитул. footerData предоставляет данные, которые иначе не доступны расширениям.
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Статистика токенов доступна через ctx.sessionManager.getBranch() и ctx.model.
Примеры: custom-footer.ts
Шаблон 7: Пользовательский редактор (режим vim и т. д.)
Замените основной редактор ввода собственной реализацией. Полезно для модального редактирования (vim), различных сочетаний клавиш (emacs) или специальной обработки ввода.
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)
);
});
}Ключевые моменты:
- Расширьте
CustomEditor(не базовыйEditor), чтобы получить привязки клавиш приложения (Escape для прерывания, ctrl+d для выхода, переключение модели и т. д.). - Позвоните по номеру
super.handleInput(data), чтобы узнать ключи, которыми вы не пользуетесь. - Фабричный шаблон:
setEditorComponentполучает фабричную функцию, которая получаетtui,themeиkeybindings. - Введите
undefined, чтобы восстановить редактор по умолчанию:ctx.ui.setEditorComponent(undefined)
Примеры: modal-editor.ts
Ключевые правила
Всегда используйте тему из обратного вызова. Не импортируйте тему напрямую. Используйте
themeиз обратного вызоваctx.ui.custom((tui, theme, keybindings, done) =>...).Всегда вводите параметр цвета DynamicBorder — напишите
(s: string) => theme.fg("accent", s), а не(s) => theme.fg("accent", s).Вызов tui.requestRender() после изменения состояния — В
handleInputвызовитеtui.requestRender()после обновления состояния.Вернуть объект с тремя методами — Пользовательским компонентам требуется
{ render, invalidate, handleInput }.Использовать существующие компоненты —
SelectList,SettingsList,BorderedLoaderохватывают 90 % случаев. Не восстанавливайте их.
Примеры
- Интерфейс выбора: examples/extensions/preset.ts — SelectList с рамкой DynamicBorder
- Асинхронность с отменой: examples/extensions/qna.ts — BorderedLoader для вызовов LLM.
- Переключение настроек: examples/extensions/tools.ts — список настроек для включения/отключения инструмента.
- Индикаторы состояния: examples/extensions/plan-mode/index.ts — setStatus и setWidget.
- Индикатор работы: examples/extensions/working-indicator.ts - setWorkingIndicator
- Пользовательский нижний колонтитул: examples/extensions/custom-footer.ts — setFooter со статистикой
- Пользовательский редактор: examples/extensions/modal-editor.ts — модальное редактирование в стиле Vim.
- Игра «Змея»: examples/extensions/snake.ts — Полная версия игры с вводом с клавиатуры и игровым циклом.
- Рендеринг пользовательского инструмента: examples/extensions/todo.ts — renderCall и renderResult