Konfigurasi, kustomisasi, pengaturan platform, dan referensi API untuk Pi.

TUI Komponen

pi dapat membuat komponen TUI. Mintalah untuk membuat satu untuk kasus penggunaan Anda.

Extensions dan alat khusus dapat merender komponen TUI khusus untuk antarmuka pengguna interaktif. Halaman ini mencakup sistem komponen dan blok penyusun yang tersedia.

Sumber: @earendil-works/pi-tui

Antarmuka Komponen

Semua komponen menerapkan:

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  wantsKeyRelease?: boolean;
  invalidate(): void;
}
Metode Keterangan
render(width) Kembalikan array string (satu per baris). Setiap baris tidak boleh melebihi width.
handleInput?(data) Menerima input keyboard saat komponen memiliki fokus.
wantsKeyRelease? Jika benar, komponen menerima peristiwa rilis penting (protokol Kitty). Bawaan: salah.
invalidate() Hapus status render yang di-cache. Dipanggil pada perubahan tema.

TUI menambahkan reset SGR penuh dan reset OSC 8 di akhir setiap baris yang dirender. Gaya tidak bersifat lintas batas. Jika Anda memancarkan teks multi-baris dengan gaya, terapkan kembali gaya per baris atau gunakan wrapTextWithAnsi() sehingga gaya dipertahankan untuk setiap baris yang dibungkus.

Antarmuka yang Dapat Difokuskan (Dukungan IME)

