Files
oh-my-pi/packages/coding-agent/docs/tui.md
T
can1357 d9a3291641 feat(deps): replaced external JSON/JSONL and text utilities with Bun built-in APIs
- 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.
2026-01-27 19:18:58 +01:00

9.6 KiB
Raw Blame History

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

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 @oh-my-pi/pi-tui:

import { Text, Box, Container, Spacer, Markdown } from "@oh-my-pi/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 "@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).

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

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:

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 "@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:

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