- System prompt now instructs to read docs AND examples, follow cross-refs - Each doc starts with 'pi can create X. Ask it to build one.'
8.4 KiB
pi can create TUI components. Ask it to build one for your use case.
TUI Components
Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.
Source: @mariozechner/pi-tui
Component Interface
All components implement:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
invalidate?(): void;
}
| Method | Description |
|---|---|
render(width) |
Return array of strings (one per line). Each line must not exceed width. |
handleInput?(data) |
Receive keyboard input when component has focus. |
invalidate?() |
Clear cached render state. |
Using Components
In hooks via ctx.ui.custom():
pi.on("session_start", async (_event, ctx) => {
const handle = ctx.ui.custom(myComponent);
// handle.requestRender() - trigger re-render
// handle.close() - restore normal UI
});
In custom tools via pi.ui.custom():
async execute(toolCallId, params, onUpdate, ctx, signal) {
const handle = pi.ui.custom(myComponent);
// ...
handle.close();
}
Built-in Components
Import from @mariozechner/pi-tui:
import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui";
Text
Multi-line text with word wrapping.
const text = new Text(
"Hello World", // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s) // optional background function
);
text.setText("Updated");
Box
Container with padding and background color.
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));
Container
Groups child components vertically.
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);
Spacer
Empty vertical space.
const spacer = new Spacer(2); // 2 empty lines
Markdown
Renders markdown with syntax highlighting.
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");
Image
Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).
const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);
Keyboard Input
Use key detection helpers:
import {
isEnter, isEscape, isTab,
isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
isCtrlC, isCtrlO, isBackspace, isDelete,
// ... and more
} from "@mariozechner/pi-tui";
handleInput(data: string) {
if (isArrowUp(data)) {
this.selectedIndex--;
} else if (isEnter(data)) {
this.onSelect?.(this.selectedIndex);
} else if (isEscape(data)) {
this.onCancel?.();
}
}
Line Width
Critical: Each line from render() must not exceed the width parameter.
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}
Utilities:
visibleWidth(str)- Get display width (ignores ANSI codes)truncateToWidth(str, width, ellipsis?)- Truncate with optional ellipsiswrapTextWithAnsi(str, width)- Word wrap preserving ANSI codes
Creating Custom Components
Example: Interactive selector
import {
isEnter, isEscape, isArrowUp, isArrowDown,
truncateToWidth, visibleWidth
} from "@mariozechner/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 (isArrowUp(data) && this.selected > 0) {
this.selected--;
this.invalidate();
} else if (isArrowDown(data) && this.selected < this.items.length - 1) {
this.selected++;
this.invalidate();
} else if (isEnter(data)) {
this.onSelect?.(this.items[this.selected]);
} else if (isEscape(data)) {
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;
}
}
Usage in a hook:
pi.registerCommand("pick", {
description: "Pick an item",
handler: async (args, ctx) => {
const items = ["Option A", "Option B", "Option C"];
const selector = new MySelector(items);
let handle: { close: () => void; requestRender: () => void };
await new Promise<void>((resolve) => {
selector.onSelect = (item) => {
ctx.ui.notify(`Selected: ${item}`, "info");
handle.close();
resolve();
};
selector.onCancel = () => {
handle.close();
resolve();
};
handle = ctx.ui.custom(selector);
});
}
});
Theming
Components accept theme objects for styling.
In renderCall/renderResult, use the theme parameter:
renderResult(result, options, theme) {
// 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"));
}
Foreground colors (theme.fg(color, text)):
| Category | Colors |
|---|---|
| General | text, accent, muted, dim |
| Status | success, error, warning |
| Borders | border, borderAccent, borderMuted |
| Messages | userMessageText, customMessageText, customMessageLabel |
| Tools | toolTitle, toolOutput |
| Diffs | toolDiffAdded, toolDiffRemoved, toolDiffContext |
| Markdown | mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet |
| Syntax | syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation |
| Thinking | thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh |
| Modes | bashMode |
Background colors (theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
For Markdown, use getMarkdownTheme():
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Markdown } from "@mariozechner/pi-tui";
renderResult(result, options, theme) {
const mdTheme = getMarkdownTheme();
return new Markdown(result.details.markdown, 0, 0, mdTheme);
}
For custom components, define your own theme interface:
interface MyTheme {
selected: (s: string) => string;
normal: (s: string) => string;
}
Performance
Cache rendered output when possible:
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;
}
}
Call invalidate() when state changes, then handle.requestRender() to trigger re-render.
Examples
- Snake game: examples/hooks/snake.ts - Full game with keyboard input, game loop, state persistence
- Custom tool rendering: examples/custom-tools/todo/ - Custom
renderCallandrenderResult