23  TUI Components

23.1 Overview

Pi’s terminal user interface is built from composable components. The TUI component system provides a framework for building interactive terminal interfaces with keyboard input, focus management, and rich rendering. Extensions can create custom TUI components to provide interactive experiences within Pi.

23.2 Component Interface

Every TUI component implements the Component interface:

interface Component {
  render(): ComponentOutput;
  onKey?(event: KeyEvent): boolean | void;
  onFocus?(): void;
  onBlur?(): void;
  dispose?(): void;
}

interface ComponentOutput {
  lines: string[];
  cursor?: { x: number; y: number };
}

interface KeyEvent {
  key: string;
  ctrl: boolean;
  alt: boolean;
  shift: boolean;
  meta: boolean;
  sequence: string;
}

23.2.1 Key Methods

  • render() — Returns the visual output as an array of lines with optional cursor position
  • onKey(event) — Handles keyboard input; return true to stop propagation
  • onFocus() / onBlur() — Called when the component gains or loses focus
  • dispose() — Cleanup when the component is removed

23.3 Focusable

Components that can receive keyboard input implement the Focusable interface:

interface Focusable {
  canFocus: boolean;
  onKey(event: KeyEvent): boolean | void;
  onFocus?(): void;
  onBlur?(): void;
}

Focusable components participate in the focus cycle. When focused, they receive all keyboard events until they release focus or the user navigates away.

23.4 Containers

Containers hold and manage child components:

interface Container extends Component {
  children: Component[];
  addChild(child: Component): void;
  removeChild(child: Component): void;
  focusChild(child: Component): void;
  focusNext(): void;
  focusPrev(): void;
}

23.4.1 Layout Containers

import { VBox, HBox, Stack } from "@earendil-works/pi-tui";

// Vertical layout
const vBox = new VBox([
  header,
  content,
  footer,
]);

// Horizontal layout
const hBox = new HBox([
  sidebar,
  mainContent,
]);

// Stack (overlapping, for overlays)
const stack = new Stack([
  background,
  modal,
]);

23.5 Overlays

Overlays are temporary components displayed on top of the main UI:

import { Overlay, Dialog } from "@earendil-works/pi-tui";

// Show a dialog overlay
const dialog = new Dialog({
  title: "Confirm",
  message: "Delete this file?",
  buttons: ["Cancel", "Delete"],
});

dialog.onResult((button) => {
  if (button === "Delete") {
    // Proceed with deletion
  }
});

ctx.ui.showOverlay(dialog);

23.6 Built-in Components

Pi provides several built-in TUI components:

23.6.1 Input

import { Input } from "@earendil-works/pi-tui";

const input = new Input({
  placeholder: "Enter your name...",
  multiline: false,
});

input.onChange((value) => {
  console.log("Current value:", value);
});

input.onSubmit((value) => {
  console.log("Submitted:", value);
});

23.6.2 List

import { List } from "@earendil-works/pi-tui";

const list = new List({
  items: [
    { label: "Option A", value: "a" },
    { label: "Option B", value: "b" },
    { label: "Option C", value: "c" },
  ],
  selected: 0,
});

list.onSelect((item) => {
  console.log("Selected:", item.value);
});

23.6.3 Tree

import { Tree } from "@earendil-works/pi-tui";

const tree = new Tree({
  nodes: [
    {
      label: "src/",
      children: [
        { label: "app.ts" },
        { label: "router.ts" },
        {
          label: "components/",
          children: [
            { label: "Button.tsx" },
            { label: "Input.tsx" },
          ],
        },
      ],
    },
  ],
});

23.6.4 Markdown Renderer

import { MarkdownRenderer } from "@earendil-works/pi-tui";

const renderer = new MarkdownRenderer({
  maxWidth: 80,
  syntaxHighlight: true,
  theme: "default-dark",
});

const output = renderer.render(`
# Hello

This is **bold** and *italic*.

\`\`\`typescript
const x = 42;
\`\`\`
`);

23.6.5 Spinner

import { Spinner } from "@earendil-works/pi-tui";

const spinner = new Spinner({
  label: "Loading...",
  style: "dots",
});

23.6.6 Progress Bar

import { ProgressBar } from "@earendil-works/pi-tui";

const progress = new ProgressBar({
  label: "Building",
  total: 100,
  current: 0,
});

progress.update(50);  // 50%
progress.update(100); // Complete

23.7 Keyboard Input

23.7.1 Key Events

import type { KeyEvent } from "@earendil-works/pi-tui";

class MyComponent implements Component {
  onKey(event: KeyEvent): boolean | void {
    // Check specific keys
    if (event.key === "enter") {
      this.submit();
      return true; // Stop propagation
    }
    
    if (event.ctrl && event.key === "c") {
      this.cancel();
      return true;
    }
    
    // Arrow keys
    if (event.key === "up") this.cursorUp();
    if (event.key === "down") this.cursorDown();
    
    // Let parent handle other keys
    return false;
  }
  
