@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
6
6
  "keywords": [
@@ -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
- let t = theme.fg("toolTitle", theme.bold("edit "));
216
- t += theme.fg("accent", args.path);
217
- const n = args.edits?.length ?? 0;
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);
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
- publishDiffCounts(diff, context);
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.
@@ -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 publishes diff counts, renderCall shows +N -N", async () => {
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 context: any = { isError: false, state: {}, invalidate: () => {} };
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
- // 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");
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 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).
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(diff: string | undefined, context: any): void {
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
- context.invalidate?.();
102
+ if (!prev || prev.added !== counts.added || prev.removed !== counts.removed) refreshHeader(counts);
89
103
  }
@@ -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
- let t = theme.fg("toolTitle", theme.bold("replace "));
169
- t += theme.fg("accent", args.path);
170
- const mode = args.regex ? "regex" : "lit";
171
- const f = args.flags ? `/${args.flags}` : "";
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);
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
- publishDiffCounts(diff, context);
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.
@@ -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");