@oh-my-pi/pi-coding-agent 15.1.8 → 15.2.1

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 (71) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/types/cli/update-cli.d.ts +18 -0
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/eval/py/kernel.d.ts +6 -0
  5. package/dist/types/goals/state.d.ts +1 -1
  6. package/dist/types/goals/tools/goal-tool.d.ts +4 -0
  7. package/dist/types/hashline/parser.d.ts +6 -2
  8. package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
  9. package/dist/types/main.d.ts +25 -1
  10. package/dist/types/modes/theme/shimmer.d.ts +27 -0
  11. package/dist/types/slash-commands/helpers/format.d.ts +4 -1
  12. package/dist/types/tools/ast-edit.d.ts +3 -0
  13. package/dist/types/tools/ast-grep.d.ts +3 -0
  14. package/dist/types/tools/find.d.ts +3 -0
  15. package/dist/types/tools/search.d.ts +3 -0
  16. package/dist/types/tui/file-list.d.ts +6 -0
  17. package/dist/types/tui/hyperlink.d.ts +42 -0
  18. package/dist/types/tui/index.d.ts +1 -0
  19. package/dist/types/utils/tool-choice.d.ts +2 -1
  20. package/dist/types/web/search/providers/utils.d.ts +27 -1
  21. package/package.json +7 -7
  22. package/src/cli/update-cli.ts +78 -36
  23. package/src/config/model-registry.ts +23 -12
  24. package/src/config/settings-schema.ts +12 -0
  25. package/src/config/settings.ts +28 -5
  26. package/src/edit/renderer.ts +5 -3
  27. package/src/eval/py/executor.ts +12 -1
  28. package/src/eval/py/kernel.ts +24 -8
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  30. package/src/goals/runtime.ts +9 -3
  31. package/src/goals/state.ts +1 -1
  32. package/src/goals/tools/goal-tool.ts +12 -2
  33. package/src/hashline/diff.ts +1 -1
  34. package/src/hashline/execute.ts +2 -2
  35. package/src/hashline/parser.ts +87 -12
  36. package/src/internal-urls/memory-protocol.ts +1 -1
  37. package/src/main.ts +13 -2
  38. package/src/modes/interactive-mode.ts +29 -1
  39. package/src/modes/theme/shimmer.ts +79 -0
  40. package/src/prompts/agents/oracle.md +15 -16
  41. package/src/prompts/tools/goal.md +7 -2
  42. package/src/session/agent-session.ts +12 -75
  43. package/src/slash-commands/helpers/format.ts +23 -3
  44. package/src/task/executor.ts +115 -19
  45. package/src/tools/ast-edit.ts +39 -6
  46. package/src/tools/ast-grep.ts +38 -6
  47. package/src/tools/find.ts +13 -2
  48. package/src/tools/read.ts +46 -6
  49. package/src/tools/search.ts +447 -265
  50. package/src/tui/file-list.ts +10 -2
  51. package/src/tui/hyperlink.ts +126 -0
  52. package/src/tui/index.ts +1 -0
  53. package/src/utils/tool-choice.ts +7 -7
  54. package/src/web/kagi.ts +2 -2
  55. package/src/web/parallel.ts +3 -3
  56. package/src/web/search/index.ts +20 -9
  57. package/src/web/search/providers/anthropic.ts +4 -2
  58. package/src/web/search/providers/brave.ts +4 -2
  59. package/src/web/search/providers/codex.ts +4 -1
  60. package/src/web/search/providers/exa.ts +4 -1
  61. package/src/web/search/providers/gemini.ts +4 -1
  62. package/src/web/search/providers/jina.ts +4 -2
  63. package/src/web/search/providers/kagi.ts +5 -1
  64. package/src/web/search/providers/kimi.ts +4 -2
  65. package/src/web/search/providers/parallel.ts +5 -1
  66. package/src/web/search/providers/perplexity.ts +7 -2
  67. package/src/web/search/providers/searxng.ts +4 -1
  68. package/src/web/search/providers/synthetic.ts +4 -2
  69. package/src/web/search/providers/tavily.ts +4 -2
  70. package/src/web/search/providers/utils.ts +63 -1
  71. package/src/web/search/providers/zai.ts +4 -2
@@ -1,3 +1,5 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
1
3
  import * as path from "node:path";
2
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
5
  import { type GrepMatch, GrepOutputMode, type GrepResult, grep } from "@oh-my-pi/pi-natives";
@@ -10,14 +12,20 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
12
  import type { Theme } from "../modes/theme/theme";
11
13
  import searchDescription from "../prompts/tools/search.md" with { type: "text" };
12
14
  import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
13
- import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
15
+ import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
16
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
17
  import type { ToolSession } from ".";
18
+ import {
19
+ type ArchiveReader,
20
+ type ExtractedArchiveFile,
21
+ openArchive,
22
+ parseArchivePathCandidates,
23
+ } from "./archive-reader";
16
24
  import { createFileRecorder, formatResultPath } from "./file-recorder";
17
25
  import { formatGroupedFiles } from "./grouped-file-output";
