跳转到内容

TUI 组件

pi 可以创建 TUI 组件。让 pi 为你的使用场景构建一个。

扩展(extension)和自定义工具(tool)可以渲染自定义 TUI(终端界面)组件(component),用于构建交互式用户界面。本页介绍组件系统及可用的构建块。

来源: @earendil-works/pi-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(输入法编辑器,Input Method Editor)支持的组件应实现 Focusable 接口:

import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui";
class MyInput implements Component, Focusable {
focused: boolean = false; // 由 TUI 在焦点变化时设置
render(width: number): string[] {
const marker = this.focused ? CURSOR_MARKER : "";
// 在模拟光标之前输出标记
return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
}
}

Focusable 组件获得焦点时,TUI 会:

  1. 在组件上设置 focused = true
  2. 在渲染输出中查找 CURSOR_MARKER(一个零宽度的 APC 转义序列)
  3. 将终端硬件光标定位到该位置
  4. 仅在启用 showHardwareCursor 时显示硬件光标

光标默认保持隐藏。这样既保留了模拟光标的渲染,又能为通过隐藏光标跟踪 IME 候选窗口的终端定位硬件光标。部分终端需要可见的硬件光标才能进行 IME 定位,可通过 showHardwareCursorsetShowHardwareCursor(true)PI_HARDWARE_CURSOR=1 启用。内置的 EditorInput 组件已实现此接口。

当容器组件(对话框、选择器等)包含 InputEditor 子组件时,容器必须实现 Focusable 并将焦点状态传递给子组件。否则,硬件光标将无法为 IME 输入正确定位。

import { Container, type Focusable, Input } from "@earendil-works/pi-tui";
class SearchDialog extends Container implements Focusable {
private searchInput: Input;
// Focusable 实现 - 将焦点传递给子输入组件以定位 IME 光标
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),
})
);
// 使用结果...
}

覆盖层(overlay)在现有内容之上渲染组件,而不会清屏。向 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: {
// 尺寸:数字或百分比字符串
width: "50%", // 终端宽度的 50%
minWidth: 40, // 最小 40 列
maxHeight: "80%", // 终端高度的 80%
// 位置:基于锚点(默认:"center")
anchor: "right-center", // 9 个位置:center、top-left、top-center 等
offsetX: -2, // 相对锚点的偏移
offsetY: 0,
// 或使用百分比/绝对定位
row: "25%", // 距顶部 25%
col: 10, // 第 10 列
// 外边距
margin: 2, // 四周,或 { top, right, bottom, left }
// 响应式:在窄终端上隐藏
visible: (termWidth, termHeight) => termWidth >= 80,
},
// 获取句柄,用于编程式控制焦点和可见性
onHandle: (handle) => {
// handle.focus() - 聚焦该覆盖层并将其置于视觉最前
// handle.unfocus() - 将输入交还给普通回退
// handle.unfocus({ target }) - 将输入交给指定组件或 null
// handle.setHidden(true/false) - 切换可见性
// handle.hide() - 永久移除
},
}
);

获得焦点的可见覆盖层会在临时非覆盖层 UI 存在期间保留输入所有权。如果某个覆盖层在不带 { overlay: true } 的情况下打开另一个 ctx.ui.custom() 组件,那么该替换 UI 在活动期间接收输入;当它关闭时,获得焦点的覆盖层可以收回输入。

当可见覆盖层应停止持有输入、让 TUI 回退到另一个可见的捕获覆盖层或之前的焦点目标时,使用 handle.unfocus()。当覆盖层保持可见、同时要让指定组件接收输入时,使用 handle.unfocus({ target })。传入 { target: null } 会刻意不留下任何聚焦组件,直到再次设置焦点。

覆盖层组件在关闭时会被销毁。不要复用旧引用——请创建新实例:

// 错误 - 过期引用
let menu: MenuComponent;
await ctx.ui.custom((_, __, ___, done) => {
menu = new MenuComponent(done);
return menu;
}, { overlay: true });
setActiveComponent(menu); // 已销毁
// 正确 - 重新调用以重新显示
const showMenu = () => ctx.ui.custom((_, __, ___, done) =>
new MenuComponent(done), { overlay: true });
await showMenu(); // 首次显示
await showMenu(); // "返回" = 直接再次调用

完整的示例(涵盖锚点、外边距、堆叠、响应式可见性和动画)参见 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", // 内容
1, // paddingX(默认:1)
1, // paddingY(默认:1)
(s) => bgGray(s) // 可选背景函数
);
text.setText("Updated");

