pi-hashline-edit-pro 4.3.3 → 4.3.4

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
@@ -85,7 +85,7 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
85
85
  }
86
86
  ```
87
87
 
88
- Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` work as aliases.
88
+ Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` and `from`/`to` work as aliases.
89
89
 
90
90
  The request is checked before any file I/O, so a bad request never touches the file.
91
91
 
@@ -167,7 +167,7 @@ Files are filtered before injection: symlinks, directories, image extensions (in
167
167
  Each attached file is shown as `=== path ===` followed by its `anchor│content` rows — edit directly from the attachment with replace and insert, no read needed. Files attach whole.
168
168
  A coverage line after the header reports the complete count. A `[files complete: [...] omitted: [...]]` line right after it lists every attached complete file and omitted file in one place — check that line instead of scanning sections.
169
169
 
170
- The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders are ignored via `/hashline-config` as Ignore folders and via `config.json` as `autoReadAllIgnore` (array of folder names, for example `["docs", "tmp"]`); a single-segment entry skips any path containing that folder (case-insensitive), while an entry with a slash (for example `"src/tmp"`) skips that folder path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
170
+ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `anchor_grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
171
171
 
172
172
  ## Tool result details
173
173
 
@@ -184,7 +184,7 @@ All five tools return machine-readable metadata in `details` alongside the model
184
184
 
185
185
  | Command | Description |
186
186
  | --- | --- |
187
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
187
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
188
188
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
189
189
 
190
190
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
package/index.ts CHANGED
@@ -124,7 +124,7 @@ export default function (pi: ExtensionAPI): void {
124
124
  }));
125
125
 
