Example

Add a slash command and a custom screen that respect the mode rule

A /guard command that opens a dialog under RPC and prints without a UI, and a custom screen that only exists in a real terminal.

Whether a command can do anything at all depends on its mode: dialogs need ctx.hasUI, custom screens need a real terminal. This example is the smallest /guard command, that two-guard rule, and a column-sized component — all observed on a scripted model.

Run it

Use Pi 1.0.4 and one of the example repository’s test runners. The smallest command is one registration, loaded with pi -e ./guard.ts:

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

export default function (pi: ExtensionAPI) {
	pi.registerCommand("guard", {
		description: "Inspect and configure the path guard",
		handler: async (args, ctx) => {
			ctx.ui.notify(`guard received: "${args}"`, "info");
		},
	});
}

The full command, from guard-ui.ts, branches on the two questions — dialogs work wherever ctx.hasUI is true, and with no UI it says so on the way out:

			if (ctx.hasUI) {
				const choice = await ctx.ui.select("Path guard", ["Show blocked calls", "Pause the guard", "Resume the guard"]);
				if (choice === "Show blocked calls") {
					ctx.ui.notify(`${blocks.length} blocked call(s) this session`, "info");
				}
				return;
			}

			// JSON and print: no UI at all. Say something useful on the way out.
			console.log(`[guard] ${blocks.length} blocked call(s); run this in interactive mode to configure`);

A custom screen is a tui thing, not a hasUI thing — under RPC it is refused, never attempted:

			if (ctx.mode !== "tui") {
				ctx.ui.notify("/guard-blocks requires interactive mode", "error");
				return;
			}

The screen renders through a reusable component from block-list.ts that draws lines for a width, measures display columns, and caches by width:

	render(width: number): string[] {
		// Cache by width: re-laying-out on every keystroke is what makes a terminal feel slow.
		if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;

		const lines = [this.theme.fg("accent", this.theme.bold("Blocked calls")), ""];
		if (this.blocks.length === 0) lines.push(this.theme.fg("dim", "none on this branch"));
		for (const b of this.blocks) lines.push(`${b.tool}: ${b.reason}`);
		lines.push("", this.theme.fg("dim", "press q or Escape to close"));

		// Every line must fit the width, measured in display columns, not string length.
		this.cachedLines = lines.map((l) => (visibleWidth(l) > width ? truncateToWidth(l, width) : l));
		this.cachedWidth = width;
		return this.cachedLines;
	}

Expected outcome

Mode ctx.mode ctx.hasUI /guard /guard-blocks
Interactive "tui" true Dialog Custom screen
RPC-style client "rpc" true Dialog answered through the client Refused
JSON / print "json" / "print" false Prints Prints

The RPC and print rows are observed; the custom screen and the entry renderer were not run — custom screens need a real terminal.

Mechanism and limitations

Documented: extensions.md, “UI and modes”: interactive provides the complete terminal UI; RPC forwards dialogs and notifications but not custom terminal components; JSON and print have no UI. Guard terminal-only behaviour with ctx.mode === "tui" and use ctx.hasUI for what interactive and RPC clients support. Observed: ui.test.ts binds a recording stand-in for the interface and asserts each row: with a UI, /guard opened a select and answered through notify; with no UI it printed; under RPC /guard-blocks refused before attempting it; the component’s lines never exceeded 20, 40 or 80 columns including wide characters, its output was cached by width, and q/Escape finished through the completion callback.

Full source and test .

Understand this example

Copy this prompt into your AI tool. No code runs here.

Apply this example

Copy this prompt into your AI tool. No code runs here.