TUI コンポーネント
pi は TUI コンポーネントを作成できます。ユースケースに合わせて構築するよう依頼してください。
TUI コンポーネント
Section titled “TUI コンポーネント”拡張機能とカスタムツールは、インタラクティブなユーザーインターフェース用にカスタム 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 は次を行います:
- コンポーネントの
focused = trueを設定 - 描画出力から
CURSOR_MARKER(ゼロ幅 APC エスケープシーケンス)を検索 - ハードウェア端末カーソルをその位置に配置
showHardwareCursorが有効なときのみハードウェアカーソルを表示
カーソルはデフォルトで非表示のままです。これにより擬似カーソル描画を維持しつつ、非表示カーソルで IME 候補ウィンドウを追跡する端末向けにハードウェアカーソルを配置できます。一部の端末では IME 位置決めに可視のハードウェアカーソルが必要です。showHardwareCursor、setShowHardwareCursor(true)、または PI_HARDWARE_CURSOR=1 で有効化できます。組み込みの Editor と Input コンポーネントはすでにこのインターフェースを実装しています。
埋め込み入力を含むコンテナコンポーネント
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(中国語、日本語、韓国語など)での入力時に候補ウィンドウが画面上の誤った位置に表示されます。
コンポーネントの使用
Section titled “コンポーネントの使用”拡張機能では 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();}オーバーレイ
Section titled “オーバーレイ”オーバーレイは画面をクリアせず、既存コンテンツの上にコンポーネントを描画します。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 }, });オーバーレイのフォーカス
Section titled “オーバーレイのフォーカス”フォーカスされた可視オーバーレイは、一時的な非オーバーレイ UI 中も入力の所有権を保持します。オーバーレイが { overlay: true } なしで別の ctx.ui.custom() コンポーネントを開くと、その置換 UI がアクティブ中は入力を受け取ります。閉じると、フォーカスされたオーバーレイが入力を取り戻せます。
可視オーバーレイが入力の所有をやめ、TUI が別の可視キャプチャオーバーレイまたは以前のフォーカス先にフォールバックすべきときは handle.unfocus() を使います。オーバーレイを可視のまま特定コンポーネントに入力させるときは handle.unfocus({ target }) を使います。{ target: null } を渡すと、意図的にフォーカスコンポーネントをなくし、再度フォーカスが設定されるまでそのままにします。
オーバーレイのライフサイクル
Section titled “オーバーレイのライフサイクル”オーバーレイコンポーネントは閉じると破棄されます。参照を再利用しないでください。新しいインスタンスを作成します:
// Wrong - stale referencelet menu: MenuComponent;await ctx.ui.custom((_, __, ___, done) => { menu = new MenuComponent(done); return menu;}, { overlay: true });setActiveComponent(menu); // Disposed
// Correct - re-call to re-showconst showMenu = () => ctx.ui.custom((_, __, ___, done) => new MenuComponent(done), { overlay: true });
await showMenu(); // First showawait showMenu(); // "Back" = just call againアンカー、マージン、スタッキング、レスポンシブ可視性、アニメーションを網羅した例は overlay-qa-tests.ts を参照してください。
組み込みコンポーネント
Section titled “組み込みコンポーネント”@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));Container
Section titled “Container”子コンポーネントを縦にグループ化。
const container = new Container();container.addChild(component1);container.addChild(component2);container.removeChild(component1);Spacer
Section titled “Spacer”空の縦方向スペース。
const spacer = new Spacer(2); // 2 empty linesMarkdown
Section titled “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 });キーボード入力
Section titled “キーボード入力”キー検出には 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.enter、Key.escape、Key.tab、Key.space、Key.backspace、Key.delete、Key.home、Key.end - 矢印キー:
Key.up、Key.down、Key.left、Key.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)):
| カテゴリ | 色 |
|---|---|
| 一般 | 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 |
| モード | 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;}デバッグログ
Section titled “デバッグログ”PI_TUI_WRITE_LOG を設定すると、stdout に書き込まれる生の ANSI ストリームをキャプチャできます。
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.tsパフォーマンス
Section titled “パフォーマンス”可能な限り描画出力をキャッシュ:
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() で再描画をトリガーします。
無効化とテーマ変更
Section titled “無効化とテーマ変更”テーマ変更時、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 時に再構築
Section titled “パターン: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(); }}次の場合にこのパターンが必要です:
- テーマ色の事前焼き込み —
theme.fg()やtheme.bg()で子コンポーネントに保存するスタイル文字列を作成 - シンタックスハイライト — テーマベースの構文色を適用する
highlightCode()の使用 - 複雑なレイアウト — テーマ色を埋め込んだ子コンポーネントツリーの構築
次の場合は不要です:
- テーマコールバックの使用 — 描画時に呼ばれる
(text) => theme.fg("accent", text)のような関数を渡す - 単純なコンテナ — テーマ付きコンテンツを追加せず他コンポーネントをグループ化するだけ
- ステートレス描画 — 毎回の
render()でテーマ付き出力を新規計算(キャッシュなし)
よくあるパターン
Section titled “よくあるパターン”これらのパターンは拡張機能で最も一般的な UI ニーズをカバーします。ゼロから作らず、これらのパターンをコピーしてください。
パターン 1:選択ダイアログ(SelectList)
Section titled “パターン 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"); } },});パターン 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.ts、handoff.ts
パターン 3:設定/トグル(SettingsList)
Section titled “パターン 3:設定/トグル(SettingsList)”複数設定の切り替え向け。@earendil-works/pi-tui の SettingsList と getSettingsListTheme() を使用。
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 statusctx.ui.setStatus("my-ext", undefined);例: status-line.ts、plan-mode.ts、preset.ts
パターン 4b:作業インジケーターのカスタマイズ
Section titled “パターン 4b:作業インジケーターのカスタマイズ”pi が応答をストリーミング中に表示するインライン作業インジケーターをカスタマイズ。
// Static indicatorctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
// Custom animated indicatorctx.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 entirelyctx.ui.setWorkingIndicator({ frames: [] });
// Restore pi's default spinnerctx.ui.setWorkingIndicator();これは通常のストリーミング作業インジケーターのみに影響します。コンパクションとリトライローダーは組み込みスタイルを維持します。カスタムフレームはそのまま描画されるため、必要に応じて拡張側で色を付ける必要があります。
パターン 5:エディターの上下ウィジェット
Section titled “パターン 5:エディターの上下ウィジェット”入力エディターの上または下に永続コンテンツを表示。ToDo リストや進捗に適しています。
// Simple string array (above editor by default)ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
// Render below the editorctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
// Or with themectx.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: () => {}, };});
// Clearctx.ui.setWidget("my-widget", undefined);例: plan-mode.ts
パターン 6:カスタムフッター
Section titled “パターン 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 から取得できます。
パターン 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)を呼ぶ - ファクトリパターン:
setEditorComponentはtui、theme、keybindingsを受け取るファクトリ関数を受け取る undefinedを渡すとデフォルトエディターに戻る:ctx.ui.setEditorComponent(undefined)
重要なルール
Section titled “重要なルール”-
常にコールバックの theme を使う — theme を直接インポートしない。
ctx.ui.custom((tui, theme, keybindings, done) => ...)コールバックのthemeを使う。 -
DynamicBorder の色パラメータに常に型を付ける —
(s: string) => theme.fg("accent", s)と書き、(s) => theme.fg("accent", s)とは書かない。 -
状態変更後に tui.requestRender() を呼ぶ —
handleInputで状態を更新した後にtui.requestRender()を呼ぶ。 -
3 メソッドオブジェクトを返す — カスタムコンポーネントには
{ render, invalidate, handleInput }が必要。 -
既存コンポーネントを使う —
SelectList、SettingsList、BorderedLoaderで約 90% をカバー。再実装しない。
- 選択 UI:examples/extensions/preset.ts — DynamicBorder 枠付き SelectList
- キャンセル付き非同期:examples/extensions/qna.ts — LLM 呼び出し用 BorderedLoader
- 設定トグル:examples/extensions/tools.ts — ツール有効/無効用 SettingsList
- ステータス表示:examples/extensions/plan-mode.ts — setStatus と setWidget
- 作業インジケーター:examples/extensions/working-indicator.ts — setWorkingIndicator
- カスタムフッター:examples/extensions/custom-footer.ts — 統計付き setFooter
- カスタムエディター:examples/extensions/modal-editor.ts — Vim 風モーダル編集
- スネークゲーム:examples/extensions/snake.ts — キー入力とゲームループ付きフルゲーム
- カスタムツール描画:examples/extensions/todo.ts — renderCall と renderResult