@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
@@ -422,6 +422,7 @@ export async function computeEditDiff(
422
422
  newText: string,
423
423
  cwd: string,
424
424
  fuzzy = true,
425
+ all = false,
425
426
  ): Promise<EditDiffResult | EditDiffError> {
426
427
  const absolutePath = resolveToCwd(path, cwd);
427
428
 
@@ -443,34 +444,82 @@ export async function computeEditDiff(
443
444
  const normalizedOldText = normalizeToLF(oldText);
444
445
  const normalizedNewText = normalizeToLF(newText);
445
446
 
446
- const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
447
- allowFuzzy: fuzzy,
448
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
449
- });
447
+ let normalizedNewContent: string;
450
448
 
451
- if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
452
- return {
453
- error: `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
454
- };
455
- }
449
+ if (all) {
450
+ // Replace all occurrences mode with fuzzy matching
451
+ normalizedNewContent = normalizedContent;
452
+ let replacementCount = 0;
456
453
 
457
- if (!matchOutcome.match) {
458
- return {
459
- error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
460
- allowFuzzy: fuzzy,
461
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
462
- fuzzyMatches: matchOutcome.fuzzyMatches,
463
- }),
464
- };
465
- }
454
+ // First check: if exact matches exist, use simple replaceAll
455
+ const exactCount = normalizedContent.split(normalizedOldText).length - 1;
456
+ if (exactCount > 0) {
457
+ normalizedNewContent = normalizedContent.split(normalizedOldText).join(normalizedNewText);
458
+ replacementCount = exactCount;
459
+ } else {
460
+ // No exact matches - try fuzzy matching iteratively
461
+ while (true) {
462
+ const matchOutcome = findEditMatch(normalizedNewContent, normalizedOldText, {
463
+ allowFuzzy: fuzzy,
464
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
465
+ });
466
+
467
+ // In all mode, use closest match if it passes threshold (even with multiple matches)
468
+ const match =
469
+ matchOutcome.match ||
470
+ (fuzzy && matchOutcome.closest && matchOutcome.closest.confidence >= DEFAULT_FUZZY_THRESHOLD
471
+ ? matchOutcome.closest
472
+ : undefined);
473
+
474
+ if (!match) {
475
+ if (replacementCount === 0) {
476
+ return {
477
+ error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
478
+ allowFuzzy: fuzzy,
479
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
480
+ fuzzyMatches: matchOutcome.fuzzyMatches,
481
+ }),
482
+ };
483
+ }
484
+ break;
485
+ }
486
+
487
+ normalizedNewContent =
488
+ normalizedNewContent.substring(0, match.startIndex) +
489
+ normalizedNewText +
490
+ normalizedNewContent.substring(match.startIndex + match.actualText.length);
491
+ replacementCount++;
492
+ }
493
+ }
494
+ } else {
495
+ // Single replacement mode with fuzzy matching
496
+ const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
497
+ allowFuzzy: fuzzy,
498
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
499
+ });
500
+
501
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
502
+ return {
503
+ error: `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
504
+ };
505
+ }
466
506
 
467
- const match = matchOutcome.match;
507
+ if (!matchOutcome.match) {
508
+ return {
509
+ error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
510
+ allowFuzzy: fuzzy,
511
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
512
+ fuzzyMatches: matchOutcome.fuzzyMatches,
513
+ }),
514
+ };
515
+ }
468
516
 
469
- // Compute the new content
470
- const normalizedNewContent =
471
- normalizedContent.substring(0, match.startIndex) +
472
- normalizedNewText +
473
- normalizedContent.substring(match.startIndex + match.actualText.length);
517
+ const match = matchOutcome.match;
518
+ normalizedNewContent =
519
+ normalizedContent.substring(0, match.startIndex) +
520
+ normalizedNewText +
521
+ normalizedContent.substring(match.startIndex + match.actualText.length);
522
+ }
474
523
 
