1dba122c创建于 1 天前历史提交

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

Mode ctx.ui.custom(...) availability Notes
Interactive TUI Supported Component is mounted in the editor area or overlay, focused, and must call done(result) to resolve.
Background/headless Not interactive UI context is no-op (hasUI === false).
RPC mode Not mounted custom() is implemented as unsupported UI and returns undefined as never; do not depend on interactive UI in RPC handlers.

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:

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

Focusable is separate:

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. Do not intentionally exceed width on any line. The renderer truncates overwide non-image lines as a last-resort guard, but components should still return width-safe output.
  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:

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:

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

Key release/repeat events

Key release events are filtered unless your component sets:

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). In interactive extension/custom UI, custom(..., { overlay: true }) mounts your component through TUI.showOverlay(...); without overlay, it replaces the editor component area directly.
  • Overlay custom UI is anchored at bottom-center with full terminal width/max height and is removed through the returned overlay handle when done(...) closes the flow.

Mount points and return contracts

1) Extension UI (ExtensionUIContext)

Current signature (extensibility/extensions/types.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.
  • Without options.overlay, replaces the editor component with your component.
  • With options.overlay, mounts your component as a bottom-centered overlay instead of replacing the editor.
  • Focuses your component.
  • On done(result): calls component.dispose?.(), hides the overlay if present, restores editor + text for non-overlay flows, 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:

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, options, 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:

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)

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).