← Pi Agents

Slash Commands and Custom UI

How an extension adds a / command and renders its own screen, and the mode rules that decide whether that screen exists at all.

Your guard extension logs blocked writes. The reader wants to see them. The obvious move is a /guard command that opens a picker. You write it, then invoke the command in print mode on a CI machine. There is no UI to show the picker, so a command written only for interactive mode cannot complete that interaction.

The absence of a UI is expected, but silent failure is not a useful design. Handle modes explicitly before opening a dialog. This chapter covers the available UI surfaces and the mode rules that constrain them.

A command is a registration, not a script

pi.registerCommand(name, options) adds a /name entry to the same menu that holds built-in commands, prompt templates, and skills. slash-commands.md is explicit that “the command menu in Pi is therefore the exact reference for the resources loaded in your session.” Your command is not a second-class citizen; it sits beside /model and /tree.

The smallest useful command needs a name, a description, and a handler. The handler receives the raw argument text and a context:

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");
		},
	});
}

description is optional, but it is worth supplying: it helps users identify the command in completion and is exposed as metadata to clients. rpc-commands.md describes command objects returned by get_commands with name, optional description, source, and sourceInfo. A user can also invoke a known command directly without finding it in the menu.

Arguments are yours to parse. If your command wants structured completion, supply getArgumentCompletions, which returns { value, label } objects for a prefix or null:

pi.registerCommand("guard", {
	description: "Inspect and configure the path guard",
	getArgumentCompletions: (prefix) => {
		const modes = ["status", "pause", "resume", "reset"];
		const hit = modes.filter((m) => m.startsWith(prefix));
		return hit.length ? hit.map((m) => ({ value: m, label: m })) : null;
	},

Documented shape, taken from the shipped commands.ts example, which filters a list of sources against a prefix and returns null when nothing matches.

The three levels of interface

tui.md gives you a ladder, and the top of it is where most people should stop:

Need Use
Select, confirm, input, or multi-line editor ctx.ui.select(), confirm(), input(), editor()
Non-blocking feedback ctx.ui.notify() or setStatus()
Persistent content near the editor ctx.ui.setWidget()
Replace the header, footer, or editor The corresponding ctx.ui component factory
Temporary interactive screen or overlay ctx.ui.custom()
Custom rendering for a tool or session entry An extension renderer

The instruction that follows the table matters: “Use ctx.ui.custom() only when the interaction needs its own rendering and input.” If you can express the interaction with a dialog, use a dialog.

So the guard’s menu is a select:

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");
}

The mode rule

Here is the sentence to memorise. extensions.md, section “UI and modes”:

Extensions load in interactive, RPC, JSON, and print modes. Interactive mode provides the complete terminal UI. RPC can forward supported dialogs and notifications through the RPC Extension UI protocol, but not custom terminal components; JSON and print modes have no UI. Guard terminal-only behavior with ctx.mode === "tui" and use ctx.hasUI for interactions supported by interactive and RPC clients.

Documented, and it hands you two different guards. Interactive mode supplies terminal UI; RPC can support dialogs when a connected client implements the extension UI protocol. Check ctx.hasUI rather than assuming that every RPC client can answer. Use ctx.mode === "tui" when the feature requires a terminal component. A dialog is a hasUI feature; a custom terminal component is a tui feature.

Mode ctx.mode ctx.hasUI Dialogs and notify() ctx.ui.custom()
Interactive "tui" true yes yes
RPC with extension UI support "rpc" true forwarded to the client returns undefined
JSON "json" false no UI at all no UI at all
Print "print" false no UI at all no UI at all

The RPC row assumes a client with extension UI support; otherwise treat the invocation as having no interactive UI. rpc-extension-ui.md distinguishes dialogs (select, confirm, input, editor), which wait for a client response, from fire-and-forget calls (notify, setStatus, setWidget, setTitle). Terminal-only operations are not made available by RPC: custom() returns undefined, and unsupported UI methods degrade or do nothing.

Here is a command that respects both:

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;
}

