pi-hashline-edit-pro 4.3.2 → 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 +8 -5
- package/index.ts +8 -3
- package/package.json +1 -1
- package/prompts/grep-guidelines.md +0 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +1 -2
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +2 -3
- package/prompts/replace-guidelines.md +4 -4
- package/prompts/replace.md +1 -1
- package/src/auto-read-all-state.ts +13 -0
- package/src/auto-read-all.ts +79 -27
- package/src/batch.ts +23 -32
- package/src/config-ui.ts +79 -7
- package/src/config.ts +35 -0
- package/src/edit-common.ts +25 -7
- package/src/file-reader.ts +2 -3
- package/src/glob.ts +186 -0
- package/src/grep.ts +11 -41
- package/src/hash-store.ts +21 -7
- package/src/hashline/apply.ts +1 -1
- package/src/hashline/hash.ts +3 -15
- package/src/hashline/hasher.ts +15 -1
- package/src/insert.ts +1 -1
- package/src/paths.ts +5 -1
- package/src/payload-contract.ts +2 -2
- package/src/read.ts +20 -1
- package/src/replace-undo.ts +10 -4
- package/src/replace.ts +2 -3
- package/src/utils.ts +9 -1
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/files", hint: "Extra folders, files, or globs 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
|
-
|
|
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
|
-
|
|
208
|
+
const footer = this.editingIgnore ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit list · 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
|
+
}
|
package/src/edit-common.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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));
|
package/src/file-reader.ts
CHANGED
|
@@ -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 =
|
|
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/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
|
@@ -2,16 +2,17 @@ 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,
|
|
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
|
+
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
|
-
import { toCwd } from "./paths";
|
|
12
|
+
import { toCwd, toDisplayPath } from "./paths";
|
|
12
13
|
import { loadP, loadGuide } from "./prompts";
|
|
13
14
|
import { normReq } from "./payload-contract";
|
|
14
|
-
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
15
|
+
import { abortIf, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
15
16
|
import { withAnchorSession } from "./anchor-registry";
|
|
16
17
|
import { serveRows } from "./served";
|
|
17
18
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -152,34 +153,6 @@ function assertSafeRegex(pattern: string): void {
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
function globToRegex(glob: string): RegExp {
|
|
156
|
-
if (glob.startsWith("/")) glob = glob.slice(1);
|
|
157
|
-
let source = "";
|
|
158
|
-
let i = 0;
|
|
159
|
-
while (i < glob.length) {
|
|
160
|
-
const ch = glob[i]!;
|
|
161
|
-
if (ch === "*") {
|
|
162
|
-
if (glob[i + 1] === "*") {
|
|
163
|
-
i += 2;
|
|
164
|
-
if (glob[i] === "/") {
|
|
165
|
-
i += 1;
|
|
166
|
-
source += "(?:.*\\/)?";
|
|
167
|
-
} else {
|
|
168
|
-
source += ".*";
|
|
169
|
-
}
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
source += ".*";
|
|
173
|
-
} else if (ch === "?") {
|
|
174
|
-
source += "[^/]";
|
|
175
|
-
} else {
|
|
176
|
-
source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
177
|
-
}
|
|
178
|
-
i += 1;
|
|
179
|
-
}
|
|
180
|
-
return new RegExp(`^${source}$`);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
156
|
interface FileHit {
|
|
184
157
|
path: string;
|
|
185
158
|
displayPath: string;
|
|
@@ -401,14 +374,11 @@ async function collectRgMatches(
|
|
|
401
374
|
});
|
|
402
375
|
}
|
|
403
376
|
|
|
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
377
|
|
|
410
378
|
function displayRowsForHit(hit: FileHit): string[] {
|
|
411
|
-
|
|
379
|
+
let max = 0;
|
|
380
|
+
for (const n of hit.lineNumbers) if (n > max) max = n;
|
|
381
|
+
const width = gutterWidth(max, 1);
|
|
412
382
|
return hit.rows.map((row, i) => {
|
|
413
383
|
const n = hit.lineNumbers[i]!;
|
|
414
384
|
const padded = String(n).padStart(width, " ");
|
|
@@ -571,8 +541,8 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
571
541
|
const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
|
|
572
542
|
const matchesGlob = (absPath: string): boolean => {
|
|
573
543
|
if (globRegex === undefined) return true;
|
|
574
|
-
const displayPath =
|
|
575
|
-
const globPath =
|
|
544
|
+
const displayPath = toDisplayPath(ctx.cwd, absPath);
|
|
545
|
+
const globPath = toDisplayPath(globRoot, absPath);
|
|
576
546
|
return globRegex.test(globPath) || globRegex.test(displayPath);
|
|
577
547
|
};
|
|
578
548
|
const validatedRegex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
|
|
@@ -613,7 +583,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
613
583
|
if (!matchesGlob(absPath)) continue;
|
|
614
584
|
const norm = await readGrepFileShadow(absPath);
|
|
615
585
|
if (!norm) continue;
|
|
616
|
-
const hit = makeHitFromIndices(norm,
|
|
586
|
+
const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), indices, context, validatedRegex, totalForFile, indices.length);
|
|
617
587
|
const display = displayRowsForHit(hit);
|
|
618
588
|
totalRows += display.length;
|
|
619
589
|
for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
|
|
@@ -635,7 +605,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
635
605
|
if (!matchesGlob(absPath)) continue;
|
|
636
606
|
const norm = await readGrepFile(absPath);
|
|
637
607
|
if (!norm) continue;
|
|
638
|
-
const hit = makeHitFromIndices(norm,
|
|
608
|
+
const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
|
|
639
609
|
const display = displayRowsForHit(hit);
|
|
640
610
|
const keptRows: string[] = [];
|
|
641
611
|
const keptHashes: string[] = [];
|