@oh-my-pi/pi-coding-agent 3.20.0 → 3.21.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 (95) hide show
  1. package/CHANGELOG.md +78 -8
  2. package/docs/custom-tools.md +3 -3
  3. package/docs/extensions.md +226 -220
  4. package/docs/hooks.md +2 -2
  5. package/docs/sdk.md +3 -3
  6. package/examples/custom-tools/README.md +2 -2
  7. package/examples/custom-tools/subagent/index.ts +1 -1
  8. package/examples/extensions/README.md +76 -74
  9. package/examples/extensions/todo.ts +2 -5
  10. package/examples/hooks/custom-compaction.ts +1 -1
  11. package/examples/hooks/handoff.ts +1 -1
  12. package/examples/hooks/qna.ts +1 -1
  13. package/examples/sdk/02-custom-model.ts +1 -1
  14. package/examples/sdk/12-full-control.ts +1 -1
  15. package/examples/sdk/README.md +1 -1
  16. package/package.json +5 -5
  17. package/src/cli/file-processor.ts +1 -1
  18. package/src/cli/list-models.ts +1 -1
  19. package/src/core/agent-session.ts +13 -2
  20. package/src/core/auth-storage.ts +1 -1
  21. package/src/core/compaction/branch-summarization.ts +2 -2
  22. package/src/core/compaction/compaction.ts +2 -2
  23. package/src/core/compaction/utils.ts +1 -1
  24. package/src/core/custom-tools/types.ts +1 -1
  25. package/src/core/extensions/runner.ts +1 -1
  26. package/src/core/extensions/types.ts +1 -1
  27. package/src/core/extensions/wrapper.ts +1 -1
  28. package/src/core/hooks/runner.ts +2 -2
  29. package/src/core/hooks/types.ts +1 -1
  30. package/src/core/messages.ts +1 -1
  31. package/src/core/model-registry.ts +1 -1
  32. package/src/core/model-resolver.ts +1 -1
  33. package/src/core/sdk.ts +33 -4
  34. package/src/core/session-manager.ts +11 -22
  35. package/src/core/settings-manager.ts +66 -1
  36. package/src/core/slash-commands.ts +12 -5
  37. package/src/core/system-prompt.ts +27 -3
  38. package/src/core/title-generator.ts +2 -2
  39. package/src/core/tools/ask.ts +88 -1
  40. package/src/core/tools/bash-interceptor.ts +7 -0
  41. package/src/core/tools/bash.ts +106 -0
  42. package/src/core/tools/edit-diff.ts +73 -24
  43. package/src/core/tools/edit.ts +214 -20
  44. package/src/core/tools/find.ts +162 -1
  45. package/src/core/tools/gemini-image.ts +279 -56
  46. package/src/core/tools/git.ts +4 -0
  47. package/src/core/tools/grep.ts +191 -0
  48. package/src/core/tools/index.ts +3 -6
  49. package/src/core/tools/ls.ts +142 -2
  50. package/src/core/tools/lsp/render.ts +34 -14
  51. package/src/core/tools/notebook.ts +110 -0
  52. package/src/core/tools/output.ts +179 -7
  53. package/src/core/tools/read.ts +122 -9
  54. package/src/core/tools/render-utils.ts +241 -0
  55. package/src/core/tools/renderers.ts +40 -828
  56. package/src/core/tools/review.ts +26 -7
  57. package/src/core/tools/rulebook.ts +3 -1
  58. package/src/core/tools/task/index.ts +18 -3
  59. package/src/core/tools/task/render.ts +7 -2
  60. package/src/core/tools/task/types.ts +1 -1
  61. package/src/core/tools/truncate.ts +27 -1
  62. package/src/core/tools/web-fetch.ts +23 -15
  63. package/src/core/tools/web-search/index.ts +130 -45
  64. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  65. package/src/core/tools/web-search/providers/exa.ts +2 -1
  66. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  67. package/src/core/tools/web-search/render.ts +5 -0
  68. package/src/core/tools/web-search/types.ts +13 -0
  69. package/src/core/tools/write.ts +90 -0
  70. package/src/core/voice.ts +1 -1
  71. package/src/lib/worktree/constants.ts +6 -6
  72. package/src/main.ts +1 -1
  73. package/src/modes/interactive/components/assistant-message.ts +1 -1
  74. package/src/modes/interactive/components/custom-message.ts +1 -1
  75. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  76. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  77. package/src/modes/interactive/components/footer.ts +1 -1
  78. package/src/modes/interactive/components/hook-message.ts +1 -1
  79. package/src/modes/interactive/components/model-selector.ts +1 -1
  80. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  81. package/src/modes/interactive/components/settings-defs.ts +49 -0
  82. package/src/modes/interactive/components/status-line.ts +1 -1
  83. package/src/modes/interactive/components/tool-execution.ts +93 -538
  84. package/src/modes/interactive/interactive-mode.ts +19 -7
  85. package/src/modes/print-mode.ts +1 -1
  86. package/src/modes/rpc/rpc-client.ts +1 -1
  87. package/src/modes/rpc/rpc-types.ts +1 -1
  88. package/src/prompts/system-prompt.md +4 -0
  89. package/src/prompts/tools/gemini-image.md +5 -1
  90. package/src/prompts/tools/output.md +4 -0
  91. package/src/prompts/tools/web-fetch.md +1 -0
  92. package/src/prompts/tools/web-search.md +2 -0
  93. package/src/utils/image-convert.ts +8 -2
  94. package/src/utils/image-magick.ts +247 -0
  95. package/src/utils/image-resize.ts +53 -13
