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 協議)。預設值:false
invalidate() 清除快取的渲染狀態。主題變化時會呼叫。

TUI 會在每個渲染行末尾附加完整的 SGR reset 和 OSC 8 reset。樣式不會跨行延續。如果輸出帶樣式的多行文字,請為每行重新應用樣式,或使用 wrapTextWithAnsi() 保留換行後的樣式。

Focusable 介面(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 fallback 到另一個可見的捕獲浮層或前一個焦點目標時,使用 handle.unfocus()。如果需要在浮層保持可見時讓特定元件接收輸入,使用 handle.unfocus({ target })。有意傳入 { target: null } 時,在再次設定焦點前不會有任何元件獲得焦點。

浮層生命週期

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

容器

垂直分組子元件。

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

圖片

在支援的終端機(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, searchMatchText
地位 success, error, warning
邊框 border, borderAccent, borderMuted
留言 userMessageText, customMessageText, customMessageLabel
工具 toolTitle, toolOutput
diff toolDiffAdded, toolDiffRemoved, toolDiffContext
Markdown mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
句法 syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
thinking thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax
模式 bashMode

背景顏色 (theme.bg(color, text)):

selectedBg, searchMatchBg, 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 中的 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.tstools.ts

模式 2:帶取消的async操作 (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();

這只影響普通的串流工作指示器。壓縮和重試 loader 會保留內建樣式。自訂幀會按原樣渲染,因此擴充必須在需要時自行新增顏色。

範例: 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% 的場景。不要重新實作它們。

範例