Skip to content

TUI 组件

·
  • Pi
本页为 Pi 官方文档的非官方中文翻译,请以官方英文文档为准。 原文 · 许可证

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

扩展和自定义工具可以为交互式用户界面渲染自定义 TUI 组件。本页介绍组件系统以及可用的构建模块。

来源: @earendil-works/pi-tui

组件接口(Component Interface) ​

所有组件都实现:

typescript
interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
  wantsKeyRelease?: boolean;
  invalidate(): void;
}
方法描述
render(width)返回字符串数组(每行一个)。每行不得超过 width。
handleInput?(data)组件获得焦点时接收键盘输入。
handleMouse?(event)在全屏模式下接收标准化的指针输入。
wantsKeyRelease?若为 true,组件接收按键释放事件(Kitty 协议)。默认值:false。
invalidate()清除缓存的渲染状态。在主题变化时调用。

TUI 在每一行渲染结果的末尾追加一次完整的 SGR 重置和 OSC 8 重置。样式不会跨行延续。如果你输出带样式的多行文本,需要逐行重新应用样式,或使用 wrapTextWithAnsi() 让每个换行后的行都保留样式。

可聚焦接口(IME 支持) ​

需要显示文本光标并支持 IME(输入法)的组件应实现 Focusable 接口:

typescript
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 : '';
    // 在伪光标之前立即输出 marker
    return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
  }
}

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

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

默认情况下光标保持隐藏。这保留了伪光标的渲染,同时仍为那些以隐藏光标跟踪 IME 候选窗口的终端定位硬件光标。某些终端需要可见的硬件光标才能进行 IME 定位;可以通过渲染器的 showHardwareCursor 构造参数或 setShowHardwareCursor(true) 启用它。Pi 在创建渲染器之前也会将 PI_HARDWARE_CURSOR=1 映射到此设置。内置的 Editor 和 Input 组件已实现该接口。

包含内嵌输入框的容器组件 ​

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

typescript
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():

typescript
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():

typescript
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),
    })
  );
  // 使用 result...
}

覆盖层(Overlays) ​

覆盖层在不清除屏幕的情况下,将组件渲染在现有内容之上。向 ctx.ui.custom() 传入 { overlay: true }:

typescript
const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
  { overlay: true }
);

对于定位和尺寸,使用 overlayOptions:

typescript
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 } 会有意地让焦点留空,直到再次设置焦点。

覆盖层生命周期 ​

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

typescript
// 错误 - 过期的引用
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 导入:

typescript
import { Text, Box, Container, Spacer, Markdown } from '@earendil-works/pi-tui';

Text ​

带自动换行的多行文本。

typescript
const text = new Text(
  'Hello World', // 内容
  1, // paddingX(默认:1)
  1, // paddingY(默认:1)
  (s) => bgGray(s) // 可选的背景函数
);
text.setText('Updated');

Box ​

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

typescript
const box = new Box(
  1, // paddingX
  1, // paddingY
  (s) => bgGray(s) // 背景函数
);
box.addChild(new Text('Content', 0, 0));
box.setBgFn((s) => bgBlue(s));

Container ​

垂直排列子组件。

typescript
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);

Spacer ​

空白的垂直空间。

typescript
const spacer = new Spacer(2); // 2 个空行

Markdown ​

带语法高亮地渲染 Markdown。

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

Image ​

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

typescript
const image = new Image(
  base64Data, // base64 编码的图像
  'image/png', // MIME 类型
  theme, // ImageTheme
  { maxWidthCells: 80, maxHeightCells: 24 }
);

键盘输入 ​

使用 matchesKey() 进行按键检测:

typescript
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"

鼠标输入 ​

全屏模式会将标准化的按下(press)、释放(release)、点击(click)、移动(move)、拖拽(drag)和滚轮(wheel)事件路由到组件和覆盖层。返回 { handled: true } 以抑制默认行为,capture: true 以保留拖拽/释放所有权,focus: true 以请求键盘焦点,当悬停或释放事件使组件发生可见变化时返回 render: true。按下、点击、拖拽和滚轮默认会渲染;无操作的移动/释放事件则不会。

typescript
import { MouseRegion } from '@earendil-works/pi-tui';

const clickable = new MouseRegion(content, (event) => {
  if (event.type !== 'click' || event.button !== 'left') return undefined;
  expanded = !expanded;
  return { handled: true };
});

未处理的滚轮输入会滚动最近的 ScrollView;未处理的主键拖拽会保留文本选择。OSC 8 链接优先于父级点击区域。Input、Editor、SelectList 和 SettingsList 包含全屏鼠标行为。普通模式不捕获鼠标输入,因为终端拥有自己的滚动回看。

