Cấu hình, tùy chỉnh, thiết lập nền tảng và tham chiếu API cho Pi.

TUI Thành phần

pi có thể tạo các thành phần TUI. Yêu cầu nó xây dựng một cái cho trường hợp sử dụng của bạn.

Extensions và các công cụ tùy chỉnh có thể hiển thị các thành phần TUI tùy chỉnh cho giao diện người dùng tương tác. Trang này bao gồm hệ thống thành phần và các khối xây dựng có sẵn.

Nguồn: @earendil-works/pi-tui

Giao diện thành phần

Tất cả các thành phần thực hiện:

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  wantsKeyRelease?: boolean;
  invalidate(): void;
}
Phương pháp Sự miêu tả
render(width) Trả về mảng các chuỗi (mỗi chuỗi một dòng). Mỗi dòng không được vượt quá width.
handleInput?(data) Nhận thông tin đầu vào từ bàn phím khi thành phần được lấy tiêu điểm.
wantsKeyRelease? Nếu đúng, thành phần sẽ nhận được các sự kiện phát hành khóa (giao thức Kitty). Mặc định: sai.
invalidate() Xóa trạng thái hiển thị được lưu trong bộ nhớ đệm. Kêu gọi thay đổi chủ đề.

TUI gắn thêm thiết lập lại SGR đầy đủ và thiết lập lại OSC 8 ở cuối mỗi dòng được hiển thị. Phong cách không vượt qua các dòng. Nếu bạn phát ra văn bản nhiều dòng có kiểu dáng, hãy áp dụng lại kiểu trên mỗi dòng hoặc sử dụng wrapTextWithAnsi() để kiểu được giữ nguyên cho mỗi dòng được ngắt dòng.

Giao diện có thể lấy nét (Hỗ trợ IME)

