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:
- Đặt
focused = truetrên thành phần - 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) - Định vị con trỏ đầu cuối phần cứng tại vị trí đó
- 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 Editor và Input đã 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 againXem 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 linesMarkdown
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ọnwrapTextWithAnsi(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.tsHiệ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:
- Màu chủ đề chuẩn bị trước - Sử dụng
theme.fg()hoặctheme.bg()để tạo các chuỗi theo kiểu được lưu trữ trong các thành phần con - Đánh dấu cú pháp - Sử dụng
highlightCode()áp dụng màu cú pháp dựa trên chủ đề - 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:
- 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 - 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ủ đề
- 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");
}
},
});
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 defaultSố liệu thống kê về mã thông báo có sẵn qua ctx.sessionManager.getBranch() và 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:
setEditorComponentnhận được hàm nhà máy cótui,themevàkeybindings - 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
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
themetừ lệnh gọi lạictx.ui.custom((tui, theme, keybindings, done) =>...).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).Gọi tôi.requestRender() sau khi thay đổi trạng thái - Trong
handleInput, gọitui.requestRender()sau khi cập nhật trạng thái.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 }.Sử dụng các thành phần hiện có -
SelectList,SettingsList,BorderedLoaderbao gồm 90% trường hợp. Đừng xây dựng lại chúng.
Ví dụ
- Giao diện người dùng lựa chọn: examples/extensions/preset.ts - SelectList với khung DynamicBorder
- Không đồng bộ với hủy: examples/extensions/qna.ts - BorderedLoader cho cuộc gọi LLM
- Bật/tắt cài đặt: examples/extensions/tools.ts - Danh sách cài đặt để bật/tắt công cụ
- Chỉ báo trạng thái: examples/extensions/plan-mode/index.ts - setStatus và setWidget
- Chỉ báo hoạt động: examples/extensions/working-indicator.ts - setWorkingIndicator
- Chân trang tùy chỉnh: examples/extensions/custom-footer.ts - setFooter có số liệu thống kê
- Trình chỉnh sửa tùy chỉnh: examples/extensions/modal-editor.ts - Chỉnh sửa phương thức giống Vim
- Trò chơi rắn: examples/extensions/snake.ts - Trò chơi đầy đủ với đầu vào bàn phím, vòng lặp trò chơi
- Hiển thị công cụ tùy chỉnh: examples/extensions/todo.ts - renderCall và renderResult