18
26
  import { formatMatchLine } from "./match-line-format";
19
27
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
20
- import { resolveToolSearchScope } from "./path-utils";
28
+ import { resolveReadPath, resolveToolSearchScope } from "./path-utils";
21
29
  import {
22
30
  createCachedComponent,
23
31
  formatCodeFrameLine,
@@ -86,6 +94,96 @@ function containsTopLevelComma(entry: string): boolean {
86
94
  return false;
87
95
  }
88
96
 
97
+ /**
98
+ * Pre-resolve any `paths` entries that point at a member inside an archive
99
+ * (e.g. `bundle.zip:src/foo.ts`, `release.tar.gz:notes.md`). Native grep
100
+ * cannot read archive members, so we materialize each text member to a
101
+ * temp scratch file and substitute that path into the search inputs. After
102
+ * grep returns, callers remap `match.path` back to the original
103
+ * `archive:member` selector so it round-trips through the `read` tool.
104
+ *
105
+ * Returns the rewritten paths array (same length/order as input), a map
106
+ * from absolute scratch path → original selector, a list of entries we
107
+ * could not materialize (binary member, missing archive, etc.), and a
108
+ * cleanup hook the caller MUST invoke in a `finally`.
109
+ */
110
+ async function resolveArchiveSearchPaths(
111
+ paths: string[],
112
+ cwd: string,
113
+ ): Promise<{
114
+ resolvedPaths: string[];
115
+ displayMap: Map<string, string>;
116
+ displaySet: Set<string>;
117
+ unreadable: string[];
118
+ cleanup: () => Promise<void>;
119
+ }> {
120
+ const resolvedPaths = paths.slice();
121
+ const displayMap = new Map<string, string>();
122
+ const displaySet = new Set<string>();
123
+ const unreadable: string[] = [];
124
+ let tempDir: string | undefined;
125
+ const archiveCache = new Map<string, ArchiveReader>();
126
+
127
+ for (let idx = 0; idx < paths.length; idx++) {
128
+ const entry = paths[idx];
129
+ const candidates = parseArchivePathCandidates(entry);
130
+ // Longest archive prefix first; we want the one whose member portion is non-empty.
131
+ const member = candidates.find(c => c.subPath !== "" && c.archivePath !== entry);
132
+ if (!member) continue;
133
+
134
+ const archiveAbs = resolveReadPath(member.archivePath, cwd);
135
+ let archive = archiveCache.get(archiveAbs);
136
+ if (!archive) {
137
+ try {
138
+ archive = await openArchive(archiveAbs);
139
+ } catch (err) {
140
+ unreadable.push(`${entry} (cannot open archive: ${(err as Error).message})`);
141
+ continue;
142
+ }
143
+ archiveCache.set(archiveAbs, archive);
144
+ }
145
+
146
+ let extracted: ExtractedArchiveFile;
147
+ try {
148
+ extracted = await archive.readFile(member.subPath);
149
+ } catch (err) {
150
+ unreadable.push(`${entry} (${(err as Error).message})`);
151
+ continue;
152
+ }
153
+ // UTF-8 only — binary members would just produce noise through ripgrep.
154
+ if (extracted.bytes.some(byte => byte === 0)) {
155
+ unreadable.push(`${entry} (binary archive entry)`);
156
+ continue;
157
+ }
158
+ let text: string;
159
+ try {
160
+ text = new TextDecoder("utf-8", { fatal: true }).decode(extracted.bytes);
161
+ } catch {
162
+ unreadable.push(`${entry} (non-UTF-8 archive entry)`);
163
+ continue;
164
+ }
165
+
166
+ if (!tempDir) {
167
+ tempDir = await mkdtemp(path.join(tmpdir(), "omp-search-archive-"));
168
+ }
169
+ // Per-entry filename keeps the scratch path unique even when two selectors
170
+ // resolve to members with the same basename.
171
+ const safeBase = path.basename(member.subPath).replace(/[^\w.-]+/g, "_") || "entry";
172
+ const tempPath = path.join(tempDir, `${idx}-${safeBase}`);
173
+ await writeFile(tempPath, text);
174
+ resolvedPaths[idx] = tempPath;
175
+ displayMap.set(tempPath, entry);
176
+ displaySet.add(entry);
177
+ }
178
+
179
+ const cleanup = async () => {
180
+ if (tempDir) {
181
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
182
+ }
183
+ };
184
+ return { resolvedPaths, displayMap, displaySet, unreadable, cleanup };
185
+ }
186
+
89
187
  export interface SearchToolDetails {
90
188
  truncation?: TruncationResult;
91
189
  fileLimitReached?: number;
@@ -103,6 +201,9 @@ export interface SearchToolDetails {
103
201
  * `result.text` lines but uses a `│` gutter and `*` to mark match lines (vs space for
104
202
  * context). The TUI uses this directly so it never parses model-facing hashline anchors. */
105
203
  displayContent?: string;
204
+ /** Absolute base directory used during search. Used by the renderer to resolve
205
+ * display-relative paths to absolute paths for OSC 8 hyperlinks. */
206
+ searchPath?: string;
106
207
  /** User-supplied paths whose base directory was missing on disk. The tool
107
208
  * skipped these and continued with the surviving entries; surfaced as a
108
209
  * non-fatal warning in the renderer and in the model-facing text. */
@@ -152,62 +253,118 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
152
253
  throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]');
153
254
  }
154
255
  }
