@d3ara1n/pi-hashline-edit 0.3.4 → 0.4.1
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/edit-tool.ts +27 -18
- package/src/pi/execute.test.ts +42 -2
- package/src/pi/render.ts +74 -1
- package/src/pi/replace-tool.ts +28 -19
- package/src/pi/replace.test.ts +25 -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/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, type DiffCounts } 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;
|
|
@@ -189,6 +190,16 @@ function formatUpdatedAnchors(newText: string, touched: readonly number[], hashL
|
|
|
189
190
|
return `\nUpdated anchors (use these for the next edit):\n${shown.join("\n")}${more}`;
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
/** Call-header line: `edit path — N ops: op`, plus `+N -N` once the result's diff counts are known. */
|
|
194
|
+
function editHeader(args: Static<typeof editSchema>, theme: any, counts?: DiffCounts): string {
|
|
195
|
+
let t = theme.fg("toolTitle", theme.bold("edit "));
|
|
196
|
+
t += theme.fg("accent", args.path);
|
|
197
|
+
const n = args.edits?.length ?? 0;
|
|
198
|
+
if (n) t += theme.fg("dim", ` — ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
|
|
199
|
+
if (counts && (counts.added || counts.removed)) t += formatDiffCounts(counts, theme);
|
|
200
|
+
return t;
|
|
201
|
+
}
|
|
202
|
+
|
|
192
203
|
export function makeEditOverride(cwd: string) {
|
|
193
204
|
const builtin = createEditTool(cwd);
|
|
194
205
|
|
|
@@ -209,12 +220,14 @@ export function makeEditOverride(cwd: string) {
|
|
|
209
220
|
parameters: editSchema,
|
|
210
221
|
renderShell: "default" as const,
|
|
211
222
|
|
|
212
|
-
renderCall(args: Static<typeof editSchema>, theme: any) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
223
|
+
renderCall(args: Static<typeof editSchema>, theme: any, context: any) {
|
|
224
|
+
const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
225
|
+
// Stash the header for renderResult: the diff counts land after
|
|
226
|
+
// execution and are refreshed in place (renderResult's lastComponent
|
|
227
|
+
// is the result component, not this header)
|
|
228
|
+
if (context?.state) context.state.callText = text;
|
|
229
|
+
text.setText(editHeader(args, theme, context?.state?.diffCounts));
|
|
230
|
+
return text;
|
|
218
231
|
},
|
|
219
232
|
|
|
220
233
|
renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
|
|
@@ -225,24 +238,20 @@ export function makeEditOverride(cwd: string) {
|
|
|
225
238
|
return new Text(theme.fg("error", t), 0, 0);
|
|
226
239
|
}
|
|
227
240
|
const diff: string | undefined = result.details?.diff;
|
|
241
|
+
// refresh the call header's +N -N in place — never invalidate from
|
|
242
|
+
// inside a renderer (re-enters updateDisplay, diff renders twice)
|
|
243
|
+
publishDiffCounts(diff, context, (counts) => {
|
|
244
|
+
context.state?.callText?.setText(editHeader(context.args, theme, counts));
|
|
245
|
+
});
|
|
228
246
|
if (!diff) {
|
|
229
247
|
// No net diff (e.g. a successful but non-mutating edit): show only the summary
|
|
230
248
|
// line — content.text also carries `Updated anchors` (hashline) for the model.
|
|
231
249
|
const t = content?.type === "text" ? content.text.split("\n")[0] : "Edited";
|
|
232
250
|
return new Text(theme.fg("success", t), 0, 0);
|
|
233
251
|
}
|
|
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);
|
|
252
|
+
// details.diff is pi-format (+N/-N/<space>N content); renderDiff handles
|
|
253
|
+
// semantic colors plus intra-line change highlighting
|
|
254
|
+
return new Text(renderDiffPreview(diff, expanded, theme), 0, 0);
|
|
246
255
|
},
|
|
247
256
|
|
|
248
257
|
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,48 @@ 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 refreshes the call header in place — no invalidate", 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 args = { path: "f.txt", edits: [{ op: "replace" as const }] };
|
|
271
|
+
let invalidated = false;
|
|
272
|
+
const context: any = { args, isError: false, state: {}, invalidate: () => { invalidated = true; } };
|
|
273
|
+
// first pass: renderCall builds the header; no counts exist yet
|
|
274
|
+
const header: any = edit.renderCall(args, stubTheme, context);
|
|
275
|
+
assert.ok(!header.text.includes("+3"), "pre-execution header must not show counts");
|
|
276
|
+
// result lands: renderResult refreshes the SAME component in place
|
|
277
|
+
edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, context);
|
|
278
|
+
assert.deepEqual(context.state.diffCounts, { added: 3, removed: 2 });
|
|
279
|
+
assert.ok(header.text.includes("+3"), "header should show added count");
|
|
280
|
+
assert.ok(header.text.includes("-2"), "header should show removed count");
|
|
281
|
+
// refreshing via context.invalidate() re-enters updateDisplay and renders
|
|
282
|
+
// the diff twice — the renderer must never call it
|
|
283
|
+
assert.ok(!invalidated, "renderResult must not call invalidate");
|
|
284
|
+
// later full passes (expand/collapse) re-run renderCall; counts survive in state
|
|
285
|
+
const header2: any = edit.renderCall(args, stubTheme, { args, state: context.state, lastComponent: header });
|
|
286
|
+
assert.equal(header2, header, "renderCall reuses the stashed component");
|
|
287
|
+
assert.ok(header2.text.includes("+3"), "re-render keeps the counts");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
251
291
|
test("edit error: renderResult renders the error line without throwing", async () => {
|
|
252
292
|
await withDir(async (dir) => {
|
|
253
293
|
await writeFile(join(dir, "f.txt"), "a\n");
|
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,74 @@ 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 refresh the
|
|
79
|
+
* call header component in place — pi's own edit-tool pattern (renderResult
|
|
80
|
+
* mutates the component stashed by renderCall; it never re-runs the renderer).
|
|
81
|
+
*
|
|
82
|
+
* `updateDisplay` runs renderCall before renderResult in every pass, so later
|
|
83
|
+
* passes (expand/collapse, result updates) rebuild the header from
|
|
84
|
+
* `state.diffCounts`; the in-place refresh covers the first result render,
|
|
85
|
+
* where renderCall ran before the counts existed. renderResult cannot find the
|
|
86
|
+
* header via `lastComponent` — there it is the *result* component — so
|
|
87
|
+
* renderCall stashes it (e.g. `state.callText`).
|
|
88
|
+
*
|
|
89
|
+
* MUST NOT call `context.invalidate()`: it re-enters `updateDisplay`
|
|
90
|
+
* synchronously (not re-entrant) and the outer pass then re-adds the result
|
|
91
|
+
* component after the nested one — the diff renders twice.
|
|
92
|
+
*/
|
|
93
|
+
export function publishDiffCounts(
|
|
94
|
+
diff: string | undefined,
|
|
95
|
+
context: any,
|
|
96
|
+
refreshHeader: (counts: DiffCounts) => void,
|
|
97
|
+
): void {
|
|
98
|
+
if (!diff || !context?.state) return;
|
|
99
|
+
const counts = countDiffLines(diff);
|
|
100
|
+
const prev: DiffCounts | undefined = context.state.diffCounts;
|
|
101
|
+
context.state.diffCounts = counts;
|
|
102
|
+
if (!prev || prev.added !== counts.added || prev.removed !== counts.removed) refreshHeader(counts);
|
|
103
|
+
}
|
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, type DiffCounts } 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;
|
|
@@ -144,6 +145,17 @@ function show(s: string, n = 30): string {
|
|
|
144
145
|
return folded.length > n ? folded.slice(0, n) + "…" : folded;
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
/** Call-header line: `replace path — mode "find" → "replace"`, plus `+N -N` once the result's diff counts are known. */
|
|
149
|
+
function replaceHeader(args: ReplaceParams, theme: any, counts?: DiffCounts): string {
|
|
150
|
+
let t = theme.fg("toolTitle", theme.bold("replace "));
|
|
151
|
+
t += theme.fg("accent", args.path);
|
|
152
|
+
const mode = args.regex ? "regex" : "lit";
|
|
153
|
+
const f = args.flags ? `/${args.flags}` : "";
|
|
154
|
+
t += theme.fg("dim", ` — ${mode}${f} "${show(args.find)}" → "${show(args.replace)}"`);
|
|
155
|
+
if (counts && (counts.added || counts.removed)) t += formatDiffCounts(counts, theme);
|
|
156
|
+
return t;
|
|
157
|
+
}
|
|
158
|
+
|
|
147
159
|
export function makeReplaceTool(cwd: string) {
|
|
148
160
|
return {
|
|
149
161
|
name: "replace" as const,
|
|
@@ -162,13 +174,14 @@ export function makeReplaceTool(cwd: string) {
|
|
|
162
174
|
parameters: replaceSchema,
|
|
163
175
|
renderShell: "default" as const,
|
|
164
176
|
|
|
165
|
-
renderCall(args: ReplaceParams, theme: any) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
renderCall(args: ReplaceParams, theme: any, context: any) {
|
|
178
|
+
const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
179
|
+
// Stash the header for renderResult: the diff counts land after
|
|
180
|
+
// execution and are refreshed in place (renderResult's lastComponent
|
|
181
|
+
// is the result component, not this header)
|
|
182
|
+
if (context?.state) context.state.callText = text;
|
|
183
|
+
text.setText(replaceHeader(args, theme, context?.state?.diffCounts));
|
|
184
|
+
return text;
|
|
172
185
|
},
|
|
173
186
|
|
|
174
187
|
renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
|
|
@@ -179,24 +192,20 @@ export function makeReplaceTool(cwd: string) {
|
|
|
179
192
|
return new Text(theme.fg("error", t), 0, 0);
|
|
180
193
|
}
|
|
181
194
|
const diff: string | undefined = result.details?.diff;
|
|
195
|
+
// refresh the call header's +N -N in place — never invalidate from
|
|
196
|
+
// inside a renderer (re-enters updateDisplay, diff renders twice)
|
|
197
|
+
publishDiffCounts(diff, context, (counts) => {
|
|
198
|
+
context.state?.callText?.setText(replaceHeader(context.args, theme, counts));
|
|
199
|
+
});
|
|
182
200
|
if (!diff) {
|
|
183
201
|
// No net diff: show only the summary line — content.text also carries
|
|
184
202
|
// `Updated anchors` (hashline) for the model.
|
|
185
203
|
const t = content?.type === "text" ? content.text.split("\n")[0] : "Replaced";
|
|
186
204
|
return new Text(theme.fg("success", t), 0, 0);
|
|
187
205
|
}
|
|
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);
|
|
206
|
+
// details.diff is pi-format (+N/-N/<space>N content); renderDiff handles
|
|
207
|
+
// semantic colors plus intra-line change highlighting
|
|
208
|
+
return new Text(renderDiffPreview(diff, expanded, theme), 0, 0);
|
|
200
209
|
},
|
|
201
210
|
|
|
202
211
|
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");
|
|
@@ -263,6 +268,25 @@ test("replace renderResult: renders the error line without throwing", async () =
|
|
|
263
268
|
});
|
|
264
269
|
});
|
|
265
270
|
|
|
271
|
+
test("replace header: renderResult refreshes the call header in place — no invalidate", async () => {
|
|
272
|
+
await withDir(async (dir) => {
|
|
273
|
+
const f = join(dir, "f.txt");
|
|
274
|
+
await writeFile(f, "a\nb\nc\n");
|
|
275
|
+
const tool = makeReplaceTool(dir);
|
|
276
|
+
const args = { path: "f.txt", find: "b", replace: "B1\nB2" };
|
|
277
|
+
const r: any = await call(tool, args);
|
|
278
|
+
let invalidated = false;
|
|
279
|
+
const context: any = { args, isError: false, state: {}, invalidate: () => { invalidated = true; } };
|
|
280
|
+
const header: any = tool.renderCall(args, stubTheme, context);
|
|
281
|
+
assert.ok(!header.text.includes("+2"), "pre-execution header must not show counts");
|
|
282
|
+
tool.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, context);
|
|
283
|
+
assert.deepEqual(context.state.diffCounts, { added: 2, removed: 1 });
|
|
284
|
+
assert.ok(header.text.includes("+2"), "header should show added count");
|
|
285
|
+
assert.ok(header.text.includes("-1"), "header should show removed count");
|
|
286
|
+
assert.ok(!invalidated, "renderResult must not call invalidate");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
266
290
|
test("replace: refuses and leaves the file untouched when hashlineEdit is disabled", async () => {
|
|
267
291
|
await withDir(async (dir) => {
|
|
268
292
|
const f = join(dir, "f.txt");
|