diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md index 68c4d01a..74b908bd 100644 --- a/packages/coding-agent/docs/custom-tools.md +++ b/packages/coding-agent/docs/custom-tools.md @@ -2,12 +2,18 @@ Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering. +**Key capabilities:** +- **User interaction** - Prompt users via `pi.ui` (select, confirm, input dialogs) +- **Custom rendering** - Control how tool calls and results appear via `renderCall`/`renderResult` +- **TUI components** - Render custom components with `pi.ui.custom()` (see [tui.md](tui.md)) +- **State management** - Persist state in tool result `details` for proper branching support +- **Streaming results** - Send partial updates via `onUpdate` callback + **Example use cases:** -- Ask the user questions with selectable options -- Maintain state across calls (todo lists, connection pools) -- Custom TUI rendering (progress indicators, structured output) -- Integrate external services with proper error handling -- Tools that need user confirmation before proceeding +- Interactive dialogs (questions with selectable options) +- Stateful tools (todo lists, connection pools) +- Rich output rendering (progress indicators, structured views) +- External service integrations with confirmation flows **When to use custom tools vs. alternatives:** @@ -283,7 +289,7 @@ This pattern ensures: ## Custom Rendering -Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. +Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. See [tui.md](tui.md) for the full component API. ### How It Works diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 92fb90b4..e8c3c856 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -432,7 +432,7 @@ Your component can: - Call `handle.requestRender()` to trigger re-render - Call `handle.close()` when done to restore normal UI -See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with game loop, keyboard handling, and state persistence. +See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with game loop, keyboard handling, and state persistence. See [tui.md](tui.md) for the full component API. ### ctx.hasUI diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md new file mode 100644 index 00000000..3dc6894c --- /dev/null +++ b/packages/coding-agent/docs/tui.md @@ -0,0 +1,324 @@ +# 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 key detection helpers: + +```typescript +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. + +```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 { + 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: + +```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((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. Use ANSI color functions (e.g., from `chalk` or pi's theme): + +```typescript +// In hooks, use the theme from renderResult/renderCall +renderResult(result, options, theme) { + return new Text(theme.fg("success", "Done!"), 0, 0); +} + +// For custom components, define your own theme interface +interface MyTheme { + selected: (s: string) => string; + normal: (s: string) => string; +} +``` + +### MarkdownTheme + +```typescript +interface MarkdownTheme { + heading: (text: string) => string; + link: (text: string) => string; + linkUrl: (text: string) => string; + code: (text: string) => string; + codeBlock: (text: string) => string; + codeBlockBorder: (text: string) => string; + quote: (text: string) => string; + quoteBorder: (text: string) => string; + hr: (text: string) => string; + listBullet: (text: string) => string; + bold: (text: string) => string; + italic: (text: string) => string; + strikethrough: (text: string) => string; + underline: (text: string) => string; + highlightCode?: (code: string, lang?: 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`