// No interactive UI. Report status through a non-transcript channel.
// Avoid console.log here: stdout may be a machine-readable JSON event stream.
console.error(`[guard] ${blocks.length} blocked call(s); use interactive mode to configure`);

This is the difference between an extension that is “just terminal-only” and one that is “terminal-aware”. The tool and event behaviour stays identical; only the presentation branch changes. extensions.md insists on exactly this separation: “Keep tool and event behavior independent from rendering so non-interactive modes remain functional.”

    flowchart TD
  EXT["your extension code"] --> Q{"what does it need?"}
  Q -->|"a dialog"| H{"ctx.hasUI?"}
  H -->|yes| D["select, confirm, input, editor<br/>in TUI: a dialog; in RPC: forwarded"]
  H -->|no| REFUSE["do not proceed, and say why<br/>nobody can answer"]
  Q -->|"a custom screen"| T{"ctx.mode === 'tui'?"}
  T -->|yes| C["ctx.ui.custom()<br/>you own the area, the keys, the lifecycle"]
  T -->|"no, e.g. RPC"| UNDEF["custom() returns undefined<br/>degrade to something else"]
    Q -->|"a terminal component"| G{"ctx.mode === 'tui'?"}
  G -->|yes| C
  G -->|no| FALL["fall back to a dialog or plain output"]
  

The two guards answer different questions and are not interchangeable. hasUI means someone can be asked; mode === "tui" means there is a terminal to draw on. The RPC column is where conflating them breaks: it has a UI that can ask, and no terminal to take over.

When you genuinely need a screen

Sometimes the interaction is not a dialog. A board game, a diff reviewer, a live meter — those need their own rendering, their own keyboard handling, their own lifecycle. That is ctx.ui.custom(), and it is a promise you must keep carefully.

From tui.md: ctx.ui.custom() “temporarily gives one component control of the interactive area and resolves when that component calls the supplied completion callback.” You get a factory; you return a component; you call the callback when the interaction ends.

The shipped todo example is the shape. It defines a small class with render(width) and invalidate(), then opens it:

pi.registerCommand("guard-blocks", {
	description: "Show blocked calls recorded on this branch",
	handler: async (_args, ctx) => {
		if (ctx.mode !== "tui") {
			ctx.ui.notify("/guard-blocks requires interactive mode", "error");
			return;
		}

		await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
			return new BlockListComponent(blocks, theme, () => done());
		});
	},
});

The BlockListComponent it opens is a complete class. Here it is whole, because the rules in the next list are easier to see in one than to take on trust:

import type { Component } from "@earendil-works/pi-tui";
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";

export interface GuardBlock {
	tool: string;
	reason: string;
	at: number;
}

// The theme methods this component uses. pi-tui passes the real theme in.
export interface BlockListTheme {
	fg(color: string, text: string): string;
	bold(text: string): string;
}

// A custom component renders an array of lines for a width, and must say when
// its cached output is stale.
export class BlockListComponent implements Component {
	private cachedWidth?: number;
	private cachedLines?: string[];
	private readonly blocks: readonly GuardBlock[];
	private readonly theme: BlockListTheme;
	private readonly close: () => void;

	// Explicit fields rather than constructor parameter properties, so the file
	// also runs under Node's type stripping.
	constructor(blocks: readonly GuardBlock[], theme: BlockListTheme, close: () => void) {
		this.blocks = blocks;
		this.theme = theme;
		this.close = close;
	}

	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;
	}

	handleInput(data: string): void {
		if (data === "q" || matchesKey(data, "escape")) this.close();
	}

	invalidate(): void {
		this.cachedWidth = undefined;
		this.cachedLines = undefined;
	}
}

