co-mono/packages/coding-agent/docs/tui.md
2025-12-31 13:09:19 +01:00

8.3 KiB

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

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

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

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

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:

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 "@mariozechner/pi-coding-agent";
import { Markdown } from "@mariozechner/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