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% 的情況。不要重建它們。

範例