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