@aaroncql/pim-agent 0.1.0 → 0.3.0

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.
Files changed (38) hide show
  1. package/README.md +92 -65
  2. package/package.json +6 -6
  3. package/src/extensions/apply-patch/coordinator.ts +67 -0
  4. package/src/extensions/apply-patch/executor.ts +566 -0
  5. package/src/extensions/apply-patch/index.ts +75 -0
  6. package/src/extensions/apply-patch/matcher.ts +66 -0
  7. package/src/extensions/apply-patch/model.ts +34 -0
  8. package/src/extensions/apply-patch/parser.ts +381 -0
  9. package/src/extensions/apply-patch/render.ts +261 -0
  10. package/src/extensions/apply-patch/schema.ts +43 -0
  11. package/src/extensions/apply-patch/types.ts +30 -0
  12. package/src/extensions/bash/index.ts +3 -3
  13. package/src/extensions/edit/index.ts +2 -1
  14. package/src/extensions/file-picker/FilePickerSuggestionEngine.ts +14 -0
  15. package/src/extensions/file-picker/InProcessFilePickerSuggestionEngine.ts +52 -0
  16. package/src/extensions/file-picker/WorkerFilePickerSuggestionEngine.ts +268 -0
  17. package/src/extensions/file-picker/catalog.ts +38 -33
  18. package/src/extensions/file-picker/filePickerWorker.ts +72 -0
  19. package/src/extensions/file-picker/filePickerWorkerMessages.ts +39 -0
  20. package/src/extensions/file-picker/index.ts +138 -83
  21. package/src/extensions/file-picker/ranker.ts +180 -12
  22. package/src/extensions/glob/index.ts +3 -1
  23. package/src/extensions/glob/schema.ts +2 -1
  24. package/src/extensions/grep/grep.ts +45 -2
  25. package/src/extensions/grep/index.ts +3 -1
  26. package/src/extensions/grep/render.ts +18 -4
  27. package/src/extensions/grep/schema.ts +1 -1
  28. package/src/extensions/read/index.ts +36 -9
  29. package/src/extensions/read/render.ts +31 -3
  30. package/src/extensions/subagent/index.ts +4 -1
  31. package/src/extensions/todo/index.ts +4 -3
  32. package/src/extensions/web-search/index.ts +2 -1
  33. package/src/extensions/write/index.ts +2 -1
  34. package/src/shared/FileEnumerator.ts +492 -0
  35. package/src/shared/FileScanner.ts +15 -17
  36. package/src/shared/PatchSummary.ts +82 -0
  37. package/src/telegram/Renderer.ts +190 -4
  38. package/src/shared/GitignoreFilter.ts +0 -142
