@heyhuynhgiabuu/pi-pretty 0.4.1 → 0.4.3

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.
@@ -8,9 +8,17 @@
8
8
  * - Graceful degradation (FFF fails → SDK fallback)
9
9
  */
10
10
 
11
- import { describe, it, expect, vi, beforeEach } from "vitest";
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
12
15
  import { CursorStore, fffFormatGrepText } from "../src/fff-helpers.js";
13
16
  import piPrettyExtension, { type PiPrettyDeps } from "../src/index.js";
17
+ import {
18
+ getMultiGrepRipgrepArgs,
19
+ parseMultiGrepConstraints,
20
+ runMultiGrepRipgrepFallback,
21
+ } from "../src/multi-grep-fallback.js";
14
22
 
15
23
  // =========================================================================
16
24
  // 1. Unit tests — pure functions
@@ -109,6 +117,117 @@ describe("fffFormatGrepText", () => {
109
117
  expect(lines[0]).toBe("a.ts:5:match");
110
118
  expect(lines[1]).toBe("a.ts-6-after1");
111
119
  });
120
+
121
+ it("sanitizes CRLF and CR without injecting grep record newlines", () => {
122
+ const items = [{
123
+ relativePath: "a.ts",
124
+ lineNumber: 5,
125
+ lineContent: "match\r\ncontinued\rtrail",
126
+ contextBefore: ["before\r\nline"],
127
+ contextAfter: ["after\rline"],
128
+ }];
129
+ const text = fffFormatGrepText(items, 100);
130
+ const lines = text.split("\n");
131
+
132
+ expect(lines).toEqual([
133
+ "a.ts-4-before\\nline",
134
+ "a.ts:5:match\\ncontinued\\rtrail",
135
+ "a.ts-6-after\\rline",
136
+ ]);
137
+ expect(lines).toHaveLength(3);
138
+ });
139
+
140
+ it("strips trailing CR from CRLF-backed FFF records", () => {
141
+ const items = [{ relativePath: "a.ts", lineNumber: 5, lineContent: "match\r" }];
142
+ expect(fffFormatGrepText(items, 100)).toBe("a.ts:5:match");
143
+ });
144
+ });
145
+
146
+ describe("multi_grep constraint parsing", () => {
147
+ it("maps complex include/exclude constraints to ripgrep globs", () => {
148
+ expect(parseMultiGrepConstraints("*.{ts,tsx} !test/")).toEqual({
149
+ ok: true,
150
+ tokens: ["*.{ts,tsx}", "!test/"],
151
+ globs: ["*.{ts,tsx}", "!**/test/**"],
152
+ });
153
+ });
154
+
155
+ it("maps directory constraints as path components", () => {
156
+ expect(parseMultiGrepConstraints("src/ !src/generated/")).toEqual({
157
+ ok: true,
158
+ tokens: ["src/", "!src/generated/"],
159
+ globs: ["**/src/**", "!**/src/generated/**"],
160
+ });
161
+ });
162
+
163
+ it("builds literal ripgrep OR arguments with every constraint glob", () => {
164
+ const result = getMultiGrepRipgrepArgs({
165
+ cwd: "/repo",
166
+ patterns: ["foo", "bar"],
167
+ path: "src",
168
+ constraints: "*.ts !test/",
169
+ ignoreCase: true,
170
+ limit: 100,
171
+ });
172
+
173
+ expect(result.ok).toBe(true);
174
+ if (!result.ok) return;
175
+ expect(result.args).toEqual([
176
+ "--line-number",
177
+ "--with-filename",
178
+ "--color=never",
179
+ "--hidden",
180
+ "--fixed-strings",
181
+ "--ignore-case",
182
+ "--glob",
183
+ "*.ts",
184
+ "--glob",
185
+ "!**/test/**",
186
+ "-e",
187
+ "foo",
188
+ "-e",
189
+ "bar",
190
+ "--",
191
+ "src",
192
+ ]);
193
+ });
194
+
195
+ it("ripgrep fallback enforces include/exclude constraints without widening", async () => {
196
+ const root = mkdtempSync(join(tmpdir(), "pi-pretty-mgrep-"));
197
+ try {
198
+ mkdirSync(join(root, "src", "test"), { recursive: true });
199
+ mkdirSync(join(root, "test"), { recursive: true });
200
+ writeFileSync(join(root, "src", "keep.ts"), "needle\n");
201
+ writeFileSync(join(root, "src", "keep.js"), "needle\n");
202
+ writeFileSync(join(root, "src", "test", "drop.ts"), "needle\n");
203
+ writeFileSync(join(root, "test", "drop.ts"), "needle\n");
204
+
205
+ const result = await runMultiGrepRipgrepFallback({
206
+ cwd: root,
207
+ patterns: ["needle"],
208
+ constraints: "*.ts !test/",
209
+ ignoreCase: true,
210
+ limit: 100,
211
+ });
212
+
213
+ expect(result.text).toContain("src/keep.ts");
214
+ expect(result.text).not.toContain("src/keep.js");
215
+ expect(result.text).not.toContain("src/test/drop.ts");
216
+ expect(result.text).not.toContain("test/drop.ts");
217
+ } catch (error) {
218
+ if (String(error).includes("ripgrep (rg) is not available")) return;
219
+ throw error;
220
+ } finally {
221
+ rmSync(root, { recursive: true, force: true });
222
+ }
223
+ });
224
+
225
+ it("rejects unsupported empty negation instead of ignoring it", () => {
226
+ expect(parseMultiGrepConstraints("*.ts !")).toEqual({
227
+ ok: false,
228
+ error: "empty constraint token: !",
229
+ });
230
+ });
112
231
  });