155
- const normalizedContextBefore = this.session.settings.get("search.contextBefore");
156
- const normalizedContextAfter = this.session.settings.get("search.contextAfter");
157
- const ignoreCase = i ?? false;
158
- const useGitignore = gitignore ?? true;
159
- const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
160
- const effectiveMultiline = patternHasNewline;
161
-
162
- const scope = await resolveToolSearchScope({
163
- rawPaths: paths,
164
- cwd: this.session.cwd,
165
- internalUrlAction: "search",
166
- trackImmutableSources: true,
167
- surfaceExactFilePaths: true,
168
- multipathStatHint: " (`paths` entries must each exist relative to cwd)",
169
- });
170
256
  const {
171
- searchPath,
172
- scopePath,
173
- isDirectory,
174
- multiTargets,
175
- exactFilePaths,
176
- missingPaths,
177
- immutableSourcePaths,
178
- } = scope;
179
- if (missingPaths.length > 0 && missingPaths.length === paths.length) {
180
- throw new ToolError(`Path not found: ${missingPaths.join(", ")}; pass each path as its own array element`);
181
- }
182
- const { globFilter } = scope;
183
- const baseDisplayMode = resolveFileDisplayMode(this.session);
184
- const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
185
-
186
- const effectiveOutputMode = GrepOutputMode.Content;
187
- // Multi-scope = more than one file may match. We fetch up to
188
- // INTERNAL_TOTAL_CAP matches from native grep, then in JS group by
189
- // file, apply a per-file cap (so one hot file doesn't crowd the
190
- // window), and round-robin emit from up to DEFAULT_FILE_LIMIT files.
191
- const isMultiScope = isDirectory || Boolean(exactFilePaths) || Boolean(multiTargets);
192
- const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES;
193
-
194
- // Run grep
195
- let result: GrepResult;
257
+ resolvedPaths,
258
+ displayMap: archiveDisplayMap,
259
+ displaySet: archiveDisplaySet,
260
+ unreadable: archiveUnreadable,
261
+ cleanup: cleanupArchiveScratch,
262
+ } = await resolveArchiveSearchPaths(paths, this.session.cwd);
196
263
  try {
197
- if (exactFilePaths || multiTargets) {
198
- const matches: GrepMatch[] = [];
199
- let limitReached = false;
200
- let totalMatches = 0;
201
- let filesSearched = 0;
202
- const targets = exactFilePaths
203
- ? exactFilePaths.map(filePath => ({ basePath: filePath, glob: undefined as string | undefined }))
204
- : (multiTargets ?? []);
205
- for (const target of targets) {
206
- const targetResult = await grep(
264
+ if (archiveUnreadable.length > 0 && resolvedPaths.length === archiveUnreadable.length) {
265
+ // All inputs were archive selectors we couldn't materialize; surface the
266
+ // reason instead of a downstream "path not found" from the scope resolver.
267
+ throw new ToolError(
268
+ `Cannot search archive member(s): ${archiveUnreadable.join(", ")}. ` +
269
+ `Read the file directly with \`read <archive>:<member>\` and grep the returned content, ` +
270
+ `or pass a UTF-8 text member.`,
271
+ );
272
+ }
273
+ const normalizedContextBefore = this.session.settings.get("search.contextBefore");
274
+ const normalizedContextAfter = this.session.settings.get("search.contextAfter");
275
+ const ignoreCase = i ?? false;
276
+ const useGitignore = gitignore ?? true;
277
+ const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
278
+ const effectiveMultiline = patternHasNewline;
279
+
280
+ const scope = await resolveToolSearchScope({
281
+ rawPaths: resolvedPaths,
282
+ cwd: this.session.cwd,
283
+ internalUrlAction: "search",
284
+ trackImmutableSources: true,
285
+ surfaceExactFilePaths: true,
286
+ multipathStatHint: " (`paths` entries must each exist relative to cwd)",
287
+ });
288
+ const { searchPath, isDirectory, multiTargets, exactFilePaths, missingPaths, immutableSourcePaths } = scope;
289
+ // When the only input was an archive selector, surface that selector instead
290
+ // of the temp scratch path the resolver substituted in.
291
+ const scopePath =
292
+ resolvedPaths.length === 1 && archiveDisplayMap.get(searchPath)
293
+ ? (archiveDisplayMap.get(searchPath) as string)
294
+ : scope.scopePath;
295
+ if (missingPaths.length > 0 && missingPaths.length === resolvedPaths.length) {
296
+ const archiveHint =
297
+ archiveUnreadable.length > 0
298
+ ? ` (archive members were not searchable: ${archiveUnreadable.join(", ")})`
299
+ : "";
300
+ throw new ToolError(
301
+ `Path not found: ${missingPaths.join(", ")}; pass each path as its own array element${archiveHint}`,
302
+ );
303
+ }
304
+ const { globFilter } = scope;
305
+ const baseDisplayMode = resolveFileDisplayMode(this.session);
306
+ const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
307
+
308
+ const effectiveOutputMode = GrepOutputMode.Content;
309
+ // Multi-scope = more than one file may match. We fetch up to
310
+ // INTERNAL_TOTAL_CAP matches from native grep, then in JS group by
311
+ // file, apply a per-file cap (so one hot file doesn't crowd the
312
+ // window), and round-robin emit from up to DEFAULT_FILE_LIMIT files.
313
+ const isMultiScope = isDirectory || Boolean(exactFilePaths) || Boolean(multiTargets);
314
+ const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES;
315
+
316
+ // Run grep
317
+ let result: GrepResult;
318
+ try {
319
+ if (exactFilePaths || multiTargets) {
320
+ const matches: GrepMatch[] = [];
321
+ let limitReached = false;
322
+ let totalMatches = 0;
323
+ let filesSearched = 0;
324
+ const targets = exactFilePaths
325
+ ? exactFilePaths.map(filePath => ({ basePath: filePath, glob: undefined as string | undefined }))
326
+ : (multiTargets ?? []);
327
+ for (const target of targets) {
328
+ const targetResult = await grep(
329
+ {
330
+ pattern: normalizedPattern,
331
+ path: target.basePath,
332
+ glob: target.glob,
333
+ ignoreCase,
334
+ multiline: effectiveMultiline,
335
+ hidden: true,
336
+ gitignore: useGitignore,
337
+ cache: false,
338
+ maxCount: INTERNAL_TOTAL_CAP,
339
+ contextBefore: normalizedContextBefore,
340
+ contextAfter: normalizedContextAfter,
341
+ maxColumns: DEFAULT_MAX_COLUMN,
342
+ mode: effectiveOutputMode,
343
+ },
344
+ undefined,
345
+ );
346
+ limitReached = limitReached || Boolean(targetResult.limitReached);
347
+ totalMatches += targetResult.totalMatches;
348
+ filesSearched += targetResult.filesSearched;
349
+ for (const match of targetResult.matches) {
350
+ const absolute = path.resolve(target.basePath, match.path);
351
+ const rebased = path.relative(searchPath, absolute).replace(/\\/g, "/");
352
+ matches.push({ ...match, path: rebased });
353
+ }
354
+ }
355
+ result = {
356
+ matches,
357
+ totalMatches: exactFilePaths ? matches.length : totalMatches,
358
+ filesWithMatches: new Set(matches.map(match => match.path)).size,
359
+ filesSearched: exactFilePaths ? exactFilePaths.length : filesSearched,
360
+ limitReached,
361
+ };
362
+ } else {
363
+ result = await grep(
207
364
  {
208
365
  pattern: normalizedPattern,
209
- path: target.basePath,
210
- glob: target.glob,
366
+ path: searchPath,
367
+ glob: globFilter,
211
368
  ignoreCase,
212
369
  multiline: effectiveMultiline,
213
370
  hidden: true,
@@ -221,232 +378,225 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
221
378
  },
222
379
  undefined,
223
380
  );
224
- limitReached = limitReached || Boolean(targetResult.limitReached);
225
- totalMatches += targetResult.totalMatches;
226
- filesSearched += targetResult.filesSearched;
227
- for (const match of targetResult.matches) {
228
- const absolute = path.resolve(target.basePath, match.path);
229
- const rebased = path.relative(searchPath, absolute).replace(/\\/g, "/");
230
- matches.push({ ...match, path: rebased });
231
- }
232
381
  }
233
- result = {
234
- matches,
235
- totalMatches: exactFilePaths ? matches.length : totalMatches,
236
- filesWithMatches: new Set(matches.map(match => match.path)).size,
237
- filesSearched: exactFilePaths ? exactFilePaths.length : filesSearched,
238
- limitReached,
239
- };
240
- } else {
241
- result = await grep(
242
- {
243
- pattern: normalizedPattern,
244
- path: searchPath,
245
- glob: globFilter,
246
- ignoreCase,
247
- multiline: effectiveMultiline,
248
- hidden: true,
249
- gitignore: useGitignore,
250
- cache: false,
251
- maxCount: INTERNAL_TOTAL_CAP,
252
- contextBefore: normalizedContextBefore,
253
- contextAfter: normalizedContextAfter,
254
- maxColumns: DEFAULT_MAX_COLUMN,
255
- mode: effectiveOutputMode,
256
- },
257
- undefined,
258
- );
382
+ } catch (err) {
383
+ if (err instanceof Error && err.message.startsWith("regex parse error")) {
384
+ throw new ToolError(err.message);
385
+ }
386
+ throw err;
259
387
  }
260
- } catch (err) {
261
- if (err instanceof Error && err.message.startsWith("regex parse error")) {
262
- throw new ToolError(err.message);
388
+ if (archiveDisplayMap.size > 0) {
389
+ for (const match of result.matches) {
390
+ let abs: string;
391
+ if (match.path === "") abs = searchPath;
392
+ else if (path.isAbsolute(match.path)) abs = match.path;
393
+ else abs = path.resolve(searchPath, match.path);
394
+ const display = archiveDisplayMap.get(abs);
395
+ if (display) match.path = display;
396
+ }
263
397
  }
264
- throw err;
265
- }
266
398
 
267
- const formatPath = (filePath: string): string =>
268
- formatResultPath(filePath, isDirectory, searchPath, this.session.cwd);
269
-
270
- // Group matches by file in encounter order. Detect per-file overflow
271
- // BEFORE truncation so the renderer can surface that a hot file was
272
- // trimmed for diversity.
273
- const fileOrder: string[] = [];
274
- const matchesByPath = new Map<string, GrepMatch[]>();
275
- for (const match of result.matches) {
276
- if (!matchesByPath.has(match.path)) {
277
- fileOrder.push(match.path);
278
- matchesByPath.set(match.path, []);
399
+ const formatPath = (filePath: string): string =>
400
+ archiveDisplaySet.has(filePath)
401
+ ? filePath
402
+ : formatResultPath(filePath, isDirectory, searchPath, this.session.cwd);
403
+
404
+ // Group matches by file in encounter order. Detect per-file overflow
405
+ // BEFORE truncation so the renderer can surface that a hot file was
406
+ // trimmed for diversity.
407
+ const fileOrder: string[] = [];
408
+ const matchesByPath = new Map<string, GrepMatch[]>();
409
+ for (const match of result.matches) {
410
+ if (!matchesByPath.has(match.path)) {
411
+ fileOrder.push(match.path);
412
+ matchesByPath.set(match.path, []);
413
+ }
414
+ matchesByPath.get(match.path)!.push(match);
279
415
  }
280
- matchesByPath.get(match.path)!.push(match);
281
- }
282
- let perFileLimitReached = false;
283
- for (const file of fileOrder) {
284
- const list = matchesByPath.get(file)!;
285
- if (list.length > perFileMatchCap) {
286
- perFileLimitReached = true;
287
- list.length = perFileMatchCap;
416
+ let perFileLimitReached = false;
417
+ for (const file of fileOrder) {
418
+ const list = matchesByPath.get(file)!;
419
+ if (list.length > perFileMatchCap) {
420
+ perFileLimitReached = true;
421
+ list.length = perFileMatchCap;
422
+ }
288
423
  }
289
- }
290
- const totalFiles = fileOrder.length;
291
- // Single-file scopes can't paginate — there is one file by definition.
292
- const canPaginate = isMultiScope;
293
- const skipFiles = canPaginate ? Math.min(normalizedSkip, totalFiles) : 0;
294
- const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + DEFAULT_FILE_LIMIT) : fileOrder;
295
- const fileLimitReached = canPaginate && totalFiles > skipFiles + DEFAULT_FILE_LIMIT;
296
- const selectedMatches: GrepMatch[] = [];
297
- if (windowFiles.length > 0) {
298
- const lists = windowFiles.map(file => matchesByPath.get(file) ?? []);
299
- const cursors = new Array<number>(lists.length).fill(0);
300
- let anyAdded = true;
301
- while (anyAdded) {
302
- anyAdded = false;
303
- for (let i = 0; i < lists.length; i++) {
304
- if (cursors[i] < lists[i].length) {
305
- selectedMatches.push(lists[i][cursors[i]++]);
306
- anyAdded = true;
424
+ const totalFiles = fileOrder.length;
425
+ // Single-file scopes can't paginate — there is one file by definition.
426
+ const canPaginate = isMultiScope;
427
+ const skipFiles = canPaginate ? Math.min(normalizedSkip, totalFiles) : 0;
428
+ const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + DEFAULT_FILE_LIMIT) : fileOrder;
429
+ const fileLimitReached = canPaginate && totalFiles > skipFiles + DEFAULT_FILE_LIMIT;
430
+ const selectedMatches: GrepMatch[] = [];
431
+ if (windowFiles.length > 0) {
432
+ const lists = windowFiles.map(file => matchesByPath.get(file) ?? []);
433
+ const cursors = new Array<number>(lists.length).fill(0);
434
+ let anyAdded = true;
435
+ while (anyAdded) {
436
+ anyAdded = false;
437
+ for (let i = 0; i < lists.length; i++) {
438
+ if (cursors[i] < lists[i].length) {
439
+ selectedMatches.push(lists[i][cursors[i]++]);
440
+ anyAdded = true;
441
+ }
307
442
  }
308
443
  }
309
444
  }
310
- }
311
- const nextSkip = skipFiles + windowFiles.length;
312
- const limitMessage = fileLimitReached
313
- ? `Showing files ${skipFiles + 1}-${nextSkip} of ${totalFiles}. Use skip=${nextSkip} for the next page, or narrow paths/pattern.`
314
- : "";
315
- const { record: recordFile, list: fileList } = createFileRecorder();
316
- const fileMatchCounts = new Map<string, number>();
317
- const missingPathsNote =
318
- missingPaths.length > 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : undefined;
319
- if (selectedMatches.length === 0) {
320
- const details: SearchToolDetails = {
321
- scopePath,
322
- matchCount: 0,
323
- fileCount: 0,
324
- files: [],
325
- truncated: false,
326
- missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
327
- };
328
- const text = missingPathsNote ? `No matches found\n${missingPathsNote}` : "No matches found";
329
- return toolResult(details).text(text).done();
330
- }
331
- const outputLines: string[] = [];
332
- let linesTruncated = false;
333
- const matchesByFile = new Map<string, GrepMatch[]>();
334
- for (const match of selectedMatches) {
335
- const relativePath = formatPath(match.path);
336
- recordFile(relativePath);
337
- if (!matchesByFile.has(relativePath)) {
338
- matchesByFile.set(relativePath, []);
445
+ const nextSkip = skipFiles + windowFiles.length;
446
+ const limitMessage = fileLimitReached
447
+ ? `Showing files ${skipFiles + 1}-${nextSkip} of ${totalFiles}. Use skip=${nextSkip} for the next page, or narrow paths/pattern.`
448
+ : "";
449
+ const { record: recordFile, list: fileList } = createFileRecorder();
450
+ const fileMatchCounts = new Map<string, number>();
451
+ const archiveNote =
452
+ archiveUnreadable.length > 0
453
+ ? `Skipped archive entries (search supports text members only): ${archiveUnreadable.join(", ")}`
454
+ : undefined;
455
+ // Suppress entries we already explained via archiveNote — they would otherwise
456
+ // double up (the unreadable selector also failed the scope's existence check).
457
+ const archiveUnreadablePaths = new Set(archiveUnreadable.map(s => s.replace(/ \(.*\)$/, "")));
458
+ const missingPathsForNote = missingPaths.filter(p => !archiveUnreadablePaths.has(p));
459
+ const missingPathsNote =
460
+ missingPathsForNote.length > 0 ? `Skipped missing paths: ${missingPathsForNote.join(", ")}` : undefined;
461
+ const warningNote =
462
+ [missingPathsNote, archiveNote].filter((s): s is string => Boolean(s)).join("\n") || undefined;
463
+ if (selectedMatches.length === 0) {
464
+ const details: SearchToolDetails = {
465
+ scopePath,
466
+ searchPath,
467
+ matchCount: 0,
468
+ fileCount: 0,
469
+ files: [],
470
+ truncated: false,
471
+ missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
472
+ };
473
+ const text = warningNote ? `No matches found\n${warningNote}` : "No matches found";
474
+ return toolResult(details).text(text).done();
339
475
  }
340
- matchesByFile.get(relativePath)!.push(match);
341
- }
342
- const displayLines: string[] = [];
343
- const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
344
- const modelOut: string[] = [];
345
- const displayOut: string[] = [];
346
- const fileMatches = matchesByFile.get(relativePath) ?? [];
347
- const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
348
- const useHashLines = immutableSourcePaths.has(absoluteFilePath)
349
- ? immutableDisplayMode.hashLines
350
- : baseDisplayMode.hashLines;
351
- const lineNumberWidth = fileMatches.reduce((width, match) => {
352
- let nextWidth = Math.max(width, String(match.lineNumber).length);
353
- for (const ctx of match.contextBefore ?? []) {
354
- nextWidth = Math.max(nextWidth, String(ctx.lineNumber).length);
476
+ const outputLines: string[] = [];
477
+ let linesTruncated = false;
478
+ const matchesByFile = new Map<string, GrepMatch[]>();
479
+ for (const match of selectedMatches) {
480
+ const relativePath = formatPath(match.path);
481
+ recordFile(relativePath);
482
+ if (!matchesByFile.has(relativePath)) {
483
+ matchesByFile.set(relativePath, []);
355
484
  }
356
- for (const ctx of match.contextAfter ?? []) {
357
- nextWidth = Math.max(nextWidth, String(ctx.lineNumber).length);
358
- }
359
- return nextWidth;
360
- }, 0);
361
- const cacheEntries: Array<readonly [number, string]> = [];
362
- let lastEmittedLine: number | undefined;
363
- const gutterPad = " ".repeat(lineNumberWidth + 1);
364
- for (const match of fileMatches) {
365
- const pushLine = (lineNumber: number, line: string, isMatch: boolean, recordable: boolean) => {
366
- if (lastEmittedLine !== undefined && lineNumber > lastEmittedLine + 1) {
367
- modelOut.push("...");
368
- displayOut.push(`${gutterPad}│...`);
485
+ matchesByFile.get(relativePath)!.push(match);
486
+ }
487
+ const displayLines: string[] = [];
488
+ const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
489
+ const modelOut: string[] = [];
490
+ const displayOut: string[] = [];
491
+ const fileMatches = matchesByFile.get(relativePath) ?? [];
492
+ const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
493
+ const useHashLines = immutableSourcePaths.has(absoluteFilePath)
494
+ ? immutableDisplayMode.hashLines
495
+ : baseDisplayMode.hashLines;
496
+ const lineNumberWidth = fileMatches.reduce((width, match) => {
497
+ let nextWidth = Math.max(width, String(match.lineNumber).length);
498
+ for (const ctx of match.contextBefore ?? []) {
499
+ nextWidth = Math.max(nextWidth, String(ctx.lineNumber).length);
369
500
  }
370
- modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
371
- displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
372
- if (recordable) cacheEntries.push([lineNumber, line] as const);
373
- lastEmittedLine = lineNumber;
374
- };
375
- if (match.contextBefore) {
376
- for (const ctx of match.contextBefore) {
377
- pushLine(ctx.lineNumber, ctx.line, false, true);
501
+ for (const ctx of match.contextAfter ?? []) {
502
+ nextWidth = Math.max(nextWidth, String(ctx.lineNumber).length);
503
+ }
504
+ return nextWidth;
505
+ }, 0);
506
+ const cacheEntries: Array<readonly [number, string]> = [];
507
+ let lastEmittedLine: number | undefined;
508
+ const gutterPad = " ".repeat(lineNumberWidth + 1);
509
+ for (const match of fileMatches) {
510
+ const pushLine = (lineNumber: number, line: string, isMatch: boolean, recordable: boolean) => {
511
+ if (lastEmittedLine !== undefined && lineNumber > lastEmittedLine + 1) {
512
+ modelOut.push("...");
513
+ displayOut.push(`${gutterPad}│...`);
514
+ }
515
+ modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
516
+ displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
517
+ if (recordable) cacheEntries.push([lineNumber, line] as const);
518
+ lastEmittedLine = lineNumber;
519
+ };
520
+ if (match.contextBefore) {
521
+ for (const ctx of match.contextBefore) {
522
+ pushLine(ctx.lineNumber, ctx.line, false, true);
523
+ }
378
524
  }
525
+ pushLine(match.lineNumber, match.line, true, !match.truncated);
526
+ if (match.truncated) {
527
+ linesTruncated = true;
528
+ }
529
+ if (match.contextAfter) {
530
+ for (const ctx of match.contextAfter) {
531
+ pushLine(ctx.lineNumber, ctx.line, false, true);
532
+ }
533
+ }
534
+ fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
379
535
  }
380
- pushLine(match.lineNumber, match.line, true, !match.truncated);
381
- if (match.truncated) {
382
- linesTruncated = true;
536
+ if (cacheEntries.length > 0 && !archiveDisplaySet.has(relativePath)) {
537
+ getFileReadCache(this.session).recordSparse(path.resolve(searchPath, relativePath), cacheEntries);
383
538
  }
384
- if (match.contextAfter) {
385
- for (const ctx of match.contextAfter) {
386
- pushLine(ctx.lineNumber, ctx.line, false, true);
387
- }
539
+ return { model: modelOut, display: displayOut };
540
+ };
541
+ if (isDirectory) {
542
+ const grouped = formatGroupedFiles(fileList, relativePath => {
543
+ const rendered = renderMatchesForFile(relativePath);
544
+ return {
545
+ modelLines: rendered.model,
546
+ displayLines: rendered.display,
547
+ skip: rendered.model.length === 0,
548
+ };
549
+ });
550
+ outputLines.push(...grouped.model);
551
+ displayLines.push(...grouped.display);
552
+ } else {
553
+ for (const relativePath of fileList) {
554
+ const rendered = renderMatchesForFile(relativePath);
555
+ outputLines.push(...rendered.model);
556
+ displayLines.push(...rendered.display);
388
557
  }
389
- fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
390
558
  }
391
- if (cacheEntries.length > 0) {
392
- getFileReadCache(this.session).recordSparse(path.resolve(searchPath, relativePath), cacheEntries);
559
+ if (limitMessage) {
560
+ outputLines.push("", limitMessage);
393
561
  }
394
- return { model: modelOut, display: displayOut };
395
- };
396
- if (isDirectory) {
397
- const grouped = formatGroupedFiles(fileList, relativePath => {
398
- const rendered = renderMatchesForFile(relativePath);
399
- return {
400
- modelLines: rendered.model,
401
- displayLines: rendered.display,
402
- skip: rendered.model.length === 0,
403
- };
404
- });
405
- outputLines.push(...grouped.model);
406
- displayLines.push(...grouped.display);
407
- } else {
408
- for (const relativePath of fileList) {
409
- const rendered = renderMatchesForFile(relativePath);
410
- outputLines.push(...rendered.model);
411
- displayLines.push(...rendered.display);
562
+ if (warningNote) {
563
+ outputLines.push("", warningNote);
412
564
  }
565
+ const rawOutput = outputLines.join("\n");
566
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
567
+ const output = truncation.content;
568
+ const displayText = displayLines.join("\n");
569
+ const truncated = Boolean(
570
+ fileLimitReached || perFileLimitReached || result.limitReached || truncation.truncated || linesTruncated,
571
+ );
572
+ const details: SearchToolDetails = {
573
+ scopePath,
574
+ searchPath,
575
+ matchCount: selectedMatches.length,
576
+ fileCount: fileList.length,
577
+ files: fileList,
578
+ fileMatches: fileList.map(path => ({
579
+ path,
580
+ count: fileMatchCounts.get(path) ?? 0,
581
+ })),
582
+ truncated,
583
+ fileLimitReached: fileLimitReached ? DEFAULT_FILE_LIMIT : undefined,
584
+ perFileLimitReached: perFileLimitReached ? perFileMatchCap : undefined,
585
+ displayContent: displayText,
586
+ missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
587
+ };
588
+ if (truncation.truncated) details.truncation = truncation;
589
+ if (linesTruncated) details.linesTruncated = true;
590
+ const resultBuilder = toolResult(details)
591
+ .text(output)
592
+ .limits({ columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined });
593
+ if (truncation.truncated) {
594
+ resultBuilder.truncation(truncation, { direction: "head" });
595
+ }
596
+ return resultBuilder.done();
597
+ } finally {
598
+ await cleanupArchiveScratch();
413
599
  }
414
- if (limitMessage) {
415
- outputLines.push("", limitMessage);
416
- }
417
- if (missingPathsNote) {
418
- outputLines.push("", missingPathsNote);
419
- }
420
- const rawOutput = outputLines.join("\n");
421
- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
422
- const output = truncation.content;
423
- const truncated = Boolean(
424
- fileLimitReached || perFileLimitReached || result.limitReached || truncation.truncated || linesTruncated,
425
- );
426
- const details: SearchToolDetails = {
427
- scopePath,
428
- matchCount: selectedMatches.length,
429
- fileCount: fileList.length,
430
- files: fileList,
431
- fileMatches: fileList.map(path => ({
432
- path,
433
- count: fileMatchCounts.get(path) ?? 0,
434
- })),
435
- truncated,
436
- fileLimitReached: fileLimitReached ? DEFAULT_FILE_LIMIT : undefined,
437
- perFileLimitReached: perFileLimitReached ? perFileMatchCap : undefined,
438
- displayContent: displayLines.join("\n"),
439
- missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
440
- };
441
- if (truncation.truncated) details.truncation = truncation;
442
- if (linesTruncated) details.linesTruncated = true;
443
- const resultBuilder = toolResult(details)
444
- .text(output)
445
- .limits({ columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined });
446
- if (truncation.truncated) {
447
- resultBuilder.truncation(truncation, { direction: "head" });
448
- }
449
- return resultBuilder.done();
450
600
  });
451
601
  }
452
602
  }
@@ -580,6 +730,7 @@ export const searchToolRenderer = {
580
730
  () => options.expanded,
581
731
  width => {
582
732
  const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
733
+ const searchBase = details?.searchPath;
583
734
  const matchLines = renderTreeList(
584
735
  {
585
736
  items: matchGroups,
@@ -587,12 +738,43 @@ export const searchToolRenderer = {
587
738
  maxCollapsed: matchGroups.length,
588
739
  maxCollapsedLines: collapsedMatchLineBudget,
589
740
  itemType: "match",
590
- renderItem: group =>
591
- group.map(line => {
592
- if (line.startsWith("## ")) return uiTheme.fg("dim", line);
593
- if (line.startsWith("# ")) return uiTheme.fg("accent", line);
741
+ renderItem: group => {
742
+ // Track directory context within a group for ## file headers.
743
+ // `# foo/` is a directory header; `# foo.ts` is a root-level file
744
+ // from formatGroupedFiles (single-# when directory is `.`).
745
+ let contextDir = searchBase ?? "";
746
+ return group.map(line => {
747
+ if (line.startsWith("## ")) {
748
+ // Strip optional ` (suffix)` like ` (3 replacements)` before resolving.
749
+ const fileName = line
750
+ .slice(3)
751
+ .trimEnd()
752
+ .replace(/\s+\([^)]*\)\s*$/, "");
753
+ const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
754
+ const styled = uiTheme.fg("dim", line);
755
+ return absPath ? fileHyperlink(absPath, styled) : styled;
756
+ }
757
+ if (line.startsWith("# ")) {
758
+ const raw = line
759
+ .slice(2)
760
+ .trimEnd()
761
+ .replace(/\s+\([^)]*\)\s*$/, "");
762
+ const isDirectory = raw.endsWith("/");
763
+ const name = raw.replace(/\/$/, "");
764
+ if (isDirectory) {
765
+ if (searchBase) {
766
+ contextDir = name === "." ? searchBase : path.join(searchBase, name);
767
+ }
768
+ return uiTheme.fg("accent", line);
769
+ }
770
+ // Root-level file emitted by formatGroupedFiles when the directory is `.`.
771
+ const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
772
+ const styled = uiTheme.fg("accent", line);
773
+ return absPath ? fileHyperlink(absPath, styled) : styled;
774
+ }
594
775
  return uiTheme.fg("toolOutput", line);
595
- }),
776
+ });
777
+ },
596
778
  },
597
779
  uiTheme,
598
780
  );