@nmzpy/pi-ember-stack 0.1.6 → 0.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.
@@ -0,0 +1,717 @@
1
+ /**
2
+ * pi-ember-fff: Ember-owned FFF-powered file search extension for pi.
3
+ *
4
+ * Forked from @ff-labs/pi-fff 0.9.6 (MIT, Copyright (c) Dmitry Kovalenko).
5
+ * Always registers canonical `grep` and `find` tool names (override mode),
6
+ * and delegates rendering to the shared Ember compact renderer from
7
+ * @nmzpy/pi-ember-stack so the TUI stays consistent across all tools.
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ type AutocompleteItem,
13
+ type AutocompleteProvider,
14
+ } from "@earendil-works/pi-tui";
15
+ import type {
16
+ GrepCursor,
17
+ GrepMode,
18
+ GrepResult,
19
+ MixedItem,
20
+ SearchResult,
21
+ } from "@ff-labs/fff-node";
22
+ import { FileFinder } from "@ff-labs/fff-node";
23
+ import { Type } from "@sinclair/typebox";
24
+ import { buildQuery } from "./query.ts";
25
+ import { getSharedRenderer } from "../pi-compact-tools/index.ts";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Constants
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const DEFAULT_GREP_LIMIT = 20;
32
+ const DEFAULT_FIND_LIMIT = 30;
33
+ const GREP_MAX_LINE_LENGTH = 500;
34
+ const MENTION_MAX_RESULTS = 20;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Cursor store — simple bounded Map for pagination cursors
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const cursorCache = new Map<string, GrepCursor>();
41
+ let cursorCounter = 0;
42
+
43
+ function storeCursor(cursor: GrepCursor): string {
44
+ const id = `fff_c${++cursorCounter}`;
45
+ cursorCache.set(id, cursor);
46
+ if (cursorCache.size > 200) {
47
+ const first = cursorCache.keys().next().value;
48
+ if (first) cursorCache.delete(first);
49
+ }
50
+ return id;
51
+ }
52
+
53
+ function getCursor(id: string): GrepCursor | undefined {
54
+ return cursorCache.get(id);
55
+ }
56
+
57
+ interface FindCursor {
58
+ query: string;
59
+ pattern: string;
60
+ pageSize: number;
61
+ nextPageIndex: number;
62
+ }
63
+
64
+ const findCursorCache = new Map<string, FindCursor>();
65
+ let findCursorCounter = 0;
66
+
67
+ function storeFindCursor(cursor: FindCursor): string {
68
+ const id = `${++findCursorCounter}`;
69
+ findCursorCache.set(id, cursor);
70
+ if (findCursorCache.size > 200) {
71
+ const first = findCursorCache.keys().next().value;
72
+ if (first) findCursorCache.delete(first);
73
+ }
74
+ return id;
75
+ }
76
+
77
+ function getFindCursor(id: string): FindCursor | undefined {
78
+ return findCursorCache.get(id);
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Output formatting helpers
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function truncateLine(line: string, max = GREP_MAX_LINE_LENGTH): string {
86
+ const trimmed = line.trim();
87
+ return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}...`;
88
+ }
89
+
90
+ const HOT_FRECENCY = 25;
91
+ const WARM_FRECENCY = 20;
92
+
93
+ export function fffFileAnnotation(item: {
94
+ gitStatus?: string;
95
+ totalFrecencyScore?: number;
96
+ accessFrecencyScore?: number;
97
+ }): string {
98
+ const git = item.gitStatus;
99
+ if (git && git !== "clean" && git !== "unknown" && git !== "") {
100
+ return ` [${git} in git]`;
101
+ }
102
+
103
+ const frecency = item.totalFrecencyScore ?? item.accessFrecencyScore ?? 0;
104
+ if (frecency >= HOT_FRECENCY) return " [VERY often touched file]";
105
+ if (frecency >= WARM_FRECENCY) return " [often touched file]";
106
+
107
+ return "";
108
+ }
109
+
110
+ function formatGrepOutput(result: GrepResult): string {
111
+ if (result.items.length === 0) return "No matches found";
112
+
113
+ const lines: string[] = [];
114
+ let currentFile = "";
115
+
116
+ for (const match of result.items) {
117
+ if (match.relativePath !== currentFile) {
118
+ if (lines.length > 0) lines.push("");
119
+ currentFile = match.relativePath;
120
+ lines.push(`${currentFile}${fffFileAnnotation(match)}`);
121
+ }
122
+
123
+ match.contextBefore?.forEach((line: string, i: number) => {
124
+ const lineNum = match.lineNumber - match.contextBefore!.length + i;
125
+ lines.push(` ${lineNum}- ${truncateLine(line)}`);
126
+ });
127
+
128
+ lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
129
+
130
+ match.contextAfter?.forEach((line: string, i: number) => {
131
+ const lineNum = match.lineNumber + 1 + i;
132
+ lines.push(` ${lineNum}- ${truncateLine(line)}`);
133
+ });
134
+ }
135
+
136
+ return lines.join("\n");
137
+ }
138
+
139
+ const FIND_WEAK_SAMPLE_SIZE = 5;
140
+
141
+ function weakScoreThreshold(pattern: string): number {
142
+ const perfect = pattern.length * 12;
143
+ return Math.floor((perfect * 50) / 100);
144
+ }
145
+
146
+ interface FormattedFind {
147
+ output: string;
148
+ weak: boolean;
149
+ shownCount: number;
150
+ }
151
+
152
+ function formatFindOutput(
153
+ result: SearchResult,
154
+ limit: number,
155
+ pattern: string,
156
+ ): FormattedFind {
157
+ if (result.items.length === 0) {
158
+ return {
159
+ output: "No files found matching pattern",
160
+ weak: false,
161
+ shownCount: 0,
162
+ };
163
+ }
164
+
165
+ const reordered = result.items.map((item) => ({ item }));
166
+
167
+ const topScore = result.scores[0]?.total ?? 0;
168
+ const weak = topScore < weakScoreThreshold(pattern);
169
+ const effective = weak ? Math.min(FIND_WEAK_SAMPLE_SIZE, limit) : limit;
170
+ const shown = reordered.slice(0, effective);
171
+
172
+ return {
173
+ output: shown
174
+ .map((p) => `${p.item.relativePath}${fffFileAnnotation(p.item)}`)
175
+ .join("\n"),
176
+ weak,
177
+ shownCount: shown.length,
178
+ };
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Mention autocomplete helpers
183
+ // ---------------------------------------------------------------------------
184
+
185
+ function extractAtPrefix(textBeforeCursor: string): string | null {
186
+ const match = textBeforeCursor.match(/(?:^|[ \t])(@(?:"[^"]*|[^\s]*))$/);
187
+ return match?.[1] ?? null;
188
+ }
189
+
190
+ function buildAtCompletionValue(path: string): string {
191
+ return path.includes(" ") ? `@"${path}"` : `@${path}`;
192
+ }
193
+
194
+ function createFffMentionProvider(
195
+ getItems: (query: string, signal: AbortSignal) => Promise<AutocompleteItem[]>,
196
+ ): AutocompleteProvider {
197
+ return {
198
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
199
+ const currentLine = lines[cursorLine] || "";
200
+ const prefix = extractAtPrefix(currentLine.slice(0, cursorCol));
201
+ if (!prefix || options.signal.aborted) return null;
202
+
203
+ const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
204
+ const items = await getItems(query, options.signal);
205
+ return options.signal.aborted || items.length === 0 ? null : { items, prefix };
206
+ },
207
+ applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
208
+ const currentLine = _lines[cursorLine] || "";
209
+ const before = currentLine.slice(0, cursorCol - prefix.length);
210
+ const after = currentLine.slice(cursorCol);
211
+ const newLine = before + item.value + after;
212
+ const newCursorCol = cursorCol - prefix.length + item.value.length;
213
+ return {
214
+ lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
215
+ cursorLine,
216
+ cursorCol: newCursorCol,
217
+ };
218
+ },
219
+ };
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Extension
224
+ // ---------------------------------------------------------------------------
225
+
226
+ export default function emberFffExtension(pi: ExtensionAPI) {
227
+ const renderer = getSharedRenderer();
228
+
229
+ let finder: FileFinder | null = null;
230
+ let finderCwd: string | null = null;
231
+ let finderPromise: Promise<FileFinder> | null = null;
232
+ let activeCwd = process.cwd();
233
+
234
+ const frecencyDbPath =
235
+ (pi.getFlag("fff-frecency-db") as string | undefined) ??
236
+ process.env.FFF_FRECENCY_DB ??
237
+ undefined;
238
+ const historyDbPath =
239
+ (pi.getFlag("fff-history-db") as string | undefined) ??
240
+ process.env.FFF_HISTORY_DB ??
241
+ undefined;
242
+
243
+ function resolveBoolOpt(flagName: string, envName: string): boolean {
244
+ const flag = pi.getFlag(flagName);
245
+ if (typeof flag === "boolean") return flag;
246
+ if (typeof flag === "string") return flag === "true" || flag === "1";
247
+ const env = process.env[envName];
248
+ return env === "1" || env === "true";
249
+ }
250
+ const enableFsRootScanning = resolveBoolOpt(
251
+ "fff-enable-root-scan",
252
+ "FFF_ENABLE_ROOT_SCAN",
253
+ );
254
+
255
+ function ensureFinder(cwd: string): Promise<FileFinder> {
256
+ if (finder && !finder.isDestroyed && finderCwd === cwd)
257
+ return Promise.resolve(finder);
258
+ if (finderPromise) return finderPromise;
259
+
260
+ finderPromise = (async () => {
261
+ if (finder && !finder.isDestroyed) {
262
+ finder.destroy();
263
+ finder = null;
264
+ finderCwd = null;
265
+ }
266
+
267
+ const result = FileFinder.create({
268
+ basePath: cwd,
269
+ frecencyDbPath,
270
+ historyDbPath,
271
+ aiMode: true,
272
+ enableHomeDirScanning: true,
273
+ enableFsRootScanning,
274
+ });
275
+
276
+ if (!result.ok)
277
+ throw new Error(`Failed to create FFF file finder: ${result.error}`);
278
+
279
+ finder = result.value;
280
+ finderCwd = cwd;
281
+ await finder.waitForScan(15000);
282
+ return finder;
283
+ })().finally(() => {
284
+ finderPromise = null;
285
+ });
286
+
287
+ return finderPromise;
288
+ }
289
+
290
+ function destroyFinder() {
291
+ if (finder && !finder.isDestroyed) {
292
+ finder.destroy();
293
+ finder = null;
294
+ finderCwd = null;
295
+ }
296
+ }
297
+
298
+ async function getMentionItems(
299
+ query: string,
300
+ signal: AbortSignal,
301
+ ): Promise<AutocompleteItem[]> {
302
+ if (signal.aborted) return [];
303
+ const f = await ensureFinder(activeCwd);
304
+ if (signal.aborted) return [];
305
+
306
+ const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
307
+ if (!result.ok) return [];
308
+
309
+ return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
310
+ if (mixed.type === "directory") {
311
+ return {
312
+ value: buildAtCompletionValue(mixed.item.relativePath),
313
+ label: mixed.item.dirName,
314
+ description: mixed.item.relativePath,
315
+ };
316
+ }
317
+ return {
318
+ value: buildAtCompletionValue(mixed.item.relativePath),
319
+ label: mixed.item.fileName,
320
+ description: mixed.item.relativePath,
321
+ };
322
+ });
323
+ }
324
+
325
+ function registerAutocompleteProvider(ctx: {
326
+ ui: {
327
+ addAutocompleteProvider?: (
328
+ factory: (current: AutocompleteProvider) => AutocompleteProvider,
329
+ ) => void;
330
+ };
331
+ }) {
332
+ if (typeof ctx.ui.addAutocompleteProvider !== "function") return;
333
+
334
+ ctx.ui.addAutocompleteProvider((current) => {
335
+ const mentionProvider = createFffMentionProvider(getMentionItems);
336
+
337
+ return {
338
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
339
+ try {
340
+ const mentionResult = await mentionProvider.getSuggestions(
341
+ lines,
342
+ cursorLine,
343
+ cursorCol,
344
+ options,
345
+ );
346
+ if (mentionResult) return mentionResult;
347
+ } catch {
348
+ // Delegate when FFF lookup is unavailable.
349
+ }
350
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
351
+ },
352
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
353
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
354
+ },
355
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
356
+ return (
357
+ current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true
358
+ );
359
+ },
360
+ };
361
+ });
362
+ }
363
+
364
+ // --- Flags / lifecycle ---
365
+
366
+ pi.registerFlag("fff-frecency-db", {
367
+ description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
368
+ type: "string",
369
+ });
370
+
371
+ pi.registerFlag("fff-history-db", {
372
+ description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
373
+ type: "string",
374
+ });
375
+
376
+ pi.registerFlag("fff-enable-root-scan", {
377
+ description:
378
+ "Allow indexing when launched from the filesystem root (also: FFF_ENABLE_ROOT_SCAN env)",
379
+ type: "boolean",
380
+ });
381
+
382
+ pi.on("session_start", async (_event, ctx) => {
383
+ try {
384
+ activeCwd = ctx.cwd;
385
+ registerAutocompleteProvider(ctx);
386
+ await ensureFinder(activeCwd);
387
+ } catch (e: unknown) {
388
+ ctx.ui.notify(
389
+ `FFF init failed: ${e instanceof Error ? e.message : String(e)}`,
390
+ "error",
391
+ );
392
+ }
393
+ });
394
+
395
+ pi.on("session_shutdown", async () => {
396
+ destroyFinder();
397
+ });
398
+
399
+ // --- grep tool ---
400
+
401
+ const grepSchema = Type.Object({
402
+ pattern: Type.String({
403
+ description: "Search pattern (literal text or regex)",
404
+ }),
405
+ path: Type.Optional(
406
+ Type.String({
407
+ description:
408
+ "Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
409
+ }),
410
+ ),
411
+ exclude: Type.Optional(
412
+ Type.Union([Type.String(), Type.Array(Type.String())], {
413
+ description:
414
+ "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/'.",
415
+ }),
416
+ ),
417
+ caseSensitive: Type.Optional(
418
+ Type.Boolean({
419
+ description:
420
+ "Force case-sensitive matching. Default uses smart-case (case-insensitive when pattern is all lowercase).",
421
+ }),
422
+ ),
423
+ context: Type.Optional(
424
+ Type.Number({ description: "Context lines before+after each match" }),
425
+ ),
426
+ limit: Type.Optional(
427
+ Type.Number({
428
+ description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
429
+ }),
430
+ ),
431
+ cursor: Type.Optional(
432
+ Type.String({ description: "Pagination cursor from previous result" }),
433
+ ),
434
+ });
435
+
436
+ pi.registerTool({
437
+ name: "grep",
438
+ label: "grep",
439
+ 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}.`,
440
+ promptSnippet: "Grep contents",
441
+ promptGuidelines: [
442
+ "Prefer bare identifiers as patterns. Literal queries are most efficient.",
443
+ "Use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').",
444
+ "caseSensitive: true when you need exact case (smart-case otherwise).",
445
+ "After 1-2 greps, read the top match instead of more greps.",
446
+ ],
447
+ parameters: grepSchema,
448
+ renderShell: "self",
449
+
450
+ async execute(_toolCallId, params, signal) {
451
+ if (signal?.aborted) throw new Error("Operation aborted");
452
+
453
+ const f = await ensureFinder(activeCwd);
454
+ const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
455
+ const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
456
+ const hasRegexSyntax =
457
+ params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
458
+ let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
459
+ if (mode === "regex") {
460
+ try {
461
+ new RegExp(params.pattern);
462
+ } catch {
463
+ mode = "plain";
464
+ }
465
+ }
466
+
467
+ const p = params.pattern.trim();
468
+ const isWildcardOnly =
469
+ hasRegexSyntax &&
470
+ /^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
471
+ p,
472
+ );
473
+
474
+ if (isWildcardOnly) {
475
+ return {
476
+ content: [
477
+ {
478
+ type: "text",
479
+ text: `Pattern '${params.pattern}' matches everything — grep needs a concrete substring or identifier. Example: \`pattern: 'MyClass'\` or \`pattern: 'export function'\`.`,
480
+ },
481
+ ],
482
+ details: { totalMatched: 0, totalFiles: 0 },
483
+ };
484
+ }
485
+
486
+ const smartCase = params.caseSensitive !== true;
487
+
488
+ const grepResult = f.grep(query, {
489
+ mode,
490
+ smartCase,
491
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
492
+ cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
493
+ beforeContext: params.context ?? 0,
494
+ afterContext: params.context ?? 0,
495
+ classifyDefinitions: true,
496
+ });
497
+
498
+ if (!grepResult.ok) throw new Error(grepResult.error);
499
+
500
+ let result = grepResult.value;
501
+ let fuzzyNotice: string | null = null;
502
+
503
+ if (result.items.length === 0 && !params.cursor && mode !== "regex") {
504
+ const fuzzy = f.grep(params.pattern, {
505
+ mode: "fuzzy",
506
+ smartCase,
507
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
508
+ cursor: null,
509
+ beforeContext: 0,
510
+ afterContext: 0,
511
+ classifyDefinitions: true,
512
+ });
513
+
514
+ if (fuzzy.ok && fuzzy.value.items.length > 0) {
515
+ fuzzyNotice = `0 exact matches. Maybe you meant this?`;
516
+ result = fuzzy.value;
517
+ }
518
+ }
519
+
520
+ let output = formatGrepOutput(result);
521
+ const notices: string[] = [];
522
+ if (result.regexFallbackError) {
523
+ notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
524
+ }
525
+ if (result.nextCursor) {
526
+ notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
527
+ }
528
+
529
+ if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
530
+ if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
531
+
532
+ return {
533
+ content: [{ type: "text", text: output }],
534
+ details: {
535
+ totalMatched: result.totalMatched,
536
+ totalFiles: result.totalFiles,
537
+ },
538
+ };
539
+ },
540
+
541
+ renderCall(args: any, theme: any, context: any) {
542
+ return renderer.renderCall("grep", args, theme, context);
543
+ },
544
+
545
+ renderResult(result: any, options: any, theme: any, context: any) {
546
+ return renderer.renderResult("grep", context.args, result, options, theme, context);
547
+ },
548
+ });
549
+
550
+ // --- find tool ---
551
+
552
+ const findSchema = Type.Object({
553
+ pattern: Type.String({
554
+ description:
555
+ "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.",
556
+ }),
557
+ path: Type.Optional(
558
+ Type.String({
559
+ description:
560
+ "Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
561
+ }),
562
+ ),
563
+ exclude: Type.Optional(
564
+ Type.Union([Type.String(), Type.Array(Type.String())], {
565
+ description:
566
+ "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/'.",
567
+ }),
568
+ ),
569
+ limit: Type.Optional(
570
+ Type.Number({
571
+ description: `Max results per page (default ${DEFAULT_FIND_LIMIT})`,
572
+ }),
573
+ ),
574
+ cursor: Type.Optional(
575
+ Type.String({ description: "Pagination cursor from previous result" }),
576
+ ),
577
+ });
578
+
579
+ pi.registerTool({
580
+ name: "find",
581
+ label: "find",
582
+ 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}.`,
583
+ promptSnippet: "Find files by path or glob",
584
+ promptGuidelines: [
585
+ "Matches the WHOLE path, not just the filename — `profile` hits `chrome/browser/profiles/x.cc` too.",
586
+ "Keep queries to 1-2 terms; extra words narrow.",
587
+ "Use for paths, not content. Use grep for content.",
588
+ "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.",
589
+ "To list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.",
590
+ "Use exclude: 'test/,*.min.js' to cut noise in large repos.",
591
+ ],
592
+ parameters: findSchema,
593
+ renderShell: "self",
594
+
595
+ async execute(_toolCallId, params, signal) {
596
+ if (signal?.aborted) throw new Error("Operation aborted");
597
+
598
+ const f = await ensureFinder(activeCwd);
599
+
600
+ const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
601
+ const effectiveLimit = resumed
602
+ ? resumed.pageSize
603
+ : Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
604
+ const query = resumed
605
+ ? resumed.query
606
+ : buildQuery(params.path, params.pattern, params.exclude, activeCwd);
607
+ const pattern = resumed ? resumed.pattern : params.pattern;
608
+ const pageIndex = resumed?.nextPageIndex ?? 0;
609
+
610
+ const searchResult = f.fileSearch(query, {
611
+ pageIndex,
612
+ pageSize: effectiveLimit,
613
+ });
614
+ if (!searchResult.ok) throw new Error(searchResult.error);
615
+
616
+ const result = searchResult.value;
617
+ const formatted = formatFindOutput(result, effectiveLimit, pattern);
618
+ let output = formatted.output;
619
+
620
+ const shownSoFar = pageIndex * effectiveLimit + result.items.length;
621
+ const hasMore =
622
+ result.items.length >= effectiveLimit && result.totalMatched > shownSoFar;
623
+
624
+ const notices: string[] = [];
625
+ if (formatted.weak && formatted.shownCount > 0)
626
+ notices.push(
627
+ `Query "${pattern}" produced only weak scattered fuzzy matches. Output capped at ${formatted.shownCount}/${result.totalMatched}.`,
628
+ );
629
+
630
+ if (!formatted.weak && hasMore) {
631
+ const remaining = result.totalMatched - shownSoFar;
632
+ const cursorId = storeFindCursor({
633
+ query,
634
+ pattern,
635
+ pageSize: effectiveLimit,
636
+ nextPageIndex: pageIndex + 1,
637
+ });
638
+ notices.push(
639
+ `${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
640
+ );
641
+ }
642
+
643
+ if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
644
+ return {
645
+ content: [{ type: "text", text: output }],
646
+ details: {
647
+ totalMatched: result.totalMatched,
648
+ totalFiles: result.totalFiles,
649
+ pageIndex,
650
+ hasMore,
651
+ },
652
+ };
653
+ },
654
+
655
+ renderCall(args: any, theme: any, context: any) {
656
+ return renderer.renderCall("find", args, theme, context);
657
+ },
658
+
659
+ renderResult(result: any, options: any, theme: any, context: any) {
660
+ return renderer.renderResult("find", context.args, result, options, theme, context);
661
+ },
662
+ });
663
+
664
+ // --- commands ---
665
+
666
+ pi.registerCommand("fff-health", {
667
+ description: "Show FFF file finder health and status",
668
+ handler: async (_args, ctx) => {
669
+ if (!finder || finder.isDestroyed) {
670
+ ctx.ui.notify("FFF not initialized", "warning");
671
+ return;
672
+ }
673
+
674
+ const health = finder.healthCheck();
675
+ if (!health.ok) {
676
+ ctx.ui.notify(`Health check failed: ${health.error}`, "error");
677
+ return;
678
+ }
679
+
680
+ const h = health.value;
681
+ const lines = [
682
+ `FFF v${h.version}`,
683
+ `Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
684
+ `Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
685
+ `Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
686
+ `Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
687
+ ];
688
+
689
+ const progress = finder.getScanProgress();
690
+ if (progress.ok) {
691
+ lines.push(
692
+ `Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
693
+ );
694
+ }
695
+
696
+ ctx.ui.notify(lines.join("\n"), "info");
697
+ },
698
+ });
699
+
700
+ pi.registerCommand("fff-rescan", {
701
+ description: "Trigger FFF to rescan files",
702
+ handler: async (_args, ctx) => {
703
+ if (!finder || finder.isDestroyed) {
704
+ ctx.ui.notify("FFF not initialized", "warning");
705
+ return;
706
+ }
707
+
708
+ const result = finder.scanFiles();
709
+ if (!result.ok) {
710
+ ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
711
+ return;
712
+ }
713
+
714
+ ctx.ui.notify("FFF rescan triggered", "info");
715
+ },
716
+ });
717
+ }