@heyhuynhgiabuu/pi-pretty 0.3.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.3.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",
@@ -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
@@ -29,6 +29,8 @@ import { basename, dirname, extname, join, relative } from "node:path";
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
  // ---------------------------------------------------------------------------
@@ -676,25 +678,6 @@ async function renderGrepResults(text: string, pattern: string): Promise<string>
676
678
  // If not, falls back to wrapping SDK tools (current behavior).
677
679
  // ---------------------------------------------------------------------------
678
680
 
679
- class CursorStore {
680
- private cursors = new Map<string, any>();
681
- private counter = 0;
682
-
683
- store(cursor: any): string {
684
- const id = `fff_c${++this.counter}`;
685
- this.cursors.set(id, cursor);
686
- if (this.cursors.size > 200) {
687
- const first = this.cursors.keys().next().value;
688
- if (first) this.cursors.delete(first);
689
- }
690
- return id;
691
- }
692
-
693
- get(id: string): any | undefined {
694
- return this.cursors.get(id);
695
- }
696
- }
697
-
698
681
  const _cursorStore = new CursorStore();
699
682
  let _fffModule: any = null;
700
683
  let _fffFinder: any = null;
@@ -730,47 +713,21 @@ function fffDestroy(): void {
730
713
  _fffPartialIndex = false;
731
714
  }
732
715
 
733
- /**
734
- * Convert FFF GrepResult items to ripgrep-style "file:line:content" text.
735
- * This ensures pi-pretty's renderGrepResults works unchanged.
736
- */
737
- function fffFormatGrepText(items: any[], limit: number): string {
738
- const capped = items.slice(0, limit);
739
- if (!capped.length) return "No matches found";
740
-
741
- const lines: string[] = [];
742
- let currentFile = "";
743
-
744
- for (const match of capped) {
745
- if (match.relativePath !== currentFile) {
746
- if (currentFile) lines.push("");
747
- currentFile = match.relativePath;
748
- }
749
- if (match.contextBefore?.length) {
750
- const startLine = match.lineNumber - match.contextBefore.length;
751
- for (let i = 0; i < match.contextBefore.length; i++) {
752
- lines.push(`${match.relativePath}-${startLine + i}-${match.contextBefore[i]}`);
753
- }
754
- }
755
- const content =
756
- match.lineContent.length > 500 ? `${match.lineContent.slice(0, 500)}...` : match.lineContent;
757
- lines.push(`${match.relativePath}:${match.lineNumber}:${content}`);
758
- if (match.contextAfter?.length) {
759
- const startLine = match.lineNumber + 1;
760
- for (let i = 0; i < match.contextAfter.length; i++) {
761
- lines.push(`${match.relativePath}-${startLine + i}-${match.contextAfter[i]}`);
762
- }
763
- }
764
- }
765
-
766
- return lines.join("\n");
767
- }
768
-
769
716
  // ---------------------------------------------------------------------------
770
717
  // Extension entry point
771
718
  // ---------------------------------------------------------------------------
772
719
 
773
- 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 {
774
731
  let createReadTool: any;
775
732
  let createBashTool: any;
776
733
  let createLsTool: any;
@@ -780,16 +737,31 @@ export default function piPrettyExtension(pi: any): void {
780
737
 
781
738
  let sdk: any;
782
739
 
783
- try {
784
- sdk = require("@mariozechner/pi-coding-agent");
740
+ if (deps) {
741
+ // Test path: use injected dependencies, reset module state
742
+ sdk = deps.sdk;
785
743
  createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
786
744
  createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
787
745
  createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
788
746
  createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
789
747
  createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
790
- TextComponent = require("@mariozechner/pi-tui").Text;
791
- } catch {
792
- 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
+ }
793
765
  }
794
766
  if (!createReadTool || !TextComponent) return;
795
767
 
@@ -802,16 +774,24 @@ export default function piPrettyExtension(pi: any): void {
802
774
  // ===================================================================
803
775
 
804
776
  const getAgentDir = (sdk as any).getAgentDir;
805
- try {
806
- _fffModule = require("@ff-labs/fff-node");
807
- if (getAgentDir) {
808
- _fffDbDir = join(getAgentDir(), "fff");
809
- try {
810
- mkdirSync(_fffDbDir, { recursive: true });
811
- } catch {}
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 */
812
789
  }
813
- } catch {
814
- /* FFF not installed — SDK tools will be used */
790
+ } else if (_fffModule && getAgentDir) {
791
+ _fffDbDir = join(getAgentDir(), "fff");
792
+ try {
793
+ mkdirSync(_fffDbDir, { recursive: true });
794
+ } catch {}
815
795
  }
816
796
 
817
797
  pi.on("session_start", async (_event: any, ctx: any) => {
@@ -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
+ });