Files
oh-my-pi/packages/coding-agent/docs/tui.md
T
can1357 7d60a1af85 docs(coding-agent): updated documentation
- Updated documentation to reflect product name change from 'pi' to 'omp' throughout guides and API references.
- Restructured extension and hook documentation to clarify discovery mechanisms, loading behavior, and configuration across multiple config systems (.omp, .pi, .claude, .codex).
- Updated SDK API documentation with new method signatures: discoverHooks() -> discoverExtensions(), SessionManager methods now async, settings format changed to YAML.
- Expanded session architecture documentation with new entry types (TtsrInjectionEntry, SessionInitEntry), updated field names (fromHook -> fromExtension), and clarified session file format versioning.
- Simplified session-tree-plan.md from detailed implementation checklist to architecture summary, removing completed tasks and rollout details.
2026-02-05 01:34:20 +01:00

14 KiB

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: packages/tui

Component Interface

All components implement:

interface Component {
	render(width: number): string[];
	handleInput?(data: string): void;
	wantsKeyRelease?: boolean;
	getCursorPosition?(width: number): { row: number; col: number } | null;
	invalidate(): void;
}
Member 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.
wantsKeyRelease? Opt-in to key release events (Kitty protocol). Default is false (release events are filtered out).
getCursorPosition?(width) Optional cursor position within the rendered output (0-based row/col) for hardware cursor placement.
invalidate() Clear cached render state (called when themes change or the component needs a full re-render).

Using Components

In hooks via ctx.ui.custom():

pi.on("session_start", async (_event, ctx) => {
	const result = await ctx.ui.custom((tui, theme, keybindings, done) => {
		const component = new MySelector(items);
		component.onSelect = (item) => done(item);
		component.onCancel = () => done(null);
		return component;
	});
	if (result) {
		ctx.ui.notify(`Selected: ${result}`, "info");
	}
});

In extensions/custom tools via pi.ui.custom():

async execute(toolCallId, params, onUpdate, ctx, signal) {
	const result = await pi.ui.custom((tui, theme, keybindings, done) => {
		const component = new MyComponent(theme);
		component.onFinish = (value) => done(value);
		return component;
	});
	return { content: [{ type: "text", text: `Result: ${result}` }] };
}

The factory receives tui, theme, keybindings, and a done() callback. Call done(value) to close the component and resolve the promise with value. (timers, watchers), implement dispose(); it is called when done() closes the UI. For floating modals, call tui.showOverlay(component, options) inside the factory.

The factory receives tui, theme, and a done() callback. Call done(value) to close the component and resolve the promise with value.

Built-in Components

Import from @oh-my-pi/pi-tui:

import {
	Box,
	CancellableLoader,
	Container,
	Editor,
	Image,
	Input,
	Loader,
	Markdown,
	SelectList,
	SettingsList,
	Spacer,
	TabBar,
	Text,
	TruncatedText,
} 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");
text.setCustomBgFn((s) => bgBlue(s));

TruncatedText

Single-line text truncated to fit the viewport width.

const truncated = new TruncatedText("Long status line...", 0, 0);

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);
container.clear();

Spacer

Empty vertical space.

const spacer = new Spacer(2); // 2 empty lines
spacer.setLines(3);

Input

Single-line input with editor-style keybindings.

const input = new Input();
input.onSubmit = (value) => {
	// ...
};
input.setValue("Prefill");

Editor

Multi-line editor with autocomplete and paste handling. Provide an EditorTheme.

const editor = new Editor(editorTheme);
editor.onSubmit = (value) => {
	// ...
};

Markdown

Renders markdown with syntax highlighting.

import { getMarkdownTheme } from "@oh-my-pi/pi-coding-agent";

const md = new Markdown(
	"# Title\n\nSome **bold** text",
	1, // paddingX
	0, // paddingY
	getMarkdownTheme(),
	defaultTextStyle, // optional DefaultTextStyle
	2 // codeBlockIndent (default: 2)
);
md.setText("Updated markdown");

Loader

Spinner component that auto-renders.

const loader = new Loader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");

