@heyhuynhgiabuu/pi-pretty 0.4.0 → 0.4.2
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/README.md +1 -1
- package/package.json +1 -1
- package/release-notes/v0.4.1.md +28 -0
- package/release-notes/v0.4.2.md +36 -0
- package/src/fff-helpers.ts +16 -4
- package/src/index.ts +220 -53
- package/src/multi-grep-fallback.ts +248 -0
- package/test/bash-rendering.test.ts +109 -0
- package/test/fff-integration.test.ts +266 -10
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ Use them when:
|
|
|
135
135
|
|
|
136
136
|
Optional environment variables:
|
|
137
137
|
|
|
138
|
-
- `PRETTY_THEME` (
|
|
138
|
+
- `PRETTY_THEME` (overrides `~/.pi/agent/settings.json` `theme`; otherwise pi-pretty falls back to that setting before `github-dark`)
|
|
139
139
|
- `PRETTY_MAX_HL_CHARS` (default: `80000`)
|
|
140
140
|
- `PRETTY_MAX_PREVIEW_LINES` (default: `80`)
|
|
141
141
|
- `PRETTY_CACHE_LIMIT` (default: `128`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyhuynhgiabuu/pi-pretty",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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,28 @@
|
|
|
1
|
+
# pi-pretty v0.4.1
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
This patch release prevents `grep` from aborting Pi when FFF 0.5.2 indexes Unicode filenames and the grep call includes a `path` or `glob` constraint.
|
|
5
|
+
|
|
6
|
+
## What changed
|
|
7
|
+
- Constrained `grep` calls now bypass native FFF and use the SDK fallback path.
|
|
8
|
+
- Unconstrained `grep` still uses FFF for the fast path.
|
|
9
|
+
- Added regression tests for both `path` and `glob` constrained grep bypass behavior.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
`@ff-labs/fff-node@0.5.2` can panic inside native Rust constraint matching when a byte-based slice lands inside a multi-byte UTF-8 character in an indexed filename, such as an en dash (`–`). Because that panic happens across FFI, JavaScript cannot catch it; the whole `pi` process aborts.
|
|
13
|
+
|
|
14
|
+
This release avoids the crash-prone native constraint path until upstream FFF ships the Unicode boundary fix.
|
|
15
|
+
|
|
16
|
+
## Files
|
|
17
|
+
- `src/index.ts`
|
|
18
|
+
- `test/fff-integration.test.ts`
|
|
19
|
+
- `package.json`
|
|
20
|
+
- `package-lock.json`
|
|
21
|
+
|
|
22
|
+
## Verification
|
|
23
|
+
- `npm run typecheck` ✅
|
|
24
|
+
- `npm run lint` ✅
|
|
25
|
+
- `npm test` ✅ (48 tests)
|
|
26
|
+
|
|
27
|
+
## Upgrade notes
|
|
28
|
+
No configuration changes required. After updating, file- or glob-scoped `grep` calls will prefer the SDK fallback for stability; broad unconstrained grep remains FFF-backed.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# pi-pretty 0.4.2
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
This patch release merges the reviewed `customizations` PR into `main` and ships safer `multi_grep` fallback behavior, improved theme resolution, normalized output handling, and clearer bash command rendering.
|
|
5
|
+
|
|
6
|
+
## What changed
|
|
7
|
+
- `multi_grep` now preserves constrained searches by routing them through a dedicated ripgrep fallback instead of silently dropping complex constraints.
|
|
8
|
+
- Complex constraint expressions such as `*.ts !test/` are converted into ripgrep `--glob` filters, while invalid constraint syntax now returns an error instead of being ignored.
|
|
9
|
+
- Constrained `grep` safety from `0.4.1` remains intact: `path`/`glob` constrained searches still avoid native FFF.
|
|
10
|
+
- pi-pretty now reads `~/.pi/agent/settings.json` theme settings by default, while `PRETTY_THEME` continues to override explicitly.
|
|
11
|
+
- Expanded bash tool rendering so expanded tool calls show the full command instead of the truncated preview.
|
|
12
|
+
- Normalized read/grep output handling and tightened single-line-safe FFF grep record formatting.
|
|
13
|
+
- Added test coverage for bash rendering, constrained grep and multi_grep fallback behavior, CRLF handling, and ripgrep constraint preservation.
|
|
14
|
+
|
|
15
|
+
## Files
|
|
16
|
+
- `src/index.ts`
|
|
17
|
+
- `src/fff-helpers.ts`
|
|
18
|
+
- `src/multi-grep-fallback.ts`
|
|
19
|
+
- `test/bash-rendering.test.ts`
|
|
20
|
+
- `test/fff-integration.test.ts`
|
|
21
|
+
- `README.md`
|
|
22
|
+
- `package.json`
|
|
23
|
+
- `package-lock.json`
|
|
24
|
+
|
|
25
|
+
## Verification
|
|
26
|
+
- `npm run typecheck` ✅
|
|
27
|
+
- `npm run lint` ✅
|
|
28
|
+
- `npm test` ✅ (67 tests)
|
|
29
|
+
|
|
30
|
+
## Upgrade notes
|
|
31
|
+
No configuration changes are required.
|
|
32
|
+
|
|
33
|
+
After updating:
|
|
34
|
+
- constrained `grep` and `multi_grep` searches prefer safe fallback paths instead of unsafe native constrained matching
|
|
35
|
+
- complex `multi_grep` constraints are preserved instead of being widened silently
|
|
36
|
+
- theme selection can follow Pi settings automatically unless `PRETTY_THEME` is set
|
package/src/fff-helpers.ts
CHANGED
|
@@ -6,6 +6,19 @@
|
|
|
6
6
|
|
|
7
7
|
import type { GrepCursor, GrepMatch } from "@ff-labs/fff-node";
|
|
8
8
|
|
|
9
|
+
function sanitizeGrepRecordContent(text: string): string {
|
|
10
|
+
let content = text;
|
|
11
|
+
if (content.endsWith("\r\n")) content = content.slice(0, -2);
|
|
12
|
+
else if (content.endsWith("\r") || content.endsWith("\n")) content = content.slice(0, -1);
|
|
13
|
+
|
|
14
|
+
return content.replace(/\r\n/g, "\\n").replace(/\r/g, "\\r").replace(/\n/g, "\\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function truncateGrepRecordContent(text: string): string {
|
|
18
|
+
const content = sanitizeGrepRecordContent(text);
|
|
19
|
+
return content.length > 500 ? `${content.slice(0, 500)}...` : content;
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
/**
|
|
10
23
|
* Store for FFF grep pagination cursors.
|
|
11
24
|
* Evicts oldest entry when exceeding maxSize.
|
|
@@ -57,15 +70,14 @@ export function fffFormatGrepText(items: GrepMatch[], limit: number): string {
|
|
|
57
70
|
if (match.contextBefore?.length) {
|
|
58
71
|
const startLine = match.lineNumber - match.contextBefore.length;
|
|
59
72
|
for (let i = 0; i < match.contextBefore.length; i++) {
|
|
60
|
-
lines.push(`${match.relativePath}-${startLine + i}-${match.contextBefore[i]}`);
|
|
73
|
+
lines.push(`${match.relativePath}-${startLine + i}-${truncateGrepRecordContent(match.contextBefore[i] ?? "")}`);
|
|
61
74
|
}
|
|
62
75
|
}
|
|
63
|
-
|
|
64
|
-
lines.push(`${match.relativePath}:${match.lineNumber}:${content}`);
|
|
76
|
+
lines.push(`${match.relativePath}:${match.lineNumber}:${truncateGrepRecordContent(match.lineContent)}`);
|
|
65
77
|
if (match.contextAfter?.length) {
|
|
66
78
|
const startLine = match.lineNumber + 1;
|
|
67
79
|
for (let i = 0; i < match.contextAfter.length; i++) {
|
|
68
|
-
lines.push(`${match.relativePath}-${startLine + i}-${match.contextAfter[i]}`);
|
|
80
|
+
lines.push(`${match.relativePath}-${startLine + i}-${truncateGrepRecordContent(match.contextAfter[i] ?? "")}`);
|
|
69
81
|
}
|
|
70
82
|
}
|
|
71
83
|
}
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import * as childProcess from "node:child_process";
|
|
27
|
-
import { mkdirSync } from "node:fs";
|
|
27
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
28
28
|
import { basename, dirname, extname, join, relative } from "node:path";
|
|
29
29
|
|
|
30
30
|
import type { FileFinder, FileItem, GrepResult, SearchResult } from "@ff-labs/fff-node";
|
|
@@ -45,12 +45,46 @@ import { codeToANSI } from "@shikijs/cli";
|
|
|
45
45
|
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
46
46
|
|
|
47
47
|
import { CursorStore, fffFormatGrepText } from "./fff-helpers.js";
|
|
48
|
+
import { type MultiGrepRipgrepFallback, runMultiGrepRipgrepFallback } from "./multi-grep-fallback.js";
|
|
48
49
|
|
|
49
50
|
// ---------------------------------------------------------------------------
|
|
50
51
|
// Config
|
|
51
52
|
// ---------------------------------------------------------------------------
|
|
52
53
|
|
|
53
|
-
const
|
|
54
|
+
const DEFAULT_THEME: BundledTheme = "github-dark";
|
|
55
|
+
|
|
56
|
+
function getDefaultAgentDir(): string | undefined {
|
|
57
|
+
const home = process.env.HOME ?? "";
|
|
58
|
+
return home ? join(home, ".pi/agent") : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readThemeFromSettings(agentDir?: string): BundledTheme | undefined {
|
|
62
|
+
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
|
|
63
|
+
if (!resolvedAgentDir) return undefined;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const settings = JSON.parse(readFileSync(join(resolvedAgentDir, "settings.json"), "utf8")) as {
|
|
67
|
+
theme?: unknown;
|
|
68
|
+
};
|
|
69
|
+
return typeof settings.theme === "string" ? (settings.theme as BundledTheme) : undefined;
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolvePrettyTheme(agentDir?: string): BundledTheme {
|
|
76
|
+
return (process.env.PRETTY_THEME as BundledTheme | undefined) ?? readThemeFromSettings(agentDir) ?? DEFAULT_THEME;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let THEME: BundledTheme = resolvePrettyTheme();
|
|
80
|
+
|
|
81
|
+
function setPrettyTheme(agentDir?: string): void {
|
|
82
|
+
const resolvedTheme = resolvePrettyTheme(agentDir);
|
|
83
|
+
if (resolvedTheme === THEME) return;
|
|
84
|
+
THEME = resolvedTheme;
|
|
85
|
+
_cache.clear();
|
|
86
|
+
codeToANSI("", "typescript", THEME).catch(() => {});
|
|
87
|
+
}
|
|
54
88
|
|
|
55
89
|
function envInt(name: string, fallback: number): number {
|
|
56
90
|
const v = Number.parseInt(process.env[name] ?? "", 10);
|
|
@@ -146,6 +180,10 @@ function strip(s: string): string {
|
|
|
146
180
|
return s.replace(ANSI_RE, "");
|
|
147
181
|
}
|
|
148
182
|
|
|
183
|
+
function normalizeLineEndings(text: string): string {
|
|
184
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
149
187
|
function preserveToolBackground(ansi: string, bg: string): string {
|
|
150
188
|
return ansi.replace(ANSI_CAPTURE_RE, (seq, params: string) => {
|
|
151
189
|
const codes = params.split(";");
|
|
@@ -614,7 +652,8 @@ async function renderFileContent(
|
|
|
614
652
|
offset = 1,
|
|
615
653
|
maxLines = MAX_PREVIEW_LINES,
|
|
616
654
|
): Promise<string> {
|
|
617
|
-
const
|
|
655
|
+
const normalizedContent = normalizeLineEndings(content);
|
|
656
|
+
const lines = normalizedContent.split("\n");
|
|
618
657
|
const total = lines.length;
|
|
619
658
|
const show = lines.slice(0, maxLines);
|
|
620
659
|
const lg = lang(filePath);
|
|
@@ -754,7 +793,7 @@ function renderFindResults(text: string): string {
|
|
|
754
793
|
|
|
755
794
|
/** Render grep results with highlighted matches and line numbers. */
|
|
756
795
|
async function renderGrepResults(text: string, pattern: string): Promise<string> {
|
|
757
|
-
const lines = text.split("\n");
|
|
796
|
+
const lines = normalizeLineEndings(text).split("\n");
|
|
758
797
|
if (!lines.length || (lines.length === 1 && !lines[0].trim())) return `${FG_DIM}(no matches)${RST}`;
|
|
759
798
|
|
|
760
799
|
const out: string[] = [];
|
|
@@ -875,6 +914,7 @@ type FindParams = FindToolInput;
|
|
|
875
914
|
type GrepParams = GrepToolInput;
|
|
876
915
|
type MultiGrepParams = {
|
|
877
916
|
patterns: string[];
|
|
917
|
+
path?: string;
|
|
878
918
|
constraints?: string;
|
|
879
919
|
context?: number;
|
|
880
920
|
limit?: number;
|
|
@@ -934,6 +974,34 @@ function getErrorMessage(error: unknown): string {
|
|
|
934
974
|
return error instanceof Error ? error.message : String(error);
|
|
935
975
|
}
|
|
936
976
|
|
|
977
|
+
function trimToUndefined(value: string | undefined): string | undefined {
|
|
978
|
+
const trimmed = value?.trim();
|
|
979
|
+
return trimmed ? trimmed : undefined;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function escapeRegexLiteral(text: string): string {
|
|
983
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function buildLiteralAlternationPattern(patterns: string[]): string {
|
|
987
|
+
return patterns
|
|
988
|
+
.map(escapeRegexLiteral)
|
|
989
|
+
.sort((a, b) => b.length - a.length)
|
|
990
|
+
.join("|");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function shouldIgnoreCaseForPatterns(patterns: string[]): boolean {
|
|
994
|
+
return patterns.every((pattern) => pattern.toLowerCase() === pattern);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function getConstraintBackedPath(constraints: string | undefined): string | undefined {
|
|
998
|
+
const trimmed = trimToUndefined(constraints);
|
|
999
|
+
if (!trimmed || /\s/.test(trimmed) || trimmed.includes("!") || trimmed.endsWith("/") || /[*?[{]/.test(trimmed)) {
|
|
1000
|
+
return undefined;
|
|
1001
|
+
}
|
|
1002
|
+
return trimmed;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
937
1005
|
const _cursorStore = new CursorStore();
|
|
938
1006
|
let _fffModule: OptionalFffModule | null = null;
|
|
939
1007
|
let _fffFinder: FffBackedFinder | null = null;
|
|
@@ -985,6 +1053,7 @@ export interface PiPrettyDeps {
|
|
|
985
1053
|
sdk: PiPrettySdk;
|
|
986
1054
|
TextComponent: TextComponentCtor;
|
|
987
1055
|
fffModule?: OptionalFffModule;
|
|
1056
|
+
multiGrepRipgrepFallback?: MultiGrepRipgrepFallback;
|
|
988
1057
|
}
|
|
989
1058
|
|
|
990
1059
|
export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps): void {
|
|
@@ -1028,12 +1097,22 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1028
1097
|
const cwd = process.cwd();
|
|
1029
1098
|
const home = process.env.HOME ?? "";
|
|
1030
1099
|
const sp = (p: string) => shortPath(cwd, home, p);
|
|
1100
|
+
const multiGrepRipgrepFallback = deps?.multiGrepRipgrepFallback ?? runMultiGrepRipgrepFallback;
|
|
1031
1101
|
|
|
1032
1102
|
// ===================================================================
|
|
1033
1103
|
// FFF initialization (optional — graceful fallback to SDK)
|
|
1034
1104
|
// ===================================================================
|
|
1035
1105
|
|
|
1036
1106
|
const getAgentDir = sdk.getAgentDir;
|
|
1107
|
+
setPrettyTheme(
|
|
1108
|
+
(() => {
|
|
1109
|
+
try {
|
|
1110
|
+
return getAgentDir?.() ?? getDefaultAgentDir();
|
|
1111
|
+
} catch {
|
|
1112
|
+
return getDefaultAgentDir();
|
|
1113
|
+
}
|
|
1114
|
+
})(),
|
|
1115
|
+
);
|
|
1037
1116
|
if (!deps) {
|
|
1038
1117
|
// Only try require() in production — tests inject fffModule via deps
|
|
1039
1118
|
try {
|
|
@@ -1124,11 +1203,12 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1124
1203
|
|
|
1125
1204
|
const textContent = getTextContent(result);
|
|
1126
1205
|
if (textContent && fp) {
|
|
1127
|
-
const
|
|
1206
|
+
const normalizedContent = normalizeLineEndings(textContent);
|
|
1207
|
+
const lineCount = normalizedContent.split("\n").length;
|
|
1128
1208
|
setResultDetails(result, {
|
|
1129
1209
|
_type: "readFile",
|
|
1130
1210
|
filePath: fp,
|
|
1131
|
-
content:
|
|
1211
|
+
content: normalizedContent,
|
|
1132
1212
|
offset,
|
|
1133
1213
|
lineCount,
|
|
1134
1214
|
});
|
|
@@ -1277,9 +1357,10 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1277
1357
|
const cmd = args.command ?? "";
|
|
1278
1358
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1279
1359
|
const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
|
|
1360
|
+
const displayCmd = ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
|
|
1280
1361
|
text.setText(
|
|
1281
1362
|
fillToolBackground(
|
|
1282
|
-
`${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent",
|
|
1363
|
+
`${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`,
|
|
1283
1364
|
),
|
|
1284
1365
|
);
|
|
1285
1366
|
return text;
|
|
@@ -1516,13 +1597,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1516
1597
|
upd: unknown,
|
|
1517
1598
|
ctx: ExtensionContext,
|
|
1518
1599
|
) {
|
|
1519
|
-
// Try FFF first (SIMD-accelerated, frecency-ranked)
|
|
1520
|
-
|
|
1600
|
+
// Try FFF first (SIMD-accelerated, frecency-ranked).
|
|
1601
|
+
// FFF 0.5.2 can abort the process when path/glob constraints meet
|
|
1602
|
+
// Unicode filenames, so constrained searches use the SDK fallback.
|
|
1603
|
+
if (_fffFinder && !_fffFinder.isDestroyed && !params.path && !params.glob) {
|
|
1521
1604
|
try {
|
|
1522
1605
|
const effectiveLimit = Math.max(1, params.limit ?? 100);
|
|
1523
|
-
|
|
1524
|
-
if (params.glob) query = `${params.glob} ${query}`;
|
|
1525
|
-
else if (params.path) query = `${params.path} ${query}`;
|
|
1606
|
+
const query = params.pattern;
|
|
1526
1607
|
|
|
1527
1608
|
const grepResult = _fffFinder.grep(query, {
|
|
1528
1609
|
mode: params.literal ? "plain" : "regex",
|
|
@@ -1559,7 +1640,12 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1559
1640
|
|
|
1560
1641
|
// SDK fallback
|
|
1561
1642
|
const result = await origGrep.execute(tid, params, sig, upd as never, ctx);
|
|
1562
|
-
const textContent = getTextContent(result);
|
|
1643
|
+
const textContent = normalizeLineEndings(getTextContent(result));
|
|
1644
|
+
if (result.content) {
|
|
1645
|
+
for (const content of result.content) {
|
|
1646
|
+
if (isTextContent(content)) content.text = normalizeLineEndings(content.text || "");
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1563
1649
|
const matchCount = textContent ? countRipgrepMatches(textContent) : 0;
|
|
1564
1650
|
|
|
1565
1651
|
setResultDetails<GrepResultDetails>(result, {
|
|
@@ -1629,25 +1715,30 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1629
1715
|
}
|
|
1630
1716
|
|
|
1631
1717
|
// ===================================================================
|
|
1632
|
-
// multi_grep —
|
|
1718
|
+
// multi_grep — OR-logic multi-pattern search (FFF when available,
|
|
1719
|
+
// SDK grep fallback otherwise)
|
|
1633
1720
|
// ===================================================================
|
|
1634
1721
|
|
|
1635
|
-
if (_fffModule) {
|
|
1722
|
+
if (_fffModule || createGrepTool) {
|
|
1723
|
+
const multiGrepFallback = createGrepTool ? createGrepTool(cwd) : null;
|
|
1724
|
+
|
|
1636
1725
|
pi.registerTool({
|
|
1637
1726
|
name: "multi_grep",
|
|
1638
|
-
label: "multi_grep
|
|
1727
|
+
label: "multi_grep",
|
|
1639
1728
|
description: [
|
|
1640
1729
|
"Search file contents for lines matching ANY of multiple patterns (OR logic).",
|
|
1641
|
-
"Uses SIMD-accelerated Aho-Corasick multi-pattern matching
|
|
1730
|
+
"Uses SIMD-accelerated Aho-Corasick multi-pattern matching when FFF is available.",
|
|
1731
|
+
"Falls back to ripgrep while preserving literal OR semantics and file constraints when needed.",
|
|
1642
1732
|
"Patterns are literal text — never escape special characters.",
|
|
1643
|
-
"Use
|
|
1733
|
+
"Use path to scope a directory/file and constraints for file filtering ('*.rs', 'src/', '!test/').",
|
|
1644
1734
|
].join(" "),
|
|
1645
|
-
promptSnippet: "Multi-pattern OR search across file contents (FFF
|
|
1735
|
+
promptSnippet: "Multi-pattern OR search across file contents (FFF-accelerated with grep fallback)",
|
|
1646
1736
|
promptGuidelines: [
|
|
1647
1737
|
"Use multi_grep when you need to find multiple identifiers at once (OR logic).",
|
|
1648
1738
|
"Include all naming conventions: snake_case, PascalCase, camelCase variants.",
|
|
1649
1739
|
"Patterns are literal text. Never escape special characters.",
|
|
1650
|
-
"Use
|
|
1740
|
+
"Use path to scope a directory or file when you need fresh on-disk results.",
|
|
1741
|
+
"Use the constraints parameter for additional file filtering, not inside patterns.",
|
|
1651
1742
|
],
|
|
1652
1743
|
|
|
1653
1744
|
parameters: {
|
|
@@ -1658,6 +1749,10 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1658
1749
|
items: { type: "string" },
|
|
1659
1750
|
description: "Patterns to search for (OR logic — matches lines containing ANY pattern).",
|
|
1660
1751
|
},
|
|
1752
|
+
path: {
|
|
1753
|
+
type: "string",
|
|
1754
|
+
description: "Directory or file path to search (default: current directory)",
|
|
1755
|
+
},
|
|
1661
1756
|
constraints: {
|
|
1662
1757
|
type: "string",
|
|
1663
1758
|
description: "File constraints, e.g. '*.{ts,tsx} !test/' to filter files.",
|
|
@@ -1675,11 +1770,11 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1675
1770
|
},
|
|
1676
1771
|
|
|
1677
1772
|
async execute(
|
|
1678
|
-
|
|
1773
|
+
tid: string,
|
|
1679
1774
|
params: MultiGrepParams,
|
|
1680
1775
|
sig: AbortSignal | undefined,
|
|
1681
|
-
|
|
1682
|
-
|
|
1776
|
+
upd: unknown,
|
|
1777
|
+
ctx: ExtensionContext,
|
|
1683
1778
|
) {
|
|
1684
1779
|
if (sig?.aborted) return makeTextResult("Aborted", {});
|
|
1685
1780
|
|
|
@@ -1687,42 +1782,112 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1687
1782
|
return makeTextResult("Error: patterns array must have at least 1 element", { error: "empty patterns" });
|
|
1688
1783
|
}
|
|
1689
1784
|
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1785
|
+
const effectiveLimit = Math.max(1, params.limit ?? 100);
|
|
1786
|
+
const pattern = buildLiteralAlternationPattern(params.patterns);
|
|
1787
|
+
const requestedPath = trimToUndefined(params.path);
|
|
1788
|
+
const requestedConstraints = trimToUndefined(params.constraints);
|
|
1789
|
+
const effectivePath = requestedPath ?? getConstraintBackedPath(requestedConstraints);
|
|
1790
|
+
const hasNativeConstraints = Boolean(requestedPath || requestedConstraints);
|
|
1693
1791
|
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1792
|
+
if (_fffFinder && !_fffFinder.isDestroyed && !hasNativeConstraints) {
|
|
1793
|
+
try {
|
|
1794
|
+
const grepResult = _fffFinder.multiGrep({
|
|
1795
|
+
patterns: params.patterns,
|
|
1796
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
1797
|
+
smartCase: true,
|
|
1798
|
+
cursor: null,
|
|
1799
|
+
beforeContext: params.context ?? 0,
|
|
1800
|
+
afterContext: params.context ?? 0,
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
if (!grepResult.ok) {
|
|
1804
|
+
return makeTextResult(`multi_grep error: ${grepResult.error}`, { error: grepResult.error });
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
const grep: GrepResult = grepResult.value;
|
|
1808
|
+
const notices: string[] = [];
|
|
1809
|
+
if (_fffPartialIndex) notices.push("Warning: partial file index");
|
|
1810
|
+
if (grep.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
|
|
1811
|
+
if (grep.nextCursor) {
|
|
1812
|
+
const cursorId = _cursorStore.store(grep.nextCursor);
|
|
1813
|
+
notices.push(`More results: cursor="${cursorId}"`);
|
|
1814
|
+
}
|
|
1706
1815
|
|
|
1707
|
-
|
|
1708
|
-
return makeTextResult(
|
|
1816
|
+
const textContent = appendNotices(fffFormatGrepText(grep.items, effectiveLimit), notices);
|
|
1817
|
+
return makeTextResult<GrepResultDetails>(textContent, {
|
|
1818
|
+
_type: "grepResult",
|
|
1819
|
+
text: textContent,
|
|
1820
|
+
pattern,
|
|
1821
|
+
matchCount: Math.min(grep.items.length, effectiveLimit),
|
|
1822
|
+
});
|
|
1823
|
+
} catch {
|
|
1824
|
+
/* fall through to SDK */
|
|
1709
1825
|
}
|
|
1826
|
+
}
|
|
1710
1827
|
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
const
|
|
1717
|
-
notices
|
|
1828
|
+
if (requestedConstraints || !multiGrepFallback) {
|
|
1829
|
+
try {
|
|
1830
|
+
const pathBackedConstraint = Boolean(
|
|
1831
|
+
requestedConstraints && !requestedPath && requestedConstraints === effectivePath,
|
|
1832
|
+
);
|
|
1833
|
+
const constraintsForRipgrep = pathBackedConstraint ? undefined : requestedConstraints;
|
|
1834
|
+
const notices: string[] = [];
|
|
1835
|
+
|
|
1836
|
+
if (!_fffFinder || _fffFinder.isDestroyed) notices.push("FFF unavailable, used ripgrep fallback");
|
|
1837
|
+
else if (hasNativeConstraints) notices.push("Used ripgrep fallback for constrained search");
|
|
1838
|
+
else notices.push("Used ripgrep fallback");
|
|
1839
|
+
|
|
1840
|
+
const rgResult = await multiGrepRipgrepFallback({
|
|
1841
|
+
cwd,
|
|
1842
|
+
patterns: params.patterns,
|
|
1843
|
+
path: effectivePath,
|
|
1844
|
+
constraints: constraintsForRipgrep,
|
|
1845
|
+
ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
|
|
1846
|
+
context: params.context,
|
|
1847
|
+
limit: effectiveLimit,
|
|
1848
|
+
signal: sig,
|
|
1849
|
+
});
|
|
1850
|
+
const textContent = normalizeLineEndings(rgResult.text) || "No matches found";
|
|
1851
|
+
if (rgResult.limitReached) notices.push(`${effectiveLimit} limit reached`);
|
|
1852
|
+
const finalText = appendNotices(textContent, notices);
|
|
1853
|
+
|
|
1854
|
+
return makeTextResult<GrepResultDetails>(finalText, {
|
|
1855
|
+
_type: "grepResult",
|
|
1856
|
+
text: finalText,
|
|
1857
|
+
pattern,
|
|
1858
|
+
matchCount: rgResult.matchCount,
|
|
1859
|
+
});
|
|
1860
|
+
} catch (error: unknown) {
|
|
1861
|
+
const message = getErrorMessage(error);
|
|
1862
|
+
return makeTextResult(`multi_grep error: ${message}`, { error: message });
|
|
1718
1863
|
}
|
|
1864
|
+
}
|
|
1719
1865
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1866
|
+
try {
|
|
1867
|
+
const notices: string[] = [];
|
|
1868
|
+
if (!_fffFinder || _fffFinder.isDestroyed) notices.push("FFF unavailable, used SDK grep fallback");
|
|
1869
|
+
|
|
1870
|
+
const result = await multiGrepFallback.execute(
|
|
1871
|
+
tid,
|
|
1872
|
+
{
|
|
1873
|
+
pattern,
|
|
1874
|
+
path: effectivePath,
|
|
1875
|
+
ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
|
|
1876
|
+
context: params.context,
|
|
1877
|
+
limit: params.limit,
|
|
1878
|
+
},
|
|
1879
|
+
sig,
|
|
1880
|
+
upd as never,
|
|
1881
|
+
ctx,
|
|
1882
|
+
);
|
|
1883
|
+
const textContent = normalizeLineEndings(getTextContent(result)) || "No matches found";
|
|
1884
|
+
const finalText = appendNotices(textContent, notices);
|
|
1885
|
+
|
|
1886
|
+
return makeTextResult<GrepResultDetails>(finalText, {
|
|
1722
1887
|
_type: "grepResult",
|
|
1723
|
-
text:
|
|
1724
|
-
pattern
|
|
1725
|
-
matchCount:
|
|
1888
|
+
text: finalText,
|
|
1889
|
+
pattern,
|
|
1890
|
+
matchCount: textContent ? countRipgrepMatches(textContent) : 0,
|
|
1726
1891
|
});
|
|
1727
1892
|
} catch (error: unknown) {
|
|
1728
1893
|
const message = getErrorMessage(error);
|
|
@@ -1733,14 +1898,16 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1733
1898
|
renderCall(args: MultiGrepParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1734
1899
|
resolveBaseBackground(theme);
|
|
1735
1900
|
const patterns = args.patterns ?? [];
|
|
1901
|
+
const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
|
|
1736
1902
|
const constraints = args.constraints;
|
|
1737
1903
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1738
1904
|
let content =
|
|
1739
1905
|
theme.fg("toolTitle", theme.bold("multi_grep")) +
|
|
1740
1906
|
" " +
|
|
1741
1907
|
theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
|
|
1908
|
+
content += path;
|
|
1742
1909
|
if (constraints) content += theme.fg("muted", ` (${constraints})`);
|
|
1743
|
-
text.setText(content);
|
|
1910
|
+
text.setText(fillToolBackground(content));
|
|
1744
1911
|
return text;
|
|
1745
1912
|
},
|
|
1746
1913
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type ConstraintParseResult = { ok: true; globs: string[]; tokens: string[] } | { ok: false; error: string };
|
|
4
|
+
|
|
5
|
+
export type MultiGrepRipgrepFallbackParams = {
|
|
6
|
+
cwd: string;
|
|
7
|
+
patterns: string[];
|
|
8
|
+
path?: string;
|
|
9
|
+
constraints?: string;
|
|
10
|
+
context?: number;
|
|
11
|
+
limit: number;
|
|
12
|
+
ignoreCase: boolean;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type MultiGrepRipgrepFallbackResult = {
|
|
17
|
+
text: string;
|
|
18
|
+
matchCount: number;
|
|
19
|
+
limitReached: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MultiGrepRipgrepFallback = (
|
|
23
|
+
params: MultiGrepRipgrepFallbackParams,
|
|
24
|
+
) => Promise<MultiGrepRipgrepFallbackResult>;
|
|
25
|
+
|
|
26
|
+
const GLOB_META_RE = /[*?[{]/;
|
|
27
|
+
|
|
28
|
+
function trimSlashes(value: string): string {
|
|
29
|
+
return value.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeConstraintPath(value: string): string {
|
|
33
|
+
let normalized = value.replace(/\\/g, "/").trim();
|
|
34
|
+
while (normalized.startsWith("./")) normalized = normalized.slice(2);
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function tokenizeConstraints(constraints: string): ConstraintParseResult {
|
|
39
|
+
const tokens: string[] = [];
|
|
40
|
+
let current = "";
|
|
41
|
+
let quote: '"' | "'" | null = null;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < constraints.length; i++) {
|
|
44
|
+
const char = constraints[i];
|
|
45
|
+
if (quote) {
|
|
46
|
+
if (char === quote) quote = null;
|
|
47
|
+
else current += char;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (char === '"' || char === "'") {
|
|
52
|
+
quote = char;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (/\s/.test(char)) {
|
|
57
|
+
if (current) {
|
|
58
|
+
tokens.push(current);
|
|
59
|
+
current = "";
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
current += char;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (quote) return { ok: false, error: "unterminated quoted constraint" };
|
|
68
|
+
if (current) tokens.push(current);
|
|
69
|
+
|
|
70
|
+
return { ok: true, globs: [], tokens };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tokenToRipgrepGlob(token: string): { ok: true; glob: string } | { ok: false; error: string } {
|
|
74
|
+
let negated = false;
|
|
75
|
+
let body = token;
|
|
76
|
+
|
|
77
|
+
if (body.startsWith("!")) {
|
|
78
|
+
negated = true;
|
|
79
|
+
body = body.slice(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
body = normalizeConstraintPath(body);
|
|
83
|
+
if (!body) return { ok: false, error: `empty constraint token: ${token}` };
|
|
84
|
+
if (body.includes("\0")) return { ok: false, error: `invalid NUL byte in constraint token: ${token}` };
|
|
85
|
+
|
|
86
|
+
let glob: string;
|
|
87
|
+
if (body.endsWith("/")) {
|
|
88
|
+
const dir = trimSlashes(body);
|
|
89
|
+
if (!dir) return { ok: false, error: `empty directory constraint: ${token}` };
|
|
90
|
+
glob = `**/${dir}/**`;
|
|
91
|
+
} else if (GLOB_META_RE.test(body) || body.includes("/")) {
|
|
92
|
+
glob = body.replace(/^\/+/, "");
|
|
93
|
+
} else if (body.includes(".")) {
|
|
94
|
+
glob = `**/${body}`;
|
|
95
|
+
} else {
|
|
96
|
+
glob = `**/${body}/**`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { ok: true, glob: negated ? `!${glob}` : glob };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseMultiGrepConstraints(constraints: string | undefined): ConstraintParseResult {
|
|
103
|
+
const trimmed = constraints?.trim();
|
|
104
|
+
if (!trimmed) return { ok: true, globs: [], tokens: [] };
|
|
105
|
+
|
|
106
|
+
const tokenized = tokenizeConstraints(trimmed);
|
|
107
|
+
if (!tokenized.ok) return tokenized;
|
|
108
|
+
|
|
109
|
+
const globs: string[] = [];
|
|
110
|
+
for (const token of tokenized.tokens) {
|
|
111
|
+
const parsed = tokenToRipgrepGlob(token);
|
|
112
|
+
if (!parsed.ok) return parsed;
|
|
113
|
+
globs.push(parsed.glob);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ok: true, globs, tokens: tokenized.tokens };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isRipgrepMatchLine(line: string): boolean {
|
|
120
|
+
return /^.+?:\d+:/.test(line);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildRipgrepArgs(params: MultiGrepRipgrepFallbackParams, globs: string[]): string[] {
|
|
124
|
+
const args = ["--line-number", "--with-filename", "--color=never", "--hidden", "--fixed-strings"];
|
|
125
|
+
|
|
126
|
+
if (params.ignoreCase) args.push("--ignore-case");
|
|
127
|
+
if (params.context && params.context > 0) args.push("--context", String(params.context));
|
|
128
|
+
|
|
129
|
+
for (const glob of globs) args.push("--glob", glob);
|
|
130
|
+
for (const pattern of params.patterns) args.push("-e", pattern);
|
|
131
|
+
|
|
132
|
+
const searchPath = params.path?.trim();
|
|
133
|
+
if (searchPath) args.push("--", searchPath);
|
|
134
|
+
|
|
135
|
+
return args;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getMultiGrepRipgrepArgs(
|
|
139
|
+
params: MultiGrepRipgrepFallbackParams,
|
|
140
|
+
): ConstraintParseResult & { args?: string[] } {
|
|
141
|
+
const parsed = parseMultiGrepConstraints(params.constraints);
|
|
142
|
+
if (!parsed.ok) return parsed;
|
|
143
|
+
return { ...parsed, args: buildRipgrepArgs(params, parsed.globs) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function runMultiGrepRipgrepFallback(
|
|
147
|
+
params: MultiGrepRipgrepFallbackParams,
|
|
148
|
+
): Promise<MultiGrepRipgrepFallbackResult> {
|
|
149
|
+
const parsed = parseMultiGrepConstraints(params.constraints);
|
|
150
|
+
if (!parsed.ok) throw new Error(`unsupported constraints: ${parsed.error}`);
|
|
151
|
+
|
|
152
|
+
const args = buildRipgrepArgs(params, parsed.globs);
|
|
153
|
+
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
if (params.signal?.aborted) {
|
|
156
|
+
reject(new Error("Operation aborted"));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const child = spawn("rg", args, { cwd: params.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
161
|
+
const outputLines: string[] = [];
|
|
162
|
+
let stderr = "";
|
|
163
|
+
let buffer = "";
|
|
164
|
+
let matchCount = 0;
|
|
165
|
+
let limitReached = false;
|
|
166
|
+
let killedForLimit = false;
|
|
167
|
+
let settled = false;
|
|
168
|
+
|
|
169
|
+
const settle = (fn: () => void): void => {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
settled = true;
|
|
172
|
+
fn();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const stopChild = (dueToLimit = false): void => {
|
|
176
|
+
if (!child.killed) {
|
|
177
|
+
killedForLimit = dueToLimit;
|
|
178
|
+
child.kill();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const onAbort = (): void => stopChild(false);
|
|
183
|
+
params.signal?.addEventListener("abort", onAbort, { once: true });
|
|
184
|
+
|
|
185
|
+
const cleanup = (): void => {
|
|
186
|
+
params.signal?.removeEventListener("abort", onAbort);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handleLine = (line: string): void => {
|
|
190
|
+
if (limitReached) return;
|
|
191
|
+
outputLines.push(line);
|
|
192
|
+
if (isRipgrepMatchLine(line)) {
|
|
193
|
+
matchCount++;
|
|
194
|
+
if (matchCount >= params.limit) {
|
|
195
|
+
limitReached = true;
|
|
196
|
+
stopChild(true);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
202
|
+
buffer += chunk.toString("utf8");
|
|
203
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
204
|
+
while (newlineIndex >= 0) {
|
|
205
|
+
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
|
|
206
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
207
|
+
handleLine(line);
|
|
208
|
+
newlineIndex = buffer.indexOf("\n");
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
213
|
+
stderr += chunk.toString("utf8");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
217
|
+
cleanup();
|
|
218
|
+
const message =
|
|
219
|
+
error.code === "ENOENT" ? "ripgrep (rg) is not available" : `Failed to run ripgrep: ${error.message}`;
|
|
220
|
+
settle(() => reject(new Error(message)));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
child.on("close", (code) => {
|
|
224
|
+
cleanup();
|
|
225
|
+
|
|
226
|
+
if (params.signal?.aborted) {
|
|
227
|
+
settle(() => reject(new Error("Operation aborted")));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (buffer && !limitReached) handleLine(buffer.replace(/\r$/, ""));
|
|
232
|
+
|
|
233
|
+
if (!killedForLimit && code !== 0 && code !== 1) {
|
|
234
|
+
const message = stderr.trim() || `ripgrep exited with code ${code}`;
|
|
235
|
+
settle(() => reject(new Error(message)));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
settle(() =>
|
|
240
|
+
resolve({
|
|
241
|
+
text: outputLines.length ? outputLines.join("\n") : "No matches found",
|
|
242
|
+
matchCount,
|
|
243
|
+
limitReached,
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import piPrettyExtension from "../src/index.js";
|
|
4
|
+
|
|
5
|
+
class MockText {
|
|
6
|
+
private text = "";
|
|
7
|
+
constructor(_text = "", _x = 0, _y = 0) {}
|
|
8
|
+
setText(value: string) {
|
|
9
|
+
this.text = value;
|
|
10
|
+
}
|
|
11
|
+
getText() {
|
|
12
|
+
return this.text;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const mockTheme = {
|
|
17
|
+
fg: (_key: string, text: string) => text,
|
|
18
|
+
bold: (text: string) => text,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function mockToolFactory(exec: any) {
|
|
22
|
+
return (_cwd: string) => ({
|
|
23
|
+
name: "mock",
|
|
24
|
+
description: "mock",
|
|
25
|
+
parameters: { type: "object", properties: {} },
|
|
26
|
+
execute: exec,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadBashTool() {
|
|
31
|
+
const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
|
|
32
|
+
const tools = new Map<string, any>();
|
|
33
|
+
const pi = {
|
|
34
|
+
registerTool: (tool: any) => tools.set(tool.name, tool),
|
|
35
|
+
registerCommand: () => {},
|
|
36
|
+
on: () => {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
piPrettyExtension(pi, {
|
|
40
|
+
sdk: {
|
|
41
|
+
createReadToolDefinition: mockToolFactory(noopExec),
|
|
42
|
+
createBashToolDefinition: mockToolFactory(noopExec),
|
|
43
|
+
createLsToolDefinition: mockToolFactory(noopExec),
|
|
44
|
+
createFindToolDefinition: mockToolFactory(noopExec),
|
|
45
|
+
createGrepToolDefinition: mockToolFactory(noopExec),
|
|
46
|
+
getAgentDir: () => "/tmp/pi-pretty-test",
|
|
47
|
+
},
|
|
48
|
+
TextComponent: MockText,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return tools.get("bash");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("bash renderCall expansion", () => {
|
|
55
|
+
it("truncates long commands when collapsed", () => {
|
|
56
|
+
const bashTool = loadBashTool();
|
|
57
|
+
const command = `printf '${"x".repeat(120)}'`;
|
|
58
|
+
|
|
59
|
+
const rendered = bashTool.renderCall({ command }, mockTheme, {
|
|
60
|
+
lastComponent: new MockText(),
|
|
61
|
+
isError: false,
|
|
62
|
+
state: {},
|
|
63
|
+
expanded: false,
|
|
64
|
+
invalidate: () => {},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(rendered.getText()).toContain("bash");
|
|
68
|
+
expect(rendered.getText()).toContain("…");
|
|
69
|
+
expect(rendered.getText()).not.toContain(command);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("shows the full command when expanded", () => {
|
|
73
|
+
const bashTool = loadBashTool();
|
|
74
|
+
const command = `printf '${"x".repeat(120)}'`;
|
|
75
|
+
|
|
76
|
+
const rendered = bashTool.renderCall({ command }, mockTheme, {
|
|
77
|
+
lastComponent: new MockText(),
|
|
78
|
+
isError: false,
|
|
79
|
+
state: {},
|
|
80
|
+
expanded: true,
|
|
81
|
+
invalidate: () => {},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(rendered.getText()).toContain(command);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("preserves timeout text in both collapsed and expanded states", () => {
|
|
88
|
+
const bashTool = loadBashTool();
|
|
89
|
+
const command = `printf '${"x".repeat(120)}'`;
|
|
90
|
+
|
|
91
|
+
const collapsed = bashTool.renderCall({ command, timeout: 5 }, mockTheme, {
|
|
92
|
+
lastComponent: new MockText(),
|
|
93
|
+
isError: false,
|
|
94
|
+
state: {},
|
|
95
|
+
expanded: false,
|
|
96
|
+
invalidate: () => {},
|
|
97
|
+
});
|
|
98
|
+
const expanded = bashTool.renderCall({ command, timeout: 5 }, mockTheme, {
|
|
99
|
+
lastComponent: new MockText(),
|
|
100
|
+
isError: false,
|
|
101
|
+
state: {},
|
|
102
|
+
expanded: true,
|
|
103
|
+
invalidate: () => {},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(collapsed.getText()).toContain("5s timeout");
|
|
107
|
+
expect(expanded.getText()).toContain("5s timeout");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -8,9 +8,17 @@
|
|
|
8
8
|
* - Graceful degradation (FFF fails → SDK fallback)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
11
14
|
import { describe, it, expect, vi, beforeEach } 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,6 +309,7 @@ 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
|
|
|
@@ -201,12 +322,13 @@ describe("piPrettyExtension integration", () => {
|
|
|
201
322
|
on: vi.fn((e: string, h: Function) => events.set(e, h)),
|
|
202
323
|
};
|
|
203
324
|
|
|
204
|
-
for (const fn of [findExec, grepExec, readExec, bashExec, lsExec]) fn.mockReset();
|
|
325
|
+
for (const fn of [findExec, grepExec, readExec, bashExec, lsExec, multiGrepRgExec]) fn.mockReset();
|
|
205
326
|
findExec.mockResolvedValue({ content: [{ type: "text", text: "src/index.ts\nsrc/main.ts" }] });
|
|
206
327
|
grepExec.mockResolvedValue({ content: [{ type: "text", text: "src/index.ts:10:const x = 1;" }] });
|
|
207
328
|
readExec.mockResolvedValue({ content: [{ type: "text", text: "content" }] });
|
|
208
329
|
bashExec.mockResolvedValue({ content: [{ type: "text", text: "output" }] });
|
|
209
330
|
lsExec.mockResolvedValue({ content: [{ type: "text", text: "f1\nf2" }] });
|
|
331
|
+
multiGrepRgExec.mockResolvedValue({ text: "src/index.ts:10:const x = 1;", matchCount: 1, limitReached: false });
|
|
210
332
|
});
|
|
211
333
|
|
|
212
334
|
function load(withFFF = false, finderOverrides?: Record<string, any>) {
|
|
@@ -236,9 +358,9 @@ describe("piPrettyExtension integration", () => {
|
|
|
236
358
|
expect(tools.has("multi_grep")).toBe(true);
|
|
237
359
|
});
|
|
238
360
|
|
|
239
|
-
it("
|
|
361
|
+
it("registers multi_grep when grep SDK available", () => {
|
|
240
362
|
load(false);
|
|
241
|
-
expect(tools.has("multi_grep")).toBe(
|
|
363
|
+
expect(tools.has("multi_grep")).toBe(true);
|
|
242
364
|
});
|
|
243
365
|
|
|
244
366
|
it("registers session_start + session_shutdown", () => {
|
|
@@ -285,6 +407,32 @@ describe("piPrettyExtension integration", () => {
|
|
|
285
407
|
const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
|
|
286
408
|
expect(r.details.matchCount).toBe(3);
|
|
287
409
|
});
|
|
410
|
+
|
|
411
|
+
it("normalizes CRLF in SDK text results", async () => {
|
|
412
|
+
grepExec.mockResolvedValue({
|
|
413
|
+
content: [{ type: "text", text: "a.ts:1:TODO\r\na.ts:5:TODO\rb.ts:10:TODO" }],
|
|
414
|
+
});
|
|
415
|
+
load(false);
|
|
416
|
+
const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
|
|
417
|
+
expect(r.content[0].text).toBe("a.ts:1:TODO\na.ts:5:TODO\nb.ts:10:TODO");
|
|
418
|
+
expect(r.details.text).toBe("a.ts:1:TODO\na.ts:5:TODO\nb.ts:10:TODO");
|
|
419
|
+
expect(r.details.matchCount).toBe(3);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// ---- read -----------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
describe("read", () => {
|
|
426
|
+
it("normalizes CRLF in read details content", async () => {
|
|
427
|
+
readExec.mockResolvedValue({
|
|
428
|
+
content: [{ type: "text", text: "line1\r\nline2\rline3" }],
|
|
429
|
+
});
|
|
430
|
+
load(false);
|
|
431
|
+
const r = await tools.get("read")!.execute("t1", { path: "file.txt" }, null, null, {});
|
|
432
|
+
expect(r.details._type).toBe("readFile");
|
|
433
|
+
expect(r.details.content).toBe("line1\nline2\nline3");
|
|
434
|
+
expect(r.details.lineCount).toBe(3);
|
|
435
|
+
});
|
|
288
436
|
});
|
|
289
437
|
|
|
290
438
|
// ---- find: FFF path ------------------------------------------------
|
|
@@ -356,6 +504,22 @@ describe("piPrettyExtension integration", () => {
|
|
|
356
504
|
expect(r.content[0].text).toContain("src/index.ts:42:const x = 1;");
|
|
357
505
|
});
|
|
358
506
|
|
|
507
|
+
it("sanitizes CRLF in FFF grep output without extra records", async () => {
|
|
508
|
+
await loadWithFFF({
|
|
509
|
+
grep: vi.fn().mockReturnValue({
|
|
510
|
+
ok: true,
|
|
511
|
+
value: {
|
|
512
|
+
items: [{ relativePath: "src/index.ts", lineNumber: 42, lineContent: "const x = 1;\r\nconst y = 2;" }],
|
|
513
|
+
totalMatched: 1,
|
|
514
|
+
nextCursor: null,
|
|
515
|
+
},
|
|
516
|
+
}),
|
|
517
|
+
});
|
|
518
|
+
const r = await tools.get("grep")!.execute("t1", { pattern: "const" }, null, null, {});
|
|
519
|
+
expect(r.content[0].text).toBe("src/index.ts:42:const x = 1;\\nconst y = 2;");
|
|
520
|
+
expect(r.details.text.split("\n")).toHaveLength(1);
|
|
521
|
+
});
|
|
522
|
+
|
|
359
523
|
it("literal=true → mode=plain", async () => {
|
|
360
524
|
const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
361
525
|
await loadWithFFF({ grep });
|
|
@@ -370,11 +534,20 @@ describe("piPrettyExtension integration", () => {
|
|
|
370
534
|
expect(grep).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ mode: "regex" }));
|
|
371
535
|
});
|
|
372
536
|
|
|
373
|
-
it("glob
|
|
537
|
+
it("glob constraints bypass FFF to avoid native Unicode path panic", async () => {
|
|
374
538
|
const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
375
539
|
await loadWithFFF({ grep });
|
|
376
540
|
await tools.get("grep")!.execute("t1", { pattern: "TODO", glob: "*.ts" }, null, null, {});
|
|
377
|
-
expect(grep).
|
|
541
|
+
expect(grep).not.toHaveBeenCalled();
|
|
542
|
+
expect(grepExec).toHaveBeenCalledOnce();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("path constraints bypass FFF to avoid native Unicode path panic", async () => {
|
|
546
|
+
const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
547
|
+
await loadWithFFF({ grep });
|
|
548
|
+
await tools.get("grep")!.execute("t1", { pattern: "TODO", path: "file_reviewapp/static/app.js" }, null, null, {});
|
|
549
|
+
expect(grep).not.toHaveBeenCalled();
|
|
550
|
+
expect(grepExec).toHaveBeenCalledOnce();
|
|
378
551
|
});
|
|
379
552
|
|
|
380
553
|
it("falls back to SDK on throw", async () => {
|
|
@@ -406,10 +579,11 @@ describe("piPrettyExtension integration", () => {
|
|
|
406
579
|
expect(r.content[0].text).toContain("patterns array must have at least 1 element");
|
|
407
580
|
});
|
|
408
581
|
|
|
409
|
-
it("
|
|
582
|
+
it("falls back to SDK when FFF not initialized (no session_start)", async () => {
|
|
410
583
|
load(true);
|
|
411
584
|
const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["foo"] }, null, null, null);
|
|
412
|
-
expect(
|
|
585
|
+
expect(grepExec).toHaveBeenCalledOnce();
|
|
586
|
+
expect(r.details._type).toBe("grepResult");
|
|
413
587
|
});
|
|
414
588
|
|
|
415
589
|
it("returns multiGrep results", async () => {
|
|
@@ -433,14 +607,96 @@ describe("piPrettyExtension integration", () => {
|
|
|
433
607
|
expect(r.content[0].text).toContain("compile failed");
|
|
434
608
|
});
|
|
435
609
|
|
|
436
|
-
it("passes
|
|
610
|
+
it("passes context to unconstrained FFF multiGrep", async () => {
|
|
437
611
|
const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
438
612
|
await loadWithFFF({ multiGrep });
|
|
439
|
-
await tools.get("multi_grep")!.execute("t1", { patterns: ["a", "b"],
|
|
613
|
+
await tools.get("multi_grep")!.execute("t1", { patterns: ["a", "b"], context: 2 }, null, null, null);
|
|
440
614
|
expect(multiGrep).toHaveBeenCalledWith(expect.objectContaining({
|
|
441
|
-
patterns: ["a", "b"],
|
|
615
|
+
patterns: ["a", "b"], beforeContext: 2, afterContext: 2,
|
|
616
|
+
}));
|
|
617
|
+
expect(multiGrep.mock.calls[0][0]).not.toHaveProperty("constraints");
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("glob constraints bypass FFF multiGrep and use ripgrep fallback", async () => {
|
|
621
|
+
const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
622
|
+
await loadWithFFF({ multiGrep });
|
|
623
|
+
await tools.get("multi_grep")!.execute("t1", { patterns: ["a", "b"], constraints: "*.ts", context: 2 }, null, null, {});
|
|
624
|
+
expect(multiGrep).not.toHaveBeenCalled();
|
|
625
|
+
expect(grepExec).not.toHaveBeenCalled();
|
|
626
|
+
expect(multiGrepRgExec).toHaveBeenCalledWith(expect.objectContaining({
|
|
627
|
+
patterns: ["a", "b"], constraints: "*.ts", context: 2, ignoreCase: true,
|
|
628
|
+
}));
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("path and constraints together bypass FFF multiGrep", async () => {
|
|
632
|
+
const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
633
|
+
await loadWithFFF({ multiGrep });
|
|
634
|
+
await tools.get("multi_grep")!.execute(
|
|
635
|
+
"t1",
|
|
636
|
+
{ patterns: ["a", "b"], path: "src", constraints: "*.ts" },
|
|
637
|
+
null,
|
|
638
|
+
null,
|
|
639
|
+
{},
|
|
640
|
+
);
|
|
641
|
+
expect(multiGrep).not.toHaveBeenCalled();
|
|
642
|
+
expect(grepExec).not.toHaveBeenCalled();
|
|
643
|
+
expect(multiGrepRgExec).toHaveBeenCalledWith(expect.objectContaining({
|
|
644
|
+
patterns: ["a", "b"], path: "src", constraints: "*.ts",
|
|
442
645
|
}));
|
|
443
646
|
});
|
|
647
|
+
|
|
648
|
+
it("falls back to SDK when path is provided", async () => {
|
|
649
|
+
const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
650
|
+
await loadWithFFF({ multiGrep });
|
|
651
|
+
await tools.get("multi_grep")!.execute("t1", { patterns: ["foo", "bar"], path: "src" }, null, null, {});
|
|
652
|
+
expect(multiGrep).not.toHaveBeenCalled();
|
|
653
|
+
expect(grepExec).toHaveBeenCalledWith(
|
|
654
|
+
"t1",
|
|
655
|
+
expect.objectContaining({ pattern: "foo|bar", path: "src", ignoreCase: true }),
|
|
656
|
+
null,
|
|
657
|
+
null,
|
|
658
|
+
{},
|
|
659
|
+
);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("uses path-backed ripgrep fallback for simple path constraints", async () => {
|
|
663
|
+
const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
664
|
+
await loadWithFFF({ multiGrep });
|
|
665
|
+
await tools.get("multi_grep")!.execute("t1", { patterns: ["foo", "bar"], constraints: "src" }, null, null, {});
|
|
666
|
+
expect(multiGrep).not.toHaveBeenCalled();
|
|
667
|
+
expect(grepExec).not.toHaveBeenCalled();
|
|
668
|
+
expect(multiGrepRgExec).toHaveBeenCalledWith(expect.objectContaining({
|
|
669
|
+
patterns: ["foo", "bar"], path: "src", constraints: undefined,
|
|
670
|
+
}));
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("preserves complex constraints in ripgrep fallback", async () => {
|
|
674
|
+
await loadWithFFF();
|
|
675
|
+
const result = await tools.get("multi_grep")!.execute(
|
|
676
|
+
"t1",
|
|
677
|
+
{ patterns: ["foo", "bar"], path: "src", constraints: "*.ts !test/" },
|
|
678
|
+
null,
|
|
679
|
+
null,
|
|
680
|
+
{},
|
|
681
|
+
);
|
|
682
|
+
expect(grepExec).not.toHaveBeenCalled();
|
|
683
|
+
expect(multiGrepRgExec).toHaveBeenCalledWith(expect.objectContaining({
|
|
684
|
+
patterns: ["foo", "bar"], path: "src", constraints: "*.ts !test/",
|
|
685
|
+
}));
|
|
686
|
+
expect(result.content[0].text).not.toContain("ignored unsupported constraints");
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("uses case-sensitive SDK fallback when any pattern contains uppercase", async () => {
|
|
690
|
+
await loadWithFFF();
|
|
691
|
+
await tools.get("multi_grep")!.execute("t1", { patterns: ["foo", "Bar"], path: "src" }, null, null, {});
|
|
692
|
+
expect(grepExec).toHaveBeenCalledWith(
|
|
693
|
+
"t1",
|
|
694
|
+
expect.objectContaining({ pattern: "foo|Bar", ignoreCase: false }),
|
|
695
|
+
null,
|
|
696
|
+
null,
|
|
697
|
+
{},
|
|
698
|
+
);
|
|
699
|
+
});
|
|
444
700
|
});
|
|
445
701
|
|
|
446
702
|
// ---- session lifecycle ---------------------------------------------
|