@oh-my-pi/pi-coding-agent 15.1.9 → 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.
- package/CHANGELOG.md +30 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/eval/py/kernel.d.ts +6 -0
- package/dist/types/goals/state.d.ts +1 -1
- package/dist/types/goals/tools/goal-tool.d.ts +4 -0
- package/dist/types/hashline/parser.d.ts +6 -2
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
- package/dist/types/modes/theme/shimmer.d.ts +27 -0
- package/dist/types/slash-commands/helpers/format.d.ts +4 -1
- package/dist/types/tools/ast-edit.d.ts +3 -0
- package/dist/types/tools/ast-grep.d.ts +3 -0
- package/dist/types/tools/find.d.ts +3 -0
- package/dist/types/tools/search.d.ts +3 -0
- package/dist/types/tui/file-list.d.ts +6 -0
- package/dist/types/tui/hyperlink.d.ts +42 -0
- package/dist/types/tui/index.d.ts +1 -0
- package/dist/types/web/search/providers/utils.d.ts +2 -1
- package/package.json +7 -7
- package/src/config/settings-schema.ts +12 -0
- package/src/config/settings.ts +28 -5
- package/src/edit/renderer.ts +5 -3
- package/src/eval/py/executor.ts +12 -1
- package/src/eval/py/kernel.ts +24 -8
- package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
- package/src/goals/runtime.ts +9 -3
- package/src/goals/state.ts +1 -1
- package/src/goals/tools/goal-tool.ts +12 -2
- package/src/hashline/diff.ts +1 -1
- package/src/hashline/execute.ts +2 -2
- package/src/hashline/parser.ts +87 -12
- package/src/internal-urls/memory-protocol.ts +1 -1
- package/src/modes/interactive-mode.ts +29 -1
- package/src/modes/theme/shimmer.ts +79 -0
- package/src/prompts/tools/goal.md +7 -2
- package/src/session/agent-session.ts +12 -75
- package/src/slash-commands/helpers/format.ts +23 -3
- package/src/task/executor.ts +115 -19
- package/src/tools/ast-edit.ts +39 -6
- package/src/tools/ast-grep.ts +38 -6
- package/src/tools/find.ts +13 -2
- package/src/tools/read.ts +46 -6
- package/src/tools/search.ts +447 -265
- package/src/tui/file-list.ts +10 -2
- package/src/tui/hyperlink.ts +126 -0
- package/src/tui/index.ts +1 -0
- package/src/web/search/index.ts +13 -9
- package/src/web/search/providers/anthropic.ts +3 -1
- package/src/web/search/providers/brave.ts +3 -1
- package/src/web/search/providers/codex.ts +3 -1
- package/src/web/search/providers/exa.ts +3 -1
- package/src/web/search/providers/gemini.ts +3 -1
- package/src/web/search/providers/jina.ts +3 -1
- package/src/web/search/providers/kagi.ts +5 -1
- package/src/web/search/providers/kimi.ts +3 -1
- package/src/web/search/providers/parallel.ts +5 -1
- package/src/web/search/providers/perplexity.ts +5 -1
- package/src/web/search/providers/searxng.ts +3 -1
- package/src/web/search/providers/synthetic.ts +3 -1
- package/src/web/search/providers/tavily.ts +3 -1
- package/src/web/search/providers/utils.ts +33 -1
- package/src/web/search/providers/zai.ts +3 -1
package/src/tools/search.ts
CHANGED
|
@@ -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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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:
|
|
210
|
-
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
matchesByPath.
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
:
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
linesTruncated = true;
|
|
536
|
+
if (cacheEntries.length > 0 && !archiveDisplaySet.has(relativePath)) {
|
|
537
|
+
getFileReadCache(this.session).recordSparse(path.resolve(searchPath, relativePath), cacheEntries);
|
|
383
538
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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 (
|
|
392
|
-
|
|
559
|
+
if (limitMessage) {
|
|
560
|
+
outputLines.push("", limitMessage);
|
|
393
561
|
}
|
|
394
|
-
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
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
|
);
|