23 TUI 组件
23.1 TUI 组件概述
Pi 的扩展和自定义工具可以渲染自定义 TUI 组件,构建交互式的终端用户界面。本章涵盖组件系统和可用的构建块。
TUI 组件系统来源于 @earendil-works/pi-tui 包,提供了一套完整的终端 UI 构建组件。
23.2 组件接口
所有组件实现以下接口:
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() 确保换行时保留样式。
23.3 Focusable 接口(IME 支持)
需要显示文本光标和 IME(输入法编辑器)支持的组件应实现 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 会:
- 设置
focused = true - 扫描渲染输出中的
CURSOR_MARKER(零宽度 APC 转义序列) - 将硬件终端光标定位到该位置
- 仅在启用
showHardwareCursor时显示硬件光标
某些终端需要可见的硬件光标才能正确定位 IME 候选窗口。通过 showHardwareCursor、setShowHardwareCursor(true) 或 PI_HARDWARE_CURSOR=1 启用。Editor 和 Input 内置组件已实现此接口。
23.3.1 容器组件中的嵌入式输入
当容器组件(对话框、选择器等)包含 Input 或 Editor 子组件时,容器必须实现 Focusable 并传播焦点状态:
import { Container, type Focusable, Input } from "@earendil-works/pi-tui";
class SearchDialog extends Container implements Focusable {
private searchInput: Input;
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(中文、日文、韩文等)输入时,候选窗口会显示在屏幕上的错误位置。
23.4 使用组件
在扩展中通过 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),
})
);
});在自定义工具中:
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),
})
);
}23.5 Overlays(覆盖层)
Overlays 在不清除屏幕的情况下将组件渲染在现有内容之上:
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
{ overlay: true }
);23.5.1 定位和尺寸
使用 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%
anchor: "right-center", // 9 个位置:center, top-left, top-center 等
offsetX: -2,
offsetY: 0,
// 或百分比/绝对定位
row: "25%",
col: 10,
margin: 2, // 所有边,或 { top, right, bottom, left }
// 响应式:窄终端时隐藏
visible: (termWidth, termHeight) => termWidth >= 80,
},
onHandle: (handle) => {
// handle.focus() - 聚焦此 overlay 并置于最前
// handle.unfocus() - 释放输入到正常回退
// handle.setHidden() - 切换可见性
// handle.hide() - 永久移除
},
}
);23.5.2 Overlay 焦点
获得焦点的可见 overlay 在临时非 overlay UI 期间保持输入所有权。如果 overlay 打开了另一个 ctx.ui.custom() 组件(不带 { overlay: true }),该替换 UI 在活跃时接收输入;关闭后,获得焦点的 overlay 可以收回输入。
23.5.3 Overlay 生命周期
Overlay 组件在关闭时被销毁。不要重用引用——创建新实例:
// 错误 - 过期引用
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(); // "返回" = 再次调用23.6 内置组件
从 @earendil-works/pi-tui 导入:
import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";23.6.1 Text
带自动换行的多行文本:
const text = new Text(
"Hello World", // 内容
1, // paddingX(默认 1)
1, // paddingY(默认 1)
(s) => bgGray(s) // 可选的背景函数
);
text.setText("Updated");23.6.2 Box
带内边距和背景色的容器:
const box = new Box(
1, // paddingX
1, // paddingY
(s) => bgGray(s) // 背景函数
);
box.addChild(new Text("Content", 0, 0));
box.setBgFn((s) => bgBlue(s));23.6.3 Container
垂直分组子组件:
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);23.6.4 Spacer
空白垂直空间:
const spacer = new Spacer(2); // 2 行空白23.6.5 Markdown
带语法高亮的 Markdown 渲染:
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme
);
md.setText("Updated markdown");23.6.6 Image
在支持的终端中渲染图片(Kitty、iTerm2、Ghostty、WezTerm、Warp):
const image = new Image(
base64Data, // base64 编码的图片数据
"image/png", // MIME 类型
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);23.7 键盘输入
使用 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.items[this.selectedIndex]);
} else if (matchesKey(data, Key.escape)) {
this.onCancel?.();
} else if (matchesKey(data, Key.ctrl("c"))) {
// Ctrl+C
}
}23.7.1 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"
23.8 行宽工具
关键规则: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 代码 |
23.9 创建自定义组件
23.9.1 示例:交互式选择器
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;
}
}23.9.2 在扩展中使用
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, _kb, done) => {
const selector = new MySelector(items);
selector.onSelect = (value) => done(value);
selector.onCancel = () => done(null);
return selector;
});
if (selected) {
ctx.ui.notify(`Selected: ${selected}`, "info");
}
},
});23.10 主题化
组件通过 theme 参数访问当前主题颜色:
class ThemedComponent {
constructor(private theme: MarkdownTheme) {}
render(width: number): string[] {
const accent = this.theme.colors.accent;
return [`${accent}Highlighted text\x1b[0m`];
}
}主题变更时,invalidate() 会被自动调用。
23.11 性能优化
23.11.1 缓存渲染结果
render(width: number): string[] {
// 宽度未变且有缓存时直接返回
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
// 重新计算并缓存
this.cachedLines = this.computeLines(width);
this.cachedWidth = width;
return this.cachedLines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}23.11.2 失效和主题变更
当主题变更时,TUI 会调用所有组件的 invalidate()。如果组件缓存了渲染结果且包含主题颜色,需要重建缓存。
问题:如果组件在渲染时直接读取主题颜色,缓存的行包含旧的 ANSI 颜色代码。
解决方案:在 invalidate() 中清除所有缓存,下次 render() 时自动重建。
class ColoredComponent {
private cachedLines?: string[];
render(width: number): string[] {
if (this.cachedLines) return this.cachedLines;
// 使用当前主题颜色渲染
this.cachedLines = [`${this.theme.colors.accent}Text\x1b[0m`];
return this.cachedLines;
}
invalidate(): void {
this.cachedLines = undefined; // 强制下次 render 重建
}
}23.12 常见模式
23.12.1 Pattern 1: 选择对话框(SelectList)
class SelectList {
private items: string[];
private selected = 0;
constructor(items: string[]) {
this.items = items;
}
handleInput(data: string): void {
if (matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
else if (matchesKey(data, Key.down)) this.selected = Math.min(this.items.length - 1, this.selected + 1);
else if (matchesKey(data, Key.enter)) this.onSelect?.(this.items[this.selected]);
else if (matchesKey(data, Key.escape)) this.onCancel?.();
}
render(width: number): string[] {
return this.items.map((item, i) => {
const prefix = i === this.selected ? "▶ " : " ";
return truncateToWidth(prefix + item, width);
});
}
}23.12.2 Pattern 2: 异步操作与取消(BorderedLoader)
class BorderedLoader {
private message: string;
private dots = 0;
constructor(message: string) {
this.message = message;
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
this.onCancel?.();
}
}
render(width: number): string[] {
const dots = ".".repeat(this.dots % 4);
return [
"┌──────────────┐",
`│ ${truncateToWidth(this.message + dots, width - 4)} │`,
"└──────────────┘",
];
}
}23.12.3 Pattern 3: 设置列表(SettingsList)
class SettingsList {
private settings: { label: string; value: string; enabled: boolean }[] = [];
handleInput(data: string): void {
if (matchesKey(data, Key.enter)) {
const item = this.settings[this.selected];
item.enabled = !item.enabled;
this.invalidate();
}
}
// ...
}23.12.4 Pattern 4: 持久状态指示器
class StatusIndicator {
constructor(private getStatus: () => string) {}
render(width: number): string[] {
return [truncateToWidth(`Status: ${this.getStatus()}`, width)];
}
invalidate(): void {}
}23.12.5 Pattern 5: Widget(编辑器上方/下方)
通过扩展在编辑器区域上方或下方放置组件:
pi.on("session_start", async (_event, ctx) => {
// 显示持久 widget
ctx.ui.custom((tui) => {
const widget = new TodoWidget();
return widget;
});
});23.12.7 Pattern 7: 自定义编辑器(vim 模式等)
通过实现完整的 Focusable 接口和自定义输入处理,可以创建类似 vim 的模态编辑器。这需要深入理解终端输入处理,建议参考内置 Editor 组件的实现。
23.13 关键规则
- 每行不超过 width:使用
truncateToWidth()确保不超限 - 实现
invalidate():清除所有缓存,否则主题变更后颜色不正确 - 传播焦点到子组件:容器组件必须实现
Focusable并传播focused状态 - 不要重用 Overlay 引用:关闭后创建新实例
- 使用
matchesKey()检测按键:不要手动解析转义序列 - 考虑 CJK 宽字符:使用
visibleWidth()而非.length计算显示宽度 - 在非 UI 模式下优雅降级:检查
ctx.hasUI确保组件可用
完整的 TUI 组件示例参见 GitHub 示例库,包含 snake 游戏、对话框、覆盖层测试等各种组件。