d9a3291641
- Replaced external JSON/JSONL parsing libraries with Bun's built-in JSON5 and JSONL APIs across all packages. - Removed dependencies: json5, ndjson, get-east-asian-width, json-stringify-safe, split2, through2, and @types/ndjson. - Replaced custom text width and ANSI wrapping implementations with Bun.stringWidth() and Bun.wrapAnsi() APIs. - Updated Bun type definitions from ^1.2.17 to ^1.2.18 and bun-types from ^1.3.5 to ^1.3.7. - Refactored JSONL parsing throughout codebase to use Bun.JSONL.parse() and Bun.JSONL.parseChunk() with improved buffer management and error handling. - Updated test assertions to use Bun.stringWidth() for dynamic width calculations instead of hardcoded values.
342 lines
9.6 KiB
Markdown
342 lines
9.6 KiB
Markdown
> omp 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:** [`@oh-my-pi/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 `@oh-my-pi/pi-tui`:
|
||
|
||
```typescript
|
||
import { Text, Box, Container, Spacer, Markdown } from "@oh-my-pi/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 key detection helpers:
|
||
|
||
```typescript
|
||
import {
|
||
isEnter, isEscape, isTab,
|
||
isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
|
||
isCtrlC, isCtrlO, isBackspace, isDelete,
|
||
// ... and more
|
||
} from "@oh-my-pi/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. Width calculations and wrapping follow Bun’s built-ins (`Bun.stringWidth`, `Bun.wrapAnsi`).
|
||
|
||
```typescript
|
||
import { visibleWidth, truncateToWidth } from "@oh-my-pi/pi-tui";
|
||
|
||
render(width: number): string[] {
|
||
// Truncate long lines
|
||
return [truncateToWidth(this.text, width)];
|
||
}
|
||
```
|
||
|
||
Utilities:
|
||
|
||
- `visibleWidth(str)` - Get display width (ANSI-safe, Unicode-width aware)
|
||
- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis
|
||
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes (Bun.wrapAnsi)
|
||
|
||
## Creating Custom Components
|
||
|
||
Example: Interactive selector
|
||
|
||
```typescript
|
||
import { isEnter, isEscape, isArrowUp, isArrowDown, truncateToWidth, visibleWidth } from "@oh-my-pi/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:
|
||
|
||
```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 "@oh-my-pi/pi-coding-agent";
|
||
import { Markdown } from "@oh-my-pi/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`
|