@aliou/pi-dev-kit 0.6.4 → 0.7.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.
@@ -1,274 +1,174 @@
1
1
  # Tools
2
2
 
3
- Tools are functions the LLM can call. They are the primary way extensions add capabilities to pi.
3
+ Tools are functions the LLM can call. They are the main way extensions add capabilities to Pi.
4
4
 
5
5
  ## Imports
6
6
 
7
- Use these imports at the top of your tool file:
7
+ Use the namespace that matches the target Pi package. Examples use the forward namespace.
8
8
 
9
9
  ```typescript
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 "@sinclair/typebox";
10
+ import { ToolBody, ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
11
+ import type { AgentToolResult, ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ defineTool,
14
+ formatSize,
15
+ getMarkdownTheme,
16
+ keyHint,
17
+ truncateHead,
18
+ truncateTail,
19
+ withFileMutationQueue,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import { StringEnum } from "@earendil-works/pi-ai";
22
+ import { Container, Markdown, Text } from "@earendil-works/pi-tui";
23
+ import { type Static, Type } from "typebox";
22
24
  ```
23
25
 
24
- ## Registration
26
+ Use legacy `@mariozechner/*` imports only when the target `@earendil-works/*` package is not available yet.
27
+
28
+ ## Minimal Tool Entry Point
29
+
30
+ Tool entry points are normal Pi extension entry points. Export a default function and list the file in `package.json` `pi.extensions`.
25
31
 
26
32
  ```typescript
27
- const myTool = {
33
+ const parameters = Type.Object({
34
+ query: Type.String({ description: "Search query" }),
35
+ limit: Type.Optional(Type.Number({ description: "Max results", default: 10 })),
36
+ });
37
+
38
+ type MyToolParams = Static<typeof parameters>;
39
+
40
+ interface MyToolDetails {
41
+ results: string[];
42
+ }
43
+
44
+ const myTool = defineTool({
28
45
  name: "my_tool",
29
- label: "My Tool", // Required: human-readable name for UI
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 appended verbatim to the global "Guidelines" section when this tool is active
33
- "Use my_tool when the user asks about search.",
34
- "Prefer specific queries over broad ones when calling my_tool.",
46
+ label: "My Tool",
47
+ description: "Search for items by query.",
48
+ promptSnippet: "Search for items by query",
49
+ promptGuidelines: [
50
+ "Use my_tool when the user asks to search these items.",
51
+ "Prefer specific queries when calling my_tool.",
35
52
  ],
36
- parameters: Type.Object({
37
- query: Type.String({ description: "Search query" }),
38
- limit: Type.Optional(Type.Number({ description: "Max results", default: 10 })),
39
- }),
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>> {
48
- const results = await doSomething(params.query, params.limit);
53
+ parameters,
54
+
55
+ async execute(_toolCallId, params, signal, onUpdate, ctx): Promise<AgentToolResult<MyToolDetails>> {
56
+ onUpdate?.({ content: [{ type: "text", text: "Searching..." }] });
57
+ const results = await searchItems(params.query, { limit: params.limit, signal });
49
58
  return {
50
59
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
51
60
  details: { results },
52
61
  };
53
62
  },
54
- };
55
-
56
- // Typed param alias - define once, use everywhere
57
- type MyToolParams = Static<typeof myTool.parameters>;
58
- interface MyToolDetails {
59
- results: string[];
60
- }
63
+ });
61
64
 
62
- export default function (pi: ExtensionAPI) {
65
+ export default function toolsExtension(pi: ExtensionAPI) {
63
66
  pi.registerTool(myTool);
64
67
  }
65
68
  ```
66
69
 
67
- ## Tool Definition Fields
70
+ ## `defineTool()`
68
71
 
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 verbatim to the global "Guidelines" system prompt section when this tool is active. Write each bullet so it still makes sense standalone. |
77
- | `execute` | `function` | Yes | Implementation |
78
- | `renderCall` | `function` | No | Custom call rendering |
79
- | `renderResult` | `function` | No | Custom result rendering |
72
+ Use `defineTool({...})` for standalone tool objects. It preserves parameter inference from the `parameters` field when the tool is stored in a variable or passed through arrays.
80
73
 
81
- ## Typed Param Alias
74
+ Rules:
82
75
 
83
- Define a type alias at the top of your file instead of repeating `Static<typeof parameters>`:
76
+ - Do not pass explicit generic arguments to `defineTool`.
77
+ - Do not annotate callback parameters unless TypeScript needs help.
78
+ - Add a `Static<typeof parameters>` alias for helper functions and action modules.
79
+ - If you want `renderResult` to know the `details` shape, annotate `execute` with `Promise<AgentToolResult<MyDetails>>`.
84
80
 
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
- });
81
+ ## Tool Definition Fields
90
82
 
91
- type MyToolParams = Static<typeof parameters>;
92
- // Use MyToolParams everywhere: execute params, renderCall args, context.args, etc.
93
- ```
83
+ | Field | Required | Notes |
84
+ |---|---:|---|
85
+ | `name` | Yes | Snake_case identifier used in tool calls. |
86
+ | `label` | Yes | Human-readable display name. |
87
+ | `description` | Yes | Model-facing description. |
88
+ | `parameters` | Yes | TypeBox 1.x schema from `typebox`. |
89
+ | `execute` | Yes | `(toolCallId, params, signal, onUpdate, ctx)`. |
90
+ | `promptSnippet` | No | One-line entry in Available tools. Custom tools are omitted there when absent. |
91
+ | `promptGuidelines` | No | Raw bullets appended to the global Guidelines section. Each bullet must name the tool. |
92
+ | `prepareArguments` | No | Compatibility shim before schema validation. |
93
+ | `executionMode` | No | Use `"sequential"` for shared-state tools that cannot run concurrently. |
94
+ | `renderCall` / `renderResult` | No | Custom TUI component renderers. |
95
+ | `renderShell` | No | Use `"self"` only when the default boxed shell gets in the way. |
94
96
 
95
97
  ## Execute Signature
96
98
 
97
99
  ```typescript
