← 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, run pi --print on a CI machine, and the command silently does nothing because print mode has no UI at all.

That silence is not a bug. It is the rule you must design around before you write the command, not after. This chapter is about that rule, and about the three levels of interface Pi actually offers an extension.

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 in principle but you should always write it, because the menu is the only place a user discovers the command. rpc-commands.md describes command objects returned by get_commands with name, optional description, source, and sourceInfo, so your description becomes structured metadata, not just a menu label.

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;
  },
  handler: async (args, ctx) => {
    // dispatch on args
  },
});

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:

pi.registerCommand("guard", {
  description: "Inspect and configure the path guard",
  handler: async (_args, ctx) => {
    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. ctx.hasUI is true in interactive and RPC clients. ctx.mode === "tui" is true only when a real terminal is attached. A dialog is a hasUI thing. A custom component is a tui thing.

Mode ctx.mode ctx.hasUI Dialogs and notify() ctx.ui.custom()
Interactive "tui" true yes yes
RPC "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

Set against the declared ExtensionMode values, that is the whole matrix. The RPC row is where the two guards diverge: rpc-extension-ui.md splits the methods into dialogs (select, confirm, input, editor), which block on a client response, and fire-and-forget calls (notify, setStatus, setWidget, setTitle), which do not. Everything needing terminal access degrades — custom() returns undefined, most setters are no-ops, getTheme() returns undefined.

Here is a command that respects both:

pi.registerCommand("guard", {
  description: "Inspect and configure the path guard",
  handler: async (_args, ctx) => {
    if (ctx.hasUI) {
      const choice = await ctx.ui.select("Path guard", ["Status", "Pause", "Resume"]);
      ctx.ui.notify(`Chose: ${choice ?? "cancelled"}`, "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`);
  },
});

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

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

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, which is the pattern the todo example uses, and it is worth copying: a transcript that re-lays-out on every keystroke is the single most common way an extension makes a terminal feel slow.

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 --print "edit .env and add a token"

The guard still blocks the write — the tool_call handler is unchanged — but the command path prints to stderr instead of opening a menu. Nothing about the safety behaviour changed; only the presentation did. That is the whole discipline.

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.