Các thành phần hiển thị con trỏ văn bản và cần hỗ trợ IME (Trình chỉnh sửa phương thức nhập) phải triển khai giao diện 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}`];
  }
}

Khi thành phần Focusable có tiêu điểm, TUI:

  1. Đặt focused = true trên thành phần
  2. Quét kết quả đầu ra được hiển thị để tìm CURSOR_MARKER (chuỗi thoát APC có độ rộng bằng 0)
  3. Định vị con trỏ đầu cuối phần cứng tại vị trí đó
  4. Chỉ hiển thị con trỏ phần cứng khi showHardwareCursor được bật

Con trỏ vẫn bị ẩn theo mặc định. Điều này giữ cho kết xuất con trỏ giả trong khi vẫn định vị con trỏ phần cứng cho các thiết bị đầu cuối theo dõi các cửa sổ ứng cử viên IME có con trỏ ẩn. Một số thiết bị đầu cuối yêu cầu con trỏ phần cứng hiển thị để định vị IME; kích hoạt nó bằng showHardwareCursor, setShowHardwareCursor(true) hoặc PI_HARDWARE_CURSOR=1. Các thành phần tích hợp EditorInput đã triển khai giao diện này.

Thành phần vùng chứa có đầu vào được nhúng

Khi một thành phần vùng chứa (hộp thoại, bộ chọn, v.v.) chứa phần tử con Input hoặc Editor, thì vùng chứa đó phải triển khai Focusable và truyền trạng thái tiêu điểm cho phần tử con đó. Nếu không, con trỏ phần cứng sẽ không được định vị chính xác cho đầu vào 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);
  }
}

Nếu không có sự lan truyền này, việc nhập bằng IME (tiếng Trung, tiếng Nhật, tiếng Hàn, v.v.) sẽ hiển thị cửa sổ ứng viên ở vị trí sai trên màn hình.

Sử dụng thành phần

Trong tiện ích mở rộng qua 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),
    })
  );
});

Trong các công cụ tùy chỉnh qua 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...
}

Lớp phủ

Lớp phủ hiển thị các thành phần trên nội dung hiện có mà không xóa màn hình. Chuyển { overlay: true } đến ctx.ui.custom():

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

Để định vị và định cỡ, hãy sử dụng 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
    },
  }
);

Tiêu điểm lớp phủ

Lớp phủ hiển thị tập trung giữ quyền sở hữu đầu vào trên giao diện người dùng không có lớp phủ tạm thời. Nếu lớp phủ mở một thành phần ctx.ui.custom() khác không có { overlay: true }, thì giao diện người dùng thay thế đó sẽ nhận được đầu vào khi nó đang hoạt động; khi nó đóng lại, lớp phủ tập trung có thể lấy lại dữ liệu đầu vào.

Sử dụng handle.unfocus() khi lớp phủ hiển thị sẽ ngừng sở hữu đầu vào và để TUI quay trở lại lớp phủ chụp hiển thị khác hoặc mục tiêu tiêu điểm trước đó. Sử dụng handle.unfocus({ target }) khi một thành phần cụ thể sẽ nhận được đầu vào trong khi lớp phủ vẫn hiển thị. Việc chuyển { target: null } có ​​chủ ý không để lại thành phần nào được tập trung cho đến khi tiêu điểm được đặt lại.

Vòng đời lớp phủ

Các thành phần lớp phủ được xử lý khi đóng. Không sử dụng lại tài liệu tham khảo - tạo phiên bản mới:

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

Xem overlay-qa-tests.ts để biết các ví dụ toàn diện bao gồm neo, lề, xếp chồng, khả năng hiển thị phản hồi và hoạt ảnh.

Các thành phần tích hợp

Nhập từ @earendil-works/pi-tui:

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

Chữ

Văn bản nhiều dòng có gói từ.

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

Hộp

Vùng chứa có phần đệm và màu nền.

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

thùng chứa

Nhóm các thành phần con theo chiều dọc.

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

Miếng đệm

Không gian dọc trống rỗng.

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

Markdown

Hiển thị đánh dấu bằng cách tô sáng cú pháp.

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

Hình ảnh

Hiển thị hình ảnh trong các thiết bị đầu cuối được hỗ trợ (Kitty, iTerm2, Ghostty, WezTerm, Warp).

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

Đầu vào bàn phím

Sử dụng matchesKey() để phát hiện khóa:

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

Mã định danh khóa (sử dụng Key.* để tự động hoàn thành hoặc chuỗi ký tự):

  • Các phím cơ bản: Key.enter, Key.escape, Key.tab, Key.space, Key.backspace, Key.delete, Key.home, Key.end
  • Phím mũi tên: Key.up, Key.down, Key.left, Key.right
  • Với các sửa đổi: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
  • Định dạng chuỗi cũng hoạt động: "enter", "ctrl+c", "shift+tab", "ctrl+shift+p"

Chiều rộng dòng

Quan trọng: Mỗi dòng từ render() không được vượt quá tham số width.

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

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

Tiện ích:

  • visibleWidth(str) - Nhận chiều rộng hiển thị (bỏ qua mã ANSI)
  • truncateToWidth(str, width, ellipsis?) - Cắt ngắn bằng dấu chấm lửng tùy chọn
  • wrapTextWithAnsi(str, width) - Gói từ bảo toàn mã ANSI

Tạo thành phần tùy chỉnh

Ví dụ: Bộ chọn tương tác

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

Cách sử dụng trong tiện ích mở rộng:

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

Chủ đề

Các thành phần chấp nhận các đối tượng chủ đề để tạo kiểu.

Trong renderCall/renderResult, sử dụng tham số 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"));
}

Màu nền trước (theme.fg(color, text)):

Loại Màu sắc
Tổng quan text, accent, muted, dim
Trạng thái success, error, warning
Biên giới border, borderAccent, borderMuted
Tin nhắn userMessageText, customMessageText, customMessageLabel
Công cụ toolTitle, toolOutput
Khác biệt toolDiffAdded, toolDiffRemoved, toolDiffContext
Markdown mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
Cú pháp syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
suy nghĩ thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax
Chế độ bashMode

Màu nền (theme.bg(color, text)):

selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg

Đối với Markdown, sử dụng 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);
}

Đối với các thành phần tùy chỉnh, hãy xác định giao diện chủ đề của riêng bạn:

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

Ghi nhật ký gỡ lỗi

Đặt PI_TUI_WRITE_LOG để ghi lại luồng ANSI thô được ghi vào stdout.

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

Hiệu suất

Đầu ra được hiển thị bằng bộ nhớ đệm khi có thể:

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

Gọi invalidate() khi trạng thái thay đổi, sau đó sử dụng tui.requestRender() được chèn để kích hoạt kết xuất lại.

Vô hiệu hóa và thay đổi chủ đề

Khi chủ đề thay đổi, TUI gọi invalidate() trên tất cả các thành phần để xóa bộ nhớ đệm của chúng. Các thành phần phải triển khai đúng invalidate() để đảm bảo các thay đổi về chủ đề có hiệu lực.

Vấn đề

Nếu một thành phần nướng trước màu chủ đề thành chuỗi (thông qua theme.fg(), theme.bg(), v.v.) và lưu chúng vào bộ nhớ đệm thì các chuỗi được lưu trong bộ nhớ đệm sẽ chứa mã thoát ANSI từ chủ đề cũ. Chỉ xóa bộ đệm kết xuất là không đủ nếu thành phần lưu trữ nội dung theo chủ đề riêng biệt.

Cách tiếp cận sai (màu chủ đề sẽ không cập nhật):

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
}

Giải pháp

Các thành phần xây dựng nội dung có màu chủ đề phải xây dựng lại nội dung đó khi invalidate() được gọi:

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

Mẫu: Xây dựng lại khi không hợp lệ

Đối với các thành phần có nội dung phức tạp:

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

Khi điều này quan trọng

Mẫu này là cần thiết khi:

  1. Màu chủ đề chuẩn bị trước - Sử dụng theme.fg() hoặc theme.bg() để tạo các chuỗi theo kiểu được lưu trữ trong các thành phần con
  2. Đánh dấu cú pháp - Sử dụng highlightCode() áp dụng màu cú pháp dựa trên chủ đề
  3. Bố cục phức tạp - Xây dựng cây thành phần con nhúng màu chủ đề

Mẫu này KHÔNG cần thiết khi:

  1. Sử dụng lệnh gọi lại chủ đề - Truyền các hàm như (text) => theme.fg("accent", text) được gọi trong khi kết xuất
  2. Vùng chứa đơn giản - Chỉ nhóm các thành phần khác mà không thêm nội dung theo chủ đề
  3. Kết xuất không trạng thái - Kết quả tính toán theo chủ đề mới trong mỗi cuộc gọi render() (không có bộ nhớ đệm)

Các mẫu chung

Các mẫu này đáp ứng các nhu cầu giao diện người dùng phổ biến nhất trong tiện ích mở rộng. Sao chép các mẫu này thay vì xây dựng từ đầu.

Mẫu 1: Hộp thoại lựa chọn (SelectList)

Để cho phép người dùng chọn từ danh sách các tùy chọn. Sử dụng SelectList từ @earendil-works/pi-tui với DynamicBorder để đóng khung.

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

Ví dụ: preset.ts, tools.ts

Mẫu 2: Thao tác không đồng bộ với tính năng Hủy (BorderedLoader)

Đối với các hoạt động mất thời gian và có thể hủy được. BorderedLoader hiển thị một vòng quay và xử lý lối thoát để hủy.

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

Ví dụ: qna.ts, handoff.ts

Mẫu 3: Cài đặt/Bật tắt (Danh sách cài đặt)

Để chuyển đổi nhiều cài đặt. Sử dụng SettingsList từ @earendil-works/pi-tui với 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),
      };
    });
  },
});

Ví dụ: tools.ts

Mẫu 4: Chỉ báo trạng thái liên tục

Hiển thị trạng thái ở chân trang vẫn tồn tại trong các lần hiển thị. Tốt cho các chỉ số chế độ.

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

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

Ví dụ: status-line.ts, plan-mode/index.ts, preset.ts

Mẫu 4b: Tùy chỉnh chỉ báo hoạt động

Tùy chỉnh chỉ báo hoạt động nội tuyến được hiển thị trong khi pi đang truyền phát phản hồi.

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

Điều này chỉ ảnh hưởng đến chỉ báo hoạt động phát trực tuyến bình thường. Bộ tải nén và thử lại vẫn giữ nguyên kiểu dáng tích hợp của chúng. Các khung tùy chỉnh được hiển thị nguyên văn nên các tiện ích mở rộng phải thêm màu riêng khi cần.

Ví dụ: working-indicator.ts

Mẫu 5: Widget Trên/Dưới Trình chỉnh sửa

Hiển thị nội dung liên tục ở trên hoặc bên dưới trình chỉnh sửa đầu vào. Tốt cho danh sách việc cần làm, tiến bộ.

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

Ví dụ: plan-mode/index.ts

Mẫu 6: Chân trang tùy chỉnh

Thay thế chân trang. footerData hiển thị dữ liệu mà các tiện ích mở rộng không thể truy cập được.

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

Số liệu thống kê về mã thông báo có sẵn qua ctx.sessionManager.getBranch()ctx.model.

Ví dụ: custom-footer.ts

Mẫu 7: Trình chỉnh sửa tùy chỉnh (chế độ vim, v.v.)

Thay thế trình chỉnh sửa đầu vào chính bằng cách triển khai tùy chỉnh. Hữu ích cho việc chỉnh sửa phương thức (vim), các tổ hợp phím khác nhau (emacs) hoặc xử lý đầu vào chuyên dụng.

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

Các điểm chính:

  • Mở rộng CustomEditor (không phải cơ sở Editor) để nhận các tổ hợp phím ứng dụng (thoát để hủy, ctrl+d để thoát, chuyển đổi mô hình, v.v.)
  • Gọi super.handleInput(data) để biết các phím bạn không xử lý
  • Mẫu nhà máy: setEditorComponent nhận được hàm nhà máy có tui, themekeybindings
  • Vượt qua undefined để khôi phục trình chỉnh sửa mặc định: ctx.ui.setEditorComponent(undefined)

Ví dụ: modal-editor.ts

Quy tắc chính

  1. Luôn sử dụng chủ đề từ lệnh gọi lại - Không nhập trực tiếp chủ đề. Sử dụng theme từ lệnh gọi lại ctx.ui.custom((tui, theme, keybindings, done) =>...).

  2. Luôn nhập thông số màu DynamicBorder - Viết (s: string) => theme.fg("accent", s), không phải (s) => theme.fg("accent", s).

  3. Gọi tôi.requestRender() sau khi thay đổi trạng thái - Trong handleInput, gọi tui.requestRender() sau khi cập nhật trạng thái.

  4. Trả về đối tượng ba phương thức - Các thành phần tùy chỉnh cần { render, invalidate, handleInput }.

  5. Sử dụng các thành phần hiện có - SelectList, SettingsList, BorderedLoader bao gồm 90% trường hợp. Đừng xây dựng lại chúng.

Ví dụ