带内边距和背景色的容器。

const box = new Box(
1, // paddingX
1, // paddingY
(s) => bgGray(s) // 背景函数
);
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 个空行

渲染带语法高亮的 markdown。

const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme(见下文)
);
md.setText("Updated markdown");

在受支持的终端(Kitty、iTerm2、Ghostty、WezTerm、Warp)中渲染图片。

const image = new Image(
base64Data, // base64 编码的图片
"image/png", // MIME 类型
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.enterKey.escapeKey.tabKey.spaceKey.backspaceKey.deleteKey.homeKey.end
  • 方向键:Key.upKey.downKey.leftKey.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[] {
// 截断长行
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) {
// 使用 theme.fg() 设置前景色
return new Text(theme.fg("success", "Done!"), 0, 0);
// 使用 theme.bg() 设置背景色
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}

前景色theme.fg(color, text)):

类别 颜色
通用 textaccentmuteddim
状态 successerrorwarning
边框 borderborderAccentborderMuted
消息 userMessageTextcustomMessageTextcustomMessageLabel
工具 toolTitletoolOutput
差异 toolDiffAddedtoolDiffRemovedtoolDiffContext
Markdown mdHeadingmdLinkmdLinkUrlmdCodemdCodeBlockmdCodeBlockBordermdQuotemdQuoteBordermdHrmdListBullet
语法 syntaxCommentsyntaxKeywordsyntaxFunctionsyntaxVariablesyntaxStringsyntaxNumbersyntaxTypesyntaxOperatorsyntaxPunctuation
思考级别 thinkingOffthinkingMinimalthinkingLowthinkingMediumthinkingHighthinkingXhighthinkingMax
模式 bashMode

背景色theme.bg(color, text)):

selectedBguserMessageBgcustomMessageBgtoolPendingBgtoolSuccessBgtoolErrorBg

对于 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 流。

Terminal window
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;
}
// ... 计算行 ...
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() 等方式将主题色预烘焙(pre-bake)进字符串并加以缓存,那么缓存的字符串包含旧主题的 ANSI 转义码。如果组件将主题化内容单独存储,仅清除渲染缓存是不够的。

错误做法(主题色不会更新):

class BadComponent extends Container {
private content: Text;
constructor(message: string, theme: Theme) {
super();
// 预烘焙的主题色存储在 Text 组件中
this.content = new Text(theme.fg("accent", message), 1, 0);
this.addChild(this.content);
}
// 未重写 invalidate - 父级的 invalidate 只会清除
// 子组件的渲染缓存,而非预烘焙的内容
}

使用主题色构建内容的组件,必须在调用 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 {
// 使用当前主题重建内容
this.content.setText(theme.fg("accent", this.message));
}
override invalidate(): void {
super.invalidate(); // 清除子组件缓存
this.updateDisplay(); // 使用新主题重建
}
}

对于内容复杂的组件:

class ComplexComponent extends Container {
private data: SomeData;
constructor(data: SomeData) {
super();
this.data = data;
this.rebuild();
}
private rebuild(): void {
this.clear(); // 移除所有子组件
// 使用当前主题构建 UI
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();
}
}

以下情况需要此模式:

  1. 预烘焙主题色 - 使用 theme.fg()theme.bg() 创建带样式的字符串并存储在子组件中
  2. 语法高亮 - 使用 highlightCode(),它会应用基于主题的语法配色
  3. 复杂布局 - 构建内嵌主题色的子组件树

以下情况不需要此模式:

  1. 使用主题回调 - 传入诸如 (text) => theme.fg("accent", text) 这类在渲染期间调用的函数
  2. 简单容器 - 仅组合其他组件而不添加主题化内容
  3. 无状态渲染 - 每次 render() 调用都重新计算主题化输出(不缓存)

这些模式涵盖了扩展中最常见的 UI 需求。直接复制这些模式,不要从零开始构建。

模式 1:选择对话框(SelectList)

Section titled “模式 1:选择对话框(SelectList)”

用于让用户从选项列表中进行选择。使用 @earendil-works/pi-tuiSelectList,配合 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 为可选
];
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const container = new Container();
// 顶部边框
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// 标题
container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0));
// 带主题的 SelectList
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);
// 帮助文本
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
// 底部边框
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");
}
},
});

示例: preset.tstools.ts

模式 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);
// 执行异步工作
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.tshandoff.ts

模式 3:设置/开关(SettingsList)

Section titled “模式 3:设置/开关(SettingsList)”

