pi-edit-file 0.3.0 → 0.3.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 CHANGED
@@ -156,7 +156,7 @@ function f() {
156
156
 
157
157
  ## Rendering
158
158
 
159
- The UI draws the change as a unified diff with word-level highlighting (`details.diff`). The transcript card shows that diff plus a compact `+adds / -removals · N hunks` line — the per-hunk report, the anchor caveats and the shorter-form tip are written for the model and are **not** drawn next to the diff (Nik, 2026-10-01: "только сам дифф"). The model-facing content stays a summary, because models that see raw diffs in tool output start imitating the diff format in their own patches (this happened, and is why the escaping and diff-style diagnostics above exist).
159
+ The UI draws the change as a unified diff with word-level highlighting (`details.diff`). A diff too large for the card keeps **both ends** — the head, a dim `… (N diff lines omitted) …` marker, and the tail — and the `+adds / -removals` counts always describe the whole diff, never the excerpt (2026-10-08: a rewritten file rendered as a wall of `-` lines with `+0` in the header). The transcript card shows that diff plus a compact `+adds / -removals · N hunks` line — the per-hunk report, the anchor caveats and the shorter-form tip are written for the model and are **not** drawn next to the diff (Nik, 2026-10-01: "только сам дифф"). The model-facing content stays a summary, because models that see raw diffs in tool output start imitating the diff format in their own patches (this happened, and is why the escaping and diff-style diagnostics above exist).
160
160
  A failed call draws its **error text** instead: a patch rejection carries the whole diagnosis there, and rendering the empty result showed "+0 / -0 · 0 hunks", hiding the reason (2026-10-07).
161
161
 
162
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-edit-file",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Block-based atomic file editing for Pi agents: one flat patch string, precise match diagnostics, word-level diff rendering",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/core.ts CHANGED
@@ -1223,9 +1223,50 @@ export function sequentialDiffs(original: string[], resolved: ResolvedHunk[]): s
1223
1223
  return diffs;
1224
1224
  }
1225
1225
 
