TUI Komponen
pi dapat membuat komponen TUI. Mintalah untuk membuat satu untuk kasus penggunaan Anda.
Extensions dan alat khusus dapat merender komponen TUI khusus untuk antarmuka pengguna interaktif. Halaman ini mencakup sistem komponen dan blok penyusun yang tersedia.
Sumber: @earendil-works/pi-tui
Antarmuka Komponen
Semua komponen menerapkan:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}| Metode | Keterangan |
|---|---|
render(width) |
Kembalikan array string (satu per baris). Setiap baris tidak boleh melebihi width. |
handleInput?(data) |
Menerima input keyboard saat komponen memiliki fokus. |
wantsKeyRelease? |
Jika benar, komponen menerima peristiwa rilis penting (protokol Kitty). Bawaan: salah. |
invalidate() |
Hapus status render yang di-cache. Dipanggil pada perubahan tema. |
TUI menambahkan reset SGR penuh dan reset OSC 8 di akhir setiap baris yang dirender. Gaya tidak bersifat lintas batas. Jika Anda memancarkan teks multi-baris dengan gaya, terapkan kembali gaya per baris atau gunakan wrapTextWithAnsi() sehingga gaya dipertahankan untuk setiap baris yang dibungkus.
Antarmuka yang Dapat Difokuskan (Dukungan IME)
Komponen yang menampilkan kursor teks dan memerlukan dukungan IME (Input Method Editor) harus mengimplementasikan antarmuka 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}`];
}
}Ketika komponen Focusable memiliki fokus, TUI:
- Menyetel
focused = truepada komponen - Memindai keluaran yang diberikan untuk
CURSOR_MARKER(urutan escape APC dengan lebar nol) - Memposisikan kursor terminal perangkat keras di lokasi itu
- Menampilkan kursor perangkat keras hanya ketika
showHardwareCursordiaktifkan
Kursor tetap tersembunyi secara default. Hal ini menjaga rendering kursor palsu, sambil tetap memposisikan kursor perangkat keras untuk terminal yang melacak jendela kandidat IME dengan kursor tersembunyi. Beberapa terminal memerlukan kursor perangkat keras yang terlihat untuk penentuan posisi IME; aktifkan dengan showHardwareCursor, setShowHardwareCursor(true), atau PI_HARDWARE_CURSOR=1. Komponen bawaan Editor dan Input sudah mengimplementasikan antarmuka ini.
Komponen Kontainer dengan Input Tersemat
Ketika komponen container (dialog, selector, dll.) berisi turunan Input atau Editor, container harus mengimplementasikan Focusable dan menyebarkan status fokus ke turunan tersebut. Jika tidak, kursor perangkat keras tidak akan ditempatkan dengan benar untuk input 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);
}
}Tanpa propagasi ini, mengetik dengan IME (China, Jepang, Korea, dll.) akan menampilkan jendela kandidat pada posisi yang salah di layar.
Menggunakan Komponen
Dalam ekstensi melalui 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),
})
);
});Dalam alat khusus melalui 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...
}Hamparan
Overlay merender komponen di atas konten yang ada tanpa membersihkan layar. Teruskan { overlay: true } ke ctx.ui.custom():
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
{ overlay: true }
);Untuk penentuan posisi dan ukuran, gunakan 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
},
}
);Fokus Hamparan
Hamparan terlihat terfokus mempertahankan kepemilikan masukan di seluruh UI non-hamparan sementara. Jika overlay membuka komponen ctx.ui.custom() lain tanpa { overlay: true }, UI pengganti tersebut menerima input saat sedang aktif; ketika ditutup, hamparan terfokus dapat memperoleh kembali masukan.
Gunakan handle.unfocus() ketika overlay yang terlihat tidak lagi memiliki masukan dan biarkan TUI kembali ke overlay pengambilan lain yang terlihat atau target fokus sebelumnya. Gunakan handle.unfocus({ target }) ketika komponen tertentu harus menerima masukan sementara overlay tetap terlihat. Melewati { target: null } dengan sengaja tidak meninggalkan komponen fokus hingga fokus diatur kembali.
Siklus Hidup Hamparan
Komponen overlay dibuang saat ditutup. Jangan gunakan kembali referensi - buatlah instance baru:
// 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 againLihat overlay-qa-tests.ts untuk contoh komprehensif yang mencakup jangkar, margin, penumpukan, visibilitas responsif, dan animasi.
Komponen Bawaan
Impor dari @earendil-works/pi-tui:
import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";Teks
Teks multi-baris dengan pembungkusan kata.
const text = new Text(
"Hello World", // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s) // optional background function
);
text.setText("Updated");Kotak
Wadah dengan padding dan warna latar belakang.
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));Wadah
Mengelompokkan komponen anak secara vertikal.
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);pengatur jarak
Ruang vertikal kosong.
const spacer = new Spacer(2); // 2 empty linesMarkdown
Merender penurunan harga dengan penyorotan sintaksis.
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");Gambar
Merender gambar di terminal yang didukung (Kitty, iTerm2, Ghostty, WezTerm, Warp).
const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);Masukan Papan Ketik
Gunakan matchesKey() untuk deteksi kunci:
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
}
}Pengidentifikasi kunci (gunakan Key.* untuk pelengkapan otomatis, atau literal string):
- Kunci dasar:
Key.enter,Key.escape,Key.tab,Key.space,Key.backspace,Key.delete,Key.home,Key.end - Tombol panah:
Key.up,Key.down,Key.left,Key.right - Dengan pengubah:
Key.ctrl("c"),Key.shift("tab"),Key.alt("left"),Key.ctrlShift("p") - Format string juga berfungsi:
"enter","ctrl+c","shift+tab","ctrl+shift+p"
Lebar Garis
Kritis: Setiap baris dari render() tidak boleh melebihi parameter width.
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}Utilitas:
visibleWidth(str)- Dapatkan lebar tampilan (abaikan kode ANSI)truncateToWidth(str, width, ellipsis?)- Potong dengan elipsis opsionalwrapTextWithAnsi(str, width)- Bungkus kata yang menyimpan kode ANSI
Membuat Komponen Khusus
Contoh: Pemilih interaktif
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;
}
}Penggunaan dalam ekstensi:
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
Komponen menerima objek tema untuk penataan gaya.
Di renderCall/renderResult, gunakan parameter 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"));
}Warna latar depan (theme.fg(color, text)):
| Kategori | Warna |
|---|---|
| Umum | text, accent, muted, dim |
| Status | success, error, warning |
| Perbatasan | border, borderAccent, borderMuted |
| Pesan | userMessageText, customMessageText, customMessageLabel |
| Peralatan | toolTitle, toolOutput |
| Perbedaan | toolDiffAdded, toolDiffRemoved, toolDiffContext |
| Markdown | mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet |
| Sintaksis | syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation |
| Pemikiran | thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax |
| Mode | bashMode |
Warna latar belakang (theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
Untuk Markdown, gunakan 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);
}Untuk komponen khusus, tentukan antarmuka tema Anda sendiri:
interface MyTheme {
selected: (s: string) => string;
normal: (s: string) => string;
}Pencatatan debug
Setel PI_TUI_WRITE_LOG untuk menangkap aliran ANSI mentah yang ditulis ke stdout.
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.tsPertunjukan
Output yang di-cache jika memungkinkan:
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;
}
}Panggil invalidate() ketika status berubah, lalu gunakan tui.requestRender() yang disuntikkan untuk memicu rendering ulang.
Pembatalan dan Perubahan Tema
Saat tema berubah, TUI memanggil invalidate() pada semua komponen untuk menghapus cache-nya. Komponen harus menerapkan invalidate() dengan benar untuk memastikan perubahan tema diterapkan.
Masalahnya
Jika komponen membuat warna tema menjadi string terlebih dahulu (melalui theme.fg(), theme.bg(), dll.) dan menyimpannya dalam cache, string yang di-cache berisi kode escape ANSI dari tema lama. Menghapus cache render saja tidak cukup jika komponen menyimpan konten bertema secara terpisah.
Pendekatan yang salah (warna tema tidak akan diperbarui):
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
}Solusinya
Komponen yang membuat konten dengan warna tema harus membangun kembali konten tersebut ketika invalidate() dipanggil:
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
}
}Pola: Membangun Kembali saat Tidak Valid
Untuk komponen dengan konten kompleks:
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();
}
}Kapan Ini Penting
Pola ini diperlukan ketika:
- Warna tema sebelum dipanggang - Menggunakan
theme.fg()atautheme.bg()untuk membuat string bergaya yang disimpan di komponen anak - Penyorotan sintaksis - Menggunakan
highlightCode()yang menerapkan warna sintaksis berbasis tema - Tata letak kompleks - Membangun pohon komponen anak yang menyematkan warna tema
Pola ini TIDAK diperlukan ketika:
- Menggunakan panggilan balik tema - Meneruskan fungsi seperti
(text) => theme.fg("accent", text)yang dipanggil selama render - Wadah sederhana - Cukup mengelompokkan komponen lain tanpa menambahkan konten bertema
- Render tanpa status - Menghitung keluaran bertema baru di setiap panggilan
render()(tanpa caching)
Pola Umum
Pola-pola ini mencakup kebutuhan UI yang paling umum dalam ekstensi. Salin pola ini daripada membuat dari awal.
Pola 1: Dialog Seleksi (SelectList)
Untuk membiarkan pengguna memilih dari daftar opsi. Gunakan SelectList dari @earendil-works/pi-tui dengan DynamicBorder untuk pembingkaian.
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");
}
},
});
Pola 2: Operasi Asinkron dengan Pembatalan (BorderedLoader)
Untuk operasi yang memakan waktu dan harus dibatalkan. BorderedLoader menunjukkan pemintal dan pegangan escape untuk membatalkan.
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);
}
},
});Contoh: qna.ts, handoff.ts
Pola 3: Pengaturan/Toggles (Daftar Pengaturan)
Untuk mengubah beberapa pengaturan. Gunakan SettingsList dari @earendil-works/pi-tui dengan 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),
};
});
},
});Contoh: tools.ts
Pola 4: Indikator Status Persisten
Tampilkan status di footer yang tetap ada di seluruh render. Cocok untuk indikator mode.
// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
// Clear status
ctx.ui.setStatus("my-ext", undefined);Contoh: status-line.ts, plan-mode/index.ts, preset.ts
Pola 4b: Kustomisasi Indikator Kerja
Sesuaikan indikator kerja sebaris yang ditampilkan saat pi mengalirkan respons.
// 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();Ini hanya mempengaruhi indikator kerja streaming normal. Loader pemadatan dan percobaan ulang mempertahankan gaya bawaannya. Bingkai khusus ditampilkan kata demi kata, jadi ekstensi harus menambahkan warnanya sendiri bila diperlukan.
Contoh: working-indicator.ts
Pola 5: Widget Di Atas/Di Bawah Editor
Tampilkan konten persisten di atas atau di bawah editor masukan. Bagus untuk daftar tugas, kemajuan.
// 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);Contoh: plan-mode/index.ts
Pola 6: Footer Kustom
Ganti footernya. footerData memaparkan data yang tidak dapat diakses oleh ekstensi.
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 defaultStatistik token tersedia melalui ctx.sessionManager.getBranch() dan ctx.model.
Contoh: custom-footer.ts
Pola 7: Editor Kustom (mode vim, dll.)
Ganti editor masukan utama dengan implementasi khusus. Berguna untuk pengeditan modal (vim), pengikatan tombol yang berbeda (emacs), atau penanganan masukan khusus.
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)
);
});
}Poin-poin penting:
- Perluas
CustomEditor(bukan basisEditor) untuk mendapatkan pengikatan kunci aplikasi (escape untuk membatalkan, ctrl+d untuk keluar, peralihan model, dll.) - Hubungi
super.handleInput(data)untuk kunci yang tidak Anda tangani - Pola pabrik:
setEditorComponentmenerima fungsi pabrik yang mendapatkantui,theme, dankeybindings - Lewati
undefineduntuk memulihkan editor default:ctx.ui.setEditorComponent(undefined)
Contoh: modal-editor.ts
Aturan Utama
Selalu gunakan tema dari panggilan balik - Jangan mengimpor tema secara langsung. Gunakan
themedari panggilan balikctx.ui.custom((tui, theme, keybindings, done) =>...).Selalu ketik parameter warna DynamicBorder - Tulis
(s: string) => theme.fg("accent", s), bukan(s) => theme.fg("accent", s).Panggil tui.requestRender() setelah status berubah - Di
handleInput, hubungitui.requestRender()setelah memperbarui status.Kembalikan objek tiga metode - Komponen khusus memerlukan
{ render, invalidate, handleInput }.Gunakan komponen yang ada -
SelectList,SettingsList,BorderedLoadermencakup 90% kasus. Jangan membangunnya kembali.
Contoh
- Seleksi UI: examples/extensions/preset.ts - SelectList dengan framing DynamicBorder
- Async dengan pembatalan: examples/extensions/qna.ts - BorderedLoader untuk panggilan LLM
- Pengaturan beralih: examples/extensions/tools.ts - Daftar Pengaturan untuk mengaktifkan/menonaktifkan alat
- Indikator status: examples/extensions/plan-mode/index.ts - setStatus dan setWidget
- Indikator kerja: examples/extensions/working-indicator.ts - setIndikator Kerja
- Footer khusus: examples/extensions/custom-footer.ts - setFooter dengan statistik
- Editor khusus: examples/extensions/modal-editor.ts - Pengeditan modal seperti Vim
- Permainan ular: examples/extensions/snake.ts - Game lengkap dengan input keyboard, game loop
- Render alat khusus: examples/extensions/todo.ts - renderCall dan renderResult