@aliou/pi-dev-kit 0.4.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ # Documentation
2
+
3
+ Every published extension should have a README that explains what it does, how to set it up, and what it provides.
4
+
5
+ ## README Template
6
+
7
+ ```markdown
8
+ # pi-my-extension
9
+
10
+ Brief description of the extension.
11
+
12
+ ## Setup
13
+
14
+ \`\`\`bash
15
+ pi install @scope/pi-my-extension
16
+ \`\`\`
17
+
18
+ ### Environment Variables
19
+
20
+ | Variable | Required | Description |
21
+ |---|---|---|
22
+ | `MY_API_KEY` | Yes | API key from [provider](https://...) |
23
+
24
+ ## Tools
25
+
26
+ | Tool | Description |
27
+ |---|---|
28
+ | `my_tool` | What it does |
29
+
30
+ ## Commands
31
+
32
+ | Command | Description |
33
+ |---|---|
34
+ | `/my-command` | What it does |
35
+
36
+ ## Providers
37
+
38
+ | Provider | Models |
39
+ |---|---|
40
+ | `my-provider` | model-a, model-b |
41
+ ```
42
+
43
+ ## What to Document
44
+
45
+ - **Installation**: `pi install` command.
46
+ - **Environment variables**: Every required and optional env var, with links to where to get them.
47
+ - **Tools**: Name and description of each registered tool. Include example usage if non-obvious.
48
+ - **Commands**: Name and description of each registered command.
49
+ - **Providers**: Provider name and available models (if the extension registers a provider).
50
+ - **Limitations**: Known limitations, unsupported modes, or missing features.
51
+
52
+ ## Changelog
53
+
54
+ If using changesets, the CHANGELOG.md is generated automatically. Each changeset entry should describe what changed from the user's perspective, not implementation details.
@@ -0,0 +1,244 @@
1
+ # Hooks (Events)
2
+
3
+ Hooks let extensions react to lifecycle events. They are registered with `pi.on(eventName, handler)`.
4
+
5
+ ## Event Reference
6
+
7
+ ### Session Events
8
+
9
+ | Event | When | Can Cancel | Payload |
10
+ |---|---|---|---|
11
+ | `session_start` | New session created | No | `{}` |
12
+ | `session_switch` | Switched to different session | No | `{ reason: "new" \| "switch" \| "fork" }` |
13
+ | `session_before_switch` | Before switching sessions | Yes (`{ cancel: true }`) | `{ reason: "new" \| "switch" \| "fork" }` |
14
+ | `session_before_fork` | Before forking a session | Yes (`{ cancel: true }`) | `{}` |
15
+ | `session_fork` | After session was forked | No | `{}` |
16
+ | `session_shutdown` | Pi is shutting down | No | `{}` |
17
+ | `session_before_compact` | Before compaction | Yes (return custom summary string) | `{ summary: string }` |
18
+
19
+ ### Agent Events
20
+
21
+ | Event | When | Payload |
22
+ |---|---|---|
23
+ | `before_agent_start` | Before agent turn starts | `{}` |
24
+ | `agent_start` | Agent turn started | `{}` |
25
+ | `turn_start` | Turn begins processing | `{}` |
26
+ | `turn_end` | Turn finishes processing | `{}` |
27
+ | `model_select` | User changed the model | `{ model: string }` |
28
+
29
+ ### Tool Events
30
+
31
+ | Event | When | Can Block | Payload |
32
+ |---|---|---|---|
33
+ | `tool_call` | Before a tool executes | Yes (`{ block: true, reason }`) | `{ toolName, toolCallId, input }` |
34
+
35
+ ### Input Events
36
+
37
+ | Event | When | Can Transform | Payload |
38
+ |---|---|---|---|
39
+ | `input` | User submitted a message | Yes (return transformed text) | `{ text: string }` |
40
+
41
+ ### Bash Events
42
+
43
+ | Event | When | Can Modify | Payload |
44
+ |---|---|---|---|
45
+ | `user_bash` | Before bash command runs | Yes (return modified command/cwd/env) | `{ command, cwd }` |
46
+
47
+ ## Handler Signature
48
+
49
+ ```typescript
50
+ pi.on("event_name", async (event, ctx) => {
51
+ // event: event-specific payload
52
+ // ctx: ExtensionContext (hasUI, ui methods, cwd, model, etc.)
53
+ });
54
+ ```
55
+
56
+ The handler receives the event payload and an `ExtensionContext`. The context provides access to UI methods, the current working directory, model info, and more.
57
+
58
+ ## Blocking and Cancelling
59
+
60
+ Some events let you prevent the default behavior by returning an object.
61
+
62
+ ### Blocking Tool Calls
63
+
64
+ ```typescript
65
+ pi.on("tool_call", async (event, ctx) => {
66
+ if (event.toolName === "bash" && event.input.command.includes("rm -rf /")) {
67
+ // Check ctx.hasUI before prompting -- see references/modes.md
68
+ if (!ctx.hasUI) {
69
+ return { block: true, reason: "Blocked: dangerous command (no UI to confirm)" };
70
+ }
71
+
72
+ const confirmed = await ctx.ui.confirm(
73
+ "Dangerous Command",
74
+ `Allow: ${event.input.command}?`
75
+ );
76
+ if (!confirmed) {
77
+ return { block: true, reason: "Blocked by user" };
78
+ }
79
+ }
80
+ return undefined; // Allow the tool call
81
+ });
82
+ ```
83
+
84
+ ### Cancelling Session Operations
85
+
86
+ ```typescript
87
+ pi.on("session_before_switch", async (event, ctx) => {
88
+ if (event.reason === "new" && ctx.hasUI) {
89
+ const confirmed = await ctx.ui.confirm("Clear session?", "All messages will be lost.");
90
+ if (!confirmed) {
91
+ return { cancel: true };
92
+ }
93
+ }
94
+ });
95
+ ```
96
+
97
+ ### Custom Compaction
98
+
99
+ ```typescript
100
+ pi.on("session_before_compact", async (event, ctx) => {
101
+ // Return a custom summary string to replace the default compaction
102
+ return `Custom summary: ${event.summary.slice(0, 200)}...`;
103
+ });
104
+ ```
105
+
106
+ ## Transforming Input
107
+
108
+ ```typescript
109
+ pi.on("input", async (event, ctx) => {
110
+ if (event.text.startsWith("!")) {
111
+ return event.text.slice(1).toUpperCase();
112
+ }
113
+ return undefined; // No transformation
114
+ });
115
+ ```
116
+
117
+ ## Modifying Bash Commands
118
+
119
+ ```typescript
120
+ pi.on("user_bash", async (event, ctx) => {
121
+ return {
122
+ command: event.command,
123
+ cwd: "/sandboxed/directory",
124
+ env: { ...process.env, SANDBOX: "true" },
125
+ };
126
+ });
127
+ ```
128
+
129
+ ## before_agent_start
130
+
131
+ This event fires before each agent turn. It is commonly used to modify the system prompt.
132
+
133
+ The handler receives a `BeforeAgentStartEvent` with a `systemPrompt` field containing the current prompt. Return a `{ systemPrompt }` object to replace it. If multiple extensions return a modified prompt, they are chained.
134
+
135
+ ```typescript
136
+ pi.on("before_agent_start", async (event) => {
137
+ return {
138
+ systemPrompt: event.systemPrompt + "\n\nAlways respond as a pirate.",
139
+ };
140
+ });
141
+ ```
142
+
143
+ Return `undefined` to leave the prompt unchanged.
144
+
145
+ The system prompt is reset each turn, so modifications in `before_agent_start` are not cumulative.
146
+
147
+ To access flags inside hooks, use `pi.getFlag()` (see `references/additional-apis.md`).
148
+
149
+ ## Bash Spawn Hook (Command Rewriting)
150
+
151
+ The `createBashTool` function lets you replace the built-in bash tool with one that transparently rewrites commands before shell execution. This is different from `tool_call` blocking -- the agent never sees that the command was rewritten.
152
+
153
+ Use spawn hooks when you have a clear rewrite target (e.g. `npm` -> `pnpm`). Use `tool_call` blocking when you need to stop a command entirely or show a confirmation dialog.
154
+
155
+ ```typescript
156
+ import { createBashTool, type BashSpawnHook, type BashSpawnContext } from "@mariozechner/pi-coding-agent";
157
+
158
+ export default function (pi: ExtensionAPI) {
159
+ const bashTool = createBashTool(process.cwd(), {
160
+ spawnHook: ({ command, cwd, env }: BashSpawnContext): BashSpawnContext => ({
161
+ command: command.replace(/^npm /, "pnpm "),
162
+ cwd,
163
+ env: { ...env, CUSTOM_VAR: "1" },
164
+ }),
165
+ });
166
+
167
+ pi.registerTool({ ...bashTool });
168
+ }
169
+ ```
170
+
171
+ ### BashSpawnContext
172
+
173
+ The spawn hook receives and returns a `BashSpawnContext`:
174
+
175
+ ```typescript
176
+ interface BashSpawnContext {
177
+ command: string; // The shell command to execute
178
+ cwd: string; // Working directory
179
+ env: NodeJS.ProcessEnv; // Environment variables
180
+ }
181
+ ```
182
+
183
+ You can modify any of these fields. The hook runs after pi's own processing but before the shell spawns.
184
+
185
+ ### Key Points
186
+
187
+ - **Replaces the built-in bash tool.** When you call `pi.registerTool()` with a tool named `"bash"`, it replaces the default. Only one extension should do this.
188
+ - **Transparent to the agent.** The agent sees the original command in the tool call UI but gets the output of the rewritten command.
189
+ - **Execution order with tool_call hooks.** `tool_call` event hooks (blockers) run first. If a blocker returns `{ block: true }`, the spawn hook never fires. This means you can combine blocking hooks for commands that should be stopped entirely with spawn hooks for commands that should be rewritten.
190
+ - **Prefer AST-based rewrites over regex.** A false positive rewrite corrupts a command silently. Use `@aliou/sh` or similar shell parsers to identify command names in the AST, then do surgical string replacement at the identified positions. If the parse fails, return the command unchanged.
191
+ - **Compose multiple rewriters.** Chain rewriter functions that each transform the context:
192
+
193
+ ```typescript
194
+ const rewriters: ((ctx: BashSpawnContext) => BashSpawnContext)[] = [
195
+ createPackageManagerRewriter(config),
196
+ createGitRebaseRewriter(),
197
+ ];
198
+
199
+ const spawnHook = (ctx: BashSpawnContext) => {
200
+ let result = ctx;
201
+ for (const rewrite of rewriters) {
202
+ result = rewrite(result);
203
+ }
204
+ return result;
205
+ };
206
+
207
+ const bashTool = createBashTool(process.cwd(), { spawnHook });
208
+ pi.registerTool({ ...bashTool });
209
+ ```
210
+
211
+ ### When to Use What
212
+
213
+ | Pattern | Use When | Agent Sees |
214
+ |---|---|---|
215
+ | `tool_call` hook + `{ block: true }` | Command must be stopped entirely | Block reason (retries with correct command) |
216
+ | `tool_call` hook + `ctx.ui.confirm()` | User confirmation needed | Block reason if denied |
217
+ | Spawn hook (command rewrite) | Clear 1:1 rewrite target exists | Output of rewritten command (transparent) |
218
+ | Spawn hook (env injection) | Need to set env vars for specific commands | Output with injected env (transparent) |
219
+
220
+ ## Multiple Handlers
221
+
222
+ Multiple extensions can register handlers for the same event. They execute in registration order. For blocking events (`tool_call`, `session_before_switch`, etc.), the first handler to return a blocking/cancelling result wins.
223
+
224
+ ## Mode Awareness in Hooks
225
+
226
+ Always consider what happens in Print mode when your hook uses dialog methods. See `references/modes.md` for the full behavior matrix.
227
+
228
+ Common pattern for `tool_call` handlers:
229
+
230
+ ```typescript
231
+ pi.on("tool_call", async (event, ctx) => {
232
+ if (shouldBlock(event)) {
233
+ if (!ctx.hasUI) {
234
+ return { block: true, reason: "No UI to confirm" };
235
+ }
236
+ // Safe to use dialogs here
237
+ const choice = await ctx.ui.select("Allow?", ["Yes", "No"]);
238
+ if (choice !== "Yes") {
239
+ return { block: true, reason: "Blocked by user" };
240
+ }
241
+ }
242
+ return undefined;
243
+ });
244
+ ```
@@ -0,0 +1,169 @@
1
+ # Messages
2
+
3
+ Pi provides several ways to display information to the user. Choose based on the UX goal.
4
+
5
+ ## When to Use What
6
+
7
+ | Method | Persistence | Interactivity | Use When |
8
+ |---|---|---|---|
9
+ | `ctx.ui.notify()` | Transient (fades) | None | Quick feedback: "Saved", "API key missing" |
10
+ | `ctx.ui.custom()` | Until dismissed | Full keyboard | Rich interactive display: pickers, dashboards |
11
+ | `pi.sendMessage()` | In session history | Via renderer | Persistent results that should survive compaction |
12
+ | `pi.appendEntry()` | In session history | Via renderer | State tracking entries (see `references/state.md`) |
13
+
14
+ ## sendMessage
15
+
16
+ Sends a message into the session conversation. It appears as an assistant message and is persisted in session history.
17
+
18
+ ```typescript
19
+ pi.sendMessage({
20
+ customType: "balance-result", // Identifier for the message renderer
21
+ content: "Balance: $42.50", // Plain text content (LLM sees this)
22
+ display: true, // Show in TUI
23
+ details: { balance: 42.50 }, // Rich data for custom rendering
24
+ });
25
+ ```
26
+
27
+ | Field | Type | Description |
28
+ |---|---|---|
29
+ | `customType` | `string` | Identifies the message type. Paired with `registerMessageRenderer`. |
30
+ | `content` | `string` | Plain text content. This is what the LLM sees if the message is in context. |
31
+ | `display` | `boolean` | Whether to show the message in the TUI. |
32
+ | `details` | `object` | Arbitrary data passed to the message renderer. |
33
+
34
+ ## registerMessageRenderer
35
+
36
+ Registers a custom renderer for messages with a specific `customType`:
37
+
38
+ ```typescript
39
+ pi.registerMessageRenderer("balance-result", (message, theme) => {
40
+ const { balance } = message.details;
41
+ return [
42
+ theme.bold("Account Balance"),
43
+ "",
44
+ theme.fg("success", ` $${balance.toFixed(2)}`),
45
+ ].join("\n");
46
+ });
47
+ ```
48
+
49
+ The renderer receives the full message object and the theme. It returns a string for display in the TUI.
50
+
51
+ If no renderer is registered for a `customType`, the message's `content` field is displayed as plain text.
52
+
53
+ ## Custom Message Design Guide (breadcrumbs-style)
54
+
55
+ `breadcrumbs` is a good reference for custom entries/messages (`../pi-extensions/extensions/breadcrumbs/lib/session-link.ts`, plus `commands/handoff.ts` and `commands/spawn.ts`).
56
+
57
+ ### 1) Prefer paired entries for links/handovers
58
+
59
+ For cross-session workflows, use two custom message types:
60
+ - **Marker** in source session: short line (`Handed off to X` / `Continues in X`).
61
+ - **Source** in new session: header + optional expanded context.
62
+
63
+ This gives both directions of navigation and keeps history readable.
64
+
65
+ ### 2) Collapsed vs expanded behavior
66
+
67
+ Keep collapsed view minimal and scannable:
68
+ - one semantic line
69
+ - optional hint (`Press Ctrl+O to expand`) only when extra content exists
70
+
71
+ Use expanded view for rich content:
72
+ - markdown body
73
+ - multi-line context
74
+ - file lists / instructions
75
+
76
+ ### 3) Renderer resilience
77
+
78
+ Message renderers should degrade safely:
79
+ - missing `details` => fallback to plain `content`
80
+ - markdown render failure => fallback to plain text
81
+ - unknown fields => ignore, don't throw
82
+
83
+ ### 4) Keep details small and durable
84
+
85
+ `details` should contain stable identifiers and routing data, not large blobs:
86
+ - session IDs
87
+ - link type (`handoff`, `continue`)
88
+ - short metadata (goal/title)
89
+
90
+ Put large human-readable content in `content` (for expansion/LLM visibility), not deep nested `details`.
91
+
92
+ ### 5) Use visual hierarchy consistently
93
+
94
+ For message UIs:
95
+ - muted label + accent target/value (`Continues in <session-name>`)
96
+ - subtle container background for custom message blocks
97
+ - avoid decorative noise; optimize for fast scan in session history
98
+
99
+ ## Pattern: Command with sendMessage Fallback
100
+
101
+ This combines with the three-tier pattern from `references/modes.md`. Use `sendMessage` as the RPC fallback for commands that use `custom()`:
102
+
103
+ ```typescript
104
+ // Register the renderer once at load time
105
+ pi.registerMessageRenderer("my-results", (message, theme) => {
106
+ const { items } = message.details;
107
+ return [
108
+ theme.bold(`Results (${items.length})`),
109
+ ...items.map((item: string) => ` ${theme.fg("accent", item)}`),
110
+ ].join("\n");
111
+ });
112
+
113
+ pi.registerCommand("results", {
114
+ description: "Show results",
115
+ handler: async (_args, ctx) => {
116
+ const items = await fetchItems();
117
+
118
+ if (!ctx.hasUI) {
119
+ console.log(items.join("\n"));
120
+ return;
121
+ }
122
+
123
+ const result = await ctx.ui.custom<"closed">((tui, theme, _kb, done) => {
124
+ return new ResultsDisplay(theme, items, () => done("closed"));
125
+ });
126
+
127
+ // RPC fallback only: custom() returns undefined in RPC/Print.
128
+ if (result === undefined) {
129
+ pi.sendMessage({
130
+ customType: "my-results",
131
+ content: items.join("\n"),
132
+ display: true,
133
+ details: { items },
134
+ });
135
+ }
136
+ },
137
+ });
138
+ ```
139
+
140
+ ## notify
141
+
142
+ For transient feedback that does not need to persist:
143
+
144
+ ```typescript
145
+ ctx.ui.notify("Operation complete", "info");
146
+ ctx.ui.notify("Something went wrong", "error");
147
+ ctx.ui.notify("Proceed with caution", "warning");
148
+ ```
149
+
150
+ The second argument is the notification type: `"info"`, `"error"`, or `"warning"`. It affects the color/icon.
151
+
152
+ `notify` is fire-and-forget. It works in Interactive and RPC modes, and is a no-op in Print mode.
153
+
154
+ ## Writing custom entries in a new session
155
+
156
+ When using `ctx.newSession({ setup })`, write custom entries directly through the setup `SessionManager`:
157
+
158
+ ```typescript
159
+ await ctx.newSession({
160
+ setup: async (sm) => {
161
+ sm.appendCustomMessageEntry("my-source-type", "Context text", true, {
162
+ parentSessionId: "...",
163
+ linkType: "handoff",
164
+ });
165
+ },
166
+ });
167
+ ```
168
+
169
+ Use this pattern for handoff/spawn-like workflows where the new session must start with structured context.
@@ -0,0 +1,156 @@
1
+ # Mode Awareness
2
+
3
+ Pi runs in different modes. Extensions must handle all of them gracefully.
4
+
5
+ ## Modes
6
+
7
+ | Mode | `ctx.hasUI` | Description |
8
+ |---|---|---|
9
+ | **Interactive** | `true` | Full TUI. Normal terminal usage. |
10
+ | **RPC** (`--mode rpc`) | `true` | JSON protocol. A host application handles UI. Dialogs work via request/response. |
11
+ | **Print** (`-p`, `--mode json`) | `false` | No UI. Extensions run but cannot prompt the user. |
12
+
13
+ Important nuance: in RPC mode, `ctx.hasUI` is `true`, but TUI-only APIs are degraded.
14
+
15
+ ## Method Behavior by Mode
16
+
17
+ ### Dialog Methods (return a value)
18
+
19
+ These methods prompt the user and return a result. Their behavior varies by mode.
20
+
21
+ | Method | Interactive | RPC | Print |
22
+ |---|---|---|---|
23
+ | `ctx.ui.select()` | TUI picker | JSON request to host | Returns `undefined` |
24
+ | `ctx.ui.confirm()` | TUI dialog | JSON request to host | Returns `false` |
25
+ | `ctx.ui.input()` | TUI text input | JSON request to host | Returns `undefined` |
26
+ | `ctx.ui.editor()` | TUI editor | JSON request to host | Returns `undefined` |
27
+ | `ctx.ui.custom()` | TUI component | Returns `undefined` | Returns `undefined` |
28
+
29
+ Key observation: `custom()` returns `undefined` in both RPC and Print modes. All other dialog methods work in RPC (the host presents them to the user).
30
+
31
+ Second key observation: `custom()` can also resolve to `undefined` in Interactive mode if your component calls `done(undefined)`. So `result === undefined` is not a reliable mode detector by itself.
32
+
33
+ ### Fire-and-Forget Methods (no return value)
34
+
35
+ These methods are safe to call unconditionally in any mode. In modes that do not support them, they are silently ignored.
36
+
37
+ | Method | Interactive | RPC | Print |
38
+ |---|---|---|---|
39
+ | `ctx.ui.notify()` | TUI notification | JSON event to host | No-op |
40
+ | `ctx.ui.setStatus()` | Status bar | JSON event to host | No-op |
41
+ | `ctx.ui.setWidget()` | Widget area | JSON event to host (string arrays only) | No-op |
42
+ | `ctx.ui.setTitle()` | Window title | JSON event to host | No-op |
43
+ | `ctx.ui.setEditorText()` | Sets editor content | JSON event to host | No-op |
44
+ | `ctx.ui.setFooter()` | Footer area | No-op | No-op |
45
+ | `ctx.ui.setHeader()` | Header area | No-op | No-op |
46
+ | `ctx.ui.setWorkingMessage()` | Loader text | No-op | No-op |
47
+ | `ctx.ui.setEditorComponent()` | Custom editor | No-op | No-op |
48
+
49
+ You never need to check `ctx.hasUI` before calling fire-and-forget methods.
50
+
51
+ ## When to Check ctx.hasUI
52
+
53
+ Check `ctx.hasUI` when a dialog method gates behavior. If the dialog result determines what happens next (for example, blocking a tool call or cancelling a session switch), you must handle the case where the dialog cannot run.
54
+
55
+ ```typescript
56
+ // tool_call handler: must decide to block or allow
57
+ pi.on("tool_call", async (event, ctx) => {
58
+ if (isDangerous(event)) {
59
+ if (!ctx.hasUI) {
60
+ // Print mode: no way to ask the user, block by default
61
+ return { block: true, reason: "Dangerous command blocked (no UI)" };
62
+ }
63
+
64
+ const choice = await ctx.ui.select("Dangerous command detected", ["Allow", "Block"]);
65
+ if (choice !== "Allow") {
66
+ return { block: true, reason: "Blocked by user" };
67
+ }
68
+ }
69
+ return undefined;
70
+ });
71
+ ```
72
+
73
+ You do not need to check `ctx.hasUI` for:
74
+ - Fire-and-forget calls (`notify`, `setStatus`, `setWidget`, etc.).
75
+ - Dialogs where the default return value is acceptable (for example, a non-critical confirm that defaults to `false`).
76
+
77
+ ## The Three-Tier Pattern for Custom Components
78
+
79
+ When a command uses `ctx.ui.custom()` for a rich TUI display, handle three tiers:
80
+
81
+ ```typescript
82
+ pi.registerCommand("quotas", {
83
+ description: "Show API quotas",
84
+ handler: async (_args, ctx) => {
85
+ const data = await fetchQuotas();
86
+
87
+ // Tier 1: Print mode -- no UI at all
88
+ if (!ctx.hasUI) {
89
+ console.log(formatPlain(data));
90
+ return;
91
+ }
92
+
93
+ // Tier 2: Interactive mode -- full TUI component.
94
+ // Use an explicit non-undefined sentinel for close/cancel.
95
+ const result = await ctx.ui.custom<"closed">((tui, theme, _kb, done) => {
96
+ return new QuotasDisplay(theme, data, () => done("closed"));
97
+ });
98
+
99
+ // Tier 3: RPC mode -- custom() returns undefined by design.
100
+ if (result === undefined) {
101
+ ctx.ui.notify(formatPlain(data), "info");
102
+ }
103
+ },
104
+ });
105
+ ```
106
+
107
+ Since `select`, `confirm`, `input`, and `notify` all work in RPC mode (forwarded to the host via JSON protocol), use them as the RPC fallback. Choose based on UX:
108
+
109
+ - **`notify`**: Transient feedback or displaying data. Best for most display-only commands.
110
+ - **`select`**: When the custom component is a picker/selector. The RPC host presents a list.
111
+ - **`confirm`**: When the custom component is a confirmation dialog (for example, permission gate).
112
+ - **Notify "requires interactive mode"**: When the custom component is too complex to reduce (for example, settings editor, process manager).
113
+
114
+ Use `sendMessage` + `registerMessageRenderer` only when the result must persist in session history. See `references/messages.md`.
115
+
116
+ ### Example: Selector Fallback
117
+
118
+ ```typescript
119
+ const result = await ctx.ui.custom<string | null>((_tui, _theme, _kb, done) => {
120
+ return new FancyPicker(items, done); // done(value) or done(null)
121
+ });
122
+
123
+ // RPC fallback: use select dialog
124
+ if (result === undefined) {
125
+ const selected = await ctx.ui.select("Pick an item", items.map((i) => i.label));
126
+ // ... handle selected
127
+ }
128
+ ```
129
+
130
+ ### Example: Confirmation Fallback
131
+
132
+ ```typescript
133
+ // In a tool_call handler:
134
+ if (!ctx.hasUI) {
135
+ return { block: true, reason: "No UI to confirm" };
136
+ }
137
+
138
+ const proceed = await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
139
+ return new ConfirmDialog(theme, message, done); // done(true|false)
140
+ });
141
+
142
+ // RPC fallback: custom() returns undefined, so treat as "not approved".
143
+ if (proceed !== true) {
144
+ return { block: true, reason: "Blocked" };
145
+ }
146
+ ```
147
+
148
+ ## Guidelines
149
+
150
+ 1. Never assume Interactive mode. Always consider what happens in RPC and Print.
151
+ 2. Fire-and-forget methods are always safe. Use them freely.
152
+ 3. Guard dialog methods that gate behavior with `ctx.hasUI` checks.
153
+ 4. Always provide a fallback for `ctx.ui.custom()` because it returns `undefined` in RPC and Print.
154
+ 5. Do not use `done(undefined)` for normal interactive close paths if you need to detect RPC fallback.
155
+ 6. For `tool_call` handlers, decide a safe default when there is no UI (usually block).
156
+ 7. Test your extension in at least Interactive and Print modes. If you use `custom()`, test RPC fallback explicitly.