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() 清除缓存的渲染状态。主题变更时调用
Warning

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 会:

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

某些终端需要可见的硬件光标才能正确定位 IME 候选窗口。通过 showHardwareCursorsetShowHardwareCursor(true)PI_HARDWARE_CURSOR=1 启用。EditorInput 内置组件已实现此接口。

23.3.1 容器组件中的嵌入式输入

当容器组件(对话框、选择器等)包含 InputEditor 子组件时,容器必须实现 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);
  }
}
Warning

如果不传播焦点状态,使用 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.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"

23.8 行宽工具

Warning

关键规则: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 关键规则

  1. 每行不超过 width:使用 truncateToWidth() 确保不超限
  2. 实现 invalidate():清除所有缓存,否则主题变更后颜色不正确
  3. 传播焦点到子组件:容器组件必须实现 Focusable 并传播 focused 状态
  4. 不要重用 Overlay 引用:关闭后创建新实例
  5. 使用 matchesKey() 检测按键:不要手动解析转义序列
  6. 考虑 CJK 宽字符:使用 visibleWidth() 而非 .length 计算显示宽度
  7. 在非 UI 模式下优雅降级:检查 ctx.hasUI 确保组件可用
Tip

完整的 TUI 组件示例参见 GitHub 示例库,包含 snake 游戏、对话框、覆盖层测试等各种组件。