TUI 组件
pi 可以创建 TUI 组件。请它为你的用例构建一个。
TUI 组件
Section titled “TUI 组件”扩展和自定义工具可以渲染自定义 TUI 组件,用于交互式用户界面。本页介绍组件系统及可用的构建块。
所有组件都实现:
interface Component { render(width: number): string[]; handleInput?(data: string): void; wantsKeyRelease?: boolean; invalidate(): void;}| 方法 | 说明 |
|---|---|
render(width) | 返回字符串数组(每行一个)。每行不得超过 width。 |
handleInput?(data) | 组件获得焦点时接收键盘输入。 |
wantsKeyRelease? | 若为 true,组件会接收按键释放事件(Kitty 协议)。默认:false。 |
invalidate() | 清除缓存的渲染状态。主题变更时调用。 |
TUI 会在每行渲染输出的末尾追加完整的 SGR 重置和 OSC 8 重置。样式不会跨行延续。若输出带样式的多行文本,请为每行重新应用样式,或使用 wrapTextWithAnsi(),以便为每个换行保留样式。
可聚焦接口(IME 支持)
Section titled “可聚焦接口(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 组件已实现此接口。
含嵌入式输入的容器组件
Section titled “含嵌入式输入的容器组件”当容器组件(对话框、选择器等)包含 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 handle = ctx.ui.custom(myComponent); // handle.requestRender() - trigger re-render // handle.close() - restore normal UI});在自定义工具中通过 pi.ui.custom():
async execute(toolCallId, params, onUpdate, ctx, signal) { const handle = pi.ui.custom(myComponent); // ... handle.close();}叠加层在现有内容之上渲染组件,而不清空屏幕。向 ctx.ui.custom() 传入 { overlay: true }:
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 } 会故意不聚焦任何组件,直到再次设置焦点。
叠加层生命周期
Section titled “叠加层生命周期”叠加层组件在关闭时会被销毁。不要复用引用——应创建新实例:
// Wrong - stale referencelet menu: MenuComponent;await ctx.ui.custom((_, __, ___, done) => { menu = new MenuComponent(done); return menu;}, { overlay: true });setActiveComponent(menu); // Disposed
// Correct - re-call to re-showconst showMenu = () => ctx.ui.custom((_, __, ___, done) => new MenuComponent(done), { overlay: true });
await showMenu(); // First showawait 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));Container
Section titled “Container”垂直分组子组件。
const container = new Container();container.addChild(component1);container.addChild(component2);container.removeChild(component1);Spacer
Section titled “Spacer”空白垂直间距。
const spacer = new Spacer(2); // 2 empty linesMarkdown
Section titled “Markdown”渲染带语法高亮的 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 码
创建自定义组件
Section titled “创建自定义组件”示例:交互式选择器
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 selector = new MySelector(items);
let handle: { close: () => void; requestRender: () => void };
await new Promise<void>((resolve) => { selector.onSelect = (item) => { ctx.ui.notify(`Selected: ${item}`, "info"); handle.close(); resolve(); }; selector.onCancel = () => { handle.close(); resolve(); }; handle = ctx.ui.custom(selector); }); }});组件接受主题对象进行样式设置。
在 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 |
| 模式 | 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(),然后调用 handle.requestRender() 触发重新渲染。
失效与主题变更
Section titled “失效与主题变更”主题变更时,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 }}模式:在 invalidate 时重建
Section titled “模式:在 invalidate 时重建”对于内容复杂的组件:
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)
Section titled “模式 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)
Section titled “模式 2:可取消的异步操作(BorderedLoader)”适用于耗时且应可取消的操作。BorderedLoader 显示加载动画,并支持按 Esc 取消。
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)
Section titled “模式 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:持久状态指示器
Section titled “模式 4:持久状态指示器”在页脚显示跨渲染持久的状态。适合模式指示器。
// Set status (shown in footer)ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
// Clear statusctx.ui.setStatus("my-ext", undefined);示例: status-line.ts、plan-mode.ts、preset.ts
模式 4b:工作指示器自定义
Section titled “模式 4b:工作指示器自定义”自定义 pi 流式响应时显示的内联工作指示器。
// Static indicatorctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
// Custom animated indicatorctx.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 entirelyctx.ui.setWorkingIndicator({ frames: [] });
// Restore pi's default spinnerctx.ui.setWorkingIndicator();这仅影响正常的流式工作指示器。压缩与重试加载器保留内置样式。自定义帧会原样渲染,扩展需在需要时自行添加颜色。
模式 5:编辑器上方/下方小组件
Section titled “模式 5:编辑器上方/下方小组件”在输入编辑器上方或下方显示持久内容。适合待办列表、进度等。
// Simple string array (above editor by default)ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
// Render below the editorctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
// Or with themectx.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: () => {}, };});
// Clearctx.ui.setWidget("my-widget", undefined);示例: plan-mode.ts
模式 6:自定义页脚
Section titled “模式 6:自定义页脚”替换页脚。footerData 暴露扩展 otherwise 无法访问的数据。
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 获取 token 统计。
示例: custom-footer.ts
模式 7:自定义编辑器(vim 模式等)
Section titled “模式 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 theme and keybindings from the app ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(theme, keybindings) ); });}要点:
- 继承
CustomEditor(而非基础Editor)以获得应用键绑定(Esc 中止、Ctrl+D 退出、模型切换等) - 对未处理的键调用
super.handleInput(data) - 工厂模式:
setEditorComponent接收工厂函数,该函数获得tui、theme和keybindings - 传入
undefined可恢复默认编辑器:ctx.ui.setEditorComponent(undefined)
示例: modal-editor.ts
-
始终使用回调中的 theme — 不要直接导入 theme。使用
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 — 用于启用/禁用工具的 SettingsList
- 状态指示器:examples/extensions/plan-mode.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