@heyhuynhgiabuu/pi-pretty 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyhuynhgiabuu/pi-pretty",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Pretty terminal output for pi — syntax-highlighted file reads, colored bash output, tree-view directory listings, and more.",
5
5
  "author": "huynhgiabuu",
6
6
  "license": "MIT",
@@ -24,6 +24,9 @@
24
24
  "dependencies": {
25
25
  "@shikijs/cli": "^4.0.2"
26
26
  },
27
+ "optionalDependencies": {
28
+ "@ff-labs/fff-node": "0.5.2"
29
+ },
27
30
  "peerDependencies": {
28
31
  "@mariozechner/pi-coding-agent": "*",
29
32
  "@mariozechner/pi-tui": "*"
@@ -0,0 +1,73 @@
1
+ /**
2
+ * FFF helper functions — extracted for testability.
3
+ *
4
+ * Pure functions and classes used by the FFF integration in index.ts.
5
+ */
6
+
7
+ /**
8
+ * Store for FFF grep pagination cursors.
9
+ * Evicts oldest entry when exceeding maxSize.
10
+ */
11
+ export class CursorStore {
12
+ private cursors = new Map<string, any>();
13
+ private counter = 0;
14
+ private maxSize: number;
15
+
16
+ constructor(maxSize = 200) {
17
+ this.maxSize = maxSize;
18
+ }
19
+
20
+ store(cursor: any): string {
21
+ const id = `fff_c${++this.counter}`;
22
+ this.cursors.set(id, cursor);
23
+ if (this.cursors.size > this.maxSize) {
24
+ const first = this.cursors.keys().next().value;
25
+ if (first) this.cursors.delete(first);
26
+ }
27
+ return id;
28
+ }
29
+
30
+ get(id: string): any | undefined {
31
+ return this.cursors.get(id);
32
+ }
33
+
34
+ get size(): number {
35
+ return this.cursors.size;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Convert FFF GrepResult items to ripgrep-style "file:line:content" text.
41
+ * This ensures pi-pretty's renderGrepResults works unchanged.
42
+ */
43
+ export function fffFormatGrepText(items: any[], limit: number): string {
44
+ const capped = items.slice(0, limit);
45
+ if (!capped.length) return "No matches found";
46
+
47
+ const lines: string[] = [];
48
+ let currentFile = "";
49
+
50
+ for (const match of capped) {
51
+ if (match.relativePath !== currentFile) {
52
+ if (currentFile) lines.push("");
53
+ currentFile = match.relativePath;
54
+ }
55
+ if (match.contextBefore?.length) {
56
+ const startLine = match.lineNumber - match.contextBefore.length;
57
+ for (let i = 0; i < match.contextBefore.length; i++) {
58
+ lines.push(`${match.relativePath}-${startLine + i}-${match.contextBefore[i]}`);
59
+ }
60
+ }
61
+ const content =
62
+ match.lineContent.length > 500 ? `${match.lineContent.slice(0, 500)}...` : match.lineContent;
63
+ lines.push(`${match.relativePath}:${match.lineNumber}:${content}`);
64
+ if (match.contextAfter?.length) {
65
+ const startLine = match.lineNumber + 1;
66
+ for (let i = 0; i < match.contextAfter.length; i++) {
67
+ lines.push(`${match.relativePath}-${startLine + i}-${match.contextAfter[i]}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ return lines.join("\n");
73
+ }
package/src/index.ts CHANGED
@@ -23,12 +23,14 @@
23
23
  * • Large-file fallback (skip highlighting, still show line numbers)
24
24
  */
25
25
 
26
- import { existsSync, statSync } from "node:fs";
27
- import { basename, dirname, extname, relative } from "node:path";
26
+ import { existsSync, mkdirSync, statSync } from "node:fs";
27
+ import { basename, dirname, extname, join, relative } from "node:path";
28
28
 
29
29
  import { codeToANSI } from "@shikijs/cli";
30
30
  import type { BundledLanguage, BundledTheme } from "shiki";
31
31
 
32
+ import { CursorStore, fffFormatGrepText } from "./fff-helpers.js";
33
+
32
34
  // ---------------------------------------------------------------------------
33
35
  // Config
34
36
  // ---------------------------------------------------------------------------
@@ -669,11 +671,63 @@ async function renderGrepResults(text: string, pattern: string): Promise<string>
669
671
  return out.join("\n");
670
672
  }
671
673
 
674
+ // ---------------------------------------------------------------------------
675
+ // FFF integration (optional) — Fast File Finder with frecency & SIMD search
676
+ //
677
+ // If @ff-labs/fff-node is installed, find/grep use FFF for speed + frecency.
678
+ // If not, falls back to wrapping SDK tools (current behavior).
679
+ // ---------------------------------------------------------------------------
680
+
681
+ const _cursorStore = new CursorStore();
682
+ let _fffModule: any = null;
683
+ let _fffFinder: any = null;
684
+ let _fffPartialIndex = false;
685
+ let _fffDbDir: string | null = null;
686
+ const FFF_SCAN_TIMEOUT = 15_000;
687
+
688
+ async function fffEnsureFinder(cwd: string): Promise<any> {
689
+ if (_fffFinder && !_fffFinder.isDestroyed) return _fffFinder;
690
+ if (!_fffModule || !_fffDbDir) return null;
691
+
692
+ const result = _fffModule.FileFinder.create({
693
+ basePath: cwd,
694
+ frecencyDbPath: join(_fffDbDir, "frecency.mdb"),
695
+ historyDbPath: join(_fffDbDir, "history.mdb"),
696
+ aiMode: true,
697
+ });
698
+
699
+ if (!result.ok) throw new Error(`FFF init failed: ${result.error}`);
700
+
701
+ _fffFinder = result.value;
702
+ const scan = await _fffFinder.waitForScan(FFF_SCAN_TIMEOUT);
703
+ _fffPartialIndex = scan.ok && !scan.value;
704
+
705
+ return _fffFinder;
706
+ }
707
+
708
+ function fffDestroy(): void {
709
+ if (_fffFinder && !_fffFinder.isDestroyed) {
710
+ _fffFinder.destroy();
711
+ _fffFinder = null;
712
+ }
713
+ _fffPartialIndex = false;
714
+ }
715
+
672
716
  // ---------------------------------------------------------------------------
673
717
  // Extension entry point
674
718
  // ---------------------------------------------------------------------------
675
719
 
676
- export default function piPrettyExtension(pi: any): void {
720
+ /**
721
+ * Dependencies that can be injected for testing.
722
+ * In production, omit `deps` — the extension uses require() to load them.
723
+ */
724
+ export interface PiPrettyDeps {
725
+ sdk: any;
726
+ TextComponent: any;
727
+ fffModule?: any;
728
+ }
729
+
730
+ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
677
731
  let createReadTool: any;
678
732
  let createBashTool: any;
679
733
  let createLsTool: any;
@@ -681,16 +735,33 @@ export default function piPrettyExtension(pi: any): void {
681
735
  let createGrepTool: any;
682
736
  let TextComponent: any;
683
737
 
684
- try {
685
- const sdk = require("@mariozechner/pi-coding-agent");
738
+ let sdk: any;
739
+
740
+ if (deps) {
741
+ // Test path: use injected dependencies, reset module state
742
+ sdk = deps.sdk;
686
743
  createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
687
744
  createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
688
745
  createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
689
746
  createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
690
747
  createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
691
- TextComponent = require("@mariozechner/pi-tui").Text;
692
- } catch {
693
- return;
748
+ TextComponent = deps.TextComponent;
749
+ _fffModule = deps.fffModule ?? null;
750
+ _fffFinder = null;
751
+ _fffPartialIndex = false;
752
+ _fffDbDir = null;
753
+ } else {
754
+ try {
755
+ sdk = require("@mariozechner/pi-coding-agent");
756
+ createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
757
+ createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
758
+ createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
759
+ createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
760
+ createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
761
+ TextComponent = require("@mariozechner/pi-tui").Text;
762
+ } catch {
763
+ return;
764
+ }
694
765
  }
695
766
  if (!createReadTool || !TextComponent) return;
696
767
 
@@ -698,6 +769,66 @@ export default function piPrettyExtension(pi: any): void {
698
769
  const home = process.env.HOME ?? "";
699
770
  const sp = (p: string) => shortPath(cwd, home, p);
700
771
 
772
+ // ===================================================================
773
+ // FFF initialization (optional — graceful fallback to SDK)
774
+ // ===================================================================
775
+
776
+ const getAgentDir = (sdk as any).getAgentDir;
777
+ if (!deps) {
778
+ // Only try require() in production — tests inject fffModule via deps
779
+ try {
780
+ _fffModule = require("@ff-labs/fff-node");
781
+ if (getAgentDir) {
782
+ _fffDbDir = join(getAgentDir(), "fff");
783
+ try {
784
+ mkdirSync(_fffDbDir, { recursive: true });
785
+ } catch {}
786
+ }
787
+ } catch {
788
+ /* FFF not installed — SDK tools will be used */
789
+ }
790
+ } else if (_fffModule && getAgentDir) {
791
+ _fffDbDir = join(getAgentDir(), "fff");
792
+ try {
793
+ mkdirSync(_fffDbDir, { recursive: true });
794
+ } catch {}
795
+ }
796
+
797
+ pi.on("session_start", async (_event: any, ctx: any) => {
798
+ // Try dynamic import if sync require failed (ESM-only package)
799
+ if (!_fffModule) {
800
+ try {
801
+ // @ts-ignore — optional dependency, may not be installed
802
+ _fffModule = await import("@ff-labs/fff-node");
803
+ } catch {}
804
+ }
805
+ if (!_fffModule) return;
806
+
807
+ if (!_fffDbDir) {
808
+ const agentDir = getAgentDir?.() ?? join(home, ".pi/agent");
809
+ _fffDbDir = join(agentDir, "fff");
810
+ try {
811
+ mkdirSync(_fffDbDir, { recursive: true });
812
+ } catch {}
813
+ }
814
+
815
+ try {
816
+ await fffEnsureFinder(ctx.cwd);
817
+ if (_fffPartialIndex) {
818
+ ctx.ui?.notify?.("FFF: scan timed out — using partial index. Run /fff-rescan when ready.", "warning");
819
+ } else {
820
+ ctx.ui?.setStatus?.("fff", "FFF indexed");
821
+ setTimeout(() => ctx.ui?.setStatus?.("fff", undefined), 3000);
822
+ }
823
+ } catch (e: any) {
824
+ ctx.ui?.notify?.(`FFF init failed: ${e.message}`, "error");
825
+ }
826
+ });
827
+
828
+ pi.on("session_shutdown", async () => {
829
+ fffDestroy();
830
+ });
831
+
701
832
  // ===================================================================
702
833
  // read — syntax-highlighted file content
703
834
  // ===================================================================
@@ -1013,6 +1144,43 @@ export default function piPrettyExtension(pi: any): void {
1013
1144
  name: "find",
1014
1145
 
1015
1146
  async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1147
+ // Try FFF first (frecency-ranked, SIMD-accelerated)
1148
+ if (_fffFinder && !_fffFinder.isDestroyed) {
1149
+ try {
1150
+ const effectiveLimit = Math.max(1, params.limit ?? 200);
1151
+ let query = params.pattern ?? "";
1152
+ if (params.path) query = `${params.path} ${query}`;
1153
+
1154
+ const searchResult = _fffFinder.fileSearch(query, { pageSize: effectiveLimit });
1155
+ if (searchResult.ok) {
1156
+ const items = searchResult.value.items.slice(0, effectiveLimit);
1157
+ let textContent = items.map((i: any) => i.relativePath).join("\n");
1158
+ const matchCount = items.length;
1159
+
1160
+ const notices: string[] = [];
1161
+ if (_fffPartialIndex) notices.push("Warning: partial file index");
1162
+ if (items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1163
+ if (searchResult.value.totalMatched > items.length) {
1164
+ notices.push(`${searchResult.value.totalMatched} total matches`);
1165
+ }
1166
+ if (notices.length) textContent += `\n\n[${notices.join(". ")}]`;
1167
+
1168
+ return {
1169
+ content: [{ type: "text", text: textContent }],
1170
+ details: {
1171
+ _type: "findResult",
1172
+ text: textContent,
1173
+ pattern: params.pattern ?? "",
1174
+ matchCount,
1175
+ },
1176
+ };
1177
+ }
1178
+ } catch {
1179
+ /* fall through to SDK */
1180
+ }
1181
+ }
1182
+
1183
+ // SDK fallback
1016
1184
  const result = await origFind.execute(tid, params, sig, upd, ctx);
1017
1185
 
1018
1186
  const textContent = result.content
@@ -1082,6 +1250,58 @@ export default function piPrettyExtension(pi: any): void {
1082
1250
  name: "grep",
1083
1251
 
1084
1252
  async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1253
+ // Try FFF first (SIMD-accelerated, frecency-ranked)
1254
+ if (_fffFinder && !_fffFinder.isDestroyed) {
1255
+ try {
1256
+ const effectiveLimit = Math.max(1, params.limit ?? 100);
1257
+ let query = params.pattern ?? "";
1258
+ if (params.glob) query = `${params.glob} ${query}`;
1259
+ else if (params.path) query = `${params.path} ${query}`;
1260
+
1261
+ const mode = params.literal ? "plain" : "regex";
1262
+
1263
+ const grepResult = _fffFinder.grep(query, {
1264
+ mode,
1265
+ smartCase: !params.ignoreCase,
1266
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
1267
+ cursor: null,
1268
+ beforeContext: params.context ?? 0,
1269
+ afterContext: params.context ?? 0,
1270
+ });
1271
+
1272
+ if (grepResult.ok) {
1273
+ const result = grepResult.value;
1274
+ let textContent = fffFormatGrepText(result.items, effectiveLimit);
1275
+ const matchCount = Math.min(result.items.length, effectiveLimit);
1276
+
1277
+ const notices: string[] = [];
1278
+ if (_fffPartialIndex) notices.push("Warning: partial file index");
1279
+ if (result.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1280
+ if ((result as any).regexFallbackError) {
1281
+ notices.push(`Regex failed: ${(result as any).regexFallbackError}, used literal match`);
1282
+ }
1283
+ if (result.nextCursor) {
1284
+ const cursorId = _cursorStore.store(result.nextCursor);
1285
+ notices.push(`More results available. Use cursor="${cursorId}" to continue`);
1286
+ }
1287
+ if (notices.length) textContent += `\n\n[${notices.join(". ")}]`;
1288
+
1289
+ return {
1290
+ content: [{ type: "text", text: textContent }],
1291
+ details: {
1292
+ _type: "grepResult",
1293
+ text: textContent,
1294
+ pattern: params.pattern ?? "",
1295
+ matchCount,
1296
+ },
1297
+ };
1298
+ }
1299
+ } catch {
1300
+ /* fall through to SDK */
1301
+ }
1302
+ }
1303
+
1304
+ // SDK fallback
1085
1305
  const result = await origGrep.execute(tid, params, sig, upd, ctx);
1086
1306
 
1087
1307
  const textContent = result.content
@@ -1156,4 +1376,227 @@ export default function piPrettyExtension(pi: any): void {
1156
1376
  },
1157
1377
  });
1158
1378
  }
1379
+
1380
+ // ===================================================================
1381
+ // multi_grep — FFF-only OR-logic multi-pattern search
1382
+ // ===================================================================
1383
+
1384
+ if (_fffModule) {
1385
+ pi.registerTool({
1386
+ name: "multi_grep",
1387
+ label: "multi_grep (fff)",
1388
+ description: [
1389
+ "Search file contents for lines matching ANY of multiple patterns (OR logic).",
1390
+ "Uses SIMD-accelerated Aho-Corasick multi-pattern matching. Faster than regex alternation.",
1391
+ "Patterns are literal text — never escape special characters.",
1392
+ "Use the constraints parameter for file filtering ('*.rs', 'src/', '!test/').",
1393
+ ].join(" "),
1394
+ promptSnippet:
1395
+ "Multi-pattern OR search across file contents (FFF: SIMD-accelerated, frecency-ranked)",
1396
+ promptGuidelines: [
1397
+ "Use multi_grep when you need to find multiple identifiers at once (OR logic).",
1398
+ "Include all naming conventions: snake_case, PascalCase, camelCase variants.",
1399
+ "Patterns are literal text. Never escape special characters.",
1400
+ "Use the constraints parameter for file type/path filtering, not inside patterns.",
1401
+ ],
1402
+
1403
+ parameters: {
1404
+ type: "object",
1405
+ properties: {
1406
+ patterns: {
1407
+ type: "array",
1408
+ items: { type: "string" },
1409
+ description: "Patterns to search for (OR logic — matches lines containing ANY pattern).",
1410
+ },
1411
+ constraints: {
1412
+ type: "string",
1413
+ description: "File constraints, e.g. '*.{ts,tsx} !test/' to filter files.",
1414
+ },
1415
+ context: {
1416
+ type: "number",
1417
+ description: "Number of context lines before and after each match (default: 0)",
1418
+ },
1419
+ limit: {
1420
+ type: "number",
1421
+ description: "Maximum number of matches to return (default: 100)",
1422
+ },
1423
+ },
1424
+ required: ["patterns"],
1425
+ },
1426
+
1427
+ async execute(_tid: string, params: any, sig: any, _upd: any, _ctx: any) {
1428
+ if (sig?.aborted) return { content: [{ type: "text", text: "Aborted" }], details: {} };
1429
+
1430
+ if (!params.patterns || params.patterns.length === 0) {
1431
+ return {
1432
+ content: [{ type: "text", text: "Error: patterns array must have at least 1 element" }],
1433
+ details: { error: "empty patterns" },
1434
+ };
1435
+ }
1436
+
1437
+ if (!_fffFinder || _fffFinder.isDestroyed) {
1438
+ return {
1439
+ content: [{ type: "text", text: "FFF not initialized. Wait for session start or run /fff-rescan." }],
1440
+ details: {},
1441
+ };
1442
+ }
1443
+
1444
+ try {
1445
+ const effectiveLimit = Math.max(1, params.limit ?? 100);
1446
+
1447
+ const grepResult = _fffFinder.multiGrep({
1448
+ patterns: params.patterns,
1449
+ constraints: params.constraints,
1450
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
1451
+ smartCase: true,
1452
+ cursor: null,
1453
+ beforeContext: params.context ?? 0,
1454
+ afterContext: params.context ?? 0,
1455
+ });
1456
+
1457
+ if (!grepResult.ok) {
1458
+ return {
1459
+ content: [{ type: "text", text: `multi_grep error: ${grepResult.error}` }],
1460
+ details: { error: grepResult.error },
1461
+ };
1462
+ }
1463
+
1464
+ const result = grepResult.value;
1465
+ let textContent = fffFormatGrepText(result.items, effectiveLimit);
1466
+ const matchCount = Math.min(result.items.length, effectiveLimit);
1467
+
1468
+ const notices: string[] = [];
1469
+ if (_fffPartialIndex) notices.push("Warning: partial file index");
1470
+ if (result.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1471
+ if (result.nextCursor) {
1472
+ const cursorId = _cursorStore.store(result.nextCursor);
1473
+ notices.push(`More results: cursor="${cursorId}"`);
1474
+ }
1475
+ if (notices.length) textContent += `\n\n[${notices.join(". ")}]`;
1476
+
1477
+ return {
1478
+ content: [{ type: "text", text: textContent }],
1479
+ details: {
1480
+ _type: "grepResult",
1481
+ text: textContent,
1482
+ pattern: params.patterns.join(" | "),
1483
+ matchCount,
1484
+ },
1485
+ };
1486
+ } catch (e: any) {
1487
+ return {
1488
+ content: [{ type: "text", text: `multi_grep error: ${e.message}` }],
1489
+ details: { error: e.message },
1490
+ };
1491
+ }
1492
+ },
1493
+
1494
+ renderCall(args: any, theme: any, ctx: any) {
1495
+ resolveBaseBackground(theme);
1496
+ const patterns = args?.patterns ?? [];
1497
+ const constraints = args?.constraints;
1498
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1499
+ let content =
1500
+ theme.fg("toolTitle", theme.bold("multi_grep")) +
1501
+ " " +
1502
+ theme.fg("accent", patterns.map((p: string) => `"${p}"`).join(", "));
1503
+ if (constraints) content += theme.fg("muted", ` (${constraints})`);
1504
+ text.setText(content);
1505
+ return text;
1506
+ },
1507
+
1508
+ renderResult(result: any, _opt: any, theme: any, ctx: any) {
1509
+ resolveBaseBackground(theme);
1510
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1511
+
1512
+ if (ctx.isError) {
1513
+ const e = result.content?.[0]?.text ?? "Error";
1514
+ text.setText(`\n${theme.fg("error", e)}`);
1515
+ return text;
1516
+ }
1517
+
1518
+ const d = result.details;
1519
+ if (d?._type === "grepResult" && d.text) {
1520
+ const key = `mgrep:${d.pattern}:${d.matchCount}:${termW()}`;
1521
+ if (ctx.state._mgk !== key) {
1522
+ ctx.state._mgk = key;
1523
+ const info = `${FG_DIM}${d.matchCount} matches${RST}`;
1524
+ ctx.state._mgt = ` ${info}`;
1525
+
1526
+ renderGrepResults(d.text, d.pattern)
1527
+ .then((rendered: string) => {
1528
+ if (ctx.state._mgk !== key) return;
1529
+ ctx.state._mgt = ` ${info}\n${rendered}`;
1530
+ ctx.invalidate();
1531
+ })
1532
+ .catch(() => {});
1533
+ }
1534
+ text.setText(ctx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`);
1535
+ return text;
1536
+ }
1537
+
1538
+ const fallback = result.content?.[0]?.text ?? "searched";
1539
+ text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1540
+ return text;
1541
+ },
1542
+ });
1543
+ }
1544
+
1545
+ // ===================================================================
1546
+ // FFF commands
1547
+ // ===================================================================
1548
+
1549
+ if (_fffModule) {
1550
+ pi.registerCommand("fff-health", {
1551
+ description: "Show FFF file finder health and indexer status",
1552
+ handler: async (_args: any, ctx: any) => {
1553
+ if (!_fffFinder || _fffFinder.isDestroyed) {
1554
+ ctx.ui?.notify?.("FFF not initialized", "warning");
1555
+ return;
1556
+ }
1557
+
1558
+ const health = _fffFinder.healthCheck();
1559
+ if (!health.ok) {
1560
+ ctx.ui?.notify?.(`Health check failed: ${health.error}`, "error");
1561
+ return;
1562
+ }
1563
+
1564
+ const h = health.value;
1565
+ const lines = [
1566
+ `FFF v${h.version}`,
1567
+ `Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
1568
+ `Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
1569
+ `Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
1570
+ `Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
1571
+ `Partial index: ${_fffPartialIndex ? "yes (scan timed out)" : "no"}`,
1572
+ ];
1573
+
1574
+ const progress = _fffFinder.getScanProgress();
1575
+ if (progress.ok) {
1576
+ lines.push(`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`);
1577
+ }
1578
+
1579
+ ctx.ui?.notify?.(lines.join("\n"), "info");
1580
+ },
1581
+ });
1582
+
1583
+ pi.registerCommand("fff-rescan", {
1584
+ description: "Trigger FFF to rescan files",
1585
+ handler: async (_args: any, ctx: any) => {
1586
+ if (!_fffFinder || _fffFinder.isDestroyed) {
1587
+ ctx.ui?.notify?.("FFF not initialized", "warning");
1588
+ return;
1589
+ }
1590
+
1591
+ const result = _fffFinder.scanFiles();
1592
+ if (!result.ok) {
1593
+ ctx.ui?.notify?.(`Rescan failed: ${result.error}`, "error");
1594
+ return;
1595
+ }
1596
+
1597
+ _fffPartialIndex = false;
1598
+ ctx.ui?.notify?.("FFF rescan triggered", "info");
1599
+ },
1600
+ });
1601
+ }
1159
1602
  }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Tests for pi-pretty FFF integration vs SDK fallback.
3
+ *
4
+ * 1. Unit tests for CursorStore + fffFormatGrepText (extracted helpers)
5
+ * 2. Integration tests via dependency injection (PiPrettyDeps)
6
+ * - SDK fallback path (no FFF)
7
+ * - FFF path (FFF injected)
8
+ * - Graceful degradation (FFF fails → SDK fallback)
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach } from "vitest";
12
+ import { CursorStore, fffFormatGrepText } from "../src/fff-helpers.js";
13
+ import piPrettyExtension, { type PiPrettyDeps } from "../src/index.js";
14
+
15
+ // =========================================================================
16
+ // 1. Unit tests — pure functions
17
+ // =========================================================================
18
+
19
+ describe("CursorStore", () => {
20
+ it("stores and retrieves a cursor", () => {
21
+ const store = new CursorStore();
22
+ const cursor = { page: 2, offset: 50 };
23
+ const id = store.store(cursor);
24
+ expect(id).toMatch(/^fff_c\d+$/);
25
+ expect(store.get(id)).toBe(cursor);
26
+ });
27
+
28
+ it("returns undefined for unknown id", () => {
29
+ expect(new CursorStore().get("fff_c999")).toBeUndefined();
30
+ });
31
+
32
+ it("increments ids sequentially", () => {
33
+ const store = new CursorStore();
34
+ const n1 = Number.parseInt(store.store("a").slice(5), 10);
35
+ const n2 = Number.parseInt(store.store("b").slice(5), 10);
36
+ expect(n2).toBe(n1 + 1);
37
+ });
38
+
39
+ it("evicts oldest when exceeding maxSize", () => {
40
+ const store = new CursorStore(3);
41
+ const id1 = store.store("a");
42
+ store.store("b"); store.store("c");
43
+ expect(store.size).toBe(3);
44
+ store.store("d");
45
+ expect(store.size).toBe(3);
46
+ expect(store.get(id1)).toBeUndefined();
47
+ });
48
+
49
+ it("default maxSize is 200", () => {
50
+ const store = new CursorStore();
51
+ const ids: string[] = [];
52
+ for (let i = 0; i < 201; i++) ids.push(store.store(i));
53
+ expect(store.size).toBe(200);
54
+ expect(store.get(ids[0])).toBeUndefined();
55
+ expect(store.get(ids[200])).toBe(200);
56
+ });
57
+ });
58
+
59
+ describe("fffFormatGrepText", () => {
60
+ it("empty → 'No matches found'", () => {
61
+ expect(fffFormatGrepText([], 100)).toBe("No matches found");
62
+ });
63
+
64
+ it("single match → file:line:content", () => {
65
+ const items = [{ relativePath: "src/a.ts", lineNumber: 42, lineContent: "const x = 1;" }];
66
+ expect(fffFormatGrepText(items, 100)).toBe("src/a.ts:42:const x = 1;");
67
+ });
68
+
69
+ it("groups by file with blank separator", () => {
70
+ const items = [
71
+ { relativePath: "a.ts", lineNumber: 1, lineContent: "L1" },
72
+ { relativePath: "a.ts", lineNumber: 5, lineContent: "L5" },
73
+ { relativePath: "b.ts", lineNumber: 10, lineContent: "LB" },
74
+ ];
75
+ expect(fffFormatGrepText(items, 100).split("\n")).toEqual(["a.ts:1:L1", "a.ts:5:L5", "", "b.ts:10:LB"]);
76
+ });
77
+
78
+ it("truncates >500 char lines", () => {
79
+ const items = [{ relativePath: "a.ts", lineNumber: 1, lineContent: "x".repeat(600) }];
80
+ expect(fffFormatGrepText(items, 100)).toBe(`a.ts:1:${"x".repeat(500)}...`);
81
+ });
82
+
83
+ it("respects limit", () => {
84
+ const items = [
85
+ { relativePath: "a.ts", lineNumber: 1, lineContent: "one" },
86
+ { relativePath: "a.ts", lineNumber: 2, lineContent: "two" },
87
+ { relativePath: "a.ts", lineNumber: 3, lineContent: "three" },
88
+ ];
89
+ expect(fffFormatGrepText(items, 2).split("\n")).toHaveLength(2);
90
+ });
91
+
92
+ it("contextBefore with dash format", () => {
93
+ const items = [{
94
+ relativePath: "a.ts", lineNumber: 5, lineContent: "match",
95
+ contextBefore: ["before1", "before2"],
96
+ }];
97
+ const lines = fffFormatGrepText(items, 100).split("\n");
98
+ expect(lines[0]).toBe("a.ts-3-before1");
99
+ expect(lines[1]).toBe("a.ts-4-before2");
100
+ expect(lines[2]).toBe("a.ts:5:match");
101
+ });
102
+
103
+ it("contextAfter with dash format", () => {
104
+ const items = [{
105
+ relativePath: "a.ts", lineNumber: 5, lineContent: "match",
106
+ contextAfter: ["after1"],
107
+ }];
108
+ const lines = fffFormatGrepText(items, 100).split("\n");
109
+ expect(lines[0]).toBe("a.ts:5:match");
110
+ expect(lines[1]).toBe("a.ts-6-after1");
111
+ });
112
+ });
113
+
114
+ // =========================================================================
115
+ // 2. Integration tests — via PiPrettyDeps injection
116
+ // =========================================================================
117
+
118
+ // Mock SDK tool factories
119
+ function mockToolFactory(exec: ReturnType<typeof vi.fn>) {
120
+ return (_cwd: string) => ({
121
+ name: "mock",
122
+ description: "mock",
123
+ parameters: { type: "object", properties: {} },
124
+ execute: exec,
125
+ });
126
+ }
127
+
128
+ // Mock FFF finder
129
+ function mkFinder(overrides?: Record<string, any>) {
130
+ return {
131
+ isDestroyed: false,
132
+ waitForScan: vi.fn().mockResolvedValue({ ok: true, value: true }),
133
+ fileSearch: vi.fn().mockReturnValue({
134
+ ok: true,
135
+ value: {
136
+ items: [{ relativePath: "src/index.ts" }, { relativePath: "src/main.ts" }],
137
+ totalMatched: 2,
138
+ },
139
+ }),
140
+ grep: vi.fn().mockReturnValue({
141
+ ok: true,
142
+ value: {
143
+ items: [{ relativePath: "src/index.ts", lineNumber: 42, lineContent: "const x = 1;" }],
144
+ totalMatched: 1,
145
+ nextCursor: null,
146
+ },
147
+ }),
148
+ multiGrep: vi.fn().mockReturnValue({
149
+ ok: true,
150
+ value: {
151
+ items: [
152
+ { relativePath: "src/index.ts", lineNumber: 10, lineContent: "import {foo}" },
153
+ { relativePath: "src/main.ts", lineNumber: 5, lineContent: "const baz" },
154
+ ],
155
+ totalMatched: 2,
156
+ nextCursor: null,
157
+ },
158
+ }),
159
+ destroy: vi.fn(),
160
+ ...overrides,
161
+ };
162
+ }
163
+
164
+ describe("piPrettyExtension integration", () => {
165
+ let tools: Map<string, any>;
166
+ let events: Map<string, Function>;
167
+ let mockPi: any;
168
+
169
+ // SDK execute mocks
170
+ const findExec = vi.fn();
171
+ const grepExec = vi.fn();
172
+ const readExec = vi.fn();
173
+ const bashExec = vi.fn();
174
+ const lsExec = vi.fn();
175
+
176
+ function makeDeps(withFFF: boolean, finderOverrides?: Record<string, any>): PiPrettyDeps {
177
+ const finder = mkFinder(finderOverrides);
178
+ return {
179
+ sdk: {
180
+ createReadToolDefinition: mockToolFactory(readExec),
181
+ createBashToolDefinition: mockToolFactory(bashExec),
182
+ createLsToolDefinition: mockToolFactory(lsExec),
183
+ createFindToolDefinition: mockToolFactory(findExec),
184
+ createGrepToolDefinition: mockToolFactory(grepExec),
185
+ getAgentDir: () => "/tmp/pi-pretty-test",
186
+ },
187
+ TextComponent: class { private t = ""; setText(v: string) { this.t = v; } getText() { return this.t; } },
188
+ fffModule: withFFF
189
+ ? { FileFinder: { create: vi.fn().mockReturnValue({ ok: true, value: finder }) } }
190
+ : undefined,
191
+ };
192
+ }
193
+
194
+ beforeEach(() => {
195
+ tools = new Map();
196
+ events = new Map();
197
+ mockPi = {
198
+ registerTool: vi.fn((t: any) => tools.set(t.name, t)),
199
+ registerCommand: vi.fn((c: any) => {}),
200
+ on: vi.fn((e: string, h: Function) => events.set(e, h)),
201
+ };
202
+
203
+ for (const fn of [findExec, grepExec, readExec, bashExec, lsExec]) fn.mockReset();
204
+ findExec.mockResolvedValue({ content: [{ type: "text", text: "src/index.ts\nsrc/main.ts" }] });
205
+ grepExec.mockResolvedValue({ content: [{ type: "text", text: "src/index.ts:10:const x = 1;" }] });
206
+ readExec.mockResolvedValue({ content: [{ type: "text", text: "content" }] });
207
+ bashExec.mockResolvedValue({ content: [{ type: "text", text: "output" }] });
208
+ lsExec.mockResolvedValue({ content: [{ type: "text", text: "f1\nf2" }] });
209
+ });
210
+
211
+ function load(withFFF = false, finderOverrides?: Record<string, any>) {
212
+ const deps = makeDeps(withFFF, finderOverrides);
213
+ piPrettyExtension(mockPi, deps);
214
+ }
215
+
216
+ async function loadWithFFF(finderOverrides?: Record<string, any>) {
217
+ load(true, finderOverrides);
218
+ const start = events.get("session_start")!;
219
+ expect(start, "session_start not registered").toBeDefined();
220
+ await start({}, { cwd: "/tmp/test" });
221
+ }
222
+
223
+ // ---- registration --------------------------------------------------
224
+
225
+ describe("tool registration", () => {
226
+ it("registers core tools (find, grep, read, bash, ls)", () => {
227
+ load();
228
+ for (const n of ["find", "grep", "read", "bash", "ls"]) {
229
+ expect(tools.has(n), `missing: ${n}`).toBe(true);
230
+ }
231
+ });
232
+
233
+ it("registers multi_grep when FFF available", () => {
234
+ load(true);
235
+ expect(tools.has("multi_grep")).toBe(true);
236
+ });
237
+
238
+ it("NO multi_grep when FFF unavailable", () => {
239
+ load(false);
240
+ expect(tools.has("multi_grep")).toBe(false);
241
+ });
242
+
243
+ it("registers session_start + session_shutdown", () => {
244
+ load();
245
+ expect(events.has("session_start")).toBe(true);
246
+ expect(events.has("session_shutdown")).toBe(true);
247
+ });
248
+ });
249
+
250
+ // ---- find: SDK fallback (no FFF) -----------------------------------
251
+
252
+ describe("find — SDK fallback", () => {
253
+ it("delegates to SDK when FFF not loaded", async () => {
254
+ load(false);
255
+ const r = await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
256
+ expect(findExec).toHaveBeenCalledOnce();
257
+ expect(r.details._type).toBe("findResult");
258
+ expect(r.details.pattern).toBe("*.ts");
259
+ });
260
+
261
+ it("counts matches from SDK text", async () => {
262
+ findExec.mockResolvedValue({ content: [{ type: "text", text: "a.ts\nb.ts\nc.ts" }] });
263
+ load(false);
264
+ const r = await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
265
+ expect(r.details.matchCount).toBe(3);
266
+ });
267
+ });
268
+
269
+ // ---- grep: SDK fallback (no FFF) -----------------------------------
270
+
271
+ describe("grep — SDK fallback", () => {
272
+ it("delegates to SDK when FFF not loaded", async () => {
273
+ load(false);
274
+ const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
275
+ expect(grepExec).toHaveBeenCalledOnce();
276
+ expect(r.details._type).toBe("grepResult");
277
+ });
278
+
279
+ it("counts ripgrep-style matches", async () => {
280
+ grepExec.mockResolvedValue({
281
+ content: [{ type: "text", text: "a.ts:1:TODO\na.ts:5:TODO\nb.ts:10:TODO" }],
282
+ });
283
+ load(false);
284
+ const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
285
+ expect(r.details.matchCount).toBe(3);
286
+ });
287
+ });
288
+
289
+ // ---- find: FFF path ------------------------------------------------
290
+
291
+ describe("find — FFF path", () => {
292
+ it("uses FFF fileSearch when initialized", async () => {
293
+ await loadWithFFF();
294
+ const r = await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
295
+ expect(findExec).not.toHaveBeenCalled();
296
+ expect(r.details._type).toBe("findResult");
297
+ expect(r.content[0].text).toContain("src/index.ts");
298
+ });
299
+
300
+ it("falls back to SDK on FFF { ok: false }", async () => {
301
+ await loadWithFFF({
302
+ fileSearch: vi.fn().mockReturnValue({ ok: false, error: "fail" }),
303
+ });
304
+ await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
305
+ expect(findExec).toHaveBeenCalledOnce();
306
+ });
307
+
308
+ it("falls back to SDK on FFF throw", async () => {
309
+ await loadWithFFF({
310
+ fileSearch: vi.fn().mockImplementation(() => { throw new Error("crash"); }),
311
+ });
312
+ await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
313
+ expect(findExec).toHaveBeenCalledOnce();
314
+ });
315
+
316
+ it("respects limit param", async () => {
317
+ const fileSearch = vi.fn().mockReturnValue({
318
+ ok: true,
319
+ value: { items: Array.from({ length: 50 }, (_, i) => ({ relativePath: `f${i}.ts` })), totalMatched: 50 },
320
+ });
321
+ await loadWithFFF({ fileSearch });
322
+ await tools.get("find")!.execute("t1", { pattern: "*.ts", limit: 5 }, null, null, {});
323
+ expect(fileSearch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ pageSize: 5 }));
324
+ });
325
+
326
+ it("includes path in search query", async () => {
327
+ const fileSearch = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0 } });
328
+ await loadWithFFF({ fileSearch });
329
+ await tools.get("find")!.execute("t1", { pattern: "*.ts", path: "src/" }, null, null, {});
330
+ expect(fileSearch).toHaveBeenCalledWith("src/ *.ts", expect.any(Object));
331
+ });
332
+
333
+ it("shows partial-index + limit notices", async () => {
334
+ await loadWithFFF({
335
+ waitForScan: vi.fn().mockResolvedValue({ ok: true, value: false }),
336
+ fileSearch: vi.fn().mockReturnValue({
337
+ ok: true,
338
+ value: { items: Array.from({ length: 200 }, (_, i) => ({ relativePath: `f${i}` })), totalMatched: 500 },
339
+ }),
340
+ });
341
+ const text = (await tools.get("find")!.execute("t1", { pattern: "*" }, null, null, {})).content[0].text;
342
+ expect(text).toContain("partial file index");
343
+ expect(text).toContain("200 limit reached");
344
+ expect(text).toContain("500 total matches");
345
+ });
346
+ });
347
+
348
+ // ---- grep: FFF path ------------------------------------------------
349
+
350
+ describe("grep — FFF path", () => {
351
+ it("uses FFF grep when initialized", async () => {
352
+ await loadWithFFF();
353
+ const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
354
+ expect(grepExec).not.toHaveBeenCalled();
355
+ expect(r.content[0].text).toContain("src/index.ts:42:const x = 1;");
356
+ });
357
+
358
+ it("literal=true → mode=plain", async () => {
359
+ const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
360
+ await loadWithFFF({ grep });
361
+ await tools.get("grep")!.execute("t1", { pattern: "foo", literal: true }, null, null, {});
362
+ expect(grep).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ mode: "plain" }));
363
+ });
364
+
365
+ it("no literal → mode=regex", async () => {
366
+ const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
367
+ await loadWithFFF({ grep });
368
+ await tools.get("grep")!.execute("t1", { pattern: "foo.*bar" }, null, null, {});
369
+ expect(grep).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ mode: "regex" }));
370
+ });
371
+
372
+ it("glob prepended to query", async () => {
373
+ const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
374
+ await loadWithFFF({ grep });
375
+ await tools.get("grep")!.execute("t1", { pattern: "TODO", glob: "*.ts" }, null, null, {});
376
+ expect(grep).toHaveBeenCalledWith("*.ts TODO", expect.any(Object));
377
+ });
378
+
379
+ it("falls back to SDK on throw", async () => {
380
+ await loadWithFFF({ grep: vi.fn().mockImplementation(() => { throw new Error("crash"); }) });
381
+ const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
382
+ expect(grepExec).toHaveBeenCalledOnce();
383
+ expect(r.details._type).toBe("grepResult");
384
+ });
385
+
386
+ it("cursor notice when nextCursor present", async () => {
387
+ await loadWithFFF({
388
+ grep: vi.fn().mockReturnValue({
389
+ ok: true,
390
+ value: { items: [{ relativePath: "a.ts", lineNumber: 1, lineContent: "hit" }], totalMatched: 1, nextCursor: { p: 2 } },
391
+ }),
392
+ });
393
+ const text = (await tools.get("grep")!.execute("t1", { pattern: "hit" }, null, null, {})).content[0].text;
394
+ expect(text).toContain("More results available");
395
+ expect(text).toMatch(/cursor="fff_c\d+"/);
396
+ });
397
+ });
398
+
399
+ // ---- multi_grep (FFF only) -----------------------------------------
400
+
401
+ describe("multi_grep", () => {
402
+ it("error for empty patterns", async () => {
403
+ await loadWithFFF();
404
+ const r = await tools.get("multi_grep")!.execute("t1", { patterns: [] }, null, null, null);
405
+ expect(r.content[0].text).toContain("patterns array must have at least 1 element");
406
+ });
407
+
408
+ it("error when FFF not initialized (no session_start)", async () => {
409
+ load(true);
410
+ const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["foo"] }, null, null, null);
411
+ expect(r.content[0].text).toContain("FFF not initialized");
412
+ });
413
+
414
+ it("returns multiGrep results", async () => {
415
+ await loadWithFFF();
416
+ const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["foo", "bar"] }, null, null, null);
417
+ expect(r.details._type).toBe("grepResult");
418
+ expect(r.content[0].text).toContain("src/index.ts");
419
+ });
420
+
421
+ it("aborted signal → Aborted", async () => {
422
+ await loadWithFFF();
423
+ const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["x"] }, { aborted: true }, null, null);
424
+ expect(r.content[0].text).toBe("Aborted");
425
+ });
426
+
427
+ it("multiGrep failure → error text", async () => {
428
+ await loadWithFFF({
429
+ multiGrep: vi.fn().mockReturnValue({ ok: false, error: "compile failed" }),
430
+ });
431
+ const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["[bad"] }, null, null, null);
432
+ expect(r.content[0].text).toContain("compile failed");
433
+ });
434
+
435
+ it("passes constraints and context", async () => {
436
+ const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
437
+ await loadWithFFF({ multiGrep });
438
+ await tools.get("multi_grep")!.execute("t1", { patterns: ["a", "b"], constraints: "*.ts", context: 2 }, null, null, null);
439
+ expect(multiGrep).toHaveBeenCalledWith(expect.objectContaining({
440
+ patterns: ["a", "b"], constraints: "*.ts", beforeContext: 2, afterContext: 2,
441
+ }));
442
+ });
443
+ });
444
+
445
+ // ---- session lifecycle ---------------------------------------------
446
+
447
+ describe("session lifecycle", () => {
448
+ it("shutdown → subsequent find falls back to SDK", async () => {
449
+ await loadWithFFF();
450
+ await events.get("session_shutdown")!();
451
+ await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
452
+ expect(findExec).toHaveBeenCalledOnce();
453
+ });
454
+ });
455
+ });