@d3ara1n/pi-hashline-edit 0.1.1 → 0.2.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.
- package/README.md +71 -15
- package/package.json +1 -1
- package/src/core/apply.test.ts +177 -98
- package/src/core/apply.ts +161 -66
- package/src/core/hash.test.ts +16 -11
- package/src/core/hash.ts +26 -57
- package/src/core/index.ts +1 -5
- package/src/core/lines.test.ts +30 -0
- package/src/core/lines.ts +42 -0
- package/src/core/types.ts +52 -36
- package/src/index.ts +9 -10
- package/src/pi/config.ts +7 -1
- package/src/pi/edit-tool.ts +184 -118
- package/src/pi/execute.test.ts +193 -47
- package/src/pi/grep-tool.ts +335 -0
- package/src/pi/pi.test.ts +6 -18
- package/src/pi/read-tool.ts +27 -17
- package/src/pi/state.ts +5 -59
- package/src/core/diff.test.ts +0 -21
- package/src/core/diff.ts +0 -41
- package/src/core/parse.test.ts +0 -117
- package/src/core/parse.ts +0 -166
- package/src/core/snapshot.test.ts +0 -60
- package/src/core/snapshot.ts +0 -84
package/src/pi/execute.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* pi integration execute tests: drive the real makeReadOverride/makeEditOverride
|
|
3
|
+
* execute, covering text read with anchors, the hashline edit round-trip,
|
|
4
|
+
* chained edits via returned anchors, and error returns with isError.
|
|
5
5
|
*/
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import assert from "node:assert/strict";
|
|
@@ -10,26 +10,36 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { makeEditOverride } from "./edit-tool.ts";
|
|
12
12
|
import { makeReadOverride } from "./read-tool.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { computeLineHash } from "../core/hash.ts";
|
|
14
|
+
import { splitLines } from "../core/lines.ts";
|
|
14
15
|
|
|
15
16
|
async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
16
17
|
const dir = await mkdtemp(join(tmpdir(), "hl-"));
|
|
17
|
-
clearSnapshots();
|
|
18
18
|
try {
|
|
19
19
|
return await fn(dir);
|
|
20
20
|
} finally {
|
|
21
21
|
await rm(dir, { recursive: true, force: true });
|
|
22
|
-
clearSnapshots();
|
|
23
22
|
}
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
|
|
27
26
|
|
|
27
|
+
/** Anchor a model would copy from read output for `line` of `text` (1-based). */
|
|
28
|
+
function h(text: string, line: number) {
|
|
29
|
+
return { line, hash: computeLineHash(line, splitLines(text)[line - 1]) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Extract a `LINE#HASH` anchor from a read/edit result text block. */
|
|
33
|
+
function anchorLine(block: string, line: number) {
|
|
34
|
+
const m = new RegExp(`^${line}#([0-9A-Z]+)│`, "m").exec(block);
|
|
35
|
+
if (!m) throw new Error(`line ${line} anchor not found in block`);
|
|
36
|
+
return { line, hash: m[1] };
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
test("read execute: text outputs LINE#HASH│content", async () => {
|
|
29
40
|
await withDir(async (dir) => {
|
|
30
41
|
await writeFile(join(dir, "f.txt"), "line1\nline2\n");
|
|
31
|
-
const
|
|
32
|
-
const r: any = await call(read, { path: "f.txt" });
|
|
42
|
+
const r: any = await call(makeReadOverride(dir), { path: "f.txt" });
|
|
33
43
|
const text = r.content[0];
|
|
34
44
|
assert.equal(text.type, "text");
|
|
35
45
|
assert.match(text.text, /1#[0-9A-Z]+│line1/);
|
|
@@ -38,88 +48,224 @@ test("read execute: text outputs LINE#HASH│content", async () => {
|
|
|
38
48
|
});
|
|
39
49
|
});
|
|
40
50
|
|
|
41
|
-
test("
|
|
51
|
+
test("edit execute: hashline round-trip (read → edit → file changed)", async () => {
|
|
42
52
|
await withDir(async (dir) => {
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
await
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
const f = join(dir, "f.txt");
|
|
54
|
+
const text = "a\nb\nc\n";
|
|
55
|
+
await writeFile(f, text);
|
|
56
|
+
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
57
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
58
|
+
path: "f.txt",
|
|
59
|
+
edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
|
|
60
|
+
});
|
|
61
|
+
assert.equal(r.isError, undefined, "should not be an error");
|
|
62
|
+
assert.equal(await readFile(f, "utf-8"), "a\nB\nc\n");
|
|
49
63
|
});
|
|
50
64
|
});
|
|
51
65
|
|
|
52
|
-
test("edit execute:
|
|
66
|
+
test("edit execute: multiple ops in one call", async () => {
|
|
53
67
|
await withDir(async (dir) => {
|
|
54
68
|
const f = join(dir, "f.txt");
|
|
55
|
-
|
|
69
|
+
const text = "a\nb\nc\n";
|
|
70
|
+
await writeFile(f, text);
|
|
56
71
|
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
57
|
-
const
|
|
72
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
73
|
+
path: "f.txt",
|
|
74
|
+
edits: [
|
|
75
|
+
{ op: "insert_after", anchor: h(text, 3), body: ["z"] },
|
|
76
|
+
{ op: "replace", anchor: h(text, 1), body: ["A"] },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
assert.equal(r.isError, undefined);
|
|
80
|
+
assert.equal(await readFile(f, "utf-8"), "A\nb\nc\nz\n");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("edit result returns Updated anchors that chain the next edit without a re-read", async () => {
|
|
85
|
+
await withDir(async (dir) => {
|
|
86
|
+
const f = join(dir, "f.txt");
|
|
87
|
+
const text = "a\nb\nc\n";
|
|
88
|
+
await writeFile(f, text);
|
|
58
89
|
const edit = makeEditOverride(dir);
|
|
59
|
-
|
|
90
|
+
// first edit (model cites the read anchor for line 1)
|
|
91
|
+
const r1: any = await call(edit, {
|
|
60
92
|
path: "f.txt",
|
|
61
|
-
|
|
93
|
+
edits: [{ op: "replace", anchor: h(text, 1), body: ["A"] }],
|
|
62
94
|
});
|
|
63
|
-
assert.equal(
|
|
64
|
-
|
|
95
|
+
assert.equal(r1.isError, undefined);
|
|
96
|
+
const out: string = r1.content[0].text;
|
|
97
|
+
assert.match(out, /Updated anchors/);
|
|
98
|
+
// second edit chains on the anchor returned by the first edit — no read in between
|
|
99
|
+
const r2: any = await call(edit, {
|
|
100
|
+
path: "f.txt",
|
|
101
|
+
edits: [{ op: "replace", anchor: anchorLine(out, 1), body: ["AA"] }],
|
|
102
|
+
});
|
|
103
|
+
assert.equal(r2.isError, undefined);
|
|
104
|
+
assert.equal(await readFile(f, "utf-8"), "AA\nb\nc\n");
|
|
65
105
|
});
|
|
66
106
|
});
|
|
67
107
|
|
|
68
|
-
test("edit
|
|
108
|
+
test("edit result anchors cover an inserted block (chain an edit inside it)", async () => {
|
|
69
109
|
await withDir(async (dir) => {
|
|
70
110
|
const f = join(dir, "f.txt");
|
|
71
|
-
|
|
72
|
-
|
|
111
|
+
const text = "a\nb\n";
|
|
112
|
+
await writeFile(f, text);
|
|
73
113
|
const edit = makeEditOverride(dir);
|
|
74
|
-
await call(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
114
|
+
const r1: any = await call(edit, {
|
|
115
|
+
path: "f.txt",
|
|
116
|
+
edits: [{ op: "insert_after", anchor: h(text, 2), body: ["c", "d", "e"] }],
|
|
117
|
+
});
|
|
118
|
+
assert.equal(r1.isError, undefined);
|
|
119
|
+
const out: string = r1.content[0].text;
|
|
120
|
+
// line 4 (d, one of the inserted lines) must be anchored in the result
|
|
121
|
+
const a4 = anchorLine(out, 4);
|
|
122
|
+
const r2: any = await call(edit, {
|
|
123
|
+
path: "f.txt",
|
|
124
|
+
edits: [{ op: "replace", anchor: a4, body: ["DD"] }],
|
|
125
|
+
});
|
|
126
|
+
assert.equal(r2.isError, undefined);
|
|
127
|
+
assert.equal(await readFile(f, "utf-8"), "a\nb\nc\nDD\ne\n");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("unrelated external change does NOT block an edit on a stable line", async () => {
|
|
132
|
+
await withDir(async (dir) => {
|
|
133
|
+
const f = join(dir, "f.txt");
|
|
134
|
+
const text = "a\nb\nc\n";
|
|
135
|
+
await writeFile(f, text);
|
|
136
|
+
// simulate an external change at line 3 between read and edit
|
|
137
|
+
await writeFile(f, "a\nb\nCHANGED\n");
|
|
138
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
139
|
+
path: "f.txt",
|
|
140
|
+
edits: [{ op: "replace", anchor: h(text, 1), body: ["A"] }],
|
|
141
|
+
});
|
|
80
142
|
assert.equal(r.isError, undefined);
|
|
81
|
-
assert.equal(await readFile(f, "utf-8"), "A\
|
|
143
|
+
assert.equal(await readFile(f, "utf-8"), "A\nb\nCHANGED\n");
|
|
82
144
|
});
|
|
83
145
|
});
|
|
84
146
|
|
|
85
|
-
test("edit
|
|
147
|
+
test("edit on a line that changed externally → anchor mismatch", async () => {
|
|
148
|
+
await withDir(async (dir) => {
|
|
149
|
+
const f = join(dir, "f.txt");
|
|
150
|
+
const text = "a\nb\nc\n";
|
|
151
|
+
await writeFile(f, text);
|
|
152
|
+
await writeFile(f, "a\nBCHANGED\nc\n"); // line 2 changed
|
|
153
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
154
|
+
path: "f.txt",
|
|
155
|
+
edits: [{ op: "replace", anchor: h(text, 2), body: ["x"] }],
|
|
156
|
+
});
|
|
157
|
+
assert.equal(r.isError, true);
|
|
158
|
+
assert.match(r.content[0].text, /anchor|re-read/i);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("edit execute: no read before edit → anchor verification fails", async () => {
|
|
86
163
|
await withDir(async (dir) => {
|
|
87
164
|
await writeFile(join(dir, "f.txt"), "a\nb\n");
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
165
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
166
|
+
path: "f.txt",
|
|
167
|
+
edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
|
|
168
|
+
});
|
|
91
169
|
assert.equal(r.isError, true);
|
|
92
170
|
});
|
|
93
171
|
});
|
|
94
172
|
|
|
95
|
-
test("edit execute:
|
|
173
|
+
test("edit execute: empty edits → isError", async () => {
|
|
96
174
|
await withDir(async (dir) => {
|
|
97
175
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
98
|
-
const r: any = await call(makeEditOverride(dir), { path: "f.txt" });
|
|
176
|
+
const r: any = await call(makeEditOverride(dir), { path: "f.txt", edits: [] });
|
|
99
177
|
assert.equal(r.isError, true);
|
|
100
|
-
assert.match(r.content[0].text, /missing/);
|
|
178
|
+
assert.match(r.content[0].text, /empty|missing/i);
|
|
101
179
|
});
|
|
102
180
|
});
|
|
103
181
|
|
|
104
|
-
test("edit execute:
|
|
182
|
+
test("edit execute: malformed op (replace without body) → isError", async () => {
|
|
105
183
|
await withDir(async (dir) => {
|
|
106
184
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
107
185
|
const r: any = await call(makeEditOverride(dir), {
|
|
108
186
|
path: "f.txt",
|
|
109
|
-
edits: [{
|
|
187
|
+
edits: [{ op: "replace", anchor: { line: 1, hash: "XX" } }],
|
|
110
188
|
});
|
|
111
189
|
assert.equal(r.isError, true);
|
|
112
|
-
assert.match(r.content[0].text, /
|
|
113
|
-
assert.match(r.content[0].text, /ONLY/);
|
|
190
|
+
assert.match(r.content[0].text, /body/i);
|
|
114
191
|
});
|
|
115
192
|
});
|
|
116
193
|
|
|
117
|
-
test("edit execute:
|
|
194
|
+
test("edit execute: delete op", async () => {
|
|
118
195
|
await withDir(async (dir) => {
|
|
119
|
-
|
|
196
|
+
const f = join(dir, "f.txt");
|
|
197
|
+
const text = "a\nb\nc\n";
|
|
198
|
+
await writeFile(f, text);
|
|
120
199
|
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
121
|
-
const r: any = await call(makeEditOverride(dir), {
|
|
200
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
201
|
+
path: "f.txt",
|
|
202
|
+
edits: [{ op: "delete", anchor: h(text, 2) }],
|
|
203
|
+
});
|
|
204
|
+
assert.equal(r.isError, undefined);
|
|
205
|
+
assert.equal(await readFile(f, "utf-8"), "a\nc\n");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// --- renderer regression guards (details.diff must be a string, renderResult must not throw) ---
|
|
210
|
+
|
|
211
|
+
const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
|
|
212
|
+
|
|
213
|
+
test("edit success: details.diff is a string (not the generateDiffString object)", async () => {
|
|
214
|
+
await withDir(async (dir) => {
|
|
215
|
+
const f = join(dir, "f.txt");
|
|
216
|
+
const text = "a\nb\nc\n";
|
|
217
|
+
await writeFile(f, text);
|
|
218
|
+
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
219
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
220
|
+
path: "f.txt",
|
|
221
|
+
edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
|
|
222
|
+
});
|
|
223
|
+
assert.equal(typeof r.details.diff, "string", "details.diff must be a string");
|
|
224
|
+
assert.equal(typeof r.details.patch, "string");
|
|
225
|
+
assert.equal(typeof r.details.firstChangedLine, "number");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("edit success: renderResult renders the diff without throwing", async () => {
|
|
230
|
+
await withDir(async (dir) => {
|
|
231
|
+
const f = join(dir, "f.txt");
|
|
232
|
+
const text = "a\nb\nc\n";
|
|
233
|
+
await writeFile(f, text);
|
|
234
|
+
const edit = makeEditOverride(dir);
|
|
235
|
+
const r: any = await call(edit, {
|
|
236
|
+
path: "f.txt",
|
|
237
|
+
edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
|
|
238
|
+
});
|
|
239
|
+
// @ts-ignore — drive the renderer with a stub theme
|
|
240
|
+
const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, { isError: r.isError ?? false });
|
|
241
|
+
assert.ok(typeof comp?.text === "string");
|
|
242
|
+
assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("edit error: renderResult renders the error line without throwing", async () => {
|
|
247
|
+
await withDir(async (dir) => {
|
|
248
|
+
await writeFile(join(dir, "f.txt"), "a\n");
|
|
249
|
+
const edit = makeEditOverride(dir);
|
|
250
|
+
const r: any = await call(edit, {
|
|
251
|
+
path: "f.txt",
|
|
252
|
+
edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
|
|
253
|
+
});
|
|
122
254
|
assert.equal(r.isError, true);
|
|
123
|
-
|
|
255
|
+
// @ts-ignore
|
|
256
|
+
const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: false }, stubTheme, { isError: r.isError ?? false });
|
|
257
|
+
assert.ok(typeof comp?.text === "string");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("hash length stays 4 even for runs of identical lines (no explosion)", async () => {
|
|
262
|
+
await withDir(async (dir) => {
|
|
263
|
+
const f = join(dir, "f.txt");
|
|
264
|
+
await writeFile(f, "\n\n\n\ncode\n");
|
|
265
|
+
const r: any = await call(makeReadOverride(dir), { path: "f.txt" });
|
|
266
|
+
const text: string = r.content[0].text;
|
|
267
|
+
for (const m of text.matchAll(/\d+#([0-9A-Z]+)│/g)) {
|
|
268
|
+
assert.equal(m[1].length, 4, `anchor ${m[0]} hash is not 4 chars`);
|
|
269
|
+
}
|
|
124
270
|
});
|
|
125
271
|
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Override grep: search results carry `LINE#HASH│` anchors (same format as
|
|
3
|
+
* read), grouped by file. The model can copy `LINE#HASH` straight into an edit
|
|
4
|
+
* anchor — no re-read needed. Context lines (`-C`) are anchored too.
|
|
5
|
+
*
|
|
6
|
+
* We run ripgrep directly (`--json`) rather than wrap the built-in grep, so we
|
|
7
|
+
* control formatting and can compute each line's hash from its FULL content
|
|
8
|
+
* while displaying a truncated copy. (The built-in grep truncates long lines
|
|
9
|
+
* before formatting; hashing that truncated text would not match what edit
|
|
10
|
+
* verifies against the full line — so the hash must be computed from the full
|
|
11
|
+
* content, independently of what is displayed.)
|
|
12
|
+
*
|
|
13
|
+
* Falls back to the built-in grep when: hashline disabled, aborted, or ripgrep
|
|
14
|
+
* cannot be located.
|
|
15
|
+
*
|
|
16
|
+
* @module pi-hashline-edit/pi
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createGrepTool,
|
|
21
|
+
truncateHead,
|
|
22
|
+
truncateLine,
|
|
23
|
+
formatSize,
|
|
24
|
+
DEFAULT_MAX_BYTES,
|
|
25
|
+
} from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { createInterface } from "node:readline";
|
|
29
|
+
import { access, constants, readFile, stat } from "node:fs/promises";
|
|
30
|
+
import { basename, delimiter, join, relative } from "node:path";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { hashFileLines } from "../core/hash.ts";
|
|
33
|
+
import { splitLines } from "../core/lines.ts";
|
|
34
|
+
import { getState } from "./state.ts";
|
|
35
|
+
import { canonicalPath } from "./read-tool.ts";
|
|
36
|
+
|
|
37
|
+
const DEFAULT_LIMIT = 100;
|
|
38
|
+
/** Max chars per result line for display (mirrors pi's truncate.ts; not exported there). */
|
|
39
|
+
const GREP_MAX_LINE_LENGTH = 500;
|
|
40
|
+
|
|
41
|
+
/** Locate ripgrep: pi's bundled bin first, then PATH. Returns null if not found. */
|
|
42
|
+
async function findRg(): Promise<string | null> {
|
|
43
|
+
const agentDir = process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
44
|
+
const piRg = join(agentDir, "bin", "rg");
|
|
45
|
+
try {
|
|
46
|
+
await access(piRg, constants.X_OK);
|
|
47
|
+
return piRg;
|
|
48
|
+
} catch {}
|
|
49
|
+
for (const dir of process.env.PATH?.split(delimiter) ?? []) {
|
|
50
|
+
if (!dir) continue;
|
|
51
|
+
const p = join(dir, "rg");
|
|
52
|
+
try {
|
|
53
|
+
await access(p, constants.X_OK);
|
|
54
|
+
return p;
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface RawMatch {
|
|
61
|
+
filePath: string;
|
|
62
|
+
lineNumber: number;
|
|
63
|
+
match: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Convert the anchored grep output (grouped, `LINE#HASH│`) into a human-readable
|
|
68
|
+
* form for the TUI: drop the hash, keep file headers and line numbers. The model
|
|
69
|
+
* still receives the anchored `content` text; this only affects what the user sees.
|
|
70
|
+
*/
|
|
71
|
+
function toDisplayLines(raw: string, theme: any): string[] {
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
for (const line of raw.split("\n")) {
|
|
74
|
+
// anchored line first: "lineNo#HASH│content" → " lineNo: content"
|
|
75
|
+
const a = line.match(/^(\d+)#[A-Za-z0-9]+│(.*)$/);
|
|
76
|
+
if (a) {
|
|
77
|
+
out.push(theme.fg("dim", ` ${a[1]}:`) + theme.fg("toolOutput", ` ${a[2]}`));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// file header: "path · N match(es)" → path accent, count dim
|
|
81
|
+
const h = line.match(/^(.+?) · (\d+ match(?:es)?)$/);
|
|
82
|
+
if (h) {
|
|
83
|
+
out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// truncation notice block "[...]"
|
|
87
|
+
if (line.startsWith("[")) {
|
|
88
|
+
out.push(theme.fg("warning", line));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
out.push(theme.fg("toolOutput", line));
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Build the grep override (a ToolDefinition fragment for registerTool). */
|
|
97
|
+
export function makeGrepOverride(cwd: string) {
|
|
98
|
+
const builtin = createGrepTool(cwd);
|
|
99
|
+
const delegate = (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) =>
|
|
100
|
+
builtin.execute(toolCallId, params, signal, onUpdate);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
name: "grep" as const,
|
|
104
|
+
label: "grep",
|
|
105
|
+
description:
|
|
106
|
+
"Search file contents for a pattern. Matches show per-line content hashes (LINE#HASH│content) grouped by file — copy LINE#HASH straight into an edit anchor, no re-read needed. Context lines (context) are anchored too. Respects .gitignore.",
|
|
107
|
+
promptSnippet: "Search file contents; results show LINE#HASH anchors usable directly in edit (no re-read needed)",
|
|
108
|
+
promptGuidelines: [
|
|
109
|
+
"Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
|
|
110
|
+
"Copy `LINE#HASH` straight into an edit `anchor`/`end` — no re-read needed. Context lines (from `context`) are anchored and editable too.",
|
|
111
|
+
"Pass `pattern`; optionally `path`, `glob`, `ignoreCase`, `literal`, `context` (lines before+after each match), `limit` (max matches, default 100).",
|
|
112
|
+
],
|
|
113
|
+
parameters: builtin.parameters,
|
|
114
|
+
|
|
115
|
+
renderShell: "default" as const,
|
|
116
|
+
|
|
117
|
+
renderCall(args: any, theme: any) {
|
|
118
|
+
const pattern = args?.pattern ?? "";
|
|
119
|
+
const p = args?.path ?? ".";
|
|
120
|
+
let text =
|
|
121
|
+
theme.fg("toolTitle", theme.bold("grep ")) +
|
|
122
|
+
theme.fg("accent", `/${pattern}/`) +
|
|
123
|
+
theme.fg("toolOutput", ` in ${p}`);
|
|
124
|
+
if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
|
|
125
|
+
if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
|
|
126
|
+
return new Text(text, 0, 0);
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
|
|
130
|
+
if (isPartial) return new Text(theme.fg("warning", "Searching…"), 0, 0);
|
|
131
|
+
if (context?.isError) {
|
|
132
|
+
const t = result.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "Error";
|
|
133
|
+
return new Text(theme.fg("error", t), 0, 0);
|
|
134
|
+
}
|
|
135
|
+
const out = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
|
136
|
+
const styled = toDisplayLines(out, theme);
|
|
137
|
+
const maxLines = expanded ? styled.length : 15;
|
|
138
|
+
const shown = styled.slice(0, maxLines);
|
|
139
|
+
const more =
|
|
140
|
+
!expanded && styled.length > maxLines
|
|
141
|
+
? `\n${theme.fg("muted", `… (${styled.length - maxLines} more lines)`)}`
|
|
142
|
+
: "";
|
|
143
|
+
return new Text(shown.join("\n") + more, 0, 0);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any): Promise<any> {
|
|
147
|
+
const state = getState();
|
|
148
|
+
// disabled or already aborted → built-in grep (it handles abort itself)
|
|
149
|
+
if (!state.config.enabled || signal?.aborted) return delegate(toolCallId, params, signal, onUpdate);
|
|
150
|
+
|
|
151
|
+
const rgPath = await findRg();
|
|
152
|
+
// ripgrep unavailable → degrade to the built-in (which can auto-download rg)
|
|
153
|
+
if (!rgPath) return delegate(toolCallId, params, signal, onUpdate);
|
|
154
|
+
|
|
155
|
+
const { pattern, path: searchDir, glob, ignoreCase, literal, context, limit } = params;
|
|
156
|
+
const searchPath = canonicalPath(cwd, searchDir || ".");
|
|
157
|
+
const hashLen = state.config.hashLen;
|
|
158
|
+
|
|
159
|
+
let isDir = true;
|
|
160
|
+
try {
|
|
161
|
+
isDir = (await stat(searchPath)).isDirectory();
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error(`Path not found: ${searchPath}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return new Promise((resolvePromise, reject) => {
|
|
167
|
+
if (signal?.aborted) {
|
|
168
|
+
reject(new Error("Operation aborted"));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const args = ["--json", "--line-number", "--color=never", "--hidden"];
|
|
173
|
+
if (ignoreCase) args.push("--ignore-case");
|
|
174
|
+
if (literal) args.push("--fixed-strings");
|
|
175
|
+
if (glob) args.push("--glob", glob);
|
|
176
|
+
const ctx = context && context > 0 ? context : 0;
|
|
177
|
+
if (ctx > 0) args.push("--context", String(ctx));
|
|
178
|
+
args.push("--", String(pattern), searchPath);
|
|
179
|
+
|
|
180
|
+
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
|
|
181
|
+
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
182
|
+
const rl = createInterface({ input: child.stdout });
|
|
183
|
+
let stderr = "";
|
|
184
|
+
let matchCount = 0;
|
|
185
|
+
let matchLimitReached = false;
|
|
186
|
+
let linesTruncated = false;
|
|
187
|
+
let aborted = false;
|
|
188
|
+
let killedDueToLimit = false;
|
|
189
|
+
const raw: RawMatch[] = [];
|
|
190
|
+
|
|
191
|
+
const cleanup = () => {
|
|
192
|
+
rl.close();
|
|
193
|
+
signal?.removeEventListener("abort", onAbort);
|
|
194
|
+
};
|
|
195
|
+
const stopChild = (dueToLimit = false) => {
|
|
196
|
+
if (!child.killed) {
|
|
197
|
+
killedDueToLimit = dueToLimit;
|
|
198
|
+
child.kill();
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const onAbort = () => {
|
|
202
|
+
aborted = true;
|
|
203
|
+
stopChild();
|
|
204
|
+
};
|
|
205
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
206
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
207
|
+
stderr += chunk.toString();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
rl.on("line", (line: string) => {
|
|
211
|
+
if (!line.trim() || matchCount >= effectiveLimit) return;
|
|
212
|
+
let event: any;
|
|
213
|
+
try {
|
|
214
|
+
event = JSON.parse(line);
|
|
215
|
+
} catch {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (event.type === "match") {
|
|
219
|
+
matchCount++;
|
|
220
|
+
const filePath = event.data?.path?.text;
|
|
221
|
+
const lineNumber = event.data?.line_number;
|
|
222
|
+
if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: true });
|
|
223
|
+
if (matchCount >= effectiveLimit) {
|
|
224
|
+
matchLimitReached = true;
|
|
225
|
+
stopChild(true);
|
|
226
|
+
}
|
|
227
|
+
} else if (event.type === "context") {
|
|
228
|
+
const filePath = event.data?.path?.text;
|
|
229
|
+
const lineNumber = event.data?.line_number;
|
|
230
|
+
if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: false });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
child.on("error", (error) => {
|
|
235
|
+
cleanup();
|
|
236
|
+
reject(new Error(`Failed to run ripgrep: ${error.message}`));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
child.on("close", async (code) => {
|
|
240
|
+
cleanup();
|
|
241
|
+
if (aborted) {
|
|
242
|
+
reject(new Error("Operation aborted"));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!killedDueToLimit && code !== 0 && code !== 1) {
|
|
246
|
+
reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (raw.length === 0) {
|
|
250
|
+
resolvePromise({ content: [{ type: "text", text: "No matches found" }], details: undefined });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Dedupe by (file, line); a line that is both a match and a context line counts as a match.
|
|
255
|
+
const map = new Map<string, RawMatch>();
|
|
256
|
+
for (const m of raw) {
|
|
257
|
+
const key = `${m.filePath}:${m.lineNumber}`;
|
|
258
|
+
const prev = map.get(key);
|
|
259
|
+
if (!prev || (!prev.match && m.match)) map.set(key, m);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Group by file, each group sorted by line number.
|
|
263
|
+
const byFile = new Map<string, RawMatch[]>();
|
|
264
|
+
for (const m of map.values()) {
|
|
265
|
+
const arr = byFile.get(m.filePath) ?? [];
|
|
266
|
+
arr.push(m);
|
|
267
|
+
byFile.set(m.filePath, arr);
|
|
268
|
+
}
|
|
269
|
+
for (const arr of byFile.values()) arr.sort((a, b) => a.lineNumber - b.lineNumber);
|
|
270
|
+
|
|
271
|
+
// Read each file once and hash all its lines; hash is computed from the FULL line.
|
|
272
|
+
const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
|
|
273
|
+
const getFile = async (fp: string) => {
|
|
274
|
+
let entry = fileCache.get(fp);
|
|
275
|
+
if (!entry) {
|
|
276
|
+
let content = "";
|
|
277
|
+
try {
|
|
278
|
+
content = (await readFile(fp)).toString("utf-8");
|
|
279
|
+
} catch {
|
|
280
|
+
content = "";
|
|
281
|
+
}
|
|
282
|
+
const lines = splitLines(content);
|
|
283
|
+
entry = { lines, hashes: hashFileLines(lines, hashLen) };
|
|
284
|
+
fileCache.set(fp, entry);
|
|
285
|
+
}
|
|
286
|
+
return entry;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const formatPath = (fp: string): string => {
|
|
290
|
+
if (isDir) {
|
|
291
|
+
const rel = relative(searchPath, fp).replace(/\\/g, "/");
|
|
292
|
+
if (rel && !rel.startsWith("..")) return rel;
|
|
293
|
+
}
|
|
294
|
+
return basename(fp);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const blocks: string[] = [];
|
|
298
|
+
for (const [fp, matches] of byFile) {
|
|
299
|
+
const { lines, hashes } = await getFile(fp);
|
|
300
|
+
const n = matches.filter((m) => m.match).length;
|
|
301
|
+
const header = `${formatPath(fp)} · ${n} match${n !== 1 ? "es" : ""}`;
|
|
302
|
+
const rows: string[] = [];
|
|
303
|
+
for (const m of matches) {
|
|
304
|
+
const content = lines[m.lineNumber - 1] ?? "";
|
|
305
|
+
const hash = hashes[m.lineNumber - 1] ?? "";
|
|
306
|
+
const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
|
|
307
|
+
if (wasTruncated) linesTruncated = true;
|
|
308
|
+
rows.push(`${m.lineNumber}#${hash}│${disp}`);
|
|
309
|
+
}
|
|
310
|
+
blocks.push(`${header}\n${rows.join("\n")}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let output = blocks.join("\n\n");
|
|
314
|
+
const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
|
|
315
|
+
output = truncation.content;
|
|
316
|
+
|
|
317
|
+
const notices: string[] = [];
|
|
318
|
+
if (matchLimitReached)
|
|
319
|
+
notices.push(
|
|
320
|
+
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
|
321
|
+
);
|
|
322
|
+
if (truncation.truncated) notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
323
|
+
if (linesTruncated)
|
|
324
|
+
notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read to see full lines`);
|
|
325
|
+
if (notices.length) output += `\n\n[${notices.join(". ")}]`;
|
|
326
|
+
|
|
327
|
+
resolvePromise({
|
|
328
|
+
content: [{ type: "text" as const, text: output }],
|
|
329
|
+
details: undefined,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|