用于切换多个设置项。使用 @earendil-works/pi-tuiSettingsList,配合 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) => {
// 处理值变化
ctx.ui.notify(`${id} = ${newValue}`, "info");
},
() => done(undefined), // 关闭时
{ enableSearch: true }, // 可选:启用按标签模糊搜索
);
container.addChild(settingsList);
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => settingsList.handleInput?.(data),
};
});
},
});

示例: tools.ts

在底栏中显示跨渲染保持的状态。适合用作模式指示器。

// 设置状态(显示在底栏)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
// 清除状态
ctx.ui.setStatus("my-ext", undefined);

示例: status-line.tsplan-mode/index.tspreset.ts

定制 pi 流式输出响应时显示的内联工作指示器。

// 静态指示器
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "")] });
// 自定义动画指示器
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,
});
// 完全隐藏指示器
ctx.ui.setWorkingIndicator({ frames: [] });
// 恢复 pi 的默认旋转指示器
ctx.ui.setWorkingIndicator();

这只会影响常规的流式工作指示器。上下文压缩和重试的加载器保持其内置样式。自定义帧会原样渲染,因此扩展在需要时必须自行添加颜色。

示例: working-indicator.ts

模式 5:编辑器上方/下方的组件

Section titled “模式 5:编辑器上方/下方的组件”

在输入编辑器上方或下方显示持续内容。适合用作待办列表、进度显示。

// 简单的字符串数组(默认显示在编辑器上方)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
// 渲染在编辑器下方
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
// 或使用主题
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: () => {},
};
});
// 清除
ctx.ui.setWidget("my-widget", undefined);

示例: plan-mode/index.ts

替换默认底栏。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()), // 响应式
}));
ctx.ui.setFooter(undefined); // 恢复默认

令牌统计可通过 ctx.sessionManager.getBranch()ctx.model 获取。

示例: 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 {
// Esc:切换到普通模式,或传递给应用处理
if (matchesKey(data, "escape")) {
if (this.mode === "insert") {
this.mode = "normal";
return;
}
// 在普通模式下,Esc 中止智能体(由 CustomEditor 处理)
super.handleInput(data);
return;
}
// 插入模式:将所有输入传递给 CustomEditor
if (this.mode === "insert") {
super.handleInput(data);
return;
}
// 普通模式:vim 风格导航
switch (data) {
case "i": this.mode = "insert"; return;
case "h": super.handleInput("\x1b[D"); return; // 左
case "j": super.handleInput("\x1b[B"); return; // 下
case "k": super.handleInput("\x1b[A"); return; // 上
case "l": super.handleInput("\x1b[C"); return; // 右
}
// 将未处理的按键传递给 super(如 ctrl+c),但过滤可打印字符
if (data.length === 1 && data.charCodeAt(0) >= 32) return;
super.handleInput(data);
}
render(width: number): string[] {
const lines = super.render(width);
// 在底部边框添加模式指示器(使用 truncateToWidth 进行 ANSI 安全的截断)
if (lines.length > 0) {
const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
const lastLine = lines[lines.length - 1]!;
// 传入 "" 作为省略号,避免截断时添加 "..."
lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, "") + label;
}
return lines;
}
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
// 工厂函数从应用接收 TUI、主题和快捷键绑定
ctx.ui.setEditorComponent((tui, theme, keybindings) =>
new VimEditor(tui, theme, keybindings)
);
});
}

要点:

  • 继承 CustomEditor(而不是基础 Editor),以获得应用快捷键(Esc 中止、ctrl+d 退出、模型切换等)
  • 对未处理的按键调用 super.handleInput(data)
  • 工厂模式setEditorComponent 接收一个工厂函数,该函数会获得 tuithemekeybindings
  • 传入 undefined 以恢复默认编辑器:ctx.ui.setEditorComponent(undefined)

示例: modal-editor.ts

  1. 始终使用回调中的 theme - 不要直接导入主题。使用 ctx.ui.custom((tui, theme, keybindings, done) => ...) 回调中的 theme

  2. 始终为 DynamicBorder 的颜色参数标注类型 - 写 (s: string) => theme.fg("accent", s),不要写 (s) => theme.fg("accent", s)

  3. 状态变化后调用 tui.requestRender() - 在 handleInput 中更新状态后调用 tui.requestRender()

  4. 返回三方法对象 - 自定义组件需要 { render, invalidate, handleInput }

  5. 复用现有组件 - SelectListSettingsListBorderedLoader 能覆盖 90% 的场景,不要重复造轮子。