@mariozechner/pi-coding-agent 0.22.4 → 0.23.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 (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +54 -2
  3. package/dist/cli/args.d.ts +1 -0
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +5 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/agent-session.d.ts +9 -0
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +64 -11
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/custom-tools/index.d.ts +6 -0
  12. package/dist/core/custom-tools/index.d.ts.map +1 -0
  13. package/dist/core/custom-tools/index.js +5 -0
  14. package/dist/core/custom-tools/index.js.map +1 -0
  15. package/dist/core/custom-tools/loader.d.ts +24 -0
  16. package/dist/core/custom-tools/loader.d.ts.map +1 -0
  17. package/dist/core/custom-tools/loader.js +210 -0
  18. package/dist/core/custom-tools/loader.js.map +1 -0
  19. package/dist/core/custom-tools/types.d.ts +81 -0
  20. package/dist/core/custom-tools/types.d.ts.map +1 -0
  21. package/dist/core/custom-tools/types.js +8 -0
  22. package/dist/core/custom-tools/types.js.map +1 -0
  23. package/dist/core/hooks/index.d.ts +1 -1
  24. package/dist/core/hooks/index.d.ts.map +1 -1
  25. package/dist/core/hooks/index.js.map +1 -1
  26. package/dist/core/hooks/types.d.ts +14 -19
  27. package/dist/core/hooks/types.d.ts.map +1 -1
  28. package/dist/core/hooks/types.js.map +1 -1
  29. package/dist/core/index.d.ts +1 -0
  30. package/dist/core/index.d.ts.map +1 -1
  31. package/dist/core/index.js +1 -0
  32. package/dist/core/index.js.map +1 -1
  33. package/dist/core/session-manager.d.ts.map +1 -1
  34. package/dist/core/session-manager.js +4 -0
  35. package/dist/core/session-manager.js.map +1 -1
  36. package/dist/core/settings-manager.d.ts +3 -0
  37. package/dist/core/settings-manager.d.ts.map +1 -1
  38. package/dist/core/settings-manager.js +7 -0
  39. package/dist/core/settings-manager.js.map +1 -1
  40. package/dist/index.d.ts +4 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +3 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/main.d.ts.map +1 -1
  45. package/dist/main.js +22 -3
  46. package/dist/main.js.map +1 -1
  47. package/dist/modes/interactive/components/tool-execution.d.ts +4 -1
  48. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  49. package/dist/modes/interactive/components/tool-execution.js +64 -20
  50. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.d.ts +11 -2
  52. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  53. package/dist/modes/interactive/interactive-mode.js +66 -12
  54. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  55. package/dist/modes/print-mode.d.ts.map +1 -1
  56. package/dist/modes/print-mode.js +35 -9
  57. package/dist/modes/print-mode.js.map +1 -1
  58. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  59. package/dist/modes/rpc/rpc-mode.js +27 -2
  60. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  61. package/docs/custom-tools.md +341 -0
  62. package/docs/hooks.md +54 -34
  63. package/examples/custom-tools/README.md +101 -0
  64. package/examples/custom-tools/hello.ts +20 -0
  65. package/examples/custom-tools/question.ts +83 -0
  66. package/examples/custom-tools/todo.ts +192 -0
  67. package/examples/hooks/README.md +79 -0
  68. package/examples/hooks/file-trigger.ts +36 -0
  69. package/examples/hooks/git-checkpoint.ts +48 -0
  70. package/examples/hooks/permission-gate.ts +38 -0
  71. package/examples/hooks/protected-paths.ts +30 -0
  72. package/package.json +7 -6
@@ -0,0 +1,341 @@
1
+ # Custom Tools
2
+
3
+ Custom tools extend pi with new capabilities beyond the built-in read/write/edit/bash tools. They are TypeScript modules that define one or more tools with optional custom rendering for the TUI.
4
+
5
+ ## Quick Start
6
+
7
+ Create a file `~/.pi/agent/tools/hello.ts`:
8
+
9
+ ```typescript
10
+ import { Type } from "@mariozechner/pi-coding-agent";
11
+ import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
12
+
13
+ const factory: CustomToolFactory = (pi) => ({
14
+ name: "hello",
15
+ label: "Hello",
16
+ description: "A simple greeting tool",
17
+ parameters: Type.Object({
18
+ name: Type.String({ description: "Name to greet" }),
19
+ }),
20
+
21
+ async execute(toolCallId, params) {
22
+ return {
23
+ content: [{ type: "text", text: `Hello, ${params.name}!` }],
24
+ details: { greeted: params.name },
25
+ };
26
+ },
27
+ });
28
+
29
+ export default factory;
30
+ ```
31
+
32
+ The tool is automatically discovered and available in your next pi session.
33
+
34
+ ## Tool Locations
35
+
36
+ | Location | Scope | Auto-discovered |
37
+ |----------|-------|-----------------|
38
+ | `~/.pi/agent/tools/*.ts` | Global (all projects) | Yes |
39
+ | `.pi/tools/*.ts` | Project-local | Yes |
40
+ | `settings.json` `customTools` array | Configured paths | Yes |
41
+ | `--tool <path>` CLI flag | One-off/debugging | No |
42
+
43
+ **Priority:** Later sources win on name conflicts. CLI `--tool` takes highest priority.
44
+
45
+ **Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`).
46
+
47
+ ## Tool Definition
48
+
49
+ ```typescript
50
+ import { Type } from "@mariozechner/pi-coding-agent";
51
+ import { StringEnum } from "@mariozechner/pi-ai";
52
+ import { Text } from "@mariozechner/pi-tui";
53
+ import type { CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
54
+
55
+ const factory: CustomToolFactory = (pi) => ({
56
+ name: "my_tool",
57
+ label: "My Tool",
58
+ description: "What this tool does (be specific for LLM)",
59
+ parameters: Type.Object({
60
+ // Use StringEnum for string enums (Google API compatible)
61
+ action: StringEnum(["list", "add", "remove"] as const),
62
+ text: Type.Optional(Type.String()),
63
+ }),
64
+
65
+ async execute(toolCallId, params, signal, onUpdate) {
66
+ // signal - AbortSignal for cancellation
67
+ // onUpdate - Callback for streaming partial results
68
+ return {
69
+ content: [{ type: "text", text: "Result for LLM" }],
70
+ details: { /* structured data for rendering */ },
71
+ };
72
+ },
73
+
74
+ // Optional: Session lifecycle callback
75
+ onSession(event) { /* reconstruct state from entries */ },
76
+
77
+ // Optional: Custom rendering
78
+ renderCall(args, theme) { /* return Component */ },
79
+ renderResult(result, options, theme) { /* return Component */ },
80
+
81
+ // Optional: Cleanup on session end
82
+ dispose() { /* save state, close connections */ },
83
+ });
84
+
85
+ export default factory;
86
+ ```
87
+
88
+ **Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API.
89
+
90
+ ## ToolAPI Object
91
+
92
+ The factory receives a `ToolAPI` object (named `pi` by convention):
93
+
94
+ ```typescript
95
+ interface ToolAPI {
96
+ cwd: string; // Current working directory
97
+ exec(command: string, args: string[]): Promise<ExecResult>;
98
+ ui: {
99
+ select(title: string, options: string[]): Promise<string | null>;
100
+ confirm(title: string, message: string): Promise<boolean>;
101
+ input(title: string, placeholder?: string): Promise<string | null>;
102
+ notify(message: string, type?: "info" | "warning" | "error"): void;
103
+ };
104
+ hasUI: boolean; // false in --print or --mode rpc
105
+ }
106
+ ```
107
+
108
+ Always check `pi.hasUI` before using UI methods.
109
+
110
+ ## Session Lifecycle
111
+
112
+ Tools can implement `onSession` to react to session changes:
113
+
114
+ ```typescript
115
+ interface ToolSessionEvent {
116
+ entries: SessionEntry[]; // All session entries
117
+ sessionFile: string | null; // Current session file
118
+ previousSessionFile: string | null; // Previous session file
119
+ reason: "start" | "switch" | "branch" | "clear";
120
+ }
121
+ ```
122
+
123
+ **Reasons:**
124
+ - `start`: Initial session load on startup
125
+ - `switch`: User switched to a different session (`/session`)
126
+ - `branch`: User branched from a previous message (`/branch`)
127
+ - `clear`: User cleared the session (`/clear`)
128
+
129
+ ### State Management Pattern
130
+
131
+ Tools that maintain state should store it in `details` of their results, not external files. This allows branching to work correctly, as the state is reconstructed from the session history.
132
+
133
+ ```typescript
134
+ interface MyToolDetails {
135
+ items: string[];
136
+ }
137
+
138
+ const factory: CustomToolFactory = (pi) => {
139
+ // In-memory state
140
+ let items: string[] = [];
141
+
142
+ // Reconstruct state from session entries
143
+ const reconstructState = (event: ToolSessionEvent) => {
144
+ items = [];
145
+ for (const entry of event.entries) {
146
+ if (entry.type !== "message") continue;
147
+ const msg = entry.message;
148
+ if (msg.role !== "toolResult") continue;
149
+ if (msg.toolName !== "my_tool") continue;
150
+
151
+ const details = msg.details as MyToolDetails | undefined;
152
+ if (details) {
153
+ items = details.items;
154
+ }
155
+ }
156
+ };
157
+
158
+ return {
159
+ name: "my_tool",
160
+ label: "My Tool",
161
+ description: "...",
162
+ parameters: Type.Object({ ... }),
163
+
164
+ onSession: reconstructState,
165
+
166
+ async execute(toolCallId, params) {
167
+ // Modify items...
168
+ items.push("new item");
169
+
170
+ return {
171
+ content: [{ type: "text", text: "Added item" }],
172
+ // Store current state in details for reconstruction
173
+ details: { items: [...items] },
174
+ };
175
+ },
176
+ };
177
+ };
178
+ ```
179
+
180
+ This pattern ensures:
181
+ - When user branches, state is correct for that point in history
182
+ - When user switches sessions, state matches that session
183
+ - When user clears, state resets
184
+
185
+ ## Custom Rendering
186
+
187
+ Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional.
188
+
189
+ ### How It Works
190
+
191
+ Tool output is wrapped in a `Box` component that handles:
192
+ - Padding (1 character horizontal, 1 line vertical)
193
+ - Background color based on state (pending/success/error)
194
+
195
+ Your render methods return `Component` instances (typically `Text`) that go inside this box. Use `Text(content, 0, 0)` since the Box handles padding.
196
+
197
+ ### renderCall
198
+
199
+ Renders the tool call (before/during execution):
200
+
201
+ ```typescript
202
+ renderCall(args, theme) {
203
+ let text = theme.fg("toolTitle", theme.bold("my_tool "));
204
+ text += theme.fg("muted", args.action);
205
+ if (args.text) {
206
+ text += " " + theme.fg("dim", `"${args.text}"`);
207
+ }
208
+ return new Text(text, 0, 0);
209
+ }
210
+ ```
211
+
212
+ Called when:
213
+ - Tool call starts (may have partial args during streaming)
214
+ - Args are updated during streaming
215
+
216
+ ### renderResult
217
+
218
+ Renders the tool result:
219
+
220
+ ```typescript
221
+ renderResult(result, { expanded, isPartial }, theme) {
222
+ const { details } = result;
223
+
224
+ // Handle streaming/partial results
225
+ if (isPartial) {
226
+ return new Text(theme.fg("warning", "Processing..."), 0, 0);
227
+ }
228
+
229
+ // Handle errors
230
+ if (details?.error) {
231
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
232
+ }
233
+
234
+ // Normal result
235
+ let text = theme.fg("success", "✓ ") + theme.fg("muted", "Done");
236
+
237
+ // Support expanded view (Ctrl+O)
238
+ if (expanded && details?.items) {
239
+ for (const item of details.items) {
240
+ text += "\n" + theme.fg("dim", ` ${item}`);
241
+ }
242
+ }
243
+
244
+ return new Text(text, 0, 0);
245
+ }
246
+ ```
247
+
248
+ **Options:**
249
+ - `expanded`: User pressed Ctrl+O to expand
250
+ - `isPartial`: Result is from `onUpdate` (streaming), not final
251
+
252
+ ### Best Practices
253
+
254
+ 1. **Use `Text` with padding `(0, 0)`** - The Box handles padding
255
+ 2. **Use `\n` for multi-line content** - Not multiple Text components
256
+ 3. **Handle `isPartial`** - Show progress during streaming
257
+ 4. **Support `expanded`** - Show more detail when user requests
258
+ 5. **Use theme colors** - For consistent appearance
259
+ 6. **Keep it compact** - Show summary by default, details when expanded
260
+
261
+ ### Theme Colors
262
+
263
+ ```typescript
264
+ // Foreground
265
+ theme.fg("toolTitle", text) // Tool names
266
+ theme.fg("accent", text) // Highlights
267
+ theme.fg("success", text) // Success
268
+ theme.fg("error", text) // Errors
269
+ theme.fg("warning", text) // Warnings
270
+ theme.fg("muted", text) // Secondary text
271
+ theme.fg("dim", text) // Tertiary text
272
+ theme.fg("toolOutput", text) // Output content
273
+
274
+ // Styles
275
+ theme.bold(text)
276
+ theme.italic(text)
277
+ ```
278
+
279
+ ### Fallback Behavior
280
+
281
+ If `renderCall` or `renderResult` is not defined or throws an error:
282
+ - `renderCall`: Shows tool name
283
+ - `renderResult`: Shows raw text output from `content`
284
+
285
+ ## Execute Function
286
+
287
+ ```typescript
288
+ async execute(toolCallId, args, signal, onUpdate) {
289
+ // Type assertion for params (TypeBox schema doesn't flow through)
290
+ const params = args as { action: "list" | "add"; text?: string };
291
+
292
+ // Check for abort
293
+ if (signal?.aborted) {
294
+ return { content: [...], details: { status: "aborted" } };
295
+ }
296
+
297
+ // Stream progress
298
+ onUpdate?.({
299
+ content: [{ type: "text", text: "Working..." }],
300
+ details: { progress: 50 },
301
+ });
302
+
303
+ // Return final result
304
+ return {
305
+ content: [{ type: "text", text: "Done" }], // Sent to LLM
306
+ details: { data: result }, // For rendering only
307
+ };
308
+ }
309
+ ```
310
+
311
+ ## Multiple Tools from One File
312
+
313
+ Return an array to share state between related tools:
314
+
315
+ ```typescript
316
+ const factory: CustomToolFactory = (pi) => {
317
+ // Shared state
318
+ let connection = null;
319
+
320
+ return [
321
+ { name: "db_connect", ... },
322
+ { name: "db_query", ... },
323
+ {
324
+ name: "db_close",
325
+ dispose() { connection?.close(); }
326
+ },
327
+ ];
328
+ };
329
+ ```
330
+
331
+ ## Examples
332
+
333
+ See [`examples/custom-tools/todo.ts`](../examples/custom-tools/todo.ts) for a complete example with:
334
+ - `onSession` for state reconstruction
335
+ - Custom `renderCall` and `renderResult`
336
+ - Proper branching support via details storage
337
+
338
+ Test with:
339
+ ```bash
340
+ pi --tool packages/coding-agent/examples/custom-tools/todo.ts
341
+ ```
package/docs/hooks.md CHANGED
@@ -43,8 +43,8 @@ A hook is a TypeScript file that exports a default function. The function receiv
43
43
  import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
44
44
 
45
45
  export default function (pi: HookAPI) {
46
- pi.on("session_start", async (event, ctx) => {
47
- ctx.ui.notify(`Session: ${ctx.sessionFile ?? "ephemeral"}`, "info");
46
+ pi.on("session", async (event, ctx) => {
47
+ ctx.ui.notify(`Session ${event.reason}: ${ctx.sessionFile ?? "ephemeral"}`, "info");
48
48
  });
49
49
  }
50
50
  ```
@@ -76,7 +76,7 @@ Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works
76
76
  ```
77
77
  pi starts
78
78
 
79
- ├─► session_start
79
+ ├─► session (reason: "start")
80
80
 
81
81
 
82
82
  user sends prompt ─────────────────────────────────────────┐
@@ -98,36 +98,54 @@ user sends prompt ────────────────────
98
98
 
99
99
  user sends another prompt ◄────────────────────────────────┘
100
100
 
101
- user branches or switches session
101
+ user branches (/branch)
102
102
 
103
- └─► session_switch
103
+ ├─► branch (BEFORE branch, can control)
104
+ └─► session (reason: "switch", AFTER branch)
105
+
106
+ user switches session (/session)
107
+
108
+ └─► session (reason: "switch")
109
+
110
+ user clears session (/clear)
111
+
112
+ └─► session (reason: "clear")
104
113
  ```
105
114
 
106
115
  A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.
107
116
 
108
- ### session_start
117
+ ### session
109
118
 
110
- Fired once when pi starts.
119
+ Fired on startup and when session changes.
111
120
 
112
121
  ```typescript
113
- pi.on("session_start", async (event, ctx) => {
114
- // ctx.sessionFile: string | null
115
- // ctx.hasUI: boolean
122
+ pi.on("session", async (event, ctx) => {
123
+ // event.entries: SessionEntry[] - all session entries
124
+ // event.sessionFile: string | null - current session file
125
+ // event.previousSessionFile: string | null - previous session file
126
+ // event.reason: "start" | "switch" | "clear"
116
127
  });
117
128
  ```
118
129
 
119
- ### session_switch
130
+ **Reasons:**
131
+ - `start`: Initial session load on startup
132
+ - `switch`: User switched sessions (`/session`) or branched (`/branch`)
133
+ - `clear`: User cleared the session (`/clear`)
120
134
 
121
- Fired when session changes (`/branch` or session switch).
135
+ ### branch
136
+
137
+ Fired BEFORE a branch happens. Can control branch behavior.
122
138
 
123
139
  ```typescript
124
- pi.on("session_switch", async (event, ctx) => {
125
- // event.newSessionFile: string | null (null in --no-session mode)
126
- // event.previousSessionFile: string | null (null in --no-session mode)
127
- // event.reason: "branch" | "switch"
140
+ pi.on("branch", async (event, ctx) => {
141
+ // event.targetTurnIndex: number
142
+ // event.entries: SessionEntry[]
143
+ return { skipConversationRestore: true }; // or undefined
128
144
  });
129
145
  ```
130
146
 
147
+ Note: After branch completes, a `session` event fires with `reason: "switch"`.
148
+
131
149
  ### agent_start / agent_end
132
150
 
133
151
  Fired once per user prompt.
@@ -192,18 +210,6 @@ pi.on("tool_result", async (event, ctx) => {
192
210
  });
193
211
  ```
194
212
 
195
- ### branch
196
-
197
- Fired when user branches via `/branch`.
198
-
199
- ```typescript
200
- pi.on("branch", async (event, ctx) => {
201
- // event.targetTurnIndex: number
202
- // event.entries: SessionEntry[]
203
- return { skipConversationRestore: true }; // or undefined
204
- });
205
- ```
206
-
207
213
  ## Context API
208
214
 
209
215
  Every event handler receives a context object with these methods:
@@ -309,7 +315,9 @@ import * as fs from "node:fs";
309
315
  import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
310
316
 
311
317
  export default function (pi: HookAPI) {
312
- pi.on("session_start", async (event, ctx) => {
318
+ pi.on("session", async (event, ctx) => {
319
+ if (event.reason !== "start") return;
320
+
313
321
  // Watch a trigger file
314
322
  const triggerFile = "/tmp/agent-trigger.txt";
315
323
 
@@ -339,7 +347,9 @@ import * as http from "node:http";
339
347
  import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
340
348
 
341
349
  export default function (pi: HookAPI) {
342
- pi.on("session_start", async (event, ctx) => {
350
+ pi.on("session", async (event, ctx) => {
351
+ if (event.reason !== "start") return;
352
+
343
353
  const server = http.createServer((req, res) => {
344
354
  let body = "";
345
355
  req.on("data", chunk => body += chunk);
@@ -563,7 +573,7 @@ Key behaviors:
563
573
  Mode initialization:
564
574
  -> hookRunner.setUIContext(ctx, hasUI)
565
575
  -> hookRunner.setSessionFile(path)
566
- -> hookRunner.emit({ type: "session_start" })
576
+ -> hookRunner.emit({ type: "session", reason: "start", ... })
567
577
 
568
578
  User sends prompt:
569
579
  -> AgentSession.prompt()
@@ -581,9 +591,19 @@ User sends prompt:
581
591
  -> [repeat if more tool calls]
582
592
  -> hookRunner.emit({ type: "agent_end", messages })
583
593
 
584
- Branch or session switch:
585
- -> AgentSession.branch() or AgentSession.switchSession()
586
- -> hookRunner.emit({ type: "session_switch", ... })
594
+ Branch:
595
+ -> AgentSession.branch()
596
+ -> hookRunner.emit({ type: "branch", ... }) # BEFORE branch
597
+ -> [branch happens]
598
+ -> hookRunner.emit({ type: "session", reason: "switch", ... }) # AFTER
599
+
600
+ Session switch:
601
+ -> AgentSession.switchSession()
602
+ -> hookRunner.emit({ type: "session", reason: "switch", ... })
603
+
604
+ Clear:
605
+ -> AgentSession.reset()
606
+ -> hookRunner.emit({ type: "session", reason: "clear", ... })
587
607
  ```
588
608
 
589
609
  ## UI Context by Mode
@@ -0,0 +1,101 @@
1
+ # Custom Tools Examples
2
+
3
+ Example custom tools for pi-coding-agent.
4
+
5
+ ## Examples
6
+
7
+ ### hello.ts
8
+ Minimal example showing the basic structure of a custom tool.
9
+
10
+ ### question.ts
11
+ Demonstrates `pi.ui.select()` for asking the user questions with options.
12
+
13
+ ### todo.ts
14
+ Full-featured example demonstrating:
15
+ - `onSession` for state reconstruction from session history
16
+ - Custom `renderCall` and `renderResult`
17
+ - Proper branching support via details storage
18
+ - State management without external files
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ # Test directly
24
+ pi --tool examples/custom-tools/todo.ts
25
+
26
+ # Or copy to tools directory for persistent use
27
+ cp todo.ts ~/.pi/agent/tools/
28
+ ```
29
+
30
+ Then in pi:
31
+ ```
32
+ > add a todo "test custom tools"
33
+ > list todos
34
+ > toggle todo #1
35
+ > clear todos
36
+ ```
37
+
38
+ ## Writing Custom Tools
39
+
40
+ See [docs/custom-tools.md](../../docs/custom-tools.md) for full documentation.
41
+
42
+ ### Key Points
43
+
44
+ **Factory pattern:**
45
+ ```typescript
46
+ import { Type } from "@mariozechner/pi-coding-agent";
47
+ import { StringEnum } from "@mariozechner/pi-ai";
48
+ import { Text } from "@mariozechner/pi-tui";
49
+ import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
50
+
51
+ const factory: CustomToolFactory = (pi) => ({
52
+ name: "my_tool",
53
+ label: "My Tool",
54
+ description: "Tool description for LLM",
55
+ parameters: Type.Object({
56
+ action: StringEnum(["list", "add"] as const),
57
+ }),
58
+
59
+ // Called on session start/switch/branch/clear
60
+ onSession(event) {
61
+ // Reconstruct state from event.entries
62
+ },
63
+
64
+ async execute(toolCallId, params) {
65
+ return {
66
+ content: [{ type: "text", text: "Result" }],
67
+ details: { /* for rendering and state reconstruction */ },
68
+ };
69
+ },
70
+ });
71
+
72
+ export default factory;
73
+ ```
74
+
75
+ **Custom rendering:**
76
+ ```typescript
77
+ renderCall(args, theme) {
78
+ return new Text(
79
+ theme.fg("toolTitle", theme.bold("my_tool ")) + args.action,
80
+ 0, 0 // No padding - Box handles it
81
+ );
82
+ },
83
+
84
+ renderResult(result, { expanded, isPartial }, theme) {
85
+ if (isPartial) {
86
+ return new Text(theme.fg("warning", "Working..."), 0, 0);
87
+ }
88
+ return new Text(theme.fg("success", "✓ Done"), 0, 0);
89
+ },
90
+ ```
91
+
92
+ **Use StringEnum for string parameters** (required for Google API compatibility):
93
+ ```typescript
94
+ import { StringEnum } from "@mariozechner/pi-ai";
95
+
96
+ // Good
97
+ action: StringEnum(["list", "add"] as const)
98
+
99
+ // Bad - doesn't work with Google
100
+ action: Type.Union([Type.Literal("list"), Type.Literal("add")])
101
+ ```
@@ -0,0 +1,20 @@
1
+ import { Type } from "@mariozechner/pi-coding-agent";
2
+ import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
3
+
4
+ const factory: CustomToolFactory = (pi) => ({
5
+ name: "hello",
6
+ label: "Hello",
7
+ description: "A simple greeting tool",
8
+ parameters: Type.Object({
9
+ name: Type.String({ description: "Name to greet" }),
10
+ }),
11
+
12
+ async execute(toolCallId, params) {
13
+ return {
14
+ content: [{ type: "text", text: `Hello, ${params.name}!` }],
15
+ details: { greeted: params.name },
16
+ };
17
+ },
18
+ });
19
+
20
+ export default factory;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Question Tool - Let the LLM ask the user a question with options
3
+ */
4
+
5
+ import { Type } from "@mariozechner/pi-coding-agent";
6
+ import { Text } from "@mariozechner/pi-tui";
7
+ import type { CustomAgentTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
8
+
9
+ interface QuestionDetails {
10
+ question: string;
11
+ options: string[];
12
+ answer: string | null;
13
+ }
14
+
15
+ const QuestionParams = Type.Object({
16
+ question: Type.String({ description: "The question to ask the user" }),
17
+ options: Type.Array(Type.String(), { description: "Options for the user to choose from" }),
18
+ });
19
+
20
+ const factory: CustomToolFactory = (pi) => {
21
+ const tool: CustomAgentTool<typeof QuestionParams, QuestionDetails> = {
22
+ name: "question",
23
+ label: "Question",
24
+ description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
25
+ parameters: QuestionParams,
26
+
27
+ async execute(_toolCallId, params) {
28
+ if (!pi.hasUI) {
29
+ return {
30
+ content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
31
+ details: { question: params.question, options: params.options, answer: null },
32
+ };
33
+ }
34
+
35
+ if (params.options.length === 0) {
36
+ return {
37
+ content: [{ type: "text", text: "Error: No options provided" }],
38
+ details: { question: params.question, options: [], answer: null },
39
+ };
40
+ }
41
+
42
+ const answer = await pi.ui.select(params.question, params.options);
43
+
44
+ if (answer === null) {
45
+ return {
46
+ content: [{ type: "text", text: "User cancelled the selection" }],
47
+ details: { question: params.question, options: params.options, answer: null },
48
+ };
49
+ }
50
+
51
+ return {
52
+ content: [{ type: "text", text: `User selected: ${answer}` }],
53
+ details: { question: params.question, options: params.options, answer },
54
+ };
55
+ },
56
+
57
+ renderCall(args, theme) {
58
+ let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
59
+ if (args.options?.length) {
60
+ text += "\n" + theme.fg("dim", ` Options: ${args.options.join(", ")}`);
61
+ }
62
+ return new Text(text, 0, 0);
63
+ },
64
+
65
+ renderResult(result, _options, theme) {
66
+ const { details } = result;
67
+ if (!details) {
68
+ const text = result.content[0];
69
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
70
+ }
71
+
72
+ if (details.answer === null) {
73
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
74
+ }
75
+
76
+ return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0);
77
+ },
78
+ };
79
+
80
+ return tool;
81
+ };
82
+
83
+ export default factory;