@@ -3,116 +3,171 @@ import type {
3
3
  ExtensionAPI,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import type { AutocompleteProvider } from "@earendil-works/pi-tui";
6
- import { type FileCandidate, loadRelative } from "./catalog";
7
- import { rank } from "./ranker";
6
+ import type { FilePickerSuggestionEngine } from "./FilePickerSuggestionEngine";
7
+ import { WorkerFilePickerSuggestionEngine } from "./WorkerFilePickerSuggestionEngine";
8
8
 
9
9
  const MAX_VISIBLE_ROWS = 50;
10
10
  const AT_PREFIX = /(?:^|\s)@(\S*)$/;
11
11
 
12
+ // Pi cancels autocomplete after Tab; for directories we want to keep
13
+ // drilling, so re-enter Tab on the next tick.
14
+ function keepDrilling(): void {
15
+ setTimeout(() => {
16
+ try {
17
+ process.stdin.emit("data", "\t");
18
+ } catch {}
19
+ }, 0);
20
+ }
21
+
22
+ function activeAtTokenFromMatch(
23
+ match: RegExpMatchArray,
24
+ cursorLine: number
25
+ ): ActiveAtToken {
26
+ const matchedText = match[0] ?? "";
27
+ const matchCol = match.index ?? 0;
28
+ const atCol = matchCol + (matchedText.startsWith("@") ? 0 : 1);
29
+ return { cursorLine, atCol };
30
+ }
31
+
32
+ function sameActiveAtToken(
33
+ a: ActiveAtToken | undefined,
34
+ b: ActiveAtToken
35
+ ): boolean {
36
+ return a?.cursorLine === b.cursorLine && a.atCol === b.atCol;
37
+ }
38
+
12
39
  export type FilePickerProviderFactoryOptions = {
13
- readonly loadRelativeCatalog: () => Promise<readonly FileCandidate[]>;
40
+ readonly engine: FilePickerSuggestionEngine;
41
+ };
42
+
43
+ type ActiveAtToken = {
44
+ readonly cursorLine: number;
45
+ readonly atCol: number;
14
46
  };
15
47
 
16
48
  export function createFilePickerProviderFactory(
17
49
  options: FilePickerProviderFactoryOptions
18
50
  ): AutocompleteProviderFactory {
19
- let cachedRelative: readonly FileCandidate[] | undefined;
20
- let relativeRefresh: Promise<void> | undefined;
21
-
22
51
  const refreshRelative = (): void => {
23
- relativeRefresh ??= options
24
- .loadRelativeCatalog()
25
- .then((catalog) => {
26
- cachedRelative = catalog;
27
- })
28
- .catch(() => {
29
- if (cachedRelative === undefined) {
30
- cachedRelative = [];
31
- }
32
- })
33
- .finally(() => {
34
- relativeRefresh = undefined;
35
- });
52
+ void options.engine.refreshRelative();
36
53
  };
37
54
 
38
- refreshRelative();
55
+ return (current: AutocompleteProvider): AutocompleteProvider => {
56
+ let activeAtToken: ActiveAtToken | undefined;
39
57
 
40
- return (current: AutocompleteProvider): AutocompleteProvider => ({
41
- async getSuggestions(lines, cursorLine, cursorCol, autocompleteOptions) {
42
- const line = lines[cursorLine] ?? "";
43
- const beforeCursor = line.slice(0, cursorCol);
58
+ return {
59
+ async getSuggestions(lines, cursorLine, cursorCol, autocompleteOptions) {
60
+ const line = lines[cursorLine] ?? "";
61
+ const beforeCursor = line.slice(0, cursorCol);
44
62
 
45
- const atMatch = beforeCursor.match(AT_PREFIX);
46
- if (!atMatch) {
47
- return current.getSuggestions(
48
- lines,
49
- cursorLine,
50
- cursorCol,
51
- autocompleteOptions
52
- );
53
- }
63
+ const atMatch = beforeCursor.match(AT_PREFIX);
64
+ if (!atMatch) {
65
+ activeAtToken = undefined;
66
+ return current.getSuggestions(
67
+ lines,
68
+ cursorLine,
69
+ cursorCol,
70
+ autocompleteOptions
71
+ );
72
+ }
73
+
74
+ const query = atMatch[1] ?? "";
75
+ const atToken = activeAtTokenFromMatch(atMatch, cursorLine);
76
+ if (!sameActiveAtToken(activeAtToken, atToken)) {
77
+ activeAtToken = atToken;
78
+ refreshRelative();
79
+ }
80
+
81
+ const items = await options.engine
82
+ .rank(query, {
83
+ limit: MAX_VISIBLE_ROWS,
84
+ signal: autocompleteOptions.signal,
85
+ })
86
+ .catch(() => undefined);
87
+ if (items === undefined) {
88
+ return current.getSuggestions(
89
+ lines,
90
+ cursorLine,
91
+ cursorCol,
92
+ autocompleteOptions
93
+ );
94
+ }
95
+ if (items.length === 0) {
96
+ return null;
97
+ }
98
+ return {
99
+ items: items.map((item) => ({
100
+ ...item,
101
+ value: `@${item.value}`,
102
+ })),
103
+ prefix: `@${query}`,
104
+ };
105
+ },
106
+
107
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
108
+ // Pi appends a trailing space after file completions; apply @ items
109
+ // ourselves so Tab inserts the bare path.
110
+ if (prefix.startsWith("@")) {
111
+ const line = lines[cursorLine] ?? "";
112
+ const beforePrefix = line.slice(0, cursorCol - prefix.length);
113
+ const afterCursor = line.slice(cursorCol);
114
+ const hasTrailingQuote = item.value.endsWith('"');
115
+ const adjustedAfterCursor =
116
+ prefix.startsWith('@"') &&
117
+ hasTrailingQuote &&
118
+ afterCursor.startsWith('"')
119
+ ? afterCursor.slice(1)
120
+ : afterCursor;
121
+
122
+ const newLines = [...lines];
123
+ newLines[cursorLine] =
124
+ `${beforePrefix}${item.value}${adjustedAfterCursor}`;
54
125
 
55
- const query = atMatch[1] ?? "";
56
- refreshRelative();
126
+ const isDirectory = item.label.endsWith("/");
127
+ const cursorOffset =
128
+ isDirectory && hasTrailingQuote
129
+ ? item.value.length - 1
130
+ : item.value.length;
57
131
 
58
- const items = await rank(query, {
59
- cachedRelative,
60
- limit: MAX_VISIBLE_ROWS,
61
- });
62
- if (items === undefined) {
63
- return current.getSuggestions(
132
+ if (isDirectory) {
133
+ keepDrilling();
134
+ }
135
+
136
+ return {
137
+ lines: newLines,
138
+ cursorLine,
139
+ cursorCol: beforePrefix.length + cursorOffset,
140
+ };
141
+ }
142
+
143
+ const result = current.applyCompletion(
64
144
  lines,
65
145
  cursorLine,
66
146
  cursorCol,
67
- autocompleteOptions
147
+ item,
148
+ prefix
68
149
  );
69
- }
70
- if (items.length === 0) {
71
- return null;
72
- }
73
- return {
74
- items: items.map((item) => ({
75
- ...item,
76
- value: `@${item.value}`,
77
- })),
78
- prefix: `@${query}`,
79
- };
80
- },
81
-
82
- applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
83
- const result = current.applyCompletion(
84
- lines,
85
- cursorLine,
86
- cursorCol,
87
- item,
88
- prefix
89
- );
90
- // Pi cancels autocomplete after Tab; for directories we want to keep
91
- // drilling, so re-enter Tab on the next tick.
92
- if (typeof item.value === "string" && item.value.endsWith("/")) {
93
- setTimeout(() => {
94
- try {
95
- process.stdin.emit("data", "\t");
96
- } catch {}
97
- }, 0);
98
- }
99
- return result;
100
- },
101
-
102
- shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
103
- return (
104
- current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
105
- true
106
- );
107
- },
108
- });
150
+ if (item.value.endsWith("/")) {
151
+ keepDrilling();
152
+ }
153
+ return result;
154
+ },
155
+
156
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
157
+ return (
158
+ current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
159
+ true
160
+ );
161
+ },
162
+ };
163
+ };
109
164
  }