98
- execute(
99
- toolCallId: string,
100
- params: Static<TParams>, // Typed from the parameters schema
101
- signal: AbortSignal | undefined,
102
- onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
103
- ctx: ExtensionContext,
104
- ): Promise<AgentToolResult<TDetails>>
100
+ async execute(toolCallId, params, signal, onUpdate, ctx): Promise<AgentToolResult<TDetails>>
105
101
  ```
106
102
 
107
- **Parameter order matters.** The signal comes before onUpdate.
103
+ Parameter order matters. `signal` comes before `onUpdate`.
108
104
 
109
- Always use optional chaining when calling `onUpdate`:
105
+ Use optional chaining for updates:
110
106
 
111
107
  ```typescript
112
- onUpdate?.({ output: "partial result", details: { progress: 50 } });
108
+ onUpdate?.({
109
+ content: [{ type: "text", text: "Working..." }],
110
+ details: { progress: 50 },
111
+ });
113
112
  ```
114
113
 
115
- The `onUpdate` parameter can be `undefined`. Calling it without optional chaining will throw.
116
-
117
- ## Tool Overrides and Delegation
118
-
119
- If you override a built-in tool or wrap another tool, audit any delegated `tool.execute(...)` calls during upgrades. These forwarders often pass through `signal`, `onUpdate`, or `ctx` and can silently break when the execute signature changes. Always recheck the delegate call parameter order and include optional parameters that the target tool expects.
120
-
121
- Prompt metadata is not inherited automatically when you override a built-in tool. If the original tool had `promptSnippet` or `promptGuidelines` and you still want that system prompt behavior, define those fields explicitly on the override.
122
-
123
- ## Return Value
114
+ Forward `signal` to all abort-aware work:
124
115
 
125
116
  ```typescript
126
- return {
127
- content: (TextContent | ImageContent)[], // Content blocks sent to the LLM
128
- details?: TDetails, // Arbitrary data available in the renderer
129
- };
117
+ const response = await fetch(url, { signal });
118
+ const result = await pi.exec("git", ["status", "--porcelain"], { signal, cwd: ctx.cwd });
130
119
  ```
131
120
 
132
- - `content` is what the LLM sees. Each block is `{ type: "text", text: "..." }` or an image. Keep it structured and concise.
133
- - `details` is what the renderer sees. Put rich data here for custom display.
121
+ Do not use Node `child_process` for normal commands. Use `pi.exec(command, args, options)` so Pi handles CWD, cancellation, output capture, and lifecycle integration.
134
122
 
135
- Common pattern:
123
+ ## Return Value and Errors
136
124
 
137
125
  ```typescript
138
126
  return {
139
- content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
140
- details: { results },
127
+ content: [{ type: "text", text: "Text sent to the LLM" }],
128
+ details: { rich: "data for rendering and state" },
129
+ terminate: true, // optional final/structured-output tools only
141
130
  };
142
131
  ```
143
132
 
144
- ## Error Handling
145
-
146
- To report a tool call failure, **throw an error**. The framework catches it and produces a result with `isError: true` on the context.
147
-
148
- ```typescript
149
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
150
- const result = await fetchData(params.query);
151
- if (!result) {
152
- throw new Error("No results found. Try a different query.");
153
- }
154
- return {
155
- content: [{ type: "text", text: JSON.stringify(result) }],
156
- details: { result },
157
- };
158
- },
159
- ```
160
-
161
- The framework's `createErrorToolResult` sets `details: {}` (empty object, not `undefined`) and puts the error message in `content`:
162
-
163
- ```typescript
164
- // Framework produces when tool throws:
165
- {
166
- content: [{ type: "text", text: errorMessage }],
167
- details: {}
168
- }
169
- // And sets context.isError = true in renderResult
170
- ```
171
-
172
- ### Error rendering in `renderResult`
173
-
174
- The framework passes `isError` on the 4th `context` parameter to `renderResult`, but `ToolRenderContext` is not currently exported from the public API. Two practical approaches:
175
-
176
- **Approach 1: Check for missing expected fields in `details`** (recommended for extensions)
177
-
178
- When a tool throws, the framework sets `details: {}` (empty object). Check for missing expected fields:
179
-
180
- ```typescript
181
- renderResult(
182
- result: AgentToolResult<MyToolDetails>,
183
- options: ToolRenderResultOptions,
184
- theme: Theme,
185
- ) {
186
- const { details } = result;
187
-
188
- // details is {} when tool threw — expected fields are missing
189
- if (!details?.results) {
190
- const textBlock = result.content.find((c) => c.type === "text");
191
- const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Operation failed";
192
- return new Text(theme.fg("error", errorMsg), 0, 0);
193
- }
194
- // ... normal rendering
195
- }
196
- ```
197
-
198
- **Approach 2: Use the 4th context parameter** (used by native tools)
133
+ - `content` is sent to the LLM. Keep it concise and useful.
134
+ - `details` is persisted in the session and used by renderers. Put state snapshots here when the tool mutates extension state.
135
+ - `terminate: true` skips the automatic follow-up LLM call only when every finalized tool in the current batch also terminates.
136
+ - Throw an error to mark a tool result as failed. Returning an object never sets `isError`.
199
137
 
200
- Define a minimal interface locally:
138
+ When a tool throws, Pi creates an error result with `details: {}` and the error message in `content`. Renderers should check for missing expected fields or use the 4th render context's `isError` field.
201
139
 
202
- ```typescript
203
- interface RenderContext { isError: boolean }
204
-
205
- renderResult(
206
- result: AgentToolResult<MyToolDetails>,
207
- options: ToolRenderResultOptions,
208
- theme: Theme,
209
- context: RenderContext,
210
- ) {
211
- if (context.isError) {
212
- const textBlock = result.content.find((c) => c.type === "text");
213
- const errorMsg = textBlock?.type === "text" ? textBlock.text : "Operation failed";
214
- return new Text(theme.fg("error", errorMsg), 0, 0);
215
- }
216
- // ... normal rendering
217
- }
218
- ```
140
+ ## Parameters
219
141
 
220
- Both approaches work. Approach 1 is more common in published extensions. Approach 2 is what native tools use.
221
-
222
- ## Parameters Schema
223
-
224
- Use TypeBox (`Type.*`) for parameter schemas. The LLM sees the schema to know what arguments to provide.
142
+ Use TypeBox 1.x from `typebox`. Always add useful descriptions.
225
143
 
226
144
  ```typescript
