23 TUI Components
23.1 Overview
Pi’s terminal user interface is built from composable components. The TUI component system provides a framework for building interactive terminal interfaces with keyboard input, focus management, and rich rendering. Extensions can create custom TUI components to provide interactive experiences within Pi.
23.2 Component Interface
Every TUI component implements the Component interface:
interface Component {
render(): ComponentOutput;
onKey?(event: KeyEvent): boolean | void;
onFocus?(): void;
onBlur?(): void;
dispose?(): void;
}
interface ComponentOutput {
lines: string[];
cursor?: { x: number; y: number };
}
interface KeyEvent {
key: string;
ctrl: boolean;
alt: boolean;
shift: boolean;
meta: boolean;
sequence: string;
}23.2.1 Key Methods
render()— Returns the visual output as an array of lines with optional cursor positiononKey(event)— Handles keyboard input; returntrueto stop propagationonFocus()/onBlur()— Called when the component gains or loses focusdispose()— Cleanup when the component is removed
23.3 Focusable
Components that can receive keyboard input implement the Focusable interface:
interface Focusable {
canFocus: boolean;
onKey(event: KeyEvent): boolean | void;
onFocus?(): void;
onBlur?(): void;
}Focusable components participate in the focus cycle. When focused, they receive all keyboard events until they release focus or the user navigates away.
23.4 Containers
Containers hold and manage child components:
interface Container extends Component {
children: Component[];
addChild(child: Component): void;
removeChild(child: Component): void;
focusChild(child: Component): void;
focusNext(): void;
focusPrev(): void;
}23.4.1 Layout Containers
import { VBox, HBox, Stack } from "@earendil-works/pi-tui";
// Vertical layout
const vBox = new VBox([
header,
content,
footer,
]);
// Horizontal layout
const hBox = new HBox([
sidebar,
mainContent,
]);
// Stack (overlapping, for overlays)
const stack = new Stack([
background,
modal,
]);23.5 Overlays
Overlays are temporary components displayed on top of the main UI:
import { Overlay, Dialog } from "@earendil-works/pi-tui";
// Show a dialog overlay
const dialog = new Dialog({
title: "Confirm",
message: "Delete this file?",
buttons: ["Cancel", "Delete"],
});
dialog.onResult((button) => {
if (button === "Delete") {
// Proceed with deletion
}
});
ctx.ui.showOverlay(dialog);23.6 Built-in Components
Pi provides several built-in TUI components:
23.6.1 Input
import { Input } from "@earendil-works/pi-tui";
const input = new Input({
placeholder: "Enter your name...",
multiline: false,
});
input.onChange((value) => {
console.log("Current value:", value);
});
input.onSubmit((value) => {
console.log("Submitted:", value);
});23.6.2 List
import { List } from "@earendil-works/pi-tui";
const list = new List({
items: [
{ label: "Option A", value: "a" },
{ label: "Option B", value: "b" },
{ label: "Option C", value: "c" },
],
selected: 0,
});
list.onSelect((item) => {
console.log("Selected:", item.value);
});23.6.3 Tree
import { Tree } from "@earendil-works/pi-tui";
const tree = new Tree({
nodes: [
{
label: "src/",
children: [
{ label: "app.ts" },
{ label: "router.ts" },
{
label: "components/",
children: [
{ label: "Button.tsx" },
{ label: "Input.tsx" },
],
},
],
},
],
});23.6.4 Markdown Renderer
import { MarkdownRenderer } from "@earendil-works/pi-tui";
const renderer = new MarkdownRenderer({
maxWidth: 80,
syntaxHighlight: true,
theme: "default-dark",
});
const output = renderer.render(`
# Hello
This is **bold** and *italic*.
\`\`\`typescript
const x = 42;
\`\`\`
`);23.6.5 Spinner
import { Spinner } from "@earendil-works/pi-tui";
const spinner = new Spinner({
label: "Loading...",
style: "dots",
});23.6.6 Progress Bar
import { ProgressBar } from "@earendil-works/pi-tui";
const progress = new ProgressBar({
label: "Building",
total: 100,
current: 0,
});
progress.update(50); // 50%
progress.update(100); // Complete23.7 Keyboard Input
23.7.1 Key Events
import type { KeyEvent } from "@earendil-works/pi-tui";
class MyComponent implements Component {
onKey(event: KeyEvent): boolean | void {
// Check specific keys
if (event.key === "enter") {
this.submit();
return true; // Stop propagation
}
if (event.ctrl && event.key === "c") {
this.cancel();
return true;
}
// Arrow keys
if (event.key === "up") this.cursorUp();
if (event.key === "down") this.cursorDown();
// Let parent handle other keys
return false;
}
render() { /* ... */ }
}23.7.2 Key Sequences
Some interactions require detecting key sequences (e.g., gg for “go to top”):
private lastKey = "";
onKey(event: KeyEvent): boolean | void {
if (event.key === "g") {
if (this.lastKey === "g") {
this.scrollToTop();
this.lastKey = "";
return true;
}
this.lastKey = "g";
// Set timeout to clear
setTimeout(() => { this.lastKey = ""; }, 500);
return true;
}
this.lastKey = "";
return false;
}23.8 Custom Components
Extensions can create fully custom TUI components using ctx.ui.custom():
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
class TodoListComponent {
private todos: Array<{ text: string; done: boolean }> = [];
private selected = 0;
constructor(private onClose: () => void) {}
addTodo(text: string) {
this.todos.push({ text, done: false });
}
toggleSelected() {
if (this.todos[this.selected]) {
this.todos[this.selected].done = !this.todos[this.selected].done;
}
}
onKey(event: any): boolean | void {
switch (event.key) {
case "up":
this.selected = Math.max(0, this.selected - 1);
return true;
case "down":
this.selected = Math.min(this.todos.length - 1, this.selected + 1);
return true;
case "enter":
this.toggleSelected();
return true;
case "escape":
this.onClose();
return true;
}
return false;
}
render() {
const lines: string[] = ["┌─ Todo List ──────────────────┐"];
if (this.todos.length === 0) {
lines.push("│ (no items) │");
}
this.todos.forEach((todo, i) => {
const checkbox = todo.done ? "✓" : " ";
const selected = i === this.selected ? "▶" : " ";
const text = todo.text.padEnd(26).slice(0, 26);
lines.push(`│ ${selected} [${checkbox}] ${text} │`);
});
lines.push("└──────────────────────────────┘");
lines.push(" ↑↓ Navigate · Enter Toggle · Esc Close");
return {
lines,
cursor: { x: 0, y: 0 },
};
}
}
export default function (pi: ExtensionAPI) {
pi.registerCommand("todo", {
description: "Open todo list",
handler: async (_args, ctx) => {
const component = new TodoListComponent(() => {
ctx.ui.clearCustom();
});
component.addTodo("Review pull request");
component.addTodo("Write documentation");
component.addTodo("Fix linting errors");
ctx.ui.custom(component);
},
});
}23.9 Theming
Components can access the current theme colors:
import type { ThemeColors } from "@earendil-works/pi-tui";
class ThemedComponent implements Component {
constructor(private theme: ThemeColors) {}
render() {
return {
lines: [
`\x1b[38;2;${rgb(this.theme.primary)}mPrimary text\x1b[0m`,
`\x1b[38;2;${rgb(this.theme.muted)}mMuted text\x1b[0m`,
],
};
}
}The theme is passed to components during rendering, ensuring all components use the current color scheme.
23.10 Performance
23.10.1 Rendering Efficiency
Pi’s TUI uses a diff-based rendering system:
- Components return their output via
render() - The TUI engine diffs the output against the previous frame
- Only changed lines are written to the terminal
This approach minimizes terminal output and keeps the UI responsive.
23.10.2 Best Practices
- Cache render output — only recompute when state changes
- Minimize work in
onKey— handle input quickly and return - Use timers sparingly — clean up timers in
dispose() - Avoid heavy computation — offload to background processes if needed
class CachedComponent implements Component {
private cachedOutput: ComponentOutput | null = null;
private dirty = true;
markDirty() {
this.dirty = true;
}
render(): ComponentOutput {
if (this.dirty || !this.cachedOutput) {
this.cachedOutput = this.computeRender();
this.dirty = false;
}
return this.cachedOutput;
}
private computeRender(): ComponentOutput {
// Expensive rendering logic
return { lines: [...] };
}
}23.11 Common Patterns
23.11.1 Modal Dialog
class ConfirmDialog {
private selected = 0;
private options = ["Cancel", "Confirm"];
constructor(
private title: string,
private message: string,
private onResult: (confirmed: boolean) => void
) {}
onKey(event: KeyEvent): boolean {
if (event.key === "left") this.selected = 0;
if (event.key === "right") this.selected = 1;
if (event.key === "enter") {
this.onResult(this.selected === 1);
return true;
}
if (event.key === "escape") {
this.onResult(false);
return true;
}
return false;
}
render() { /* ... */ }
}23.11.2 Form
class Form implements Component {
private fields = [
{ label: "Name:", value: "", focused: true },
{ label: "Email:", value: "", focused: false },
];
private activeField = 0;
onKey(event: KeyEvent): boolean {
if (event.key === "tab") {
this.activeField = (this.activeField + 1) % this.fields.length;
return true;
}
if (event.key === "enter") {
this.submit();
return true;
}
// Handle text input for active field
// ...
return false;
}
render() { /* ... */ }
}The @earendil-works/pi-tui package provides all the building blocks for creating rich terminal interfaces. Combine built-in components with custom logic to build specialized workflows.