475
524
  // Check if it would actually change anything
476
525
  if (normalizedContent === normalizedNewContent) {
@@ -1,9 +1,15 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { Text } from "@oh-my-pi/pi-tui";
2
4
  import { Type } from "@sinclair/typebox";
5
+ import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
3
6
  import editDescription from "../../prompts/tools/edit.md" with { type: "text" };
7
+ import type { RenderResultOptions } from "../custom-tools/types";
4
8
  import {
5
9
  DEFAULT_FUZZY_THRESHOLD,
6
10
  detectLineEnding,
11
+ type EditDiffError,
12
+ type EditDiffResult,
7
13
  EditMatchError,
8
14
  findEditMatch,
9
15
  generateDiffString,
@@ -13,6 +19,14 @@ import {
13
19
  } from "./edit-diff";
14
20
  import { type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "./lsp/index";
15
21
  import { resolveToCwd } from "./path-utils";
22
+ import {
23
+ formatDiagnostics,
24
+ formatDiffStats,
25
+ getDiffStats,
26
+ shortenPath,
27
+ truncateDiffByHunk,
28
+ wrapBrackets,
29
+ } from "./render-utils";
16
30
 
17
31
  const editSchema = Type.Object({
18
32
  path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
@@ -20,6 +34,7 @@ const editSchema = Type.Object({
20
34
  description: "Text to find and replace (high-confidence fuzzy matching for whitespace/indentation is always on)",
21
35
  }),
22
36
  newText: Type.String({ description: "New text to replace the old text with" }),
37
+ all: Type.Optional(Type.Boolean({ description: "Replace all occurrences instead of requiring unique match" })),
23
38
  });
24
39
 
25
40
  export interface EditToolDetails {
@@ -48,7 +63,7 @@ export function createEditTool(cwd: string, options: EditToolOptions = {}): Agen
48
63
  parameters: editSchema,
49
64
  execute: async (
50
65
  _toolCallId: string,
51
- { path, oldText, newText }: { path: string; oldText: string; newText: string },
66
+ { path, oldText, newText, all }: { path: string; oldText: string; newText: string; all?: boolean },
52
67
  signal?: AbortSignal,
53
68
  ) => {
54
69
  // Reject .ipynb files - use NotebookEdit tool instead
@@ -73,30 +88,79 @@ export function createEditTool(cwd: string, options: EditToolOptions = {}): Agen
73
88
  const normalizedOldText = normalizeToLF(oldText);
74
89
  const normalizedNewText = normalizeToLF(newText);
75
90
 
76
- const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
77
- allowFuzzy,
78
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
79
- });
91
+ let normalizedNewContent: string;
92
+ let replacementCount = 0;
80
93
 
81
- if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
82
- throw new Error(
83
- `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
84
- );
85
- }
94
+ if (all) {
95
+ // Replace all occurrences mode with fuzzy matching
96
+ normalizedNewContent = normalizedContent;
97
+
98
+ // First check: if exact matches exist, use simple replaceAll
99
+ const exactCount = normalizedContent.split(normalizedOldText).length - 1;
100
+ if (exactCount > 0) {
101
+ normalizedNewContent = normalizedContent.split(normalizedOldText).join(normalizedNewText);
102
+ replacementCount = exactCount;
103
+ } else {
104
+ // No exact matches - try fuzzy matching iteratively
105
+ while (true) {
106
+ const matchOutcome = findEditMatch(normalizedNewContent, normalizedOldText, {
107
+ allowFuzzy,
108
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
109
+ });
86
110
 
87
- if (!matchOutcome.match) {
88
- throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
111
+ // In all mode, use closest match if it passes threshold (even with multiple matches)
112
+ const match =
113
+ matchOutcome.match ||
114
+ (allowFuzzy && matchOutcome.closest && matchOutcome.closest.confidence >= DEFAULT_FUZZY_THRESHOLD
115
+ ? matchOutcome.closest
116
+ : undefined);
117
+
118
+ if (!match) {
119
+ if (replacementCount === 0) {
120
+ throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
121
+ allowFuzzy,
122
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
123
+ fuzzyMatches: matchOutcome.fuzzyMatches,
124
+ });
125
+ }
126
+ break;
127
+ }
128
+
129
+ normalizedNewContent =
130
+ normalizedNewContent.substring(0, match.startIndex) +
131
+ normalizedNewText +
132
+ normalizedNewContent.substring(match.startIndex + match.actualText.length);
133
+ replacementCount++;
134
+ }
135
+ }
136
+ } else {
137
+ // Single replacement mode with fuzzy matching
138
+ const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
89
139
  allowFuzzy,
90
140
  similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
91
- fuzzyMatches: matchOutcome.fuzzyMatches,
92
141
  });
93
- }
94
142
 
95
- const match = matchOutcome.match;
96
- const normalizedNewContent =
97
- normalizedContent.substring(0, match.startIndex) +
98
- normalizedNewText +
99
- normalizedContent.substring(match.startIndex + match.actualText.length);
143
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
144
+ throw new Error(
145
+ `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
146
+ );
147
+ }
148
+
149
+ if (!matchOutcome.match) {
150
+ throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
151
+ allowFuzzy,
152
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
153
+ fuzzyMatches: matchOutcome.fuzzyMatches,
154
+ });
155
+ }
156
+
157
+ const match = matchOutcome.match;
158
+ normalizedNewContent =
159
+ normalizedContent.substring(0, match.startIndex) +
160
+ normalizedNewText +
161
+ normalizedContent.substring(match.startIndex + match.actualText.length);
162
+ replacementCount = 1;
163
+ }
100
164
 