  render() { /* ... */ }
}

23.7.2 Key Sequences

Some interactions require detecting key sequences (e.g., gg for “go to top”):

private lastKey = "";

onKey(event: KeyEvent): boolean | void {
  if (event.key === "g") {
    if (this.lastKey === "g") {
      this.scrollToTop();
      this.lastKey = "";
      return true;
    }
    this.lastKey = "g";
    // Set timeout to clear
    setTimeout(() => { this.lastKey = ""; }, 500);
    return true;
  }
  this.lastKey = "";
  return false;
}

23.8 Custom Components

Extensions can create fully custom TUI components using ctx.ui.custom():

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

class TodoListComponent {
  private todos: Array<{ text: string; done: boolean }> = [];
  private selected = 0;

  constructor(private onClose: () => void) {}

  addTodo(text: string) {
    this.todos.push({ text, done: false });
  }

  toggleSelected() {
    if (this.todos[this.selected]) {
      this.todos[this.selected].done = !this.todos[this.selected].done;
    }
  }

  onKey(event: any): boolean | void {
    switch (event.key) {
      case "up":
        this.selected = Math.max(0, this.selected - 1);
        return true;
      case "down":
        this.selected = Math.min(this.todos.length - 1, this.selected + 1);
        return true;
      case "enter":
        this.toggleSelected();
        return true;
      case "escape":
        this.onClose();
        return true;
    }
    return false;
  }

  render() {
    const lines: string[] = ["┌─ Todo List ──────────────────┐"];
    
    if (this.todos.length === 0) {
      lines.push("│  (no items)                   │");
    }
    
    this.todos.forEach((todo, i) => {
      const checkbox = todo.done ? "✓" : " ";
      const selected = i === this.selected ? "▶" : " ";
      const text = todo.text.padEnd(26).slice(0, 26);
      lines.push(`│ ${selected} [${checkbox}] ${text} │`);
    });
    
    lines.push("└──────────────────────────────┘");
    lines.push("  ↑↓ Navigate · Enter Toggle · Esc Close");
    
    return {
      lines,
      cursor: { x: 0, y: 0 },
    };
  }
}

export default function (pi: ExtensionAPI) {
  pi.registerCommand("todo", {
    description: "Open todo list",
    handler: async (_args, ctx) => {
      const component = new TodoListComponent(() => {
        ctx.ui.clearCustom();
      });
      
      component.addTodo("Review pull request");
      component.addTodo("Write documentation");
      component.addTodo("Fix linting errors");
      
      ctx.ui.custom(component);
    },
  });
}

23.9 Theming

Components can access the current theme colors:

import type { ThemeColors } from "@earendil-works/pi-tui";

class ThemedComponent implements Component {
  constructor(private theme: ThemeColors) {}

  render() {
    return {
      lines: [
        `\x1b[38;2;${rgb(this.theme.primary)}mPrimary text\x1b[0m`,
        `\x1b[38;2;${rgb(this.theme.muted)}mMuted text\x1b[0m`,
      ],
    };
  }
}

The theme is passed to components during rendering, ensuring all components use the current color scheme.

23.10 Performance

23.10.1 Rendering Efficiency

Pi’s TUI uses a diff-based rendering system:

  1. Components return their output via render()
  2. The TUI engine diffs the output against the previous frame
  3. Only changed lines are written to the terminal

This approach minimizes terminal output and keeps the UI responsive.

23.10.2 Best Practices

  1. Cache render output — only recompute when state changes
  2. Minimize work in onKey — handle input quickly and return
  3. Use timers sparingly — clean up timers in dispose()
  4. Avoid heavy computation — offload to background processes if needed
class CachedComponent implements Component {
  private cachedOutput: ComponentOutput | null = null;
  private dirty = true;

  markDirty() {
    this.dirty = true;
  }

  render(): ComponentOutput {
    if (this.dirty || !this.cachedOutput) {
      this.cachedOutput = this.computeRender();
      this.dirty = false;
    }
    return this.cachedOutput;
  }

  private computeRender(): ComponentOutput {
    // Expensive rendering logic
    return { lines: [...] };
  }
}

23.11 Common Patterns

23.11.2 Form

class Form implements Component {
  private fields = [
    { label: "Name:", value: "", focused: true },
    { label: "Email:", value: "", focused: false },
  ];
  private activeField = 0;

  onKey(event: KeyEvent): boolean {
    if (event.key === "tab") {
      this.activeField = (this.activeField + 1) % this.fields.length;
      return true;
    }
    if (event.key === "enter") {
      this.submit();
      return true;
    }
    // Handle text input for active field
    // ...
    return false;
  }

  render() { /* ... */ }
}
Tip

The @earendil-works/pi-tui package provides all the building blocks for creating rich terminal interfaces. Combine built-in components with custom logic to build specialized workflows.