@edxeth/pi-fff 0.7.2-edxeth.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.
- package/README.md +152 -0
- package/package.json +54 -0
- package/src/index.ts +1281 -0
- package/src/query.ts +87 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-fff: FFF-powered file search extension for pi
|
|
3
|
+
*
|
|
4
|
+
* Overrides built-in `find` and `grep` tools with FFF and can also replace
|
|
5
|
+
* @-mention autocomplete suggestions in the interactive editor.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import type {
|
|
12
|
+
GrepCursor,
|
|
13
|
+
GrepMode,
|
|
14
|
+
GrepResult,
|
|
15
|
+
MixedItem,
|
|
16
|
+
SearchResult,
|
|
17
|
+
} from "@edxeth/fff-node";
|
|
18
|
+
import { FileFinder } from "@edxeth/fff-node";
|
|
19
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import { CustomEditor } from "@mariozechner/pi-coding-agent";
|
|
21
|
+
import {
|
|
22
|
+
type AutocompleteItem,
|
|
23
|
+
type AutocompleteProvider,
|
|
24
|
+
Text,
|
|
25
|
+
} from "@mariozechner/pi-tui";
|
|
26
|
+
import { Type } from "@sinclair/typebox";
|
|
27
|
+
import { buildQuery } from "./query";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Constants
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const DEFAULT_GREP_LIMIT = 20;
|
|
34
|
+
const DEFAULT_FIND_LIMIT = 30;
|
|
35
|
+
const MAX_CACHED_FINDERS = 4;
|
|
36
|
+
const GREP_MAX_LINE_LENGTH = 500;
|
|
37
|
+
const MENTION_MAX_RESULTS = 20;
|
|
38
|
+
|
|
39
|
+
type FffMode = "tools-and-ui" | "tools-only" | "override";
|
|
40
|
+
|
|
41
|
+
const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"];
|
|
42
|
+
|
|
43
|
+
interface ToolNames {
|
|
44
|
+
grep: string;
|
|
45
|
+
find: string;
|
|
46
|
+
multiGrep: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FFF_TOOL_NAMES: ToolNames = {
|
|
50
|
+
grep: "ffgrep",
|
|
51
|
+
find: "fffind",
|
|
52
|
+
multiGrep: "fff-multi-grep",
|
|
53
|
+
};
|
|
54
|
+
const OVERRIDE_TOOL_NAMES: ToolNames = {
|
|
55
|
+
grep: "grep",
|
|
56
|
+
find: "find",
|
|
57
|
+
multiGrep: "multi_grep",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function resolveToolNames(mode: FffMode): ToolNames {
|
|
61
|
+
return mode === "override" ? OVERRIDE_TOOL_NAMES : FFF_TOOL_NAMES;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Cursor store — simple bounded Map for pagination cursors
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const cursorCache = new Map<string, GrepCursor>();
|
|
69
|
+
let cursorCounter = 0;
|
|
70
|
+
|
|
71
|
+
function storeCursor(cursor: GrepCursor): string {
|
|
72
|
+
const id = `fff_c${++cursorCounter}`;
|
|
73
|
+
cursorCache.set(id, cursor);
|
|
74
|
+
if (cursorCache.size > 200) {
|
|
75
|
+
const first = cursorCache.keys().next().value;
|
|
76
|
+
if (first) cursorCache.delete(first);
|
|
77
|
+
}
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getCursor(id: string): GrepCursor | undefined {
|
|
82
|
+
return cursorCache.get(id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Find pagination uses a page-index cursor: native `fileSearch` takes
|
|
86
|
+
// pageIndex/pageSize, so the cursor is just the next page index paired with
|
|
87
|
+
// the query+limit that produced it. Stored tokens are opaque IDs to the agent.
|
|
88
|
+
interface FindCursor {
|
|
89
|
+
basePath: string;
|
|
90
|
+
query: string;
|
|
91
|
+
pattern: string;
|
|
92
|
+
pageSize: number;
|
|
93
|
+
nextPageIndex: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const findCursorCache = new Map<string, FindCursor>();
|
|
97
|
+
let findCursorCounter = 0;
|
|
98
|
+
|
|
99
|
+
function storeFindCursor(cursor: FindCursor): string {
|
|
100
|
+
const id = `${++findCursorCounter}`;
|
|
101
|
+
findCursorCache.set(id, cursor);
|
|
102
|
+
if (findCursorCache.size > 200) {
|
|
103
|
+
const first = findCursorCache.keys().next().value;
|
|
104
|
+
if (first) findCursorCache.delete(first);
|
|
105
|
+
}
|
|
106
|
+
return id;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getFindCursor(id: string): FindCursor | undefined {
|
|
110
|
+
return findCursorCache.get(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Output formatting helpers
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
function truncateLine(line: string, max = GREP_MAX_LINE_LENGTH): string {
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}...`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const HOT_FRECENCY = 25;
|
|
123
|
+
const WARM_FRECENCY = 20;
|
|
124
|
+
|
|
125
|
+
// Shared annotation helper for both find-output paths and grep-output file
|
|
126
|
+
// headers. Returns at most ONE tag so output stays scannable. Priority:
|
|
127
|
+
// git-dirty (most actionable — file is changing right now) beats frecency
|
|
128
|
+
// (historically often-touched). Keeping one function ensures the two tools
|
|
129
|
+
// never drift in how they surface git/frecency signal.
|
|
130
|
+
export function fffFileAnnotation(item: {
|
|
131
|
+
gitStatus?: string;
|
|
132
|
+
totalFrecencyScore?: number;
|
|
133
|
+
accessFrecencyScore?: number;
|
|
134
|
+
}): string {
|
|
135
|
+
const git = item.gitStatus;
|
|
136
|
+
if (git && git !== "clean" && git !== "unknown" && git !== "") {
|
|
137
|
+
return ` [${git} in git]`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const frecency = item.totalFrecencyScore ?? item.accessFrecencyScore ?? 0;
|
|
141
|
+
if (frecency >= HOT_FRECENCY) return " [VERY often touched file]";
|
|
142
|
+
if (frecency >= WARM_FRECENCY) return " [often touched file]";
|
|
143
|
+
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// fff-core native definition classifier (byte-level scanner in Rust) is enabled
|
|
148
|
+
// via GrepOptions.classifyDefinitions. Each GrepMatch carries isDefinition for
|
|
149
|
+
// downstream consumers; pi-fff does NOT use it to re-sort.
|
|
150
|
+
//
|
|
151
|
+
// Ordering policy: NO CUSTOM SORTING. The engine already returns items in
|
|
152
|
+
// frecency order (most-accessed files first). pi-fff only groups consecutive
|
|
153
|
+
// matches into per-file blocks and preserves whatever order the engine
|
|
154
|
+
// provided — inside a file we keep matches in source-line order because the
|
|
155
|
+
// engine emits them that way.
|
|
156
|
+
|
|
157
|
+
function formatGrepOutput(result: GrepResult): string {
|
|
158
|
+
if (result.items.length === 0) return "No matches found";
|
|
159
|
+
|
|
160
|
+
// Build file-grouped output in the order files first appear in the result.
|
|
161
|
+
// This preserves native frecency ordering across files without re-sorting.
|
|
162
|
+
const lines: string[] = [];
|
|
163
|
+
let currentFile = "";
|
|
164
|
+
let _shown = 0;
|
|
165
|
+
|
|
166
|
+
for (const match of result.items) {
|
|
167
|
+
if (match.relativePath !== currentFile) {
|
|
168
|
+
if (lines.length > 0) lines.push("");
|
|
169
|
+
currentFile = match.relativePath;
|
|
170
|
+
lines.push(`${currentFile}${fffFileAnnotation(match)}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
match.contextBefore?.forEach((line: string, i: number) => {
|
|
174
|
+
const lineNum = match.lineNumber - match.contextBefore!.length + i;
|
|
175
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
|
|
179
|
+
_shown++;
|
|
180
|
+
|
|
181
|
+
match.contextAfter?.forEach((line: string, i: number) => {
|
|
182
|
+
const lineNum = match.lineNumber + 1 + i;
|
|
183
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return lines.join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Weak-match threshold is derived from the query length, matching the
|
|
191
|
+
// scoring formula in crates/fff-core/src/score.rs: a perfect match scores
|
|
192
|
+
// `len * 16`, so we treat anything below 50% of that as scattered fuzzy noise.
|
|
193
|
+
// When the top score is weak, trim output to a small sample instead of dumping
|
|
194
|
+
// the full limit worth of noise into the agent's context.
|
|
195
|
+
const FIND_WEAK_SAMPLE_SIZE = 5;
|
|
196
|
+
|
|
197
|
+
function weakScoreThreshold(pattern: string): number {
|
|
198
|
+
const perfect = pattern.length * 12;
|
|
199
|
+
return Math.floor((perfect * 50) / 100);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface FormattedFind {
|
|
203
|
+
output: string;
|
|
204
|
+
weak: boolean;
|
|
205
|
+
shownCount: number;
|
|
206
|
+
literalTailSuppressed: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizeLiteralPattern(pattern: string): string | null {
|
|
210
|
+
const trimmed = pattern.trim().toLowerCase();
|
|
211
|
+
return /^[a-z0-9._-]+$/.test(trimmed) ? trimmed : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pathHasLiteralSegment(relativePath: string, pattern: string): boolean {
|
|
215
|
+
const literal = normalizeLiteralPattern(pattern);
|
|
216
|
+
if (!literal) return false;
|
|
217
|
+
|
|
218
|
+
return relativePath
|
|
219
|
+
.toLowerCase()
|
|
220
|
+
.split("/")
|
|
221
|
+
.some((segment) => segment === literal || segment.startsWith(`${literal}.`));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function patternLooksLikePath(pattern: string): boolean {
|
|
225
|
+
return /[\\/]|[*?[{]/.test(pattern);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function pathLikePatternMessage(_pattern: string): string {
|
|
229
|
+
return "Path/glob belongs in path, not pattern";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pathLooksLikeMultiplePaths(pathConstraint: string): boolean {
|
|
233
|
+
const parts = pathConstraint.trim().split(/\s+/).filter(Boolean);
|
|
234
|
+
if (parts.length < 2) return false;
|
|
235
|
+
return parts.every((part) => part.includes("/") || part.includes("\\"));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function multiplePathsMessage(): string {
|
|
239
|
+
return "Multiple paths are not supported in path; use one file, directory, or glob";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function formatFindOutput(
|
|
243
|
+
result: SearchResult,
|
|
244
|
+
limit: number,
|
|
245
|
+
pattern: string,
|
|
246
|
+
): FormattedFind {
|
|
247
|
+
if (result.items.length === 0) {
|
|
248
|
+
return {
|
|
249
|
+
output: "No files found matching pattern",
|
|
250
|
+
weak: false,
|
|
251
|
+
shownCount: 0,
|
|
252
|
+
literalTailSuppressed: false,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// NO CUSTOM SORTING — trust native frecency order from the engine.
|
|
257
|
+
const reordered = result.items.map((item) => ({ item }));
|
|
258
|
+
|
|
259
|
+
// Peek at the top native score to decide whether results are scattered
|
|
260
|
+
// fuzzy noise (query length-scaled threshold from score.rs).
|
|
261
|
+
const topScore = result.scores[0]?.total ?? 0;
|
|
262
|
+
const weak = topScore < weakScoreThreshold(pattern);
|
|
263
|
+
const literalFiltered =
|
|
264
|
+
!weak &&
|
|
265
|
+
pathHasLiteralSegment(result.items[0]?.relativePath ?? "", pattern) &&
|
|
266
|
+
result.totalMatched > FIND_WEAK_SAMPLE_SIZE;
|
|
267
|
+
const effective = weak ? Math.min(FIND_WEAK_SAMPLE_SIZE, limit) : limit;
|
|
268
|
+
const shown = literalFiltered
|
|
269
|
+
? reordered
|
|
270
|
+
.filter((p) => pathHasLiteralSegment(p.item.relativePath, pattern))
|
|
271
|
+
.slice(0, effective)
|
|
272
|
+
: reordered.slice(0, effective);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
output: shown
|
|
276
|
+
.map((p) => `${p.item.relativePath}${fffFileAnnotation(p.item)}`)
|
|
277
|
+
.join("\n"),
|
|
278
|
+
weak,
|
|
279
|
+
shownCount: shown.length,
|
|
280
|
+
literalTailSuppressed: literalFiltered && shown.length < result.totalMatched,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Mention autocomplete helpers
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
function extractAtPrefix(textBeforeCursor: string): string | null {
|
|
289
|
+
const match = textBeforeCursor.match(/(?:^|[ \t])(@(?:"[^"]*|[^\s]*))$/);
|
|
290
|
+
return match?.[1] ?? null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function buildAtCompletionValue(path: string): string {
|
|
294
|
+
return path.includes(" ") ? `@"${path}"` : `@${path}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function createFffMentionProvider(
|
|
298
|
+
getItems: (query: string, signal: AbortSignal) => Promise<AutocompleteItem[]>,
|
|
299
|
+
): AutocompleteProvider {
|
|
300
|
+
return {
|
|
301
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
302
|
+
const currentLine = lines[cursorLine] || "";
|
|
303
|
+
const prefix = extractAtPrefix(currentLine.slice(0, cursorCol));
|
|
304
|
+
if (!prefix || options.signal.aborted) return null;
|
|
305
|
+
|
|
306
|
+
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
|
307
|
+
const items = await getItems(query, options.signal);
|
|
308
|
+
return options.signal.aborted || items.length === 0 ? null : { items, prefix };
|
|
309
|
+
},
|
|
310
|
+
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
|
311
|
+
const currentLine = _lines[cursorLine] || "";
|
|
312
|
+
const before = currentLine.slice(0, cursorCol - prefix.length);
|
|
313
|
+
const after = currentLine.slice(cursorCol);
|
|
314
|
+
const newLine = before + item.value + after;
|
|
315
|
+
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
|
316
|
+
return {
|
|
317
|
+
lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
|
|
318
|
+
cursorLine,
|
|
319
|
+
cursorCol: newCursorCol,
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// FffEditor is defined inside fffExtension() so it can capture `getMentionItems`
|
|
326
|
+
// via closure rather than via a 4th constructor parameter. This makes the class
|
|
327
|
+
// safe to subclass via `new SubClass(tui, theme, keybindings)` -- the pattern
|
|
328
|
+
// pi-vim and pi-image-attachments use to compose editors. See:
|
|
329
|
+
// https://github.com/badlogic/pi-mono/issues/3935
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Extension
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
export default function fffExtension(pi: ExtensionAPI) {
|
|
336
|
+
const finders = new Map<string, FileFinder>();
|
|
337
|
+
let activeBasePath: string | null = null;
|
|
338
|
+
// Concurrent ensureFinder() callers share in-flight promises by base path so
|
|
339
|
+
// FileFinder.create() (which takes native DB locks) runs at most once per
|
|
340
|
+
// base path at a time — otherwise parallel tool calls would race and
|
|
341
|
+
// deadlock at the native layer (issue #403).
|
|
342
|
+
const finderPromises = new Map<string, Promise<FileFinder>>();
|
|
343
|
+
const finderLocks = new Map<string, Promise<void>>();
|
|
344
|
+
const finderActiveOps = new Map<string, number>();
|
|
345
|
+
let activeCwd = process.cwd();
|
|
346
|
+
|
|
347
|
+
// Mode resolution: flag > env > default
|
|
348
|
+
let currentMode: FffMode =
|
|
349
|
+
(pi.getFlag("fff-mode") as FffMode) ??
|
|
350
|
+
(process.env.PI_FFF_MODE as FffMode) ??
|
|
351
|
+
"tools-and-ui";
|
|
352
|
+
|
|
353
|
+
const toolNames = resolveToolNames(currentMode);
|
|
354
|
+
|
|
355
|
+
// DB path resolution: flag > env > undefined (use fff-node defaults)
|
|
356
|
+
const frecencyDbPath =
|
|
357
|
+
(pi.getFlag("fff-frecency-db") as string | undefined) ??
|
|
358
|
+
process.env.FFF_FRECENCY_DB ??
|
|
359
|
+
undefined;
|
|
360
|
+
const historyDbPath =
|
|
361
|
+
(pi.getFlag("fff-history-db") as string | undefined) ??
|
|
362
|
+
process.env.FFF_HISTORY_DB ??
|
|
363
|
+
undefined;
|
|
364
|
+
|
|
365
|
+
function getMode(): FffMode {
|
|
366
|
+
return currentMode;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function setMode(mode: FffMode): void {
|
|
370
|
+
currentMode = mode;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function shouldEnableMentions(): boolean {
|
|
374
|
+
return currentMode !== "tools-only";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function resolveGitRoot(dir: string): string | null {
|
|
378
|
+
try {
|
|
379
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
380
|
+
cwd: dir,
|
|
381
|
+
encoding: "utf8",
|
|
382
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
383
|
+
timeout: 3000,
|
|
384
|
+
}).trim();
|
|
385
|
+
return root || null;
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function expandHomePath(pathConstraint: string): string {
|
|
392
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
393
|
+
if (!home) return pathConstraint;
|
|
394
|
+
return pathConstraint.replace(/^~($|\/|\\)/, (_, sep) => home + sep);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function concreteStatPath(pathConstraint: string, cwd = activeCwd): string {
|
|
398
|
+
const expanded = expandHomePath(pathConstraint);
|
|
399
|
+
const absolute = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
|
|
400
|
+
const wildcard = absolute.search(/[*?[{]/);
|
|
401
|
+
const concrete = wildcard === -1 ? absolute : absolute.slice(0, wildcard);
|
|
402
|
+
if (wildcard === -1) return absolute;
|
|
403
|
+
return concrete.endsWith(path.sep) ? concrete.slice(0, -1) : path.dirname(concrete);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function hasHiddenSegment(pathConstraint: string): boolean {
|
|
407
|
+
return pathConstraint
|
|
408
|
+
.split(/[\\/]+/)
|
|
409
|
+
.some((segment) => segment.startsWith(".") && segment !== "." && segment !== "..");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function invalidPathMessage(pathConstraint: string, cwd = activeCwd): string | null {
|
|
413
|
+
const statPath = concreteStatPath(pathConstraint, cwd);
|
|
414
|
+
return fs.existsSync(statPath)
|
|
415
|
+
? null
|
|
416
|
+
: `Path not found: ${statPath || pathConstraint}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function absolutePathBase(pathConstraint: string): {
|
|
420
|
+
basePath: string;
|
|
421
|
+
pathConstraint?: string;
|
|
422
|
+
} {
|
|
423
|
+
const wildcard = pathConstraint.search(/[*?[{]/);
|
|
424
|
+
const hasWildcard = wildcard !== -1;
|
|
425
|
+
const concrete = hasWildcard ? pathConstraint.slice(0, wildcard) : pathConstraint;
|
|
426
|
+
const concreteDir = concrete.endsWith(path.sep)
|
|
427
|
+
? concrete.slice(0, -1)
|
|
428
|
+
: path.dirname(concrete);
|
|
429
|
+
const statPath = hasWildcard ? concreteDir : pathConstraint;
|
|
430
|
+
const isDir = fs.existsSync(statPath) && fs.statSync(statPath).isDirectory();
|
|
431
|
+
const fallbackBase = isDir ? statPath : path.dirname(statPath);
|
|
432
|
+
const gitRoot = resolveGitRoot(fallbackBase);
|
|
433
|
+
const basePath = gitRoot ?? fallbackBase;
|
|
434
|
+
const relative = path.relative(basePath, pathConstraint).replaceAll(path.sep, "/");
|
|
435
|
+
const pathValue =
|
|
436
|
+
relative && relative !== "**" && relative !== "**/*" ? relative : undefined;
|
|
437
|
+
return { basePath, pathConstraint: pathValue };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function resolveSearchBase(pathConstraint: string | undefined): {
|
|
441
|
+
basePath: string;
|
|
442
|
+
pathConstraint?: string;
|
|
443
|
+
} {
|
|
444
|
+
if (!pathConstraint) return { basePath: activeCwd, pathConstraint };
|
|
445
|
+
const expanded = expandHomePath(pathConstraint);
|
|
446
|
+
if (path.isAbsolute(expanded)) return absolutePathBase(expanded);
|
|
447
|
+
if (expanded === ".." || expanded.startsWith(`..${path.sep}`)) {
|
|
448
|
+
return absolutePathBase(path.resolve(activeCwd, expanded));
|
|
449
|
+
}
|
|
450
|
+
if (/\s/.test(expanded) && fs.existsSync(concreteStatPath(expanded))) {
|
|
451
|
+
return absolutePathBase(path.resolve(activeCwd, expanded));
|
|
452
|
+
}
|
|
453
|
+
if (hasHiddenSegment(expanded) && fs.existsSync(concreteStatPath(expanded))) {
|
|
454
|
+
return absolutePathBase(path.resolve(activeCwd, expanded));
|
|
455
|
+
}
|
|
456
|
+
return { basePath: activeCwd, pathConstraint };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function trimFinderCache() {
|
|
460
|
+
while (finders.size >= MAX_CACHED_FINDERS) {
|
|
461
|
+
const evictable = [...finders.entries()].find(
|
|
462
|
+
([basePath]) => (finderActiveOps.get(basePath) ?? 0) === 0,
|
|
463
|
+
);
|
|
464
|
+
if (!evictable) return;
|
|
465
|
+
|
|
466
|
+
const [oldestBase, oldestFinder] = evictable;
|
|
467
|
+
if (!oldestFinder.isDestroyed) oldestFinder.destroy();
|
|
468
|
+
finders.delete(oldestBase);
|
|
469
|
+
if (activeBasePath === oldestBase) activeBasePath = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function ensureFinder(basePath: string): Promise<FileFinder> {
|
|
474
|
+
const existing = finders.get(basePath);
|
|
475
|
+
if (existing && !existing.isDestroyed) return Promise.resolve(existing);
|
|
476
|
+
const pending = finderPromises.get(basePath);
|
|
477
|
+
if (pending) return pending;
|
|
478
|
+
|
|
479
|
+
const promise = (async () => {
|
|
480
|
+
trimFinderCache();
|
|
481
|
+
const useDatabases = basePath === activeCwd;
|
|
482
|
+
const result = FileFinder.create({
|
|
483
|
+
basePath,
|
|
484
|
+
frecencyDbPath: useDatabases ? frecencyDbPath : undefined,
|
|
485
|
+
historyDbPath: useDatabases ? historyDbPath : undefined,
|
|
486
|
+
aiMode: true,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
if (!result.ok)
|
|
490
|
+
throw new Error(`Failed to create FFF file finder: ${result.error}`);
|
|
491
|
+
|
|
492
|
+
const finder = result.value;
|
|
493
|
+
finders.set(basePath, finder);
|
|
494
|
+
activeBasePath = basePath;
|
|
495
|
+
await finder.waitForScan(15000);
|
|
496
|
+
return finder;
|
|
497
|
+
})().finally(() => {
|
|
498
|
+
finderPromises.delete(basePath);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
finderPromises.set(basePath, promise);
|
|
502
|
+
return promise;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function destroyFinder() {
|
|
506
|
+
for (const finder of finders.values()) {
|
|
507
|
+
if (!finder.isDestroyed) finder.destroy();
|
|
508
|
+
}
|
|
509
|
+
finders.clear();
|
|
510
|
+
finderLocks.clear();
|
|
511
|
+
finderActiveOps.clear();
|
|
512
|
+
activeBasePath = null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function withFinderLease<T>(
|
|
516
|
+
basePath: string,
|
|
517
|
+
work: (finder: FileFinder) => T | Promise<T>,
|
|
518
|
+
): Promise<T> {
|
|
519
|
+
const previous = finderLocks.get(basePath) ?? Promise.resolve();
|
|
520
|
+
let release!: () => void;
|
|
521
|
+
const current = new Promise<void>((resolve) => {
|
|
522
|
+
release = resolve;
|
|
523
|
+
});
|
|
524
|
+
finderLocks.set(
|
|
525
|
+
basePath,
|
|
526
|
+
previous.then(
|
|
527
|
+
() => current,
|
|
528
|
+
() => current,
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
await previous.catch(() => undefined);
|
|
533
|
+
finderActiveOps.set(basePath, (finderActiveOps.get(basePath) ?? 0) + 1);
|
|
534
|
+
try {
|
|
535
|
+
const finder = await ensureFinder(basePath);
|
|
536
|
+
return await work(finder);
|
|
537
|
+
} finally {
|
|
538
|
+
const remaining = (finderActiveOps.get(basePath) ?? 1) - 1;
|
|
539
|
+
if (remaining > 0) finderActiveOps.set(basePath, remaining);
|
|
540
|
+
else finderActiveOps.delete(basePath);
|
|
541
|
+
release();
|
|
542
|
+
if (finderLocks.get(basePath) === current) finderLocks.delete(basePath);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function getActiveFinder(): FileFinder | null {
|
|
547
|
+
if (!activeBasePath) return null;
|
|
548
|
+
const finder = finders.get(activeBasePath);
|
|
549
|
+
return finder && !finder.isDestroyed ? finder : null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function getMentionItems(
|
|
553
|
+
query: string,
|
|
554
|
+
signal: AbortSignal,
|
|
555
|
+
): Promise<AutocompleteItem[]> {
|
|
556
|
+
if (signal.aborted) return [];
|
|
557
|
+
const result = await withFinderLease(activeCwd, (finder) => {
|
|
558
|
+
if (signal.aborted) return null;
|
|
559
|
+
return finder.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
|
560
|
+
});
|
|
561
|
+
if (!result) return [];
|
|
562
|
+
if (!result.ok) return [];
|
|
563
|
+
|
|
564
|
+
return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
|
|
565
|
+
if (mixed.type === "directory") {
|
|
566
|
+
return {
|
|
567
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
568
|
+
label: mixed.item.dirName,
|
|
569
|
+
description: mixed.item.relativePath,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
574
|
+
label: mixed.item.fileName,
|
|
575
|
+
description: mixed.item.relativePath,
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Editor wrapper that injects FFF @-mention autocomplete alongside base provider.
|
|
581
|
+
// Defined inside fffExtension() so the class methods capture `getMentionItems`
|
|
582
|
+
// via closure. Subclasses constructed as `new Sub(tui, theme, keybindings)` by
|
|
583
|
+
// composability wrappers (pi-vim, pi-image-attachments) still get a working
|
|
584
|
+
// mention provider because the closure binding is preserved across subclassing.
|
|
585
|
+
class FffEditor extends CustomEditor {
|
|
586
|
+
private baseProvider: AutocompleteProvider | undefined;
|
|
587
|
+
|
|
588
|
+
override setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
589
|
+
this.baseProvider = provider;
|
|
590
|
+
// Create composite provider that handles @-mentions and falls back to base
|
|
591
|
+
const mentionProvider = createFffMentionProvider(getMentionItems);
|
|
592
|
+
const compositeProvider: AutocompleteProvider = {
|
|
593
|
+
getSuggestions: async (lines, cursorLine, cursorCol, options) => {
|
|
594
|
+
// Try @-mention first
|
|
595
|
+
const mentionResult = await mentionProvider.getSuggestions(
|
|
596
|
+
lines,
|
|
597
|
+
cursorLine,
|
|
598
|
+
cursorCol,
|
|
599
|
+
options,
|
|
600
|
+
);
|
|
601
|
+
if (mentionResult) return mentionResult;
|
|
602
|
+
// Fall back to base provider
|
|
603
|
+
return (
|
|
604
|
+
this.baseProvider?.getSuggestions(lines, cursorLine, cursorCol, options) ??
|
|
605
|
+
null
|
|
606
|
+
);
|
|
607
|
+
},
|
|
608
|
+
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
|
609
|
+
// Let mention provider handle @ completions, base provider for others
|
|
610
|
+
if (prefix?.startsWith("@")) {
|
|
611
|
+
return mentionProvider.applyCompletion!(
|
|
612
|
+
lines,
|
|
613
|
+
cursorLine,
|
|
614
|
+
cursorCol,
|
|
615
|
+
item,
|
|
616
|
+
prefix,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
return (
|
|
620
|
+
this.baseProvider?.applyCompletion?.(
|
|
621
|
+
lines,
|
|
622
|
+
cursorLine,
|
|
623
|
+
cursorCol,
|
|
624
|
+
item,
|
|
625
|
+
prefix,
|
|
626
|
+
) ?? { lines, cursorLine, cursorCol }
|
|
627
|
+
);
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
super.setAutocompleteProvider(compositeProvider);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function applyEditorMode(ctx: {
|
|
635
|
+
ui: {
|
|
636
|
+
setEditorComponent: (
|
|
637
|
+
factory: ((tui: any, theme: any, keybindings: any) => any) | undefined,
|
|
638
|
+
) => void;
|
|
639
|
+
};
|
|
640
|
+
}) {
|
|
641
|
+
if (!shouldEnableMentions()) {
|
|
642
|
+
ctx.ui.setEditorComponent(undefined);
|
|
643
|
+
} else {
|
|
644
|
+
ctx.ui.setEditorComponent(
|
|
645
|
+
(tui: any, theme: any, keybindings: any) =>
|
|
646
|
+
new FffEditor(tui, theme, keybindings),
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// --- Flags / lifecycle ---
|
|
652
|
+
|
|
653
|
+
pi.registerFlag("fff-mode", {
|
|
654
|
+
description: "FFF mode: tools-and-ui | tools-only | override",
|
|
655
|
+
type: "string",
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
pi.registerFlag("fff-frecency-db", {
|
|
659
|
+
description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
660
|
+
type: "string",
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
pi.registerFlag("fff-history-db", {
|
|
664
|
+
description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
665
|
+
type: "string",
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
669
|
+
try {
|
|
670
|
+
activeCwd = ctx.cwd;
|
|
671
|
+
await withFinderLease(activeCwd, () => undefined);
|
|
672
|
+
applyEditorMode(ctx);
|
|
673
|
+
} catch (e: unknown) {
|
|
674
|
+
ctx.ui.notify(
|
|
675
|
+
`FFF init failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
676
|
+
"error",
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
pi.on("session_shutdown", async () => {
|
|
682
|
+
destroyFinder();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// --- Shared render helpers ---
|
|
686
|
+
|
|
687
|
+
const renderTextResult = (
|
|
688
|
+
result: { content?: { type: string; text?: string }[] },
|
|
689
|
+
options: { expanded?: boolean },
|
|
690
|
+
theme: any,
|
|
691
|
+
context: any,
|
|
692
|
+
maxLines = 15,
|
|
693
|
+
) => {
|
|
694
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
695
|
+
const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
|
696
|
+
if (!output) {
|
|
697
|
+
text.setText(theme.fg("muted", "No output"));
|
|
698
|
+
return text;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const lines = output.split("\n");
|
|
702
|
+
const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
|
|
703
|
+
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
|
|
704
|
+
if (lines.length > displayLines.length) {
|
|
705
|
+
content += theme.fg(
|
|
706
|
+
"muted",
|
|
707
|
+
`\n... (${lines.length - displayLines.length} more lines)`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
text.setText(content);
|
|
711
|
+
return text;
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// --- grep tool ---
|
|
715
|
+
|
|
716
|
+
const grepSchema = Type.Object({
|
|
717
|
+
pattern: Type.String({
|
|
718
|
+
description: "Search pattern (literal text or regex)",
|
|
719
|
+
}),
|
|
720
|
+
path: Type.Optional(
|
|
721
|
+
Type.String({
|
|
722
|
+
description:
|
|
723
|
+
"Single path constraint: one file, one directory, or one glob. Do not pass multiple paths. Applied to the full repo-relative path.",
|
|
724
|
+
}),
|
|
725
|
+
),
|
|
726
|
+
exclude: Type.Optional(
|
|
727
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
728
|
+
description:
|
|
729
|
+
"Exclude paths (comma/space-separated or array). Same syntax as path: directory prefix ('test/'), filename with extension ('config.json'), or glob ('*.min.js', '**/*.{rs,go}'). A leading '!' is optional and ignored — both 'test/' and '!test/' work. Example: 'test/,*.min.js,!vendor/'.",
|
|
730
|
+
}),
|
|
731
|
+
),
|
|
732
|
+
caseSensitive: Type.Optional(
|
|
733
|
+
Type.Boolean({
|
|
734
|
+
description:
|
|
735
|
+
"Force case-sensitive matching. Default uses smart-case (case-insensitive when pattern is all lowercase).",
|
|
736
|
+
}),
|
|
737
|
+
),
|
|
738
|
+
context: Type.Optional(
|
|
739
|
+
Type.Number({ description: "Context lines before+after each match" }),
|
|
740
|
+
),
|
|
741
|
+
limit: Type.Optional(
|
|
742
|
+
Type.Number({
|
|
743
|
+
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
744
|
+
}),
|
|
745
|
+
),
|
|
746
|
+
cursor: Type.Optional(
|
|
747
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
748
|
+
),
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
pi.registerTool({
|
|
752
|
+
name: toolNames.grep,
|
|
753
|
+
label: toolNames.grep,
|
|
754
|
+
description: `Grep file contents. Smart-case, auto-detects regex vs literal, git-aware. Results are ranked by frecency (most-accessed files first); matches within a file stay in source order. Default limit ${DEFAULT_GREP_LIMIT}.`,
|
|
755
|
+
promptSnippet: "Grep contents",
|
|
756
|
+
promptGuidelines: [
|
|
757
|
+
"Prefer bare identifiers as patterns. Literal queries are most efficient.",
|
|
758
|
+
"Use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').",
|
|
759
|
+
"caseSensitive: true when you need exact case (smart-case otherwise).",
|
|
760
|
+
"Never combine paths in one call. For multiple files, make separate grep calls.",
|
|
761
|
+
"After 1-2 greps, read the top match instead of more greps.",
|
|
762
|
+
],
|
|
763
|
+
parameters: grepSchema,
|
|
764
|
+
|
|
765
|
+
async execute(_toolCallId, params, signal) {
|
|
766
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
767
|
+
|
|
768
|
+
if (params.path && pathLooksLikeMultiplePaths(params.path)) {
|
|
769
|
+
throw new Error(multiplePathsMessage());
|
|
770
|
+
}
|
|
771
|
+
const invalidPath = params.path ? invalidPathMessage(params.path) : null;
|
|
772
|
+
if (invalidPath) throw new Error(invalidPath);
|
|
773
|
+
|
|
774
|
+
const searchBase = resolveSearchBase(params.path);
|
|
775
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
776
|
+
const query = buildQuery(
|
|
777
|
+
searchBase.pathConstraint,
|
|
778
|
+
params.pattern,
|
|
779
|
+
params.exclude,
|
|
780
|
+
searchBase.basePath,
|
|
781
|
+
);
|
|
782
|
+
// Auto-detect: regex if the pattern has regex metacharacters AND parses
|
|
783
|
+
// as a valid regex, otherwise plain literal. The fuzzy fallback below
|
|
784
|
+
// only kicks in for plain mode — regex queries are intentional.
|
|
785
|
+
const hasRegexSyntax =
|
|
786
|
+
params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
787
|
+
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
|
|
788
|
+
if (mode === "regex") {
|
|
789
|
+
try {
|
|
790
|
+
new RegExp(params.pattern);
|
|
791
|
+
} catch {
|
|
792
|
+
mode = "plain";
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
|
|
797
|
+
// to try to read a whole file. That's not what grep is for — return a terse error
|
|
798
|
+
// steering them to a real pattern, preventing dozens of wasted retries.
|
|
799
|
+
const p = params.pattern.trim();
|
|
800
|
+
const isWildcardOnly =
|
|
801
|
+
hasRegexSyntax &&
|
|
802
|
+
/^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
|
|
803
|
+
p,
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
if (isWildcardOnly) {
|
|
807
|
+
return {
|
|
808
|
+
content: [
|
|
809
|
+
{
|
|
810
|
+
type: "text",
|
|
811
|
+
text: `Pattern '${params.pattern}' matches everything — grep needs a concrete substring or identifier. Example: \`pattern: 'MyClass'\` or \`pattern: 'export function'\`.`,
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
details: { totalMatched: 0, totalFiles: 0 },
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// caseSensitive override flips smartCase off; omitting it keeps smart-case
|
|
819
|
+
// (case-insensitive when pattern is all lowercase).
|
|
820
|
+
const smartCase = params.caseSensitive !== true;
|
|
821
|
+
|
|
822
|
+
const grepResult = await withFinderLease(searchBase.basePath, (finder) =>
|
|
823
|
+
finder.grep(query, {
|
|
824
|
+
mode,
|
|
825
|
+
smartCase,
|
|
826
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
827
|
+
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
828
|
+
beforeContext: params.context ?? 0,
|
|
829
|
+
afterContext: params.context ?? 0,
|
|
830
|
+
classifyDefinitions: true,
|
|
831
|
+
}),
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
835
|
+
|
|
836
|
+
let result = grepResult.value;
|
|
837
|
+
let fuzzyNotice: string | null = null;
|
|
838
|
+
|
|
839
|
+
// Fuzzy fallback helps broad plain greps, but excludes mean exact filtering.
|
|
840
|
+
if (
|
|
841
|
+
result.items.length === 0 &&
|
|
842
|
+
!params.cursor &&
|
|
843
|
+
!params.exclude &&
|
|
844
|
+
mode !== "regex"
|
|
845
|
+
) {
|
|
846
|
+
const fuzzy = await withFinderLease(searchBase.basePath, (finder) =>
|
|
847
|
+
finder.grep(query, {
|
|
848
|
+
mode: "fuzzy",
|
|
849
|
+
smartCase,
|
|
850
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
851
|
+
cursor: null,
|
|
852
|
+
beforeContext: 0,
|
|
853
|
+
afterContext: 0,
|
|
854
|
+
classifyDefinitions: true,
|
|
855
|
+
}),
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
if (fuzzy.ok && fuzzy.value.items.length > 0) {
|
|
859
|
+
fuzzyNotice = `0 exact matches. Maybe you meant this?`;
|
|
860
|
+
result = fuzzy.value;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (result.items.length === 0) throw new Error("No matches found");
|
|
865
|
+
|
|
866
|
+
let output = formatGrepOutput(result);
|
|
867
|
+
const notices: string[] = [];
|
|
868
|
+
if (result.regexFallbackError) {
|
|
869
|
+
notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
|
|
870
|
+
}
|
|
871
|
+
if (result.nextCursor) {
|
|
872
|
+
notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
876
|
+
if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
|
|
877
|
+
|
|
878
|
+
return {
|
|
879
|
+
content: [{ type: "text", text: output }],
|
|
880
|
+
details: {
|
|
881
|
+
totalMatched: result.totalMatched,
|
|
882
|
+
totalFiles: result.totalFiles,
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
},
|
|
886
|
+
|
|
887
|
+
renderCall(args, theme, context) {
|
|
888
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
889
|
+
const pattern = args?.pattern ?? "";
|
|
890
|
+
const path = args?.path ?? ".";
|
|
891
|
+
let content =
|
|
892
|
+
theme.fg("toolTitle", theme.bold(toolNames.grep)) +
|
|
893
|
+
" " +
|
|
894
|
+
theme.fg("accent", `/${pattern}/`) +
|
|
895
|
+
theme.fg("toolOutput", ` in ${path}`);
|
|
896
|
+
if (args?.limit !== undefined)
|
|
897
|
+
content += theme.fg("toolOutput", ` limit ${args.limit}`);
|
|
898
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
899
|
+
text.setText(content);
|
|
900
|
+
return text;
|
|
901
|
+
},
|
|
902
|
+
|
|
903
|
+
renderResult(result, options, theme, context) {
|
|
904
|
+
return renderTextResult(result, options, theme, context, 15);
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// --- find tool ---
|
|
909
|
+
|
|
910
|
+
const findSchema = Type.Object({
|
|
911
|
+
pattern: Type.String({
|
|
912
|
+
description:
|
|
913
|
+
"Fuzzy filename search and glob search. Frecency-ranked, git-aware. Multi-word = narrower (AND) not bound to order, use for multi word related concept search. Prefer this over ls/find/bash as the first exploration step whenever the user names a concept, feature, or symbol — it surfaces the relevant files in one call. Only use ls/read on a directory when you specifically need the alphabetical layout of an unknown repo, or when a concept search returned nothing.",
|
|
914
|
+
}),
|
|
915
|
+
path: Type.Optional(
|
|
916
|
+
Type.String({
|
|
917
|
+
description:
|
|
918
|
+
"Single path constraint: one file, one directory, or one glob. Do not pass multiple paths. Applied to the full repo-relative path.",
|
|
919
|
+
}),
|
|
920
|
+
),
|
|
921
|
+
exclude: Type.Optional(
|
|
922
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
923
|
+
description:
|
|
924
|
+
"Exclude paths (comma/space-separated or array). Same syntax as path: directory prefix ('test/'), filename with extension ('config.json'), or glob ('*.min.js', '**/*.{rs,go}'). A leading '!' is optional and ignored — both 'test/' and '!test/' work. Example: 'test/,*.min.js,!vendor/'.",
|
|
925
|
+
}),
|
|
926
|
+
),
|
|
927
|
+
limit: Type.Optional(
|
|
928
|
+
Type.Number({
|
|
929
|
+
description: `Max results per page (default ${DEFAULT_FIND_LIMIT})`,
|
|
930
|
+
}),
|
|
931
|
+
),
|
|
932
|
+
cursor: Type.Optional(
|
|
933
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
934
|
+
),
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
pi.registerTool({
|
|
938
|
+
name: toolNames.find,
|
|
939
|
+
label: toolNames.find,
|
|
940
|
+
description: `Fuzzy path search and glob search. Matches against the whole repo-relative path, not just the filename. Frecency-ranked, git-aware. Multi-word = narrower (AND). Default limit ${DEFAULT_FIND_LIMIT}.`,
|
|
941
|
+
promptSnippet: "Find files by path or glob",
|
|
942
|
+
promptGuidelines: [
|
|
943
|
+
"Matches the WHOLE path, not just the filename — `profile` hits `chrome/browser/profiles/x.cc` too.",
|
|
944
|
+
"Keep queries to 1-2 terms; extra words narrow.",
|
|
945
|
+
"Use one path constraint only: one file, directory, or glob.",
|
|
946
|
+
"Use for paths, not content. Use grep for content.",
|
|
947
|
+
"For exact path matches use a glob in `path` — e.g. path: '**/profile.h' for exact filename, or path: 'src/**/profile.h' scoped to a subtree. Bare patterns are fuzzy.",
|
|
948
|
+
"To list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.",
|
|
949
|
+
"Use exclude: 'test/,*.min.js' to cut noise in large repos.",
|
|
950
|
+
],
|
|
951
|
+
parameters: findSchema,
|
|
952
|
+
|
|
953
|
+
async execute(_toolCallId, params, signal) {
|
|
954
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
955
|
+
|
|
956
|
+
// Resume from a prior cursor if supplied — cursor owns basePath+query+pageSize
|
|
957
|
+
// so the agent can't accidentally mix patterns across pages.
|
|
958
|
+
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
|
|
959
|
+
if (!params.cursor && params.path && pathLooksLikeMultiplePaths(params.path)) {
|
|
960
|
+
throw new Error(multiplePathsMessage());
|
|
961
|
+
}
|
|
962
|
+
const invalidPath =
|
|
963
|
+
!params.cursor && params.path ? invalidPathMessage(params.path) : null;
|
|
964
|
+
if (invalidPath) throw new Error(invalidPath);
|
|
965
|
+
|
|
966
|
+
const resolvedBase = resolveSearchBase(params.path);
|
|
967
|
+
const basePath = resumed?.basePath ?? resolvedBase.basePath;
|
|
968
|
+
const effectiveLimit = resumed
|
|
969
|
+
? resumed.pageSize
|
|
970
|
+
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
971
|
+
const query = resumed
|
|
972
|
+
? resumed.query
|
|
973
|
+
: buildQuery(
|
|
974
|
+
resolvedBase.pathConstraint,
|
|
975
|
+
params.pattern,
|
|
976
|
+
params.exclude,
|
|
977
|
+
resolvedBase.basePath,
|
|
978
|
+
);
|
|
979
|
+
const pattern = resumed ? resumed.pattern : params.pattern;
|
|
980
|
+
if (!resumed && patternLooksLikePath(pattern)) {
|
|
981
|
+
throw new Error(pathLikePatternMessage(pattern));
|
|
982
|
+
}
|
|
983
|
+
const pageIndex = resumed?.nextPageIndex ?? 0;
|
|
984
|
+
|
|
985
|
+
const searchResult = await withFinderLease(basePath, (finder) =>
|
|
986
|
+
finder.fileSearch(query, {
|
|
987
|
+
pageIndex,
|
|
988
|
+
pageSize: effectiveLimit,
|
|
989
|
+
}),
|
|
990
|
+
);
|
|
991
|
+
if (!searchResult.ok) throw new Error(searchResult.error);
|
|
992
|
+
|
|
993
|
+
let result = searchResult.value;
|
|
994
|
+
if (result.items.length === 0 && /\s/.test(pattern.trim())) {
|
|
995
|
+
const scopedQuery = buildQuery(
|
|
996
|
+
resolvedBase.pathConstraint,
|
|
997
|
+
"",
|
|
998
|
+
params.exclude,
|
|
999
|
+
basePath,
|
|
1000
|
+
);
|
|
1001
|
+
const fallback = await withFinderLease(basePath, (finder) =>
|
|
1002
|
+
finder.fileSearch(scopedQuery, {
|
|
1003
|
+
pageIndex: 0,
|
|
1004
|
+
pageSize: Math.max(effectiveLimit, 500),
|
|
1005
|
+
}),
|
|
1006
|
+
);
|
|
1007
|
+
if (fallback.ok) {
|
|
1008
|
+
const needle = pattern.trim().toLowerCase();
|
|
1009
|
+
const pairs = fallback.value.items
|
|
1010
|
+
.map((item, index) => ({ item, score: fallback.value.scores[index] }))
|
|
1011
|
+
.filter(({ item }) => item.relativePath.toLowerCase().includes(needle))
|
|
1012
|
+
.slice(0, effectiveLimit);
|
|
1013
|
+
if (pairs.length > 0) {
|
|
1014
|
+
result = {
|
|
1015
|
+
...fallback.value,
|
|
1016
|
+
items: pairs.map((pair) => pair.item),
|
|
1017
|
+
scores: pairs.map((pair) => pair.score),
|
|
1018
|
+
totalMatched: pairs.length,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (result.items.length === 0) throw new Error("No files found matching pattern");
|
|
1024
|
+
|
|
1025
|
+
const formatted = formatFindOutput(result, effectiveLimit, pattern);
|
|
1026
|
+
let output = formatted.output;
|
|
1027
|
+
|
|
1028
|
+
// Infer hasMore: native fileSearch fills pageSize when more results
|
|
1029
|
+
// exist, so if we got a full page AND totalMatched exceeds what we've
|
|
1030
|
+
// shown so far there's another page to fetch.
|
|
1031
|
+
const shownSoFar = pageIndex * effectiveLimit + result.items.length;
|
|
1032
|
+
const hasMore =
|
|
1033
|
+
result.items.length >= effectiveLimit && result.totalMatched > shownSoFar;
|
|
1034
|
+
|
|
1035
|
+
const notices: string[] = [];
|
|
1036
|
+
if (formatted.weak && formatted.shownCount > 0)
|
|
1037
|
+
notices.push(
|
|
1038
|
+
`Query "${pattern}" produced only weak scattered fuzzy matches. Output capped at ${formatted.shownCount}/${result.totalMatched}.`,
|
|
1039
|
+
);
|
|
1040
|
+
const hiddenFuzzyMatches = result.totalMatched - formatted.shownCount;
|
|
1041
|
+
if (formatted.literalTailSuppressed && hiddenFuzzyMatches >= 1000)
|
|
1042
|
+
notices.push(`${formatted.shownCount} exact matches shown. Fuzzy tail hidden`);
|
|
1043
|
+
|
|
1044
|
+
if (!formatted.weak && !formatted.literalTailSuppressed && hasMore) {
|
|
1045
|
+
const remaining = result.totalMatched - shownSoFar;
|
|
1046
|
+
const cursorId = storeFindCursor({
|
|
1047
|
+
basePath,
|
|
1048
|
+
query,
|
|
1049
|
+
pattern,
|
|
1050
|
+
pageSize: effectiveLimit,
|
|
1051
|
+
nextPageIndex: pageIndex + 1,
|
|
1052
|
+
});
|
|
1053
|
+
notices.push(`${remaining} more. Next page: find cursor="${cursorId}"`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
1057
|
+
return {
|
|
1058
|
+
content: [{ type: "text", text: output }],
|
|
1059
|
+
details: {
|
|
1060
|
+
totalMatched: result.totalMatched,
|
|
1061
|
+
totalFiles: result.totalFiles,
|
|
1062
|
+
pageIndex,
|
|
1063
|
+
hasMore,
|
|
1064
|
+
},
|
|
1065
|
+
};
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1068
|
+
renderCall(args, theme, context) {
|
|
1069
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
1070
|
+
const pattern = args?.pattern ?? "";
|
|
1071
|
+
const path = args?.path ?? ".";
|
|
1072
|
+
let content =
|
|
1073
|
+
theme.fg("toolTitle", theme.bold(toolNames.find)) +
|
|
1074
|
+
" " +
|
|
1075
|
+
theme.fg("accent", pattern) +
|
|
1076
|
+
theme.fg("toolOutput", ` in ${path}`);
|
|
1077
|
+
if (args?.limit !== undefined)
|
|
1078
|
+
content += theme.fg("toolOutput", ` (limit ${args.limit})`);
|
|
1079
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
1080
|
+
text.setText(content);
|
|
1081
|
+
return text;
|
|
1082
|
+
},
|
|
1083
|
+
|
|
1084
|
+
renderResult(result, options, theme, context) {
|
|
1085
|
+
return renderTextResult(result, options, theme, context, 20);
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// --- multi_grep tool ---
|
|
1090
|
+
// My latest tests are showing that the multi grep tool is only harmful, trying to get rid of it
|
|
1091
|
+
const enableMultiGrep = process.env.PI_FFF_MULTIGREP === "1";
|
|
1092
|
+
|
|
1093
|
+
if (enableMultiGrep) {
|
|
1094
|
+
const multiGrepSchema = Type.Object({
|
|
1095
|
+
patterns: Type.Array(Type.String(), {
|
|
1096
|
+
description:
|
|
1097
|
+
"Literal patterns (OR). Include snake_case/camelCase/PascalCase variants.",
|
|
1098
|
+
}),
|
|
1099
|
+
constraints: Type.Optional(
|
|
1100
|
+
Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
|
|
1101
|
+
),
|
|
1102
|
+
context: Type.Optional(Type.Number({ description: "Context lines before+after" })),
|
|
1103
|
+
limit: Type.Optional(
|
|
1104
|
+
Type.Number({
|
|
1105
|
+
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
1106
|
+
}),
|
|
1107
|
+
),
|
|
1108
|
+
cursor: Type.Optional(Type.String({ description: "Pagination cursor" })),
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
pi.registerTool({
|
|
1112
|
+
name: toolNames.multiGrep,
|
|
1113
|
+
label: toolNames.multiGrep,
|
|
1114
|
+
description:
|
|
1115
|
+
"Search file contents for ANY of multiple literal patterns (OR, SIMD Aho-Corasick). Faster than regex alternation.",
|
|
1116
|
+
promptSnippet: "Multi-pattern OR content search",
|
|
1117
|
+
promptGuidelines: [
|
|
1118
|
+
"Use when searching for several identifiers at once.",
|
|
1119
|
+
"Include all naming-convention variants (snake/camel/Pascal).",
|
|
1120
|
+
"Patterns are literal. Use constraints for file filters.",
|
|
1121
|
+
],
|
|
1122
|
+
parameters: multiGrepSchema,
|
|
1123
|
+
|
|
1124
|
+
async execute(_toolCallId, params, signal) {
|
|
1125
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1126
|
+
if (!params.patterns?.length)
|
|
1127
|
+
throw new Error("patterns array must have at least 1 element");
|
|
1128
|
+
|
|
1129
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
1130
|
+
|
|
1131
|
+
const grepResult = await withFinderLease(activeCwd, (finder) =>
|
|
1132
|
+
finder.multiGrep({
|
|
1133
|
+
patterns: params.patterns,
|
|
1134
|
+
constraints: params.constraints,
|
|
1135
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
1136
|
+
smartCase: true,
|
|
1137
|
+
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
1138
|
+
beforeContext: params.context ?? 0,
|
|
1139
|
+
afterContext: params.context ?? 0,
|
|
1140
|
+
}),
|
|
1141
|
+
);
|
|
1142
|
+
|
|
1143
|
+
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
1144
|
+
|
|
1145
|
+
const result = grepResult.value;
|
|
1146
|
+
if (result.items.length === 0) throw new Error("No matches found");
|
|
1147
|
+
|
|
1148
|
+
let output = formatGrepOutput(result);
|
|
1149
|
+
|
|
1150
|
+
const notices: string[] = [];
|
|
1151
|
+
if (result.items.length >= effectiveLimit)
|
|
1152
|
+
notices.push(`${effectiveLimit}+ matches (refine patterns)`);
|
|
1153
|
+
if (result.nextCursor)
|
|
1154
|
+
notices.push(
|
|
1155
|
+
`More available. cursor="${storeCursor(result.nextCursor)}" to continue`,
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
1159
|
+
|
|
1160
|
+
return {
|
|
1161
|
+
content: [{ type: "text", text: output }],
|
|
1162
|
+
details: {
|
|
1163
|
+
totalMatched: result.totalMatched,
|
|
1164
|
+
totalFiles: result.totalFiles,
|
|
1165
|
+
patterns: params.patterns,
|
|
1166
|
+
},
|
|
1167
|
+
};
|
|
1168
|
+
},
|
|
1169
|
+
|
|
1170
|
+
renderCall(args, theme, context) {
|
|
1171
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
1172
|
+
const patterns = args?.patterns ?? [];
|
|
1173
|
+
const constraints = args?.constraints;
|
|
1174
|
+
let content =
|
|
1175
|
+
theme.fg("toolTitle", theme.bold(toolNames.multiGrep)) +
|
|
1176
|
+
" " +
|
|
1177
|
+
theme.fg("accent", patterns.map((p: string) => `"${p}"`).join(", "));
|
|
1178
|
+
if (constraints) content += theme.fg("toolOutput", ` (${constraints})`);
|
|
1179
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
1180
|
+
text.setText(content);
|
|
1181
|
+
return text;
|
|
1182
|
+
},
|
|
1183
|
+
|
|
1184
|
+
renderResult(result, options, theme, context) {
|
|
1185
|
+
return renderTextResult(result, options, theme, context, 15);
|
|
1186
|
+
},
|
|
1187
|
+
});
|
|
1188
|
+
} // end if (enableMultiGrep)
|
|
1189
|
+
|
|
1190
|
+
// --- commands ---
|
|
1191
|
+
|
|
1192
|
+
pi.registerCommand("fff-mode", {
|
|
1193
|
+
description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
1194
|
+
handler: async (args, ctx) => {
|
|
1195
|
+
const arg = (args || "").trim();
|
|
1196
|
+
|
|
1197
|
+
// No args - show current mode
|
|
1198
|
+
if (!arg) {
|
|
1199
|
+
const mode = getMode();
|
|
1200
|
+
const flag = pi.getFlag("fff-mode") ?? "unset";
|
|
1201
|
+
const env = process.env.PI_FFF_MODE ?? "unset";
|
|
1202
|
+
ctx.ui.notify(`Current mode: '${mode}'\nFlag: ${flag}, Env: ${env}`, "info");
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Validate and set mode
|
|
1207
|
+
if (!VALID_MODES.includes(arg as FffMode)) {
|
|
1208
|
+
ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const newMode = arg as FffMode;
|
|
1213
|
+
const oldMode = getMode();
|
|
1214
|
+
setMode(newMode);
|
|
1215
|
+
|
|
1216
|
+
// Apply immediately using the shared function
|
|
1217
|
+
applyEditorMode(ctx);
|
|
1218
|
+
|
|
1219
|
+
const note =
|
|
1220
|
+
(oldMode === "override") !== (newMode === "override")
|
|
1221
|
+
? " (tool name change requires restart)"
|
|
1222
|
+
: "";
|
|
1223
|
+
ctx.ui.notify(`Mode changed: '${oldMode}' → '${newMode}'${note}`, "info");
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
pi.registerCommand("fff-health", {
|
|
1228
|
+
description: "Show FFF file finder health and status",
|
|
1229
|
+
handler: async (_args, ctx) => {
|
|
1230
|
+
const finder = getActiveFinder();
|
|
1231
|
+
if (!finder) {
|
|
1232
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const health = finder.healthCheck();
|
|
1237
|
+
if (!health.ok) {
|
|
1238
|
+
ctx.ui.notify(`Health check failed: ${health.error}`, "error");
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const h = health.value;
|
|
1243
|
+
const lines = [
|
|
1244
|
+
`FFF v${h.version}`,
|
|
1245
|
+
`Mode: ${getMode()}`,
|
|
1246
|
+
`Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
|
|
1247
|
+
`Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
|
1248
|
+
`Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
|
|
1249
|
+
`Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
|
|
1250
|
+
];
|
|
1251
|
+
|
|
1252
|
+
const progress = finder.getScanProgress();
|
|
1253
|
+
if (progress.ok) {
|
|
1254
|
+
lines.push(
|
|
1255
|
+
`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
1260
|
+
},
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
pi.registerCommand("fff-rescan", {
|
|
1264
|
+
description: "Trigger FFF to rescan files",
|
|
1265
|
+
handler: async (_args, ctx) => {
|
|
1266
|
+
const finder = getActiveFinder();
|
|
1267
|
+
if (!finder) {
|
|
1268
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const result = finder.scanFiles();
|
|
1273
|
+
if (!result.ok) {
|
|
1274
|
+
ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
ctx.ui.notify("FFF rescan triggered", "info");
|
|
1279
|
+
},
|
|
1280
|
+
});
|
|
1281
|
+
}
|