@heyhuynhgiabuu/pi-diff 0.1.5 → 0.1.6

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
@@ -43,7 +43,7 @@ pi -e ./src/index.ts
43
43
 
44
44
  ## How It Works
45
45
 
46
- pi-diff wraps the built-in `write` and `edit` tools from the pi SDK. When the agent writes or edits a file:
46
+ pi-diff wraps the built-in `write` and `edit` tools from the pi SDK, including single-edit and multi-edit `edit` calls. When the agent writes or edits a file:
47
47
 
48
48
  1. **Before the write** — reads the existing file content
49
49
  2. **Delegates** to the original SDK tool (file is actually written)
package/biome.json CHANGED
@@ -11,7 +11,14 @@
11
11
  "linter": {
12
12
  "enabled": true,
13
13
  "rules": {
14
- "recommended": true
14
+ "recommended": true,
15
+ "suspicious": {
16
+ "noExplicitAny": "off",
17
+ "noFocusedTests": "off"
18
+ },
19
+ "correctness": {
20
+ "noUnusedVariables": "off"
21
+ }
15
22
  }
16
23
  },
17
24
  "formatter": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyhuynhgiabuu/pi-diff",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Shiki-powered terminal diff renderer for pi — syntax-highlighted, word-level diffs in split and unified views.",
5
5
  "author": "huynhgiabuu",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -172,7 +172,11 @@ function deriveBgFromFg(fgAnsi: string, intensity: number): string {
172
172
  /** Mix an accent color into a base color at the given intensity (0.0–1.0).
173
173
  * Returns an ANSI 24-bit background escape. Used to derive diff backgrounds
174
174
  * that blend with the tool box background (toolSuccessBg). */
175
- function mixBg(base: { r: number; g: number; b: number }, accent: { r: number; g: number; b: number }, intensity: number): string {
175
+ function mixBg(
176
+ base: { r: number; g: number; b: number },
177
+ accent: { r: number; g: number; b: number },
178
+ intensity: number,
179
+ ): string {
176
180
  const r = Math.round(base.r + (accent.r - base.r) * intensity);
177
181
  const g = Math.round(base.g + (accent.g - base.g) * intensity);
178
182
  const b = Math.round(base.b + (accent.b - base.b) * intensity);
@@ -207,15 +211,17 @@ function autoDeriveBgFromTheme(theme: any): void {
207
211
  base = parsed;
208
212
  BG_BASE = bgAnsi;
209
213
  }
210
- } catch { /* no toolSuccessBg — use black */ }
214
+ } catch {
215
+ /* no toolSuccessBg — use black */
216
+ }
211
217
  }
212
218
 
213
219
  // Line backgrounds — subtle accent mixed into base (8–10%)
214
220
  BG_ADD = mixBg(base, addRgb, 0.08);
215
- BG_DEL = mixBg(base, delRgb, 0.10);
221
+ BG_DEL = mixBg(base, delRgb, 0.1);
216
222
 
217
223
  // Word-level highlights — more visible (20–22%)
218
- BG_ADD_W = mixBg(base, addRgb, 0.20);
224
+ BG_ADD_W = mixBg(base, addRgb, 0.2);
219
225
  BG_DEL_W = mixBg(base, delRgb, 0.22);
220
226
 
221
227
  // Gutters — subtler than lines (5–6%)