110
165
 
111
166
  export default function (pi: ExtensionAPI): void {
112
167
  pi.on("session_start", (_event, ctx) => {
113
168
  ctx.ui.addAutocompleteProvider(
114
169
  createFilePickerProviderFactory({
115
- loadRelativeCatalog: () => loadRelative({ root: ctx.cwd }),
170
+ engine: new WorkerFilePickerSuggestionEngine(ctx.cwd),
116
171
  })
117
172
  );
118
173
  });
@@ -14,31 +14,175 @@ export type FileRankOptions = {
14
14
 
15
15
  const isAbsoluteQuery = (query: string): boolean =>
16
16
  query.startsWith("/") || query.startsWith("~");
17
+ const GLOBAL_FUZZY_QUERY_MIN_LENGTH = 3;
17
18
 
18
19
  let cachedIndex:
19
20
  | {
20
21
  readonly source: readonly FileCandidate[];
21
- readonly index: FuzzyIndex<FileCandidate>;
22
+ readonly index: RelativeRankingIndex;
22
23
  }
23
24
  | undefined;
24
25
 
25
26
  const indexFor = (
26
27
  candidates: readonly FileCandidate[]
27
- ): FuzzyIndex<FileCandidate> => {
28
+ ): RelativeRankingIndex => {
28
29
  if (cachedIndex?.source === candidates) {
29
30
  return cachedIndex.index;
30
31
  }
31
- const fuzzy: FuzzyCandidate<FileCandidate>[] = candidates.map(
32
- (candidate) => ({
33
- item: candidate,
34
- haystacks: [candidate.matchHaystack],
35
- })
36
- );
37
- const index = FuzzyMatcher.prepare(fuzzy);
32
+
33
+ const index = new RelativeRankingIndex(candidates);
38
34
  cachedIndex = { source: candidates, index };
39
35
  return index;
40
36
  };
41
37
 
38
+ type LoweredCandidate = {
39
+ readonly candidate: FileCandidate;
40
+ readonly nameLower: string;
41
+ readonly haystackLower: string;
42
+ };
43
+
44
+ class RelativeRankingIndex {
45
+ private readonly childrenByDirectory = new Map<string, FileCandidate[]>();
46
+ private readonly loweredSource: readonly LoweredCandidate[];
47
+
48
+ private readonly scopedIndexes = new Map<string, FuzzyIndex<FileCandidate>>();
49
+ private globalIndex: FuzzyIndex<FileCandidate> | undefined;
50
+
51
+ public constructor(private readonly source: readonly FileCandidate[]) {
52
+ const lowered: LoweredCandidate[] = [];
53
+
54
+ for (const candidate of source) {
55
+ const slash = candidate.insertPath.lastIndexOf("/");
56
+ const directory =
57
+ slash === -1 ? "" : candidate.insertPath.slice(0, slash);
58
+ const children = this.childrenByDirectory.get(directory) ?? [];
59
+ children.push(candidate);
60
+ this.childrenByDirectory.set(directory, children);
61
+
62
+ lowered.push({
63
+ candidate,
64
+ nameLower: basename(candidate.insertPath).toLocaleLowerCase(),
65
+ haystackLower: candidate.matchHaystack.toLocaleLowerCase(),
66
+ });
67
+ }
68
+
69
+ this.loweredSource = lowered;
70
+ }
71
+
72
+ public rank(query: string, limit: number | undefined): AutocompleteItem[] {
73
+ const scoped = this.scopedCandidates(query);
74
+ if (scoped !== undefined) {
75
+ return rankCandidates(scoped.candidates, scoped.residualQuery, limit, {
76
+ index: () => this.indexForScope(scoped.directory, scoped.candidates),
77
+ });
78
+ }
79
+
80
+ const literalHits = this.literalRank(query, limit);
81
+ if (literalHits !== undefined) {
82
+ return literalHits;
83
+ }
84
+
85
+ return rankCandidates(this.source, query, limit, {
86
+ index: () => this.indexForGlobal(),
87
+ });
88
+ }
89
+
90
+ private scopedCandidates(query: string):
91
+ | {
92
+ readonly directory: string;
93
+ readonly residualQuery: string;
94
+ readonly candidates: readonly FileCandidate[];
95
+ }
96
+ | undefined {
97
+ const slash = query.lastIndexOf("/");
98
+ if (slash === -1) {
99
+ return undefined;
100
+ }
101
+
102
+ const directory = query.slice(0, slash);
103
+ const candidates = this.childrenByDirectory.get(directory);
104
+ if (candidates === undefined) {
105
+ return undefined;
106
+ }
107
+
108
+ return {
109
+ directory,
110
+ residualQuery: query.slice(slash + 1),
111
+ candidates,
112
+ };
113
+ }
114
+
115
+ private indexForScope(
116
+ directory: string,
117
+ candidates: readonly FileCandidate[]
118
+ ): FuzzyIndex<FileCandidate> {
119
+ const cached = this.scopedIndexes.get(directory);
120
+ if (cached !== undefined) {
121
+ return cached;
122
+ }
123
+
124
+ const index = prepareIndex(candidates, (candidate) =>
125
+ basename(candidate.insertPath)
126
+ );
127
+ this.scopedIndexes.set(directory, index);
128
+ return index;
129
+ }
130
+
131
+ private indexForGlobal(): FuzzyIndex<FileCandidate> {
132
+ this.globalIndex ??= prepareIndex(
133
+ this.source,
134
+ (candidate) => candidate.matchHaystack
135
+ );
136
+ return this.globalIndex;
137
+ }
138
+
139
+ private literalRank(
140
+ query: string,
141
+ limit: number | undefined
142
+ ): AutocompleteItem[] | undefined {
143
+ const needle = query.trim().toLocaleLowerCase();
144
+ if (needle.length === 0 || query.includes("/")) {
145
+ return undefined;
146
+ }
147
+
148
+ const prefixHits: FileCandidate[] = [];
149
+ const substringHits: FileCandidate[] = [];
150
+ const limitSize = limit ?? Infinity;
151
+
152
+ for (const { candidate, nameLower, haystackLower } of this.loweredSource) {
153
+ if (nameLower.startsWith(needle) || haystackLower.startsWith(needle)) {
154
+ prefixHits.push(candidate);
155
+ } else if (nameLower.includes(needle) || haystackLower.includes(needle)) {
156
+ substringHits.push(candidate);
157
+ }
158
+
159
+ // Prefix hits always outrank substring hits, so we can stop only once we
160
+ // have enough prefixes to fill the limit; otherwise a late-sorting prefix
161
+ // could be dropped for an earlier substring match.
162
+ if (prefixHits.length >= limitSize) {
163
+ break;
164
+ }
165
+ }
166
+
167
+ const hitCount = prefixHits.length + substringHits.length;
168
+ if (hitCount === 0) {
169
+ return undefined;
170
+ }
171
+
172
+ if (
173
+ prefixHits.length === 0 &&
174
+ needle.length >= GLOBAL_FUZZY_QUERY_MIN_LENGTH &&
175
+ hitCount < limitSize
176
+ ) {
177
+ return undefined;
178
+ }
179
+
180
+ return [...prefixHits, ...substringHits]
181
+ .slice(0, limit)
182
+ .map((candidate) => toItem(candidate));
183
+ }
184
+ }
185
+
42
186
  export async function rank(
43
187
  query: string,
44
188
  options: FileRankOptions
@@ -52,18 +196,42 @@ export async function rank(
52
196
  return undefined;
53
197
  }
54
198
 
55
- return rankCandidates(options.cachedRelative, query, options.limit);
199
+ return indexFor(options.cachedRelative).rank(query, options.limit);
56
200
  }
57
201
 
202
+ type RankCandidatesOptions = {
203
+ readonly index?: () => FuzzyIndex<FileCandidate>;
204
+ };
205
+
58
206
  const rankCandidates = (
59
207
  candidates: readonly FileCandidate[],
60
208
  query: string,
61
- limit: number | undefined
209
+ limit: number | undefined,
210
+ options: RankCandidatesOptions = {}
62
211
  ): AutocompleteItem[] => {
63
- const hits = indexFor(candidates).find(query, { limit });
212
+ if (query.trim().length === 0) {
213
+ return candidates.slice(0, limit).map((candidate) => toItem(candidate));
214
+ }
215
+
216
+ const index = options.index ?? (() => prepareIndex(candidates));
217
+ const hits = index().find(query, { limit });
64
218
  return hits.map((hit) => toItem(hit.item));
65
219
  };
66
220
 
221
+ const prepareIndex = (
222
+ candidates: readonly FileCandidate[],
223
+ haystack: (candidate: FileCandidate) => string = (candidate) =>
224
+ candidate.matchHaystack
225
+ ): FuzzyIndex<FileCandidate> => {
226
+ const fuzzy: FuzzyCandidate<FileCandidate>[] = candidates.map(
227
+ (candidate) => ({
228
+ item: candidate,
229
+ haystacks: [haystack(candidate)],
230
+ })
231
+ );
232
+ return FuzzyMatcher.prepare(fuzzy);
233
+ };
234
+
67
235
  const toItem = (candidate: FileCandidate): AutocompleteItem => {
68
236
  const suffix = candidate.isDirectory ? "/" : "";
69
237
  const value = `${candidate.insertPath}${suffix}`;
@@ -52,7 +52,9 @@ export default function (pi: ExtensionAPI): void {
52
52
  name: "glob",
53
53
  label: "glob",
54
54
  description:
55
- "Find files by glob pattern under a directory, sorted newest first. Skips gitignored paths and dotfiles unless requested. Use glob to enumerate files instead of bash with find, fd, ls -R, or similar.",
55
+ "Find files by glob pattern under a directory, sorted newest first. " +
56
+ "Skips gitignored paths and dotfiles unless requested. " +
57
+ "Use glob to enumerate files instead of bash with find, fd, ls -R, or similar.",
56
58
  parameters: globSchema,
57
59
  renderShell: "self",
58
60
  executionMode: "parallel",
@@ -7,7 +7,8 @@ export const GLOB_PATH_FORMATS = ["relative", "absolute"] as const;
7
7
 
8
8
  export const globSchema = Type.Object({
9
9
  pattern: Type.String({
10
- description: "Glob pattern relative to path (eg. **/*.ts).",
10
+ description:
11
+ "Glob pattern relative to path (eg. **/*.ts). Brace expansion spans sibling dirs (eg. {src,docs}/**/*.ts).",
11
12
  }),
12
13
  path: Type.Optional(
13
14
  Type.String({
@@ -2,7 +2,7 @@ import { FileScanner, type FileScanOptions } from "../../shared/FileScanner";
2
2
  import { FsErrors } from "../../shared/FsErrors";
3
3
  import { Lines } from "../../shared/Lines";
4
4
 
5
- const MATCH_CONCURRENCY = 32;
5
+ const MATCH_CONCURRENCY = 16;
6
6
 
7
7
  export type GrepLine = {
8
8
  readonly lineNumber: number;
@@ -25,10 +25,33 @@ export type GrepMatch = {
25
25
  export type GrepMatcher = {
26
26
  readonly regex: RegExp;
27
27
  readonly matchAcrossLines: boolean;
28
+ /**
29
+ * Raw-byte needle for the literal fast path: present only when the pattern is
30
+ * a pure literal, case-sensitive, and single-line, so `matchFile` can reject a
31
+ * non-matching file with `Buffer.indexOf` before decoding it. Undefined
32
+ * otherwise, in which case the regex path runs unchanged.
33
+ */
34
+ readonly literal: Buffer | undefined;
28
35
  };
29
36
 
30
37
  export type GrepScanOptions = FileScanOptions;
31
38
 
39
+ // Characters that stand for themselves in both a default-flag regex and raw
40
+ // UTF-8 bytes (all ASCII, so they never alias a multibyte sequence). A pattern
41
+ // made only of these is a literal we can scan on bytes.
42
+ const PURE_LITERAL = /^[A-Za-z0-9_ \-/]+$/;
43
+
44
+ function literalNeedle(
45
+ pattern: string,
46
+ caseInsensitive: boolean,
47
+ matchAcrossLines: boolean
48
+ ): Buffer | undefined {
49
+ if (caseInsensitive || matchAcrossLines || !PURE_LITERAL.test(pattern)) {
50
+ return undefined;
51
+ }
52
+ return Buffer.from(pattern, "utf8");
53
+ }
54
+
32
55
  export function buildMatcher(options: {
33
56
  readonly pattern: string;
34
57
  readonly caseInsensitive: boolean;
@@ -42,6 +65,11 @@ export function buildMatcher(options: {
42
65
  return {
43
66
  regex: new RegExp(options.pattern, flags),
44
67
  matchAcrossLines: options.matchAcrossLines,
68
+ literal: literalNeedle(
69
+ options.pattern,
70
+ options.caseInsensitive,
71
+ options.matchAcrossLines
72
+ ),
45
73
  };
46
74
  } catch (error) {
47
75
  const message = error instanceof Error ? error.message : String(error);
@@ -87,11 +115,26 @@ async function matchFile(
87
115
  ): Promise<GrepMatch | undefined> {
88
116
  const file = Bun.file(filePath);
89
117
 
118
+ // Binary skip reads only the first 8KB, so a binary file is never fully read.
90
119
  if (await Lines.isBinary(file)) {
91
120
  return undefined;
92
121
  }
93
122
 
94
- const content = Lines.normalize(await file.text());
123
+ let text: string;
124
+ if (matcher.literal !== undefined) {
125
+ // Literal fast path: scan raw bytes and bail on a miss without decoding. An
126
+ // ASCII literal can't match across a normalized newline or alias a
127
+ // multibyte char, so a raw-byte hit/miss matches the decoded result.
128
+ const bytes = Buffer.from(await file.arrayBuffer());
129
+ if (bytes.indexOf(matcher.literal) < 0) {
130
+ return undefined;
131
+ }
132
+ text = bytes.toString("utf8");
133
+ } else {
134
+ text = await file.text();
135
+ }
136
+
137
+ const content = Lines.normalize(text);
95
138
  const fileLines = Lines.split(content);
96
139
  const ranges = matcher.matchAcrossLines
97
140
  ? regexRanges(content, matcher.regex)
@@ -55,7 +55,9 @@ export default function (pi: ExtensionAPI): void {
55
55
  name: "grep",
56
56
  label: "grep",
57
57
  description:
58
- "Search UTF-8 text files with a JavaScript regex. Directory scans skip binary files, gitignored paths, and dotfiles unless requested; direct file paths are always searched. Use grep to search file contents instead of bash with grep, rg, ag, find -exec, or similar.",
58
+ "Search UTF-8 text files with a JavaScript regex. " +
59
+ "Directory scans skip binary files, gitignored paths, and dotfiles unless requested; direct file paths are always searched. " +
60
+ "Use grep to search file contents instead of bash with grep, rg, ag, find -exec, or similar.",
59
61
  parameters: grepSchema,
60
62
  renderShell: "self",
61
63
  executionMode: "parallel",
@@ -84,17 +84,31 @@ export type TitleOptions = {
84
84
 
85
85
  export function formatTitle(options: TitleOptions): string {
86
86
  const pattern = formatPattern(options.pattern);
87
- const resolvedPath =
87
+ const resolved =
88
88
  options.path === undefined
89
89
  ? undefined
90
90
  : Paths.resolve(options.path, options.cwd);
91
- const target = Paths.titleOr(resolvedPath, options.cwd, ".");
92
- const glob = options.glob ? ` ${options.glob}` : "";
91
+ const dir =
92
+ resolved === undefined || resolved === options.cwd
93
+ ? undefined
94
+ : Paths.displayRelative(resolved, options.cwd);
95
+ const target = joinTarget(dir, options.glob);
96
+ const location = target ? ` in ${target}` : "";
93
97
  const suffix =
94
98
  options.fileCount === undefined
95
99
  ? ""
96
100
  : ` (${options.fileCount} ${options.fileCount === 1 ? "file" : "files"})`;
97
- return `${pattern} in ${target}${glob}${suffix}`;
101
+ return `${pattern}${location}${suffix}`;
102
+ }
103
+
104
+ function joinTarget(
105
+ dir: string | undefined,
106
+ glob: string | undefined
107
+ ): string | undefined {
108
+ if (glob === undefined) {
109
+ return dir;
110
+ }
111
+ return dir === undefined ? glob : `${dir}/${glob}`;
98
112
  }
99
113
 
100
114
  function formatPattern(pattern: string | undefined): string {
@@ -25,7 +25,7 @@ export const grepSchema = Type.Object({
25
25
  glob: Type.Optional(
26
26
  Type.String({
27
27
  description:
28
- "Relative glob filter under path when path is a directory. Gitignored files and dotfiles are skipped during directory scans unless includeIgnored/includeDotfiles is true.",
28
+ "Relative glob filter under path when path is a directory. Brace expansion spans sibling dirs (eg. {src,docs}/**/*.ts). Gitignored files and dotfiles are skipped during directory scans unless includeIgnored/includeDotfiles is true.",
29
29
  })
30
30
  ),
31
31
  exclude: Type.Optional(