@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 CHANGED
@@ -135,7 +135,7 @@ Use them when:
135
135
 
136
136
  Optional environment variables:
137
137
 
138
- - `PRETTY_THEME` (default: `github-dark`)
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.0",
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
@@ -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
- const content = match.lineContent.length > 500 ? `${match.lineContent.slice(0, 500)}...` : match.lineContent;
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 THEME: BundledTheme = (process.env.PRETTY_THEME as BundledTheme | undefined) ?? "github-dark";
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 lines = content.split("\n");
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 lineCount = textContent.split("\n").length;
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: textContent,
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", cmd.length > 80 ? `${cmd.slice(0, 77)}…` : cmd)}${timeout}`,
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
- if (_fffFinder && !_fffFinder.isDestroyed) {
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
- let query = params.pattern;
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 — FFF-only OR-logic multi-pattern search
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 (fff)",
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. Faster than regex alternation.",
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 the constraints parameter for file filtering ('*.rs', 'src/', '!test/').",
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: SIMD-accelerated, frecency-ranked)",
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 the constraints parameter for file type/path filtering, not inside patterns.",
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
- _tid: string,
1773
+ tid: string,
1679
1774
  params: MultiGrepParams,
1680
1775
  sig: AbortSignal | undefined,
1681
- _upd: unknown,
1682
- _ctx: ExtensionContext,
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
- if (!_fffFinder || _fffFinder.isDestroyed) {
1691
- return makeTextResult("FFF not initialized. Wait for session start or run /fff-rescan.", {});
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
- try {
1695
- const effectiveLimit = Math.max(1, params.limit ?? 100);
1696
-
1697
- const grepResult = _fffFinder.multiGrep({
1698
- patterns: params.patterns,
1699
- constraints: params.constraints,
1700
- maxMatchesPerFile: Math.min(effectiveLimit, 50),
1701
- smartCase: true,
1702
- cursor: null,
1703
- beforeContext: params.context ?? 0,
1704
- afterContext: params.context ?? 0,
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
- if (!grepResult.ok) {
1708
- return makeTextResult(`multi_grep error: ${grepResult.error}`, { error: grepResult.error });
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
- const grep: GrepResult = grepResult.value;
1712
- const notices: string[] = [];
1713
- if (_fffPartialIndex) notices.push("Warning: partial file index");
1714
- if (grep.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1715
- if (grep.nextCursor) {
1716
- const cursorId = _cursorStore.store(grep.nextCursor);
1717
- notices.push(`More results: cursor="${cursorId}"`);
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
- const textContent = appendNotices(fffFormatGrepText(grep.items, effectiveLimit), notices);
1721
- return makeTextResult<GrepResultDetails>(textContent, {
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: textContent,
1724
- pattern: params.patterns.join(" | "),
1725
- matchCount: Math.min(grep.items.length, effectiveLimit),
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("NO multi_grep when FFF unavailable", () => {
361
+ it("registers multi_grep when grep SDK available", () => {
240
362
  load(false);
241
- expect(tools.has("multi_grep")).toBe(false);
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 prepended to query", async () => {
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).toHaveBeenCalledWith("*.ts TODO", expect.any(Object));
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("error when FFF not initialized (no session_start)", async () => {
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(r.content[0].text).toContain("FFF not initialized");
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 constraints and context", async () => {
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"], constraints: "*.ts", context: 2 }, null, null, null);
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"], constraints: "*.ts", beforeContext: 2, afterContext: 2,
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 ---------------------------------------------