singularity-forge/docs/dev/extending-pi/12-custom-ui-visual-components.md
Jeremy 872b0adb48 docs: reorganize into user-docs/ and dev/ subdirectories
Split flat docs/ into user-docs/ (guides, config, troubleshooting) and
dev/ (ADRs, architecture, extension guides, proposals). Updated
docs/README.md index to reflect new paths.
2026-04-10 09:25:31 -05:00

5.4 KiB

Custom UI — Visual Components

Pi's extension UI has multiple layers, from simple notifications to full custom components.

12.1 Dialogs (Blocking)

// Selection
const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);

// Confirmation
const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");

// Text input
const name = await ctx.ui.input("Name:", "placeholder");

// Multi-line editor
const text = await ctx.ui.editor("Edit:", "prefilled text");

Timed Dialogs

// Auto-dismiss after 5s with countdown: "Title (5s)" → "Title (4s)" → ...
const ok = await ctx.ui.confirm("Auto-confirm?", "Proceeds in 5s", { timeout: 5000 });
// Returns false on timeout

12.2 Persistent UI Elements

// Footer status (persistent until cleared)
ctx.ui.setStatus("my-ext", "● Active");
ctx.ui.setStatus("my-ext", undefined);   // Clear

// Widget above editor (default placement)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);

// Widget below editor
ctx.ui.setWidget("my-widget", ["Below!"], { placement: "belowEditor" });

// Widget with theme callback
ctx.ui.setWidget("my-widget", (_tui, theme) => ({
  render: () => [theme.fg("accent", "Styled widget")],
  invalidate: () => {},
}));

// Working message during streaming
ctx.ui.setWorkingMessage("Analyzing code...");
ctx.ui.setWorkingMessage();  // Restore default

// Custom footer (replaces built-in entirely)
ctx.ui.setFooter((tui, theme, footerData) => ({
  render(width) { return [theme.fg("dim", `branch: ${footerData.getGitBranch()}`)]; },
  invalidate() {},
  dispose: footerData.onBranchChange(() => tui.requestRender()),
}));

// Editor control
ctx.ui.setEditorText("Prefill");
const current = ctx.ui.getEditorText();
ctx.ui.pasteToEditor("pasted content");

// Tool expansion
ctx.ui.setToolsExpanded(true);
ctx.ui.setToolsExpanded(false);

// Theme management
const themes = ctx.ui.getAllThemes();
ctx.ui.setTheme("light");

12.3 Custom Components (ctx.ui.custom)

For complex UI, ctx.ui.custom() temporarily replaces the editor with your component:

const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => {
  // Return a component object
  return {
    render(width: number): string[] {
      return ["Press Enter to confirm, Escape to cancel"];
    },
    handleInput(data: string) {
      if (matchesKey(data, Key.enter)) done("confirmed");
      if (matchesKey(data, Key.escape)) done(null);
    },
    invalidate() {},
  };
});

12.4 Overlays (Floating Modals)

const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
  {
    overlay: true,
    overlayOptions: {
      anchor: "center",         // 9 positions: center, top-left, top-right, etc.
      width: "50%",
      maxHeight: "80%",
      margin: 2,
      visible: (w, h) => w >= 80,  // Hide on narrow terminals
    },
    onHandle: (handle) => {
      // handle.setHidden(true/false)
    },
  }
);

12.5 Custom Editor (Replace Main Input)

import { CustomEditor } from "@mariozechner/pi-coding-agent";
import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";

class VimEditor extends CustomEditor {
  private mode: "normal" | "insert" = "insert";

  handleInput(data: string): void {
    if (matchesKey(data, "escape") && this.mode === "insert") {
      this.mode = "normal";
      return;
    }
    if (this.mode === "insert") {
      super.handleInput(data);  // Normal text editing + app keybindings
      return;
    }
    // Vim normal mode keys...
    if (data === "i") { this.mode = "insert"; return; }
    super.handleInput(data);  // Pass unhandled to parent
  }
}

// Register:
ctx.ui.setEditorComponent((_tui, theme, keybindings) => new VimEditor(theme, keybindings));
ctx.ui.setEditorComponent(undefined);  // Restore default

Key point: Extend CustomEditor (not Editor) to get app keybindings (escape to abort, ctrl+d, model switching).

12.6 Built-in TUI Components

Import from @mariozechner/pi-tui:

Component Purpose
Text Multi-line text with word wrapping
Box Container with padding and background
Container Groups children vertically
Spacer Empty vertical space
Markdown Rendered markdown with syntax highlighting
Image Image rendering (Kitty, iTerm2, etc.)
SelectList Interactive selection from list
SettingsList Toggle settings UI
Input Text input field

Import from @mariozechner/pi-coding-agent:

Component Purpose
DynamicBorder Border line with theming
BorderedLoader Spinner with cancel support

12.7 Keyboard Input

import { matchesKey, Key } from "@mariozechner/pi-tui";

handleInput(data: string) {
  if (matchesKey(data, Key.up)) { /* arrow up */ }
  if (matchesKey(data, Key.enter)) { /* enter */ }
  if (matchesKey(data, Key.escape)) { /* escape */ }
  if (matchesKey(data, Key.ctrl("c"))) { /* ctrl+c */ }
  if (matchesKey(data, Key.shift("tab"))) { /* shift+tab */ }
}

12.8 Line Width Rules

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

import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";

render(width: number): string[] {
  return [truncateToWidth(this.text, width)];
}