101
165
  // Verify the replacement actually changed something
102
166
  if (normalizedContent === normalizedNewContent) {
@@ -111,7 +175,10 @@ export function createEditTool(cwd: string, options: EditToolOptions = {}): Agen
111
175
  const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
112
176
 
113
177
  // Build result text
114
- let resultText = `Successfully replaced text in ${path}.`;
178
+ let resultText =
179
+ replacementCount > 1
180
+ ? `Successfully replaced ${replacementCount} occurrences in ${path}.`
181
+ : `Successfully replaced text in ${path}.`;
115
182
 
116
183
  const messages = diagnostics?.messages;
117
184
  if (messages && messages.length > 0) {
@@ -138,3 +205,130 @@ export function createEditTool(cwd: string, options: EditToolOptions = {}): Agen
138
205
 
139
206
  /** Default edit tool using process.cwd() - for backwards compatibility */
140
207
  export const editTool = createEditTool(process.cwd());
208
+
209
+ // =============================================================================
210
+ // TUI Renderer
211
+ // =============================================================================
212
+
213
+ interface EditRenderArgs {
214
+ path?: string;
215
+ file_path?: string;
216
+ oldText?: string;
217
+ newText?: string;
218
+ all?: boolean;
219
+ }
220
+
221
+ /** Extended context for edit tool rendering */
222
+ export interface EditRenderContext {
223
+ /** Pre-computed diff preview (computed before tool executes) */
224
+ editDiffPreview?: EditDiffResult | EditDiffError;
225
+ /** Function to render diff text with syntax highlighting */
226
+ renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
227
+ }
228
+
229
+ const EDIT_DIFF_PREVIEW_HUNKS = 2;
230
+ const EDIT_DIFF_PREVIEW_LINES = 24;
231
+
232
+ function countLines(text: string): number {
233
+ if (!text) return 0;
234
+ return text.split("\n").length;
235
+ }
236
+
237
+ function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
238
+ const icon = uiTheme.getLangIcon(language);
239
+ if (lineCount !== null) {
240
+ return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
241
+ }
242
+ return uiTheme.fg("dim", `${icon}`);
243
+ }
244
+
245
+ export const editToolRenderer = {
246
+ renderCall(args: EditRenderArgs, uiTheme: Theme): Component {
247
+ const rawPath = args.file_path || args.path || "";
248
+ const filePath = shortenPath(rawPath);
249
+ const editLanguage = getLanguageFromPath(rawPath) ?? "text";
250
+ const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
251
+ const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
252
+
253
+ const text = `${uiTheme.fg("toolTitle", uiTheme.bold("Edit"))} ${editIcon} ${pathDisplay}`;
254
+ return new Text(text, 0, 0);
255
+ },
256
+
257
+ renderResult(
258
+ result: { content: Array<{ type: string; text?: string }>; details?: EditToolDetails; isError?: boolean },
259
+ options: RenderResultOptions & { renderContext?: EditRenderContext },
260
+ uiTheme: Theme,
261
+ args?: EditRenderArgs,
262
+ ): Component {
263
+ const { expanded, renderContext } = options;
264
+ const rawPath = args?.file_path || args?.path || "";
265
+ const filePath = shortenPath(rawPath);
266
+ const editLanguage = getLanguageFromPath(rawPath) ?? "text";
267
+ const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
268
+ const editDiffPreview = renderContext?.editDiffPreview;
269
+ const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
270
+
271
+ // Build path display with line number if available
272
+ let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
273
+ const firstChangedLine =
274
+ (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
275
+ (result.details && !result.isError ? result.details.firstChangedLine : undefined);
276
+ if (firstChangedLine) {
277
+ pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`);
278
+ }
279
+
280
+ let text = `${uiTheme.fg("toolTitle", uiTheme.bold("Edit"))} ${editIcon} ${pathDisplay}`;
281
+
282
+ const editLineCount = countLines(args?.newText ?? args?.oldText ?? "");
283
+ text += `\n${formatMetadataLine(editLineCount, editLanguage, uiTheme)}`;
284
+
285
+ if (result.isError) {
286
+ // Show error from result
287
+ const errorText = result.content?.find((c) => c.type === "text")?.text ?? "";
288
+ if (errorText) {
289
+ text += `\n\n${uiTheme.fg("error", errorText)}`;
290
+ }
291
+ } else if (editDiffPreview) {
292
+ // Use cached diff preview (works both before and after execution)
293
+ if ("error" in editDiffPreview) {
294
+ text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`;
295
+ } else if (editDiffPreview.diff) {
296
+ const diffStats = getDiffStats(editDiffPreview.diff);
297
+ text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${formatDiffStats(
298
+ diffStats.added,
299
+ diffStats.removed,
300
+ diffStats.hunks,
301
+ uiTheme,
302
+ )}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
303
+
304
+ const {
305
+ text: diffText,
306
+ hiddenHunks,
307
+ hiddenLines,
308
+ } = expanded
309
+ ? { text: editDiffPreview.diff, hiddenHunks: 0, hiddenLines: 0 }
310
+ : truncateDiffByHunk(editDiffPreview.diff, EDIT_DIFF_PREVIEW_HUNKS, EDIT_DIFF_PREVIEW_LINES);
311
+
312
+ text += `\n\n${renderDiffFn(diffText, { filePath: rawPath })}`;
313
+ if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
314
+ const remainder: string[] = [];
315
+ if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
316
+ if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
317
+ text += uiTheme.fg(
318
+ "toolOutput",
319
+ `\n${uiTheme.format.ellipsis} (${remainder.join(", ")}) ${wrapBrackets("Ctrl+O to expand", uiTheme)}`,
320
+ );
321
+ }
322
+ }
323
+ }
324
+
325
+ // Show LSP diagnostics if available
326
+ if (result.details?.diagnostics) {
327
+ text += formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp) =>
328
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
329
+ );
330
+ }
331
+
332
+ return new Text(text, 0, 0);
333
+ },
334
+ };
@@ -1,12 +1,27 @@
1
1
  import { existsSync, type Stats, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
+ import type { Component } from "@oh-my-pi/pi-tui";
5
+ import { Text } from "@oh-my-pi/pi-tui";
4
6
  import { Type } from "@sinclair/typebox";
5
7
  import { globSync } from "glob";
8
+ import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
6
9
  import findDescription from "../../prompts/tools/find.md" with { type: "text" };
7
10
  import { ensureTool } from "../../utils/tools-manager";
11
+ import type { RenderResultOptions } from "../custom-tools/types";
8
12
  import { untilAborted } from "../utils";
9
13
  import { resolveToCwd } from "./path-utils";
14
+ import {
15
+ formatCount,
16
+ formatEmptyMessage,
17
+ formatErrorMessage,
18
+ formatExpandHint,
19
+ formatMeta,
20
+ formatMoreItems,
21
+ formatScope,
22
+ formatTruncationSuffix,
23
+ PREVIEW_LIMITS,
24
+ } from "./render-utils";
10
25
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate";
11
26
 
12
27
  const findSchema = Type.Object({
@@ -83,8 +98,14 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
83
98
  const shouldSortByMtime = sortByMtime ?? false;
84
99
 
85
100
  // Build fd arguments
101
+ // When pattern contains path separators (e.g. "reports/**"), use --full-path
102
+ // so fd matches against the full path, not just the filename.
103
+ // Also prepend **/ to anchor the pattern at any depth in the search path.
104
+ const hasPathSeparator = pattern.includes("/") || pattern.includes("\\");
105
+ const effectivePattern = hasPathSeparator && !pattern.startsWith("**/") ? `**/${pattern}` : pattern;
86
106
  const args: string[] = [
87
107
  "--glob", // Use glob pattern
108
+ ...(hasPathSeparator ? ["--full-path"] : []),
88
109
  "--color=never", // No ANSI colors
89
110
  "--max-results",
90
111
  String(effectiveLimit),
@@ -127,7 +148,7 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
127
148
  }
128
149
 
129
150
  // Pattern and path
130
- args.push(pattern, searchPath);
151
+ args.push(effectivePattern, searchPath);
131
152
 
132
153
  // Run fd
133
154
  const result = Bun.spawnSync([fdPath, ...args], {
@@ -242,3 +263,143 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
242
263
 
243
264
  /** Default find tool using process.cwd() - for backwards compatibility */
244
265
  export const findTool = createFindTool(process.cwd());
266
+
267
+ // =============================================================================
268
+ // TUI Renderer
269
+ // =============================================================================
270
+
271
+ interface FindRenderArgs {
272
+ pattern: string;
273
+ path?: string;
274
+ type?: string;
275
+ hidden?: boolean;
276
+ sortByMtime?: boolean;
277
+ limit?: number;
278
+ }
279
+
280
+ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
281
+
282
+ export const findToolRenderer = {
283
+ renderCall(args: FindRenderArgs, uiTheme: Theme): Component {
284
+ const label = uiTheme.fg("toolTitle", uiTheme.bold("Find"));
285
+ let text = `${label} ${uiTheme.fg("accent", args.pattern || "*")}`;
286
+
287
+ const meta: string[] = [];
288
+ if (args.path) meta.push(`in ${args.path}`);
289
+ if (args.type && args.type !== "all") meta.push(`type:${args.type}`);
290
+ if (args.hidden) meta.push("hidden");
291
+ if (args.sortByMtime) meta.push("sort:mtime");
292
+ if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
293
+
294
+ text += formatMeta(meta, uiTheme);
295
+
296
+ return new Text(text, 0, 0);
297
+ },
298
+
299
+ renderResult(
300
+ result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails },
301
+ { expanded }: RenderResultOptions,
302
+ uiTheme: Theme,
303
+ ): Component {
304
+ const details = result.details;
305
+
306
+ if (details?.error) {
307
+ return new Text(formatErrorMessage(details.error, uiTheme), 0, 0);
308
+ }
309
+
310
+ const hasDetailedData = details?.fileCount !== undefined;
311
+ const textContent = result.content?.find((c) => c.type === "text")?.text;
312
+
313
+ if (!hasDetailedData) {
314
+ if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") {
315
+ return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0);
316
+ }
317
+
318
+ const lines = textContent.split("\n").filter((l) => l.trim());
319
+ const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_LIST_LIMIT);
320
+ const displayLines = lines.slice(0, maxLines);
321
+ const remaining = lines.length - maxLines;
322
+ const hasMore = remaining > 0;
323
+
324
+ const icon = uiTheme.styledSymbol("status.success", "success");
325
+ const summary = formatCount("file", lines.length);
326
+ const expandHint = formatExpandHint(expanded, hasMore, uiTheme);
327
+ let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
328
+
329
+ for (let i = 0; i < displayLines.length; i++) {
330
+ const isLast = i === displayLines.length - 1 && remaining === 0;
331
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
332
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", displayLines[i])}`;
333
+ }
334
+ if (remaining > 0) {
335
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
336
+ "muted",
337
+ formatMoreItems(remaining, "file", uiTheme),
338
+ )}`;
339
+ }
340
+ return new Text(text, 0, 0);
341
+ }
342
+
343
+ const fileCount = details?.fileCount ?? 0;
344
+ const truncated = details?.truncated ?? details?.truncation?.truncated ?? false;
345
+ const files = details?.files ?? [];
346
+
347
+ if (fileCount === 0) {
348
+ return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0);
349
+ }
350
+
351
+ const icon = uiTheme.styledSymbol("status.success", "success");
352
+ const summaryText = formatCount("file", fileCount);
353
+ const scopeLabel = formatScope(details?.scopePath, uiTheme);
354
+ const maxFiles = expanded ? files.length : Math.min(files.length, COLLAPSED_LIST_LIMIT);
355
+ const hasMoreFiles = files.length > maxFiles;
356
+ const expandHint = formatExpandHint(expanded, hasMoreFiles, uiTheme);
357
+
358
+ let text = `${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(
359
+ truncated,
360
+ uiTheme,
361
+ )}${scopeLabel}${expandHint}`;
362
+
363
+ const truncationReasons: string[] = [];
364
+ if (details?.resultLimitReached) {
365
+ truncationReasons.push(`limit ${details.resultLimitReached} results`);
366
+ }
367
+ if (details?.truncation?.truncated) {
368
+ truncationReasons.push("size limit");
369
+ }
370
+
371
+ const hasTruncation = truncationReasons.length > 0;
372
+
373
+ if (files.length > 0) {
374
+ for (let i = 0; i < maxFiles; i++) {
375
+ const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation;
376
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
377
+ const entry = files[i];
378
+ const isDir = entry.endsWith("/");
379
+ const entryPath = isDir ? entry.slice(0, -1) : entry;
380
+ const lang = isDir ? undefined : getLanguageFromPath(entryPath);
381
+ const entryIcon = isDir
382
+ ? uiTheme.fg("accent", uiTheme.icon.folder)
383
+ : uiTheme.fg("muted", uiTheme.getLangIcon(lang));
384
+ text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg("accent", entry)}`;
385
+ }
386
+
387
+ if (hasMoreFiles) {
388
+ const moreFilesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last;
389
+ text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg(
390
+ "muted",
391
+ formatMoreItems(files.length - maxFiles, "file", uiTheme),
392
+ )}`;
393
+ }
394
+ }
395
+
396
+ if (hasTruncation) {
397
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
398
+ "warning",
399
+ `truncated: ${truncationReasons.join(", ")}`,
400
+ )}`;
401
+ }
402
+
403
+ return new Text(text, 0, 0);
404
+ },
405
+ };