@mariozechner/pi-coding-agent 0.34.1 → 0.35.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.
Files changed (251) hide show
  1. package/CHANGELOG.md +224 -18
  2. package/README.md +233 -105
  3. package/dist/cli/args.d.ts +3 -4
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +13 -18
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/config.d.ts +2 -2
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +3 -3
  10. package/dist/config.js.map +1 -1
  11. package/dist/core/agent-session.d.ts +39 -50
  12. package/dist/core/agent-session.d.ts.map +1 -1
  13. package/dist/core/agent-session.js +166 -197
  14. package/dist/core/agent-session.js.map +1 -1
  15. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  16. package/dist/core/compaction/branch-summarization.js +3 -3
  17. package/dist/core/compaction/branch-summarization.js.map +1 -1
  18. package/dist/core/compaction/compaction.d.ts +1 -1
  19. package/dist/core/compaction/compaction.d.ts.map +1 -1
  20. package/dist/core/compaction/compaction.js +6 -5
  21. package/dist/core/compaction/compaction.js.map +1 -1
  22. package/dist/core/event-bus.d.ts +9 -0
  23. package/dist/core/event-bus.d.ts.map +1 -0
  24. package/dist/core/event-bus.js +25 -0
  25. package/dist/core/event-bus.js.map +1 -0
  26. package/dist/core/exec.d.ts +1 -1
  27. package/dist/core/exec.d.ts.map +1 -1
  28. package/dist/core/exec.js +1 -1
  29. package/dist/core/exec.js.map +1 -1
  30. package/dist/core/extensions/index.d.ts +10 -0
  31. package/dist/core/extensions/index.d.ts.map +1 -0
  32. package/dist/core/extensions/index.js +9 -0
  33. package/dist/core/extensions/index.js.map +1 -0
  34. package/dist/core/extensions/loader.d.ts +21 -0
  35. package/dist/core/extensions/loader.d.ts.map +1 -0
  36. package/dist/core/extensions/loader.js +400 -0
  37. package/dist/core/extensions/loader.js.map +1 -0
  38. package/dist/core/extensions/runner.d.ts +88 -0
  39. package/dist/core/extensions/runner.d.ts.map +1 -0
  40. package/dist/core/{hooks → extensions}/runner.js +52 -141
  41. package/dist/core/extensions/runner.js.map +1 -0
  42. package/dist/core/extensions/types.d.ts +461 -0
  43. package/dist/core/extensions/types.d.ts.map +1 -0
  44. package/dist/core/{hooks → extensions}/types.js +7 -4
  45. package/dist/core/extensions/types.js.map +1 -0
  46. package/dist/core/extensions/wrapper.d.ts +25 -0
  47. package/dist/core/extensions/wrapper.d.ts.map +1 -0
  48. package/dist/core/{hooks/tool-wrapper.js → extensions/wrapper.js} +39 -24
  49. package/dist/core/extensions/wrapper.js.map +1 -0
  50. package/dist/core/index.d.ts +2 -2
  51. package/dist/core/index.d.ts.map +1 -1
  52. package/dist/core/index.js +3 -2
  53. package/dist/core/index.js.map +1 -1
  54. package/dist/core/messages.d.ts +7 -7
  55. package/dist/core/messages.d.ts.map +1 -1
  56. package/dist/core/messages.js +4 -4
  57. package/dist/core/messages.js.map +1 -1
  58. package/dist/core/prompt-templates.d.ts +40 -0
  59. package/dist/core/prompt-templates.d.ts.map +1 -0
  60. package/dist/core/{slash-commands.js → prompt-templates.js} +31 -31
  61. package/dist/core/prompt-templates.js.map +1 -0
  62. package/dist/core/sdk.d.ts +29 -52
  63. package/dist/core/sdk.d.ts.map +1 -1
  64. package/dist/core/sdk.js +111 -211
  65. package/dist/core/sdk.js.map +1 -1
  66. package/dist/core/session-manager.d.ts +17 -17
  67. package/dist/core/session-manager.d.ts.map +1 -1
  68. package/dist/core/session-manager.js +25 -10
  69. package/dist/core/session-manager.js.map +1 -1
  70. package/dist/core/settings-manager.d.ts +3 -6
  71. package/dist/core/settings-manager.d.ts.map +1 -1
  72. package/dist/core/settings-manager.js +4 -11
  73. package/dist/core/settings-manager.js.map +1 -1
  74. package/dist/core/system-prompt.d.ts.map +1 -1
  75. package/dist/core/system-prompt.js +4 -2
  76. package/dist/core/system-prompt.js.map +1 -1
  77. package/dist/index.d.ts +4 -5
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +5 -6
  80. package/dist/index.js.map +1 -1
  81. package/dist/main.d.ts.map +1 -1
  82. package/dist/main.js +36 -33
  83. package/dist/main.js.map +1 -1
  84. package/dist/migrations.d.ts +7 -2
  85. package/dist/migrations.d.ts.map +1 -1
  86. package/dist/migrations.js +93 -4
  87. package/dist/migrations.js.map +1 -1
  88. package/dist/modes/interactive/components/bordered-loader.d.ts +1 -1
  89. package/dist/modes/interactive/components/bordered-loader.d.ts.map +1 -1
  90. package/dist/modes/interactive/components/bordered-loader.js +1 -1
  91. package/dist/modes/interactive/components/bordered-loader.js.map +1 -1
  92. package/dist/modes/interactive/components/branch-summary-message.d.ts +1 -1
  93. package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -1
  94. package/dist/modes/interactive/components/branch-summary-message.js +1 -1
  95. package/dist/modes/interactive/components/branch-summary-message.js.map +1 -1
  96. package/dist/modes/interactive/components/compaction-summary-message.d.ts +1 -1
  97. package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -1
  98. package/dist/modes/interactive/components/compaction-summary-message.js +1 -1
  99. package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -1
  100. package/dist/modes/interactive/components/custom-editor.d.ts +2 -2
  101. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  102. package/dist/modes/interactive/components/custom-editor.js +4 -4
  103. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  104. package/dist/modes/interactive/components/custom-message.d.ts +18 -0
  105. package/dist/modes/interactive/components/custom-message.d.ts.map +1 -0
  106. package/dist/modes/interactive/components/{hook-message.js → custom-message.js} +3 -3
  107. package/dist/modes/interactive/components/custom-message.js.map +1 -0
  108. package/dist/modes/interactive/components/dynamic-border.d.ts +2 -2
  109. package/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  110. package/dist/modes/interactive/components/dynamic-border.js +2 -2
  111. package/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  112. package/dist/modes/interactive/components/{hook-editor.d.ts → extension-editor.d.ts} +3 -3
  113. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -0
  114. package/dist/modes/interactive/components/{hook-editor.js → extension-editor.js} +4 -4
  115. package/dist/modes/interactive/components/extension-editor.js.map +1 -0
  116. package/dist/modes/interactive/components/{hook-input.d.ts → extension-input.d.ts} +3 -3
  117. package/dist/modes/interactive/components/extension-input.d.ts.map +1 -0
  118. package/dist/modes/interactive/components/{hook-input.js → extension-input.js} +3 -3
  119. package/dist/modes/interactive/components/extension-input.js.map +1 -0
  120. package/dist/modes/interactive/components/{hook-selector.d.ts → extension-selector.d.ts} +3 -3
  121. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -0
  122. package/dist/modes/interactive/components/{hook-selector.js → extension-selector.js} +3 -3
  123. package/dist/modes/interactive/components/extension-selector.js.map +1 -0
  124. package/dist/modes/interactive/components/footer.d.ts +3 -3
  125. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  126. package/dist/modes/interactive/components/footer.js +8 -8
  127. package/dist/modes/interactive/components/footer.js.map +1 -1
  128. package/dist/modes/interactive/components/tool-execution.d.ts +3 -3
  129. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  130. package/dist/modes/interactive/components/tool-execution.js +9 -9
  131. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  132. package/dist/modes/interactive/interactive-mode.d.ts +37 -44
  133. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  134. package/dist/modes/interactive/interactive-mode.js +143 -189
  135. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  136. package/dist/modes/print-mode.d.ts.map +1 -1
  137. package/dist/modes/print-mode.js +10 -33
  138. package/dist/modes/print-mode.js.map +1 -1
  139. package/dist/modes/rpc/rpc-client.d.ts +3 -3
  140. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  141. package/dist/modes/rpc/rpc-client.js +3 -3
  142. package/dist/modes/rpc/rpc-client.js.map +1 -1
  143. package/dist/modes/rpc/rpc-mode.d.ts +2 -2
  144. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  145. package/dist/modes/rpc/rpc-mode.js +33 -57
  146. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  147. package/dist/modes/rpc/rpc-types.d.ts +16 -16
  148. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  149. package/dist/modes/rpc/rpc-types.js.map +1 -1
  150. package/docs/extensions.md +1053 -0
  151. package/docs/rpc.md +4 -4
  152. package/docs/sdk.md +62 -93
  153. package/docs/session.md +22 -19
  154. package/docs/skills.md +1 -1
  155. package/docs/tui.md +1 -1
  156. package/examples/README.md +9 -15
  157. package/examples/extensions/README.md +141 -0
  158. package/examples/{hooks → extensions}/auto-commit-on-exit.ts +3 -3
  159. package/examples/extensions/chalk-logger.ts +26 -0
  160. package/examples/{hooks → extensions}/confirm-destructive.ts +3 -3
  161. package/examples/{hooks → extensions}/custom-compaction.ts +6 -6
  162. package/examples/{hooks → extensions}/dirty-repo-guard.ts +8 -4
  163. package/examples/{hooks → extensions}/file-trigger.ts +3 -3
  164. package/examples/{hooks → extensions}/git-checkpoint.ts +3 -3
  165. package/examples/{hooks → extensions}/handoff.ts +3 -3
  166. package/examples/extensions/hello.ts +25 -0
  167. package/examples/{hooks → extensions}/permission-gate.ts +3 -3
  168. package/examples/{hooks → extensions}/pirate.ts +5 -5
  169. package/examples/{hooks → extensions}/plan-mode.ts +6 -6
  170. package/examples/{hooks → extensions}/protected-paths.ts +3 -3
  171. package/examples/{hooks → extensions}/qna.ts +3 -3
  172. package/examples/{custom-tools/question/index.ts → extensions/question.ts} +13 -17
  173. package/examples/{hooks → extensions}/snake.ts +3 -3
  174. package/examples/{hooks → extensions}/status-line.ts +3 -3
  175. package/examples/{custom-tools → extensions}/subagent/README.md +15 -15
  176. package/examples/{custom-tools → extensions}/subagent/index.ts +22 -43
  177. package/examples/{custom-tools/todo/index.ts → extensions/todo.ts} +122 -39
  178. package/examples/{hooks → extensions}/tools.ts +5 -5
  179. package/examples/extensions/with-deps/index.ts +40 -0
  180. package/examples/extensions/with-deps/package-lock.json +31 -0
  181. package/examples/extensions/with-deps/package.json +16 -0
  182. package/examples/sdk/01-minimal.ts +1 -1
  183. package/examples/sdk/05-tools.ts +7 -41
  184. package/examples/sdk/06-extensions.ts +81 -0
  185. package/examples/sdk/08-prompt-templates.ts +42 -0
  186. package/examples/sdk/12-full-control.ts +10 -29
  187. package/examples/sdk/README.md +5 -5
  188. package/package.json +4 -4
  189. package/dist/core/custom-tools/index.d.ts +0 -7
  190. package/dist/core/custom-tools/index.d.ts.map +0 -1
  191. package/dist/core/custom-tools/index.js +0 -6
  192. package/dist/core/custom-tools/index.js.map +0 -1
  193. package/dist/core/custom-tools/loader.d.ts +0 -30
  194. package/dist/core/custom-tools/loader.d.ts.map +0 -1
  195. package/dist/core/custom-tools/loader.js +0 -276
  196. package/dist/core/custom-tools/loader.js.map +0 -1
  197. package/dist/core/custom-tools/types.d.ts +0 -144
  198. package/dist/core/custom-tools/types.d.ts.map +0 -1
  199. package/dist/core/custom-tools/types.js +0 -8
  200. package/dist/core/custom-tools/types.js.map +0 -1
  201. package/dist/core/custom-tools/wrapper.d.ts +0 -15
  202. package/dist/core/custom-tools/wrapper.d.ts.map +0 -1
  203. package/dist/core/custom-tools/wrapper.js +0 -23
  204. package/dist/core/custom-tools/wrapper.js.map +0 -1
  205. package/dist/core/hooks/index.d.ts +0 -6
  206. package/dist/core/hooks/index.d.ts.map +0 -1
  207. package/dist/core/hooks/index.js +0 -6
  208. package/dist/core/hooks/index.js.map +0 -1
  209. package/dist/core/hooks/loader.d.ts +0 -146
  210. package/dist/core/hooks/loader.d.ts.map +0 -1
  211. package/dist/core/hooks/loader.js +0 -275
  212. package/dist/core/hooks/loader.js.map +0 -1
  213. package/dist/core/hooks/runner.d.ts +0 -173
  214. package/dist/core/hooks/runner.d.ts.map +0 -1
  215. package/dist/core/hooks/runner.js.map +0 -1
  216. package/dist/core/hooks/tool-wrapper.d.ts +0 -17
  217. package/dist/core/hooks/tool-wrapper.d.ts.map +0 -1
  218. package/dist/core/hooks/tool-wrapper.js.map +0 -1
  219. package/dist/core/hooks/types.d.ts +0 -767
  220. package/dist/core/hooks/types.d.ts.map +0 -1
  221. package/dist/core/hooks/types.js.map +0 -1
  222. package/dist/core/slash-commands.d.ts +0 -40
  223. package/dist/core/slash-commands.d.ts.map +0 -1
  224. package/dist/core/slash-commands.js.map +0 -1
  225. package/dist/modes/interactive/components/hook-editor.d.ts.map +0 -1
  226. package/dist/modes/interactive/components/hook-editor.js.map +0 -1
  227. package/dist/modes/interactive/components/hook-input.d.ts.map +0 -1
  228. package/dist/modes/interactive/components/hook-input.js.map +0 -1
  229. package/dist/modes/interactive/components/hook-message.d.ts +0 -18
  230. package/dist/modes/interactive/components/hook-message.d.ts.map +0 -1
  231. package/dist/modes/interactive/components/hook-message.js.map +0 -1
  232. package/dist/modes/interactive/components/hook-selector.d.ts.map +0 -1
  233. package/dist/modes/interactive/components/hook-selector.js.map +0 -1
  234. package/docs/custom-tools.md +0 -514
  235. package/docs/extension-loading.md +0 -1004
  236. package/docs/hooks.md +0 -979
  237. package/docs/session-tree-plan.md +0 -441
  238. package/examples/custom-tools/README.md +0 -114
  239. package/examples/custom-tools/hello/index.ts +0 -21
  240. package/examples/hooks/README.md +0 -60
  241. package/examples/hooks/todo/index.ts +0 -134
  242. package/examples/sdk/06-hooks.ts +0 -61
  243. package/examples/sdk/08-slash-commands.ts +0 -42
  244. /package/examples/{custom-tools → extensions}/subagent/agents/planner.md +0 -0
  245. /package/examples/{custom-tools → extensions}/subagent/agents/reviewer.md +0 -0
  246. /package/examples/{custom-tools → extensions}/subagent/agents/scout.md +0 -0
  247. /package/examples/{custom-tools → extensions}/subagent/agents/worker.md +0 -0
  248. /package/examples/{custom-tools → extensions}/subagent/agents.ts +0 -0
  249. /package/examples/{custom-tools/subagent/commands → extensions/subagent/prompts}/implement-and-review.md +0 -0
  250. /package/examples/{custom-tools/subagent/commands → extensions/subagent/prompts}/implement.md +0 -0
  251. /package/examples/{custom-tools/subagent/commands → extensions/subagent/prompts}/scout-and-plan.md +0 -0
