コンテンツにスキップ

TUI コンポーネント

pi は TUI コンポーネントを作成できます。ユースケースに合わせて構築するよう依頼してください。

拡張機能とカスタムツールは、インタラクティブなユーザーインターフェース用にカスタム TUI コンポーネントを描画できます。このページではコンポーネントシステムと利用可能なビルディングブロックを説明します。

ソース: @earendil-works/pi-tui

コンポーネントインターフェース

Section titled “コンポーネントインターフェース”

すべてのコンポーネントは次を実装します:

interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}
メソッド説明
render(width)文字列の配列(1 行 1 要素)を返す。各行は width を超えてはならない
handleInput?(data)コンポーネントにフォーカスがあるときにキーボード入力を受け取る。
wantsKeyRelease?true の場合、キーリリースイベントを受け取る(Kitty プロトコル)。デフォルト:false。
invalidate()キャッシュされた描画状態をクリアする。テーマ変更時に呼ばれる。

TUI は描画された各行の末尾に完全な SGR リセットと OSC 8 リセットを付加します。スタイルは行をまたいで継続しません。スタイル付きの複数行テキストを出力する場合は、行ごとにスタイルを再適用するか、wrapTextWithAnsi() を使って折り返し各行にスタイルを保持してください。

フォーカス可能インターフェース(IME サポート)

Section titled “フォーカス可能インターフェース(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 コンポーネントはすでにこのインターフェースを実装しています。

埋め込み入力を含むコンテナコンポーネント

Section titled “埋め込み入力を含むコンテナコンポーネント”

コンテナコンポーネント(ダイアログ、セレクターなど)が Input または Editor 子を含む場合、コンテナは 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 } を渡すと、意図的にフォーカスコンポーネントをなくし、再度フォーカスが設定されるまでそのままにします。

オーバーレイのライフサイクル

Section titled “オーバーレイのライフサイクル”

オーバーレイコンポーネントは閉じると破棄されます。参照を再利用しないでください。新しいインスタンスを作成します:

// 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 を描画。

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 コードを保持して折り返し

カスタムコンポーネントの作成

Section titled “カスタムコンポーネントの作成”

例:インタラクティブセレクター

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

コンポーネントはスタイリング用のテーマオブジェクトを受け取ります。

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
差分toolDiffAddedtoolDiffRemovedtoolDiffContext
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);
}

カスタムコンポーネントでは 独自のテーマインターフェースを定義:

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

PI_TUI_WRITE_LOG を設定すると、stdout に書き込まれる生の ANSI ストリームをキャプチャできます。

Terminal window
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
}
}

複雑なコンテンツを持つコンポーネント向け:

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)

Section titled “パターン 1:選択ダイアログ(SelectList)”

ユーザーにオプション一覧から選ばせる場合。@earendil-works/pi-tuiSelectList と枠用の 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:キャンセル可能な非同期操作(BorderedLoader)

Section titled “パターン 2:キャンセル可能な非同期操作(BorderedLoader)”

時間がかかりキャンセル可能にすべき操作向け。BorderedLoader はスピナーを表示し、Escape でキャンセルできます。

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)

Section titled “パターン 3:設定/トグル(SettingsList)”

複数設定の切り替え向け。@earendil-works/pi-tuiSettingsListgetSettingsListTheme() を使用。

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:永続ステータスインジケーター

Section titled “パターン 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.tspreset.ts

パターン 4b:作業インジケーターのカスタマイズ

Section titled “パターン 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:エディターの上下ウィジェット

Section titled “パターン 5:エディターの上下ウィジェット”

入力エディターの上または下に永続コンテンツを表示。ToDo リストや進捗に適しています。

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

フッターを置き換え。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 モードなど)

Section titled “パターン 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 theme and keybindings from the app
ctx.ui.setEditorComponent((tui, theme, keybindings) =>
new VimEditor(theme, keybindings)
);
});
}

要点:

  • ベース Editor ではなく CustomEditor を継承 — アプリのキーバインド(Escape で中止、Ctrl+D で終了、モデル切替など)を得る
  • 処理しないキーには super.handleInput(data) を呼ぶ
  • ファクトリパターンsetEditorComponenttuithemekeybindings を受け取るファクトリ関数を受け取る
  • undefined を渡すとデフォルトエディターに戻る:ctx.ui.setEditorComponent(undefined)

例: modal-editor.ts

  1. 常にコールバックの theme を使う — 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. 3 メソッドオブジェクトを返す — カスタムコンポーネントには { render, invalidate, handleInput } が必要。

  5. 既存コンポーネントを使うSelectListSettingsListBorderedLoader で約 90% をカバー。再実装しない。