@heyhuynhgiabuu/pi-pretty 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1",
3
+ "version": "0.4.3",
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,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);
@@ -100,13 +134,11 @@ function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
100
134
  }
101
135
 
102
136
  /** Read themed tool backgrounds and update BG_BASE / BG_ERROR + RST.
103
- * Call once when theme is first available. Idempotent. */
104
- let _bgBaseResolved = false;
137
+ * Recompute on each render so runtime theme changes are respected. */
105
138
  function resolveBaseBackground(theme: BgTheme | null | undefined): void {
106
- if (_bgBaseResolved || !theme?.getBgAnsi) return;
107
- _bgBaseResolved = true;
139
+ if (!theme?.getBgAnsi) return;
108
140
 
109
- BG_BASE = getThemeBgAnsi(theme, "toolSuccessBg") ?? BG_DEFAULT;
141
+ BG_BASE = getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
110
142
  BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
111
143
  RST = `\x1b[0m${BG_BASE}`;
112
144
  }
@@ -146,6 +178,10 @@ function strip(s: string): string {
146
178
  return s.replace(ANSI_RE, "");
147
179
  }
148
180
 
181
+ function normalizeLineEndings(text: string): string {
182
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
183
+ }
184
+
149
185
  function preserveToolBackground(ansi: string, bg: string): string {
150
186
  return ansi.replace(ANSI_CAPTURE_RE, (seq, params: string) => {
151
187
  const codes = params.split(";");
@@ -402,47 +438,6 @@ export const __imageInternals = {
402
438
  },
403
439
  };
404
440
 
405
- /**
406
- * Render base64 image inline using iTerm2 inline image protocol.
407
- * Protocol: ESC ] 1337 ; File=[args] : base64data BEL
408
- */
409
- function renderIterm2Image(base64Data: string, opts: { width?: string; name?: string } = {}): string {
410
- const args: string[] = ["inline=1", "preserveAspectRatio=1"];
411
- if (opts.width) args.push(`width=${opts.width}`);
412
- if (opts.name) args.push(`name=${Buffer.from(opts.name).toString("base64")}`);
413
- const byteSize = Math.ceil((base64Data.length * 3) / 4);
414
- args.push(`size=${byteSize}`);
415
- const seq = `\x1b]1337;File=${args.join(";")}:${base64Data}\x07`;
416
- return tmuxWrap(seq);
417
- }
418
-
419
- /**
420
- * Render base64 image inline using Kitty graphics protocol.
421
- * Protocol: ESC _G <key>=<value>,...; <base64data> ESC \
422
- * Chunked in 4096-byte pieces as required by protocol.
423
- * Supported by: Kitty, Ghostty
424
- */
425
- function renderKittyImage(base64Data: string, opts: { cols?: number } = {}): string {
426
- const chunks: string[] = [];
427
- const CHUNK_SIZE = 4096;
428
-
429
- for (let i = 0; i < base64Data.length; i += CHUNK_SIZE) {
430
- const chunk = base64Data.slice(i, i + CHUNK_SIZE);
431
- const isFirst = i === 0;
432
- const isLast = i + CHUNK_SIZE >= base64Data.length;
433
- const more = isLast ? 0 : 1;
434
-
435
- if (isFirst) {
436
- const colPart = opts.cols ? `,c=${opts.cols}` : "";
437
- chunks.push(tmuxWrap(`\x1b_Ga=T,f=100,t=d,m=${more}${colPart};${chunk}\x1b\\`));
438
- } else {
439
- chunks.push(tmuxWrap(`\x1b_Gm=${more};${chunk}\x1b\\`));
440
- }
441
- }
442
-
443
- return chunks.join("");
444
- }
445
-
446
441
  /**
447
442
  * Get human-readable file size
448
443
  */
@@ -614,7 +609,8 @@ async function renderFileContent(
614
609
  offset = 1,
615
610
  maxLines = MAX_PREVIEW_LINES,
616
611
  ): Promise<string> {
617
- const lines = content.split("\n");
612
+ const normalizedContent = normalizeLineEndings(content);
613
+ const lines = normalizedContent.split("\n");
618
614
  const total = lines.length;
619
615
  const show = lines.slice(0, maxLines);
620
616
  const lg = lang(filePath);
@@ -754,7 +750,7 @@ function renderFindResults(text: string): string {
754
750
 
755
751
  /** Render grep results with highlighted matches and line numbers. */
756
752
  async function renderGrepResults(text: string, pattern: string): Promise<string> {
757
- const lines = text.split("\n");
753
+ const lines = normalizeLineEndings(text).split("\n");
758
754
  if (!lines.length || (lines.length === 1 && !lines[0].trim())) return `${FG_DIM}(no matches)${RST}`;
759
755
 
760
756
  const out: string[] = [];
@@ -875,6 +871,7 @@ type FindParams = FindToolInput;
875
871
  type GrepParams = GrepToolInput;
876
872
  type MultiGrepParams = {
877
873
  patterns: string[];
874
+ path?: string;
878
875
  constraints?: string;
879
876
  context?: number;
880
877
  limit?: number;
@@ -934,6 +931,34 @@ function getErrorMessage(error: unknown): string {
934
931
  return error instanceof Error ? error.message : String(error);
935
932
  }
936
933
 
934
+ function trimToUndefined(value: string | undefined): string | undefined {
935
+ const trimmed = value?.trim();
936
+ return trimmed ? trimmed : undefined;
937
+ }
938
+
939
+ function escapeRegexLiteral(text: string): string {
940
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
941
+ }
942
+
943
+ function buildLiteralAlternationPattern(patterns: string[]): string {
944
+ return patterns
945
+ .map(escapeRegexLiteral)
946
+ .sort((a, b) => b.length - a.length)
947
+ .join("|");
948
+ }
949
+
950
+ function shouldIgnoreCaseForPatterns(patterns: string[]): boolean {
951
+ return patterns.every((pattern) => pattern.toLowerCase() === pattern);
952
+ }
953
+
954
+ function getConstraintBackedPath(constraints: string | undefined): string | undefined {
955
+ const trimmed = trimToUndefined(constraints);
956
+ if (!trimmed || /\s/.test(trimmed) || trimmed.includes("!") || trimmed.endsWith("/") || /[*?[{]/.test(trimmed)) {
957
+ return undefined;
958
+ }
959
+ return trimmed;
960
+ }
961
+
937
962
  const _cursorStore = new CursorStore();
938
963
  let _fffModule: OptionalFffModule | null = null;
939
964
  let _fffFinder: FffBackedFinder | null = null;
@@ -985,6 +1010,7 @@ export interface PiPrettyDeps {
985
1010
  sdk: PiPrettySdk;
986
1011
  TextComponent: TextComponentCtor;
987
1012
  fffModule?: OptionalFffModule;
1013
+ multiGrepRipgrepFallback?: MultiGrepRipgrepFallback;
988
1014
  }
989
1015
 
990
1016
  export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps): void {
@@ -1028,12 +1054,22 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1028
1054
  const cwd = process.cwd();
1029
1055
  const home = process.env.HOME ?? "";
1030
1056
  const sp = (p: string) => shortPath(cwd, home, p);
1057
+ const multiGrepRipgrepFallback = deps?.multiGrepRipgrepFallback ?? runMultiGrepRipgrepFallback;
1031
1058
 
1032
1059
  // ===================================================================
1033
1060
  // FFF initialization (optional — graceful fallback to SDK)
1034
1061
  // ===================================================================
1035
1062
 
1036
1063
  const getAgentDir = sdk.getAgentDir;
1064
+ setPrettyTheme(
1065
+ (() => {
1066
+ try {
1067
+ return getAgentDir?.() ?? getDefaultAgentDir();
1068
+ } catch {
1069
+ return getDefaultAgentDir();
1070
+ }
1071
+ })(),
1072
+ );
1037
1073
  if (!deps) {
1038
1074
  // Only try require() in production — tests inject fffModule via deps
1039
1075
  try {
@@ -1077,8 +1113,9 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1077
1113
  if (_fffPartialIndex) {
1078
1114
  ctx.ui?.notify?.("FFF: scan timed out — using partial index. Run /fff-rescan when ready.", "warning");
1079
1115
  } else {
1080
- ctx.ui?.setStatus?.("fff", "FFF indexed");
1081
- setTimeout(() => ctx.ui?.setStatus?.("fff", undefined), 3000);
1116
+ const ui = ctx.ui;
1117
+ ui?.setStatus?.("fff", "FFF indexed");
1118
+ setTimeout(() => ui?.setStatus?.("fff", undefined), 3000);
1082
1119
  }
1083
1120
  } catch (error: unknown) {
1084
1121
  ctx.ui?.notify?.(`FFF init failed: ${getErrorMessage(error)}`, "error");
@@ -1124,11 +1161,12 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1124
1161
 
1125
1162
  const textContent = getTextContent(result);
1126
1163
  if (textContent && fp) {
1127
- const lineCount = textContent.split("\n").length;
1164
+ const normalizedContent = normalizeLineEndings(textContent);
1165
+ const lineCount = normalizedContent.split("\n").length;
1128
1166
  setResultDetails(result, {
1129
1167
  _type: "readFile",
1130
1168
  filePath: fp,
1131
- content: textContent,
1169
+ content: normalizedContent,
1132
1170
  offset,
1133
1171
  lineCount,
1134
1172
  });
@@ -1162,45 +1200,15 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1162
1200
 
1163
1201
  const d = result.details as RenderDetails | undefined;
1164
1202
 
1165
- // Image rendering
1203
+ // Image reads keep the original image content so Pi's native TUI renderer
1204
+ // can display it exactly once. pi-pretty only renders metadata here;
1205
+ // rendering another inline image caused duplicate previews.
1166
1206
  if (d?._type === "readImage") {
1167
- const tw = termW();
1168
- const out: string[] = [];
1169
- const fname = basename(d.filePath);
1170
1207
  const byteSize = Math.ceil(((d.data as string).length * 3) / 4);
1171
1208
  const sizeStr = humanSize(byteSize);
1172
1209
  const mimeStr = d.mimeType ?? "image";
1173
1210
 
1174
- out.push(` ${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`);
1175
- out.push(rule(tw));
1176
-
1177
- const protocol = detectImageProtocol();
1178
- const passthroughWarning = getTmuxPassthroughWarning(protocol);
1179
- if (passthroughWarning) {
1180
- out.push(` ${FG_YELLOW}${passthroughWarning}${RST}`);
1181
- } else if (protocol === "kitty") {
1182
- if (d.mimeType && d.mimeType !== "image/png") {
1183
- out.push(
1184
- ` ${FG_YELLOW}Kitty/Ghostty inline preview currently supports PNG payloads (got ${d.mimeType})${RST}`,
1185
- );
1186
- } else {
1187
- const imgCols = Math.min(tw - 4, 80);
1188
- out.push(renderKittyImage(d.data, { cols: imgCols }));
1189
- }
1190
- } else if (protocol === "iterm2") {
1191
- const imgWidth = Math.min(tw - 4, 80);
1192
- out.push(
1193
- renderIterm2Image(d.data, {
1194
- width: `${imgWidth}`,
1195
- name: fname,
1196
- }),
1197
- );
1198
- } else {
1199
- out.push(` ${FG_DIM}(Inline image preview requires Ghostty, iTerm2, WezTerm, or Kitty)${RST}`);
1200
- }
1201
-
1202
- out.push(rule(tw));
1203
- text.setText(fillToolBackground(out.join("\n")));
1211
+ text.setText(fillToolBackground(` ${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`));
1204
1212
  return text;
1205
1213
  }
1206
1214
 
@@ -1277,9 +1285,10 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1277
1285
  const cmd = args.command ?? "";
1278
1286
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1279
1287
  const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
1288
+ const displayCmd = ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
1280
1289
  text.setText(
1281
1290
  fillToolBackground(
1282
- `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", cmd.length > 80 ? `${cmd.slice(0, 77)}…` : cmd)}${timeout}`,
1291
+ `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`,
1283
1292
  ),
1284
1293
  );
1285
1294
  return text;
@@ -1559,7 +1568,12 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1559
1568
 
1560
1569
  // SDK fallback
1561
1570
  const result = await origGrep.execute(tid, params, sig, upd as never, ctx);
1562
- const textContent = getTextContent(result);
1571
+ const textContent = normalizeLineEndings(getTextContent(result));
1572
+ if (result.content) {
1573
+ for (const content of result.content) {
1574
+ if (isTextContent(content)) content.text = normalizeLineEndings(content.text || "");
1575
+ }
1576
+ }
1563
1577
  const matchCount = textContent ? countRipgrepMatches(textContent) : 0;
1564
1578
 
1565
1579
  setResultDetails<GrepResultDetails>(result, {
@@ -1629,25 +1643,30 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1629
1643
  }
1630
1644
 
1631
1645
  // ===================================================================
1632
- // multi_grep — FFF-only OR-logic multi-pattern search
1646
+ // multi_grep — OR-logic multi-pattern search (FFF when available,
1647
+ // SDK grep fallback otherwise)
1633
1648
  // ===================================================================
1634
1649
 
1635
- if (_fffModule) {
1650
+ if (_fffModule || createGrepTool) {
1651
+ const multiGrepFallback = createGrepTool ? createGrepTool(cwd) : null;
1652
+
1636
1653
  pi.registerTool({
1637
1654
  name: "multi_grep",
1638
- label: "multi_grep (fff)",
1655
+ label: "multi_grep",
1639
1656
  description: [
1640
1657
  "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.",
1658
+ "Uses SIMD-accelerated Aho-Corasick multi-pattern matching when FFF is available.",
1659
+ "Falls back to ripgrep while preserving literal OR semantics and file constraints when needed.",
1642
1660
  "Patterns are literal text — never escape special characters.",
1643
- "Use the constraints parameter for file filtering ('*.rs', 'src/', '!test/').",
1661
+ "Use path to scope a directory/file and constraints for file filtering ('*.rs', 'src/', '!test/').",
1644
1662
  ].join(" "),
1645
- promptSnippet: "Multi-pattern OR search across file contents (FFF: SIMD-accelerated, frecency-ranked)",
1663
+ promptSnippet: "Multi-pattern OR search across file contents (FFF-accelerated with grep fallback)",
1646
1664
  promptGuidelines: [
1647
1665
  "Use multi_grep when you need to find multiple identifiers at once (OR logic).",
1648
1666
  "Include all naming conventions: snake_case, PascalCase, camelCase variants.",
1649
1667
  "Patterns are literal text. Never escape special characters.",
1650
- "Use the constraints parameter for file type/path filtering, not inside patterns.",
1668
+ "Use path to scope a directory or file when you need fresh on-disk results.",
1669
+ "Use the constraints parameter for additional file filtering, not inside patterns.",
1651
1670
  ],
1652
1671
 
1653
1672
  parameters: {
@@ -1658,6 +1677,10 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1658
1677
  items: { type: "string" },
1659
1678
  description: "Patterns to search for (OR logic — matches lines containing ANY pattern).",
1660
1679
  },
1680
+ path: {
1681
+ type: "string",
1682
+ description: "Directory or file path to search (default: current directory)",
1683
+ },
1661
1684
  constraints: {
1662
1685
  type: "string",
1663
1686
  description: "File constraints, e.g. '*.{ts,tsx} !test/' to filter files.",
@@ -1675,11 +1698,11 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1675
1698
  },
1676
1699
 
1677
1700
  async execute(
1678
- _tid: string,
1701
+ tid: string,
1679
1702
  params: MultiGrepParams,
1680
1703
  sig: AbortSignal | undefined,
1681
- _upd: unknown,
1682
- _ctx: ExtensionContext,
1704
+ upd: unknown,
1705
+ ctx: ExtensionContext,
1683
1706
  ) {
1684
1707
  if (sig?.aborted) return makeTextResult("Aborted", {});
1685
1708
 
@@ -1687,42 +1710,112 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1687
1710
  return makeTextResult("Error: patterns array must have at least 1 element", { error: "empty patterns" });
1688
1711
  }
1689
1712
 
1690
- if (!_fffFinder || _fffFinder.isDestroyed) {
1691
- return makeTextResult("FFF not initialized. Wait for session start or run /fff-rescan.", {});
1692
- }
1713
+ const effectiveLimit = Math.max(1, params.limit ?? 100);
1714
+ const pattern = buildLiteralAlternationPattern(params.patterns);
1715
+ const requestedPath = trimToUndefined(params.path);
1716
+ const requestedConstraints = trimToUndefined(params.constraints);
1717
+ const effectivePath = requestedPath ?? getConstraintBackedPath(requestedConstraints);
1718
+ const hasNativeConstraints = Boolean(requestedPath || requestedConstraints);
1693
1719
 
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
- });
1720
+ if (_fffFinder && !_fffFinder.isDestroyed && !hasNativeConstraints) {
1721
+ try {
1722
+ const grepResult = _fffFinder.multiGrep({
1723
+ patterns: params.patterns,
1724
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
1725
+ smartCase: true,
1726
+ cursor: null,
1727
+ beforeContext: params.context ?? 0,
1728
+ afterContext: params.context ?? 0,
1729
+ });
1730
+
1731
+ if (!grepResult.ok) {
1732
+ return makeTextResult(`multi_grep error: ${grepResult.error}`, { error: grepResult.error });
1733
+ }
1734
+
1735
+ const grep: GrepResult = grepResult.value;
1736
+ const notices: string[] = [];
1737
+ if (_fffPartialIndex) notices.push("Warning: partial file index");
1738
+ if (grep.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1739
+ if (grep.nextCursor) {
1740
+ const cursorId = _cursorStore.store(grep.nextCursor);
1741
+ notices.push(`More results: cursor="${cursorId}"`);
1742
+ }
1706
1743
 
1707
- if (!grepResult.ok) {
1708
- return makeTextResult(`multi_grep error: ${grepResult.error}`, { error: grepResult.error });
1744
+ const textContent = appendNotices(fffFormatGrepText(grep.items, effectiveLimit), notices);
1745
+ return makeTextResult<GrepResultDetails>(textContent, {
1746
+ _type: "grepResult",
1747
+ text: textContent,
1748
+ pattern,
1749
+ matchCount: Math.min(grep.items.length, effectiveLimit),
1750
+ });
1751
+ } catch {
1752
+ /* fall through to SDK */
1709
1753
  }
1754
+ }
1710
1755
 
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}"`);
1756
+ if (requestedConstraints || !multiGrepFallback) {
1757
+ try {
1758
+ const pathBackedConstraint = Boolean(
1759
+ requestedConstraints && !requestedPath && requestedConstraints === effectivePath,
1760
+ );
1761
+ const constraintsForRipgrep = pathBackedConstraint ? undefined : requestedConstraints;
1762
+ const notices: string[] = [];
1763
+
1764
+ if (!_fffFinder || _fffFinder.isDestroyed) notices.push("FFF unavailable, used ripgrep fallback");
1765
+ else if (hasNativeConstraints) notices.push("Used ripgrep fallback for constrained search");
1766
+ else notices.push("Used ripgrep fallback");
1767
+
1768
+ const rgResult = await multiGrepRipgrepFallback({
1769
+ cwd,
1770
+ patterns: params.patterns,
1771
+ path: effectivePath,
1772
+ constraints: constraintsForRipgrep,
1773
+ ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
1774
+ context: params.context,
1775
+ limit: effectiveLimit,
1776
+ signal: sig,
1777
+ });
1778
+ const textContent = normalizeLineEndings(rgResult.text) || "No matches found";
1779
+ if (rgResult.limitReached) notices.push(`${effectiveLimit} limit reached`);
1780
+ const finalText = appendNotices(textContent, notices);
1781
+
1782
+ return makeTextResult<GrepResultDetails>(finalText, {
1783
+ _type: "grepResult",
1784
+ text: finalText,
1785
+ pattern,
1786
+ matchCount: rgResult.matchCount,
1787
+ });
1788
+ } catch (error: unknown) {
1789
+ const message = getErrorMessage(error);
1790
+ return makeTextResult(`multi_grep error: ${message}`, { error: message });
1718
1791
  }
1792
+ }
1719
1793
 
1720
- const textContent = appendNotices(fffFormatGrepText(grep.items, effectiveLimit), notices);
1721
- return makeTextResult<GrepResultDetails>(textContent, {
1794
+ try {
1795
+ const notices: string[] = [];
1796
+ if (!_fffFinder || _fffFinder.isDestroyed) notices.push("FFF unavailable, used SDK grep fallback");
1797
+
1798
+ const result = await multiGrepFallback.execute(
1799
+ tid,
1800
+ {
1801
+ pattern,
1802
+ path: effectivePath,
1803
+ ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
1804
+ context: params.context,
1805
+ limit: params.limit,
1806
+ },
1807
+ sig,
1808
+ upd as never,
1809
+ ctx,
1810
+ );
1811
+ const textContent = normalizeLineEndings(getTextContent(result)) || "No matches found";
1812
+ const finalText = appendNotices(textContent, notices);
1813
+
1814
+ return makeTextResult<GrepResultDetails>(finalText, {
1722
1815
  _type: "grepResult",
1723
- text: textContent,
1724
- pattern: params.patterns.join(" | "),
1725
- matchCount: Math.min(grep.items.length, effectiveLimit),
1816
+ text: finalText,
1817
+ pattern,
1818
+ matchCount: textContent ? countRipgrepMatches(textContent) : 0,
1726
1819
  });
1727
1820
  } catch (error: unknown) {
1728
1821
  const message = getErrorMessage(error);
@@ -1733,14 +1826,16 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1733
1826
  renderCall(args: MultiGrepParams, theme: ThemeLike, ctx: RenderContextLike) {
1734
1827
  resolveBaseBackground(theme);
1735
1828
  const patterns = args.patterns ?? [];
1829
+ const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1736
1830
  const constraints = args.constraints;
1737
1831
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1738
1832
  let content =
1739
1833
  theme.fg("toolTitle", theme.bold("multi_grep")) +
1740
1834
  " " +
1741
1835
  theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
1836
+ content += path;
1742
1837
  if (constraints) content += theme.fg("muted", ` (${constraints})`);
1743
- text.setText(content);
1838
+ text.setText(fillToolBackground(content));
1744
1839
  return text;
1745
1840
  },
1746
1841