@@ -238,10 +244,7 @@ function autoDeriveBgFromTheme(theme: any): void {
238
244
 
239
245
  /** Load diff theme config from .pi/settings.json (project-level, then global). */
240
246
  function loadDiffConfig(): DiffUserConfig {
241
- const paths = [
242
- `${process.cwd()}/.pi/settings.json`,
243
- `${process.env.HOME ?? ""}/.pi/settings.json`,
244
- ];
247
+ const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
245
248
  for (const p of paths) {
246
249
  try {
247
250
  if (existsSync(p)) {
@@ -274,32 +277,66 @@ function applyDiffPalette(): void {
274
277
  const applyBg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
275
278
  if (envName && process.env[envName]) return; // env override wins
276
279
  const hex = ov[key] ?? presetVal;
277
- if (hex) { const a = hexToBgAnsi(hex); if (a) set(a); }
280
+ if (hex) {
281
+ const a = hexToBgAnsi(hex);
282
+ if (a) set(a);
283
+ }
278
284
  };
279
285
  // Helper: apply a hex fg color if not env-overridden
280
286
  const applyFg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
281
287
  if (envName && process.env[envName]) return;
282
288
  const hex = ov[key] ?? presetVal;
283
- if (hex) { const a = hexToFgAnsi(hex); if (a) set(a); }
289
+ if (hex) {
290
+ const a = hexToFgAnsi(hex);
291
+ if (a) set(a);
292
+ }
284
293
  };
285
294
 
286
295
  // --- Apply backgrounds ---
287
- applyBg("DIFF_BG_ADD", "bgAdd", preset?.bgAdd, (v) => { BG_ADD = v; });
288
- applyBg("DIFF_BG_DEL", "bgDel", preset?.bgDel, (v) => { BG_DEL = v; });
289
- applyBg("DIFF_BG_ADD_HL", "bgAddHighlight", preset?.bgAddHighlight, (v) => { BG_ADD_W = v; });
290
- applyBg("DIFF_BG_DEL_HL", "bgDelHighlight", preset?.bgDelHighlight, (v) => { BG_DEL_W = v; });
291
- applyBg("DIFF_BG_GUTTER_ADD", "bgGutterAdd", preset?.bgGutterAdd, (v) => { BG_GUTTER_ADD = v; });
292
- applyBg("DIFF_BG_GUTTER_DEL", "bgGutterDel", preset?.bgGutterDel, (v) => { BG_GUTTER_DEL = v; });
293
- applyBg(null, "bgEmpty", preset?.bgEmpty, (v) => { BG_EMPTY = v; });
296
+ applyBg("DIFF_BG_ADD", "bgAdd", preset?.bgAdd, (v) => {
297
+ BG_ADD = v;
298
+ });
299
+ applyBg("DIFF_BG_DEL", "bgDel", preset?.bgDel, (v) => {
300
+ BG_DEL = v;
301
+ });
302
+ applyBg("DIFF_BG_ADD_HL", "bgAddHighlight", preset?.bgAddHighlight, (v) => {
303
+ BG_ADD_W = v;
304
+ });
305
+ applyBg("DIFF_BG_DEL_HL", "bgDelHighlight", preset?.bgDelHighlight, (v) => {
306
+ BG_DEL_W = v;
307
+ });
308
+ applyBg("DIFF_BG_GUTTER_ADD", "bgGutterAdd", preset?.bgGutterAdd, (v) => {
309
+ BG_GUTTER_ADD = v;
310
+ });
311
+ applyBg("DIFF_BG_GUTTER_DEL", "bgGutterDel", preset?.bgGutterDel, (v) => {
312
+ BG_GUTTER_DEL = v;
313
+ });
314
+ applyBg(null, "bgEmpty", preset?.bgEmpty, (v) => {
315
+ BG_EMPTY = v;
316
+ });
294
317
 
295
318
  // --- Apply foregrounds ---
296
- applyFg("DIFF_FG_ADD", "fgAdd", preset?.fgAdd, (v) => { FG_ADD = v; });
297
- applyFg("DIFF_FG_DEL", "fgDel", preset?.fgDel, (v) => { FG_DEL = v; });
298
- applyFg(null, "fgDim", preset?.fgDim, (v) => { FG_DIM = v; });
299
- applyFg(null, "fgLnum", preset?.fgLnum, (v) => { FG_LNUM = v; });
300
- applyFg(null, "fgRule", preset?.fgRule, (v) => { FG_RULE = v; });
301
- applyFg(null, "fgStripe", preset?.fgStripe, (v) => { FG_STRIPE = v; });
302
- applyFg(null, "fgSafeMuted", preset?.fgSafeMuted, (v) => { FG_SAFE_MUTED = v; });
319
+ applyFg("DIFF_FG_ADD", "fgAdd", preset?.fgAdd, (v) => {
320
+ FG_ADD = v;
321
+ });
322
+ applyFg("DIFF_FG_DEL", "fgDel", preset?.fgDel, (v) => {
323
+ FG_DEL = v;
324
+ });
325
+ applyFg(null, "fgDim", preset?.fgDim, (v) => {
326
+ FG_DIM = v;
327
+ });
328
+ applyFg(null, "fgLnum", preset?.fgLnum, (v) => {
329
+ FG_LNUM = v;
330
+ });
331
+ applyFg(null, "fgRule", preset?.fgRule, (v) => {
332
+ FG_RULE = v;
333
+ });
334
+ applyFg(null, "fgStripe", preset?.fgStripe, (v) => {
335
+ FG_STRIPE = v;
336
+ });
337
+ applyFg(null, "fgSafeMuted", preset?.fgSafeMuted, (v) => {
338
+ FG_SAFE_MUTED = v;
339
+ });
303
340
 
304
341
  // --- Shiki syntax theme ---
305
342
  const shiki = ov.shikiTheme ?? preset?.shikiTheme;
@@ -441,7 +478,9 @@ function resolveDiffColors(theme?: any): DiffColors {
441
478
  BG_BASE = bgAnsi;
442
479
  RST = `\x1b[0m${BG_BASE}`;
443
480
  }
444
- } catch { /* ignore */ }
481
+ } catch {
482
+ /* ignore */
483
+ }
445
484
  }
446
485
 
447
486
  // Auto-derive bg colors from theme on first render (if no explicit preset/overrides)
@@ -1424,66 +1463,128 @@ export default function diffRendererExtension(pi: any): void {
1424
1463
 
1425
1464
  const origEdit = createEditTool(cwd);
1426
1465
 
1466
+ function getEditOperations(input: any): Array<{ oldText: string; newText: string }> {
1467
+ if (Array.isArray(input?.edits)) {
1468
+ return input.edits
1469
+ .map((edit: any) => ({
1470
+ oldText:
1471
+ typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
1472
+ newText:
1473
+ typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
1474
+ }))
1475
+ .filter((edit: { oldText: string; newText: string }) => edit.oldText && edit.oldText !== edit.newText);
1476
+ }
1477
+
1478
+ const oldText =
1479
+ typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
1480
+ const newText =
1481
+ typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
1482
+ return oldText && oldText !== newText ? [{ oldText, newText }] : [];
1483
+ }
1484
+
1485
+ function summarizeEditOperations(operations: Array<{ oldText: string; newText: string }>) {
1486
+ const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText));
1487
+ const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0);
1488
+ const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0);
1489
+ return {
1490
+ diffs,
1491
+ totalAdded,
1492
+ totalRemoved,
1493
+ summary: summarize(totalAdded, totalRemoved),
1494
+ };
1495
+ }
1496
+
1427
1497
  pi.registerTool({
1428
1498
  ...origEdit,
1429
1499
  name: "edit",
1430
1500
 
1431
1501
  async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1432
1502
  const fp = params.path ?? params.file_path ?? "";
1433
- const oldText = params.oldText ?? params.old_text ?? "";
1434
- const newText = params.newText ?? params.new_text ?? "";
1435
-
1503
+ const operations = getEditOperations(params);
1436
1504
  const result = await origEdit.execute(tid, params, sig, upd, ctx);
1437
1505
 
1438
- if (oldText && oldText !== newText) {
1506
+ if (operations.length === 0) return result;
1507
+
1508
+ const { diffs, summary } = summarizeEditOperations(operations);
1509
+ if (operations.length === 1) {
1439
1510
  let editLine = 0;
1440
1511
  try {
1441
1512
  if (fp && existsSync(fp)) {
1442
1513
  const f = readFileSync(fp, "utf-8");
1443
- const idx = f.indexOf(newText);
1514
+ const idx = f.indexOf(operations[0].newText);
1444
1515
  if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
1445
1516
  }
1446
1517
  } catch {
1447
1518
  editLine = 0;
1448
1519
  }
1449
- const diff = parseDiff(oldText, newText);
1450
- (result as any).details = { _type: "editInfo", summary: summarize(diff.added, diff.removed), editLine };
1520
+ (result as any).details = { _type: "editInfo", summary, editLine };
1521
+ return result;
1451
1522
  }
1523
+
1524
+ (result as any).details = {
1525
+ _type: "multiEditInfo",
1526
+ summary,
1527
+ editCount: operations.length,
1528
+ diffLineCount: diffs.reduce((sum, diff) => sum + diff.lines.length, 0),
1529
+ };
1452
1530
  return result;
1453
1531
  },
1454
1532
 
1455
1533
  renderCall(args: any, theme: any, ctx: any) {
1456
1534
  const fp = args?.path ?? args?.file_path ?? "";
1457
- const oldText = args?.oldText ?? args?.old_text ?? "";
1458
- const newText = args?.newText ?? args?.new_text ?? "";
1535
+ const operations = getEditOperations(args);
1459
1536
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1460
1537
  const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
1461
1538
 
1462
- if (!(ctx.argsComplete && oldText && oldText !== newText)) {
1539
+ if (!(ctx.argsComplete && operations.length > 0)) {
1463
1540
  text.setText(hdr);
1464
1541
  return text;
1465
1542
  }
1466
1543
 
1467
- const pk = JSON.stringify({ fp, oldText, newText, w: termW() });
1544
+ const pk = JSON.stringify({ fp, operations, w: termW() });
1468
1545
  if (ctx.state._pk !== pk) {
1469
1546
  ctx.state._pk = pk;
1470
1547
  ctx.state._pt = `${hdr} ${theme.fg("muted", "(rendering…)")}`;
1471
1548
  const lg = lang(fp);
1472
- const diff = parseDiff(oldText, newText);
1473
1549
  const dc = resolveDiffColors(theme);
1474
- renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
1475
- .then((rendered) => {
1476
- if (ctx.state._pk !== pk) return;
1477
- ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
1478
- ctx.invalidate();
1479
- })
1480
- .catch(() => {
1481
- if (ctx.state._pk !== pk) return;
1482
- // Fallback: plain word diff
1483
- const diff2 = parseDiff(oldText, newText);
1484
- ctx.state._pt = `${hdr} ${summarize(diff2.added, diff2.removed)}`;
1485
- ctx.invalidate();
1486
- });
1550
+
1551
+ if (operations.length === 1) {
1552
+ const diff = parseDiff(operations[0].oldText, operations[0].newText);
1553
+ renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
1554
+ .then((rendered) => {
1555
+ if (ctx.state._pk !== pk) return;
1556
+ ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
1557
+ ctx.invalidate();
1558
+ })
1559
+ .catch(() => {
1560
+ if (ctx.state._pk !== pk) return;
1561
+ ctx.state._pt = `${hdr} ${summarize(diff.added, diff.removed)}`;
1562
+ ctx.invalidate();
1563
+ });
1564
+ } else {
1565
+ const { diffs, summary } = summarizeEditOperations(operations);
1566
+ const maxShown = Math.min(operations.length, 3);
1567
+ const previewLines = Math.max(8, Math.floor(MAX_PREVIEW_LINES / maxShown));
1568
+ Promise.all(
1569
+ diffs.slice(0, maxShown).map((diff, index) =>
1570
+ renderSplit(diff, lg, previewLines, dc)
1571
+ .then((rendered) => `Edit ${index + 1}/${operations.length}\n${rendered}`)
1572
+ .catch(() => `Edit ${index + 1}/${operations.length} ${summarize(diff.added, diff.removed)}`),
1573
+ ),
1574
+ )
1575
+ .then((sections) => {
1576
+ if (ctx.state._pk !== pk) return;
1577
+ const remainder = operations.length - maxShown;
1578
+ const suffix = remainder > 0 ? `\n${theme.fg("muted", `… ${remainder} more edit blocks`)}` : "";
1579
+ ctx.state._pt = `${hdr}\n${operations.length} edits ${summary}\n\n${sections.join("\n\n")}${suffix}`;
1580
+ ctx.invalidate();
1581
+ })
1582
+ .catch(() => {
1583
+ if (ctx.state._pk !== pk) return;
1584
+ ctx.state._pt = `${hdr} ${operations.length} edits ${summary}`;
1585
+ ctx.invalidate();
1586
+ });
1587
+ }
1487
1588
  }
1488
1589
 
1489
1590
  text.setText(ctx.state._pt ?? hdr);
@@ -1510,6 +1611,14 @@ export default function diffRendererExtension(pi: any): void {
1510
1611
  text.setText(`${content}${" ".repeat(pad)}`);
1511
1612
  return text;
1512
1613
  }
1614
+ if (result.details?._type === "multiEditInfo") {
1615
+ const { summary: s, editCount, diffLineCount } = result.details;
1616
+ const content = ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`;
1617
+ const vis = content.replace(ANSI_RE, "").length;
1618
+ const pad = Math.max(0, termW() - vis);
1619
+ text.setText(`${content}${" ".repeat(pad)}`);
1620
+ return text;
1621
+ }
1513
1622
  text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
1514
1623
  return text;
1515
1624
  },