227
- import { Type } from "@sinclair/typebox";
228
-
229
- // Required string
230
- Type.String({ description: "File path to read" })
231
-
232
- // Optional with default
233
- Type.Optional(Type.Number({ description: "Max results", default: 10 }))
234
-
235
- // Enum (string union)
236
- Type.StringEnum(["created", "updated", "relevance"], { description: "Sort order" })
237
-
238
- // Boolean
239
- Type.Boolean({ description: "Include hidden files" })
240
-
241
- // Nested object
242
- Type.Object({
243
- name: Type.String(),
244
- value: Type.String(),
245
- })
246
-
247
- // Array
248
- Type.Array(Type.String(), { description: "List of tags" })
145
+ const parameters = Type.Object({
146
+ path: Type.String({ description: "File path" }),
147
+ includeHidden: Type.Optional(Type.Boolean({ description: "Include hidden files", default: false })),
148
+ sort: Type.Optional(StringEnum(["created", "updated", "relevance"] as const, {
149
+ description: "Sort order",
150
+ })),
151
+ tags: Type.Optional(Type.Array(Type.String({ description: "Tag" }))),
152
+ });
249
153
  ```
250
154
 
251
- Always provide `description` on parameters. The LLM uses these to understand what to pass.
155
+ Use `StringEnum` from `pi-ai` for model-facing string enums. Avoid `Type.Union([Type.Literal(...)])`; it is not compatible with all providers.
252
156
 
253
157
  ## Prompt Metadata
254
158
 
255
- `promptSnippet` and `promptGuidelines` affect different parts of the default system prompt:
256
-
257
- - `promptSnippet` adds a one-line entry to `Available tools`.
258
- - `promptGuidelines` appends raw bullets to the global `Guidelines` section.
159
+ `promptSnippet` and `promptGuidelines` affect different system prompt sections:
259
160
 
260
- Important implications:
161
+ - `promptSnippet` adds one line under Available tools.
162
+ - `promptGuidelines` appends raw bullets to the global Guidelines section when the tool is active.
261
163
 
262
- - `promptGuidelines` bullets are not wrapped with the tool name.
263
- - Write bullets so they still make sense when read out of context.
264
- - Prefer explicit tool names over phrases like `this tool`.
164
+ Because `promptGuidelines` are not grouped under the tool, each bullet must name the exact tool.
265
165
 
266
166
  Good:
267
167
 
268
168
  ```typescript
269
169
  promptGuidelines: [
270
- "Use my_tool to search project docs before broader web research.",
271
- "Prefer specific queries when calling my_tool.",
170
+ "Use repo_tree to inspect repository structure before reading individual files.",
171
+ "Start repo_tree at the root path, then drill into directories as needed.",
272
172
  ]
273
173
  ```
274
174
 
@@ -276,16 +176,16 @@ Weak:
276
176
 
277
177
  ```typescript
278
178
  promptGuidelines: [
279
- "Use this tool for docs.",
280
- "Prefer specific queries.",
179
+ "Use this tool before reading files.",
180
+ "Start at the root path.",
281
181
  ]
282
182
  ```
283
183
 
284
- Use `promptGuidelines` for short, tool-local rules. If the guidance needs cross-tool sequencing, comparisons against several tools, or dynamic config context, use a `before_agent_start` hook instead.
184
+ Use per-tool metadata for simple guidance. Use a `before_agent_start` hook for dynamic config, cross-tool workflows, or complex orchestration.
285
185
 
286
- ## Argument Compatibility and Path Handling
186
+ ## Argument Compatibility
287
187
 
288
- Use `prepareArguments(args)` when you need a compatibility shim before schema validation, for example to support an old parameter shape during a migration.
188
+ Use `prepareArguments(args)` for backward-compatible migrations of old session tool-call shapes. It runs before validation. Keep the public schema strict.
289
189
 
290
190
  ```typescript
291
191
  prepareArguments(args) {
@@ -298,21 +198,18 @@ prepareArguments(args) {
298
198
  }
299
199
  ```
300
200
 
301
- If your custom tool accepts filesystem paths, normalize a leading `@` before resolving the path. Some models include `@` in path arguments, and the built-in file tools already strip it.
201
+ ## Paths and File Mutation
202
+
203
+ If a tool accepts file paths, strip a leading `@` before resolving. Built-in tools do this because models sometimes include `@` from path mentions.
302
204
 
303
205
  ```typescript
304
206
  const normalizedPath = params.path.startsWith("@") ? params.path.slice(1) : params.path;
207
+ const absolutePath = resolve(ctx.cwd, normalizedPath);
305
208
  ```
306
209
 
307
- ## File-Mutating Tools and Concurrency
308
-
309
- Tool calls can run in parallel. If your custom tool mutates files, use `withFileMutationQueue()` so it participates in the same per-file queue as built-in `edit` and `write`.
210
+ If a custom tool mutates files, wrap the whole read-modify-write window in `withFileMutationQueue()` so sibling parallel tool calls cannot overwrite each other.
310
211
 
311
212
  ```typescript
312
- import { withFileMutationQueue } from "@mariozechner/pi-coding-agent";
313
- import { mkdir, readFile, writeFile } from "node:fs/promises";
314
- import { dirname, resolve } from "node:path";
315
-
316
213
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
317
214
  const normalizedPath = params.path.startsWith("@") ? params.path.slice(1) : params.path;
318
215
  const absolutePath = resolve(ctx.cwd, normalizedPath);
@@ -322,120 +219,94 @@ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
322
219
  const current = await readFile(absolutePath, "utf8");
323
220
  const next = current.replace(params.oldText, params.newText);
324
221
  await writeFile(absolutePath, next, "utf8");
325
-
326
222
  return {
327
223
  content: [{ type: "text", text: `Updated ${normalizedPath}` }],
328
- details: {},
224
+ details: { path: normalizedPath },
329
225
  };
330
226
  });
331
227
  }
332
228
  ```
333
229
 
334
- Queue the whole read-modify-write window, not just the final write.
230
+ ## Concurrency
335
231
 
336
- ## Streaming Updates
337
-
338
- Use `onUpdate` to stream partial results while the tool executes. This gives the user feedback during long operations.
232
+ Pi runs sibling tool calls in parallel by default. Add `executionMode: "sequential"` when this tool must serialize with sibling tool calls, for example when it mutates shared in-memory state, drives a cursor/game, or depends on strict call order.
339
233
 
340
234
  ```typescript
