@d3ara1n/pi-hashline-edit 0.3.3 → 0.4.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 +16 -0
- package/package.json +1 -1
- package/src/pi/config.ts +1 -7
- package/src/pi/edit-tool.ts +16 -17
- package/src/pi/execute.test.ts +37 -2
- package/src/pi/grep-tool.ts +2 -2
- package/src/pi/render.ts +60 -1
- package/src/pi/replace-tool.ts +16 -17
- package/src/pi/replace.test.ts +6 -1
package/README.md
CHANGED
|
@@ -24,6 +24,22 @@ Routine local code editing in pi — the common case. If you spend turns fightin
|
|
|
24
24
|
|
|
25
25
|
Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project. All four tools — `read`, `grep`, `edit`, `replace` — are one set governed by this switch: when disabled, `read`/`grep`/`edit` delegate to the built-ins and `replace` refuses (it has no built-in counterpart).
|
|
26
26
|
|
|
27
|
+
## Model compatibility — field notes
|
|
28
|
+
|
|
29
|
+
Real-world reliability depends on the model more than on anything else. Field observations from real sessions (June 2026, single environment — directional, not benchmarks; vendors iterate fast, re-test on new releases):
|
|
30
|
+
|
|
31
|
+
| Model profile | Tested example | Built-in string-replace | hashline-edit | Recommendation |
|
|
32
|
+
|---|---|---|---|---|
|
|
33
|
+
| Weak tool-call construction | DeepSeek V4 Flash | ~50% of edits fail; each fix takes several more `edit` rounds | ~80% of edits fail, but each failure converges in **one** retry | Keep hashline on — fewer total round-trips despite the higher failure rate |
|
|
34
|
+
| Strong, but not trained on hashline | Kimi K3 | Excellent | Frequent anchor mistakes | Turn the plugin off — the built-in `edit` serves this profile better |
|
|
35
|
+
| Strong, follows the schema as given | GLM 5.2 | Occasional not-found / whitespace friction | 100% — the friction disappears | The intended pairing |
|
|
36
|
+
|
|
37
|
+
What these numbers actually say:
|
|
38
|
+
|
|
39
|
+
- **A high failure rate on weak models is not a hashline problem.** DeepSeek V4 Flash fails at ~50% even on plain string-replace — the root cause is misremembered file content, and no edit protocol fixes that. What changes is the *shape* of a failure: string-replace failures are divergent (the model retries from the same wrong memory, so fixes take multiple rounds), while hashline failures are convergent (a mismatched anchor returns the live content plus a ready-to-resend `LINE#HASH`, so one retry closes the loop without trusting the model's memory).
|
|
40
|
+
- **Strong ≠ automatic win.** Hashline assumes anchor discipline — copy hashes verbatim from read output, never invent one. A model that hasn't internalized that will fabricate anchors no matter how capable it is. If a strong model keeps hitting anchor errors, the fastest fix is disabling the plugin, not more retries.
|
|
41
|
+
- **Some errors are invisible to any edit protocol.** On weak models `insert_after` is sometimes misread as string-replace-style: the model copies the anchor line into `body` (observed on DeepSeek V4 Flash), the toolcall verifies and succeeds, and the line ends up duplicated in the file. Nothing at the tool layer can catch this — the anchor is valid; the model's *intent* was wrong, and no amount of prompt wording cures it (the schema description already forbids the copy). Capable models never needed the wording in the first place: GLM used `insert_after` correctly back when the tool description didn't explain the op at all. The mismatch lives in the model, not the tool.
|
|
42
|
+
|
|
27
43
|
## Gotchas (vs. the built-in `read`/`edit`)
|
|
28
44
|
|
|
29
45
|
Once hashline overrides the built-ins, a few things behave differently:
|
package/package.json
CHANGED
package/src/pi/config.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* @module pi-hashline-edit/pi
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import * as fs from "node:fs";
|
|
10
|
-
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
|
|
13
13
|
export interface HashlineEditConfig {
|
|
@@ -21,12 +21,6 @@ export interface HashlineEditConfig {
|
|
|
21
21
|
|
|
22
22
|
const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4, shiftRadius: 15 };
|
|
23
23
|
|
|
24
|
-
function getAgentDir(): string {
|
|
25
|
-
const envDir = process.env.PI_AGENT_DIR;
|
|
26
|
-
if (envDir) return envDir;
|
|
27
|
-
return path.join(os.homedir(), ".pi", "agent");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
24
|
/** Parse JSON directly without stripping comments (standard JSON forbids comments; on error fall back to default). */
|
|
31
25
|
function readSettings(filePath: string): Record<string, unknown> {
|
|
32
26
|
try {
|
package/src/pi/edit-tool.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { splitLines } from "../core/lines.ts";
|
|
|
28
28
|
import type { ApplyFailure, Edit } from "../core/types.ts";
|
|
29
29
|
import { canonicalPath } from "./read-tool.ts";
|
|
30
30
|
import { getState } from "./state.ts";
|
|
31
|
+
import { formatDiffCounts, publishDiffCounts, renderDiffPreview } from "./render.ts";
|
|
31
32
|
|
|
32
33
|
/** Cap on the number of updated anchors returned inline (bounds token cost for large inserts). */
|
|
33
34
|
const MAX_ANCHOR_LINES = 40;
|
|
@@ -209,12 +210,18 @@ export function makeEditOverride(cwd: string) {
|
|
|
209
210
|
parameters: editSchema,
|
|
210
211
|
renderShell: "default" as const,
|
|
211
212
|
|
|
212
|
-
renderCall(args: Static<typeof editSchema>, theme: any) {
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
renderCall(args: Static<typeof editSchema>, theme: any, context: any) {
|
|
214
|
+
const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
215
|
+
let t = theme.fg("toolTitle", theme.bold("edit "));
|
|
216
|
+
t += theme.fg("accent", args.path);
|
|
215
217
|
const n = args.edits?.length ?? 0;
|
|
216
|
-
if (n)
|
|
217
|
-
|
|
218
|
+
if (n) t += theme.fg("dim", ` — ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
|
|
219
|
+
// diff counts land after execution: renderResult publishes them into
|
|
220
|
+
// context.state and invalidates the row, re-running this renderer
|
|
221
|
+
const counts = context?.state?.diffCounts;
|
|
222
|
+
if (counts && (counts.added || counts.removed)) t += formatDiffCounts(counts, theme);
|
|
223
|
+
text.setText(t);
|
|
224
|
+
return text;
|
|
218
225
|
},
|
|
219
226
|
|
|
220
227
|
renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
|
|
@@ -225,24 +232,16 @@ export function makeEditOverride(cwd: string) {
|
|
|
225
232
|
return new Text(theme.fg("error", t), 0, 0);
|
|
226
233
|
}
|
|
227
234
|
const diff: string | undefined = result.details?.diff;
|
|
235
|
+
publishDiffCounts(diff, context);
|
|
228
236
|
if (!diff) {
|
|
229
237
|
// No net diff (e.g. a successful but non-mutating edit): show only the summary
|
|
230
238
|
// line — content.text also carries `Updated anchors` (hashline) for the model.
|
|
231
239
|
const t = content?.type === "text" ? content.text.split("\n")[0] : "Edited";
|
|
232
240
|
return new Text(theme.fg("success", t), 0, 0);
|
|
233
241
|
}
|
|
234
|
-
// details.diff is pi-format (+N/-N/<space>N content);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const body = shown
|
|
238
|
-
.map((line: string) => {
|
|
239
|
-
if (line.startsWith("+")) return theme.fg("success", line);
|
|
240
|
-
if (line.startsWith("-")) return theme.fg("error", line);
|
|
241
|
-
return theme.fg("dim", line);
|
|
242
|
-
})
|
|
243
|
-
.join("\n");
|
|
244
|
-
const more = !expanded && allLines.length > 24 ? `\n${theme.fg("dim", `… (${allLines.length - 24} more)`)}` : "";
|
|
245
|
-
return new Text(body + more, 0, 0);
|
|
242
|
+
// details.diff is pi-format (+N/-N/<space>N content); renderDiff handles
|
|
243
|
+
// semantic colors plus intra-line change highlighting
|
|
244
|
+
return new Text(renderDiffPreview(diff, expanded, theme), 0, 0);
|
|
246
245
|
},
|
|
247
246
|
|
|
248
247
|
async execute(toolCallId: string, params: Static<typeof editSchema>, signal: AbortSignal | undefined, onUpdate: any) {
|
package/src/pi/execute.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import assert from "node:assert/strict";
|
|
|
8
8
|
import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import { makeEditOverride } from "./edit-tool.ts";
|
|
12
13
|
import { makeReadOverride } from "./read-tool.ts";
|
|
13
14
|
import { computeLineHash } from "../core/hash.ts";
|
|
@@ -215,6 +216,10 @@ test("edit execute: delete op", async () => {
|
|
|
215
216
|
|
|
216
217
|
const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
|
|
217
218
|
|
|
219
|
+
// renderResult delegates to pi's renderDiff, which reads the global TUI theme
|
|
220
|
+
// singleton — initialize it once for this test process (watcher off by default).
|
|
221
|
+
initTheme();
|
|
222
|
+
|
|
218
223
|
test("edit success: details.diff is a string (not the generateDiffString object)", async () => {
|
|
219
224
|
await withDir(async (dir) => {
|
|
220
225
|
const f = join(dir, "f.txt");
|
|
@@ -241,13 +246,43 @@ test("edit success: renderResult renders the diff without throwing", async () =>
|
|
|
241
246
|
path: "f.txt",
|
|
242
247
|
edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
|
|
243
248
|
});
|
|
244
|
-
|
|
245
|
-
const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, { isError: r.isError ?? false });
|
|
249
|
+
// @ts-ignore — drive the renderer with a stub theme
|
|
250
|
+
const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, { isError: r.isError ?? false, state: {}, invalidate: () => {} });
|
|
246
251
|
assert.ok(typeof comp?.text === "string");
|
|
247
252
|
assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");
|
|
248
253
|
});
|
|
249
254
|
});
|
|
250
255
|
|
|
256
|
+
test("edit header: renderResult publishes diff counts, renderCall shows +N -N", async () => {
|
|
257
|
+
await withDir(async (dir) => {
|
|
258
|
+
const f = join(dir, "f.txt");
|
|
259
|
+
const text = "a\nb\nc\nd\ne\n";
|
|
260
|
+
await writeFile(f, text);
|
|
261
|
+
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
262
|
+
const edit = makeEditOverride(dir);
|
|
263
|
+
const r: any = await call(edit, {
|
|
264
|
+
path: "f.txt",
|
|
265
|
+
edits: [
|
|
266
|
+
{ op: "replace", anchor: h(text, 2), end: h(text, 3), body: ["B"] },
|
|
267
|
+
{ op: "insert_after", anchor: h(text, 5), body: ["f", "g"] },
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
const context: any = { isError: false, state: {}, invalidate: () => {} };
|
|
271
|
+
edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, context);
|
|
272
|
+
assert.deepEqual(context.state.diffCounts, { added: 3, removed: 2 });
|
|
273
|
+
const header: any = edit.renderCall(
|
|
274
|
+
{ path: "f.txt", edits: [{ op: "replace" }] },
|
|
275
|
+
stubTheme,
|
|
276
|
+
{ state: context.state },
|
|
277
|
+
);
|
|
278
|
+
assert.ok(header.text.includes("+3"), "header should show added count");
|
|
279
|
+
assert.ok(header.text.includes("-2"), "header should show removed count");
|
|
280
|
+
// without counts in state (streaming, pre-execution), no +N -N in the header
|
|
281
|
+
const plain: any = edit.renderCall({ path: "f.txt", edits: [{ op: "replace" }] }, stubTheme, { state: {} });
|
|
282
|
+
assert.ok(!plain.text.includes("+3"), "pre-execution header must not show counts");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
251
286
|
test("edit error: renderResult renders the error line without throwing", async () => {
|
|
252
287
|
await withDir(async (dir) => {
|
|
253
288
|
await writeFile(join(dir, "f.txt"), "a\n");
|
package/src/pi/grep-tool.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
|
+
getAgentDir,
|
|
20
21
|
createGrepTool,
|
|
21
22
|
truncateHead,
|
|
22
23
|
truncateLine,
|
|
@@ -28,7 +29,6 @@ import { spawn } from "node:child_process";
|
|
|
28
29
|
import { createInterface } from "node:readline";
|
|
29
30
|
import { access, constants, readFile, stat } from "node:fs/promises";
|
|
30
31
|
import { basename, delimiter, join, relative } from "node:path";
|
|
31
|
-
import { homedir } from "node:os";
|
|
32
32
|
import { hashFileLines } from "../core/hash.ts";
|
|
33
33
|
import { splitLines } from "../core/lines.ts";
|
|
34
34
|
import { getState } from "./state.ts";
|
|
@@ -41,7 +41,7 @@ const GREP_MAX_LINE_LENGTH = 500;
|
|
|
41
41
|
|
|
42
42
|
/** Locate ripgrep: pi's bundled bin first, then PATH. Returns null if not found. */
|
|
43
43
|
async function findRg(): Promise<string | null> {
|
|
44
|
-
const agentDir =
|
|
44
|
+
const agentDir = getAgentDir();
|
|
45
45
|
const piRg = join(agentDir, "bin", "rg");
|
|
46
46
|
try {
|
|
47
47
|
await access(piRg, constants.X_OK);
|
package/src/pi/render.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared
|
|
2
|
+
* Shared helpers for the hashline-aware tool renderers.
|
|
3
3
|
*
|
|
4
4
|
* The model-facing `content` text uses the `LINE#HASH│content` anchor format so
|
|
5
5
|
* an anchor can be copied straight into an edit op. The user-facing TUI
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* @module pi-hashline-edit/pi
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { renderDiff } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
12
14
|
const HASHLINE_RE = /^(\d+)#[A-Za-z0-9]+│(.*)$/;
|
|
13
15
|
|
|
14
16
|
export interface HashlineRow {
|
|
@@ -28,3 +30,60 @@ export function parseHashline(line: string): HashlineRow | null {
|
|
|
28
30
|
const m = line.match(HASHLINE_RE);
|
|
29
31
|
return m ? { lineNo: m[1], content: m[2] } : null;
|
|
30
32
|
}
|
|
33
|
+
|
|
34
|
+
/** Max diff lines shown when a result is rendered collapsed. */
|
|
35
|
+
const MAX_COLLAPSED_DIFF_LINES = 24;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a pi-format diff (`+N`/`-N`/` N` content) for the TUI, reusing pi's
|
|
39
|
+
* built-in renderer: semantic diff colors plus intra-line (word-level) change
|
|
40
|
+
* highlighting on single-line modifications. When not expanded, collapse to the
|
|
41
|
+
* first 24 rendered lines with an overflow marker — truncating after rendering
|
|
42
|
+
* keeps `-`/`+` pairs intact so the intra-line highlight never dangles.
|
|
43
|
+
*/
|
|
44
|
+
export function renderDiffPreview(diff: string, expanded: boolean, theme: any): string {
|
|
45
|
+
const rendered = renderDiff(diff);
|
|
46
|
+
if (expanded) return rendered;
|
|
47
|
+
const allLines = rendered.split("\n");
|
|
48
|
+
const more =
|
|
49
|
+
allLines.length > MAX_COLLAPSED_DIFF_LINES
|
|
50
|
+
? `\n${theme.fg("dim", `… (${allLines.length - MAX_COLLAPSED_DIFF_LINES} more)`)}`
|
|
51
|
+
: "";
|
|
52
|
+
return allLines.slice(0, MAX_COLLAPSED_DIFF_LINES).join("\n") + more;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Added/removed line counts of a pi-format diff string (`+N`/`-N` leading char). */
|
|
56
|
+
export interface DiffCounts {
|
|
57
|
+
added: number;
|
|
58
|
+
removed: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Count added/removed lines in a pi-format diff (`+N content` / `-N content` / ` N content`). */
|
|
62
|
+
export function countDiffLines(diff: string): DiffCounts {
|
|
63
|
+
let added = 0;
|
|
64
|
+
let removed = 0;
|
|
65
|
+
for (const line of diff.split("\n")) {
|
|
66
|
+
if (line.startsWith("+")) added++;
|
|
67
|
+
else if (line.startsWith("-")) removed++;
|
|
68
|
+
}
|
|
69
|
+
return { added, removed };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Format `+N -N` with the theme's diff colors for the tool call header. */
|
|
73
|
+
export function formatDiffCounts(counts: DiffCounts, theme: any): string {
|
|
74
|
+
return ` ${theme.fg("toolDiffAdded", `+${counts.added}`)} ${theme.fg("toolDiffRemoved", `-${counts.removed}`)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Stash per-call diff counts into the row-local render state and invalidate
|
|
79
|
+
* once so the call header re-renders with `+N -N`. Idempotent: skips the
|
|
80
|
+
* invalidate when the counts are unchanged (prevents a render loop).
|
|
81
|
+
*/
|
|
82
|
+
export function publishDiffCounts(diff: string | undefined, context: any): void {
|
|
83
|
+
if (!diff || !context?.state) return;
|
|
84
|
+
const counts = countDiffLines(diff);
|
|
85
|
+
const prev: DiffCounts | undefined = context.state.diffCounts;
|
|
86
|
+
if (prev && prev.added === counts.added && prev.removed === counts.removed) return;
|
|
87
|
+
context.state.diffCounts = counts;
|
|
88
|
+
context.invalidate?.();
|
|
89
|
+
}
|
package/src/pi/replace-tool.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
35
35
|
import { hashFileLines, splitLines } from "../core/index.ts";
|
|
36
36
|
import { getState } from "./state.ts";
|
|
37
37
|
import { canonicalPath } from "./read-tool.ts";
|
|
38
|
+
import { formatDiffCounts, publishDiffCounts, renderDiffPreview } from "./render.ts";
|
|
38
39
|
|
|
39
40
|
/** Cap on updated-anchor lines returned inline (bounds token cost for large spans). */
|
|
40
41
|
const MAX_ANCHOR_LINES = 40;
|
|
@@ -162,13 +163,19 @@ export function makeReplaceTool(cwd: string) {
|
|
|
162
163
|
parameters: replaceSchema,
|
|
163
164
|
renderShell: "default" as const,
|
|
164
165
|
|
|
165
|
-
renderCall(args: ReplaceParams, theme: any) {
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
renderCall(args: ReplaceParams, theme: any, context: any) {
|
|
167
|
+
const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
168
|
+
let t = theme.fg("toolTitle", theme.bold("replace "));
|
|
169
|
+
t += theme.fg("accent", args.path);
|
|
168
170
|
const mode = args.regex ? "regex" : "lit";
|
|
169
171
|
const f = args.flags ? `/${args.flags}` : "";
|
|
170
|
-
|
|
171
|
-
|
|
172
|
+
t += theme.fg("dim", ` — ${mode}${f} "${show(args.find)}" → "${show(args.replace)}"`);
|
|
173
|
+
// diff counts land after execution: renderResult publishes them into
|
|
174
|
+
// context.state and invalidates the row, re-running this renderer
|
|
175
|
+
const counts = context?.state?.diffCounts;
|
|
176
|
+
if (counts && (counts.added || counts.removed)) t += formatDiffCounts(counts, theme);
|
|
177
|
+
text.setText(t);
|
|
178
|
+
return text;
|
|
172
179
|
},
|
|
173
180
|
|
|
174
181
|
renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
|
|
@@ -179,24 +186,16 @@ export function makeReplaceTool(cwd: string) {
|
|
|
179
186
|
return new Text(theme.fg("error", t), 0, 0);
|
|
180
187
|
}
|
|
181
188
|
const diff: string | undefined = result.details?.diff;
|
|
189
|
+
publishDiffCounts(diff, context);
|
|
182
190
|
if (!diff) {
|
|
183
191
|
// No net diff: show only the summary line — content.text also carries
|
|
184
192
|
// `Updated anchors` (hashline) for the model.
|
|
185
193
|
const t = content?.type === "text" ? content.text.split("\n")[0] : "Replaced";
|
|
186
194
|
return new Text(theme.fg("success", t), 0, 0);
|
|
187
195
|
}
|
|
188
|
-
// details.diff is pi-format (+N/-N/<space>N content);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const body = shown
|
|
192
|
-
.map((line: string) => {
|
|
193
|
-
if (line.startsWith("+")) return theme.fg("success", line);
|
|
194
|
-
if (line.startsWith("-")) return theme.fg("error", line);
|
|
195
|
-
return theme.fg("dim", line);
|
|
196
|
-
})
|
|
197
|
-
.join("\n");
|
|
198
|
-
const more = !expanded && allLines.length > 24 ? `\n${theme.fg("dim", `… (${allLines.length - 24} more)`)}` : "";
|
|
199
|
-
return new Text(body + more, 0, 0);
|
|
196
|
+
// details.diff is pi-format (+N/-N/<space>N content); renderDiff handles
|
|
197
|
+
// semantic colors plus intra-line change highlighting
|
|
198
|
+
return new Text(renderDiffPreview(diff, expanded, theme), 0, 0);
|
|
200
199
|
},
|
|
201
200
|
|
|
202
201
|
async execute(toolCallId: string, params: ReplaceParams, signal: AbortSignal | undefined, onUpdate: any) {
|
package/src/pi/replace.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import assert from "node:assert/strict";
|
|
|
11
11
|
import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
+
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
14
15
|
import { makeReplaceTool } from "./replace-tool.ts";
|
|
15
16
|
import { makeEditOverride } from "./edit-tool.ts";
|
|
16
17
|
import { getState } from "./state.ts";
|
|
@@ -35,6 +36,10 @@ function anchorLine(block: string, line: number) {
|
|
|
35
36
|
|
|
36
37
|
const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
|
|
37
38
|
|
|
39
|
+
// renderResult delegates to pi's renderDiff, which reads the global TUI theme
|
|
40
|
+
// singleton — initialize it once for this test process (watcher off by default).
|
|
41
|
+
initTheme();
|
|
42
|
+
|
|
38
43
|
test("replace literal: replaces all occurrences", async () => {
|
|
39
44
|
await withDir(async (dir) => {
|
|
40
45
|
const f = join(dir, "f.txt");
|
|
@@ -234,7 +239,7 @@ test("replace renderResult: renders the diff without throwing", async () => {
|
|
|
234
239
|
{ content: r.content, details: r.details },
|
|
235
240
|
{ isPartial: false, expanded: true },
|
|
236
241
|
stubTheme,
|
|
237
|
-
{ isError: r.isError ?? false },
|
|
242
|
+
{ isError: r.isError ?? false, state: {}, invalidate: () => {} },
|
|
238
243
|
);
|
|
239
244
|
assert.ok(typeof comp?.text === "string");
|
|
240
245
|
assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");
|