113
232
 
114
233
  // =========================================================================
@@ -172,6 +291,7 @@ describe("piPrettyExtension integration", () => {
172
291
  const readExec = vi.fn();
173
292
  const bashExec = vi.fn();
174
293
  const lsExec = vi.fn();
294
+ const multiGrepRgExec = vi.fn();
175
295
 
176
296
  function makeDeps(withFFF: boolean, finderOverrides?: Record<string, any>): PiPrettyDeps {
177
297
  const finder = mkFinder(finderOverrides);
@@ -189,10 +309,12 @@ describe("piPrettyExtension integration", () => {
189
309
  },
190
310
  TextComponent: class { private t = ""; setText(v: string) { this.t = v; } getText() { return this.t; } },
191
311
  fffModule: withFFF ? fffModule : undefined,
312
+ multiGrepRipgrepFallback: multiGrepRgExec,
192
313
  };
193
314
  }
194
315
 
195
316
  beforeEach(() => {
317
+ vi.useRealTimers();
196
318
  tools = new Map();
197
319
  events = new Map();
198
320
  mockPi = {
@@ -201,12 +323,13 @@ describe("piPrettyExtension integration", () => {
201
323
  on: vi.fn((e: string, h: Function) => events.set(e, h)),
202
324
  };
203
325
 
204
- for (const fn of [findExec, grepExec, readExec, bashExec, lsExec]) fn.mockReset();
326
+ for (const fn of [findExec, grepExec, readExec, bashExec, lsExec, multiGrepRgExec]) fn.mockReset();
205
327
  findExec.mockResolvedValue({ content: [{ type: "text", text: "src/index.ts\nsrc/main.ts" }] });
206
328
  grepExec.mockResolvedValue({ content: [{ type: "text", text: "src/index.ts:10:const x = 1;" }] });
207
329
  readExec.mockResolvedValue({ content: [{ type: "text", text: "content" }] });
208
330
  bashExec.mockResolvedValue({ content: [{ type: "text", text: "output" }] });
209
331
  lsExec.mockResolvedValue({ content: [{ type: "text", text: "f1\nf2" }] });
332
+ multiGrepRgExec.mockResolvedValue({ text: "src/index.ts:10:const x = 1;", matchCount: 1, limitReached: false });
210
333
  });
211
334
 
212
335
  function load(withFFF = false, finderOverrides?: Record<string, any>) {
@@ -214,6 +337,10 @@ describe("piPrettyExtension integration", () => {
214
337
  piPrettyExtension(mockPi, deps);
215
338
  }
216
339
 
340
+ afterEach(() => {
341
+ vi.useRealTimers();
342
+ });
343
+
217
344
  async function loadWithFFF(finderOverrides?: Record<string, any>) {
218
345
  load(true, finderOverrides);
219
346
  const start = events.get("session_start")!;
@@ -236,9 +363,9 @@ describe("piPrettyExtension integration", () => {
236
363
  expect(tools.has("multi_grep")).toBe(true);
237
364
  });
238
365
 
239
- it("NO multi_grep when FFF unavailable", () => {
366
+ it("registers multi_grep when grep SDK available", () => {
240
367
  load(false);
241
- expect(tools.has("multi_grep")).toBe(false);
368
+ expect(tools.has("multi_grep")).toBe(true);
242
369
  });
243
370
 
244
371
  it("registers session_start + session_shutdown", () => {
@@ -285,6 +412,32 @@ describe("piPrettyExtension integration", () => {
285
412
  const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
286
413
  expect(r.details.matchCount).toBe(3);
287
414
  });
415
+
416
+ it("normalizes CRLF in SDK text results", async () => {
417
+ grepExec.mockResolvedValue({
418
+ content: [{ type: "text", text: "a.ts:1:TODO\r\na.ts:5:TODO\rb.ts:10:TODO" }],
419
+ });
420
+ load(false);
421
+ const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
422
+ expect(r.content[0].text).toBe("a.ts:1:TODO\na.ts:5:TODO\nb.ts:10:TODO");
423
+ expect(r.details.text).toBe("a.ts:1:TODO\na.ts:5:TODO\nb.ts:10:TODO");
424
+ expect(r.details.matchCount).toBe(3);
425
+ });
426
+ });
427
+
428
+ // ---- read -----------------------------------------------------------
429
+
430
+ describe("read", () => {
431
+ it("normalizes CRLF in read details content", async () => {
432
+ readExec.mockResolvedValue({
433
+ content: [{ type: "text", text: "line1\r\nline2\rline3" }],
434
+ });
435
+ load(false);
436
+ const r = await tools.get("read")!.execute("t1", { path: "file.txt" }, null, null, {});
437
+ expect(r.details._type).toBe("readFile");
438
+ expect(r.details.content).toBe("line1\nline2\nline3");
439
+ expect(r.details.lineCount).toBe(3);
440
+ });
288
441
  });
289
442
 
290
443
  // ---- find: FFF path ------------------------------------------------
@@ -356,6 +509,22 @@ describe("piPrettyExtension integration", () => {
356
509
  expect(r.content[0].text).toContain("src/index.ts:42:const x = 1;");
357
510
  });
358
511
 
512
+ it("sanitizes CRLF in FFF grep output without extra records", async () => {
513
+ await loadWithFFF({
514
+ grep: vi.fn().mockReturnValue({
515
+ ok: true,
516
+ value: {
517
+ items: [{ relativePath: "src/index.ts", lineNumber: 42, lineContent: "const x = 1;\r\nconst y = 2;" }],
518
+ totalMatched: 1,
519
+ nextCursor: null,
520
+ },
521
+ }),
522
+ });
523
+ const r = await tools.get("grep")!.execute("t1", { pattern: "const" }, null, null, {});
524
+ expect(r.content[0].text).toBe("src/index.ts:42:const x = 1;\\nconst y = 2;");
525
+ expect(r.details.text.split("\n")).toHaveLength(1);
526
+ });
527
+
359
528
  it("literal=true → mode=plain", async () => {
360
529
  const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
361
530
  await loadWithFFF({ grep });
@@ -415,10 +584,11 @@ describe("piPrettyExtension integration", () => {
415
584
  expect(r.content[0].text).toContain("patterns array must have at least 1 element");
416
585
  });
417
586
 
418
- it("error when FFF not initialized (no session_start)", async () => {
587
+ it("falls back to SDK when FFF not initialized (no session_start)", async () => {
419
588
  load(true);
420
589
  const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["foo"] }, null, null, null);
421
- expect(r.content[0].text).toContain("FFF not initialized");
590
+ expect(grepExec).toHaveBeenCalledOnce();
591
+ expect(r.details._type).toBe("grepResult");
422
592
  });
423
593
 
424
594
  it("returns multiGrep results", async () => {
@@ -442,13 +612,95 @@ describe("piPrettyExtension integration", () => {
442
612
  expect(r.content[0].text).toContain("compile failed");
443
613
  });
444
614
 
445
- it("passes constraints and context", async () => {
615
+ it("passes context to unconstrained FFF multiGrep", async () => {
446
616
  const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
447
617
  await loadWithFFF({ multiGrep });
448
- await tools.get("multi_grep")!.execute("t1", { patterns: ["a", "b"], constraints: "*.ts", context: 2 }, null, null, null);
618
+ await tools.get("multi_grep")!.execute("t1", { patterns: ["a", "b"], context: 2 }, null, null, null);
449
619
  expect(multiGrep).toHaveBeenCalledWith(expect.objectContaining({
450
- patterns: ["a", "b"], constraints: "*.ts", beforeContext: 2, afterContext: 2,
620
+ patterns: ["a", "b"], beforeContext: 2, afterContext: 2,
621
+ }));
622
+ expect(multiGrep.mock.calls[0][0]).not.toHaveProperty("constraints");
623
+ });
624
+
625
+ it("glob constraints bypass FFF multiGrep and use ripgrep fallback", async () => {
626
+ const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
627
+ await loadWithFFF({ multiGrep });
628
+ await tools.get("multi_grep")!.execute("t1", { patterns: ["a", "b"], constraints: "*.ts", context: 2 }, null, null, {});
629
+ expect(multiGrep).not.toHaveBeenCalled();
630
+ expect(grepExec).not.toHaveBeenCalled();
631
+ expect(multiGrepRgExec).toHaveBeenCalledWith(expect.objectContaining({
632
+ patterns: ["a", "b"], constraints: "*.ts", context: 2, ignoreCase: true,
633
+ }));
634
+ });
635
+
636
+ it("path and constraints together bypass FFF multiGrep", async () => {
637
+ const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
638
+ await loadWithFFF({ multiGrep });
639
+ await tools.get("multi_grep")!.execute(
640
+ "t1",
641
+ { patterns: ["a", "b"], path: "src", constraints: "*.ts" },
642
+ null,
643
+ null,
644
+ {},
645
+ );
646
+ expect(multiGrep).not.toHaveBeenCalled();
647
+ expect(grepExec).not.toHaveBeenCalled();
648
+ expect(multiGrepRgExec).toHaveBeenCalledWith(expect.objectContaining({
649
+ patterns: ["a", "b"], path: "src", constraints: "*.ts",
650
+ }));
651
+ });
652
+
653
+ it("falls back to SDK when path is provided", async () => {
654
+ const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
655
+ await loadWithFFF({ multiGrep });
656
+ await tools.get("multi_grep")!.execute("t1", { patterns: ["foo", "bar"], path: "src" }, null, null, {});
657
+ expect(multiGrep).not.toHaveBeenCalled();
658
+ expect(grepExec).toHaveBeenCalledWith(
659
+ "t1",
660
+ expect.objectContaining({ pattern: "foo|bar", path: "src", ignoreCase: true }),
661
+ null,
662
+ null,
663
+ {},
664
+ );
665
+ });
666
+
667
+ it("uses path-backed ripgrep fallback for simple path constraints", async () => {
668
+ const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
669
+ await loadWithFFF({ multiGrep });
670
+ await tools.get("multi_grep")!.execute("t1", { patterns: ["foo", "bar"], constraints: "src" }, null, null, {});
671
+ expect(multiGrep).not.toHaveBeenCalled();
672
+ expect(grepExec).not.toHaveBeenCalled();
673
+ expect(multiGrepRgExec).toHaveBeenCalledWith(expect.objectContaining({
674
+ patterns: ["foo", "bar"], path: "src", constraints: undefined,
675
+ }));
676
+ });
677
+
678
+ it("preserves complex constraints in ripgrep fallback", async () => {
679
+ await loadWithFFF();
680
+ const result = await tools.get("multi_grep")!.execute(
681
+ "t1",
682
+ { patterns: ["foo", "bar"], path: "src", constraints: "*.ts !test/" },
683
+ null,
684
+ null,
685
+ {},
686
+ );
687
+ expect(grepExec).not.toHaveBeenCalled();
688
+ expect(multiGrepRgExec).toHaveBeenCalledWith(expect.objectContaining({
689
+ patterns: ["foo", "bar"], path: "src", constraints: "*.ts !test/",
451
690
  }));
691
+ expect(result.content[0].text).not.toContain("ignored unsupported constraints");
692
+ });
693
+
694
+ it("uses case-sensitive SDK fallback when any pattern contains uppercase", async () => {
695
+ await loadWithFFF();
696
+ await tools.get("multi_grep")!.execute("t1", { patterns: ["foo", "Bar"], path: "src" }, null, null, {});
697
+ expect(grepExec).toHaveBeenCalledWith(
698
+ "t1",
699
+ expect.objectContaining({ pattern: "foo|Bar", ignoreCase: false }),
700
+ null,
701
+ null,
702
+ {},
703
+ );
452
704
  });
453
705
  });
454
706
 
@@ -467,6 +719,29 @@ describe("piPrettyExtension integration", () => {
467
719
  }));
468
720
  });
469
721
 
722
+ it("delayed FFF status clear does not read a stale session ctx", async () => {
723
+ vi.useFakeTimers();
724
+ const setStatus = vi.fn();
725
+ let stale = false;
726
+ const ctx = {
727
+ cwd: "/tmp/test",
728
+ get ui() {
729
+ if (stale) throw new Error("stale ctx");
730
+ return { setStatus };
731
+ },
732
+ };
733
+
734
+ load(true);
735
+ const start = events.get("session_start")!;
736
+ await start({}, ctx);
737
+ stale = true;
738
+
739
+ vi.advanceTimersByTime(3000);
740
+
741
+ expect(setStatus).toHaveBeenNthCalledWith(1, "fff", "FFF indexed");
742
+ expect(setStatus).toHaveBeenNthCalledWith(2, "fff", undefined);
743
+ });
744
+
470
745
  it("shutdown → subsequent find falls back to SDK", async () => {
471
746
  await loadWithFFF();
472
747
  await events.get("session_shutdown")!();
@@ -128,11 +128,8 @@ describe("image rendering terminal detection", () => {
128
128
  expect(__imageInternals.getTmuxPassthroughWarning("kitty")).toBeNull();
129
129
  });
130
130
 
131
- it("renders explicit warning for read image when tmux passthrough is off", async () => {
132
- process.env.TMUX = "/tmp/tmux-1000/default,123,0";
133
- process.env.TERM_PROGRAM = "tmux";
134
- process.env.KITTY_WINDOW_ID = "1";
135
- __imageInternals.setTmuxAllowPassthroughOverrideForTests(false);
131
+ it("renders image metadata without a second inline preview", async () => {
132
+ process.env.TERM_PROGRAM = "kitty";
136
133
 
137
134
  const readTool = loadReadTool(async () => ({
138
135
  content: [{ type: "image", data: Buffer.from("fake").toString("base64"), mimeType: "image/png" }],
@@ -147,25 +144,10 @@ describe("image rendering terminal detection", () => {
147
144
  invalidate: () => {},
148
145
  });
149
146
 
150
- expect(rendered.getText()).toContain("allow-passthrough is off");
151
- });
152
-
153
- it("warns on non-PNG payloads for kitty protocol", async () => {
154
- process.env.TERM_PROGRAM = "kitty";
155
-
156
- const readTool = loadReadTool(async () => ({
157
- content: [{ type: "image", data: Buffer.from("jpeg").toString("base64"), mimeType: "image/jpeg" }],
158
- }));
159
-
160
- const result = await readTool.execute("t1", { path: "media/photo.jpg" }, null, null, {});
161
- const rendered = readTool.renderResult(result, {}, {}, {
162
- lastComponent: new MockText(),
163
- isError: false,
164
- state: {},
165
- expanded: false,
166
- invalidate: () => {},
167
- });
168
-
169
- expect(rendered.getText()).toContain("supports PNG payloads");
147
+ expect(rendered.getText()).toContain("image/png");
148
+ expect(rendered.getText()).not.toContain("\x1b_G");
149
+ expect(result.content).toEqual([
150
+ { type: "image", data: Buffer.from("fake").toString("base64"), mimeType: "image/png" },
151
+ ]);
170
152
  });
171
153
  });