@oh-my-pi/pi-coding-agent 3.30.0 → 3.31.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 (155) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/edit-diff.ts +11 -4
  16. package/src/core/tools/edit.ts +7 -13
  17. package/src/core/tools/find.ts +111 -50
  18. package/src/core/tools/gemini-image.ts +128 -147
  19. package/src/core/tools/grep.ts +397 -415
  20. package/src/core/tools/index.test.ts +5 -1
  21. package/src/core/tools/index.ts +6 -8
  22. package/src/core/tools/ls.ts +12 -10
  23. package/src/core/tools/lsp/client.ts +58 -9
  24. package/src/core/tools/lsp/config.ts +205 -656
  25. package/src/core/tools/lsp/defaults.json +465 -0
  26. package/src/core/tools/lsp/index.ts +55 -32
  27. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  28. package/src/core/tools/lsp/types.ts +1 -0
  29. package/src/core/tools/lsp/utils.ts +1 -1
  30. package/src/core/tools/read.ts +150 -74
  31. package/src/core/tools/render-utils.ts +70 -10
  32. package/src/core/tools/review.ts +38 -126
  33. package/src/core/tools/task/artifacts.ts +5 -4
  34. package/src/core/tools/task/executor.ts +94 -83
  35. package/src/core/tools/task/index.ts +129 -92
  36. package/src/core/tools/task/parallel.ts +30 -3
  37. package/src/core/tools/task/render.ts +85 -39
  38. package/src/core/tools/task/types.ts +15 -6
  39. package/src/core/tools/task/worker.ts +124 -89
  40. package/src/core/tools/web-fetch.ts +112 -377
  41. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  42. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  43. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  49. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  50. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  51. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  52. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  53. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  54. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  57. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  59. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  60. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  61. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  62. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  63. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  64. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  71. package/src/core/tools/web-scrapers/index.ts +250 -0
  72. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  73. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  74. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  75. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  76. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  79. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  82. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  83. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  84. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  86. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  87. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  90. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  93. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  96. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  99. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  102. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  103. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  104. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  105. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  106. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  107. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  111. package/src/core/tools/web-scrapers/utils.ts +162 -0
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  113. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  114. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  116. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  117. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  118. package/src/core/tools/write.ts +21 -18
  119. package/src/core/voice.ts +3 -2
  120. package/src/lib/worktree/collapse.ts +2 -1
  121. package/src/lib/worktree/git.ts +2 -18
  122. package/src/main.ts +59 -3
  123. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  124. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  125. package/src/modes/interactive/components/hook-editor.ts +2 -1
  126. package/src/modes/interactive/components/model-selector.ts +19 -4
  127. package/src/modes/interactive/interactive-mode.ts +41 -38
  128. package/src/modes/interactive/theme/theme.ts +58 -58
  129. package/src/modes/rpc/rpc-mode.ts +10 -9
  130. package/src/prompts/review-request.md +27 -0
  131. package/src/prompts/reviewer.md +64 -68
  132. package/src/prompts/tools/output.md +22 -3
  133. package/src/prompts/tools/task.md +32 -33
  134. package/src/utils/clipboard.ts +2 -1
  135. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  136. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  137. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  138. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  139. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  140. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -1,4 +1,3 @@
1
- import { readFileSync, type Stats, statSync } from "node:fs";
2
1
  import nodePath from "node:path";
3
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
3
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -9,19 +8,10 @@ import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/t
9
8
  import grepDescription from "../../prompts/tools/grep.md" with { type: "text" };
10
9
  import { ensureTool } from "../../utils/tools-manager";
11
10
  import type { RenderResultOptions } from "../custom-tools/types";
11
+ import { ScopeSignal, untilAborted } from "../utils";
12
12
  import type { ToolSession } from "./index";
13
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";
14
+ import { createToolUIKit, PREVIEW_LIMITS } from "./render-utils";
25
15
  import {
26
16
  DEFAULT_MAX_BYTES,
27
17
  formatSize,
@@ -119,155 +109,377 @@ export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchem
119
109
  },
120
110
  signal?: AbortSignal,
