@d3ara1n/pi-hashline-edit 0.4.0 → 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/package.json +1 -1
- package/src/pi/edit-tool.ts +21 -11
- package/src/pi/execute.test.ts +15 -10
- package/src/pi/render.ts +20 -6
- package/src/pi/replace-tool.ts +22 -12
- package/src/pi/replace.test.ts +19 -0
package/package.json
CHANGED
package/src/pi/edit-tool.ts
CHANGED
|
@@ -28,7 +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
|
+
import { formatDiffCounts, publishDiffCounts, renderDiffPreview, type DiffCounts } from "./render.ts";
|
|
32
32
|
|
|
33
33
|
/** Cap on the number of updated anchors returned inline (bounds token cost for large inserts). */
|
|
34
34
|
const MAX_ANCHOR_LINES = 40;
|
|
@@ -190,6 +190,16 @@ function formatUpdatedAnchors(newText: string, touched: readonly number[], hashL
|
|
|
190
190
|
return `\nUpdated anchors (use these for the next edit):\n${shown.join("\n")}${more}`;
|
|
191
191
|
}
|
|
192
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
|
+
|
|
193
203
|
export function makeEditOverride(cwd: string) {
|
|
194
204
|
const builtin = createEditTool(cwd);
|
|
195
205
|
|
|
@@ -212,15 +222,11 @@ export function makeEditOverride(cwd: string) {
|
|
|
212
222
|
|
|
213
223
|
renderCall(args: Static<typeof editSchema>, theme: any, context: any) {
|
|
214
224
|
const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
|
|
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);
|
|
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));
|
|
224
230
|
return text;
|
|
225
231
|
},
|
|
226
232
|
|
|
@@ -232,7 +238,11 @@ export function makeEditOverride(cwd: string) {
|
|
|
232
238
|
return new Text(theme.fg("error", t), 0, 0);
|
|
233
239
|
}
|
|
234
240
|
const diff: string | undefined = result.details?.diff;
|
|
235
|
-
|
|
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
|
+
});
|
|
236
246
|
if (!diff) {
|
|
237
247
|
// No net diff (e.g. a successful but non-mutating edit): show only the summary
|
|
238
248
|
// line — content.text also carries `Updated anchors` (hashline) for the model.
|
package/src/pi/execute.test.ts
CHANGED
|
@@ -253,7 +253,7 @@ test("edit success: renderResult renders the diff without throwing", async () =>
|
|
|
253
253
|
});
|
|
254
254
|
});
|
|
255
255
|
|
|
256
|
-
test("edit header: renderResult
|
|
256
|
+
test("edit header: renderResult refreshes the call header in place — no invalidate", async () => {
|
|
257
257
|
await withDir(async (dir) => {
|
|
258
258
|
const f = join(dir, "f.txt");
|
|
259
259
|
const text = "a\nb\nc\nd\ne\n";
|
|
@@ -267,19 +267,24 @@ test("edit header: renderResult publishes diff counts, renderCall shows +N -N",
|
|
|
267
267
|
{ op: "insert_after", anchor: h(text, 5), body: ["f", "g"] },
|
|
268
268
|
],
|
|
269
269
|
});
|
|
270
|
-
const
|
|
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
|
|
271
277
|
edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, context);
|
|
272
278
|
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
279
|
assert.ok(header.text.includes("+3"), "header should show added count");
|
|
279
280
|
assert.ok(header.text.includes("-2"), "header should show removed count");
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
assert.ok(!
|
|
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");
|
|
283
288
|
});
|
|
284
289
|
});
|
|
285
290
|
|
package/src/pi/render.ts
CHANGED
|
@@ -75,15 +75,29 @@ export function formatDiffCounts(counts: DiffCounts, theme: any): string {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* Stash per-call diff counts into the row-local render state and
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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.
|
|
81
92
|
*/
|
|
82
|
-
export function publishDiffCounts(
|
|
93
|
+
export function publishDiffCounts(
|
|
94
|
+
diff: string | undefined,
|
|
95
|
+
context: any,
|
|
96
|
+
refreshHeader: (counts: DiffCounts) => void,
|
|
97
|
+
): void {
|
|
83
98
|
if (!diff || !context?.state) return;
|
|
84
99
|
const counts = countDiffLines(diff);
|
|
85
100
|
const prev: DiffCounts | undefined = context.state.diffCounts;
|
|
86
|
-
if (prev && prev.added === counts.added && prev.removed === counts.removed) return;
|
|
87
101
|
context.state.diffCounts = counts;
|
|
88
|
-
|
|
102
|
+
if (!prev || prev.added !== counts.added || prev.removed !== counts.removed) refreshHeader(counts);
|
|
89
103
|
}
|
package/src/pi/replace-tool.ts
CHANGED
|
@@ -35,7 +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
|
+
import { formatDiffCounts, publishDiffCounts, renderDiffPreview, type DiffCounts } from "./render.ts";
|
|
39
39
|
|
|
40
40
|
/** Cap on updated-anchor lines returned inline (bounds token cost for large spans). */
|
|
41
41
|
const MAX_ANCHOR_LINES = 40;
|
|
@@ -145,6 +145,17 @@ function show(s: string, n = 30): string {
|
|
|
145
145
|
return folded.length > n ? folded.slice(0, n) + "…" : folded;
|
|
146
146
|
}
|
|
147
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
|
+
|
|
148
159
|
export function makeReplaceTool(cwd: string) {
|
|
149
160
|
return {
|
|
150
161
|
name: "replace" as const,
|
|
@@ -165,16 +176,11 @@ export function makeReplaceTool(cwd: string) {
|
|
|
165
176
|
|
|
166
177
|
renderCall(args: ReplaceParams, theme: any, context: any) {
|
|
167
178
|
const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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);
|
|
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));
|
|
178
184
|
return text;
|
|
179
185
|
},
|
|
180
186
|
|
|
@@ -186,7 +192,11 @@ export function makeReplaceTool(cwd: string) {
|
|
|
186
192
|
return new Text(theme.fg("error", t), 0, 0);
|
|
187
193
|
}
|
|
188
194
|
const diff: string | undefined = result.details?.diff;
|
|
189
|
-
|
|
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
|
+
});
|
|
190
200
|
if (!diff) {
|
|
191
201
|
// No net diff: show only the summary line — content.text also carries
|
|
192
202
|
// `Updated anchors` (hashline) for the model.
|
package/src/pi/replace.test.ts
CHANGED
|
@@ -268,6 +268,25 @@ test("replace renderResult: renders the error line without throwing", async () =
|
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
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
|
+
|
|
271
290
|
test("replace: refuses and leaves the file untouched when hashlineEdit is disabled", async () => {
|
|
272
291
|
await withDir(async (dir) => {
|
|
273
292
|
const f = join(dir, "f.txt");
|