Four rules from tui.md make this correct rather than merely working:

  • Every rendered line must fit the supplied width. Measure display columns, not string length, because ANSI escapes and wide characters change width. Use visibleWidth(), truncateToWidth(), sliceByColumn(), wrapTextWithAnsi() rather than writing your own.
  • After changing state, invalidate the affected component and call the injected tui.requestRender().
  • Treat each component instance as belonging to one interaction. Create a new instance each time.
  • Finish with the completion callback. Do not call OverlayHandle.hide() on an overlay you created with ctx.ui.custom().

The guard’s block list caches its lines by width. That is useful for an unchanged list, but it also means changes to the list require invalidate() followed by a render request; otherwise the component can show stale lines. Cache layout only for as long as the underlying state remains unchanged.

Rendering stored entries in the transcript

There is a fourth interface, and it is the one that makes state visible rather than merely stored. pi.registerEntryRenderer(customType, renderer) draws your custom entries in interactive mode, where the previous chapter put them.

import { Box, Text } from "@earendil-works/pi-tui";

pi.registerEntryRenderer<GuardBlock>("guard.block", (entry, { expanded }, theme) => {
	const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
	box.addChild(new Text(`${theme.fg("accent", "[guard]")} ${entry.data?.reason ?? "blocked"}`, 0, 0));
	if (expanded) {
		box.addChild(new Text(theme.fg("dim", new Date(entry.data?.at ?? Date.now()).toLocaleString()), 0, 0));
	}
	return box;
});

This is the entry-renderer.ts example adapted. session-format.md confirms the pairing is legitimate: “Interactive mode can render custom entries via pi.registerEntryRenderer(customType, renderer), but they still do not participate in LLM context.” Visible to you, invisible to the model. That is the useful asymmetry, and it is the reason a guard’s audit trail belongs in a custom entry rather than in a custom_message.

Where renderers do not work

Renderers are interactive-only by nature. In JSON mode there is no transcript to draw into; in print mode there is no screen. The debug-provider example, which stores captured provider events as custom entries and renders them, is therefore a debugging tool you use interactively and then inspect as data in a session file.

Check it yourself

pi --extension ./guard.ts

Run /guard and pick from the menu. Then, in the same project:

pi --extension ./guard.ts --print "/guard status"

Do not interpret this command as a guard test. Starting a second Pi process does not by itself demonstrate that the same extension was loaded, and the model may not make the requested tool call. To verify mode separation, load the guard explicitly in a disposable test project, invoke /guard through the command interface in both modes, and separately assert that a scripted blocked tool call never executes. The reported tests below cover the UI branches; they do not establish this end-to-end file-write scenario.

What was run

examples/ch20-ui/ contains the complete extension and the BlockListComponent. The command and dialog paths were exercised through a real session with a recording UI stand-in. This verifies the reported mode-dependent command behavior without claiming that an actual terminal drew the custom screen.

  • bound as an RPC-style client (hasUI true, not a terminal), /guard opens the select dialog and reports the answer through notify;
  • with no UI, the same command prints instead;
  • hasUI is not the same as a terminal: in that RPC-style mode /guard-blocks refuses with “requires interactive mode” and never attempts the custom screen;
  • the component’s lines never exceed the width at 20, 40 and 80 columns, including wide characters, because it measures with visibleWidth();
  • its output is cached by width and recomputed after invalidate(), and q and Escape both finish through the completion callback.

What was not run: an end-to-end interactive rendering of the custom screen or the entry renderer. Both compile against the pinned declarations, and the component is tested directly for layout and input handling, but no test here shows either rendered in a real terminal.

One practical note found on the way: the component uses explicit fields rather than TypeScript constructor parameter properties, so the file also runs under Node’s built-in type stripping, which rejects the shorthand.

What this chapter does not settle

It does not cover pi.registerShortcut() or pi.registerFlag(), which extensions.md lists as the two remaining integration points for adding a keyboard shortcut or a command-line option. Those belong with the keybinding material in the terminal-interface chapter, and they are not the same thing: a flag changes startup, a shortcut changes input.

Next: MCP Servers — the guard stops being the only thing between the model and the outside world as we register an issue-tracker server and discover that annotations are advisory, not permission.