@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 +1 -1
- package/src/fff-helpers.ts +73 -0
- package/src/index.ts +50 -70
- package/test/fff-integration.test.ts +455 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyhuynhgiabuu/pi-pretty",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
|
|
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 =
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
}
|
|
814
|
-
|
|
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
|
+
});
|