@@ -0,0 +1,1053 @@
1
+ > pi can create extensions. Ask it to build one for your use case.
2
+
3
+ # Extensions
4
+
5
+ Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.
6
+
7
+ **Key capabilities:**
8
+ - **Custom tools** - Register tools the LLM can call via `pi.registerTool()`
9
+ - **Event interception** - Block or modify tool calls, inject context, customize compaction
10
+ - **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify)
11
+ - **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions
12
+ - **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()`
13
+ - **Session persistence** - Store state that survives restarts via `pi.appendEntry()`
14
+ - **Custom rendering** - Control how tool calls/results and messages appear in TUI
15
+
16
+ **Example use cases:**
17
+ - Permission gates (confirm before `rm -rf`, `sudo`, etc.)
18
+ - Git checkpointing (stash at each turn, restore on branch)
19
+ - Path protection (block writes to `.env`, `node_modules/`)
20
+ - Custom compaction (summarize conversation your way)
21
+ - Interactive tools (questions, wizards, custom dialogs)
22
+ - Stateful tools (todo lists, connection pools)
23
+ - External integrations (file watchers, webhooks, CI triggers)
24
+ - Games while you wait (see `snake.ts` example)
25
+
26
+ See [examples/extensions/](../examples/extensions/) for working implementations.
27
+
28
+ ## Table of Contents
29
+
30
+ - [Quick Start](#quick-start)
31
+ - [Extension Locations](#extension-locations)
32
+ - [Available Imports](#available-imports)
33
+ - [Writing an Extension](#writing-an-extension)
34
+ - [Extension Styles](#extension-styles)
35
+ - [Events](#events)
36
+ - [Lifecycle Overview](#lifecycle-overview)
37
+ - [Session Events](#session-events)
38
+ - [Agent Events](#agent-events)
39
+ - [Tool Events](#tool-events)
40
+ - [ExtensionContext](#extensioncontext)
41
+ - [ExtensionCommandContext](#extensioncommandcontext)
42
+ - [ExtensionAPI Methods](#extensionapi-methods)
43
+ - [State Management](#state-management)
44
+ - [Custom Tools](#custom-tools)
45
+ - [Custom UI](#custom-ui)
46
+ - [Error Handling](#error-handling)
47
+ - [Mode Behavior](#mode-behavior)
48
+
49
+ ## Quick Start
50
+
51
+ Create `~/.pi/agent/extensions/my-extension.ts`:
52
+
53
+ ```typescript
54
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
55
+ import { Type } from "@sinclair/typebox";
56
+
57
+ export default function (pi: ExtensionAPI) {
58
+ // React to events
59
+ pi.on("session_start", async (_event, ctx) => {
60
+ ctx.ui.notify("Extension loaded!", "info");
61
+ });
62
+
63
+ pi.on("tool_call", async (event, ctx) => {
64
+ if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
65
+ const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
66
+ if (!ok) return { block: true, reason: "Blocked by user" };
67
+ }
68
+ });
69
+
70
+ // Register a custom tool
71
+ pi.registerTool({
72
+ name: "greet",
73
+ label: "Greet",
74
+ description: "Greet someone by name",
75
+ parameters: Type.Object({
76
+ name: Type.String({ description: "Name to greet" }),
77
+ }),
78
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
79
+ return {
80
+ content: [{ type: "text", text: `Hello, ${params.name}!` }],
81
+ details: {},
82
+ };
83
+ },
84
+ });
85
+
86
+ // Register a command
87
+ pi.registerCommand("hello", {
88
+ description: "Say hello",
89
+ handler: async (args, ctx) => {
90
+ ctx.ui.notify(`Hello ${args || "world"}!`, "info");
91
+ },
92
+ });
93
+ }
94
+ ```
95
+
96
+ Test with `--extension` (or `-e`) flag:
97
+
98
+ ```bash
99
+ pi -e ./my-extension.ts
100
+ ```
101
+
102
+ ## Extension Locations
103
+
104
+ Extensions are auto-discovered from:
105
+
106
+ | Location | Scope |
107
+ |----------|-------|
108
+ | `~/.pi/agent/extensions/*.ts` | Global (all projects) |
109
+ | `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) |
110
+ | `.pi/extensions/*.ts` | Project-local |
111
+ | `.pi/extensions/*/index.ts` | Project-local (subdirectory) |
112
+
113
+ Additional paths via `settings.json`:
114
+
115
+ ```json
116
+ {
117
+ "extensions": ["/path/to/extension.ts", "/path/to/extension/dir"]
118
+ }
119
+ ```
120
+
121
+ **Discovery rules:**
122
+
123
+ 1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly
124
+ 2. **Subdirectory with index:** `extensions/myext/index.ts` → loaded as single extension
125
+ 3. **Subdirectory with package.json:** `extensions/myext/package.json` with `"pi"` field → loads declared paths
126
+
127
+ ```
128
+ ~/.pi/agent/extensions/
129
+ ├── simple.ts # Direct file (auto-discovered)
130
+ ├── my-tool/
131
+ │ └── index.ts # Subdirectory with index (auto-discovered)
132
+ └── my-extension-pack/
133
+ ├── package.json # Declares multiple extensions
134
+ ├── node_modules/ # Dependencies installed here
135
+ └── src/
136
+ ├── safety-gates.ts # First extension
137
+ └── custom-tools.ts # Second extension
138
+ ```
139
+
140
+ ```json
141
+ // my-extension-pack/package.json
142
+ {
143
+ "name": "my-extension-pack",
144
+ "dependencies": {
145
+ "zod": "^3.0.0"
146
+ },
147
+ "pi": {
148
+ "extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"]
149
+ }
150
+ }
151
+ ```
152
+
153
+ The `package.json` approach enables:
154
+ - Multiple extensions from one package
155
+ - Third-party npm dependencies (resolved via jiti)
156
+ - Nested source structure (no depth limit within the package)
157
+ - Deployment to and installation from npm
158
+
159
+ ## Available Imports
160
+
161
+ | Package | Purpose |
162
+ |---------|---------|
163
+ | `@mariozechner/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) |
164
+ | `@sinclair/typebox` | Schema definitions for tool parameters |
165
+ | `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
166
+ | `@mariozechner/pi-tui` | TUI components for custom rendering |
167
+
168
+ npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically.
169
+
170
+ Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
171
+
172
+ ## Writing an Extension
173
+
174
+ An extension exports a default function that receives `ExtensionAPI`:
175
+
176
+ ```typescript
177
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
178
+
179
+ export default function (pi: ExtensionAPI) {
180
+ // Subscribe to events
181
+ pi.on("event_name", async (event, ctx) => {
182
+ // ctx.ui for user interaction
183
+ const ok = await ctx.ui.confirm("Title", "Are you sure?");
184
+ ctx.ui.notify("Done!", "success");
185
+ ctx.ui.setStatus("my-ext", "Processing..."); // Footer status
186
+ ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor
187
+ });
188
+
189
+ // Register tools, commands, shortcuts, flags
190
+ pi.registerTool({ ... });
191
+ pi.registerCommand("name", { ... });
192
+ pi.registerShortcut("ctrl+x", { ... });
193
+ pi.registerFlag("--my-flag", { ... });
194
+ }
195
+ ```
196
+
197
+ Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
198
+
199
+ ### Extension Styles
200
+
201
+ **Single file** - simplest, for small extensions:
202
+
203
+ ```
204
+ ~/.pi/agent/extensions/
205
+ └── my-extension.ts
206
+ ```
207
+
208
+ **Directory with index.ts** - for multi-file extensions:
209
+
210
+ ```
211
+ ~/.pi/agent/extensions/
212
+ └── my-extension/
213
+ ├── index.ts # Entry point (exports default function)
214
+ ├── tools.ts # Helper module
215
+ └── utils.ts # Helper module
216
+ ```
217
+
218
+ **Package with dependencies** - for extensions that need npm packages:
219
+
220
+ ```
221
+ ~/.pi/agent/extensions/
222
+ └── my-extension/
223
+ ├── package.json # Declares dependencies and entry points
224
+ ├── package-lock.json
225
+ ├── node_modules/ # After npm install
226
+ └── src/
227
+ └── index.ts
228
+ ```
229
+
230
+ ```json
231
+ // package.json
232
+ {
233
+ "name": "my-extension",
234
+ "dependencies": {
235
+ "zod": "^3.0.0",
236
+ "chalk": "^5.0.0"
237
+ },
238
+ "pi": {
239
+ "extensions": ["./src/index.ts"]
240
+ }
241
+ }
242
+ ```
243
+
244
+ Run `npm install` in the extension directory, then imports from `node_modules/` work automatically.
245
+
246
+ ## Events
247
+
248
+ ### Lifecycle Overview
249
+
250
+ ```
251
+ pi starts
252
+
253
+ └─► session_start
254
+
255
+
256
+ user sends prompt ─────────────────────────────────────────┐
257
+ │ │
258
+ ├─► before_agent_start (can inject message, append to system prompt)
259
+ ├─► agent_start │
260
+ │ │
261
+ │ ┌─── turn (repeats while LLM calls tools) ───┐ │
262
+ │ │ │ │
263
+ │ ├─► turn_start │ │
264
+ │ ├─► context (can modify messages) │ │
265
+ │ │ │ │
266
+ │ │ LLM responds, may call tools: │ │
267
+ │ │ ├─► tool_call (can block) │ │
268
+ │ │ │ tool executes │ │
269
+ │ │ └─► tool_result (can modify) │ │
270
+ │ │ │ │
271
+ │ └─► turn_end │ │
272
+ │ │
273
+ └─► agent_end │
274
+
275
+ user sends another prompt ◄────────────────────────────────┘
276
+
277
+ /new (new session) or /resume (switch session)
278
+ ├─► session_before_switch (can cancel)
279
+ └─► session_switch
280
+
281
+ /branch
282
+ ├─► session_before_branch (can cancel)
283
+ └─► session_branch
284
+
285
+ /compact or auto-compaction
286
+ ├─► session_before_compact (can cancel or customize)
287
+ └─► session_compact
288
+
289
+ /tree navigation
290
+ ├─► session_before_tree (can cancel or customize)
291
+ └─► session_tree
292
+
293
+ exit (Ctrl+C, Ctrl+D)
294
+ └─► session_shutdown
295
+ ```
296
+
297
+ ### Session Events
298
+
299
+ #### session_start
300
+
301
+ Fired on initial session load.
302
+
303
+ ```typescript
304
+ pi.on("session_start", async (_event, ctx) => {
305
+ ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
306
+ });
307
+ ```
308
+
309
+ #### session_before_switch / session_switch
310
+
311
+ Fired when starting a new session (`/new`) or switching sessions (`/resume`).
312
+
313
+ ```typescript
314
+ pi.on("session_before_switch", async (event, ctx) => {
315
+ // event.reason - "new" or "resume"
316
+ // event.targetSessionFile - session we're switching to (only for "resume")
317
+
318
+ if (event.reason === "new") {
319
+ const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
320
+ if (!ok) return { cancel: true };
321
+ }
322
+ });
323
+
324
+ pi.on("session_switch", async (event, ctx) => {
325
+ // event.reason - "new" or "resume"
326
+ // event.previousSessionFile - session we came from
327
+ });
328
+ ```
329
+
330
+ #### session_before_branch / session_branch
331
+
332
+ Fired when branching via `/branch`.
333
+
334
+ ```typescript
335
+ pi.on("session_before_branch", async (event, ctx) => {
336
+ // event.entryId - ID of the entry being branched from
337
+ return { cancel: true }; // Cancel branch
338
+ // OR
339
+ return { skipConversationRestore: true }; // Branch but don't rewind messages
340
+ });
341
+
342
+ pi.on("session_branch", async (event, ctx) => {
343
+ // event.previousSessionFile - previous session file
344
+ });
345
+ ```
346
+
347
+ #### session_before_compact / session_compact
348
+
349
+ Fired on compaction. See [compaction.md](compaction.md) for details.
350
+
351
+ ```typescript
352
+ pi.on("session_before_compact", async (event, ctx) => {
353
+ const { preparation, branchEntries, customInstructions, signal } = event;
354
+
355
+ // Cancel:
356
+ return { cancel: true };
357
+
358
+ // Custom summary:
359
+ return {
360
+ compaction: {
361
+ summary: "...",
362
+ firstKeptEntryId: preparation.firstKeptEntryId,
363
+ tokensBefore: preparation.tokensBefore,
364
+ }
365
+ };
366
+ });
367
+
368
+ pi.on("session_compact", async (event, ctx) => {
369
+ // event.compactionEntry - the saved compaction
370
+ // event.fromExtension - whether extension provided it
371
+ });
372
+ ```
373
+
374
+ #### session_before_tree / session_tree
375
+
376
+ Fired on `/tree` navigation.
377
+
378
+ ```typescript
379
+ pi.on("session_before_tree", async (event, ctx) => {
380
+ const { preparation, signal } = event;
381
+ return { cancel: true };
382
+ // OR provide custom summary:
383
+ return { summary: { summary: "...", details: {} } };
384
+ });
385
+
386
+ pi.on("session_tree", async (event, ctx) => {
387
+ // event.newLeafId, oldLeafId, summaryEntry, fromExtension
388
+ });
389
+ ```
390
+
391
+ #### session_shutdown
392
+
393
+ Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
394
+
395
+ ```typescript
396
+ pi.on("session_shutdown", async (_event, ctx) => {
397
+ // Cleanup, save state, etc.
398
+ });
399
+ ```
400
+
401
+ ### Agent Events
402
+
403
+ #### before_agent_start
404
+
405
+ Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt.
406
+
407
+ ```typescript
408
+ pi.on("before_agent_start", async (event, ctx) => {
409
+ // event.prompt - user's prompt text
410
+ // event.images - attached images (if any)
411
+
412
+ return {
413
+ // Inject a persistent message (stored in session, sent to LLM)
414
+ message: {
415
+ customType: "my-extension",
416
+ content: "Additional context for the LLM",
417
+ display: true,
418
+ },
419
+ // Append to system prompt for this turn only
420
+ systemPromptAppend: "Extra instructions for this turn...",
421
+ };
422
+ });
423
+ ```
424
+
425
+ #### agent_start / agent_end
426
+
427
+ Fired once per user prompt.
428
+
429
+ ```typescript
430
+ pi.on("agent_start", async (_event, ctx) => {});
431
+
432
+ pi.on("agent_end", async (event, ctx) => {
433
+ // event.messages - messages from this prompt
434
+ });
435
+ ```
436
+
437
+ #### turn_start / turn_end
438
+
439
+ Fired for each turn (one LLM response + tool calls).
440
+
441
+ ```typescript
442
+ pi.on("turn_start", async (event, ctx) => {
443
+ // event.turnIndex, event.timestamp
444
+ });
445
+
446
+ pi.on("turn_end", async (event, ctx) => {
447
+ // event.turnIndex, event.message, event.toolResults
448
+ });
449
+ ```
450
+
451
+ #### context
452
+
453
+ Fired before each LLM call. Modify messages non-destructively.
454
+
455
+ ```typescript
456
+ pi.on("context", async (event, ctx) => {
457
+ // event.messages - deep copy, safe to modify
458
+ const filtered = event.messages.filter(m => !shouldPrune(m));
459
+ return { messages: filtered };
460
+ });
461
+ ```
462
+
463
+ ### Tool Events
464
+
465
+ #### tool_call
466
+
467
+ Fired before tool executes. **Can block.**
468
+
469
+ ```typescript
470
+ pi.on("tool_call", async (event, ctx) => {
471
+ // event.toolName - "bash", "read", "write", "edit", etc.
472
+ // event.toolCallId
473
+ // event.input - tool parameters
474
+
475
+ if (shouldBlock(event)) {
476
+ return { block: true, reason: "Not allowed" };
477
+ }
478
+ });
479
+ ```
480
+
481
+ #### tool_result
482
+
483
+ Fired after tool executes. **Can modify result.**
484
+
485
+ ```typescript
486
+ import { isBashToolResult } from "@mariozechner/pi-coding-agent";
487
+
488
+ pi.on("tool_result", async (event, ctx) => {
489
+ // event.toolName, event.toolCallId, event.input
490
+ // event.content, event.details, event.isError
491
+
492
+ if (isBashToolResult(event)) {
493
+ // event.details is typed as BashToolDetails
494
+ }
495
+
496
+ // Modify result:
497
+ return { content: [...], details: {...}, isError: false };
498
+ });
499
+ ```
500
+
501
+ ## ExtensionContext
502
+
503
+ Every handler receives `ctx: ExtensionContext`:
504
+
505
+ ### ctx.ui
506
+
507
+ UI methods for user interaction. See [Custom UI](#custom-ui) for full details.
508
+
509
+ ### ctx.hasUI
510
+
511
+ `false` in print mode (`-p`), JSON mode, and RPC mode. Always check before using `ctx.ui`.
512
+
513
+ ### ctx.cwd
514
+
515
+ Current working directory.
516
+
517
+ ### ctx.sessionManager
518
+
519
+ Read-only access to session state:
520
+
521
+ ```typescript
522
+ ctx.sessionManager.getEntries() // All entries
523
+ ctx.sessionManager.getBranch() // Current branch
524
+ ctx.sessionManager.getLeafId() // Current leaf entry ID
525
+ ```
526
+
527
+ ### ctx.modelRegistry / ctx.model
528
+
529
+ Access to models and API keys.
530
+
531
+ ### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()
532
+
533
+ Control flow helpers.
534
+
535
+ ## ExtensionCommandContext
536
+
537
+ Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers.
538
+
539
+ ### ctx.waitForIdle()
540
+
541
+ Wait for the agent to finish streaming:
542
+
543
+ ```typescript
544
+ pi.registerCommand("my-cmd", {
545
+ handler: async (args, ctx) => {
546
+ await ctx.waitForIdle();
547
+ // Agent is now idle, safe to modify session
548
+ },
549
+ });
550
+ ```
551
+
552
+ ### ctx.newSession(options?)
553
+
554
+ Create a new session:
555
+
556
+ ```typescript
557
+ const result = await ctx.newSession({
558
+ parentSession: ctx.sessionManager.getSessionFile(),
559
+ setup: async (sm) => {
560
+ sm.appendMessage({
561
+ role: "user",
562
+ content: [{ type: "text", text: "Context from previous session..." }],
563
+ timestamp: Date.now(),
564
+ });
565
+ },
566
+ });
567
+
568
+ if (result.cancelled) {
569
+ // An extension cancelled the new session
570
+ }
571
+ ```
572
+
573
+ ### ctx.branch(entryId)
574
+
575
+ Branch from a specific entry:
576
+
577
+ ```typescript
578
+ const result = await ctx.branch("entry-id-123");
579
+ if (!result.cancelled) {
580
+ // Now in the branched session
581
+ }
582
+ ```
583
+
584
+ ### ctx.navigateTree(targetId, options?)
585
+
586
+ Navigate to a different point in the session tree:
587
+
588
+ ```typescript
589
+ const result = await ctx.navigateTree("entry-id-456", {
590
+ summarize: true,
591
+ });
592
+ ```
593
+
594
+ ## ExtensionAPI Methods
595
+
596
+ ### pi.on(event, handler)
597
+
598
+ Subscribe to events. See [Events](#events).
599
+
600
+ ### pi.registerTool(definition)
601
+
602
+ Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details.
603
+
604
+ ```typescript
605
+ import { Type } from "@sinclair/typebox";
606
+ import { StringEnum } from "@mariozechner/pi-ai";
607
+
608
+ pi.registerTool({
609
+ name: "my_tool",
610
+ label: "My Tool",
611
+ description: "What this tool does",
612
+ parameters: Type.Object({
613
+ action: StringEnum(["list", "add"] as const),
614
+ text: Type.Optional(Type.String()),
615
+ }),
616
+
617
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
618
+ // Stream progress
619
+ onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
620
+
621
+ return {
622
+ content: [{ type: "text", text: "Done" }],
623
+ details: { result: "..." },
624
+ };
625
+ },
626
+
627
+ // Optional: Custom rendering
628
+ renderCall(args, theme) { ... },
629
+ renderResult(result, options, theme) { ... },
630
+ });
631
+ ```
632
+
633
+ ### pi.sendMessage(message, options?)
634
+
635
+ Inject a message into the session:
636
+
637
+ ```typescript
638
+ pi.sendMessage({
639
+ customType: "my-extension",
640
+ content: "Message text",
641
+ display: true,
642
+ details: { ... },
643
+ }, {
644
+ triggerTurn: true,
645
+ deliverAs: "steer",
646
+ });
647
+ ```
648
+
649
+ **Options:**
650
+ - `deliverAs` - Delivery mode:
651
+ - `"steer"` (default) - Interrupts streaming. Delivered after current tool finishes, remaining tools skipped.
652
+ - `"followUp"` - Waits for agent to finish. Delivered only when agent has no more tool calls.
653
+ - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything.
654
+ - `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`).
655
+
656
+ ### pi.appendEntry(customType, data?)
657
+
658
+ Persist extension state (does NOT participate in LLM context):
659
+
660
+ ```typescript
661
+ pi.appendEntry("my-state", { count: 42 });
662
+
663
+ // Restore on reload
664
+ pi.on("session_start", async (_event, ctx) => {
665
+ for (const entry of ctx.sessionManager.getEntries()) {
666
+ if (entry.type === "custom" && entry.customType === "my-state") {
667
+ // Reconstruct from entry.data
668
+ }
669
+ }
670
+ });
671
+ ```
672
+
673
+ ### pi.registerCommand(name, options)
674
+
675
+ Register a command:
676
+
677
+ ```typescript
678
+ pi.registerCommand("stats", {
679
+ description: "Show session statistics",
680
+ handler: async (args, ctx) => {
681
+ const count = ctx.sessionManager.getEntries().length;
682
+ ctx.ui.notify(`${count} entries`, "info");
683
+ }
684
+ });
685
+ ```
686
+
687
+ ### pi.registerMessageRenderer(customType, renderer)
688
+
689
+ Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui).
690
+
691
+ ### pi.registerShortcut(shortcut, options)
692
+
693
+ Register a keyboard shortcut:
694
+
695
+ ```typescript
696
+ pi.registerShortcut("ctrl+shift+p", {
697
+ description: "Toggle plan mode",
698
+ handler: async (ctx) => {
699
+ ctx.ui.notify("Toggled!");
700
+ },
701
+ });
702
+ ```
703
+
704
+ ### pi.registerFlag(name, options)
705
+
706
+ Register a CLI flag:
707
+
708
+ ```typescript
709
+ pi.registerFlag("--plan", {
710
+ description: "Start in plan mode",
711
+ type: "boolean",
712
+ default: false,
713
+ });
714
+
715
+ // Check value
716
+ if (pi.getFlag("--plan")) {
717
+ // Plan mode enabled
718
+ }
719
+ ```
720
+
721
+ ### pi.exec(command, args, options?)
722
+
723
+ Execute a shell command:
724
+
725
+ ```typescript
726
+ const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
727
+ // result.stdout, result.stderr, result.code, result.killed
728
+ ```
729
+
730
+ ### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)
731
+
732
+ Manage active tools:
733
+
734
+ ```typescript
735
+ const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"]
736
+ pi.setActiveTools(["read", "bash"]); // Switch to read-only
737
+ ```
738
+
739
+ ### pi.events
740
+
741
+ Shared event bus for communication between extensions:
742
+
743
+ ```typescript
744
+ pi.events.on("my:event", (data) => { ... });
745
+ pi.events.emit("my:event", { ... });
746
+ ```
747
+
748
+ ## State Management
749
+
750
+ Extensions with state should store it in tool result `details` for proper branching support:
751
+
752
+ ```typescript
753
+ export default function (pi: ExtensionAPI) {
754
+ let items: string[] = [];
755
+
756
+ // Reconstruct state from session
757
+ pi.on("session_start", async (_event, ctx) => {
758
+ items = [];
759
+ for (const entry of ctx.sessionManager.getBranch()) {
760
+ if (entry.type === "message" && entry.message.role === "toolResult") {
761
+ if (entry.message.toolName === "my_tool") {
762
+ items = entry.message.details?.items ?? [];
763
+ }
764
+ }
765
+ }
766
+ });
767
+
768
+ pi.registerTool({
769
+ name: "my_tool",
770
+ // ...
771
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
772
+ items.push("new item");
773
+ return {
774
+ content: [{ type: "text", text: "Added" }],
775
+ details: { items: [...items] }, // Store for reconstruction
776
+ };
777
+ },
778
+ });
779
+ }
780
+ ```
781
+
782
+ ## Custom Tools
783
+
784
+ Register tools the LLM can call via `pi.registerTool()`. Tools appear in the system prompt and can have custom rendering.
785
+
786
+ ### Tool Definition
787
+
788
+ ```typescript
789
+ import { Type } from "@sinclair/typebox";
790
+ import { StringEnum } from "@mariozechner/pi-ai";
791
+ import { Text } from "@mariozechner/pi-tui";
792
+
793
+ pi.registerTool({
794
+ name: "my_tool",
795
+ label: "My Tool",
796
+ description: "What this tool does (shown to LLM)",
797
+ parameters: Type.Object({
798
+ action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility
799
+ text: Type.Optional(Type.String()),
800
+ }),
801
+
802
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
803
+ // Check for cancellation
804
+ if (signal?.aborted) {
805
+ return { content: [{ type: "text", text: "Cancelled" }] };
806
+ }
807
+
808
+ // Stream progress updates
809
+ onUpdate?.({
810
+ content: [{ type: "text", text: "Working..." }],
811
+ details: { progress: 50 },
812
+ });
813
+
814
+ // Run commands via pi.exec (captured from extension closure)
815
+ const result = await pi.exec("some-command", [], { signal });
816
+
817
+ // Return result
818
+ return {
819
+ content: [{ type: "text", text: "Done" }], // Sent to LLM
820
+ details: { data: result }, // For rendering & state
821
+ };
822
+ },
823
+
824
+ // Optional: Custom rendering
825
+ renderCall(args, theme) { ... },
826
+ renderResult(result, options, theme) { ... },
827
+ });
828
+ ```
829
+
830
+ **Important:** Use `StringEnum` from `@mariozechner/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API.
831
+
832
+ ### Multiple Tools
833
+
834
+ One extension can register multiple tools with shared state:
835
+
836
+ ```typescript
837
+ export default function (pi: ExtensionAPI) {
838
+ let connection = null;
839
+
840
+ pi.registerTool({ name: "db_connect", ... });
841
+ pi.registerTool({ name: "db_query", ... });
842
+ pi.registerTool({ name: "db_close", ... });
843
+
844
+ pi.on("session_shutdown", async () => {
845
+ connection?.close();
846
+ });
847
+ }
848
+ ```
849
+
850
+ ### Custom Rendering
851
+
852
+ Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API.
853
+
854
+ Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`).
855
+
856
+ #### renderCall
857
+
858
+ Renders the tool call (before/during execution):
859
+
860
+ ```typescript
861
+ import { Text } from "@mariozechner/pi-tui";
862
+
863
+ renderCall(args, theme) {
864
+ let text = theme.fg("toolTitle", theme.bold("my_tool "));
865
+ text += theme.fg("muted", args.action);
866
+ if (args.text) {
867
+ text += " " + theme.fg("dim", `"${args.text}"`);
868
+ }
869
+ return new Text(text, 0, 0); // 0,0 padding - Box handles it
870
+ }
871
+ ```
872
+
873
+ #### renderResult
874
+
875
+ Renders the tool result:
876
+
877
+ ```typescript
878
+ renderResult(result, { expanded, isPartial }, theme) {
879
+ // Handle streaming
880
+ if (isPartial) {
881
+ return new Text(theme.fg("warning", "Processing..."), 0, 0);
882
+ }
883
+
884
+ // Handle errors
885
+ if (result.details?.error) {
886
+ return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
887
+ }
888
+
889
+ // Normal result - support expanded view (Ctrl+O)
890
+ let text = theme.fg("success", "✓ Done");
891
+ if (expanded && result.details?.items) {
892
+ for (const item of result.details.items) {
893
+ text += "\n " + theme.fg("dim", item);
894
+ }
895
+ }
896
+ return new Text(text, 0, 0);
897
+ }
898
+ ```
899
+
900
+ #### Best Practices
901
+
902
+ - Use `Text` with padding `(0, 0)` - the Box handles padding
903
+ - Use `\n` for multi-line content
904
+ - Handle `isPartial` for streaming progress
905
+ - Support `expanded` for detail on demand
906
+ - Keep default view compact
907
+
908
+ #### Fallback
909
+
910
+ If `renderCall`/`renderResult` is not defined or throws:
911
+ - `renderCall`: Shows tool name
912
+ - `renderResult`: Shows raw text from `content`
913
+
914
+ ## Custom UI
915
+
916
+ Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render.
917
+
918
+ ### Dialogs
919
+
920
+ ```typescript
921
+ // Select from options
922
+ const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
923
+
924
+ // Confirm dialog
925
+ const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
926
+
927
+ // Text input
928
+ const name = await ctx.ui.input("Name:", "placeholder");
929
+
930
+ // Multi-line editor
931
+ const text = await ctx.ui.editor("Edit:", "prefilled text");
932
+
933
+ // Notification (non-blocking)
934
+ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
935
+ ```
936
+
937
+ ### Widgets and Status
938
+
939
+ ```typescript
940
+ // Status in footer (persistent until cleared)
941
+ ctx.ui.setStatus("my-ext", "Processing...");
942
+ ctx.ui.setStatus("my-ext", undefined); // Clear
943
+
944
+ // Widget above editor (string array or factory function)
945
+ ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
946
+ ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0));
947
+ ctx.ui.setWidget("my-widget", undefined); // Clear
948
+
949
+ // Terminal title
950
+ ctx.ui.setTitle("pi - my-project");
951
+
952
+ // Editor text
953
+ ctx.ui.setEditorText("Prefill text");
954
+ const current = ctx.ui.getEditorText();
955
+ ```
956
+
957
+ ### Custom Components
958
+
959
+ For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called:
960
+
961
+ ```typescript
962
+ import { Text, Component } from "@mariozechner/pi-tui";
963
+
964
+ const result = await ctx.ui.custom<boolean>((tui, theme, done) => {
965
+ const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1);
966
+
967
+ text.onKey = (key) => {
968
+ if (key === "return") done(true);
969
+ if (key === "escape") done(false);
970
+ return true;
971
+ };
972
+
973
+ return text;
974
+ });
975
+
976
+ if (result) {
977
+ // User pressed Enter
978
+ }
979
+ ```
980
+
981
+ The callback receives:
982
+ - `tui` - TUI instance (for screen dimensions, focus management)
983
+ - `theme` - Current theme for styling
984
+ - `done(value)` - Call to close component and return value
985
+
986
+ See [tui.md](tui.md) for the full component API and [examples/extensions/](../examples/extensions/) for working examples (snake.ts, todo.ts, qna.ts).
987
+
988
+ ### Message Rendering
989
+
990
+ Register a custom renderer for messages with your `customType`:
991
+
992
+ ```typescript
993
+ import { Text } from "@mariozechner/pi-tui";
994
+
995
+ pi.registerMessageRenderer("my-extension", (message, options, theme) => {
996
+ const { expanded } = options;
997
+ let text = theme.fg("accent", `[${message.customType}] `);
998
+ text += message.content;
999
+
1000
+ if (expanded && message.details) {
1001
+ text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2));
1002
+ }
1003
+
1004
+ return new Text(text, 0, 0);
1005
+ });
1006
+ ```
1007
+
1008
+ Messages are sent via `pi.sendMessage()`:
1009
+
1010
+ ```typescript
1011
+ pi.sendMessage({
1012
+ customType: "my-extension", // Matches registerMessageRenderer
1013
+ content: "Status update",
1014
+ display: true, // Show in TUI
1015
+ details: { ... }, // Available in renderer
1016
+ });
1017
+ ```
1018
+
1019
+ ### Theme Colors
1020
+
1021
+ All render functions receive a `theme` object:
1022
+
1023
+ ```typescript
1024
+ // Foreground colors
1025
+ theme.fg("toolTitle", text) // Tool names
1026
+ theme.fg("accent", text) // Highlights
1027
+ theme.fg("success", text) // Success (green)
1028
+ theme.fg("error", text) // Errors (red)
1029
+ theme.fg("warning", text) // Warnings (yellow)
1030
+ theme.fg("muted", text) // Secondary text
1031
+ theme.fg("dim", text) // Tertiary text
1032
+
1033
+ // Text styles
1034
+ theme.bold(text)
1035
+ theme.italic(text)
1036
+ theme.strikethrough(text)
1037
+ ```
1038
+
1039
+ ## Error Handling
1040
+
1041
+ - Extension errors are logged, agent continues
1042
+ - `tool_call` errors block the tool (fail-safe)
1043
+ - Tool `execute` errors are reported to the LLM with `isError: true`
1044
+
1045
+ ## Mode Behavior
1046
+
1047
+ | Mode | UI Methods | Notes |
1048
+ |------|-----------|-------|
1049
+ | Interactive | Full TUI | Normal operation |
1050
+ | RPC | JSON protocol | Host handles UI |
1051
+ | Print (`-p`) | No-op | Extensions run but can't prompt |
1052
+
1053
+ In print mode, check `ctx.hasUI` before using UI methods.