Pi 的配置、扩展、平台设置和 API 参考。

TUI 组件

pi 可以创建 TUI 个组件。要求它为您的用例构建一个。

Extensions 和自定义工具可以为交互式用户界面渲染自定义 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 协议)。默认值:假。
invalidate() 清除缓存的渲染状态。呼吁改变主题。

TUI 在每个渲染行的末尾附加完整的 SGR 重置和 OSC 8 重置。风格不跨界。如果您发出带有样式的多行文本,请重新应用每行样式或使用 wrapTextWithAnsi(),以便为每个换行行保留样式。

可聚焦界面(IME 支持)

显示文本光标并需要 IME(输入法编辑器)支持的组件应实现 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 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),
    })
  );
  // Use result...
}

叠加层

将渲染组件叠加在现有内容之上,而无需清除屏幕。将 { overlay: true } 传递到 ctx.ui.custom()

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 } 不会留下任何焦点组件,直到再次设置焦点。

覆盖生命周期

覆盖组件在关闭时被丢弃。不要重复使用引用 - 创建新实例:

// 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");

盒子

具有填充和背景颜色的容器。

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

容器

垂直分组子组件。

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

垫片

空的垂直空间。

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

图像

在支持的终端(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 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) {
  // 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)):

类别 颜色
一般的 text, accent, muted, dim
地位 success, error, warning
边框 border, borderAccent, borderMuted
留言 userMessageText, customMessageText, customMessageLabel
工具 toolTitle, toolOutput
差异 toolDiffAdded, toolDiffRemoved, toolDiffContext
Markdown mdHeading, 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, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg

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

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(),然​​后使用注入的tui.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
  }
}

模式:无效时重建

对于内容复杂的组件:

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中的SelectListDynamicBorder进行取景。

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.tstools.ts

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

对于需要时间并且应该可以取消的操作。 BorderedLoader 显示一个旋转器并处理转义以取消。

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.tshandoff.ts

模式 3:设置/切换(SettingsList)

用于切换多个设置。将 @earendil-works/pi-tui 中的 SettingsListgetSettingsListTheme() 结合使用。

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.tsplan-mode/index.tspreset.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:编辑器上方/下方的小部件

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

// 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/index.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

可通过 ctx.sessionManager.getBranch()ctx.model 获取代币统计信息。

示例: custom-footer.ts

模式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 {
    // 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 the TUI, theme, and keybindings from the app
    ctx.ui.setEditorComponent((tui, theme, keybindings) =>
      new VimEditor(tui, theme, keybindings)
    );
  });
}

要点:

  • 扩展 CustomEditor (不是基础 Editor)以获取应用程序键绑定(转义以中止、ctrl+d 退出、模型切换等)
  • 对于您不处理的钥匙,请致电 super.handleInput(data)
  • 工厂模式setEditorComponent接收一个获取tuithemekeybindings的工厂函数
  • **通过undefined**恢复默认编辑器:ctx.ui.setEditorComponent(undefined)

示例: modal-editor.ts

关键规则

  1. 始终使用回调中的主题 - 不要直接导入主题。使用 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% 的情况。不要重建它们。

示例