121
111
  ) => {
122
- if (signal?.aborted) {
123
- throw new Error("Operation aborted");
124
- }
112
+ return untilAborted(signal, async () => {
113
+ const rgPath = await ensureTool("rg", true);
114
+ if (!rgPath) {
115
+ throw new Error("ripgrep (rg) is not available and could not be downloaded");
116
+ }
125
117
 
126
- const rgPath = await ensureTool("rg", true);
127
- if (!rgPath) {
128
- throw new Error("ripgrep (rg) is not available and could not be downloaded");
129
- }
118
+ const searchPath = resolveToCwd(searchDir || ".", session.cwd);
119
+ const scopePath = (() => {
120
+ const relative = nodePath.relative(session.cwd, searchPath).replace(/\\/g, "/");
121
+ return relative.length === 0 ? "." : relative;
122
+ })();
123
+ let searchStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
124
+ try {
125
+ searchStat = await Bun.file(searchPath).stat();
126
+ } catch {
127
+ throw new Error(`Path not found: ${searchPath}`);
128
+ }
130
129
 
131
- const searchPath = resolveToCwd(searchDir || ".", session.cwd);
132
- const scopePath = (() => {
133
- const relative = nodePath.relative(session.cwd, searchPath).replace(/\\/g, "/");
134
- return relative.length === 0 ? "." : relative;
135
- })();
136
- let searchStat: Stats;
137
- try {
138
- searchStat = statSync(searchPath);
139
- } catch (_err) {
140
- throw new Error(`Path not found: ${searchPath}`);
141
- }
130
+ const isDirectory = searchStat.isDirectory();
131
+ const contextValue = context && context > 0 ? context : 0;
132
+ const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
133
+ const effectiveOutputMode = outputMode ?? "content";
134
+ const effectiveOffset = offset && offset > 0 ? offset : 0;
135
+ const hasHeadLimit = headLimit !== undefined && headLimit > 0;
136
+
137
+ const formatPath = (filePath: string): string => {
138
+ if (isDirectory) {
139
+ const relative = nodePath.relative(searchPath, filePath);
140
+ if (relative && !relative.startsWith("..")) {
141
+ return relative.replace(/\\/g, "/");
142
+ }
143
+ }
144
+ return nodePath.basename(filePath);
145
+ };
142
146
 
143
- const isDirectory = searchStat.isDirectory();
144
- const contextValue = context && context > 0 ? context : 0;
145
- const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
146
- const effectiveOutputMode = outputMode ?? "content";
147
- const effectiveOffset = offset && offset > 0 ? offset : 0;
148
- const hasHeadLimit = headLimit !== undefined && headLimit > 0;
149
-
150
- const formatPath = (filePath: string): string => {
151
- if (isDirectory) {
152
- const relative = nodePath.relative(searchPath, filePath);
153
- if (relative && !relative.startsWith("..")) {
154
- return relative.replace(/\\/g, "/");
147
+ const fileCache = new Map<string, Promise<string[]>>();
148
+ const getFileLines = async (filePath: string): Promise<string[]> => {
149
+ let linesPromise = fileCache.get(filePath);
150
+ if (!linesPromise) {
151
+ linesPromise = (async () => {
152
+ try {
153
+ const content = await Bun.file(filePath).text();
154
+ return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
155
+ } catch {
156
+ return [];
157
+ }
158
+ })();
159
+ fileCache.set(filePath, linesPromise);
155
160
  }
161
+ return linesPromise;
162
+ };
163
+
164
+ const args: string[] = [];
165
+
166
+ // Base arguments depend on output mode
167
+ if (effectiveOutputMode === "files_with_matches") {
168
+ args.push("--files-with-matches", "--color=never", "--hidden");
169
+ } else if (effectiveOutputMode === "count") {
170
+ args.push("--count", "--color=never", "--hidden");
171
+ } else {
172
+ args.push("--json", "--line-number", "--color=never", "--hidden");
156
173
  }
157
- return nodePath.basename(filePath);
158
- };
159
174
 
160
- const fileCache = new Map<string, string[]>();
161
- const getFileLines = (filePath: string): string[] => {
162
- let lines = fileCache.get(filePath);
163
- if (!lines) {
164
- try {
165
- const content = readFileSync(filePath, "utf-8");
166
- lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
167
- } catch {
168
- lines = [];
169
- }
170
- fileCache.set(filePath, lines);
175
+ if (caseSensitive) {
176
+ args.push("--case-sensitive");
177
+ } else if (ignoreCase) {
178
+ args.push("--ignore-case");
179
+ } else {
180
+ args.push("--smart-case");
171
181
  }
172
- return lines;
173
- };
174
-
175
- const args: string[] = [];
176
-
177
- // Base arguments depend on output mode
178
- if (effectiveOutputMode === "files_with_matches") {
179
- args.push("--files-with-matches", "--color=never", "--hidden");
180
- } else if (effectiveOutputMode === "count") {
181
- args.push("--count", "--color=never", "--hidden");
182
- } else {
183
- args.push("--json", "--line-number", "--color=never", "--hidden");
184
- }
185
182
 
186
- if (caseSensitive) {
187
- args.push("--case-sensitive");
188
- } else if (ignoreCase) {
189
- args.push("--ignore-case");
190
- } else {
191
- args.push("--smart-case");
192
- }
183
+ if (multiline) {
184
+ args.push("--multiline");
185
+ }
193
186
 
194
- if (multiline) {
195
- args.push("--multiline");
196
- }
187
+ if (literal) {
188
+ args.push("--fixed-strings");
189
+ }
197
190
 
198
- if (literal) {
199
- args.push("--fixed-strings");
200
- }
191
+ if (glob) {
192
+ args.push("--glob", glob);
193
+ }
201
194
 
202
- if (glob) {
203
- args.push("--glob", glob);
204
- }
195
+ if (type) {
196
+ args.push("--type", type);
197
+ }
205
198
 
206
- if (type) {
207
- args.push("--type", type);
208
- }
199
+ args.push(pattern, searchPath);
200
+
201
+ const child: Subprocess = Bun.spawn([rgPath, ...args], {
202
+ stdin: "ignore",
203
+ stdout: "pipe",
204
+ stderr: "pipe",
205
+ });
206
+
207
+ let stderr = "";
208
+ let matchCount = 0;
209
+ let matchLimitReached = false;
210
+ let linesTruncated = false;
211
+ let aborted = false;
212
+ let killedDueToLimit = false;
213
+ const outputLines: string[] = [];
214
+ const files = new Set<string>();
215
+ const fileList: string[] = [];
216
+ const fileMatchCounts = new Map<string, number>();
217
+
218
+ const recordFile = (filePath: string) => {
219
+ const relative = formatPath(filePath);
220
+ if (!files.has(relative)) {
221
+ files.add(relative);
222
+ fileList.push(relative);
223
+ }
224
+ };
209
225
 
210
- args.push(pattern, searchPath);
226
+ const recordFileMatch = (filePath: string) => {
227
+ const relative = formatPath(filePath);
228
+ fileMatchCounts.set(relative, (fileMatchCounts.get(relative) ?? 0) + 1);
229
+ };
211
230
 
212
- const child: Subprocess = Bun.spawn([rgPath, ...args], {
213
- stdin: "ignore",
214
- stdout: "pipe",
215
- stderr: "pipe",
216
- });
231
+ const stopChild = (dueToLimit: boolean = false) => {
232
+ killedDueToLimit = dueToLimit;
233
+ child.kill();
234
+ };
235
+
236
+ using signalScope = new ScopeSignal(signal ? { signal } : undefined);
237
+ signalScope.catch(() => {
238
+ aborted = true;
239
+ stopChild();
240
+ });
241
+
242
+ // For simple output modes (files_with_matches, count), process text directly
243
+ if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") {
244
+ const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
245
+ const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
246
+ const decoder = new TextDecoder();
247
+ let stdout = "";
248
+
249
+ await Promise.all([
250
+ (async () => {
251
+ while (true) {
252
+ const { done, value } = await stdoutReader.read();
253
+ if (done) break;
254
+ stdout += decoder.decode(value, { stream: true });
255
+ }
256
+ })(),
257
+ (async () => {
258
+ while (true) {
259
+ const { done, value } = await stderrReader.read();
260
+ if (done) break;
261
+ stderr += decoder.decode(value, { stream: true });
262
+ }
263
+ })(),
264
+ ]);
265
+
266
+ const exitCode = await child.exited;
267
+
268
+ if (aborted) {
269
+ throw new Error("Operation aborted");
270
+ }
271
+
272
+ if (exitCode !== 0 && exitCode !== 1) {
273
+ const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`;
274
+ throw new Error(errorMsg);
275
+ }
276
+
277
+ const lines = stdout
278
+ .trim()
279
+ .split("\n")
280
+ .filter((line) => line.length > 0);
281
+
282
+ if (lines.length === 0) {
283
+ return {
284
+ content: [{ type: "text", text: "No matches found" }],
285
+ details: {
286
+ scopePath,
287
+ matchCount: 0,
288
+ fileCount: 0,
289
+ files: [],
290
+ mode: effectiveOutputMode,
291
+ truncated: false,
292
+ },
293
+ };
294
+ }
295
+
296
+ // Apply offset and headLimit
297
+ let processedLines = lines;
298
+ if (effectiveOffset > 0) {
299
+ processedLines = processedLines.slice(effectiveOffset);
300
+ }
301
+ if (hasHeadLimit) {
302
+ processedLines = processedLines.slice(0, headLimit);
303
+ }
304
+
305
+ let simpleMatchCount = 0;
306
+ let fileCount = 0;
307
+ const simpleFiles = new Set<string>();
308
+ const simpleFileList: string[] = [];
309
+ const simpleFileMatchCounts = new Map<string, number>();
310
+
311
+ const recordSimpleFile = (filePath: string) => {
312
+ const relative = formatPath(filePath);
313
+ if (!simpleFiles.has(relative)) {
314
+ simpleFiles.add(relative);
315
+ simpleFileList.push(relative);
316
+ }
317
+ };
318
+
319
+ // Count mode: ripgrep provides total count per file, so we set directly (not increment)
320
+ const setFileMatchCount = (filePath: string, count: number) => {
321
+ const relative = formatPath(filePath);
322
+ simpleFileMatchCounts.set(relative, count);
323
+ };
324
+
325
+ if (effectiveOutputMode === "files_with_matches") {
326
+ for (const line of lines) {
327
+ recordSimpleFile(line);
328
+ }
329
+ fileCount = simpleFiles.size;
330
+ simpleMatchCount = fileCount;
331
+ } else {
332
+ for (const line of lines) {
333
+ const separatorIndex = line.lastIndexOf(":");
334
+ const filePart = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
335
+ const countPart = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1);
336
+ const count = Number.parseInt(countPart, 10);
337
+ recordSimpleFile(filePart);
338
+ if (!Number.isNaN(count)) {
339
+ simpleMatchCount += count;
340
+ setFileMatchCount(filePart, count);
341
+ }
342
+ }
343
+ fileCount = simpleFiles.size;
344
+ }
217
345
 
218
- let stderr = "";
219
- let matchCount = 0;
220
- let matchLimitReached = false;
221
- let linesTruncated = false;
222
- let aborted = false;
223
- let killedDueToLimit = false;
224
- const outputLines: string[] = [];
225
- const files = new Set<string>();
226
- const fileList: string[] = [];
227
- const fileMatchCounts = new Map<string, number>();
228
-
229
- const recordFile = (filePath: string) => {
230
- const relative = formatPath(filePath);
231
- if (!files.has(relative)) {
232
- files.add(relative);
233
- fileList.push(relative);
346
+ const truncatedByHeadLimit = hasHeadLimit && processedLines.length < lines.length;
347
+
348
+ // For count mode, format as "path:count"
349
+ if (effectiveOutputMode === "count") {
350
+ const formatted = processedLines.map((line) => {
351
+ const separatorIndex = line.lastIndexOf(":");
352
+ const relative = formatPath(separatorIndex === -1 ? line : line.slice(0, separatorIndex));
353
+ const count = separatorIndex === -1 ? "0" : line.slice(separatorIndex + 1);
354
+ return `${relative}:${count}`;
355
+ });
356
+ const output = formatted.join("\n");
357
+ return {
358
+ content: [{ type: "text", text: output }],
359
+ details: {
360
+ scopePath,
361
+ matchCount: simpleMatchCount,
362
+ fileCount,
363
+ files: simpleFileList,
364
+ fileMatches: simpleFileList.map((path) => ({
365
+ path,
366
+ count: simpleFileMatchCounts.get(path) ?? 0,
367
+ })),
368
+ mode: effectiveOutputMode,
369
+ truncated: truncatedByHeadLimit,
370
+ headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
371
+ },
372
+ };
373
+ }
374
+
375
+ // For files_with_matches, format paths
376
+ const formatted = processedLines.map((line) => formatPath(line));
377
+ const output = formatted.join("\n");
378
+ return {
379
+ content: [{ type: "text", text: output }],
380
+ details: {
381
+ scopePath,
382
+ matchCount: simpleMatchCount,
383
+ fileCount,
384
+ files: simpleFileList,
385
+ mode: effectiveOutputMode,
386
+ truncated: truncatedByHeadLimit,
387
+ headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
388
+ },
389
+ };
234
390
  }
235
- };
236
391
 
237
- const recordFileMatch = (filePath: string) => {
238
- const relative = formatPath(filePath);
239
- fileMatchCounts.set(relative, (fileMatchCounts.get(relative) ?? 0) + 1);
240
- };
392
+ // Content mode - existing JSON processing
393
+ const formatBlock = async (filePath: string, lineNumber: number): Promise<string[]> => {
394
+ const relativePath = formatPath(filePath);
395
+ const lines = await getFileLines(filePath);
396
+ if (!lines.length) {
397
+ return [`${relativePath}:${lineNumber}: (unable to read file)`];
398
+ }
241
399
 
242
- const stopChild = (dueToLimit: boolean = false) => {
243
- killedDueToLimit = dueToLimit;
244
- child.kill();
245
- };
400
+ const block: string[] = [];
401
+ const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
402
+ const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
246
403
 
247
- const onAbort = () => {
248
- aborted = true;
249
- stopChild();
250
- };
404
+ for (let current = start; current <= end; current++) {
405
+ const lineText = lines[current - 1] ?? "";
406
+ const sanitized = lineText.replace(/\r/g, "");
407
+ const isMatchLine = current === lineNumber;
251
408
 
252
- if (signal) {
253
- signal.addEventListener("abort", onAbort, { once: true });
254
- }
409
+ const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
410
+ if (wasTruncated) {
411
+ linesTruncated = true;
412
+ }
413
+
414
+ if (isMatchLine) {
415
+ block.push(`${relativePath}:${current}: ${truncatedText}`);
416
+ } else {
417
+ block.push(`${relativePath}-${current}- ${truncatedText}`);
418
+ }
419
+ }
420
+
421
+ return block;
422
+ };
423
+
424
+ const processLine = async (line: string): Promise<void> => {
425
+ if (!line.trim() || matchCount >= effectiveLimit) {
426
+ return;
427
+ }
428
+
429
+ let event: { type: string; data?: { path?: { text?: string }; line_number?: number } };
430
+ try {
431
+ event = JSON.parse(line);
432
+ } catch {
433
+ return;
434
+ }
435
+
436
+ if (event.type === "match") {
437
+ matchCount++;
438
+ const filePath = event.data?.path?.text;
439
+ const lineNumber = event.data?.line_number;
255
440
 
256
- // For simple output modes (files_with_matches, count), process text directly
257
- if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") {
441
+ if (filePath && typeof lineNumber === "number") {
442
+ recordFile(filePath);
443
+ recordFileMatch(filePath);
444
+ const block = await formatBlock(filePath, lineNumber);
445
+ outputLines.push(...block);
446
+ }
447
+
448
+ if (matchCount >= effectiveLimit) {
449
+ matchLimitReached = true;
450
+ stopChild(true);
451
+ }
452
+ }
453
+ };
454
+
455
+ // Read streams using Bun's ReadableStream API
258
456
  const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
259
457
  const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
260
458
  const decoder = new TextDecoder();
261
- let stdout = "";
459
+ let stdoutBuffer = "";
262
460
 
263
461
  await Promise.all([
462
+ // Process stdout line by line
264
463
  (async () => {
265
464
  while (true) {
266
465
  const { done, value } = await stdoutReader.read();
267
466
  if (done) break;
268
- stdout += decoder.decode(value, { stream: true });
467
+
468
+ stdoutBuffer += decoder.decode(value, { stream: true });
469
+ const lines = stdoutBuffer.split("\n");
470
+ // Keep the last incomplete line in the buffer
471
+ stdoutBuffer = lines.pop() ?? "";
472
+
473
+ for (const line of lines) {
474
+ await processLine(line);
475
+ }
476
+ }
477
+ // Process any remaining content
478
+ if (stdoutBuffer.trim()) {
479
+ await processLine(stdoutBuffer);
269
480
  }
270
481
  })(),
482
+ // Collect stderr
271
483
  (async () => {
272
484
  while (true) {
273
485
  const { done, value } = await stderrReader.read();
@@ -279,25 +491,16 @@ export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchem
279
491
 
280
492
  const exitCode = await child.exited;
281
493
 
282
- if (signal) {
283
- signal.removeEventListener("abort", onAbort);
284
- }
285
-
286
494
  if (aborted) {
287
495
  throw new Error("Operation aborted");
288
496
  }
289
497
 
290
- if (exitCode !== 0 && exitCode !== 1) {
498
+ if (!killedDueToLimit && exitCode !== 0 && exitCode !== 1) {
291
499
  const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`;
292
500
  throw new Error(errorMsg);
293
501
  }
294
502
 
295
- const lines = stdout
296
- .trim()
297
- .split("\n")
298
- .filter((line) => line.length > 0);
299
-
300
- if (lines.length === 0) {
503
+ if (matchCount === 0) {
301
504
  return {
302
505
  content: [{ type: "text", text: "No matches found" }],
303
506
  details: {
@@ -311,8 +514,8 @@ export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchem
311
514
  };
312
515
  }
313
516
 
314
- // Apply offset and headLimit
315
- let processedLines = lines;
517
+ // Apply offset and headLimit to output lines
518
+ let processedLines = outputLines;
316
519
  if (effectiveOffset > 0) {
317
520
  processedLines = processedLines.slice(effectiveOffset);
318
521
  }
@@ -320,278 +523,55 @@ export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchem
320
523
  processedLines = processedLines.slice(0, headLimit);
321
524
  }
322
525
 
323
- let simpleMatchCount = 0;
324
- let fileCount = 0;
325
- const simpleFiles = new Set<string>();
326
- const simpleFileList: string[] = [];
327
- const simpleFileMatchCounts = new Map<string, number>();
328
-
329
- const recordSimpleFile = (filePath: string) => {
330
- const relative = formatPath(filePath);
331
- if (!simpleFiles.has(relative)) {
332
- simpleFiles.add(relative);
333
- simpleFileList.push(relative);
334
- }
335
- };
336
-
337
- const recordSimpleFileMatch = (filePath: string, count: number) => {
338
- const relative = formatPath(filePath);
339
- simpleFileMatchCounts.set(relative, count);
340
- };
341
-
342
- if (effectiveOutputMode === "files_with_matches") {
343
- for (const line of lines) {
344
- recordSimpleFile(line);
345
- }
346
- fileCount = simpleFiles.size;
347
- simpleMatchCount = fileCount;
348
- } else {
349
- for (const line of lines) {
350
- const separatorIndex = line.lastIndexOf(":");
351
- const filePart = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
352
- const countPart = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1);
353
- const count = Number.parseInt(countPart, 10);
354
- recordSimpleFile(filePart);
355
- if (!Number.isNaN(count)) {
356
- simpleMatchCount += count;
357
- recordSimpleFileMatch(filePart, count);
358
- }
359
- }
360
- fileCount = simpleFiles.size;
361
- }
362
-
363
- const truncatedByHeadLimit = hasHeadLimit && processedLines.length < lines.length;
364
-
365
- // For count mode, format as "path:count"
366
- if (effectiveOutputMode === "count") {
367
- const formatted = processedLines.map((line) => {
368
- const separatorIndex = line.lastIndexOf(":");
369
- const relative = formatPath(separatorIndex === -1 ? line : line.slice(0, separatorIndex));
370
- const count = separatorIndex === -1 ? "0" : line.slice(separatorIndex + 1);
371
- return `${relative}:${count}`;
372
- });
373
- const output = formatted.join("\n");
374
- return {
375
- content: [{ type: "text", text: output }],
376
- details: {
377
- scopePath,
378
- matchCount: simpleMatchCount,
379
- fileCount,
380
- files: simpleFileList,
381
- fileMatches: simpleFileList.map((path) => ({
382
- path,
383
- count: simpleFileMatchCounts.get(path) ?? 0,
384
- })),
385
- mode: effectiveOutputMode,
386
- truncated: truncatedByHeadLimit,
387
- headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
388
- },
389
- };
390
- }
391
-
392
- // For files_with_matches, format paths
393
- const formatted = processedLines.map((line) => formatPath(line));
394
- const output = formatted.join("\n");
395
- return {
396
- content: [{ type: "text", text: output }],
397
- details: {
398
- scopePath,
399
- matchCount: simpleMatchCount,
400
- fileCount,
401
- files: simpleFileList,
402
- mode: effectiveOutputMode,
403
- truncated: truncatedByHeadLimit,
404
- headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
405
- },
526
+ // Apply byte truncation (no line limit since we already have match limit)
527
+ const rawOutput = processedLines.join("\n");
528
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
529
+
530
+ let output = truncation.content;
531
+ const truncatedByHeadLimit = hasHeadLimit && processedLines.length < outputLines.length;
532
+ const details: GrepToolDetails = {
533
+ scopePath,
534
+ matchCount,
535
+ fileCount: files.size,
536
+ files: fileList,
537
+ fileMatches: fileList.map((path) => ({
538
+ path,
539
+ count: fileMatchCounts.get(path) ?? 0,
540
+ })),
541
+ mode: effectiveOutputMode,
542
+ truncated: matchLimitReached || truncation.truncated || truncatedByHeadLimit,
543
+ headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
406
544
  };
407
- }
408
-
409
- // Content mode - existing JSON processing
410
- const formatBlock = (filePath: string, lineNumber: number): string[] => {
411
- const relativePath = formatPath(filePath);
412
- const lines = getFileLines(filePath);
413
- if (!lines.length) {
414
- return [`${relativePath}:${lineNumber}: (unable to read file)`];
415
- }
416
-
417
- const block: string[] = [];
418
- const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
419
- const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
420
545
 
421
- for (let current = start; current <= end; current++) {
422
- const lineText = lines[current - 1] ?? "";
423
- const sanitized = lineText.replace(/\r/g, "");
424
- const isMatchLine = current === lineNumber;
546
+ // Build notices
547
+ const notices: string[] = [];
425
548
 
426
- const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
427
- if (wasTruncated) {
428
- linesTruncated = true;
429
- }
430
-
431
- if (isMatchLine) {
432
- block.push(`${relativePath}:${current}: ${truncatedText}`);
433
- } else {
434
- block.push(`${relativePath}-${current}- ${truncatedText}`);
435
- }
549
+ if (matchLimitReached) {
550
+ notices.push(
551
+ `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
552
+ );
553
+ details.matchLimitReached = effectiveLimit;
436
554
  }
437
555
 
438
- return block;
439
- };
440
-
441
- const processLine = (line: string) => {
442
- if (!line.trim() || matchCount >= effectiveLimit) {
443
- return;
556
+ if (truncation.truncated) {
557
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
558
+ details.truncation = truncation;
444
559
  }
445
560
 
446
- let event: { type: string; data?: { path?: { text?: string }; line_number?: number } };
447
- try {
448
- event = JSON.parse(line);
449
- } catch {
450
- return;
561
+ if (linesTruncated) {
562
+ notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`);
563
+ details.linesTruncated = true;
451
564
  }
452
565
 
453
- if (event.type === "match") {
454
- matchCount++;
455
- const filePath = event.data?.path?.text;
456
- const lineNumber = event.data?.line_number;
457
-
458
- if (filePath && typeof lineNumber === "number") {
459
- recordFile(filePath);
460
- recordFileMatch(filePath);
461
- outputLines.push(...formatBlock(filePath, lineNumber));
462
- }
463
-
464
- if (matchCount >= effectiveLimit) {
465
- matchLimitReached = true;
466
- stopChild(true);
467
- }
566
+ if (notices.length > 0) {
567
+ output += `\n\n[${notices.join(". ")}]`;
468
568
  }
469
- };
470
-
471
- // Read streams using Bun's ReadableStream API
472
- const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
473
- const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
474
- const decoder = new TextDecoder();
475
- let stdoutBuffer = "";
476
-
477
- await Promise.all([
478
- // Process stdout line by line
479
- (async () => {
480
- while (true) {
481
- const { done, value } = await stdoutReader.read();
482
- if (done) break;
483
-
484
- stdoutBuffer += decoder.decode(value, { stream: true });
485
- const lines = stdoutBuffer.split("\n");
486
- // Keep the last incomplete line in the buffer
487
- stdoutBuffer = lines.pop() ?? "";
488
-
489
- for (const line of lines) {
490
- processLine(line);
491
- }
492
- }
493
- // Process any remaining content
494
- if (stdoutBuffer.trim()) {
495
- processLine(stdoutBuffer);
496
- }
497
- })(),
498
- // Collect stderr
499
- (async () => {
500
- while (true) {
501
- const { done, value } = await stderrReader.read();
502
- if (done) break;
503
- stderr += decoder.decode(value, { stream: true });
504
- }
505
- })(),
506
- ]);
507
569
 
508
- const exitCode = await child.exited;
509
-
510
- // Cleanup
511
- if (signal) {
512
- signal.removeEventListener("abort", onAbort);
513
- }
514
-
515
- if (aborted) {
516
- throw new Error("Operation aborted");
517
- }
518
-
519
- if (!killedDueToLimit && exitCode !== 0 && exitCode !== 1) {
520
- const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`;
521
- throw new Error(errorMsg);
522
- }
523
-
524
- if (matchCount === 0) {
525
570
  return {
526
- content: [{ type: "text", text: "No matches found" }],
527
- details: {
528
- scopePath,
529
- matchCount: 0,
530
- fileCount: 0,
531
- files: [],
532
- mode: effectiveOutputMode,
533
- truncated: false,
534
- },
571
+ content: [{ type: "text", text: output }],
572
+ details: Object.keys(details).length > 0 ? details : undefined,
535
573
  };
536
- }
537
-
538
- // Apply offset and headLimit to output lines
539
- let processedLines = outputLines;
540
- if (effectiveOffset > 0) {
541
- processedLines = processedLines.slice(effectiveOffset);
542
- }
543
- if (hasHeadLimit) {
544
- processedLines = processedLines.slice(0, headLimit);
545
- }
546
-
547
- // Apply byte truncation (no line limit since we already have match limit)
548
- const rawOutput = processedLines.join("\n");
549
- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
550
-
551
- let output = truncation.content;
552
- const truncatedByHeadLimit = hasHeadLimit && processedLines.length < outputLines.length;
553
- const details: GrepToolDetails = {
554
- scopePath,
555
- matchCount,
556
- fileCount: files.size,
557
- files: fileList,
558
- fileMatches: fileList.map((path) => ({
559
- path,
560
- count: fileMatchCounts.get(path) ?? 0,
561
- })),
562
- mode: effectiveOutputMode,
563
- truncated: matchLimitReached || truncation.truncated || truncatedByHeadLimit,
564
- headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
565
- };
566
-
567
- // Build notices
568
- const notices: string[] = [];
569
-
570
- if (matchLimitReached) {
571
- notices.push(
572
- `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
573
- );
574
- details.matchLimitReached = effectiveLimit;
575
- }
576
-
577
- if (truncation.truncated) {
578
- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
579
- details.truncation = truncation;
580
- }
581
-
582
- if (linesTruncated) {
583
- notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`);
584
- details.linesTruncated = true;
585
- }
586
-
587
- if (notices.length > 0) {
588
- output += `\n\n[${notices.join(". ")}]`;
589
- }
590
-
591
- return {
592
- content: [{ type: "text", text: output }],
593
- details: Object.keys(details).length > 0 ? details : undefined,
594
- };
574
+ });
595
575
  },
596
576
  };
597
577
  }
@@ -619,7 +599,8 @@ const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
619
599
 
620
600
  export const grepToolRenderer = {
621
601
  renderCall(args: GrepRenderArgs, uiTheme: Theme): Component {
622
- const label = uiTheme.fg("toolTitle", uiTheme.bold("Grep"));
602
+ const ui = createToolUIKit(uiTheme);
603
+ const label = ui.title("Grep");
623
604
  let text = `${label} ${uiTheme.fg("accent", args.pattern || "?")}`;
624
605
 
625
606
  const meta: string[] = [];
@@ -637,7 +618,7 @@ export const grepToolRenderer = {
637
618
  if (args.context !== undefined) meta.push(`context:${args.context}`);
638
619
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
639
620
 
640
- text += formatMeta(meta, uiTheme);
621
+ text += ui.meta(meta);
641
622
 
642
623
  return new Text(text, 0, 0);
643
624
  },
@@ -647,10 +628,11 @@ export const grepToolRenderer = {
647
628
  { expanded }: RenderResultOptions,
648
629
  uiTheme: Theme,
649
630
  ): Component {
631
+ const ui = createToolUIKit(uiTheme);
650
632
  const details = result.details;
651
633
 
652
634
  if (details?.error) {
653
- return new Text(formatErrorMessage(details.error, uiTheme), 0, 0);
635
+ return new Text(ui.errorMessage(details.error), 0, 0);
654
636
  }
655
637
 
656
638
  const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined;
@@ -658,7 +640,7 @@ export const grepToolRenderer = {
658
640
  if (!hasDetailedData) {
659
641
  const textContent = result.content?.find((c) => c.type === "text")?.text;
660
642
  if (!textContent || textContent === "No matches found") {
661
- return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0);
643
+ return new Text(ui.emptyMessage("No matches found"), 0, 0);
662
644
  }
663
645
 
664
646
  const lines = textContent.split("\n").filter((line) => line.trim() !== "");
@@ -668,8 +650,8 @@ export const grepToolRenderer = {
668
650
  const hasMore = remaining > 0;
669
651
 
670
652
  const icon = uiTheme.styledSymbol("status.success", "success");
671
- const summary = formatCount("item", lines.length);
672
- const expandHint = formatExpandHint(expanded, hasMore, uiTheme);
653
+ const summary = ui.count("item", lines.length);
654
+ const expandHint = ui.expandHint(expanded, hasMore);
673
655
  let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
674
656
 
675
657
  for (let i = 0; i < displayLines.length; i++) {
@@ -679,7 +661,7 @@ export const grepToolRenderer = {
679
661
  }
680
662
 
681
663
  if (remaining > 0) {
682
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", formatMoreItems(remaining, "item", uiTheme))}`;
664
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "item"))}`;
683
665
  }
684
666
 
685
667
  return new Text(text, 0, 0);
@@ -692,25 +674,25 @@ export const grepToolRenderer = {
692
674
  const files = details?.files ?? [];
693
675
 
694
676
  if (matchCount === 0) {
695
- return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0);
677
+ return new Text(ui.emptyMessage("No matches found"), 0, 0);
696
678
  }
697
679
 
698
680
  const icon = uiTheme.styledSymbol("status.success", "success");
699
681
  const summaryParts =
700
682
  mode === "files_with_matches"
701
- ? [formatCount("file", fileCount)]
702
- : [formatCount("match", matchCount), formatCount("file", fileCount)];
683
+ ? [ui.count("file", fileCount)]
684
+ : [ui.count("match", matchCount), ui.count("file", fileCount)];
703
685
  const summaryText = summaryParts.join(uiTheme.sep.dot);
704
- const scopeLabel = formatScope(details?.scopePath, uiTheme);
686
+ const scopeLabel = ui.scope(details?.scopePath);
705
687
 
706
688
  const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length
707
689
  ? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count }))
708
690
  : files.map((path) => ({ path }));
709
691
  const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, COLLAPSED_LIST_LIMIT);
710
692
  const hasMoreFiles = fileEntries.length > maxFiles;
711
- const expandHint = formatExpandHint(expanded, hasMoreFiles, uiTheme);
693
+ const expandHint = ui.expandHint(expanded, hasMoreFiles);
712
694
 
713
- let text = `${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, uiTheme)}${scopeLabel}${expandHint}`;
695
+ let text = `${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`;
714
696
 
715
697
  const truncationReasons: string[] = [];
716
698
  if (details?.matchLimitReached) {
@@ -750,7 +732,7 @@ export const grepToolRenderer = {
750
732
  const moreFilesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last;
751
733
  text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg(
752
734
  "muted",
753
- formatMoreItems(fileEntries.length - maxFiles, "file", uiTheme),
735
+ ui.moreItems(fileEntries.length - maxFiles, "file"),
754
736
  )}`;
755
737
  }
756
738
  }