singularity-forge/docs/dev/pi-ui-tui/06-ctx-ui-custom-full-custom-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

4 KiB

ctx.ui.custom() — Full Custom Components

This is the most powerful UI mechanism. It temporarily replaces the editor with your component. Returns a value when done() is called.

Basic Pattern

const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => {
  // tui        — TUI instance (requestRender, screen dimensions)
  // theme      — Current theme for styling
  // keybindings — App keybinding manager
  // done(value) — Call to close component and return value

  return {
    render(width: number): string[] {
      return [
        theme.fg("accent", "─".repeat(width)),
        " Press Enter to confirm, Escape to cancel",
        theme.fg("accent", "─".repeat(width)),
      ];
    },
    handleInput(data: string) {
      if (matchesKey(data, Key.enter)) done("confirmed");
      if (matchesKey(data, Key.escape)) done(null);
    },
    invalidate() {},
  };
});

if (result === "confirmed") {
  ctx.ui.notify("Confirmed!", "info");
}

The Factory Callback

The factory function receives four arguments:

Argument Type Purpose
tui TUI Screen info and render control. tui.requestRender() triggers re-render after state changes.
theme Theme Current theme. Use theme.fg(), theme.bg(), theme.bold(), etc.
keybindings KeybindingsManager App keybinding config. For checking what keys do what.
done (value: T) => void Call this to close the component and return a value to the awaiting code.

Using Existing Components as Children

const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
  const container = new Container();
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
  container.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));

  const selectList = new SelectList(items, 10, {
    selectedPrefix: (t) => theme.fg("accent", t),
    selectedText: (t) => theme.fg("accent", t),
    description: (t) => theme.fg("muted", t),
    scrollInfo: (t) => theme.fg("dim", t),
    noMatch: (t) => theme.fg("warning", t),
  });
  selectList.onSelect = (item) => done(item.value);
  selectList.onCancel = () => done(null);
  container.addChild(selectList);

  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));

  return {
    render: (w) => container.render(w),
    invalidate: () => container.invalidate(),
    handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
  };
});

Using a Class

class MyComponent {
  private selected = 0;
  private items: string[];
  private done: (value: string | null) => void;
  private tui: { requestRender: () => void };
  private cachedWidth?: number;
  private cachedLines?: string[];

  constructor(tui: TUI, items: string[], done: (value: string | null) => void) {
    this.tui = tui;
    this.items = items;
    this.done = done;
  }

  handleInput(data: string) {
    if (matchesKey(data, Key.up) && this.selected > 0) {
      this.selected--;
      this.invalidate();
      this.tui.requestRender();
    } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {
      this.selected++;
      this.invalidate();
      this.tui.requestRender();
    } else if (matchesKey(data, Key.enter)) {
      this.done(this.items[this.selected]);
    } else if (matchesKey(data, Key.escape)) {
      this.done(null);
    }
  }

  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() {
    this.cachedWidth = undefined;
    this.cachedLines = undefined;
  }
}

// Usage:
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
  return new MyComponent(tui, ["Option A", "Option B", "Option C"], done);
});