- 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.
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 ellipsiswrapTextWithAnsi(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
- Snake game: examples/hooks/snake.ts - Full game with keyboard input, game loop, state persistence
- Custom tool rendering: examples/custom-tools/todo/ - Custom
renderCallandrenderResult