TUI 組件
pi 可以创建 TUI 个组件。要求它為您的用例建立一個。
Extensions 和自訂工具可以為互動式使用者介面渲染自訂 TUI 元件。本頁介紹了元件系統和可用的建構塊。
組件介面
所有組件均實作:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}| 方法 | 描述 |
|---|---|
render(width) |
傳回字串數組(每行一個)。每行不得超過width。 |
handleInput?(data) |
當組件獲得焦點時接收鍵盤輸入。 |
wantsKeyRelease? |
如果為 true,元件會接收按鍵釋放事件(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(零寬度 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
},
}
);疊加焦點
集中可見的疊加層可在臨時非疊加 UI 中保留輸入所有權。如果覆蓋層開啟另一個沒有 { overlay: true } 的 ctx.ui.custom() 元件,則取代 UI 在活動時接收輸入;當它關閉時,聚焦的覆蓋層可以回收輸入。
當可見疊加層應停止擁有輸入並讓 TUI 回退到另一個可見捕獲疊加層或前一個焦點目標時,請使用 handle.unfocus()。當特定組件應在覆蓋層保持可見時接收輸入時,請使用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
使用語法反白呈現 Markdown。
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 以捕捉寫入stdout 的原始 ANSI 流。
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() 等)並快取它們,則快取的字串包含舊主題中的 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()呼叫中計算新鮮的主題輸出(無快取)
常見模式
這些模式涵蓋了擴充中最常見的 UI 需求。 **複製這些模式而不是從頭開始建立。 **
模式 1:選擇對話框(SelectList)
用於讓使用者從選項清單中進行選擇。使用@earendil-works/pi-tui中的SelectList和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 顯示一個旋轉器並處理轉義以取消。
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)
用於切換多個設定。將 @earendil-works/pi-tui 中的 SettingsList 與 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
模式 4b:工作指標定制
自訂 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();這只會影響正常的串流媒體工作指標。壓實和重試載入器保持其內建樣式。自訂框架逐字渲染,因此擴充功能必須在需要時添加自己的顏色。
模式 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);
模式 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)以取得應用程式鍵綁定(轉義以中止、ctrl+d 退出、模型切換等) - 對於您不處理的鑰匙,請致電
super.handleInput(data) - 工廠模式:
setEditorComponent接收一個獲取tui、theme和keybindings的工廠函數 - **透過
undefined**恢復預設編輯器:ctx.ui.setEditorComponent(undefined)
範例: modal-editor.ts
關鍵規則
始終使用回呼中的主題 - 不要直接匯入主題。使用
ctx.ui.custom((tui, theme, keybindings, done) =>...)回呼中的theme。始終輸入 DynamicBorder 顏色參數 - 寫入
(s: string) => theme.fg("accent", s),而不是(s) => theme.fg("accent", s)。狀態改變後呼叫tui.requestRender() - 在
handleInput中,更新狀態後呼叫tui.requestRender()。返回三方法物件 - 自訂元件需要
{ render, invalidate, handleInput }。使用現有組件 -
SelectList、SettingsList、BorderedLoader覆蓋 90% 的情況。不要重建它們。
範例
- 選擇 UI:examples/extensions/preset.ts - 具有 DynamicBorder 框架的 SelectList
- 與取消非同步:examples/extensions/qna.ts - 用於 LLM 呼叫的 BorderedLoader
- 設定切換: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