mcpField
P
pi-tui-design

pi-tui-design

github.com/joelhooksデザイン・UI8567 回インストール1 週間前に更新
リポジトリ

pi-tui-design について

Pi と pi-tui 向けのターミナル UI 設計スキルです。レイアウト、テーマ対応の配色、キーボード操作、コンポーネントの実装に加え、全画面表示やオーバーレイなどの選択方法を扱います。

主な機能

  1. 01ターミナルのレイアウトと文字設計
  2. 02テーマトークンによる配色指針
  3. 03キーボード操作の規約
  4. 04コンポーネントの描画とキャッシュ
  5. 05Pi 拡張の UI 表示方法

活用シーン

  1. 01Pi のターミナルダッシュボード設計
  2. 02キーボード操作型ダイアログの構築
  3. 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
}

重要なルール:

  1. render() の各行は width を超えてはいけません — truncateToWidth() を使います
  2. 状態が変化したら invalidate() を呼び出し、続けて tui.requestRender() を呼び出して再描画を行います
  3. 描画結果をキャッシュします(cachedLines/cachedWidth)— 状態または幅が変化した場合にのみ再計算します
  4. 行ごとにスタイルを適用し直します — 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()
TruncatedText1行の省略表示テキスト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)
CancellableLoaderEscape キーでキャンセルできるローダー中断シグナル付きの Loader ラッパー
Input1行のテキスト入力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 を使ったカスタムツールのレンダリング

コメント