1226
- export function truncateDiff(diff: string, max = MAX_DIFF_CHARS): { text: string; truncated: boolean } {
1227
- if (diff.length <= max) return { text: diff, truncated: false };
1228
- return { text: diff.slice(0, max) + "\n… (diff truncated)", truncated: true };
1226
+ /** Trim a diff for the transcript, keeping BOTH ends. A whole-file rewrite is one
1227
+ * block of removals followed by one block of additions, so cutting the head only
1228
+ * showed a wall of "-" lines with nothing to see (2026-10-08: every big `write`
1229
+ * rendered that way). The reported counts describe the FULL diff, never the
1230
+ * excerpt — the card header used to read "+0 / -212" for a rewritten file. */
1231
+ export function truncateDiff(
1232
+ diff: string,
1233
+ max = MAX_DIFF_CHARS,
1234
+ ): { text: string; truncated: boolean; omitted: number; additions: number; removals: number } {
1235
+ const lines = diff.split("\n");
1236
+ let additions = 0;
1237
+ let removals = 0;
1238
+ for (const line of lines) {
1239
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
1240
+ if (line.startsWith("+")) additions++;
1241
+ else if (line.startsWith("-")) removals++;
1242
+ }
1243
+ if (diff.length <= max) return { text: diff, truncated: false, omitted: 0, additions, removals };
1244
+
1245
+ const headBudget = Math.floor(max * 0.6);
1246
+ const tailBudget = max - headBudget;
1247
+ let headEnd = 0;
1248
+ let used = 0;
1249
+ while (headEnd < lines.length) {
1250
+ const cost = lines[headEnd].length + 1;
1251
+ if (used + cost > headBudget) break;
1252
+ used += cost;
1253
+ headEnd++;
1254
+ }
1255
+ let tailStart = lines.length;
1256
+ used = 0;
1257
+ while (tailStart > headEnd) {
1258
+ const cost = lines[tailStart - 1].length + 1;
1259
+ if (used + cost > tailBudget) break;
1260
+ used += cost;
1261
+ tailStart--;
1262
+ }
1263
+ const omitted = lines.length - headEnd - (lines.length - tailStart);
1264
+ const text = [
1265
+ ...lines.slice(0, headEnd),
1266
+ `… (${omitted} diff lines omitted) …`,
1267
+ ...lines.slice(tailStart),
1268
+ ].join("\n");
1269
+ return { text, truncated: true, omitted, additions, removals };
1229
1270
  }
1230
1271
 
1231
1272
  export interface EditOutcome {
@@ -1481,7 +1522,11 @@ export function unifiedDiff(oldText: string, newText: string, filePath: string,
1481
1522
  // Unified-diff parsing + word-level diff (for syntax-highlighted diff rendering)
1482
1523
  // ---------------------------------------------------------------------------
1483
1524
 
1484
- export type DiffLineKind = " " | "-" | "+";
1525
+ /** "gap" is the omission marker a truncated diff carries ("… (12 diff lines
1526
+ * omitted) …"). It is a line kind of its own so the renderer draws it dim
1527
+ * instead of dropping it: silently jumping from the head of the diff to its tail
1528
+ * looks like a complete (and wrong) diff. */
1529
+ export type DiffLineKind = " " | "-" | "+" | "gap";
1485
1530
 
1486
1531
  export interface ParsedDiffLine {
1487
1532
  kind: DiffLineKind;
@@ -1514,6 +1559,10 @@ export function parseUnifiedDiff(diffText: string): ParsedHunk[] {
1514
1559
  }
1515
1560
  if (current === undefined) continue;
1516
1561
  if (line.startsWith("\\")) continue;
1562
+ if (line.startsWith("… (")) {
1563
+ current.lines.push({ kind: "gap", text: line });
1564
+ continue;
1565
+ }
1517
1566
  const kind = line[0];
1518
1567
  if (kind !== " " && kind !== "-" && kind !== "+") continue;
1519
1568
  current.lines.push({ kind: kind as DiffLineKind, text: line.slice(1) });
package/src/extension.ts CHANGED
@@ -240,14 +240,14 @@ export default function editFileExtension(pi: { registerTool: (t: unknown) => vo
240
240
  const diffText = sequentialDiffs(file.lines, resolved)
241
241
  .filter(Boolean)
242
242
  .join("\n");
243
- const { text: diff, truncated } = truncateDiff(diffText);
243
+ const { text: diff, truncated, additions, removals } = truncateDiff(diffText);
244
244
 
245
245
  // No raw diff in model-facing content: models started mimicking the
246
246
  // unified-diff format when writing patches (see pi-hermes-memory
247
247
  // session 2026-09-25). The UI renders details.diff; the model gets
248
248
  // the per-hunk summary only.
249
249
  const text = `${summary}\nfile: ${params.path} — now ${updated.length} lines (was ${originalCount})`;
250
- return { text, reports, reportLines, totalLines: updated.length, truncated, diff };
250
+ return { text, reports, reportLines, totalLines: updated.length, truncated, diff, additions, removals };
251
251
  });
252
252
 
253
253
  return {
@@ -259,6 +259,8 @@ export default function editFileExtension(pi: { registerTool: (t: unknown) => vo
259
259
  totalLines: result.totalLines,
260
260
  diff: result.diff,
261
261
  diffTruncated: result.truncated,
262
+ diffAdditions: result.additions,
263
+ diffRemovals: result.removals,
262
264
  },
263
265
  };
264
266
  },
@@ -290,16 +292,24 @@ export default function editFileExtension(pi: { registerTool: (t: unknown) => vo
290
292
  }
291
293
 
292
294
  const details = result?.details as
293
- | { path?: string; hunks?: unknown[]; reportLines?: string[]; totalLines?: number; diff?: string; diffTruncated?: boolean }
295
+ | {
296
+ path?: string;
297
+ hunks?: unknown[];
298
+ reportLines?: string[];
299
+ totalLines?: number;
300
+ diff?: string;
301
+ diffTruncated?: boolean;
302
+ diffAdditions?: number;
303
+ diffRemovals?: number;
304
+ }
294
305
  | undefined;
295
306
  const diff = details?.diff ?? "";
296
307
  const diffLines = diff ? diff.split("\n") : [];
297
- let additions = 0;
298
- let removals = 0;
299
- for (const line of diffLines) {
300
- if (line.startsWith("+")) additions++;
301
- if (line.startsWith("-")) removals++;
302
- }
308
+ // The counts describe the FULL diff and arrive with the result; counting the
309
+ // rendered excerpt instead reported "+0" for a rewritten file (2026-10-08).
310
+ const counted = countDiffLines(diffLines);
311
+ const additions = details?.diffAdditions ?? counted.additions;
312
+ const removals = details?.diffRemovals ?? counted.removals;
303
313
 
304
314
  const hunkCount = details?.hunks?.length ?? 0;
305
315
  let text = theme.fg("success", `+${additions}`);
@@ -339,6 +349,19 @@ export default function editFileExtension(pi: { registerTool: (t: unknown) => vo
339
349
  registerWriteDiff(pi);
340
350
  }
341
351
 
352
+ /** Line counts of a rendered diff excerpt; the stored full-diff stats win when
353
+ * present (see truncateDiff). Kept here so both renderers agree. */
354
+ function countDiffLines(lines: string[]): { additions: number; removals: number } {
355
+ let additions = 0;
356
+ let removals = 0;
357
+ for (const line of lines) {
358
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
359
+ if (line.startsWith("+")) additions++;
360
+ else if (line.startsWith("-")) removals++;
361
+ }
362
+ return { additions, removals };
363
+ }
364
+
342
365
  const WRITE_TOOL_NAME = "write";
343
366
 
344
367
  /** Old text of a file worth diffing: regular, text, within the size cap. */
@@ -389,11 +412,11 @@ function registerWriteDiff(pi: { registerTool: (t: unknown) => void }) {
389
412
 
390
413
  const diffText = unifiedDiff(before, params.content, params.path);
391
414
  if (!diffText) return result;
392
- const { text: diff, truncated } = truncateDiff(diffText);
415
+ const { text: diff, truncated, additions, removals } = truncateDiff(diffText);
393
416
 
394
417
  return {
395
418
  ...(result as Record<string, unknown>),
396
- details: { path: absPath, diff, diffTruncated: truncated },
419
+ details: { path: absPath, diff, diffTruncated: truncated, diffAdditions: additions, diffRemovals: removals },
397
420
  };
398
421
  },
399
422
 
@@ -416,7 +439,9 @@ function registerWriteDiff(pi: { registerTool: (t: unknown) => void }) {
416
439
  },
417
440
 
418
441
  renderResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any, context: any) {
419
- const details = result?.details as { diff?: string; diffTruncated?: boolean } | undefined;
442
+ const details = result?.details as
443
+ | { diff?: string; diffTruncated?: boolean; diffAdditions?: number; diffRemovals?: number }
444
+ | undefined;
420
445
  const diff = details?.diff ?? "";
421
446
  if (!diff) {
422
447
  // New file or no-op: keep pi's own result rendering (it clears the
@@ -431,13 +456,9 @@ function registerWriteDiff(pi: { registerTool: (t: unknown) => void }) {
431
456
  }
432
457
 
433
458
  const lines = diff.split("\n");
434
- let additions = 0;
435
- let removals = 0;
436
- for (const line of lines) {
437
- if (line.startsWith("+++") || line.startsWith("---")) continue;
438
- if (line.startsWith("+")) additions++;
439
- if (line.startsWith("-")) removals++;
440
- }
459
+ const counted = countDiffLines(lines);
460
+ const additions = details?.diffAdditions ?? counted.additions;
461
+ const removals = details?.diffRemovals ?? counted.removals;
441
462
 
442
463
  let text = theme.fg("success", `+${additions}`);
443
464
  text += theme.fg("dim", " / ");
@@ -527,6 +548,14 @@ function renderDiffBody(diffText: string, absPath: string | undefined, theme: an
527
548
  let i = 0;
528
549
 
529
550
  while (i < lines.length) {
551
+ if (lines[i].kind === "gap") {
552
+ // The marker a truncated diff carries; without this branch the tail of
553
+ // the diff would sit right under its head, looking like one diff (2026-10-08).
554
+ out.push(theme.fg("dim", lines[i].text));
555
+ i++;
556
+ continue;
557
+ }
558
+
530
559
  if (lines[i].kind === " ") {
531
560
  out.push(` ${newLine(lines[i].text, b)}`);
532
561
  b++;