Komponen yang menampilkan kursor teks dan memerlukan dukungan IME (Input Method Editor) harus mengimplementasikan antarmuka 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}`];
  }
}

Ketika komponen Focusable memiliki fokus, TUI:

  1. Menyetel focused = true pada komponen
  2. Memindai keluaran yang diberikan untuk CURSOR_MARKER (urutan escape APC dengan lebar nol)
  3. Memposisikan kursor terminal perangkat keras di lokasi itu
  4. Menampilkan kursor perangkat keras hanya ketika showHardwareCursor diaktifkan

Kursor tetap tersembunyi secara default. Hal ini menjaga rendering kursor palsu, sambil tetap memposisikan kursor perangkat keras untuk terminal yang melacak jendela kandidat IME dengan kursor tersembunyi. Beberapa terminal memerlukan kursor perangkat keras yang terlihat untuk penentuan posisi IME; aktifkan dengan showHardwareCursor, setShowHardwareCursor(true), atau PI_HARDWARE_CURSOR=1. Komponen bawaan Editor dan Input sudah mengimplementasikan antarmuka ini.

Komponen Kontainer dengan Input Tersemat

Ketika komponen container (dialog, selector, dll.) berisi turunan Input atau Editor, container harus mengimplementasikan Focusable dan menyebarkan status fokus ke turunan tersebut. Jika tidak, kursor perangkat keras tidak akan ditempatkan dengan benar untuk input 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);
  }
}

Tanpa propagasi ini, mengetik dengan IME (China, Jepang, Korea, dll.) akan menampilkan jendela kandidat pada posisi yang salah di layar.

Menggunakan Komponen

Dalam ekstensi melalui 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),
    })
  );
});

Dalam alat khusus melalui 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...
}

Hamparan

Overlay merender komponen di atas konten yang ada tanpa membersihkan layar. Teruskan { overlay: true } ke ctx.ui.custom():

const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
  { overlay: true }
);

Untuk penentuan posisi dan ukuran, gunakan 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
    },
  }
);

Fokus Hamparan

Hamparan terlihat terfokus mempertahankan kepemilikan masukan di seluruh UI non-hamparan sementara. Jika overlay membuka komponen ctx.ui.custom() lain tanpa { overlay: true }, UI pengganti tersebut menerima input saat sedang aktif; ketika ditutup, hamparan terfokus dapat memperoleh kembali masukan.

Gunakan handle.unfocus() ketika overlay yang terlihat tidak lagi memiliki masukan dan biarkan TUI kembali ke overlay pengambilan lain yang terlihat atau target fokus sebelumnya. Gunakan handle.unfocus({ target }) ketika komponen tertentu harus menerima masukan sementara overlay tetap terlihat. Melewati { target: null } dengan sengaja tidak meninggalkan komponen fokus hingga fokus diatur kembali.

Siklus Hidup Hamparan

Komponen overlay dibuang saat ditutup. Jangan gunakan kembali referensi - buatlah instance baru:

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

Lihat overlay-qa-tests.ts untuk contoh komprehensif yang mencakup jangkar, margin, penumpukan, visibilitas responsif, dan animasi.

Komponen Bawaan

Impor dari @earendil-works/pi-tui:

import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";

Teks

Teks multi-baris dengan pembungkusan kata.

const text = new Text(
  "Hello World",    // content
  1,                // paddingX (default: 1)
  1,                // paddingY (default: 1)
  (s) => bgGray(s)  // optional background function
);
text.setText("Updated");

Kotak

Wadah dengan padding dan warna latar belakang.

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

Wadah

Mengelompokkan komponen anak secara vertikal.

const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);

pengatur jarak

Ruang vertikal kosong.

const spacer = new Spacer(2);  // 2 empty lines

Markdown

Merender penurunan harga dengan penyorotan sintaksis.

const md = new Markdown(
  "# Title\n\nSome **bold** text",
  1,        // paddingX
  1,        // paddingY
  theme     // MarkdownTheme (see below)
);
md.setText("Updated markdown");

Gambar

Merender gambar di terminal yang didukung (Kitty, iTerm2, Ghostty, WezTerm, Warp).

const image = new Image(
  base64Data,   // base64-encoded image
  "image/png",  // MIME type
  theme,        // ImageTheme
  { maxWidthCells: 80, maxHeightCells: 24 }
);

Masukan Papan Ketik

Gunakan matchesKey() untuk deteksi kunci:

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

Pengidentifikasi kunci (gunakan Key.* untuk pelengkapan otomatis, atau literal string):

  • Kunci dasar: Key.enter, Key.escape, Key.tab, Key.space, Key.backspace, Key.delete, Key.home, Key.end
  • Tombol panah: Key.up, Key.down, Key.left, Key.right
  • Dengan pengubah: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
  • Format string juga berfungsi: "enter", "ctrl+c", "shift+tab", "ctrl+shift+p"

Lebar Garis

Kritis: Setiap baris dari render() tidak boleh melebihi parameter width.

import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";

render(width: number): string[] {
  // Truncate long lines
  return [truncateToWidth(this.text, width)];
}

Utilitas:

  • visibleWidth(str) - Dapatkan lebar tampilan (abaikan kode ANSI)
  • truncateToWidth(str, width, ellipsis?) - Potong dengan elipsis opsional
  • wrapTextWithAnsi(str, width) - Bungkus kata yang menyimpan kode ANSI

Membuat Komponen Khusus

Contoh: Pemilih interaktif

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

Penggunaan dalam ekstensi:

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

Tema

Komponen menerima objek tema untuk penataan gaya.

Di renderCall/renderResult, gunakan parameter 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"));
}

Warna latar depan (theme.fg(color, text)):

Kategori Warna
Umum text, accent, muted, dim
Status success, error, warning
Perbatasan border, borderAccent, borderMuted
Pesan userMessageText, customMessageText, customMessageLabel
Peralatan toolTitle, toolOutput
Perbedaan toolDiffAdded, toolDiffRemoved, toolDiffContext
Markdown mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
Sintaksis syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
Pemikiran thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax
Mode bashMode

Warna latar belakang (theme.bg(color, text)):

selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg

Untuk Markdown, gunakan 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);
}

Untuk komponen khusus, tentukan antarmuka tema Anda sendiri:

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

Pencatatan debug

Setel PI_TUI_WRITE_LOG untuk menangkap aliran ANSI mentah yang ditulis ke stdout.

PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts

Pertunjukan

Output yang di-cache jika memungkinkan:

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

Panggil invalidate() ketika status berubah, lalu gunakan tui.requestRender() yang disuntikkan untuk memicu rendering ulang.

Pembatalan dan Perubahan Tema

Saat tema berubah, TUI memanggil invalidate() pada semua komponen untuk menghapus cache-nya. Komponen harus menerapkan invalidate() dengan benar untuk memastikan perubahan tema diterapkan.

Masalahnya

Jika komponen membuat warna tema menjadi string terlebih dahulu (melalui theme.fg(), theme.bg(), dll.) dan menyimpannya dalam cache, string yang di-cache berisi kode escape ANSI dari tema lama. Menghapus cache render saja tidak cukup jika komponen menyimpan konten bertema secara terpisah.

Pendekatan yang salah (warna tema tidak akan diperbarui):

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
}

Solusinya

Komponen yang membuat konten dengan warna tema harus membangun kembali konten tersebut ketika invalidate() dipanggil:

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

Pola: Membangun Kembali saat Tidak Valid

Untuk komponen dengan konten kompleks:

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

Kapan Ini Penting

Pola ini diperlukan ketika:

  1. Warna tema sebelum dipanggang - Menggunakan theme.fg() atau theme.bg() untuk membuat string bergaya yang disimpan di komponen anak
  2. Penyorotan sintaksis - Menggunakan highlightCode() yang menerapkan warna sintaksis berbasis tema
  3. Tata letak kompleks - Membangun pohon komponen anak yang menyematkan warna tema

Pola ini TIDAK diperlukan ketika:

  1. Menggunakan panggilan balik tema - Meneruskan fungsi seperti (text) => theme.fg("accent", text) yang dipanggil selama render
  2. Wadah sederhana - Cukup mengelompokkan komponen lain tanpa menambahkan konten bertema
  3. Render tanpa status - Menghitung keluaran bertema baru di setiap panggilan render() (tanpa caching)

Pola Umum

Pola-pola ini mencakup kebutuhan UI yang paling umum dalam ekstensi. Salin pola ini daripada membuat dari awal.

Pola 1: Dialog Seleksi (SelectList)

Untuk membiarkan pengguna memilih dari daftar opsi. Gunakan SelectList dari @earendil-works/pi-tui dengan DynamicBorder untuk pembingkaian.

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

Contoh: preset.ts, tools.ts

Pola 2: Operasi Asinkron dengan Pembatalan (BorderedLoader)

Untuk operasi yang memakan waktu dan harus dibatalkan. BorderedLoader menunjukkan pemintal dan pegangan escape untuk membatalkan.

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

Contoh: qna.ts, handoff.ts

Pola 3: Pengaturan/Toggles (Daftar Pengaturan)

Untuk mengubah beberapa pengaturan. Gunakan SettingsList dari @earendil-works/pi-tui dengan 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),
      };
    });
  },
});

Contoh: tools.ts

Pola 4: Indikator Status Persisten

Tampilkan status di footer yang tetap ada di seluruh render. Cocok untuk indikator mode.

// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));

// Clear status
ctx.ui.setStatus("my-ext", undefined);

Contoh: status-line.ts, plan-mode/index.ts, preset.ts

Pola 4b: Kustomisasi Indikator Kerja

Sesuaikan indikator kerja sebaris yang ditampilkan saat pi mengalirkan respons.

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

Ini hanya mempengaruhi indikator kerja streaming normal. Loader pemadatan dan percobaan ulang mempertahankan gaya bawaannya. Bingkai khusus ditampilkan kata demi kata, jadi ekstensi harus menambahkan warnanya sendiri bila diperlukan.

Contoh: working-indicator.ts

Pola 5: Widget Di Atas/Di Bawah Editor

Tampilkan konten persisten di atas atau di bawah editor masukan. Bagus untuk daftar tugas, kemajuan.

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

Contoh: plan-mode/index.ts

Ganti footernya. footerData memaparkan data yang tidak dapat diakses oleh ekstensi.

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

Statistik token tersedia melalui ctx.sessionManager.getBranch() dan ctx.model.

Contoh: custom-footer.ts

Pola 7: Editor Kustom (mode vim, dll.)

Ganti editor masukan utama dengan implementasi khusus. Berguna untuk pengeditan modal (vim), pengikatan tombol yang berbeda (emacs), atau penanganan masukan khusus.

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

Poin-poin penting:

  • Perluas CustomEditor (bukan basis Editor) untuk mendapatkan pengikatan kunci aplikasi (escape untuk membatalkan, ctrl+d untuk keluar, peralihan model, dll.)
  • Hubungi super.handleInput(data) untuk kunci yang tidak Anda tangani
  • Pola pabrik: setEditorComponent menerima fungsi pabrik yang mendapatkan tui, theme, dan keybindings
  • Lewati undefined untuk memulihkan editor default: ctx.ui.setEditorComponent(undefined)

Contoh: modal-editor.ts

Aturan Utama

  1. Selalu gunakan tema dari panggilan balik - Jangan mengimpor tema secara langsung. Gunakan theme dari panggilan balik ctx.ui.custom((tui, theme, keybindings, done) =>...).

  2. Selalu ketik parameter warna DynamicBorder - Tulis (s: string) => theme.fg("accent", s), bukan (s) => theme.fg("accent", s).

  3. Panggil tui.requestRender() setelah status berubah - Di handleInput, hubungi tui.requestRender() setelah memperbarui status.

  4. Kembalikan objek tiga metode - Komponen khusus memerlukan { render, invalidate, handleInput }.

  5. Gunakan komponen yang ada - SelectList, SettingsList, BorderedLoader mencakup 90% kasus. Jangan membangunnya kembali.

Contoh