@aliou/pi-dev-kit 0.5.0 → 0.6.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.
@@ -2,19 +2,49 @@
2
2
 
3
3
  Tools are functions the LLM can call. They are the primary way extensions add capabilities to pi.
4
4
 
5
- ## Registration
5
+ ## Imports
6
+
7
+ Use these imports at the top of your tool file:
6
8
 
7
9
  ```typescript
8
- import { Type, type ExtensionAPI, type ToolDefinition } from "@mariozechner/pi-coding-agent";
10
+ import { ToolCallHeader, ToolBody, ToolFooter } from "@aliou/pi-utils-ui";
11
+ import type {
12
+ AgentToolResult,
13
+ AgentToolUpdateCallback,
14
+ ExtensionAPI,
15
+ ExtensionContext,
16
+ Theme,
17
+ ToolRenderResultOptions,
18
+ } from "@mariozechner/pi-coding-agent";
19
+ import { getMarkdownTheme, keyHint, truncateHead, formatSize } from "@mariozechner/pi-coding-agent";
20
+ import { Container, Markdown, Text } from "@mariozechner/pi-tui";
21
+ import { type Static, Type } from "@mariozechner/pi-coding-agent";
22
+ ```
23
+
24
+ ## Registration
9
25
 
10
- const myTool: ToolDefinition = {
26
+ ```typescript
27
+ const myTool = {
11
28
  name: "my_tool",
29
+ label: "My Tool", // Required: human-readable name for UI
12
30
  description: "What this tool does. The LLM reads this to decide when to call it.",
31
+ promptSnippet: "Search for items by query", // One-liner for "Available tools" system prompt
32
+ promptGuidelines: [ // Guideline bullets for "Guidelines" system prompt when active
33
+ "Use this tool when the user asks about search",
34
+ "Prefer specific queries over broad ones",
35
+ ],
13
36
  parameters: Type.Object({
14
37
  query: Type.String({ description: "Search query" }),
15
38
  limit: Type.Optional(Type.Number({ description: "Max results", default: 10 })),
16
39
  }),
17
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
40
+
41
+ async execute(
42
+ toolCallId: string,
43
+ params: MyToolParams,
44
+ signal: AbortSignal | undefined,
45
+ onUpdate: AgentToolUpdateCallback<MyToolDetails> | undefined,
46
+ ctx: ExtensionContext,
47
+ ): Promise<AgentToolResult<MyToolDetails>> {
18
48
  const results = await doSomething(params.query, params.limit);
19
49
  return {
20
50
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
@@ -23,11 +53,45 @@ const myTool: ToolDefinition = {
23
53
  },
24
54
  };
25
55
 
56
+ // Typed param alias - define once, use everywhere
57
+ type MyToolParams = Static<typeof myTool.parameters>;
58
+ interface MyToolDetails {
59
+ results: string[];
60
+ }
61
+
26
62
  export default function (pi: ExtensionAPI) {
27
63
  pi.registerTool(myTool);
28
64
  }
29
65
  ```
30
66
 
67
+ ## Tool Definition Fields
68
+
69
+ | Field | Type | Required | Description |
70
+ |-------|------|----------|-------------|
71
+ | `name` | `string` | Yes | Snake_case identifier used in tool calls |
72
+ | `label` | `string` | Yes | Human-readable name shown in UI |
73
+ | `description` | `string` | Yes | What the tool does (LLM reads this) |
74
+ | `parameters` | `TSchema` | Yes | TypeBox schema for arguments |
75
+ | `promptSnippet` | `string` | No | One-liner injected into "Available tools" system prompt. Custom tools without this are omitted from that section. |
76
+ | `promptGuidelines` | `string[]` | No | Guideline bullets appended to "Guidelines" system prompt when this tool is active |
77
+ | `execute` | `function` | Yes | Implementation |
78
+ | `renderCall` | `function` | No | Custom call rendering |
79
+ | `renderResult` | `function` | No | Custom result rendering |
80
+
81
+ ## Typed Param Alias
82
+
83
+ Define a type alias at the top of your file instead of repeating `Static<typeof parameters>`:
84
+
85
+ ```typescript
86
+ const parameters = Type.Object({
87
+ query: Type.String({ description: "Search query" }),
88
+ limit: Type.Optional(Type.Number({ description: "Max results", default: 10 })),
89
+ });
90
+
91
+ type MyToolParams = Static<typeof parameters>;
92
+ // Use MyToolParams everywhere: execute params, renderCall args, context.args, etc.
93
+ ```
94
+
31
95
  ## Execute Signature
32
96
 
33
97
  ```typescript
@@ -77,7 +141,7 @@ return {
77
141
 
78
142
  ## Error Handling
79
143
 
80
- To report a tool call failure, **throw an error**. The framework catches it, sets `isError: true` on the tool result, and sends the error message to the LLM.
144
+ To report a tool call failure, **throw an error**. The framework catches it and produces a result with `isError: true` on the context.
81
145
 
82
146
  ```typescript
83
147
  execute: async (toolCallId, params, signal, onUpdate, ctx) => {
@@ -92,113 +156,67 @@ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
92
156
  },
93
157
  ```
94
158
 
95
- Do not try to return `isError` in the result object. The `AgentToolResult` type does not have an `isError` field. Only throwing sets `isError: true` on the tool result event sent to the LLM.
96
-
97
- ### Error rendering in `renderResult`
98
-
99
- When a tool throws, the framework still calls `renderResult`. It passes:
100
- - `content`: an array with the error message as a text block
101
- - `details`: an empty object `{}` (not `undefined`)
102
-
103
- Your `renderResult` must detect this and display the error. Check for missing expected fields in `details` -- do not check `!details` since the framework always provides an object.
159
+ The framework's `createErrorToolResult` sets `details: {}` (empty object, not `undefined`) and puts the error message in `content`:
104
160
 
105
161
  ```typescript
106
- // Full example: a tool that can fail, with proper error rendering.
107
- import { ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
108
- import type {
109
- AgentToolResult,
110
- ExtensionAPI,
111
- ExtensionContext,
112
- Theme,
113
- ToolRenderResultOptions,
114
- } from "@mariozechner/pi-coding-agent";
115
- import { Container, Text } from "@mariozechner/pi-tui";
116
- import { type Static, Type } from "@sinclair/typebox";
117
-
118
- interface DivideDetails {
119
- result?: number;
162
+ // Framework produces when tool throws:
163
+ {
164
+ content: [{ type: "text", text: errorMessage }],
165
+ details: {}
120
166
  }
167
+ // And sets context.isError = true in renderResult
168
+ ```
121
169
 
122
- const parameters = Type.Object({
123
- dividend: Type.Number({ description: "The number to divide" }),
124
- divisor: Type.Number({ description: "The number to divide by" }),
125
- });
126
-
127
- type DivideParams = Static<typeof parameters>;
128
-
129
- const divideTool = {
130
- name: "divide",
131
- label: "Divide",
132
- description: "Divide two numbers.",
133
- parameters,
134
-
135
- async execute(
136
- _toolCallId: string,
137
- params: DivideParams,
138
- _signal: AbortSignal | undefined,
139
- _onUpdate: undefined,
140
- _ctx: ExtensionContext,
141
- ): Promise<AgentToolResult<DivideDetails>> {
142
- if (params.divisor === 0) {
143
- throw new Error("Division by zero");
144
- }
145
- const result = params.dividend / params.divisor;
146
- return {
147
- content: [{ type: "text", text: `${result}` }],
148
- details: { result },
149
- };
150
- },
151
-
152
- renderCall(args: DivideParams, theme: Theme) {
153
- return new ToolCallHeader(
154
- { toolName: "Divide", mainArg: `${args.dividend} / ${args.divisor}` },
155
- theme,
156
- );
157
- },
170
+ ### Error rendering in `renderResult`
158
171
 
159
- renderResult(
160
- result: AgentToolResult<DivideDetails>,
161
- options: ToolRenderResultOptions,
162
- theme: Theme,
163
- ) {
164
- if (options.isPartial) {
165
- return new Text(theme.fg("muted", "Dividing..."), 0, 0);
166
- }
172
+ The framework passes `isError` on the 4th `context` parameter to `renderResult`, but `ToolRenderContext` is not currently exported from the public API. Two practical approaches:
167
173
 
168
- const details = result.details;
169
- const container = new Container();
174
+ **Approach 1: Check for missing expected fields in `details`** (recommended for extensions)
170
175
 
171
- // Detect error: details is {} when the tool threw.
172
- // Check for missing expected fields, not !details.
173
- if (details?.result === undefined) {
174
- const textBlock = result.content.find((c) => c.type === "text");
175
- const errorMsg =
176
- (textBlock?.type === "text" && textBlock.text) || "Division failed";
177
- container.addChild(new Text(theme.fg("error", errorMsg), 0, 0));
178
- return container;
179
- }
176
+ When a tool throws, the framework sets `details: {}` (empty object). Check for missing expected fields:
180
177
 
181
- container.addChild(
182
- new Text(theme.fg("success", `Result: ${details.result}`), 0, 0),
183
- );
178
+ ```typescript
179
+ renderResult(
180
+ result: AgentToolResult<MyToolDetails>,
181
+ options: ToolRenderResultOptions,
182
+ theme: Theme,
183
+ ) {
184
+ const { details } = result;
185
+
186
+ // details is {} when tool threw — expected fields are missing
187
+ if (!details?.results) {
188
+ const textBlock = result.content.find((c) => c.type === "text");
189
+ const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Operation failed";
190
+ return new Text(theme.fg("error", errorMsg), 0, 0);
191
+ }
192
+ // ... normal rendering
193
+ }
194
+ ```
184
195
 
185
- container.addChild(new Text("", 0, 0));
186
- container.addChild(
187
- new ToolFooter(theme, {
188
- items: [{ label: "result", value: `${details.result}` }],
189
- separator: " | ",
190
- }),
191
- );
196
+ **Approach 2: Use the 4th context parameter** (used by native tools)
192
197
 
193
- return container;
194
- },
195
- };
198
+ Define a minimal interface locally:
196
199
 
197
- export default function (pi: ExtensionAPI) {
198
- pi.registerTool(divideTool);
200
+ ```typescript
201
+ interface RenderContext { isError: boolean }
202
+
203
+ renderResult(
204
+ result: AgentToolResult<MyToolDetails>,
205
+ options: ToolRenderResultOptions,
206
+ theme: Theme,
207
+ context: RenderContext,
208
+ ) {
209
+ if (context.isError) {
210
+ const textBlock = result.content.find((c) => c.type === "text");
211
+ const errorMsg = textBlock?.type === "text" ? textBlock.text : "Operation failed";
212
+ return new Text(theme.fg("error", errorMsg), 0, 0);
213
+ }
214
+ // ... normal rendering
199
215
  }
200
216
  ```
201
217
 
218
+ Both approaches work. Approach 1 is more common in published extensions. Approach 2 is what native tools use.
219
+
202
220
  ## Parameters Schema
203
221
 
204
222
  Use TypeBox (`Type.*`) for parameter schemas. The LLM sees the schema to know what arguments to provide.
@@ -254,45 +272,126 @@ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
254
272
 
255
273
  Override how a tool's invocation and result appear in the TUI.
256
274
 
275
+ ### `renderCall` Signature
276
+
257
277
  ```typescript
258
- const myTool: ToolDefinition = {
259
- name: "my_tool",
260
- // ... parameters, execute ...
278
+ renderCall(args: MyToolParams, theme: Theme): Component
279
+ ```
261
280
 
262
- renderCall(params, theme) {
263
- const header = [`My Tool: search`, JSON.stringify(params.query), `limit=${params.limit ?? 10}`].join(" ");
264
- return theme.fg("toolTitle", header);
265
- },
281
+ A 3rd `context` param is available from the framework but rarely needed in `renderCall`.
266
282
 
267
- renderResult(result, { expanded, isPartial }, theme) {
268
- if (isPartial) {
269
- return theme.fg("muted", "My Tool: running...");
270
- }
283
+ Use `ToolCallHeader` from `@aliou/pi-utils-ui`:
271
284
 
272
- const items: string[] = result.details?.results ?? [];
273
- const visible = expanded ? items : items.slice(0, 5);
285
+ ```typescript
286
+ renderCall(args: MyToolParams, theme: Theme) {
287
+ return new ToolCallHeader(
288
+ {
289
+ toolName: "My Tool",
290
+ action: "search", // Optional: for multi-action tools
291
+ mainArg: `"${args.query}"`, // Primary thing user scans for
292
+ optionArgs: [`limit=${args.limit ?? 10}`], // Compact key-value pairs
293
+ longArgs: [], // Long text goes here
294
+ showColon: true, // Whether to show colon after tool name
295
+ },
296
+ theme,
297
+ );
298
+ }
299
+ ```
274
300
 
275
- const lines = [
276
- theme.fg("success", `Found ${items.length} results`),
277
- ...visible.map((r) => ` ${r}`),
278
- ];
301
+ ### `renderResult` Signature
279
302
 
280
- if (!expanded && items.length > visible.length) {
281
- lines.push(theme.fg("dim", ` ...and ${items.length - visible.length} more`));
282
- }
303
+ ```typescript
304
+ renderResult(
305
+ result: AgentToolResult<TDetails>,
306
+ options: ToolRenderResultOptions,
307
+ theme: Theme,
308
+ ): Component
309
+ ```
283
310
 
284
- return lines.join("\n");
285
- },
286
- };
311
+ - `options` has `expanded` and `isPartial`
312
+ - `result.details` is `{}` (empty object) when the tool threw an error
313
+ - A 4th `context` param is available (see Error Handling above) but not required
314
+
315
+ ```typescript
316
+ renderResult(
317
+ result: AgentToolResult<MyToolDetails>,
318
+ options: ToolRenderResultOptions,
319
+ theme: Theme,
320
+ ) {
321
+ // 1. Handle partial state first with stable message
322
+ if (options.isPartial) {
323
+ return new Text(theme.fg("muted", "MyTool: fetching..."), 0, 0);
324
+ }
325
+
326
+ const { details } = result;
327
+
328
+ // 2. Handle errors: details is {} when tool threw
329
+ if (!details?.results) {
330
+ const textBlock = result.content.find((c) => c.type === "text");
331
+ const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Operation failed";
332
+ return new Text(theme.fg("error", errorMsg), 0, 0);
333
+ }
334
+
335
+ // 3. Build fields for ToolBody (showCollapsed controls collapsed/expanded visibility)
336
+ const items = details.results;
337
+
338
+ const fields: Array<{ label: string; value: string; showCollapsed?: boolean } | Text> = [
339
+ { label: "Results", value: `${items.length} items`, showCollapsed: true },
340
+ ];
341
+
342
+ // 4. Build conditional footer
343
+ const footerItems: Array<{ label: string; value: string }> = [];
344
+ if (items.length > 0) {
345
+ footerItems.push({ label: "count", value: `${items.length}` });
346
+ }
347
+
348
+ return new ToolBody(
349
+ {
350
+ fields,
351
+ footer: footerItems.length > 0
352
+ ? new ToolFooter(theme, { items: footerItems, separator: " | " })
353
+ : undefined,
354
+ includeSpacerBeforeFooter: fields.length > 0,
355
+ },
356
+ options,
357
+ theme,
358
+ );
359
+ }
287
360
  ```
288
361
 
289
- `renderCall` receives the params the LLM passed and returns a string shown when the tool is invoked.
362
+ ### `renderCall` Design Guide
363
+
364
+ The `process` extension is a good baseline. Its call renderer is deterministic and keeps headers readable.
365
+
366
+ Use this extraction order when building header parts:
367
+
368
+ 1. **Action first**: always show action for multi-action tools (`start`, `list`, `kill`, ...).
369
+ 2. **Pick one main arg**: choose the single value the user scans for first (name, id, or short command).
370
+ 3. **Promote short fields to options**: compact values become option args (`end=true`, `limit=10`).
371
+ 4. **Demote long fields to long args**: commands/prompts/instructions move to labeled follow-up lines.
372
+ 5. **Keep it stable**: same inputs should produce same ordering and formatting.
290
373
 
291
- `renderResult` receives the result (with `details`) and rendering options:
292
- - `expanded`: Whether the entry is expanded in the TUI.
293
- - `isPartial`: Whether this is a streaming update (from `onUpdate`) or the final result.
374
+ Implementation pattern:
375
+ - Build `mainArg`, `optionArgs`, `longArgs` first, then pass to `ToolCallHeader`.
376
+ - Quote user-provided names (`"backend"`) when that improves visual parsing.
377
+ - Cap inline length (e.g. 60-80 chars), then spill to `longArgs`.
294
378
 
295
- Both return a string or undefined (falls back to default rendering).
379
+ ### `renderResult` Guidelines
380
+
381
+ - Handle `isPartial` first with a **stable, tool-scoped message** like `"MyTool: fetching..."`. Do NOT echo streaming content.
382
+ - Handle errors by checking for missing expected fields in `details` (framework sets `details: {}` on throw). Alternatively use the 4th `context` param with `context.isError`.
383
+ - Use `ToolBody` with `showCollapsed: true` fields for collapsed/expanded filtering.
384
+ - Use `ToolFooter` for stats/metadata. Omit when empty: `footer: items.length > 0 ? new ToolFooter(...) : undefined`.
385
+ - Use `includeSpacerBeforeFooter: fields.length > 0` to avoid blank line when body is empty.
386
+ - Remove redundant success footer items — don't show `status: ok` when success is obvious.
387
+ - Use `Container` + `addChild()` for multi-element results instead of string concatenation.
388
+ - Use `Markdown` component for rich markdown content:
389
+ ```typescript
390
+ new Markdown(text, 0, 0, getMarkdownTheme(), { color: (t) => theme.fg("toolOutput", t) })
391
+ ```
392
+ - Use `keyHint("app.tools.expand", "to expand")` for expand hints.
393
+ - Collapsed result should show **actionable preview** (last N lines, first N items with status), not just a status badge.
394
+ - Humanize error messages with names first: `"Could not get X for "name" (id)"`.
296
395
 
297
396
  ## Tool UI Rendering Guidelines
298
397
 
@@ -306,7 +405,7 @@ Use this line model:
306
405
  - Additional lines: long args only
307
406
 
308
407
  Guidelines:
309
- - Tool name should be a human display label, not a raw internal identifier.
408
+ - Tool name should be a human display label (`label`), not a raw internal identifier (`name`).
310
409
  - Show `action` only when it adds meaning (multi-action tools like process managers).
311
410
  - Main arg should be the primary thing user cares about (query, session id, target id/name).
312
411
  - Option args should be compact key-value pairs (`limit=10`, `cwd=/path`).
@@ -314,30 +413,13 @@ Guidelines:
314
413
  - Prefer wrapping to preserve full meaning over aggressive truncation.
315
414
  - For tools without actions, omit colon suffix after tool name if that reads better in your UI system.
316
415
 
317
- ### `renderCall` design guide (process-style)
318
-
319
- The `process` extension is a good baseline (`../pi-processes/src/tools/index.ts`). Its call renderer is deterministic and keeps headers readable.
320
-
321
- Use this extraction order when building header parts:
322
-
323
- 1. **Action first**: always show action for multi-action tools (`start`, `list`, `kill`, ...).
324
- 2. **Pick one main arg**: choose the single value the user scans for first (name, id, or short command).
325
- 3. **Promote short fields to options**: compact values become option args (`end=true`, `limit=10`).
326
- 4. **Demote long fields to long args**: commands/prompts/instructions move to labeled follow-up lines.
327
- 5. **Keep it stable**: same inputs should produce same ordering and formatting.
328
-
329
- Implementation pattern:
330
- - Build `mainArg`, `optionArgs`, `longArgs` first, then pass to one renderer.
331
- - If you use `@aliou/pi-utils-ui`, prefer `ToolCallHeader` to avoid hand-built string drift.
332
- - Quote user-provided names (`"backend"`) when that improves visual parsing.
333
- - Cap inline length (e.g. 60-80 chars), then spill to `longArgs`.
334
416
  ### `renderResult` layout
335
417
 
336
- - Handle `isPartial` first. Return a short stable loading state.
418
+ - Handle `isPartial` first. Return a short stable loading state with tool-scoped message.
337
419
  - Keep the first non-loading line as a status summary (`Found N results`, `Updated 3 files`, `Failed: reason`).
338
420
  - Use `expanded` to switch between compact and full output. Compact should show the top few items plus an omission hint.
339
421
  - Keep body content focused on state + key output; avoid dumping raw JSON unless it is the actual output.
340
- - If you render a footer (stats, backend, counts), keep one blank line above it.
422
+ - If you render a footer (stats, backend, counts), use `includeSpacerBeforeFooter` to control blank line.
341
423
  - Keep footer concise and stable across states.
342
424
  - Return `undefined` when custom rendering adds no value; fallback rendering is better than noisy UI.
343
425
 
@@ -346,9 +428,9 @@ Implementation pattern:
346
428
  Use this contract to keep tool UX consistent across extensions:
347
429
 
348
430
  1. **Call line is for scanability**: `renderCall` first line follows `[Tool Name]: [Action] [Main arg] [Option args]`.
349
- 2. **Result starts with state**: `renderResult` starts with a clear state line (running/success/error) before details.
431
+ 2. **Result detects errors** by checking for missing expected fields in `details` (framework sets `details: {}` on throw).
350
432
  3. **Long text moves down**: prompts, instructions, and context go to follow-up lines, not the call header.
351
- 4. **Partial updates stay compact**: `isPartial` output should be short and stable to prevent visual churn.
433
+ 4. **Partial updates use a fixed tool-scoped string**, not echoed streaming content.
352
434
  5. **Expanded controls density**: compact view shows summary + subset; expanded view shows full detail.
353
435
  6. **No mode leaks in tool renderers**: `renderCall`/`renderResult` should not branch on mode. Mode-specific behavior belongs in command/tool logic (`references/modes.md`).
354
436
 
@@ -413,18 +495,454 @@ Do not prefix signal with underscore (`_signal`) unless the tool genuinely canno
413
495
 
414
496
  ## Output Truncation
415
497
 
416
- For tools that may return large outputs, use the `truncateHead` utility:
498
+ For tools that may return large outputs, use `truncateHead()` which returns a `TruncationResult`:
417
499
 
418
500
  ```typescript
419
- import { truncateHead } from "@mariozechner/pi-coding-agent";
501
+ import { truncateHead, formatSize } from "@mariozechner/pi-coding-agent";
502
+ import { createWriteStream } from "fs";
503
+ import { tmpdir } from "os";
504
+ import { join } from "path";
505
+
506
+ interface FetchDetails {
507
+ content: string;
508
+ url: string;
509
+ truncated: boolean;
510
+ totalLines: number;
511
+ totalBytes: number;
512
+ outputLines: number;
513
+ outputBytes: number;
514
+ tempFile?: string;
515
+ }
516
+
517
+ async execute(
518
+ _toolCallId: string,
519
+ params: FetchParams,
520
+ signal: AbortSignal | undefined,
521
+ _onUpdate: undefined,
522
+ _ctx: ExtensionContext,
523
+ ): Promise<AgentToolResult<FetchDetails>> {
524
+ const response = await fetch(params.url, { signal });
525
+ const fullContent = await response.text();
526
+
527
+ // truncateHead keeps the tail (most recent content)
528
+ const truncated = truncateHead(fullContent, {
529
+ maxBytes: 50000,
530
+ maxLines: 2000,
531
+ });
532
+
533
+ const details: FetchDetails = {
534
+ content: truncated.content,
535
+ url: params.url,
536
+ truncated: truncated.truncated,
537
+ totalLines: truncated.totalLines,
538
+ totalBytes: truncated.totalBytes,
539
+ outputLines: truncated.outputLines,
540
+ outputBytes: truncated.outputBytes,
541
+ };
542
+
543
+ // Write full content to temp file if truncated
544
+ if (truncated.truncated) {
545
+ const tempFile = join(tmpdir(), `web-fetch-${Date.now()}.txt`);
546
+ const stream = createWriteStream(tempFile);
547
+ stream.write(fullContent);
548
+ stream.end();
549
+ details.tempFile = tempFile;
550
+ }
420
551
 
421
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
422
- const fullOutput = await getLargeOutput();
423
552
  return {
424
- content: [{ type: "text", text: truncateHead(fullOutput, 50000) }], // Keep last 50KB
425
- details: {},
553
+ content: [{ type: "text", text: truncated.content }],
554
+ details,
426
555
  };
427
- },
556
+ }
557
+ ```
558
+
559
+ ### Truncation Result Fields
560
+
561
+ ```typescript
562
+ interface TruncationResult {
563
+ content: string; // The truncated content (or full if not truncated)
564
+ truncated: boolean; // Whether truncation occurred
565
+ totalLines: number; // Original total lines
566
+ totalBytes: number; // Original total bytes
567
+ outputLines: number; // Lines in output
568
+ outputBytes: number; // Bytes in output
569
+ }
570
+ ```
571
+
572
+ ### Rendering Truncated Output
573
+
574
+ ```typescript
575
+ renderResult(
576
+ result: AgentToolResult<FetchDetails>,
577
+ options: ToolRenderResultOptions,
578
+ theme: Theme,
579
+ ) {
580
+ if (options.isPartial) {
581
+ return new Text(theme.fg("muted", "WebFetch: fetching..."), 0, 0);
582
+ }
583
+
584
+ const { details } = result;
585
+
586
+ if (!details?.content) {
587
+ const textBlock = result.content.find((c) => c.type === "text");
588
+ const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Fetch failed";
589
+ return new Text(theme.fg("error", errorMsg), 0, 0);
590
+ }
591
+ const container = new Container();
592
+
593
+ // Body with content preview
594
+ const fields = [
595
+ { label: "URL", value: details.url, showCollapsed: true },
596
+ { label: "Size", value: formatSize(details.totalBytes), showCollapsed: true },
597
+ ];
598
+
599
+ container.addChild(new ToolBody(theme, { fields, expanded: options.expanded }));
600
+
601
+ // Footer with truncation info
602
+ const footerItems = [];
603
+
604
+ if (details.truncated) {
605
+ footerItems.push(
606
+ { label: "showing", value: `${details.outputLines}/${details.totalLines} lines` },
607
+ { label: "full output", value: details.tempFile ?? "temp file" },
608
+ );
609
+ } else {
610
+ footerItems.push({ label: "lines", value: `${details.totalLines}` });
611
+ }
612
+
613
+ container.addChild(
614
+ new ToolFooter(theme, {
615
+ items: footerItems,
616
+ separator: " | ",
617
+ includeSpacerBeforeFooter: fields.length > 0,
618
+ }),
619
+ );
620
+
621
+ return container;
622
+ }
623
+ ```
624
+
625
+ ## Multi-Action Tools
626
+
627
+ For tools with multiple actions (e.g., `start`, `list`, `kill`), organize code for maintainability:
628
+
629
+ ### File Structure
630
+
631
+ ```
632
+ tools/
633
+ my_tool/
634
+ index.ts // Tool definition, registration, execute switch
635
+ actions/
636
+ start.ts // start action implementation
637
+ list.ts // list action implementation
638
+ kill.ts // kill action implementation
639
+ render.ts // renderCall and renderResult (when complex)
640
+ types.ts // Shared types and param schema
641
+ ```
642
+
643
+ ### Action Pattern
644
+
645
+ Each action takes an SDK client and typed params, returns typed results:
646
+
647
+ ```typescript
648
+ // actions/start.ts
649
+ export interface StartParams {
650
+ name: string;
651
+ command: string;
652
+ cwd?: string;
653
+ }
654
+
655
+ export interface StartResult {
656
+ sessionId: string;
657
+ pid: number;
658
+ }
659
+
660
+ export async function start(
661
+ client: MyClient,
662
+ params: StartParams,
663
+ signal?: AbortSignal,
664
+ ): Promise<StartResult> {
665
+ return client.startSession({
666
+ name: params.name,
667
+ command: params.command,
668
+ cwd: params.cwd,
669
+ signal,
670
+ });
671
+ }
672
+ ```
673
+
674
+ ### Execute Delegation
675
+
676
+ ```typescript
677
+ // index.ts
678
+ async execute(
679
+ toolCallId: string,
680
+ params: MyToolParams,
681
+ signal: AbortSignal | undefined,
682
+ onUpdate: AgentToolUpdateCallback<MyToolDetails> | undefined,
683
+ ctx: ExtensionContext,
684
+ ): Promise<AgentToolResult<MyToolDetails>> {
685
+ switch (params.action) {
686
+ case "start":
687
+ return startAction(client, params, signal, onUpdate, ctx);
688
+ case "list":
689
+ return listAction(client, params, signal);
690
+ case "kill":
691
+ return killAction(client, params, signal);
692
+ default:
693
+ throw new Error(`Unknown action: ${params.action}`);
694
+ }
695
+ }
428
696
  ```
429
697
 
430
- `truncateHead` keeps the tail of the output (most recent content), which is usually most relevant.
698
+ ### Separate Render Module
699
+
700
+ When rendering is complex, extract to `render.ts`:
701
+
702
+ ```typescript
703
+ // render.ts
704
+ export function renderCall(args: MyToolParams, theme: Theme) {
705
+ return new ToolCallHeader(
706
+ {
707
+ toolName: "MyTool",
708
+ action: args.action,
709
+ mainArg: getMainArg(args),
710
+ optionArgs: getOptionArgs(args),
711
+ longArgs: getLongArgs(args),
712
+ },
713
+ theme,
714
+ );
715
+ }
716
+
717
+ export function renderResult(
718
+ result: AgentToolResult<MyToolDetails>,
719
+ options: ToolRenderResultOptions,
720
+ theme: Theme,
721
+ ) {
722
+ // Complex rendering logic here
723
+ }
724
+ ```
725
+
726
+ ```typescript
727
+ // index.ts
728
+ import { renderCall, renderResult } from "./render.js";
729
+
730
+ const myTool = {
731
+ // ...
732
+ renderCall,
733
+ renderResult,
734
+ };
735
+ ```
736
+
737
+ **Examples to reference:**
738
+ - `pi-processes`: Multi-action process management with complex rendering
739
+ - `pi-linear`: Multi-action Linear API integration
740
+
741
+ ## Full Example
742
+
743
+ Here's a realistic tool demonstrating all the patterns:
744
+
745
+ ```typescript
746
+ import { ToolCallHeader, ToolBody, ToolFooter } from "@aliou/pi-utils-ui";
747
+ import type {
748
+ AgentToolResult,
749
+ ExtensionAPI,
750
+ ExtensionContext,
751
+ Theme,
752
+ ToolRenderResultOptions,
753
+ } from "@mariozechner/pi-coding-agent";
754
+ import { keyHint, formatSize } from "@mariozechner/pi-coding-agent";
755
+ import { Container, Text } from "@mariozechner/pi-tui";
756
+ import { type Static, Type } from "@mariozechner/pi-coding-agent";
757
+
758
+ // Schema
759
+ const parameters = Type.Object({
760
+ owner: Type.String({ description: "Repository owner" }),
761
+ repo: Type.String({ description: "Repository name" }),
762
+ path: Type.Optional(Type.String({ description: "File or directory path", default: "" })),
763
+ });
764
+
765
+ // Typed aliases
766
+ type RepoTreeParams = Static<typeof parameters>;
767
+
768
+ interface RepoTreeDetails {
769
+ owner: string;
770
+ repo: string;
771
+ path: string;
772
+ entries: Array<{ name: string; type: "file" | "dir"; size?: number }>;
773
+ truncated: boolean;
774
+ }
775
+
776
+ // Tool definition
777
+ const repoTreeTool = {
778
+ name: "repo_tree",
779
+ label: "Repo Tree",
780
+ description: "List files and directories in a GitHub repository.",
781
+ promptSnippet: "Browse repository file structure",
782
+ promptGuidelines: [
783
+ "Use this to explore repository structure before reading files",
784
+ "Start with root path, then drill down into directories",
785
+ ],
786
+ parameters,
787
+
788
+ async execute(
789
+ _toolCallId: string,
790
+ params: RepoTreeParams,
791
+ signal: AbortSignal | undefined,
792
+ _onUpdate: undefined,
793
+ _ctx: ExtensionContext,
794
+ ): Promise<AgentToolResult<RepoTreeDetails>> {
795
+ const response = await fetch(
796
+ `https://api.github.com/repos/${params.owner}/${params.repo}/contents/${params.path ?? ""}`,
797
+ { signal, headers: { Accept: "application/vnd.github.v3+json" } },
798
+ );
799
+
800
+ if (!response.ok) {
801
+ if (response.status === 404) {
802
+ throw new Error(
803
+ `Could not find repository "${params.owner}/${params.repo}" or path "${params.path ?? ""}"`,
804
+ );
805
+ }
806
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
807
+ }
808
+
809
+ const data = await response.json();
810
+
811
+ // GitHub returns a single object for files, array for directories
812
+ const entries = Array.isArray(data) ? data : [data];
813
+
814
+ const processed = entries.map((entry: any) => ({
815
+ name: entry.name,
816
+ type: entry.type === "dir" ? "dir" as const : "file" as const,
817
+ size: entry.size,
818
+ }));
819
+
820
+ // Sort: directories first, then files, alphabetically within each
821
+ processed.sort((a, b) => {
822
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
823
+ return a.name.localeCompare(b.name);
824
+ });
825
+
826
+ const truncated = processed.length > 100;
827
+
828
+ return {
829
+ content: [
830
+ {
831
+ type: "text",
832
+ text: JSON.stringify(truncated ? processed.slice(0, 100) : processed, null, 2),
833
+ },
834
+ ],
835
+ details: {
836
+ owner: params.owner,
837
+ repo: params.repo,
838
+ path: params.path ?? "",
839
+ entries: processed,
840
+ truncated,
841
+ },
842
+ };
843
+ },
844
+
845
+ renderCall(args: RepoTreeParams, theme: Theme) {
846
+ return new ToolCallHeader(
847
+ {
848
+ toolName: "Repo Tree",
849
+ mainArg: `${args.owner}/${args.repo}`,
850
+ optionArgs: args.path ? [`path=${args.path}`] : [],
851
+ longArgs: [],
852
+ },
853
+ theme,
854
+ );
855
+ },
856
+
857
+ renderResult(
858
+ result: AgentToolResult<RepoTreeDetails>,
859
+ options: ToolRenderResultOptions,
860
+ theme: Theme,
861
+ ) {
862
+ // 1. Stable partial message
863
+ if (options.isPartial) {
864
+ return new Text(theme.fg("muted", "RepoTree: fetching..."), 0, 0);
865
+ }
866
+
867
+ const { details } = result;
868
+
869
+ // 2. Error handling: details is {} when tool threw
870
+ if (!details?.entries) {
871
+ const textBlock = result.content.find((c) => c.type === "text");
872
+ const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Failed to list repository";
873
+ return new Text(theme.fg("error", errorMsg), 0, 0);
874
+ }
875
+ const container = new Container();
876
+ const entries = details?.entries ?? [];
877
+ const dirs = entries.filter((e) => e.type === "dir");
878
+ const files = entries.filter((e) => e.type === "file");
879
+
880
+ // 3. Body fields with showCollapsed for collapsed/expanded control
881
+ const fields: Array<{ label: string; value: string; showCollapsed: boolean }> = [
882
+ {
883
+ label: "Location",
884
+ value: `${details.owner}/${details.repo}${details.path ? `/${details.path}` : ""}`,
885
+ showCollapsed: true,
886
+ },
887
+ {
888
+ label: "Contents",
889
+ value: `${dirs.length} dirs, ${files.length} files`,
890
+ showCollapsed: true,
891
+ },
892
+ ];
893
+
894
+ container.addChild(new ToolBody(theme, { fields, expanded: options.expanded }));
895
+
896
+ // 4. Entry list (in expanded view)
897
+ if (options.expanded && entries.length > 0) {
898
+ const entryLines = entries
899
+ .slice(0, 50)
900
+ .map((e) => {
901
+ const icon = e.type === "dir" ? "📁" : "📄";
902
+ const size = e.size !== undefined ? ` (${formatSize(e.size)})` : "";
903
+ return `${icon} ${e.name}${size}`;
904
+ })
905
+ .join("\n");
906
+
907
+ container.addChild(new Text(entryLines, 0, 0));
908
+
909
+ if (entries.length > 50) {
910
+ container.addChild(
911
+ new Text(theme.fg("muted", `... and ${entries.length - 50} more`), 0, 0),
912
+ );
913
+ }
914
+ }
915
+
916
+ // 5. Conditional footer
917
+ const footerItems = [];
918
+
919
+ if (details?.truncated) {
920
+ footerItems.push({ label: "showing", value: "first 100 entries" });
921
+ }
922
+
923
+ if (entries.length > 0) {
924
+ footerItems.push({ label: "total", value: `${entries.length} items` });
925
+ }
926
+
927
+ if (!options.expanded && entries.length > 5) {
928
+ footerItems.push({ label: "", value: keyHint("app.tools.expand", "to expand") });
929
+ }
930
+
931
+ if (footerItems.length > 0) {
932
+ container.addChild(
933
+ new ToolFooter(theme, {
934
+ items: footerItems,
935
+ separator: " | ",
936
+ includeSpacerBeforeFooter: fields.length > 0,
937
+ }),
938
+ );
939
+ }
940
+
941
+ return container;
942
+ },
943
+ };
944
+
945
+ export default function (pi: ExtensionAPI) {
946
+ pi.registerTool(repoTreeTool);
947
+ }
948
+ ```