pi-hashline-edit-pro 4.3.1 → 4.3.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/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "autoReadAll" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -12,6 +12,7 @@ export interface ConfigRow {
12
12
  mode?: string;
13
13
  cycle?: string[];
14
14
  value?: number;
15
+ folders?: string[];
15
16
  disabled?: boolean;
16
17
  }
17
18
 
@@ -19,6 +20,7 @@ export function configRows(config: Config): ConfigRow[] {
19
20
  return [
20
21
  { key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
21
22
  { key: "autoReadAll", label: "Auto-read all", hint: "Attach files on the first turn: off, on, git (git repos only)", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "on", "git"] },
23
+ { key: "autoReadAllIgnore", label: "Ignore folders", hint: "Extra folders skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, folders: config.autoReadAllIgnore ?? [] },
22
24
  { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
23
25
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
24
26
  { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
@@ -42,12 +44,22 @@ function numberBox(theme: Theme, value: number, disabled?: boolean): string {
42
44
  if (disabled) return theme.fg("dim", `[${value}]`);
43
45
  return theme.fg("accent", `[${value}]`);
44
46
  }
47
+ function ignoreBox(theme: Theme, folders: string[]): string {
48
+ return theme.fg("accent", `[${folders.length}]`);
49
+ }
50
+ function formatIgnoreFolders(folders: string[]): string {
51
+ if (folders.length === 0) return "(empty)";
52
+ const joined = folders.join(", ");
53
+ return joined.length > 40 ? `${joined.slice(0, 37)}...` : joined;
54
+ }
45
55
 
46
56
  export class HashlineConfigOverlay {
47
57
  private rows: ConfigRow[];
48
58
  private selected = 0;
59
+ private editingIgnore = false;
60
+ private editBuffer = "";
49
61
 
50
- constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey, delta?: number) => Promise<void> }) {
62
+ constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey, delta?: number, value?: string) => Promise<void> }) {
51
63
  this.rows = [];
52
64
  }
53
65
 
@@ -55,19 +67,43 @@ export class HashlineConfigOverlay {
55
67
  this.rows = configRows(await readConfig());
56
68
  }
57
69
 
58
- private runToggle(row: ConfigRow, delta?: number): void {
70
+ private runToggle(row: ConfigRow, delta?: number, value?: string): void {
59
71
  this.opts.tui.requestRender(true);
60
- void this.opts.onToggle(row.key, delta).then(async () => {
72
+ void this.opts.onToggle(row.key, delta, value).then(async () => {
61
73
  this.rows = configRows(await readConfig());
74
+ this.editingIgnore = false;
62
75
  this.opts.tui.requestRender(true);
63
76
  }).catch((error: unknown) => {
64
77
  console.error("Failed to toggle hashline setting:", error);
65
78
  });
66
79
  }
80
+ private startIgnoreEdit(row: ConfigRow): void {
81
+ this.editingIgnore = true;
82
+ this.editBuffer = (row.folders ?? []).join(", ");
83
+ this.opts.tui.requestRender(true);
84
+ }
85
+ private commitIgnoreEdit(): void {
86
+ const row = this.rows[this.selected];
87
+ if (!row || row.key !== "autoReadAllIgnore") {
88
+ this.editingIgnore = false;
89
+ return;
90
+ }
91
+ const value = this.editBuffer;
92
+ this.editingIgnore = false;
93
+ this.runToggle(row, undefined, value);
94
+ }
95
+ private cancelIgnoreEdit(): void {
96
+ this.editingIgnore = false;
97
+ this.opts.tui.requestRender(true);
98
+ }
67
99
 
68
100
  private toggleSelected(): void {
69
101
  const row = this.rows[this.selected];
70
102
  if (!row || row.disabled) return;
103
+ if (row.key === "autoReadAllIgnore") {
104
+ this.startIgnoreEdit(row);
105
+ return;
106
+ }
71
107
  if (row.value !== undefined) {
72
108
  row.value += 1;
73
109
  this.runToggle(row, 1);
@@ -92,6 +128,32 @@ export class HashlineConfigOverlay {
92
128
  }
93
129
 
94
130
  handleInput(data: string): void {
131
+ if (this.editingIgnore) {
132
+ if (matchesKey(data, Key.escape) || data === "\x1b") {
133
+ this.cancelIgnoreEdit();
134
+ return;
135
+ }
136
+ if (matchesKey(data, Key.enter) || data === "\r" || data === "\n") {
137
+ this.commitIgnoreEdit();
138
+ return;
139
+ }
140
+ if (data === "\x7f" || data === "\b" || data === "\x08") {
141
+ this.editBuffer = this.editBuffer.slice(0, -1);
142
+ this.opts.tui.requestRender(true);
143
+ return;
144
+ }
145
+ if (data === "\x15") {
146
+ this.editBuffer = "";
147
+ this.opts.tui.requestRender(true);
148
+ return;
149
+ }
150
+ if (data.length >= 1 && [...data].every((ch) => ch.charCodeAt(0) >= 32 && ch !== "\x7f")) {
151
+ this.editBuffer += data;
152
+ this.opts.tui.requestRender(true);
153
+ return;
154
+ }
155
+ return;
156
+ }
95
157
  if (matchesKey(data, Key.up) || data === "k") {
96
158
  this.selected = (this.selected + this.rows.length - 1) % this.rows.length;
97
159
  return;
@@ -112,6 +174,11 @@ export class HashlineConfigOverlay {
112
174
  this.toggleSelected();
113
175
  return;
114
176
  }
177
+ const row = this.rows[this.selected];
178
+ if ((data === "e" || data === "E") && row && row.key === "autoReadAllIgnore" && !row.disabled) {
179
+ this.startIgnoreEdit(row);
180
+ return;
181
+ }
115
182
  if (matchesKey(data, Key.escape) || data === "q") {
116
183
  this.opts.done();
117
184
  }
@@ -129,12 +196,17 @@ export class HashlineConfigOverlay {
129
196
  lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
130
197
  this.rows.forEach((row, index) => {
131
198
  const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
132
- const box = row.value !== undefined ? numberBox(theme, row.value, row.disabled) : row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
199
+ const box = row.folders !== undefined ? ignoreBox(theme, row.folders) : row.value !== undefined ? numberBox(theme, row.value, row.disabled) : row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
133
200
  const label = row.disabled ? theme.fg("dim", row.label) : index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
134
- lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", `— ${row.hint}`)}`));
201
+ const suffix = row.folders !== undefined ? ` — ${row.hint}: ${formatIgnoreFolders(row.folders)}` : ` — ${row.hint}`;
202
+ lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", suffix)}`));
203
+ if (row.key === "autoReadAllIgnore" && index === this.selected && this.editingIgnore) {
204
+ lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", "edit:")} ${this.editBuffer}█ ${theme.fg("dim", "(Enter save · Esc cancel · Ctrl-U clear)")}`));
205
+ }
135
206
  });
136
207
  lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
137
- lines.push(padRow(theme, innerWidth, theme.fg("dim", " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · q close")));
208
+ const footer = this.editingIgnore ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit folders · q close";
209
+ lines.push(padRow(theme, innerWidth, theme.fg("dim", footer)));
138
210
  lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
139
211
  return lines;
140
212
  }
package/src/config.ts CHANGED
@@ -15,6 +15,7 @@ export interface Config {
15
15
  autoRead: boolean;
16
16
  anchorGrepEnabled: boolean;
17
17
  autoReadAll?: AutoReadAllMode;
18
+ autoReadAllIgnore?: string[];
18
19
  requirePath?: boolean;
19
20
  strictInput?: boolean;
20
21
  boundaryDedupMode?: BoundaryDedupMode;
@@ -25,6 +26,7 @@ const DEFAULT_CONFIG: Config = {
25
26
  autoRead: true,
26
27
  anchorGrepEnabled: true,
27
28
  autoReadAll: "off",
29
+ autoReadAllIgnore: [],
28
30
  requirePath: false,
29
31
  strictInput: false,
30
32
  boundaryDedupMode: "on",
@@ -47,6 +49,26 @@ function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
47
49
  return DEFAULT_CONFIG.autoReadAll ?? "off";
48
50
  }
49
51
 
52
+ export function normalizeAutoReadAllIgnoreEntry(entry: string): string {
53
+ return entry.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/{2,}/g, "/");
54
+ }
55
+
56
+ export function parseAutoReadAllIgnore(value: unknown): string[] {
57
+ const raw = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [];
58
+ const seen = new Set<string>();
59
+ const out: string[] = [];
60
+ for (const item of raw) {
61
+ if (typeof item !== "string") continue;
62
+ const cleaned = normalizeAutoReadAllIgnoreEntry(item);
63
+ if (cleaned.length === 0) continue;
64
+ const lower = cleaned.toLowerCase();
65
+ if (seen.has(lower)) continue;
66
+ seen.add(lower);
67
+ out.push(cleaned);
68
+ }
69
+ return out;
70
+ }
71
+
50
72
  export function normalizeDiffContextLines(value: unknown): number {
51
73
  if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_DIFF_CONTEXT_LINES;
52
74
  const floored = Math.floor(value);
@@ -68,6 +90,7 @@ function parseConfig(content: string): Config {
68
90
  const boundaryDedupMode = parsed.boundaryDedupMode;
69
91
  const legacyBoundaryDedup = parsed.boundaryDedupEnabled;
70
92
  const diffContextLines = parsed.diffContextLines;
93
+ const autoReadAllIgnore = parsed.autoReadAllIgnore;
71
94
  return {
72
95
  autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
73
96
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
@@ -76,6 +99,7 @@ function parseConfig(content: string): Config {
76
99
  strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
77
100
  boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
78
101
  diffContextLines: normalizeDiffContextLines(diffContextLines),
102
+ autoReadAllIgnore: parseAutoReadAllIgnore(autoReadAllIgnore),
79
103
  };
80
104
  }
81
105
 
@@ -222,3 +246,14 @@ export async function adjustDiffContextLines(delta: number): Promise<number> {
222
246
  });
223
247
  return next;
224
248
  }
249
+ export async function setAutoReadAllIgnore(dirs: string[]): Promise<string[]> {
250
+ let next: string[] = [];
251
+ await updateConfig((c) => {
252
+ next = parseAutoReadAllIgnore(dirs);
253
+ c.autoReadAllIgnore = next;
254
+ });
255
+ return next;
256
+ }
257
+ export async function setAutoReadAllIgnoreFromText(text: string): Promise<string[]> {
258
+ return setAutoReadAllIgnore(text.split(","));
259
+ }
@@ -13,13 +13,15 @@ export interface EditToolFlags {
13
13
  strictInput: boolean;
14
14
  boundaryDedupMode: BoundaryDedupMode;
15
15
  autoRead: boolean;
16
+ autoReadAllActive: boolean;
16
17
  }
17
18
 
18
19
  export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
19
20
  requirePath: false,
20
21
  strictInput: false,
21
22
  boundaryDedupMode: "on",
22
- autoRead: true
23
+ autoRead: true,
24
+ autoReadAllActive: false
23
25
  };
24
26
 
25
27
  export async function currentEditFlags(): Promise<EditToolFlags> {
@@ -28,7 +30,8 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
28
30
  requirePath: config.requirePath === true,
29
31
  strictInput: config.strictInput === true,
30
32
  boundaryDedupMode: config.boundaryDedupMode ?? "on",
31
- autoRead: config.autoRead !== false
33
+ autoRead: config.autoRead !== false,
34
+ autoReadAllActive: (config.autoReadAll ?? "off") !== "off"
32
35
  };
33
36
  }
34
37
 
@@ -38,7 +41,8 @@ export function withReplacePrompts(base: { description: string; snippet: string;
38
41
  let guidelines = [...base.guidelines];
39
42
  if (!flags.autoRead) {
40
43
  description = description.replace(" Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading.", "");
41
- guidelines = guidelines.map((guideline) => guideline.includes("post-edit diff") ? "`replace`: one batch per file per turn; verify each result before the next edit on that file." : guideline);
44
+ guidelines = guidelines.filter((guideline) => !guideline.includes("post-edit diff"));
45
+ description = description.replace("and the last call shows the combined diff,", "and the last call shows the combined result,");
42
46
  }
43
47
  const descriptionParts = [description];
44
48
  if (flags.requirePath) {
@@ -64,13 +68,21 @@ export function withReplacePrompts(base: { description: string; snippet: string;
64
68
  }
65
69
 
66
70
  export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
67
- if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...base.guidelines] };
68
- const guidelines = base.guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
69
- return { description: base.description, snippet: base.snippet, guidelines };
71
+ if (flags.autoReadAllActive) {
72
+ const rewritten = base.guidelines
73
+ .filter((guideline) => !guideline.includes("call again after an edit"))
74
+ return { description: base.description, snippet: base.snippet, guidelines: [...rewritten] };
75
+ }
76
+ const withoutAutoReadAll = base.guidelines.filter((guideline) => !guideline.includes("E_AUTO_READ_ALL"))
77
+ if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...withoutAutoReadAll] };
78
+ const guidelines = [...withoutAutoReadAll];
79
+ const mapped = guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
80
+ return { description: base.description, snippet: base.snippet, guidelines: mapped };
70
81
  }
71
82
 
72
83
  export function withInsertPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
73
- const descriptionParts = [base.description];
84
+ const baseDescription = flags.autoRead ? base.description : base.description.replace("and the last call shows the combined diff,", "and the last call shows the combined result,");
85
+ const descriptionParts = [baseDescription];
74
86
  const snippetParts = [base.snippet];
75
87
  const guidelines = [...base.guidelines];
76
88
  if (flags.requirePath) {
@@ -87,6 +99,12 @@ export function withInsertPrompts(base: { description: string; snippet: string;
87
99
  }
88
100
  return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
89
101
  }
102
+
103
+ export function withUndoPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
104
+ if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...base.guidelines] };
105
+ const guidelines = base.guidelines.map((guideline) => guideline.includes("bad diff") ? "`undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad edit — review what you're restoring." : guideline);
106
+ return { description: base.description, snippet: base.snippet, guidelines };
107
+ }
90
108
  export function resolveEditTarget(removeFrom: string, removeTo?: string): string {
91
109
  const refs = [removeFrom, removeTo].filter((value): value is string => typeof value === "string");
92
110
  const owners = refs.map((ref) => ownerOf(parseHashRef(stripAnchorRow(ref.trim(), "anchor entry")).hash));
@@ -1,10 +1,9 @@
1
1
  import { constants } from "node:fs";
2
2
  import { stat } from "node:fs/promises";
3
- import { relative } from "node:path";
4
3
  import { lineHashes } from "./hashline";
5
4
  import { loadFileKindAndText, type LFile } from "./file-kind";
6
5
  import { resolveTarget, type FileIdentity } from "./fs-write";
7
- import { toCwd } from "./paths";
6
+ import { toCwd, toDisplayPath } from "./paths";
8
7
  import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
9
8
  import { abortIf, errCode, assertLineLimit } from "./utils";
10
9
  import { ANCHOR_POOL_EXHAUSTED_PREFIX } from "./constants";
@@ -119,7 +118,7 @@ export async function tryReadNormFile(
119
118
  options?: ReadNormOptions,
120
119
  ): Promise<NormFile | undefined> {
121
120
  try {
122
- const displayPath = relative(cwd, absPath).replace(/\\/g, "/") || absPath;
121
+ const displayPath = toDisplayPath(cwd, absPath);
123
122
  const file = await loadFileKindAndText(absPath, { maxLines: options?.maxLines, displayPath });
124
123
  if (file.kind !== "text") return undefined;
125
124
  return await readNormFile(absPath, cwd, { ...options, preloadedFile: file });
package/src/grep.ts CHANGED
@@ -2,16 +2,16 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import { stat } from "node:fs/promises";
5
- import { dirname, isAbsolute, join, relative, win32 } from "node:path";
5
+ import { dirname, isAbsolute, join, win32 } from "node:path";
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { createInterface } from "node:readline";
8
8
  import { tryReadNormFile } from "./file-reader";
9
9
  import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
10
10
  import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
11
- import { toCwd } from "./paths";
11
+ import { toCwd, toDisplayPath } from "./paths";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { normReq } from "./payload-contract";
14
- import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
14
+ import { abortIf, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
15
15
  import { withAnchorSession } from "./anchor-registry";
16
16
  import { serveRows } from "./served";
17
17
  import { Text } from "@earendil-works/pi-tui";
@@ -152,12 +152,39 @@ function assertSafeRegex(pattern: string): void {
152
152
  }
153
153
  }
154
154
 
155
- function globToRegex(glob: string): RegExp {
156
- if (glob.startsWith("/")) glob = glob.slice(1);
155
+ function bracketEnd(source: string, start: number): number {
156
+ let j = start + 1;
157
+ if (j < source.length && (source[j] === "!" || source[j] === "^")) j++;
158
+ if (j < source.length && source[j] === "]") j++;
159
+ let esc = false;
160
+ while (j < source.length) {
161
+ const c = source[j]!;
162
+ if (esc) {
163
+ esc = false;
164
+ j++;
165
+ continue;
166
+ }
167
+ if (c === "\\") {
168
+ esc = true;
169
+ j++;
170
+ continue;
171
+ }
172
+ if (c === "]") return j;
173
+ j++;
174
+ }
175
+ return -1;
176
+ }
177
+ function globPartToSource(glob: string): string {
157
178
  let source = "";
158
179
  let i = 0;
159
180
  while (i < glob.length) {
160
181
  const ch = glob[i]!;
182
+ if (ch === "\\" && i + 1 < glob.length) {
183
+ const next = glob[i + 1]!;
184
+ source += next.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
185
+ i += 2;
186
+ continue;
187
+ }
161
188
  if (ch === "*") {
162
189
  if (glob[i + 1] === "*") {
163
190
  i += 2;
@@ -170,14 +197,146 @@ function globToRegex(glob: string): RegExp {
170
197
  continue;
171
198
  }
172
199
  source += ".*";
173
- } else if (ch === "?") {
200
+ i += 1;
201
+ continue;
202
+ }
203
+ if (ch === "?") {
174
204
  source += "[^/]";
175
- } else {
176
- source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
205
+ i += 1;
206
+ continue;
177
207
  }
178
- i += 1;
208
+ if (ch === "{") {
209
+ let depth = 1;
210
+ let j = i + 1;
211
+ let esc = false;
212
+ while (j < glob.length && depth > 0) {
213
+ const c = glob[j]!;
214
+ if (esc) {
215
+ esc = false;
216
+ j++;
217
+ continue;
218
+ }
219
+ if (c === "\\") {
220
+ esc = true;
221
+ j++;
222
+ continue;
223
+ }
224
+ if (c === "[") {
225
+ const e = bracketEnd(glob, j);
226
+ if (e >= 0) {
227
+ j = e + 1;
228
+ continue;
229
+ }
230
+ j++;
231
+ continue;
232
+ }
233
+ if (c === "{") depth++;
234
+ else if (c === "}") depth--;
235
+ if (depth === 0) break;
236
+ j++;
237
+ }
238
+ if (depth !== 0) {
239
+ source += "\\{";
240
+ i++;
241
+ continue;
242
+ }
243
+ const inner = glob.slice(i + 1, j);
244
+ const parts: string[] = [];
245
+ let cur = "";
246
+ let d2 = 0;
247
+ let esc2 = false;
248
+ for (let k = 0; k < inner.length; k++) {
249
+ const c = inner[k]!;
250
+ if (esc2) {
251
+ cur += c;
252
+ esc2 = false;
253
+ continue;
254
+ }
255
+ if (c === "\\") {
256
+ esc2 = true;
257
+ cur += c;
258
+ continue;
259
+ }
260
+ if (c === "[") {
261
+ const e = bracketEnd(inner, k);
262
+ if (e >= 0) {
263
+ cur += inner.slice(k, e + 1);
264
+ k = e;
265
+ continue;
266
+ }
267
+ cur += c;
268
+ continue;
269
+ }
270
+ if (c === "{") {
271
+ d2++;
272
+ cur += c;
273
+ continue;
274
+ }
275
+ if (c === "}") {
276
+ d2--;
277
+ cur += c;
278
+ continue;
279
+ }
280
+ if (c === "," && d2 === 0) {
281
+ parts.push(cur);
282
+ cur = "";
283
+ continue;
284
+ }
285
+ cur += c;
286
+ }
287
+ parts.push(cur);
288
+ if (parts.length <= 1) {
289
+ source += "\\{" + globPartToSource(inner) + "\\}";
290
+ } else {
291
+ source += "(?:" + parts.map((p) => globPartToSource(p)).join("|") + ")";
292
+ }
293
+ i = j + 1;
294
+ continue;
295
+ }
296
+ if (ch === "[") {
297
+ let j = i + 1;
298
+ if (j < glob.length && (glob[j] === "!" || glob[j] === "^")) j++;
299
+ if (j < glob.length && glob[j] === "]") j++;
300
+ let esc3 = false;
301
+ while (j < glob.length) {
302
+ const c = glob[j]!;
303
+ if (esc3) {
304
+ esc3 = false;
305
+ j++;
306
+ continue;
307
+ }
308
+ if (c === "\\") {
309
+ esc3 = true;
310
+ j++;
311
+ continue;
312
+ }
313
+ if (c === "]") break;
314
+ j++;
315
+ }
316
+ if (j >= glob.length) {
317
+ source += "\\[";
318
+ i++;
319
+ continue;
320
+ }
321
+ let content = glob.slice(i + 1, j);
322
+ if (content.startsWith("!")) content = "^" + content.slice(1);
323
+ if (content.startsWith("]") || content.startsWith("^]")) {
324
+ if (content.startsWith("^]")) content = "^\\]" + content.slice(2);
325
+ else content = "\\]" + content.slice(1);
326
+ }
327
+ if (content.startsWith("^") && !content.includes("/")) content = "^/" + content.slice(1);
328
+ source += "[" + content + "]";
329
+ i = j + 1;
330
+ continue;
331
+ }
332
+ source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
333
+ i++;
179
334
  }
180
- return new RegExp(`^${source}$`);
335
+ return source;
336
+ }
337
+ function globToRegex(glob: string): RegExp {
338
+ if (glob.startsWith("/")) glob = glob.slice(1);
339
+ return new RegExp(`^${globPartToSource(glob)}$`);
181
340
  }
182
341
 
183
342
  interface FileHit {
@@ -401,14 +560,11 @@ async function collectRgMatches(
401
560
  });
402
561
  }
403
562
 
404
- function gutterWidthFor(numbers: number[]): number {
405
- let max = 0;
406
- for (const n of numbers) if (n > max) max = n;
407
- return String(max || 1).length;
408
- }
409
563
 
410
564
  function displayRowsForHit(hit: FileHit): string[] {
411
- const width = gutterWidthFor(hit.lineNumbers);
565
+ let max = 0;
566
+ for (const n of hit.lineNumbers) if (n > max) max = n;
567
+ const width = gutterWidth(max, 1);
412
568
  return hit.rows.map((row, i) => {
413
569
  const n = hit.lineNumbers[i]!;
414
570
  const padded = String(n).padStart(width, " ");
@@ -571,8 +727,8 @@ export function regGrep(pi: ExtensionAPI): void {
571
727
  const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
572
728
  const matchesGlob = (absPath: string): boolean => {
573
729
  if (globRegex === undefined) return true;
574
- const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
575
- const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
730
+ const displayPath = toDisplayPath(ctx.cwd, absPath);
731
+ const globPath = toDisplayPath(globRoot, absPath);
576
732
  return globRegex.test(globPath) || globRegex.test(displayPath);
577
733
  };
578
734
  const validatedRegex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
@@ -613,7 +769,7 @@ export function regGrep(pi: ExtensionAPI): void {
613
769
  if (!matchesGlob(absPath)) continue;
614
770
  const norm = await readGrepFileShadow(absPath);
615
771
  if (!norm) continue;
616
- const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
772
+ const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), indices, context, validatedRegex, totalForFile, indices.length);
617
773
  const display = displayRowsForHit(hit);
618
774
  totalRows += display.length;
619
775
  for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
@@ -635,7 +791,7 @@ export function regGrep(pi: ExtensionAPI): void {
635
791
  if (!matchesGlob(absPath)) continue;
636
792
  const norm = await readGrepFile(absPath);
637
793
  if (!norm) continue;
638
- const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
794
+ const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
639
795
  const display = displayRowsForHit(hit);
640
796
  const keptRows: string[] = [];
641
797
  const keptHashes: string[] = [];