CancellableLoader

Loader with AbortSignal and Escape-to-cancel.

const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
loader.onAbort = () => {
	// ...
};

SelectList

Interactive list with selection support.

import { getSelectListTheme } from "@oh-my-pi/pi-coding-agent";

const list = new SelectList(items, getSelectListTheme());
list.onSelect = (item) => {
	// ...
};

SettingsList

Settings list with labels, values, and hints.

import { getSettingsListTheme } from "@oh-my-pi/pi-coding-agent";

const settings = new SettingsList(items, getSettingsListTheme());

TabBar

Horizontal tab switcher.

const tabs = [
	{ id: "one", label: "One" },
	{ id: "two", label: "Two" },
];
const tabBar = new TabBar("Mode", tabs, tabTheme); // TabBarTheme

Image

Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).

const image = new Image(
	base64Data, // base64-encoded image
	"image/png", // MIME type
	{ fallbackColor: (text) => theme.fg("muted", text) },
	{ maxWidthCells: 80, maxHeightCells: 24 }, // ImageOptions
	dimensions // optional: { widthPx, heightPx }
);

Keyboard Input

Use matchesKey() for key detection:

import { isKeyRelease, isKeyRepeat, matchesKey, parseKey } from "@oh-my-pi/pi-tui";

handleInput(data: string) {
	if (matchesKey(data, "up")) {
		this.selectedIndex--;
	} else if (matchesKey(data, "enter")) {
		this.onSelect?.(this.selectedIndex);
	} else if (matchesKey(data, "escape")) {
		this.onCancel?.();
	} else if (matchesKey(data, "ctrl+c")) {
		this.onCancel?.();
	}

	const parsed = parseKey(data);
	if (parsed && parsed.startsWith("alt+")) {
		// ...
	}
}

To honor coding-agent keybindings, use the keybindings argument from ctx.ui.custom():

if (keybindings.matches(data, "interrupt")) {
	this.onCancel?.();
}

To receive key release/repeat events, set wantsKeyRelease = true on your component and filter with isKeyRelease() / isKeyRepeat().

Supported key identifiers:

  • Letters: "a" through "z"
  • Specials: "escape", "enter", "tab", "space", "backspace", "delete", "home", "end", "pageUp", "pageDown"
  • Arrows: "up", "down", "left", "right"
  • Function keys: "f1" through "f12"
  • Modifiers: "ctrl+c", "shift+tab", "alt+enter", "ctrl+shift+p"

Line Width

Critical: Each line from render() must not exceed the width parameter. Use these utilities:

import { visibleWidth, truncateToWidth, wrapTextWithAnsi } 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

Creating Custom Components

Example: Interactive selector

import { matchesKey, truncateToWidth } from "@oh-my-pi/pi-tui";
import type { Component } from "@oh-my-pi/pi-tui";

class MySelector implements Component {
	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, "up") && this.selected > 0) {
			this.selected--;
			this.invalidate();
		} else if (matchesKey(data, "down") && this.selected < this.items.length - 1) {
			this.selected++;
			this.invalidate();
		} else if (matchesKey(data, "enter")) {
			this.onSelect?.(this.items[this.selected]);
		} else if (matchesKey(data, "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:

pi.registerCommand("pick", {
	description: "Pick an item",
	handler: async (args, ctx) => {
		const items = ["Option A", "Option B", "Option C"];

		const selected = await ctx.ui.custom((tui, theme, done) => {
			const selector = new MySelector(items);
			selector.onSelect = (item) => done(item);
			selector.onCancel = () => done(null);
			return selector;
		});

		if (selected) {
			ctx.ui.notify(`Selected: ${selected}`, "info");
		}
	},
});

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, thinkingText, 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, pythonMode
Status bar statusLineSep, statusLineModel, statusLinePath, statusLineGitClean, statusLineGitDirty, statusLineContext, statusLineSpend, etc.

Background colors (theme.bg(color, text)):

selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg, statusLineBg

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 implements Component {
	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. The TUI will re-render automatically when keyboard input is received.

Examples