126
126
  pi.registerCommand("hashline-config", {
127
- description: "Open the hashline settings window (auto-read, auto-read all, ignore folders, diff context, grep, path, strict input, dedup)",
127
+ description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, path, strict input, dedup)",
128
128
  handler: async (_args, ctx) => {
129
129
  if (!ctx.hasUI) {
130
130
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.3.3",
3
+ "version": "4.3.4",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -13,6 +13,7 @@ import { normalizeAutoReadAllIgnoreEntry, type AutoReadAllMode } from "./config"
13
13
  import { serveRows } from "./served";
14
14
  import { readNormFile, safeSnapId } from "./file-reader";
15
15
  import { resolveRgPath } from "./grep";
16
+ import { globToRegex } from "./glob";
16
17
  import { MAX_HASH_LINES } from "./hashline";
17
18
  import { fmtReadPreview } from "./read";
18
19
  import { splitLines } from "./utils";
@@ -117,12 +118,41 @@ export function normalizeAutoReadAllIgnoreList(entries: readonly string[] | unde
117
118
  }
118
119
  return out;
119
120
  }
121
+ const GLOB_CHARS_RE = /[*?[\]{}]/;
122
+ const GLOB_CACHE_LIMIT = 256;
123
+ const globRegexCache = new Map<string, RegExp | null>();
124
+
125
+ function globRegexFor(entry: string): RegExp | null {
126
+ const cached = globRegexCache.get(entry);
127
+ if (cached !== undefined) return cached;
128
+ let compiled: RegExp | null;
129
+ try {
130
+ compiled = globToRegex(entry);
131
+ } catch {
132
+ compiled = null;
133
+ }
134
+ if (globRegexCache.size >= GLOB_CACHE_LIMIT) {
135
+ const oldest = globRegexCache.keys().next().value;
136
+ if (oldest !== undefined) globRegexCache.delete(oldest);
137
+ }
138
+ globRegexCache.set(entry, compiled);
139
+ return compiled;
140
+ }
141
+
120
142
  export function isExcludedByCustomIgnore(path: string, customIgnore: readonly string[]): boolean {
121
143
  if (customIgnore.length === 0) return false;
122
144
  const lower = path.toLowerCase();
123
145
  const segments = lower.split("/");
146
+ const base = segments[segments.length - 1] ?? "";
124
147
  for (const entry of customIgnore) {
125
148
  if (entry.length === 0) continue;
149
+ if (GLOB_CHARS_RE.test(entry)) {
150
+ const regex = globRegexFor(entry);
151
+ if (regex !== null) {
152
+ if (entry.includes("/") ? regex.test(lower) : regex.test(base)) return true;
153
+ continue;
154
+ }
155
+ }
126
156
  if (!entry.includes("/")) {
127
157
  if (segments.includes(entry)) return true;
128
158
  } else if (lower === entry || lower.startsWith(entry + "/") || lower.includes("/" + entry + "/") || lower.endsWith("/" + entry)) return true;
package/src/config-ui.ts CHANGED
@@ -20,7 +20,7 @@ export function configRows(config: Config): ConfigRow[] {
20
20
  return [
21
21
  { key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
22
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 ?? [] },
23
+ { key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, folders: config.autoReadAllIgnore ?? [] },
24
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 },
25
25
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
26
26
  { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
@@ -205,7 +205,7 @@ export class HashlineConfigOverlay {
205
205
  }
206
206
  });
207
207
  lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
208
- const footer = this.editingIgnore ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit folders · q close";
208
+ const footer = this.editingIgnore ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit list · q close";
209
209
  lines.push(padRow(theme, innerWidth, theme.fg("dim", footer)));
210
210
  lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
211
211
  return lines;
package/src/glob.ts ADDED
@@ -0,0 +1,186 @@
1
+ function bracketEnd(source: string, start: number): number {
2
+ let j = start + 1;
3
+ if (j < source.length && (source[j] === "!" || source[j] === "^")) j++;
4
+ if (j < source.length && source[j] === "]") j++;
5
+ let esc = false;
6
+ while (j < source.length) {
7
+ const c = source[j]!;
8
+ if (esc) {
9
+ esc = false;
10
+ j++;
11
+ continue;
12
+ }
13
+ if (c === "\\") {
14
+ esc = true;
15
+ j++;
16
+ continue;
17
+ }
18
+ if (c === "]") return j;
19
+ j++;
20
+ }
21
+ return -1;
22
+ }
23
+ function globPartToSource(glob: string): string {
24
+ let source = "";
25
+ let i = 0;
26
+ while (i < glob.length) {
27
+ const ch = glob[i]!;
28
+ if (ch === "\\" && i + 1 < glob.length) {
29
+ const next = glob[i + 1]!;
30
+ source += next.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ i += 2;
32
+ continue;
33
+ }
34
+ if (ch === "*") {
35
+ if (glob[i + 1] === "*") {
36
+ i += 2;
37
+ if (glob[i] === "/") {
38
+ i += 1;
39
+ source += "(?:.*\\/)?";
40
+ } else {
41
+ source += ".*";
42
+ }
43
+ continue;
44
+ }
45
+ source += ".*";
46
+ i += 1;
47
+ continue;
48
+ }
49
+ if (ch === "?") {
50
+ source += "[^/]";
51
+ i += 1;
52
+ continue;
53
+ }
54
+ if (ch === "{") {
55
+ let depth = 1;
56
+ let j = i + 1;
57
+ let esc = false;
58
+ while (j < glob.length && depth > 0) {
59
+ const c = glob[j]!;
60
+ if (esc) {
61
+ esc = false;
62
+ j++;
63
+ continue;
64
+ }
65
+ if (c === "\\") {
66
+ esc = true;
67
+ j++;
68
+ continue;
69
+ }
70
+ if (c === "[") {
71
+ const e = bracketEnd(glob, j);
72
+ if (e >= 0) {
73
+ j = e + 1;
74
+ continue;
75
+ }
76
+ j++;
77
+ continue;
78
+ }
79
+ if (c === "{") depth++;
80
+ else if (c === "}") depth--;
81
+ if (depth === 0) break;
82
+ j++;
83
+ }
84
+ if (depth !== 0) {
85
+ source += "\\{";
86
+ i++;
87
+ continue;
88
+ }
89
+ const inner = glob.slice(i + 1, j);
90
+ const parts: string[] = [];
91
+ let cur = "";
92
+ let d2 = 0;
93
+ let esc2 = false;
94
+ for (let k = 0; k < inner.length; k++) {
95
+ const c = inner[k]!;
96
+ if (esc2) {
97
+ cur += c;
98
+ esc2 = false;
99
+ continue;
100
+ }
101
+ if (c === "\\") {
102
+ esc2 = true;
103
+ cur += c;
104
+ continue;
105
+ }
106
+ if (c === "[") {
107
+ const e = bracketEnd(inner, k);
108
+ if (e >= 0) {
109
+ cur += inner.slice(k, e + 1);
110
+ k = e;
111
+ continue;
112
+ }
113
+ cur += c;
114
+ continue;
115
+ }
116
+ if (c === "{") {
117
+ d2++;
118
+ cur += c;
119
+ continue;
120
+ }
121
+ if (c === "}") {
122
+ d2--;
123
+ cur += c;
124
+ continue;
125
+ }
126
+ if (c === "," && d2 === 0) {
127
+ parts.push(cur);
128
+ cur = "";
129
+ continue;
130
+ }
131
+ cur += c;
132
+ }
133
+ parts.push(cur);
134
+ if (parts.length <= 1) {
135
+ source += "\\{" + globPartToSource(inner) + "\\}";
136
+ } else {
137
+ source += "(?:" + parts.map((p) => globPartToSource(p)).join("|") + ")";
138
+ }
139
+ i = j + 1;
140
+ continue;
141
+ }
142
+ if (ch === "[") {
143
+ let j = i + 1;
144
+ if (j < glob.length && (glob[j] === "!" || glob[j] === "^")) j++;
145
+ if (j < glob.length && glob[j] === "]") j++;
146
+ let esc3 = false;
147
+ while (j < glob.length) {
148
+ const c = glob[j]!;
149
+ if (esc3) {
150
+ esc3 = false;
151
+ j++;
152
+ continue;
153
+ }
154
+ if (c === "\\") {
155
+ esc3 = true;
156
+ j++;
157
+ continue;
158
+ }
159
+ if (c === "]") break;
160
+ j++;
161
+ }
162
+ if (j >= glob.length) {
163
+ source += "\\[";
164
+ i++;
165
+ continue;
166
+ }
167
+ let content = glob.slice(i + 1, j);
168
+ if (content.startsWith("!")) content = "^" + content.slice(1);
169
+ if (content.startsWith("]") || content.startsWith("^]")) {
170
+ if (content.startsWith("^]")) content = "^\\]" + content.slice(2);
171
+ else content = "\\]" + content.slice(1);
172
+ }
173
+ if (content.startsWith("^") && !content.includes("/")) content = "^/" + content.slice(1);
174
+ source += "[" + content + "]";
175
+ i = j + 1;
176
+ continue;
177
+ }
178
+ source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
179
+ i++;
180
+ }
181
+ return source;
182
+ }
183
+ export function globToRegex(glob: string): RegExp {
184
+ if (glob.startsWith("/")) glob = glob.slice(1);
185
+ return new RegExp(`^${globPartToSource(glob)}$`);
186
+ }
package/src/grep.ts CHANGED
@@ -6,6 +6,7 @@ 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
+ import { globToRegex } from "./glob";
9
10
  import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
10
11
  import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
11
12
  import { toCwd, toDisplayPath } from "./paths";
@@ -152,193 +153,6 @@ function assertSafeRegex(pattern: string): void {
152
153
  }
153
154
  }
154
155
 
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 {
178
- let source = "";
179
- let i = 0;
180
- while (i < glob.length) {
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
- }
188
- if (ch === "*") {
189
- if (glob[i + 1] === "*") {
190
- i += 2;
191
- if (glob[i] === "/") {
192
- i += 1;
193
- source += "(?:.*\\/)?";
194
- } else {
195
- source += ".*";
196
- }
197
- continue;
198
- }
199
- source += ".*";
200
- i += 1;
201
- continue;
202
- }
203
- if (ch === "?") {
204
- source += "[^/]";
205
- i += 1;
206
- continue;
207
- }
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++;
334
- }
335
- return source;
336
- }
337
- function globToRegex(glob: string): RegExp {
338
- if (glob.startsWith("/")) glob = glob.slice(1);
339
- return new RegExp(`^${globPartToSource(glob)}$`);
340
- }
341
-
342
156
  interface FileHit {
343
157
  path: string;
344
158
  displayPath: string;
package/src/utils.ts CHANGED
@@ -20,6 +20,14 @@ export function normalizeAnchors(record: Record<string, unknown>): void {
20
20
  record.remove_to = record.replace_to;
21
21
  delete record.replace_to;
22
22
  }
23
+ if (typeof record.remove_from !== "string" && typeof record.from === "string") {
24
+ record.remove_from = record.from;
25
+ delete record.from;
26
+ }
27
+ if (typeof record.remove_to !== "string" && typeof record.to === "string") {
28
+ record.remove_to = record.to;
29
+ delete record.to;
30
+ }
23
31
  }
24
32
 
25
33
  export function normalizeRequest(input: unknown): unknown {