341
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
342
- for (const chunk of chunks) {
343
- const partial = processChunk(chunk);
344
- onUpdate?.({
345
- content: [{ type: "text", text: partial }],
346
- details: { progress: chunk.index / chunks.length },
347
- });
348
- }
349
- return {
350
- content: [{ type: "text", text: finalResult }],
351
- details: { complete: true },
352
- };
353
- },
235
+ const statefulTool = defineTool({
236
+ name: "game_move",
237
+ label: "Game Move",
238
+ description: "Apply a move to the current game state.",
239
+ executionMode: "sequential",
240
+ parameters,
241
+ async execute(_toolCallId, params) {
242
+ // Mutates shared in-memory state safely.
243
+ },
244
+ });
354
245
  ```
355
246
 
356
- ## Custom Rendering
247
+ Prefer making core operations concurrency-safe. Use sequential mode only when call order is inherently meaningful.
357
248
 
358
- Override how a tool's invocation and result appear in the TUI.
249
+ ## Rendering
359
250
 
360
- ### `renderCall` Signature
251
+ Custom renderers return TUI `Component` objects.
361
252
 
362
- ```typescript
363
- renderCall(args: MyToolParams, theme: Theme): Component
364
- ```
365
-
366
- A 3rd `context` param is available from the framework but rarely needed in `renderCall`.
253
+ ### `renderCall`
367
254
 
368
- Use `ToolCallHeader` from `@aliou/pi-utils-ui`:
255
+ Use `ToolCallHeader` for a stable, scannable call line.
369
256
 
370
257
  ```typescript