行宽 ​

关键: render() 输出的每一行都不得超过 width 参数。

typescript
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 代码的自动换行

创建自定义组件 ​

示例:交互式选择器

typescript
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;
  }
}

在扩展中使用:

typescript
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');
    }
  }
});

主题化(Theming) ​

组件接受主题对象用于样式设置。

在 renderCall/renderResult 中,使用 theme 参数:

typescript
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)):

类别颜色
通用text、accent、muted、dim、searchMatchText
状态success、error、warning
边框border、borderAccent、borderMuted
消息userMessageText、customMessageText、customMessageLabel
工具toolTitle、toolOutput
差异toolDiffAdded、toolDiffRemoved、toolDiffContext
MarkdownmdHeading、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、searchMatchBg、userMessageBg、customMessageBg、toolPendingBg、toolSuccessBg、toolErrorBg

对于 Markdown,使用 getMarkdownTheme():

typescript
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);
}

对于自定义组件,定义你自己的主题接口:

typescript
interface MyTheme {
  selected: (s: string) => string;
  normal: (s: string) => string;
}

调试日志 ​

设置 PI_TUI_WRITE_LOG 以捕获写入 stdout 的原始 ANSI 流。

bash
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts

性能 ​

尽可能缓存渲染输出:

typescript
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() 等将主题颜色预先烘焙进字符串并缓存它们,那么缓存的字符串会包含旧主题的 ANSI 转义码。如果组件将主题化内容单独存储,仅清除渲染缓存是不够的。

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

typescript
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() 时重建该内容:

typescript
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(); // 使用新主题重建
  }
}

模式:在失效时重建 ​

对于内容复杂的组件:

typescript
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) ​

用于让用户从选项列表中选择。使用 @earendil-works/pi-tui 中的 SelectList,并用 DynamicBorder 添加边框。

typescript
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.ts、tools.ts

模式 2:可取消的异步操作(BorderedLoader) ​

用于耗时且应可取消的操作。BorderedLoader 显示一个加载动画,并处理 escape 键以取消。

typescript
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.ts、handoff.ts

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

用于切换多个设置。使用 @earendil-works/pi-tui 中的 SettingsList,配合 getSettingsListTheme()。

typescript
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

模式 4:持久状态指示器 ​

在页脚中显示跨渲染持续存在的状态。适合模式指示器。

typescript
// 设置状态(显示在页脚)
ctx.ui.setStatus('my-ext', ctx.ui.theme.fg('accent', '● active'));

// 清除状态
ctx.ui.setStatus('my-ext', undefined);

示例: status-line.ts、plan-mode/index.ts、preset.ts

模式 4b:工作指示器自定义 ​

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

typescript
// 静态指示器
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:编辑器上方/下方的组件(Widgets) ​

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

typescript
// 简单字符串数组(默认在编辑器上方)
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

模式 6:自定义页脚 ​

替换页脚。footerData 暴露了扩展无法以其他方式访问的数据。

typescript
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); // 恢复默认

Token 统计可通过 ctx.sessionManager.getBranch() 和 ctx.model 获得。

示例: custom-footer.ts

模式 7:自定义编辑器(vim 模式等) ​

用自定义实现替换主输入编辑器。适用于模态编辑(vim)、不同的按键绑定(emacs)或专门的输入处理。

typescript
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:切换到普通模式,或传递出去交给应用处理
    if (matchesKey(data, 'escape')) {
      if (this.mode === 'insert') {
        this.mode = 'normal';
        return;
      }
      // 在普通模式下,escape 会中止智能体(由 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),以获得应用的按键绑定(escape 中止、ctrl+d 退出、模型切换等)
  • 对你未处理的按键调用 super.handleInput(data)
  • 状态加载动画:自定义编辑器默认保留独立的状态行。传入 { embedWorkingStatus: true } 作为 CustomEditor 的第四个构造参数,改为将工作、压缩、分支摘要和重试的加载动画嵌入编辑器边框
  • 工厂模式:setEditorComponent 接收一个工厂函数,该函数获取 tui、theme 和 keybindings
  • 传入 undefined 以恢复默认编辑器:ctx.ui.setEditorComponent(undefined)

示例: modal-editor.ts

关键规则 ​

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

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

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

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

  5. 使用现有组件 - SelectList、SettingsList、BorderedLoader 覆盖了 90% 的情况。不要重建它们。

示例 ​