TUI Bileşenler
pi TUI bileşen oluşturabilir. Kullanım durumunuz için bir tane oluşturmasını isteyin.
Extensions ve özel araçlar, etkileşimli kullanıcı arayüzleri için özel TUI bileşenleri oluşturabilir. Bu sayfa bileşen sistemini ve mevcut yapı taşlarını kapsar.
Kaynak: @earendil-works/pi-tui
Bileşen Arayüzü
Tüm bileşenler şunları uygular:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}| Yöntem | Tanım |
|---|---|
render(width) |
Dize dizisini döndürür (satır başına bir tane). Her satır width değerini aşmamalıdır. |
handleInput?(data) |
Bileşen odaklandığında klavye girişini alın. |
wantsKeyRelease? |
Doğruysa, bileşen önemli sürüm olaylarını alır (Kitty protokolü). Varsayılan: yanlış. |
invalidate() |
Önbelleğe alınmış oluşturma durumunu temizleyin. Tema değişiklikleri çağrısında bulunuldu. |
TUI, oluşturulan her satırın sonuna tam bir SGR sıfırlaması ve OSC 8 sıfırlaması ekler. Stiller çizgiler boyunca taşınmaz. Stil içeren çok satırlı metin yayınlıyorsanız, stilleri her satıra yeniden uygulayın veya wrapTextWithAnsi() kullanın, böylece stiller her sarılmış satır için korunur.
Odaklanabilir Arayüz (IME Desteği)
Metin imleci görüntüleyen ve IME (Giriş Yöntemi Düzenleyicisi) desteğine ihtiyaç duyan bileşenler Focusable arayüzünü uygulamalıdır:
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}`];
}
}Bir Focusable bileşeni odağa sahip olduğunda, TUI:
- Bileşende
focused = truedeğerini ayarlar - Oluşturulan çıktıyı
CURSOR_MARKER(sıfır genişlikli bir APC kaçış dizisi) için tarar - Donanım terminali imlecini bu konuma konumlandırır
- Donanım imlecini yalnızca
showHardwareCursoretkinleştirildiğinde gösterir
İmleç varsayılan olarak gizli kalır. Bu, donanım imlecini gizli imleçlerle IME aday pencerelerini izleyen terminaller için konumlandırmaya devam ederken, sahte imleç oluşturmayı korur. Bazı terminaller, IME konumlandırması için görünür bir donanım imleci gerektirir; showHardwareCursor, setShowHardwareCursor(true) veya PI_HARDWARE_CURSOR=1 ile etkinleştirin. Editor ve Input yerleşik bileşenleri bu arayüzü zaten uygulamaktadır.
Yerleşik Girişlere Sahip Konteyner Bileşenleri
Bir kapsayıcı bileşeni (iletişim kutusu, seçici vb.) bir Input veya Editor alt öğesi içerdiğinde, kapsayıcının Focusable uygulamasını yapması ve odak durumunu alt öğeye yayması gerekir. Aksi takdirde donanım imleci IME girişi için doğru şekilde konumlandırılmayacaktır.
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);
}
}Bu yayılma olmadan, IME (Çince, Japonca, Korece vb.) ile yazmak aday pencereyi ekranda yanlış konumda gösterecektir.
Bileşenleri Kullanma
Uzantılarda ctx.ui.custom() aracılığıyla:
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),
})
);
});Özel araçlarda ctx.ui.custom() aracılığıyla:
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...
}Kaplamalar
Kaplamalar, ekranı temizlemeden bileşenleri mevcut içeriğin üzerine işler. { overlay: true }'den ctx.ui.custom()'e geçin:
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
{ overlay: true }
);Konumlandırma ve boyutlandırma için overlayOptions kullanın:
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
},
}
);Yer Paylaşımlı Odak
Odaklanmış görünür bir yer paylaşımı, geçici yer paylaşımlı olmayan kullanıcı arayüzünde giriş sahipliğini korur. Bir kaplama, { overlay: true } olmadan başka bir ctx.ui.custom() bileşeni açarsa, bu yedek kullanıcı arayüzü, etkinken girişi alır; kapandığında odaklanan katman girişi geri alabilir.
Görünür bir kaplamanın girdi sahibi olmayı bırakması gerektiğinde handle.unfocus() kullanın ve TUI'nin başka bir görünür yakalama katmanına veya önceki odak hedefine geri dönmesine izin verin. Kaplama görünür kalırken belirli bir bileşenin girdi alması gerektiğinde handle.unfocus({ target }) tuşunu kullanın. { target: null }'yi kasıtlı olarak geçmek, odak yeniden ayarlanana kadar odaklanılan hiçbir bileşeni bırakmaz.
Yer Paylaşımı Yaşam Döngüsü
Kaplama bileşenleri kapatıldığında atılır. Referansları yeniden kullanmayın; yeni örnekler oluşturun:
// 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 againBağlantıları, kenar boşluklarını, yığınlamayı, duyarlı görünürlüğü ve animasyonu kapsayan kapsamlı örnekler için overlay-qa-tests.ts'e bakın.
Yerleşik Bileşenler
@earendil-works/pi-tui'den içe aktar:
import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";Metin
Kelime kaydırmalı çok satırlı metin.
const text = new Text(
"Hello World", // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s) // optional background function
);
text.setText("Updated");Kutu
Dolgu ve arka plan rengine sahip kapsayıcı.
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));Konteyner
Alt bileşenleri dikey olarak gruplandırır.
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);Ara parçası
Boş dikey alan.
const spacer = new Spacer(2); // 2 empty linesMarkdown
Sözdizimi vurgulamayla işaretlemeyi oluşturur.
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");Resim
Desteklenen terminallerdeki (Kitty, iTerm2, Ghostty, WezTerm, Warp) görüntüleri işler.
const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);Klavye Girişi
Anahtar tespiti için matchesKey() kullanın:
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
}
}Anahtar tanımlayıcılar (otomatik tamamlama veya dize değişmezleri için Key.* kullanın):
- Temel tuşlar:
Key.enter,Key.escape,Key.tab,Key.space,Key.backspace,Key.delete,Key.home,Key.end - Ok tuşları:
Key.up,Key.down,Key.left,Key.right - Değiştiricilerle:
Key.ctrl("c"),Key.shift("tab"),Key.alt("left"),Key.ctrlShift("p") - Dize formatı da çalışır:
"enter","ctrl+c","shift+tab","ctrl+shift+p"
Çizgi Genişliği
Kritik: render()'den itibaren her satır width parametresini aşmamalıdır.
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}Yardımcı programlar:
visibleWidth(str)- Ekran genişliğini al (ANSI kodlarını yok sayar)truncateToWidth(str, width, ellipsis?)- İsteğe bağlı üç noktayla kesmewrapTextWithAnsi(str, width)- ANSI kodlarını koruyan kelime kaydırma
Özel Bileşenler Oluşturma
Örnek: Etkileşimli seçici
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;
}
}Bir uzantıda kullanım:
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
Bileşenler stillendirme için tema nesnelerini kabul eder.
renderCall/renderResult'de theme parametresini kullanın:
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"));
}Ön plan renkleri (theme.fg(color, text)):
| Kategori | Renkler |
|---|---|
| Genel | text, accent, muted, dim |
| Durum | success, error, warning |
| Kenarlıklar | border, borderAccent, borderMuted |
| Mesajlar | userMessageText, customMessageText, customMessageLabel |
| Aletler | toolTitle, toolOutput |
| Farklar | toolDiffAdded, toolDiffRemoved, toolDiffContext |
| Markdown | mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet |
| Sözdizimi | syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation |
| Düşünme | thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax |
| Modlar | bashMode |
Arka plan renkleri (theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
Markdown için getMarkdownTheme() kullanın:
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);
}Özel bileşenler için kendi tema arayüzünüzü tanımlayın:
interface MyTheme {
selected: (s: string) => string;
normal: (s: string) => string;
}Hata ayıklama günlüğü
stdout'ye yazılan ham ANSI akışını yakalamak için PI_TUI_WRITE_LOG'yi ayarlayın.
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.tsPerformans
Mümkün olduğunda oluşturulan çıktıyı önbelleğe alın:
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;
}
}Durum değiştiğinde invalidate() çağrısını yapın, ardından yeniden oluşturmayı tetiklemek için enjekte edilen tui.requestRender() öğesini kullanın.
Geçersiz Kılma ve Tema Değişiklikleri
Tema değiştiğinde, TUI tüm bileşenlerin önbelleklerini temizlemek için invalidate()'yi çağırır. Tema değişikliklerinin etkili olmasını sağlamak için bileşenlerin invalidate()'yi düzgün bir şekilde uygulaması gerekir.
Sorun
Bir bileşen, tema renklerini dizeler halinde önceden hazırlıyorsa (theme.fg(), theme.bg() vb. yoluyla) ve bunları önbelleğe alıyorsa, önbelleğe alınan dizeler eski temadan ANSI çıkış kodları içerir. Bileşen temalı içeriği ayrı olarak saklıyorsa, yalnızca oluşturma önbelleğini temizlemek yeterli değildir.
Yanlış yaklaşım (tema renkleri güncellenmiyor):
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
}Çözüm
Tema renkleriyle içerik oluşturan bileşenlerin, invalidate() çağrıldığında bu içeriği yeniden oluşturması gerekir:
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
}
}Desen: Geçersiz Kılma Durumunda Yeniden Oluşturma
Karmaşık içeriğe sahip bileşenler için:
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();
}
}Bu Önemli Olduğunda
Bu model şu durumlarda gereklidir:
- Ön pişirme tema renkleri - Alt bileşenlerde saklanan stilize dizeler oluşturmak için
theme.fg()veyatheme.bg()kullanma - Sözdizimi vurgulama - Temaya dayalı sözdizimi renklerini uygulayan
highlightCode()kullanımı - Karmaşık düzenler - Tema renklerini içeren alt bileşen ağaçları oluşturma
Bu desen şu durumlarda gerekli DEĞİLDİR:
- Tema geri çağırmalarını kullanma - Oluşturma sırasında çağrılan
(text) => theme.fg("accent", text)gibi işlevlerin iletilmesi - Basit kapsayıcılar - Temalı içerik eklemeden yalnızca diğer bileşenleri gruplandırma
- Durum bilgisi olmayan işleme - Her
render()aramada temalı çıktının yeni olarak hesaplanması (önbelleğe alma yok)
Ortak Desenler
Bu modeller, uzantılardaki en yaygın kullanıcı arayüzü ihtiyaçlarını kapsar. Sıfırdan oluşturmak yerine bu kalıpları kopyalayın.
Model 1: Seçim İletişim Kutusu (SelectList)
Kullanıcıların seçenekler listesinden seçim yapmasına izin vermek için. Çerçeveleme için @earendil-works/pi-tui'den SelectList'yi DynamicBorder ile kullanın.
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");
}
},
});
Model 2: İptal ile Eşzamansız İşlem (BorderedLoader)
Zaman alan ve iptal edilmesi gereken işlemler için. BorderedLoader bir döndürücüyü gösterir ve iptal etmek için kaçış işlemlerini gerçekleştirir.
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);
}
},
});Örnekler: qna.ts, handoff.ts
Desen 3: Ayarlar/Geçişler (AyarlarList)
Birden fazla ayarı değiştirmek için. @earendil-works/pi-tui'den SettingsList'yi getSettingsListTheme() ile kullanın.
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),
};
});
},
});Örnekler: tools.ts
Desen 4: Kalıcı Durum Göstergesi
Oluşturmalarda devam eden alt bilgideki durumu gösterin. Mod göstergeleri için iyi.
// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
// Clear status
ctx.ui.setStatus("my-ext", undefined);Örnekler: status-line.ts, plan-mode/index.ts, preset.ts
Desen 4b: Çalışma Göstergesinin Özelleştirilmesi
Pi bir yanıt akışı yaparken gösterilen satır içi çalışma göstergesini özelleştirin.
// 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();Bu yalnızca normal akış çalışma göstergesini etkiler. Sıkıştırma ve yeniden deneme yükleyicileri yerleşik stillerini korur. Özel çerçeveler kelimesi kelimesine işlenir, bu nedenle gerektiğinde uzantıların kendi renklerini eklemesi gerekir.
Örnekler: working-indicator.ts
Desen 5: Düzenleyicinin Üstü/Altındaki Widget'lar
Kalıcı içeriği giriş düzenleyicisinin üstünde veya altında gösterin. Yapılacaklar listeleri için iyi, ilerleme.
// 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);Örnekler: plan-mode/index.ts
Desen 6: Özel Alt Bilgi
Altbilgiyi değiştirin. footerData uzantıların başka şekilde erişemeyeceği verileri açığa çıkarır.
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 defaultToken istatistikleri ctx.sessionManager.getBranch() ve ctx.model üzerinden kullanılabilir.
Örnekler: custom-footer.ts
Desen 7: Özel Düzenleyici (vim modu vb.)
Ana giriş düzenleyicisini özel bir uygulamayla değiştirin. Modal düzenleme (vim), farklı tuş atamaları (emacs) veya özel giriş işleme için kullanışlıdır.
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)
);
});
}Önemli noktalar:
- Uygulama tuş atamalarını almak için
CustomEditor(temelEditordeğil) öğesini genişletin (iptal etmek için kaçış, çıkmak için ctrl+d, model değiştirme vb.) - Elinizde olmayan anahtarlar için
super.handleInput(data)'ı arayın - Fabrika modeli:
setEditorComponent,tui,themevekeybindingselde eden bir fabrika işlevi alır - Varsayılan düzenleyiciyi geri yüklemek için
undefinediletin:ctx.ui.setEditorComponent(undefined)
Örnekler: modal-editor.ts
Temel Kurallar
Temayı her zaman geri aramadan kullan - Temayı doğrudan içe aktarmayın.
ctx.ui.custom((tui, theme, keybindings, done) =>...)geri aramasındanthemekullanın.Her zaman DynamicBorder renk parametresini yazın -
(s) => theme.fg("accent", s)değil,(s: string) => theme.fg("accent", s)yazın.Durum değişikliklerinden sonra tui.requestRender()'ı çağırın -
handleInput'da, durumu güncelledikten sonratui.requestRender()'yi çağırın.Üç yöntemli nesneyi döndür - Özel bileşenlerin
{ render, invalidate, handleInput }olması gerekir.Mevcut bileşenleri kullanın -
SelectList,SettingsList,BorderedLoadervakaların %90'ını kapsar. Onları yeniden inşa etmeyin.
Örnekler
- Seçim Kullanıcı Arayüzü: examples/extensions/preset.ts - DynamicBorder çerçevelemeli SelectList
- İptal ile eşzamansız: examples/extensions/qna.ts - Yüksek Lisans çağrıları için BorderedLoader
- Ayarlar arasında geçiş yapar: examples/extensions/tools.ts - Araç etkinleştirme/devre dışı bırakma için AyarlarList
- Durum göstergeleri: examples/extensions/plan-mode/index.ts - setStatus ve setWidget
- Çalışma göstergesi: examples/extensions/working-indicator.ts - setWorkingIndicator
- Özel altbilgi: examples/extensions/custom-footer.ts - istatistiklerle setFooter
- Özel düzenleyici: examples/extensions/modal-editor.ts - Vim benzeri kalıcı düzenleme
- Yılan oyunu: examples/extensions/snake.ts - Klavye girişiyle tam oyun, oyun döngüsü
- Özel araç oluşturma: examples/extensions/todo.ts - renderCall ve renderResult