371
- renderCall(args: MyToolParams, theme: Theme) {
258
+ renderCall(args, theme) {
372
259
  return new ToolCallHeader(
373
260
  {
374
- toolName: "My Tool",
375
- action: "search", // Optional: for multi-action tools
376
- mainArg: `"${args.query}"`, // Primary thing user scans for
377
- optionArgs: [`limit=${args.limit ?? 10}`], // Compact key-value pairs
378
- longArgs: [], // Long text goes here
379
- showColon: true, // Whether to show colon after tool name
261
+ toolName: "Repo Tree",
262
+ action: args.action,
263
+ mainArg: `${args.owner}/${args.repo}`,
264
+ optionArgs: args.path ? [{ label: "path", value: args.path }] : [],
265
+ longArgs: args.instructions ? [{ label: "instructions", value: args.instructions }] : [],
380
266
  },
381
267
  theme,
382
268
  );
383
269
  }
384
270
  ```
385
271
 
386
- ### `renderResult` Signature
272
+ Header extraction order:
387
273
 
388
- ```typescript
389
- renderResult(
390
- result: AgentToolResult<TDetails>,
391
- options: ToolRenderResultOptions,
392
- theme: Theme,
393
- ): Component
394
- ```
274
+ 1. Action for multi-action tools.
275
+ 2. One main argument users scan for first.
276
+ 3. Short option args.
277
+ 4. Long args on follow-up lines.
278
+
279
+ ### `renderResult`
395
280
 
396
- - `options` has `expanded` and `isPartial`
397
- - `result.details` is `{}` (empty object) when the tool threw an error
398
- - A 4th `context` param is available (see Error Handling above) but not required
281
+ Handle partial state first, then errors, then normal output.
399
282
 
400
283
  ```typescript
401
- renderResult(
402
- result: AgentToolResult<MyToolDetails>,
403
- options: ToolRenderResultOptions,
404
- theme: Theme,
405
- ) {
406
- // 1. Handle partial state first with stable message
284
+ renderResult(result, options, theme) {
407
285
  if (options.isPartial) {
408
- return new Text(theme.fg("muted", "MyTool: fetching..."), 0, 0);
286
+ return new Text(theme.fg("muted", "Repo Tree: fetching..."), 0, 0);
409
287
  }
410
288
 
411
289
  const { details } = result;
412
-
413
- // 2. Handle errors: details is {} when tool threw
414
- if (!details?.results) {
290
+ if (!details?.entries) {
415
291
  const textBlock = result.content.find((c) => c.type === "text");
416
- const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Operation failed";
417
- return new Text(theme.fg("error", errorMsg), 0, 0);
292
+ const message = textBlock?.type === "text" ? textBlock.text : "Repo Tree failed";
293
+ return new Text(theme.fg("error", message), 0, 0);
418
294
  }
419
295
 
420
- // 3. Build fields for ToolBody (showCollapsed controls collapsed/expanded visibility)
421
- const items = details.results;
422
-
423
- const fields: Array<{ label: string; value: string; showCollapsed?: boolean } | Text> = [
424
- { label: "Results", value: `${items.length} items`, showCollapsed: true },
296
+ const fields = [
297
+ { label: "Location", value: `${details.owner}/${details.repo}`, showCollapsed: true },
298
+ { label: "Entries", value: `${details.entries.length}`, showCollapsed: true },
425
299
  ];
426
300
 
427
- // 4. Build conditional footer
428
- const footerItems: Array<{ label: string; value: string }> = [];
429
- if (items.length > 0) {
430
- footerItems.push({ label: "count", value: `${items.length}` });
301
+ const footerItems = [];
302
+ if (!options.expanded && details.entries.length > 5) {
303
+ footerItems.push({ value: keyHint("app.tools.expand", "to expand") });
431
304
  }
432
305
 
433
306
  return new ToolBody(
434
307
  {
435
308
  fields,
436
- footer: footerItems.length > 0
437
- ? new ToolFooter(theme, { items: footerItems, separator: " | " })
438
- : undefined,
309
+ footer: footerItems.length > 0 ? new ToolFooter(theme, { items: footerItems }) : undefined,
439
310
  includeSpacerBeforeFooter: fields.length > 0,
440
311
  },
441
312
  options,
@@ -444,590 +315,115 @@ renderResult(
444
315
  }
445
316
  ```
446
317
 
447
- ### `renderCall` Design Guide
448
-
449
- The `process` extension is a good baseline. Its call renderer is deterministic and keeps headers readable.
450
-
451
- Use this extraction order when building header parts:
452
-
453
- 1. **Action first**: always show action for multi-action tools (`start`, `list`, `kill`, ...).
454
- 2. **Pick one main arg**: choose the single value the user scans for first (name, id, or short command).
455
- 3. **Promote short fields to options**: compact values become option args (`end=true`, `limit=10`).
456
- 4. **Demote long fields to long args**: commands/prompts/instructions move to labeled follow-up lines.
457
- 5. **Keep it stable**: same inputs should produce same ordering and formatting.
458
-
459
- Implementation pattern:
460
- - Build `mainArg`, `optionArgs`, `longArgs` first, then pass to `ToolCallHeader`.
461
- - Quote user-provided names (`"backend"`) when that improves visual parsing.
462
- - Cap inline length (e.g. 60-80 chars), then spill to `longArgs`.
463
-
464
- ### `renderResult` Guidelines
465
-
466
- - Handle `isPartial` first with a **stable, tool-scoped message** like `"MyTool: fetching..."`. Do NOT echo streaming content.
467
- - Handle errors by checking for missing expected fields in `details` (framework sets `details: {}` on throw). Alternatively use the 4th `context` param with `context.isError`.
468
- - Use `ToolBody` with `showCollapsed: true` fields for collapsed/expanded filtering.
469
- - Use `ToolFooter` for stats/metadata. Omit when empty: `footer: items.length > 0 ? new ToolFooter(...) : undefined`.
470
- - Use `includeSpacerBeforeFooter: fields.length > 0` to avoid blank line when body is empty.
471
- - Remove redundant success footer items — don't show `status: ok` when success is obvious.
472
- - Use `Container` + `addChild()` for multi-element results instead of string concatenation.
473
- - Use `Markdown` component for rich markdown content:
474
- ```typescript
475
- new Markdown(text, 0, 0, getMarkdownTheme(), { color: (t) => theme.fg("toolOutput", t) })
476
- ```
477
- - Use `keyHint("app.tools.expand", "to expand")` for expand hints.
478
- - Collapsed result should show **actionable preview** (last N lines, first N items with status), not just a status badge.
479
- - Humanize error messages with names first: `"Could not get X for "name" (id)"`.
480
-
481
- ## Tool UI Rendering Guidelines
482
-
483
- When customizing tool rendering, keep call/result UI predictable and scannable.
484
-
485
- ### `renderCall` format
486
-
487
- Use this line model:
488
-
489
- - First line: `[Tool Name]: [Action] [Main arg] [Option args]`
490
- - Additional lines: long args only
491
-
492
318
  Guidelines:
493
- - Tool name should be a human display label (`label`), not a raw internal identifier (`name`).
494
- - Show `action` only when it adds meaning (multi-action tools like process managers).
495
- - Main arg should be the primary thing user cares about (query, session id, target id/name).
496
- - Option args should be compact key-value pairs (`limit=10`, `cwd=/path`).
497
- - Long text (prompt/task/question/context/instructions) goes to additional lines.
498
- - Prefer wrapping to preserve full meaning over aggressive truncation.
499
- - For tools without actions, omit colon suffix after tool name if that reads better in your UI system.
500
-
501
- ### `renderResult` layout
502
-
503
- - Handle `isPartial` first. Return a short stable loading state with tool-scoped message.
504
- - Keep the first non-loading line as a status summary (`Found N results`, `Updated 3 files`, `Failed: reason`).
505
- - Use `expanded` to switch between compact and full output. Compact should show the top few items plus an omission hint.
506
- - Keep body content focused on state + key output; avoid dumping raw JSON unless it is the actual output.
507
- - If you render a footer (stats, backend, counts), use `includeSpacerBeforeFooter` to control blank line.
508
- - Keep footer concise and stable across states.
509
- - Return `undefined` when custom rendering adds no value; fallback rendering is better than noisy UI.
510
-
511
- ## Tool Call + UI Consistency Contract
512
-
513
- Use this contract to keep tool UX consistent across extensions:
514
-
515
- 1. **Call line is for scanability**: `renderCall` first line follows `[Tool Name]: [Action] [Main arg] [Option args]`.
516
- 2. **Result detects errors** by checking for missing expected fields in `details` (framework sets `details: {}` on throw).
517
- 3. **Long text moves down**: prompts, instructions, and context go to follow-up lines, not the call header.
518
- 4. **Partial updates use a fixed tool-scoped string**, not echoed streaming content.
519
- 5. **Expanded controls density**: compact view shows summary + subset; expanded view shows full detail.
520
- 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`).
521
-
522
- Related references:
523
- - `references/modes.md` for `custom()` fallback behavior and RPC/Print handling.
524
- - `references/components.md` for interactive component authoring.
525
- - `references/messages.md` for persistent display via `sendMessage` + `registerMessageRenderer`.
526
-
527
- ## Naming Conventions
528
319
 
529
- For extensions wrapping a third-party API, prefix tool names with the API name to avoid conflicts:
530
-
531
- ```
532
- linkup_web_search
533
- linkup_web_fetch
534
- synthetic_web_search
535
- ```
536
-
537
- For internal/custom tools, no prefix is needed:
538
-
539
- ```
540
- get_current_time
541
- processes
542
- ```
543
-
544
- Use snake_case for all tool names.
545
-
546
- ## Abort Signal
547
-
548
- The `signal` parameter lets you cancel long-running operations when the user interrupts (e.g. pressing Escape). If the tool does not forward the signal, the underlying operation keeps running even after the user cancels, wasting resources and API credits.
549
-
550
- ```typescript
551
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
552
- const response = await fetch(url, { signal });
553
- // If the user cancels, fetch throws an AbortError
554
- return { content: [{ type: "text", text: await response.text() }], details: {} };
555
- },
556
- ```
557
-
558
- Pass `signal` to every async operation that supports it: `fetch()` calls, `pi.exec()` calls, SDK clients, etc.
559
-
560
- When wrapping an API client, thread the signal through the entire call chain. The client methods should accept an optional `signal` and forward it to the underlying `fetch()`:
561
-
562
- ```typescript
563
- // In the tool:
564
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
565
- const result = await client.search({ query: params.query, signal });
566
- // ...
567
- }
568
-
569
- // In the client:
570
- async search(params: { query: string; signal?: AbortSignal }) {
571
- return this.request("/search", { method: "POST", body: ... }, params.signal);
572
- }
573
-
574
- private async request<T>(endpoint: string, options: RequestInit = {}, signal?: AbortSignal) {
575
- return fetch(`${BASE_URL}${endpoint}`, { ...options, signal, headers: { ... } });
576
- }
577
- ```
578
-
579
- Do not prefix signal with underscore (`_signal`) unless the tool genuinely cannot use it. A dangling `_signal` is a sign of a missing cancellation path.
320
+ - Partial output uses a fixed tool-scoped message; do not echo streaming content.
321
+ - Collapsed view should show useful preview, not just `ok`.
322
+ - Expanded view can add full details.
323
+ - Omit empty footers.
324
+ - Use `Markdown` with `getMarkdownTheme()` for rich markdown.
325
+ - Use `context.lastComponent` only when reusing a component instance buys real performance.
326
+ - Set `renderShell: "self"` only when the default boxed shell prevents the desired layout.
580
327
 
581
328
  ## Output Truncation
582
329
 
583
- For tools that may return large outputs, use `truncateHead()` which returns a `TruncationResult`:
330
+ Tools must avoid overwhelming model context. Use `truncateHead` for content where the beginning matters and `truncateTail` for logs or command output where the end matters.
584
331
 
585
332
  ```typescript
586
- import { truncateHead, formatSize } from "@mariozechner/pi-coding-agent";
587
- import { createWriteStream } from "fs";
588
- import { tmpdir } from "os";
589
- import { join } from "path";
590
-
591
333
  interface FetchDetails {
592
- content: string;
593
334
  url: string;
335
+ content: string;
594
336
  truncated: boolean;
595
337
  totalLines: number;
596
- totalBytes: number;
597
338
  outputLines: number;
339
+ totalBytes: number;
598
340
  outputBytes: number;
599
341
  tempFile?: string;
600
342
  }
601
343
 
602
- async execute(
603
- _toolCallId: string,
604
- params: FetchParams,
605
- signal: AbortSignal | undefined,
606
- _onUpdate: undefined,
607
- _ctx: ExtensionContext,
608
- ): Promise<AgentToolResult<FetchDetails>> {
344
+ async execute(_toolCallId, params, signal): Promise<AgentToolResult<FetchDetails>> {
609
345
  const response = await fetch(params.url, { signal });
610
346
  const fullContent = await response.text();
611
-
612
- // truncateHead keeps the tail (most recent content)
613
- const truncated = truncateHead(fullContent, {
614
- maxBytes: 50000,
615
- maxLines: 2000,
616
- });
347
+ const truncated = truncateHead(fullContent, { maxBytes: 50_000, maxLines: 2_000 });
617
348
 
618
349
  const details: FetchDetails = {
619
- content: truncated.content,
620
350
  url: params.url,
351
+ content: truncated.content,
621
352
  truncated: truncated.truncated,
622
353
  totalLines: truncated.totalLines,
623
- totalBytes: truncated.totalBytes,
624
354
  outputLines: truncated.outputLines,
355
+ totalBytes: truncated.totalBytes,
625
356
  outputBytes: truncated.outputBytes,
626
357
  };
627
358
 
628
- // Write full content to temp file if truncated
359
+ let text = truncated.content;
629
360
  if (truncated.truncated) {
630
- const tempFile = join(tmpdir(), `web-fetch-${Date.now()}.txt`);
631
- const stream = createWriteStream(tempFile);
632
- stream.write(fullContent);
633
- stream.end();
634
- details.tempFile = tempFile;
361
+ details.tempFile = await writeTempFile(fullContent);
362
+ text += `\n\n[Output truncated: ${truncated.outputLines}/${truncated.totalLines} lines, `;
363
+ text += `${formatSize(truncated.outputBytes)}/${formatSize(truncated.totalBytes)}. `;
364
+ text += `Full output: ${details.tempFile}]`;
635
365
  }
636
366
 
637
- return {
638
- content: [{ type: "text", text: truncated.content }],
639
- details,
640
- };
367
+ return { content: [{ type: "text", text }], details };
641
368
  }
642
369
  ```
643
370
 
644
- ### Truncation Result Fields
645
-
646
- ```typescript
647
- interface TruncationResult {
648
- content: string; // The truncated content (or full if not truncated)
649
- truncated: boolean; // Whether truncation occurred
650
- totalLines: number; // Original total lines
651
- totalBytes: number; // Original total bytes
652
- outputLines: number; // Lines in output
653
- outputBytes: number; // Bytes in output
654
- }
655
- ```
656
-
657
- ### Rendering Truncated Output
658
-
659
- ```typescript
660
- renderResult(
661
- result: AgentToolResult<FetchDetails>,
662
- options: ToolRenderResultOptions,
663
- theme: Theme,
664
- ) {
665
- if (options.isPartial) {
666
- return new Text(theme.fg("muted", "WebFetch: fetching..."), 0, 0);
667
- }
668
-
669
- const { details } = result;
670
-
671
- if (!details?.content) {
672
- const textBlock = result.content.find((c) => c.type === "text");
673
- const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Fetch failed";
674
- return new Text(theme.fg("error", errorMsg), 0, 0);
675
- }
676
- const container = new Container();
677
-
678
- // Body with content preview
679
- const fields = [
680
- { label: "URL", value: details.url, showCollapsed: true },
681
- { label: "Size", value: formatSize(details.totalBytes), showCollapsed: true },
682
- ];
683
-
684
- container.addChild(new ToolBody(theme, { fields, expanded: options.expanded }));
685
-
686
- // Footer with truncation info
687
- const footerItems = [];
688
-
689
- if (details.truncated) {
690
- footerItems.push(
691
- { label: "showing", value: `${details.outputLines}/${details.totalLines} lines` },
692
- { label: "full output", value: details.tempFile ?? "temp file" },
693
- );
694
- } else {
695
- footerItems.push({ label: "lines", value: `${details.totalLines}` });
696
- }
697
-
698
- container.addChild(
699
- new ToolFooter(theme, {
700
- items: footerItems,
701
- separator: " | ",
702
- includeSpacerBeforeFooter: fields.length > 0,
703
- }),
704
- );
705
-
706
- return container;
707
- }
708
- ```
371
+ Always tell the LLM when output was truncated and where full output was saved.
709
372
 
710
373
  ## Multi-Action Tools
711
374
 
712
- For tools with multiple actions (e.g., `start`, `list`, `kill`), organize code for maintainability:
713
-
714
- ### File Structure
375
+ Use a directory when one tool has several actions.
715
376
 
716
377
  ```
717
- tools/
718
- my_tool/
719
- index.ts // Tool definition, registration, execute switch
720
- actions/
721
- start.ts // start action implementation
722
- list.ts // list action implementation
723
- kill.ts // kill action implementation
724
- render.ts // renderCall and renderResult (when complex)
725
- types.ts // Shared types and param schema
378
+ tools/my_tool/
379
+ index.ts
380
+ actions/start.ts
381
+ actions/list.ts
382
+ actions/kill.ts
383
+ render.ts
384
+ types.ts
726
385
  ```
727
386
 
728
- ### Action Pattern
387
+ Pattern:
729
388
 
730
- Each action takes an SDK client and typed params, returns typed results:
731
-
732
- ```typescript
733
- // actions/start.ts
734
- export interface StartParams {
735
- name: string;
736
- command: string;
737
- cwd?: string;
738
- }
739
-
740
- export interface StartResult {
741
- sessionId: string;
742
- pid: number;
743
- }
744
-
745
- export async function start(
746
- client: MyClient,
747
- params: StartParams,
748
- signal?: AbortSignal,
749
- ): Promise<StartResult> {
750
- return client.startSession({
751
- name: params.name,
752
- command: params.command,
753
- cwd: params.cwd,
754
- signal,
755
- });
756
- }
757
- ```
389
+ - `types.ts`: parameter schema, `Static` alias, details/result types.
390
+ - `actions/*`: Pi-free or mostly Pi-free action logic.
391
+ - `render.ts`: `renderCall`/`renderResult` when complex.
392
+ - `index.ts`: `defineTool`, execute switch, registration.
758
393
 
759
- ### Execute Delegation
394
+ Inside the `defineTool` execute callback, inference is enough. If action functions need typed inputs, use the alias from `types.ts`.
760
395
 
761
396
  ```typescript
762
- // index.ts
763
- async execute(
764
- toolCallId: string,
765
- params: MyToolParams,
766
- signal: AbortSignal | undefined,
767
- onUpdate: AgentToolUpdateCallback<MyToolDetails> | undefined,
768
- ctx: ExtensionContext,
769
- ): Promise<AgentToolResult<MyToolDetails>> {
397
+ async execute(_toolCallId, params, signal, onUpdate, ctx): Promise<AgentToolResult<MyToolDetails>> {
770
398
  switch (params.action) {
771
399
  case "start":
772
400
  return startAction(client, params, signal, onUpdate, ctx);
773
401
  case "list":
774
402
  return listAction(client, params, signal);
775
- case "kill":
776
- return killAction(client, params, signal);
777
403
  default:
778
404
  throw new Error(`Unknown action: ${params.action}`);
779
405
  }
780
406
  }
781
407
  ```
782
408
 
783
- ### Separate Render Module
784
-
785
- When rendering is complex, extract to `render.ts`:
786
-
787
- ```typescript
788
- // render.ts
789
- export function renderCall(args: MyToolParams, theme: Theme) {
790
- return new ToolCallHeader(
791
- {
792
- toolName: "MyTool",
793
- action: args.action,
794
- mainArg: getMainArg(args),
795
- optionArgs: getOptionArgs(args),
796
- longArgs: getLongArgs(args),
797
- },
798
- theme,
799
- );
800
- }
801
-
802
- export function renderResult(
803
- result: AgentToolResult<MyToolDetails>,
804
- options: ToolRenderResultOptions,
805
- theme: Theme,
806
- ) {
807
- // Complex rendering logic here
808
- }
809
- ```
810
-
811
- ```typescript
812
- // index.ts
813
- import { renderCall, renderResult } from "./render.js";
814
-
815
- const myTool = {
816
- // ...
817
- renderCall,
818
- renderResult,
819
- };
820
- ```
409
+ ## Naming
821
410
 
822
- **Examples to reference:**
823
- - `pi-processes`: Multi-action process management with complex rendering
824
- - `pi-linear`: Multi-action Linear API integration
411
+ - Use snake_case tool names.
412
+ - Prefix third-party integrations with the API/product name (`linkup_web_search`, `linear_issue`).
413
+ - Internal tools can use direct names (`get_current_time`, `process`).
414
+ - Display names in UI should use `label`, not raw tool IDs.
825
415
 
826
- ## Full Example
416
+ ## Checklist
827
417
 
828
- Here's a realistic tool demonstrating all the patterns:
829
-
830
- ```typescript
831
- import { ToolCallHeader, ToolBody, ToolFooter } from "@aliou/pi-utils-ui";
832
- import type {
833
- AgentToolResult,
834
- ExtensionAPI,
835
- ExtensionContext,
836
- Theme,
837
- ToolRenderResultOptions,
838
- } from "@mariozechner/pi-coding-agent";
839
- import { keyHint, formatSize } from "@mariozechner/pi-coding-agent";
840
- import { Container, Text } from "@mariozechner/pi-tui";
841
- import { type Static, Type } from "@sinclair/typebox";
842
-
843
- // Schema
844
- const parameters = Type.Object({
845
- owner: Type.String({ description: "Repository owner" }),
846
- repo: Type.String({ description: "Repository name" }),
847
- path: Type.Optional(Type.String({ description: "File or directory path", default: "" })),
848
- });
849
-
850
- // Typed aliases
851
- type RepoTreeParams = Static<typeof parameters>;
852
-
853
- interface RepoTreeDetails {
854
- owner: string;
855
- repo: string;
856
- path: string;
857
- entries: Array<{ name: string; type: "file" | "dir"; size?: number }>;
858
- truncated: boolean;
859
- }
860
-
861
- // Tool definition
862
- const repoTreeTool = {
863
- name: "repo_tree",
864
- label: "Repo Tree",
865
- description: "List files and directories in a GitHub repository.",
866
- promptSnippet: "Browse repository file structure",
867
- promptGuidelines: [
868
- "Use repo_tree to explore repository structure before reading files.",
869
- "Start repo_tree at the root path, then drill down into directories.",
870
- ],
871
- parameters,
872
-
873
- async execute(
874
- _toolCallId: string,
875
- params: RepoTreeParams,
876
- signal: AbortSignal | undefined,
877
- _onUpdate: undefined,
878
- _ctx: ExtensionContext,
879
- ): Promise<AgentToolResult<RepoTreeDetails>> {
880
- const response = await fetch(
881
- `https://api.github.com/repos/${params.owner}/${params.repo}/contents/${params.path ?? ""}`,
882
- { signal, headers: { Accept: "application/vnd.github.v3+json" } },
883
- );
884
-
885
- if (!response.ok) {
886
- if (response.status === 404) {
887
- throw new Error(
888
- `Could not find repository "${params.owner}/${params.repo}" or path "${params.path ?? ""}"`,
889
- );
890
- }
891
- throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
892
- }
893
-
894
- const data = await response.json();
895
-
896
- // GitHub returns a single object for files, array for directories
897
- const entries = Array.isArray(data) ? data : [data];
898
-
899
- const processed = entries.map((entry: any) => ({
900
- name: entry.name,
901
- type: entry.type === "dir" ? "dir" as const : "file" as const,
902
- size: entry.size,
903
- }));
904
-
905
- // Sort: directories first, then files, alphabetically within each
906
- processed.sort((a, b) => {
907
- if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
908
- return a.name.localeCompare(b.name);
909
- });
910
-
911
- const truncated = processed.length > 100;
912
-
913
- return {
914
- content: [
915
- {
916
- type: "text",
917
- text: JSON.stringify(truncated ? processed.slice(0, 100) : processed, null, 2),
918
- },
919
- ],
920
- details: {
921
- owner: params.owner,
922
- repo: params.repo,
923
- path: params.path ?? "",
924
- entries: processed,
925
- truncated,
926
- },
927
- };
928
- },
929
-
930
- renderCall(args: RepoTreeParams, theme: Theme) {
931
- return new ToolCallHeader(
932
- {
933
- toolName: "Repo Tree",
934
- mainArg: `${args.owner}/${args.repo}`,
935
- optionArgs: args.path ? [`path=${args.path}`] : [],
936
- longArgs: [],
937
- },
938
- theme,
939
- );
940
- },
941
-
942
- renderResult(
943
- result: AgentToolResult<RepoTreeDetails>,
944
- options: ToolRenderResultOptions,
945
- theme: Theme,
946
- ) {
947
- // 1. Stable partial message
948
- if (options.isPartial) {
949
- return new Text(theme.fg("muted", "RepoTree: fetching..."), 0, 0);
950
- }
951
-
952
- const { details } = result;
953
-
954
- // 2. Error handling: details is {} when tool threw
955
- if (!details?.entries) {
956
- const textBlock = result.content.find((c) => c.type === "text");
957
- const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Failed to list repository";
958
- return new Text(theme.fg("error", errorMsg), 0, 0);
959
- }
960
- const container = new Container();
961
- const entries = details?.entries ?? [];
962
- const dirs = entries.filter((e) => e.type === "dir");
963
- const files = entries.filter((e) => e.type === "file");
964
-
965
- // 3. Body fields with showCollapsed for collapsed/expanded control
966
- const fields: Array<{ label: string; value: string; showCollapsed: boolean }> = [
967
- {
968
- label: "Location",
969
- value: `${details.owner}/${details.repo}${details.path ? `/${details.path}` : ""}`,
970
- showCollapsed: true,
971
- },
972
- {
973
- label: "Contents",
974
- value: `${dirs.length} dirs, ${files.length} files`,
975
- showCollapsed: true,
976
- },
977
- ];
978
-
979
- container.addChild(new ToolBody(theme, { fields, expanded: options.expanded }));
980
-
981
- // 4. Entry list (in expanded view)
982
- if (options.expanded && entries.length > 0) {
983
- const entryLines = entries
984
- .slice(0, 50)
985
- .map((e) => {
986
- const icon = e.type === "dir" ? "📁" : "📄";
987
- const size = e.size !== undefined ? ` (${formatSize(e.size)})` : "";
988
- return `${icon} ${e.name}${size}`;
989
- })
990
- .join("\n");
991
-
992
- container.addChild(new Text(entryLines, 0, 0));
993
-
994
- if (entries.length > 50) {
995
- container.addChild(
996
- new Text(theme.fg("muted", `... and ${entries.length - 50} more`), 0, 0),
997
- );
998
- }
999
- }
1000
-
1001
- // 5. Conditional footer
1002
- const footerItems = [];
1003
-
1004
- if (details?.truncated) {
1005
- footerItems.push({ label: "showing", value: "first 100 entries" });
1006
- }
1007
-
1008
- if (entries.length > 0) {
1009
- footerItems.push({ label: "total", value: `${entries.length} items` });
1010
- }
1011
-
1012
- if (!options.expanded && entries.length > 5) {
1013
- footerItems.push({ label: "", value: keyHint("app.tools.expand", "to expand") });
1014
- }
1015
-
1016
- if (footerItems.length > 0) {
1017
- container.addChild(
1018
- new ToolFooter(theme, {
1019
- items: footerItems,
1020
- separator: " | ",
1021
- includeSpacerBeforeFooter: fields.length > 0,
1022
- }),
1023
- );
1024
- }
1025
-
1026
- return container;
1027
- },
1028
- };
1029
-
1030
- export default function (pi: ExtensionAPI) {
1031
- pi.registerTool(repoTreeTool);
1032
- }
1033
- ```
418
+ - [ ] Tool uses `defineTool({...})` with no explicit generic args.
419
+ - [ ] Params infer from `parameters`; helper APIs use `Static<typeof parameters>` alias.
420
+ - [ ] `label`, `description`, and useful parameter descriptions are present.
421
+ - [ ] `promptGuidelines` bullets name the exact tool.
422
+ - [ ] Execute signature is `(toolCallId, params, signal, onUpdate, ctx)`.
423
+ - [ ] `onUpdate` uses optional chaining.
424
+ - [ ] `signal` is forwarded to async operations.
425
+ - [ ] Errors are thrown, not returned as fake success payloads.
426
+ - [ ] Path tools strip leading `@`; mutating tools use `withFileMutationQueue()`.
427
+ - [ ] Shared-state tools use safe concurrency or `executionMode: "sequential"`.
428
+ - [ ] Large outputs are truncated with a full-output temp file.
429
+ - [ ] Renderers return components, handle partial first, handle errors, and omit empty footers.