mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 21:03:19 +00:00
346 lines
8.8 KiB
Markdown
346 lines
8.8 KiB
Markdown
> 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`](https://github.com/badlogic/pi-mono/tree/main/packages/tui)
|
|
|
|
## Component Interface
|
|
|
|
All components implement:
|
|
|
|
```typescript
|
|
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()`:
|
|
|
|
```typescript
|
|
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()`:
|
|
|
|
```typescript
|
|
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
|
const handle = pi.ui.custom(myComponent);
|
|
// ...
|
|
handle.close();
|
|
}
|
|
```
|
|
|
|
## Built-in Components
|
|
|
|
Import from `@mariozechner/pi-tui`:
|
|
|
|
```typescript
|
|
import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui";
|
|
```
|
|
|
|
### Text
|
|
|
|
Multi-line text with word wrapping.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
const container = new Container();
|
|
container.addChild(component1);
|
|
container.addChild(component2);
|
|
container.removeChild(component1);
|
|
```
|
|
|
|
### Spacer
|
|
|
|
Empty vertical space.
|
|
|
|
```typescript
|
|
const spacer = new Spacer(2); // 2 empty lines
|
|
```
|
|
|
|
### Markdown
|
|
|
|
Renders markdown with syntax highlighting.
|
|
|
|
```typescript
|
|
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).
|
|
|
|
```typescript
|
|
const image = new Image(
|
|
base64Data, // base64-encoded image
|
|
"image/png", // MIME type
|
|
theme, // ImageTheme
|
|
{ maxWidthCells: 80, maxHeightCells: 24 }
|
|
);
|
|
```
|
|
|
|
## Keyboard Input
|
|
|
|
Use `matchesKey()` for key detection:
|
|
|
|
```typescript
|
|
import { matchesKey, Key } from "@mariozechner/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
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key identifiers** (use `Key.*` for autocomplete, or string literals):
|
|
- Basic keys: `Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end`
|
|
- Arrow keys: `Key.up`, `Key.down`, `Key.left`, `Key.right`
|
|
- With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
|
|
- String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
|
|
|
|
## Line Width
|
|
|
|
**Critical:** Each line from `render()` must not exceed the `width` parameter.
|
|
|
|
```typescript
|
|
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 ellipsis
|
|
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes
|
|
|
|
## Creating Custom Components
|
|
|
|
Example: Interactive selector
|
|
|
|
```typescript
|
|
import {
|
|
matchesKey, Key,
|
|
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 (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;
|
|
}
|
|
}
|
|
```
|
|
|
|
Usage in a hook:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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()`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
interface MyTheme {
|
|
selected: (s: string) => string;
|
|
normal: (s: string) => string;
|
|
}
|
|
```
|
|
|
|
## Performance
|
|
|
|
Cache rendered output when possible:
|
|
|
|
```typescript
|
|
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](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence
|
|
- **Custom tool rendering**: [examples/custom-tools/todo/](../examples/custom-tools/todo/) - Custom `renderCall` and `renderResult`
|