Skip to content

TUI integration for extensions and custom tools

This document covers the current TUI contract used by packages/coding-agent and packages/tui for extension UI, custom tool UI, and custom renderers.

What this subsystem is

The runtime has two layers:

  • Rendering engine (packages/tui): differential terminal renderer, input dispatch, focus, overlays, cursor placement.
  • Integration layer (packages/coding-agent): mounts extension/custom-tool components, wires keybindings/theme, and restores editor state.

Runtime behavior by mode

Modectx.ui.custom(...) availabilityNotes
Interactive TUISupportedComponent is mounted in the editor area, focused, and must call done(result) to resolve.
Background/headlessNot interactiveUI context is no-op (hasUI === false).
RPC modeNot supportedcustom() returns Promise<never> and does not mount TUI components.

If your extension/tool can run in non-interactive mode, guard with ctx.hasUI / pi.hasUI.

Core component contract (@oh-my-pi/pi-tui)

packages/tui/src/tui.ts defines:

ts
export interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  wantsKeyRelease?: boolean;
  invalidate(): void;
}

Focusable is separate:

ts
export interface Focusable {
  focused: boolean;
}

Cursor behavior uses CURSOR_MARKER (not getCursorPosition). Focused components emit the marker in rendered text; TUI extracts it and positions the hardware cursor.

Rendering constraints (terminal safety)

Your render(width) output must be terminal-safe:

  1. Never exceed width on any line. The renderer throws if a non-image line overflows.
  2. Measure visual width, not string length: use visibleWidth().
  3. Truncate/wrap ANSI-aware text with truncateToWidth() / wrapTextWithAnsi().
  4. Sanitize tabs/content from external sources using replaceTabs() (and higher-level sanitizers in coding-agent render paths).

Minimal pattern:

ts
import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";

render(width: number): string[] {
  return this.lines.map(line => truncateToWidth(replaceTabs(line), width));
}

Input handling and keybindings

Raw key matching

Use matchesKey(data, "...") for navigation keys and combos.

Respect user-configured app keybindings

Extension UI factories receive a KeybindingsManager (interactive mode) so you can honor mapped actions instead of hardcoding keys:

ts
if (keybindings.matches(data, "interrupt")) {
  done(undefined);
  return;
}

Key release/repeat events

Key release events are filtered unless your component sets:

ts
wantsKeyRelease = true;

Then use isKeyRelease() / isKeyRepeat() if needed.

Focus, overlays, and cursor

  • TUI.setFocus(component) routes input to that component.
  • Overlay APIs exist in TUI (showOverlay, OverlayHandle), but extension ctx.ui.custom mounting in interactive mode currently replaces the editor component area directly.
  • The custom(..., options?: { overlay?: boolean }) option exists in extension types; interactive extension mounting currently ignores this option.

Mount points and return contracts

1) Extension UI (ExtensionUIContext)

Current signature (extensibility/extensions/types.ts):

ts
custom<T>(
  factory: (
    tui: TUI,
    theme: Theme,
    keybindings: KeybindingsManager,
    done: (result: T) => void,
  ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
  options?: { overlay?: boolean },
): Promise<T>

Behavior in interactive mode (extension-ui-controller.ts):

  • Saves editor text.
  • Replaces editor component with your component.
  • Focuses your component.
  • On done(result): calls component.dispose?.(), restores editor + text, focuses editor, resolves promise.

So done(...) is mandatory for completion.

2) Hook/custom-tool UI context (legacy typing)

HookUIContext.custom is typed as (tui, theme, done) in hook/custom-tool types. Underlying interactive implementation calls factories with (tui, theme, keybindings, done). JS consumers can use the extra arg; type-level compatibility still reflects the 3-arg legacy signature.

Custom tools typically use the same UI entrypoint via the factory-scoped pi.ui object, then return the selected value in normal tool content:

ts
async execute(toolCallId, params, onUpdate, ctx, signal) {
  if (!pi.hasUI) {
    return { content: [{ type: "text", text: "UI unavailable" }] };
  }

  const picked = await pi.ui.custom<string | undefined>((tui, theme, done) => {
    const component = new MyPickerComponent(done, signal);
    return component;
  });

  return { content: [{ type: "text", text: picked ? `Picked: ${picked}` : "Cancelled" }] };
}

3) Custom tool call/result renderers

Custom tools and extension tools can return components from:

  • renderCall(args, theme)
  • renderResult(result, options, theme, args?)

options currently includes:

  • expanded: boolean
  • isPartial: boolean
  • spinnerFrame?: number

These renderers are mounted by ToolExecutionComponent.

Lifecycle and cancellation

  • dispose() is optional at type level but should be implemented when you own timers, subprocesses, watchers, sockets, or overlays.
  • done(...) should be called exactly once from your component flow.
  • For cancellable long-running UI, pair CancellableLoader with AbortSignal and call done(...) from onAbort.

Example cancellation pattern:

ts
const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
loader.onAbort = () => done(undefined);
void doWork(loader.signal).then(result => done(result));
return loader;

Realistic custom component example (extension command)

ts
import type { Component } from "@oh-my-pi/pi-tui";
import { SelectList, matchesKey, replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
import { getSelectListTheme, type ExtensionAPI } from "@oh-my-pi/pi-coding-agent";

class Picker implements Component {
  list: SelectList;
  keybindings: any;
  done: (value: string | undefined) => void;

  constructor(
    items: Array<{ value: string; label: string }>,
    keybindings: any,
    done: (value: string | undefined) => void,
  ) {
    this.list = new SelectList(items, 8, getSelectListTheme());
    this.keybindings = keybindings;
    this.done = done;
    this.list.onSelect = item => this.done(item.value);
    this.list.onCancel = () => this.done(undefined);
  }

  handleInput(data: string): void {
    if (this.keybindings.matches(data, "interrupt")) {
      this.done(undefined);
      return;
    }
    this.list.handleInput(data);
  }

  render(width: number): string[] {
    return this.list.render(width).map(line => truncateToWidth(replaceTabs(line), width));
  }

  invalidate(): void {
    this.list.invalidate();
  }
}

export default function extension(pi: ExtensionAPI): void {
  pi.registerCommand("pick-model", {
    description: "Pick a model profile",
    handler: async (_args, ctx) => {
      if (!ctx.hasUI) return;

      const selected = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
        const items = [
          { value: "fast", label: theme.fg("accent", "Fast") },
          { value: "balanced", label: "Balanced" },
          { value: "quality", label: "Quality" },
        ];
        return new Picker(items, keybindings, done);
      });

      if (selected) ctx.ui.notify(`Selected profile: ${selected}`, "info");
    },
  });
}

Key implementation files

  • packages/tui/src/tui.tsComponent, Focusable, cursor marker, focus, overlay, input dispatch.
  • packages/tui/src/utils.ts — width/truncation/sanitization primitives.
  • packages/tui/src/keys.ts / keybindings.ts — key parsing and configurable action mapping.
  • packages/coding-agent/src/modes/controllers/extension-ui-controller.ts — interactive mounting/unmounting for extension/hook/custom-tool UI.
  • packages/coding-agent/src/extensibility/extensions/types.ts — extension UI and renderer contracts.
  • packages/coding-agent/src/extensibility/hooks/types.ts — hook UI contract (legacy custom signature).
  • packages/coding-agent/src/extensibility/custom-tools/types.ts — custom tool execute/render contracts.
  • packages/coding-agent/src/modes/components/tool-execution.ts — mounting renderCall/renderResult components and partial-state options.
  • packages/coding-agent/src/tools/context.ts — tool UI context propagation (hasUI, ui).