Pi 官方文档

TUI 组件

pi 可以创建 TUI 组件。让它根据你的用例构建一个。

TUI 组件

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

来源: @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 protocol)。默认值:false。
invalidate()清除缓存的渲染状态。主题变化时会调用。

TUI 会在每一行渲染结果的末尾追加完整的 SGR reset 和 OSC 8 reset。样式不会跨行保留。如果你输出带样式的多行文本,要么按行重新应用样式,要么使用 wrapTextWithAnsi(),这样每个换行后的行都能保留样式。

Focusable 接口(IME 支持)

会显示文本光标、并且需要 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;  // 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 会:

  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 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 } 会刻意让当前没有任何获得焦点的组件,直到再次设置焦点。


Overlay 生命周期

Overlay 组件在关闭后会被销毁。不要复用引用,请创建新的实例:

// Wrong - stale reference
let menu: MenuComponent;
await ctx.ui.custom((_, __, ___, done) => {
  menu = new MenuComponent(done);
  return menu;
}, { overlay: true });
setActiveComponent(menu);  // Disposed

// Correct - re-call to re-show
const showMenu = () => ctx.ui.custom((_, __, ___, done) => 
  new MenuComponent(done), { overlay: true });

await showMenu();  // First show
await 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");

Box

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

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

按垂直方向组织子组件。

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

Spacer

空白的垂直间距。

const spacer = new Spacer(2);  // 2 empty lines

Markdown

渲染带语法高亮的 markdown。

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

Image

在受支持的终端中渲染图片(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.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[] {
  // Truncate long lines
  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 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);
    });
  }
});

主题

组件可接收 theme 对象用于样式设置。

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

类别颜色
通用textaccentmuteddim
状态successerrorwarning
边框borderborderAccentborderMuted
消息userMessageTextcustomMessageTextcustomMessageLabel
工具toolTitletoolOutput
DifftoolDiffAddedtoolDiffRemovedtoolDiffContext
MarkdownmdHeadingmdLinkmdLinkUrlmdCodemdCodeBlockmdCodeBlockBordermdQuotemdQuoteBordermdHrmdListBullet
语法syntaxCommentsyntaxKeywordsyntaxFunctionsyntaxVariablesyntaxStringsyntaxNumbersyntaxTypesyntaxOperatorsyntaxPunctuation
思考thinkingOffthinkingMinimalthinkingLowthinkingMediumthinkingHighthinkingXhigh
模式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);
}

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

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() 触发重新渲染。

失效与主题变更

当主题变更时,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 时重建

对于内容比较复杂的组件:

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

这个机制为什么重要

下面这些场景需要这个模式:

  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 作为边框。

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");
    }
  },
});

示例: preset.ts, tools.ts

模式 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)

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

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:持久状态指示器

在页脚显示会跨渲染保留的状态。适合用来做模式指示器。

// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));

// Clear status
ctx.ui.setStatus("my-ext", undefined);

示例: status-line.ts, plan-mode.ts, preset.ts

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

自定义 pi 流式输出回复时显示的行内工作中指示器。

// Static indicator
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });

// Custom animated indicator
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,
});

// Hide the indicator entirely
ctx.ui.setWorkingIndicator({ frames: [] });

// Restore pi's default spinner
ctx.ui.setWorkingIndicator();

这只会影响普通流式输出时的工作中指示器。上下文压缩和重试的加载器会保留它们内置的样式。自定义帧会按原样渲染,所以扩展在需要时必须自己加颜色。

示例: working-indicator.ts

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

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

// Simple string array (above editor by default)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);

// Render below the editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });

// Or with theme
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: () => {},
  };
});

// Clear
ctx.ui.setWidget("my-widget", undefined);

示例: plan-mode.ts

模式 6:自定义页脚

替换页脚。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()), // reactive
}));

ctx.ui.setFooter(undefined); // restore default

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

示例: custom-footer.ts

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

用自定义实现替换主输入编辑器。适合模态编辑(vim)、不同的 keybindings(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),这样才能拿到应用级 keybindings(Escape 取消、Ctrl+D 退出、切换模型等)
  • 对你没处理的按键,调用 super.handleInput(data)
  • 工厂模式setEditorComponent 接收一个工厂函数,函数会拿到 tuithemekeybindings
  • 传入 undefined 可恢复默认编辑器:ctx.ui.setEditorComponent(undefined)

示例: modal-editor.ts


关键规则

  1. 始终使用回调里的 theme - 不要直接 import 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% 的场景。不要重复造轮子。

示例

Pi 官方文档中文整理 · 机器初译,待人工校对

本文基于官方 MIT 文档翻译整理,不代表 pi.dev 官方中文站。同步 commit:8b97e75c,同步时间:2026/6/20

查看官方原文