pi-tui-design について
Pi と pi-tui 向けのターミナル UI 設計スキルです。レイアウト、テーマ対応の配色、キーボード操作、コンポーネントの実装に加え、全画面表示やオーバーレイなどの選択方法を扱います。
主な機能
- 01ターミナルのレイアウトと文字設計
- 02テーマトークンによる配色指針
- 03キーボード操作の規約
- 04コンポーネントの描画とキャッシュ
- 05Pi 拡張の UI 表示方法
活用シーン
- 01Pi のターミナルダッシュボード設計
- 02キーボード操作型ダイアログの構築
- 03ステータスや進捗ウィジェットの描画
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ joelhooks/ pi-tools --skill pi-tui-designインストール後、Claude Code で/pi-tui-designを入力して読み込まれたか確認します。
同梱ファイルreferences/examples.md
詳細
Pi の TUI デザイン
生成されたような見た目ではなく、丁寧に作り込まれた印象のターミナルインターフェースを構築しましょう。ターミナルには、固定幅の文字グリッド、キーボードのみの操作、テーマに依存する色という制約があります。そして、その制約こそがデザイン上の強みです。
デザインの考え方
コーディングを始める前に、方向性を決めましょう。
- トーン:ミニマルで精密にしますか?情報量を重視して高密度にしますか?遊び心を加えますか?インダストリアルな雰囲気にしますか?ターミナルには独自の美的表現があります。罫線文字の優雅さ、点字パターンの密度、ブロック要素の重厚さ、記号の明快さなどです。
- 範囲:画面全体を置き換えますか(
ctx.ui.custom)?フローティングオーバーレイにしますか?常時表示のウィジェットにしますか?ステータス行にしますか?ツールのレンダリングにしますか?操作の重要度に合わせて表示方法を選びましょう。 - 差別化:どんな工夫で意図的に作られた印象を与えますか?点字文字で細かく表現したプログレスバー。アクセント付きの見出しで整列した列。ゆとりを持たせたダイアログなどです。
ターミナルの美的表現
Unicode のレパートリー — 文字表現
ターミナル UI ではフォントを選べません。その代わり、文字のレパートリーが文字表現になります。
| カテゴリ | 文字 | 用途 |
|---|---|---|
| 罫線文字(細線) | ─│┌┐└┘├┤┬┴┼ | 標準的な枠線、テーブル |
| 罫線文字(角丸) | ╭╮╰╯ | 柔らかくモダンな印象 |
| 罫線文字(太線) | ━┃┏┓┗┛┣┫┳┻╋ | 強調、見出し |
| 罫線文字(二重線) | ═║╔╗╚╝╠╣╦╩╬ | フォーマルで構造的な印象 |
| ブロック要素 | █▓▒░▀▄▌▐ | プログレスバー、密度、塗りつぶし |
| 点字 | ⠀⠁⠂⠃...⣿ | 高解像度パターン、スパークライン、チャート |
| 記号 | ◆●○◉◎✓✗▸▹▶▷△▽★☆♦ | ステータス表示、箇条書き、選択 |
| 数学記号/矢印 | →←↑↓↔↕⇒⟶⟵∙⋯ | ナビゲーションのヒント、フロー |
| Powerline | ░ | セグメントの区切り(ターミナルによって異なります) |
文字の太さで階層を表現する:█(太い)→ ▓(中程度)→ ▒(細い)→ ░(控えめ)→ (空)。単なる塗りつぶしだけでなく、視覚的な密度のグラデーションに使いましょう。
縦横比:ターミナルのセルは約 2:1(幅の 2 倍の高さ)です。██(ブロック文字 2 つ)は、およそ正方形に見えます。空間的なレイアウトではこれを考慮してください。snake.ts の例で cellWidth = 2 としているのも、このためです。
色の使い方
常に pi のテーマトークンを使ってください。 ANSI エスケープをハードコードすると、ユーザーがテーマを切り替えたときに表示が崩れます。
// ✗ Hardcoded — breaks on theme change
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
// ✓ Theme-aware — adapts to dark/light, custom themes
const header = theme.fg("accent", theme.bold("Title"));
const status = theme.fg("success", "✓ OK");
const muted = theme.fg("muted", "secondary info");
色の階層(51 個のトークンで構成されるテーマシステム):
accent— 主な注目箇所、選択、アクティブな要素text— 本文の標準色(通常は""= ターミナルのデフォルト)muted— 補助的で、目立たせない要素dim— 第三階層の、かろうじて見える要素success/error/warning— 意味を持つステータスborder/borderAccent/borderMuted— 構造要素toolTitle— ツール風のコンテキストで使う見出し
領域の背景色:selectedBg、userMessageBg、customMessageBg、toolPendingBg、toolSuccessBg、toolErrorBg。
ルール:アクセントカラーは 1 色を中心に使います。それ以外の要素には muted/dim を使いましょう。色を使いすぎると階層が平坦になります。緑一色で埋め尽くすより、色をまったく使わないほうがよいでしょう。
空間構成
width パラメーターがキャンバスの端になります。render() の各行は、この幅を超えてはいけません。
パディングのリズム:水平方向のパディングを統一すると、視覚的にゆとりが生まれます。paddingX=1(左右にスペース 1 つずつ)が基本です。見出しには paddingX=2 が適している場合もあります。窮屈な UI は使いにくく感じられます。
配置:数値は右揃え、ラベルは左揃えにします。visibleWidth() を使って ANSI を考慮した列幅を計算しましょう。タブではなくスペースで埋めてください。
余白:セクション間に空の Spacer(1) を置くほうが、区切り線を引くより効果的です。コンテンツにゆとりを持たせましょう。
ボックスのネスト:Container > Box > [children] を使うと、パディング、背景、縦方向の積み重ねを実現できます。すべてを 1 つの render 関数にまとめず、コンポーネントを組み合わせましょう。
モーションとアニメーション
CSS トランジションはありません。setInterval と tui.requestRender() によるタイマー更新を使います。
// Spinner pattern (see Loader component)
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
this.interval = setInterval(() => {
this.frame = (this.frame + 1) % frames.length;
this.invalidate();
tui.requestRender();
}, 80);
パターン:点字スピナー、ブロック要素のプログレスバー、段階的なリスト表示(遅延を入れて項目を1つずつ描画)、タイプライター風テキスト、点滅するインジケーター。
クリーンアップは必須です:dispose() でインターバルをクリアします。タイマーが残っていると、コンポーネントの削除後も描画が行われます。
キーボード操作の設計
入力方法はキーボードのみです。操作方法を見つけやすく、統一感のある設計にしてください。
標準的な規則(ユーザーが期待する操作):
↑↓またはj/k— リストを移動Enter— 確定/選択Escape— キャンセル/戻るTab— 次のフィールド/または入力を開始 — 検索/フィルター
常にヒントを表示してください:使用可能なキーをフッター行に表示します。テーマに対応した書式設定には keyHint() を使います。
const help = theme.fg("dim", "↑↓ navigate • enter select • esc cancel");
pi-tui の matchesKey() を使います — ターミナルごとのエスケープシーケンスの違いに対応します。
import { matchesKey, Key } from "@mariozechner/pi-tui";
handleInput(data: string) {
if (matchesKey(data, Key.up)) { /* ... */ }
else if (matchesKey(data, Key.enter)) { /* ... */ }
else if (matchesKey(data, Key.escape)) { /* ... */ }
else if (matchesKey(data, Key.ctrl("c"))) { /* ... */ }
}
コンポーネントの契約
すべての pi-tui コンポーネントは、次の要件を満たします。
interface Component {
render(width: number): string[]; // Lines of output, each ≤ width
handleInput?(data: string): void; // Keyboard input when focused
wantsKeyRelease?: boolean; // Kitty protocol key release events
invalidate(): void; // Clear cached render state
}
重要なルール:
render()の各行はwidthを超えてはいけません —truncateToWidth()を使います- 状態が変化したら
invalidate()を呼び出し、続けてtui.requestRender()を呼び出して再描画を行います - 描画結果をキャッシュします(
cachedLines/cachedWidth)— 状態または幅が変化した場合にのみ再計算します - 行ごとにスタイルを適用し直します — ANSI のリセットは行の境界で行われます
キャッシュのパターン
private cachedWidth?: number;
private cachedLines?: string[];
private version = 0;
private cachedVersion = -1;
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width && this.cachedVersion === this.version) {
return this.cachedLines;
}
// ... compute lines ...
this.cachedWidth = width;
this.cachedLines = lines;
this.cachedVersion = this.version;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
状態が変化したら this.version をインクリメントします。テーマの変更時には、自動的に invalidate() が呼び出されます。
テーマの無効化
テーマカラーを子コンポーネントに事前に適用する場合は、invalidate() 時に再構築します。
override invalidate(): void {
super.invalidate(); // Clear child render caches
this.rebuildContent(); // Re-apply current theme colors
}
利用可能なコンポーネント
@mariozechner/pi-tui のコンポーネント
| コンポーネント | 用途 | 主な API |
|---|---|---|
Text | 複数行のワードラップされたテキスト | new Text(content, paddingX, paddingY, bgFn?)、.setText() |
TruncatedText | 1行の省略表示テキスト | new TruncatedText(text, paddingX, paddingY) |
Box | 背景付きのパディングされたコンテナー | new Box(paddingX, paddingY, bgFn)、.addChild()、.setBgFn() |
Container | 子要素を縦に並べるスタック | .addChild()、.removeChild()、.clear() |
Spacer | 空の垂直スペース | new Spacer(lines) |
Markdown | シンタックスハイライト付きの Markdown 表示 | new Markdown(content, paddingX, paddingY, mdTheme) |
Image | ターミナル画像(Kitty/iTerm2/Ghostty/WezTerm) | new Image(base64, mimeType, theme, options) |
SelectList | フィルター/スクロール機能付きの対話型リスト | items、maxVisible、theme;.onSelect、.onCancel |
SettingsList | 値を設定できるトグル式設定リスト | items、maxVisible、theme、onChange、onClose |
Loader | メッセージ付きの点字スピナー | new Loader(tui, spinnerColor, messageColor, message) |
CancellableLoader | Escape キーでキャンセルできるローダー | 中断シグナル付きの Loader ラッパー |
Input | 1行のテキスト入力 | IME のカーソル位置指定用に Focusable を実装 |
Editor | 複数行のテキストエディター | 取り消し、キルリング、キー割り当てに対応した完全な編集機能 |
@mariozechner/pi-coding-agent のコンポーネント
| コンポーネント | 用途 | 主な API |
|---|---|---|
DynamicBorder | 幅に応じて変化する水平ボーダー | new DynamicBorder((s: string) => theme.fg("accent", s)) |
BorderedLoader | ボーダーとキャンセル機能付きのローダー | new BorderedLoader(tui, theme, message)、.signal、.onAbort |
CustomEditor | アプリのキー割り当てを組み込んだエディター | モーダル編集(vim)やカスタムショートカットに対応するには拡張します |
ユーティリティ
import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
import { matchesKey, Key } from "@mariozechner/pi-tui";
import { DynamicBorder, getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
visibleWidth(str)— ANSI エスケープコードを無視した表示幅truncateToWidth(str, width, ellipsis?)— 必要に応じて省略記号を付けて切り詰めますwrapTextWithAnsi(str, width)— ANSI コードを保持してワードラップしますmatchesKey(data, key)— キーボード入力とキー識別子を比較しますgetMarkdownTheme()—Markdownコンポーネント用のテーマオブジェクトkeyHint(action, description)— テーマに対応したキー割り当てのヒントテキスト
提供先
操作に適した提供先を選びます。
全画面表示 — ctx.ui.custom(component)
ダッシュボード、ゲーム、複数ステップのウィザードなど、複雑なインタラクションに適しています。
await ctx.ui.custom((tui, theme, keybindings, done) => {
return new MyComponent(tui, theme, () => done(result));
});
オーバーレイ — ctx.ui.custom(factory, { overlay: true })
既存のコンテンツの上に重ねて表示します。簡単な選択、確認、パネルに適しています。
const result = await ctx.ui.custom<string | null>(
(tui, theme, kb, done) => new MyDialog(theme, done),
{
overlay: true,
overlayOptions: {
anchor: "center", // 9 positions: center, top-left, top-center, etc.
width: "50%", // number or percentage string
minWidth: 40,
maxHeight: "80%",
offsetX: -2, offsetY: 0,
margin: 2, // or { top, right, bottom, left }
visible: (w, h) => w >= 80, // responsive hide
},
}
);
ウィジェット — ctx.ui.setWidget(id, lines|factory, options?)
エディターの上または下に常時表示します。ステータス、進行状況、リストに適しています。
ctx.ui.setWidget("my-widget", (tui, theme) => ({
render: () => [theme.fg("accent", "● Active") + " — 3 items pending"],
invalidate: () => {},
}));
// placement: "belowEditor" for below
// Clear: ctx.ui.setWidget("my-widget", undefined)
ステータス行 — ctx.ui.setStatus(id, content)
フッターに常時表示する1行のインジケーターです。
ctx.ui.setStatus("mode", theme.fg("accent", "● DESIGN"));
// Clear: ctx.ui.setStatus("mode", undefined)
ツールのレンダリング — renderCall/renderResult
会話内のツール呼び出しをカスタム表示します。(0, 0) のパディングを指定した Text コンポーネントを返してください。パディングは外側の Box が処理します。
renderCall(args, theme) {
return new Text(theme.fg("toolTitle", theme.bold("my_tool ")) + theme.fg("muted", args.action), 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
let text = theme.fg("success", "✓ Done");
if (expanded) text += "\n" + theme.fg("dim", JSON.stringify(result.details, null, 2));
return new Text(text, 0, 0);
}
フッター — ctx.ui.setFooter(factory)
フッターバー全体を置き換えます。Git ブランチ、拡張機能のステータス、トークン統計にアクセスできます。
ctx.ui.setFooter((tui, theme, footerData) => ({
render: (width) => [truncateToWidth(`${model} (${footerData.getGitBranch() || "no git"})`, width)],
invalidate: () => {},
dispose: footerData.onBranchChange(() => tui.requestRender()),
}));
TUI のアンチパターン
ターミナル UI における「AI スロップ」に相当するもの:
| 避けること | 推奨する方法 |
|---|---|
ハードコードされた ANSI カラー(\x1b[31m) | テーマトークン(theme.fg("error", ...)) |
width パラメーターを超える行 | すべての行に truncateToWidth() を適用 |
invalidate() がなく、キャッシュされた描画が古くなる | 状態変更時にキャッシュを完全にクリア |
| セルの縦横比(2:1)を無視 | 「正方形」の要素には全角文字を使用 |
| 構造のないテキストの羅列 | Spacer、境界線、配置を使ったセクション |
| キーボード操作のヒントがない | 使用可能なキーを示すフッター行 |
| インターバルやタイマーの解放漏れ | クリーンアップを行う dispose() |
| 再構築せずにテーマカラーを事前に固定 | テーマ付きコンテンツを再構築するよう invalidate() をオーバーライド |
| 合成のない単一のレンダー関数 | Container → Box → 子要素の階層 |
| すべてのコンポーネントに同じ視覚表現を適用 | コンポーネントごとに意図的なデザインの方向性を設定 |
| すべての読み込み状態で汎用スピナーを使用 | 状況に応じた進行状況表示(バー、パーセンテージ、ステップ数) |
| 固定幅のレイアウト | width パラメーターに応じたレスポンシブ対応と minWidth ガード |
拡張機能のアーキテクチャパターン
Component + Focusable を使ったオーバーレイコンポーネント
Nico のオーバーレイでは、常にライフサイクルを明示的にモデル化します。
import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
import type { Theme } from "@mariozechner/pi-coding-agent";
import { matchesKey } from "@mariozechner/pi-tui";
type Done = (result: string | null) => void;
class OverlayTemplate implements Component, Focusable {
focused = false;
constructor(
private tui: TUI,
private theme: Theme,
private done: Done,
) {}
handleInput(data: string): void {
if (matchesKey(data, "escape")) this.done(null);
if (matchesKey(data, "return")) this.done("selected");
}
render(_width: number): string[] {
return [];
}
invalidate(): void {}
dispose(): void {}
}
await ctx.ui.custom((tui, theme, _kb, done) => {
return new OverlayTemplate(tui, theme, (result) => done(result));
}, { overlay: true, overlayOptions: { anchor: "center", width: 64 } });
その構成は、特に InteractiveShellOverlay と MessengerConfigOverlay で明確です。
- コンストラクターは
constructor(tui, theme, done)です。 handleInputはコマンドキーと移動キーを処理します。render(width)はフレーム全体のテキストを構築します。invalidateはレンダーキャッシュをクリアします。disposeはインターバルやタイマーを削除し、コールバックの登録を解除します。
共有レンダーヘルパー
再利用可能なヘルパー形式のレンダリングにより、オーバーレイ間の視覚的な差異を抑えられます。
import type { Theme } from "@mariozechner/pi-coding-agent";
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
export function pad(s: string, len: number): string {
const vis = visibleWidth(s);
return s + " ".repeat(Math.max(0, len - vis));
}
export function row(content: string, width: number, theme: Theme): string {
const innerW = width - 2;
return theme.fg("border", "│") + pad(" " + truncateToWidth(content, innerW - 1), innerW) + theme.fg("border", "│");
}
export function renderHeader(text: string, width: number, theme: Theme): string {
const innerW = width - 2;
const padLen = Math.max(0, innerW - visibleWidth(text));
const padLeft = Math.floor(padLen / 2);
const padRight = padLen - padLeft;
return (
theme.fg("border", "╭" + "─".repeat(padLeft)) +
theme.fg("accent", text) +
theme.fg("border", "─".repeat(padRight) + "╮")
);
}
export function renderFooter(text: string, width: number, theme: Theme): string {
const innerW = width - 2;
const padLen = Math.max(0, innerW - visibleWidth(text));
const padLeft = Math.floor(padLen / 2);
const padRight = padLen - padLeft;
return (
theme.fg("border", "╰" + "─".repeat(padLeft)) +
theme.fg("dim", text) +
theme.fg("border", "─".repeat(padRight) + "╯")
);
}
pi-subagents の共有ヘルパーファイル(render-helpers.ts)には、この目的に特化した fuzzyFilter()、formatPath()、formatScrollInfo() が含まれています。
フッターの置き換え
ctx.ui.setFooter() は、ステータスバー全体を置き換える Nico の最も優れたパターンです。
ctx.ui.setFooter((tui: any, _theme: Theme, footerData: ReadonlyFooterDataProvider) => {
footerDataRef = footerData;
tuiRef = tui;
const unsub = footerData.onBranchChange(() => tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
const segments: string[] = [];
const ctx = buildSegmentContext(footerData, activeTheme);
const preset = getPreset("default");
for (const id of [...preset.leftSegments, ...preset.rightSegments, ...(preset.secondarySegments ?? [])]) {
const rendered = renderSegment(id, ctx);
if (rendered.visible && rendered.content) segments.push(rendered.content);
}
if (segments.length === 0) return [];
return [" " + segments.join(` ${fg(activeTheme, "separator", "⟩", colors)} `) + " "];
},
};
});
pi-powerline-footer もエディターのレンダリングをオーバーライドしてエディター/フッター行を置き換え、setFooter から [] を返すことで、描画経路を1つに保ちます。
設定の永続化
これらのリポジトリでは、拡張機能の永続化に次のパターンを採用しています。
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
interface ExtensionSettings {
theme: "dark" | "light";
enabled: boolean;
}
const extensionDir = join(homedir(), ".pi", "agent", "extensions", "your-extension");
const settingsPath = join(extensionDir, "settings.json");
const extensionDefaults: ExtensionSettings = { theme: "dark", enabled: true };
function loadSettings(): ExtensionSettings {
if (!existsSync(settingsPath)) return extensionDefaults;
const raw = readFileSync(settingsPath, "utf-8");
return { ...extensionDefaults, ...JSON.parse(raw) };
}
function saveSettings(next: Partial<ExtensionSettings>): void {
mkdirSync(extensionDir, { recursive: true });
writeFileSync(settingsPath, JSON.stringify({ ...loadSettings(), ...next }, null, 2));
}
pi-messenger や pi-subagent と同様に、まずデフォルト値を適用してマージし、その後、変更が確定するタイミング(オーバーレイでの escape/q、設定コマンドなど)に限って保存します。
ハンズフリー/バックグラウンドモード
InteractiveShellOverlay は、バックグラウンドパターンを示しています。
if (options.mode === "hands-free" || options.mode === "dispatch") {
this.state = "hands-free";
this.sessionId = options.sessionId ?? generateSessionId(options.name);
sessionManager.registerActive({
id: this.sessionId,
command: options.command,
reason: options.reason,
write: (data) => this.session.write(data),
kill: () => this.killSession(),
background: () => this.backgroundSession(),
getOutput: (opts) => this.getOutputSinceLastCheck(opts),
onComplete: (cb) => this.registerCompleteCallback(cb),
setUpdateInterval: (ms) => this.setUpdateInterval(ms),
setQuietThreshold: (ms) => this.setQuietThreshold(ms),
});
}
if (this.options.onHandsFreeUpdate) {
this.options.onHandsFreeUpdate({ status: "running", sessionId: this.sessionId, runtime: 0, tail: [], tailTruncated: false, totalCharsSent: 0, budgetExhausted: false });
}
ハンズフリーモードでユーザー入力が届くと、Nico はテイクオーバー処理(Ctrl+T、Ctrl+B、または入力されたキー)を呼び出します。この処理では、インターバル更新を停止してテイクオーバー状態にし、必要に応じて入力のパススルーを続ける前にセッションの所有権登録を解除します。
コンポーネントの合成パターン
ダイアログパターン
角丸の境界線とキーボード操作のヒントを備えた標準的なダイアログ構成です。
class DialogComponent implements Component, Focusable {
focused = false;
constructor(private tui: any, private theme: Theme, private done: () => void) {}
render(width: number): string[] {
const inner = Math.max(20, width - 2);
const lines = [
`╭${"─".repeat(Math.max(0, inner - 20))} Dialog Header ${"─".repeat(Math.max(0, inner - 20))}╮`,
`│ Content line 1... │`,
`├${"─".repeat(inner)}┤`,
`│ Press \u2191\u2193 navigate • enter confirm • esc cancel │`,
`╰${"─".repeat(inner)}╯`,
];
return lines.map((line) => truncateToWidth(line, width));
}
handleInput(data: string) {
if (matchesKey(data, "escape")) this.done();
}
invalidate() {}
dispose() {}
}
確認、キューからの削除プロンプト、パレットのアクションには、まったく同じ合成を使用してください。
あいまい検索によるリストのフィルタリング
Nico 形式のリストフィルタリングでは、以下を一貫して行います。
- 入力された文字ごとにフィルタリングする
- フィルターの変更時に選択位置を先頭に戻す
selectedIndexとscrollOffsetを維持する- コンパクトな進捗インジケーターでスクロール状態と選択状態を表示する
class FuzzyListComponent implements Component, Focusable {
focused = false;
private query = "";
private selectedIndex = 0;
private scrollOffset = 0;
private maxVisible = 8;
private filtered = this.items;
constructor(private items: Array<{ name: string; description: string; model?: string }>) {}
handleInput(data: string): void {
if (matchesKey(data, "up") && this.selectedIndex > 0) this.selectedIndex--;
else if (matchesKey(data, "down") && this.selectedIndex < this.filtered.length - 1) this.selectedIndex++;
else if (data.length === 1 && data.charCodeAt(0) >= 32) this.query += data;
else if (matchesKey(data, "backspace")) this.query = this.query.slice(0, -1);
this.filtered = fuzzyFilter(this.items, this.query);
this.selectedIndex = 0;
const maxStart = Math.max(0, this.filtered.length - this.maxVisible);
this.scrollOffset = Math.min(this.scrollOffset, maxStart);
}
render(width: number): string[] {
const lines = [];
const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible));
const end = Math.min(start + this.maxVisible, this.filtered.length);
for (let i = start; i < end; i++) {
const item = this.filtered[i];
const selected = i === this.selectedIndex ? "▸" : "·";
lines.push(`${selected} ${item.name} ${item.description ?? ""}`);
}
if (this.filtered.length > this.maxVisible) {
lines.push(`${this.selectedIndex + 1}/${this.filtered.length}`);
}
return lines;
}
invalidate() {}
dispose() {}
}
セグメントベースのレンダリング
Powerline 形式のステータスバーは、独立したセグメントオブジェクトとして扱うと簡単です。
interface RenderedSegment {
content: string;
visible: boolean;
}
interface StatusLineSegment {
id: string;
render(ctx: any): RenderedSegment;
}
const SEGMENTS: Record<string, StatusLineSegment> = {
model: {
id: "model",
render(ctx) {
if (!ctx.model?.name) return { content: "", visible: false };
return { content: `🤖 ${ctx.model.name}`, visible: true };
},
},
git: {
id: "git",
render(ctx) {
if (!ctx.git?.branch) return { content: "", visible: false };
return { content: ` ${ctx.git.branch}`, visible: true };
},
},
};
function renderSegment(id: string, ctx: any): RenderedSegment {
return SEGMENTS[id]?.render(ctx) ?? { content: "", visible: false };
}
各セグメントを個別にレンダリングしてから、意味に応じた区切り文字で結合します。各セグメントは独自に表示するかどうかを決定します(空の場合は visible: false)。
コピーして使える例
完全で自己完結したコンポーネントの実装については、references/examples.md を参照してください。
- 選択ダイアログ — SelectList + DynamicBorder + キーボードのヒント
- ステータスダッシュボード — 複数セクションの罫線レイアウト、列の整列、意味に応じた色
- 進捗トラッカー — アニメーション付き点字バー、タイマーのライフサイクル、破棄時のクリーンアップ
- データテーブル — スクロール可能な行、列の整列、行のハイライト表示
- 永続ウィジェット — エディター上部のヘルスインジケーター、コンパクトな単一行表示
- ツールレンダラー — renderCall/renderResult と展開可能な詳細情報
- オーバーレイパネル — サイドパネル、レスポンシブな表示、アンカー位置の指定
上流のサンプル
pi リポジトリの拡張機能のサンプルでは、さらに多くのパターンを確認できます。
- snake.ts:完全なゲームループ、罫線、
pi.appendEntry()によるセッションの永続化 - space-invaders.ts:Kitty のキー解放イベント(
wantsKeyRelease)、複数エンティティのレンダリング - overlay-qa-tests.ts:9 つすべてのアンカー位置、レスポンシブな表示、約 30 FPS のアニメーション
- preset.ts:DynamicBorder を使用した SelectList — 標準的なダイアログのパターン
- plan-mode:永続的なモードインジケーター向けの setStatus + setWidget
- todo.ts:renderCall/renderResult を使ったカスタムツールのレンダリング