@@ -5,834 +5,46 @@
5
5
  */
6
6
 
7
7
  import type { Component } from "@oh-my-pi/pi-tui";
8
- import { Text } from "@oh-my-pi/pi-tui";
9
- import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
8
+ import type { Theme } from "../../modes/interactive/theme/theme";
10
9
  import type { RenderResultOptions } from "../custom-tools/types";
11
- import type { AskToolDetails } from "./ask";
12
- import type { FindToolDetails } from "./find";
13
- import type { GrepToolDetails } from "./grep";
14
- import type { LsToolDetails } from "./ls";
15
- import { renderCall as renderLspCall, renderResult as renderLspResult } from "./lsp/render";
16
- import type { LspToolDetails } from "./lsp/types";
17
- import type { NotebookToolDetails } from "./notebook";
18
- import type { OutputToolDetails } from "./output";
19
- import {
20
- formatBytes,
21
- formatCount,
22
- formatExpandHint,
23
- formatMoreItems,
24
- PREVIEW_LIMITS,
25
- TRUNCATE_LENGTHS,
26
- truncate,
27
- } from "./render-utils";
28
- import { renderCall as renderTaskCall, renderResult as renderTaskResult } from "./task/render";
29
- import type { TaskToolDetails } from "./task/types";
30
- import { renderWebFetchCall, renderWebFetchResult, type WebFetchToolDetails } from "./web-fetch";
31
- import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./web-search/render";
32
-
33
- // Tree drawing characters
34
-
35
- interface ToolRenderer<TArgs = any, TDetails = any> {
36
- renderCall(args: TArgs, theme: Theme): Component;
37
- renderResult(
38
- result: { content: Array<{ type: string; text?: string }>; details?: TDetails },
39
- options: RenderResultOptions,
10
+ import { askToolRenderer } from "./ask";
11
+ import { bashToolRenderer } from "./bash";
12
+ import { editToolRenderer } from "./edit";
13
+ import { findToolRenderer } from "./find";
14
+ import { grepToolRenderer } from "./grep";
15
+ import { lsToolRenderer } from "./ls";
16
+ import { lspToolRenderer } from "./lsp/render";
17
+ import { notebookToolRenderer } from "./notebook";
18
+ import { outputToolRenderer } from "./output";
19
+ import { readToolRenderer } from "./read";
20
+ import { taskToolRenderer } from "./task/render";
21
+ import { webFetchToolRenderer } from "./web-fetch";
22
+ import { webSearchToolRenderer } from "./web-search/render";
23
+ import { writeToolRenderer } from "./write";
24
+
25
+ type ToolRenderer = {
26
+ renderCall: (args: unknown, theme: Theme) => Component;
27
+ renderResult: (
28
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
29
+ options: RenderResultOptions & { renderContext?: Record<string, unknown> },
40
30
  theme: Theme,
41
- ): Component;
42
- }
43
-
44
- const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
45
- const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
46
-
47
- function formatMeta(meta: string[], theme: Theme): string {
48
- return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
49
- }
50
-
51
- function formatScope(scopePath: string | undefined, theme: Theme): string {
52
- return scopePath ? ` ${theme.fg("muted", `in ${scopePath}`)}` : "";
53
- }
54
-
55
- function formatTruncationSuffix(truncated: boolean, theme: Theme): string {
56
- return truncated ? theme.fg("warning", " (truncated)") : "";
57
- }
58
-
59
- function renderErrorMessage(_toolLabel: string, message: string, theme: Theme): Text {
60
- const clean = message.replace(/^Error:\s*/, "").trim();
61
- return new Text(
62
- `${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`,
63
- 0,
64
- 0,
65
- );
66
- }
67
-
68
- function renderEmptyMessage(_toolLabel: string, message: string, theme: Theme): Text {
69
- return new Text(`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`, 0, 0);
70
- }
71
-
72
- // ============================================================================
73
- // Grep Renderer
74
- // ============================================================================
75
-
76
- interface GrepArgs {
77
- pattern: string;
78
- path?: string;
79
- glob?: string;
80
- type?: string;
81
- ignoreCase?: boolean;
82
- caseSensitive?: boolean;
83
- literal?: boolean;
84
- multiline?: boolean;
85
- context?: number;
86
- limit?: number;
87
- outputMode?: string;
88
- }
89
-
90
- const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
91
- renderCall(args, theme) {
92
- const label = theme.fg("toolTitle", theme.bold("Grep"));
93
- let text = `${label} ${theme.fg("accent", args.pattern || "?")}`;
94
-
95
- const meta: string[] = [];
96
- if (args.path) meta.push(`in ${args.path}`);
97
- if (args.glob) meta.push(`glob:${args.glob}`);
98
- if (args.type) meta.push(`type:${args.type}`);
99
- if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(`mode:${args.outputMode}`);
100
- if (args.caseSensitive) {
101
- meta.push("case:sensitive");
102
- } else if (args.ignoreCase) {
103
- meta.push("case:insensitive");
104
- }
105
- if (args.literal) meta.push("literal");
106
- if (args.multiline) meta.push("multiline");
107
- if (args.context !== undefined) meta.push(`context:${args.context}`);
108
- if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
109
-
110
- text += formatMeta(meta, theme);
111
-
112
- return new Text(text, 0, 0);
113
- },
114
-
115
- renderResult(result, { expanded }, theme) {
116
- const label = "Grep";
117
- const details = result.details;
118
-
119
- if (details?.error) {
120
- return renderErrorMessage(label, details.error, theme);
121
- }
122
-
123
- const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined;
124
-
125
- if (!hasDetailedData) {
126
- const textContent = result.content?.find((c) => c.type === "text")?.text;
127
- if (!textContent || textContent === "No matches found") {
128
- return renderEmptyMessage(label, "No matches found", theme);
129
- }
130
-
131
- const lines = textContent.split("\n").filter((line) => line.trim() !== "");
132
- const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_TEXT_LIMIT);
133
- const displayLines = lines.slice(0, maxLines);
134
- const remaining = lines.length - maxLines;
135
- const hasMore = remaining > 0;
136
-
137
- const icon = theme.styledSymbol("status.success", "success");
138
- const summary = formatCount("item", lines.length);
139
- const expandHint = formatExpandHint(expanded, hasMore, theme);
140
- let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
141
-
142
- for (let i = 0; i < displayLines.length; i++) {
143
- const isLast = i === displayLines.length - 1 && remaining === 0;
144
- const branch = isLast ? theme.tree.last : theme.tree.branch;
145
- text += `\n ${theme.fg("dim", branch)} ${theme.fg("toolOutput", displayLines[i])}`;
146
- }
147
-
148
- if (remaining > 0) {
149
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
150
- "muted",
151
- formatMoreItems(remaining, "item", theme),
152
- )}`;
153
- }
154
-
155
- return new Text(text, 0, 0);
156
- }
157
-
158
- const matchCount = details?.matchCount ?? 0;
159
- const fileCount = details?.fileCount ?? 0;
160
- const mode = details?.mode ?? "files_with_matches";
161
- const truncated = details?.truncated ?? details?.truncation?.truncated ?? false;
162
- const files = details?.files ?? [];
163
-
164
- if (matchCount === 0) {
165
- return renderEmptyMessage(label, "No matches found", theme);
166
- }
167
-
168
- const icon = theme.styledSymbol("status.success", "success");
169
- const summaryParts =
170
- mode === "files_with_matches"
171
- ? [formatCount("file", fileCount)]
172
- : [formatCount("match", matchCount), formatCount("file", fileCount)];
173
- const summaryText = summaryParts.join(theme.sep.dot);
174
- const scopeLabel = formatScope(details?.scopePath, theme);
175
-
176
- const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length
177
- ? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count }))
178
- : files.map((path) => ({ path }));
179
- const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, COLLAPSED_LIST_LIMIT);
180
- const hasMoreFiles = fileEntries.length > maxFiles;
181
- const expandHint = formatExpandHint(expanded, hasMoreFiles, theme);
182
-
183
- let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(
184
- truncated,
185
- theme,
186
- )}${scopeLabel}${expandHint}`;
187
-
188
- const truncationReasons: string[] = [];
189
- if (details?.matchLimitReached) {
190
- truncationReasons.push(`limit ${details.matchLimitReached} matches`);
191
- }
192
- if (details?.headLimitReached) {
193
- truncationReasons.push(`head limit ${details.headLimitReached}`);
194
- }
195
- if (details?.truncation?.truncated) {
196
- truncationReasons.push("size limit");
197
- }
198
- if (details?.linesTruncated) {
199
- truncationReasons.push("line length");
200
- }
201
-
202
- const hasTruncation = truncationReasons.length > 0;
203
-
204
- if (fileEntries.length > 0) {
205
- for (let i = 0; i < maxFiles; i++) {
206
- const entry = fileEntries[i];
207
- const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation;
208
- const branch = isLast ? theme.tree.last : theme.tree.branch;
209
- const isDir = entry.path.endsWith("/");
210
- const entryPath = isDir ? entry.path.slice(0, -1) : entry.path;
211
- const lang = isDir ? undefined : getLanguageFromPath(entryPath);
212
- const entryIcon = isDir
213
- ? theme.fg("accent", theme.icon.folder)
214
- : theme.fg("muted", theme.getLangIcon(lang));
215
- const countLabel =
216
- entry.count !== undefined
217
- ? ` ${theme.fg("dim", `(${entry.count} match${entry.count !== 1 ? "es" : ""})`)}`
218
- : "";
219
- text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry.path)}${countLabel}`;
220
- }
221
-
222
- if (hasMoreFiles) {
223
- const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
224
- text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg(
225
- "muted",
226
- formatMoreItems(fileEntries.length - maxFiles, "file", theme),
227
- )}`;
228
- }
229
- }
230
-
231
- if (hasTruncation) {
232
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
233
- "warning",
234
- `truncated: ${truncationReasons.join(", ")}`,
235
- )}`;
236
- }
237
-
238
- return new Text(text, 0, 0);
239
- },
240
- };
241
-
242
- // ============================================================================
243
- // Find Renderer
244
- // ============================================================================
245
-
246
- interface FindArgs {
247
- pattern: string;
248
- path?: string;
249
- type?: string;
250
- hidden?: boolean;
251
- sortByMtime?: boolean;
252
- limit?: number;
253
- }
254
-
255
- const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
256
- renderCall(args, theme) {
257
- const label = theme.fg("toolTitle", theme.bold("Find"));
258
- let text = `${label} ${theme.fg("accent", args.pattern || "*")}`;
259
-
260
- const meta: string[] = [];
261
- if (args.path) meta.push(`in ${args.path}`);
262
- if (args.type && args.type !== "all") meta.push(`type:${args.type}`);
263
- if (args.hidden) meta.push("hidden");
264
- if (args.sortByMtime) meta.push("sort:mtime");
265
- if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
266
-
267
- text += formatMeta(meta, theme);
268
-
269
- return new Text(text, 0, 0);
270
- },
271
-
272
- renderResult(result, { expanded }, theme) {
273
- const label = "Find";
274
- const details = result.details;
275
-
276
- if (details?.error) {
277
- return renderErrorMessage(label, details.error, theme);
278
- }
279
-
280
- const hasDetailedData = details?.fileCount !== undefined;
281
- const textContent = result.content?.find((c) => c.type === "text")?.text;
282
-
283
- if (!hasDetailedData) {
284
- if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") {
285
- return renderEmptyMessage(label, "No files found", theme);
286
- }
287
-
288
- const lines = textContent.split("\n").filter((l) => l.trim());
289
- const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_LIST_LIMIT);
290
- const displayLines = lines.slice(0, maxLines);
291
- const remaining = lines.length - maxLines;
292
- const hasMore = remaining > 0;
293
-
294
- const icon = theme.styledSymbol("status.success", "success");
295
- const summary = formatCount("file", lines.length);
296
- const expandHint = formatExpandHint(expanded, hasMore, theme);
297
- let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
298
-
299
- for (let i = 0; i < displayLines.length; i++) {
300
- const isLast = i === displayLines.length - 1 && remaining === 0;
301
- const branch = isLast ? theme.tree.last : theme.tree.branch;
302
- text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", displayLines[i])}`;
303
- }
304
- if (remaining > 0) {
305
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
306
- "muted",
307
- formatMoreItems(remaining, "file", theme),
308
- )}`;
309
- }
310
- return new Text(text, 0, 0);
311
- }
312
-
313
- const fileCount = details?.fileCount ?? 0;
314
- const truncated = details?.truncated ?? details?.truncation?.truncated ?? false;
315
- const files = details?.files ?? [];
316
-
317
- if (fileCount === 0) {
318
- return renderEmptyMessage(label, "No files found", theme);
319
- }
320
-
321
- const icon = theme.styledSymbol("status.success", "success");
322
- const summaryText = formatCount("file", fileCount);
323
- const scopeLabel = formatScope(details?.scopePath, theme);
324
- const maxFiles = expanded ? files.length : Math.min(files.length, COLLAPSED_LIST_LIMIT);
325
- const hasMoreFiles = files.length > maxFiles;
326
- const expandHint = formatExpandHint(expanded, hasMoreFiles, theme);
327
-
328
- let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(
329
- truncated,
330
- theme,
331
- )}${scopeLabel}${expandHint}`;
332
-
333
- const truncationReasons: string[] = [];
334
- if (details?.resultLimitReached) {
335
- truncationReasons.push(`limit ${details.resultLimitReached} results`);
336
- }
337
- if (details?.truncation?.truncated) {
338
- truncationReasons.push("size limit");
339
- }
340
-
341
- const hasTruncation = truncationReasons.length > 0;
342
-
343
- if (files.length > 0) {
344
- for (let i = 0; i < maxFiles; i++) {
345
- const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation;
346
- const branch = isLast ? theme.tree.last : theme.tree.branch;
347
- const entry = files[i];
348
- const isDir = entry.endsWith("/");
349
- const entryPath = isDir ? entry.slice(0, -1) : entry;
350
- const lang = isDir ? undefined : getLanguageFromPath(entryPath);
351
- const entryIcon = isDir
352
- ? theme.fg("accent", theme.icon.folder)
353
- : theme.fg("muted", theme.getLangIcon(lang));
354
- text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry)}`;
355
- }
356
-
357
- if (hasMoreFiles) {
358
- const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
359
- text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg(
360
- "muted",
361
- formatMoreItems(files.length - maxFiles, "file", theme),
362
- )}`;
363
- }
364
- }
365
-
366
- if (hasTruncation) {
367
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
368
- "warning",
369
- `truncated: ${truncationReasons.join(", ")}`,
370
- )}`;
371
- }
372
-
373
- return new Text(text, 0, 0);
374
- },
375
- };
376
-
377
- // ============================================================================
378
- // Notebook Renderer
379
- // ============================================================================
380
-
381
- interface NotebookArgs {
382
- action: string;
383
- notebookPath: string;
384
- cellNumber?: number;
385
- cellType?: string;
386
- content?: string;
387
- }
388
-
389
- function normalizeCellLines(lines: string[]): string[] {
390
- return lines.map((line) => (line.endsWith("\n") ? line.slice(0, -1) : line));
391
- }
392
-
393
- function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): string {
394
- const normalized = normalizeCellLines(lines);
395
- if (normalized.length === 0) {
396
- return `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", "(empty cell)")}`;
397
- }
398
-
399
- const maxLines = expanded ? normalized.length : Math.min(normalized.length, COLLAPSED_TEXT_LIMIT);
400
- let text = "";
401
-
402
- for (let i = 0; i < maxLines; i++) {
403
- const isLast = i === maxLines - 1 && (expanded || normalized.length <= maxLines);
404
- const branch = isLast ? theme.tree.last : theme.tree.branch;
405
- const line = normalized[i];
406
- text += `\n ${theme.fg("dim", branch)} ${theme.fg("toolOutput", line)}`;
407
- }
408
-
409
- const remaining = normalized.length - maxLines;
410
- if (remaining > 0) {
411
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line", theme))}`;
412
- }
413
-
414
- return text;
415
- }
416
-
417
- const notebookRenderer: ToolRenderer<NotebookArgs, NotebookToolDetails> = {
418
- renderCall(args, theme) {
419
- const label = theme.fg("toolTitle", theme.bold("Notebook"));
420
- let text = `${label} ${theme.fg("accent", args.action || "?")}`;
421
-
422
- const meta: string[] = [];
423
- meta.push(`in ${args.notebookPath || "?"}`);
424
- if (args.cellNumber !== undefined) meta.push(`cell:${args.cellNumber}`);
425
- if (args.cellType) meta.push(`type:${args.cellType}`);
426
-
427
- text += formatMeta(meta, theme);
428
-
429
- return new Text(text, 0, 0);
430
- },
431
-
432
- renderResult(result, { expanded }, theme) {
433
- const label = "Notebook";
434
- const details = result.details;
435
-
436
- const content = result.content?.[0];
437
- if (content?.type === "text" && content.text?.startsWith("Error:")) {
438
- return renderErrorMessage(label, content.text, theme);
439
- }
440
-
441
- const action = details?.action ?? "edit";
442
- const cellIndex = details?.cellIndex;
443
- const cellType = details?.cellType;
444
- const totalCells = details?.totalCells;
445
- const cellSource = details?.cellSource;
446
- const lineCount = cellSource?.length;
447
- const canExpand = cellSource !== undefined && cellSource.length > COLLAPSED_TEXT_LIMIT;
448
-
449
- const icon = theme.styledSymbol("status.success", "success");
450
- const actionLabel = action === "insert" ? "Inserted" : action === "delete" ? "Deleted" : "Edited";
451
- const cellLabel = cellType || "cell";
452
- const summaryParts = [`${actionLabel} ${cellLabel} at index ${cellIndex ?? "?"}`];
453
- if (lineCount !== undefined) summaryParts.push(formatCount("line", lineCount));
454
- if (totalCells !== undefined) summaryParts.push(`${totalCells} total`);
455
- const summaryText = summaryParts.join(theme.sep.dot);
456
-
457
- const expandHint = formatExpandHint(expanded, canExpand, theme);
458
- let text = `${icon} ${theme.fg("dim", summaryText)}${expandHint}`;
459
-
460
- if (cellSource) {
461
- text += renderCellPreview(cellSource, expanded, theme);
462
- }
463
-
464
- return new Text(text, 0, 0);
465
- },
466
- };
467
-
468
- // ============================================================================
469
- // Ask Renderer
470
- // ============================================================================
471
-
472
- interface AskArgs {
473
- question: string;
474
- options?: Array<{ label: string }>;
475
- multi?: boolean;
476
- }
477
-
478
- const askRenderer: ToolRenderer<AskArgs, AskToolDetails> = {
479
- renderCall(args, theme) {
480
- if (!args.question) {
481
- return renderErrorMessage("Ask", "No question provided", theme);
482
- }
483
-
484
- const label = theme.fg("toolTitle", theme.bold("Ask"));
485
- let text = `${label} ${theme.fg("accent", args.question)}`;
486
-
487
- const meta: string[] = [];
488
- if (args.multi) meta.push("multi");
489
- if (args.options?.length) meta.push(`options:${args.options.length}`);
490
- text += formatMeta(meta, theme);
491
-
492
- if (args.options?.length) {
493
- for (let i = 0; i < args.options.length; i++) {
494
- const opt = args.options[i];
495
- const isLast = i === args.options.length - 1;
496
- const branch = isLast ? theme.tree.last : theme.tree.branch;
497
- text += `\n ${theme.fg("dim", branch)} ${theme.fg(
498
- "dim",
499
- theme.checkbox.unchecked,
500
- )} ${theme.fg("muted", opt.label)}`;
501
- }
502
- }
503
-
504
- return new Text(text, 0, 0);
505
- },
506
-
507
- renderResult(result, _opts, theme) {
508
- const { details } = result;
509
- if (!details) {
510
- const txt = result.content[0];
511
- return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0);
512
- }
513
-
514
- const hasSelection = details.customInput || details.selectedOptions.length > 0;
515
- const statusIcon = hasSelection
516
- ? theme.styledSymbol("status.success", "success")
517
- : theme.styledSymbol("status.warning", "warning");
518
-
519
- let text = `${statusIcon} ${theme.fg("accent", details.question)}`;
520
-
521
- if (details.customInput) {
522
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.styledSymbol(
523
- "status.success",
524
- "success",
525
- )} ${theme.fg("toolOutput", details.customInput)}`;
526
- } else if (details.selectedOptions.length > 0) {
527
- const selected = details.selectedOptions;
528
- for (let i = 0; i < selected.length; i++) {
529
- const isLast = i === selected.length - 1;
530
- const branch = isLast ? theme.tree.last : theme.tree.branch;
531
- text += `\n ${theme.fg("dim", branch)} ${theme.fg(
532
- "success",
533
- theme.checkbox.checked,
534
- )} ${theme.fg("toolOutput", selected[i])}`;
535
- }
536
- } else {
537
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.styledSymbol(
538
- "status.warning",
539
- "warning",
540
- )} ${theme.fg("warning", "Cancelled")}`;
541
- }
542
-
543
- return new Text(text, 0, 0);
544
- },
545
- };
546
-
547
- // ============================================================================
548
- // Export
549
- // ============================================================================
550
-
551
- // ============================================================================
552
- // LSP Renderer
553
- // ============================================================================
554
-
555
- interface LspArgs {
556
- action: string;
557
- file?: string;
558
- files?: string[];
559
- line?: number;
560
- column?: number;
561
- }
562
-
563
- const lspRenderer: ToolRenderer<LspArgs, LspToolDetails> = {
564
- renderCall: renderLspCall,
565
- renderResult: renderLspResult,
566
- };
567
-
568
- // ============================================================================
569
- // Output Renderer
570
- // ============================================================================
571
-
572
- interface OutputArgs {
573
- ids: string[];
574
- format?: "raw" | "json" | "stripped";
575
- }
576
-
577
- type OutputEntry = OutputToolDetails["outputs"][number];
578
-
579
- function formatOutputMeta(entry: OutputEntry, theme: Theme): string {
580
- const metaParts = [formatCount("line", entry.lineCount), formatBytes(entry.charCount)];
581
- if (entry.provenance) {
582
- metaParts.push(`agent ${entry.provenance.agent}(${entry.provenance.index})`);
583
- }
584
- return theme.fg("dim", metaParts.join(theme.sep.dot));
585
- }
586
-
587
- const outputRenderer: ToolRenderer<OutputArgs, OutputToolDetails> = {
588
- renderCall(args, theme) {
589
- const ids = args.ids?.join(", ") ?? "?";
590
- const label = theme.fg("toolTitle", theme.bold("Output"));
591
- let text = `${label} ${theme.fg("accent", ids)}`;
592
-
593
- const meta: string[] = [];
594
- if (args.format && args.format !== "raw") meta.push(`format:${args.format}`);
595
- text += formatMeta(meta, theme);
596
-
597
- return new Text(text, 0, 0);
598
- },
599
-
600
- renderResult(result, { expanded }, theme) {
601
- const label = "Output";
602
- const details = result.details;
603
-
604
- if (details?.notFound?.length) {
605
- const icon = theme.styledSymbol("status.error", "error");
606
- let text = `${icon} ${theme.fg("error", `Error: Not found: ${details.notFound.join(", ")}`)}`;
607
- if (details.availableIds?.length) {
608
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
609
- "muted",
610
- `Available: ${details.availableIds.join(", ")}`,
611
- )}`;
612
- } else {
613
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
614
- "muted",
615
- "No outputs available in current session",
616
- )}`;
617
- }
618
- return new Text(text, 0, 0);
619
- }
620
-
621
- const outputs = details?.outputs ?? [];
622
-
623
- if (outputs.length === 0) {
624
- const textContent = result.content?.find((c) => c.type === "text")?.text;
625
- return renderEmptyMessage(label, textContent || "No outputs", theme);
626
- }
627
-
628
- const icon = theme.styledSymbol("status.success", "success");
629
- const summary = `read ${formatCount("output", outputs.length)}`;
630
- const previewLimit = expanded ? 3 : 1;
631
- const maxOutputs = expanded ? outputs.length : Math.min(outputs.length, 5);
632
- const hasMoreOutputs = outputs.length > maxOutputs;
633
- const hasMorePreview = outputs.some((o) => (o.previewLines?.length ?? 0) > previewLimit);
634
- const expandHint = formatExpandHint(expanded, hasMoreOutputs || hasMorePreview, theme);
635
- let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
636
-
637
- for (let i = 0; i < maxOutputs; i++) {
638
- const o = outputs[i];
639
- const isLast = i === maxOutputs - 1 && !hasMoreOutputs;
640
- const branch = isLast ? theme.tree.last : theme.tree.branch;
641
- text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", o.id)} ${formatOutputMeta(o, theme)}`;
642
-
643
- const previewLines = o.previewLines ?? [];
644
- const shownPreview = previewLines.slice(0, previewLimit);
645
- if (shownPreview.length > 0) {
646
- const childPrefix = isLast ? " " : ` ${theme.fg("dim", theme.tree.vertical)} `;
647
- for (const line of shownPreview) {
648
- const previewText = truncate(line, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis);
649
- text += `\n${childPrefix}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
650
- "muted",
651
- "preview:",
652
- )} ${theme.fg("toolOutput", previewText)}`;
653
- }
654
- }
655
- }
656
-
657
- if (hasMoreOutputs) {
658
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
659
- "muted",
660
- formatMoreItems(outputs.length - maxOutputs, "output", theme),
661
- )}`;
662
- }
663
-
664
- return new Text(text, 0, 0);
665
- },
666
- };
667
-
668
- // ============================================================================
669
- // Task Renderer
670
- // ============================================================================
671
-
672
- const taskRenderer: ToolRenderer<any, TaskToolDetails> = {
673
- renderCall: renderTaskCall,
674
- renderResult: renderTaskResult,
675
- };
676
-
677
- // ============================================================================
678
- // Ls Renderer
679
- // ============================================================================
680
-
681
- interface LsArgs {
682
- path?: string;
683
- limit?: number;
684
- }
685
-
686
- const lsRenderer: ToolRenderer<LsArgs, LsToolDetails> = {
687
- renderCall(args, theme) {
688
- const label = theme.fg("toolTitle", theme.bold("Ls"));
689
- let text = `${label} ${theme.fg("accent", args.path || ".")}`;
690
-
691
- const meta: string[] = [];
692
- if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
693
- text += formatMeta(meta, theme);
694
-
695
- return new Text(text, 0, 0);
696
- },
697
-
698
- renderResult(result, { expanded }, theme) {
699
- const label = "Ls";
700
- const details = result.details;
701
- const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
702
-
703
- if (
704
- (!textContent || textContent.trim() === "" || textContent.trim() === "(empty directory)") &&
705
- (!details?.entries || details.entries.length === 0)
706
- ) {
707
- return renderEmptyMessage(label, "Empty directory", theme);
708
- }
709
-
710
- let entries: string[] = details?.entries ? [...details.entries] : [];
711
- if (entries.length === 0) {
712
- const rawLines = textContent.split("\n").filter((l: string) => l.trim());
713
- entries = rawLines.filter((line) => !/^\[.*\]$/.test(line.trim()));
714
- }
715
-
716
- if (entries.length === 0) {
717
- return renderEmptyMessage(label, "Empty directory", theme);
718
- }
719
-
720
- let dirCount = details?.dirCount;
721
- let fileCount = details?.fileCount;
722
- if (dirCount === undefined || fileCount === undefined) {
723
- dirCount = 0;
724
- fileCount = 0;
725
- for (const entry of entries) {
726
- if (entry.endsWith("/")) {
727
- dirCount += 1;
728
- } else {
729
- fileCount += 1;
730
- }
731
- }
732
- }
733
-
734
- const truncated = Boolean(details?.truncation?.truncated || details?.entryLimitReached);
735
- const icon = truncated
736
- ? theme.styledSymbol("status.warning", "warning")
737
- : theme.styledSymbol("status.success", "success");
738
-
739
- const summaryText = [formatCount("dir", dirCount ?? 0), formatCount("file", fileCount ?? 0)].join(theme.sep.dot);
740
- const maxEntries = expanded ? entries.length : Math.min(entries.length, COLLAPSED_LIST_LIMIT);
741
- const hasMoreEntries = entries.length > maxEntries;
742
- const expandHint = formatExpandHint(expanded, hasMoreEntries, theme);
743
-
744
- let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, theme)}${expandHint}`;
745
-
746
- const truncationReasons: string[] = [];
747
- if (details?.entryLimitReached) {
748
- truncationReasons.push(`entry limit ${details.entryLimitReached}`);
749
- }
750
- if (details?.truncation?.truncated) {
751
- truncationReasons.push(`output cap ${formatBytes(details.truncation.maxBytes)}`);
752
- }
753
-
754
- const hasTruncation = truncationReasons.length > 0;
755
-
756
- for (let i = 0; i < maxEntries; i++) {
757
- const entry = entries[i];
758
- const isLast = i === maxEntries - 1 && !hasMoreEntries && !hasTruncation;
759
- const branch = isLast ? theme.tree.last : theme.tree.branch;
760
- const isDir = entry.endsWith("/");
761
- const entryPath = isDir ? entry.slice(0, -1) : entry;
762
- const lang = isDir ? undefined : getLanguageFromPath(entryPath);
763
- const entryIcon = isDir ? theme.fg("accent", theme.icon.folder) : theme.fg("muted", theme.getLangIcon(lang));
764
- const entryColor = isDir ? "accent" : "toolOutput";
765
- text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg(entryColor, entry)}`;
766
- }
767
-
768
- if (hasMoreEntries) {
769
- const moreEntriesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
770
- text += `\n ${theme.fg("dim", moreEntriesBranch)} ${theme.fg(
771
- "muted",
772
- formatMoreItems(entries.length - maxEntries, "entry", theme),
773
- )}`;
774
- }
775
-
776
- if (hasTruncation) {
777
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
778
- "warning",
779
- `truncated: ${truncationReasons.join(", ")}`,
780
- )}`;
781
- }
782
-
783
- return new Text(text, 0, 0);
784
- },
785
- };
786
-
787
- // ============================================================================
788
- // Web Fetch Renderer
789
- // ============================================================================
790
-
791
- interface WebFetchArgs {
792
- url: string;
793
- timeout?: number;
794
- raw?: boolean;
795
- }
796
-
797
- const webFetchRenderer: ToolRenderer<WebFetchArgs, WebFetchToolDetails> = {
798
- renderCall: renderWebFetchCall,
799
- renderResult: renderWebFetchResult,
800
- };
801
-
802
- // ============================================================================
803
- // Web Search Renderer
804
- // ============================================================================
805
-
806
- interface WebSearchArgs {
807
- query: string;
808
- provider?: string;
809
- [key: string]: unknown;
810
- }
811
-
812
- const webSearchRenderer: ToolRenderer<WebSearchArgs, WebSearchRenderDetails> = {
813
- renderCall: renderWebSearchCall,
814
- renderResult: renderWebSearchResult,
815
- };
816
-
817
- // ============================================================================
818
- // Export
819
- // ============================================================================
820
-
821
- export const toolRenderers: Record<
822
- string,
823
- {
824
- renderCall: (args: any, theme: Theme) => Component;
825
- renderResult: (result: any, options: RenderResultOptions, theme: Theme) => Component;
826
- }
827
- > = {
828
- ask: askRenderer,
829
- grep: grepRenderer,
830
- find: findRenderer,
831
- notebook: notebookRenderer,
832
- ls: lsRenderer,
833
- lsp: lspRenderer,
834
- output: outputRenderer,
835
- task: taskRenderer,
836
- web_fetch: webFetchRenderer,
837
- web_search: webSearchRenderer,
31
+ args?: unknown,
32
+ ) => Component;
33
+ };
34
+
35
+ export const toolRenderers: Record<string, ToolRenderer> = {
36
+ ask: askToolRenderer as ToolRenderer,
37
+ bash: bashToolRenderer as ToolRenderer,
38
+ edit: editToolRenderer as ToolRenderer,
39
+ find: findToolRenderer as ToolRenderer,
40
+ grep: grepToolRenderer as ToolRenderer,
41
+ ls: lsToolRenderer as ToolRenderer,
42
+ lsp: lspToolRenderer as ToolRenderer,
43
+ notebook: notebookToolRenderer as ToolRenderer,
44
+ output: outputToolRenderer as ToolRenderer,
45
+ read: readToolRenderer as ToolRenderer,
46
+ task: taskToolRenderer as ToolRenderer,
47
+ web_fetch: webFetchToolRenderer as ToolRenderer,
48
+ web_search: webSearchToolRenderer as ToolRenderer,
49
+ write: writeToolRenderer as ToolRenderer,
838
50
  };