@heyhuynhgiabuu/pi-diff 0.1.4 → 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.4",
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%)
@@ -225,6 +231,10 @@ function autoDeriveBgFromTheme(theme: any): void {
225
231
  // Empty filler and context — match the base
226
232
  BG_EMPTY = BG_BASE;
227
233
 
234
+ // Update RST to re-apply base bg after every reset — prevents black
235
+ // flashes between styled segments when toolSuccessBg is non-black
236
+ RST = `\x1b[0m${BG_BASE}`;
237
+
228
238
  // Rebuild derived constants
229
239
  DIVIDER = `${FG_RULE}│${RST}`;
230
240
  } catch {
@@ -234,10 +244,7 @@ function autoDeriveBgFromTheme(theme: any): void {
234
244
 
235
245
  /** Load diff theme config from .pi/settings.json (project-level, then global). */
236
246
  function loadDiffConfig(): DiffUserConfig {
237
- const paths = [
238
- `${process.cwd()}/.pi/settings.json`,
239
- `${process.env.HOME ?? ""}/.pi/settings.json`,
240
- ];
247
+ const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
241
248
  for (const p of paths) {
242
249
  try {
243
250
  if (existsSync(p)) {
@@ -270,32 +277,66 @@ function applyDiffPalette(): void {
270
277
  const applyBg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
271
278
  if (envName && process.env[envName]) return; // env override wins
272
279
  const hex = ov[key] ?? presetVal;
273
- if (hex) { const a = hexToBgAnsi(hex); if (a) set(a); }
280
+ if (hex) {
281
+ const a = hexToBgAnsi(hex);
282
+ if (a) set(a);
283
+ }
274
284
  };
275
285
  // Helper: apply a hex fg color if not env-overridden
276
286
  const applyFg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
277
287
  if (envName && process.env[envName]) return;
278
288
  const hex = ov[key] ?? presetVal;
279
- if (hex) { const a = hexToFgAnsi(hex); if (a) set(a); }
289
+ if (hex) {
290
+ const a = hexToFgAnsi(hex);
291
+ if (a) set(a);
292
+ }
280
293
  };
281
294
 
282
295
  // --- Apply backgrounds ---
283
- applyBg("DIFF_BG_ADD", "bgAdd", preset?.bgAdd, (v) => { BG_ADD = v; });
284
- applyBg("DIFF_BG_DEL", "bgDel", preset?.bgDel, (v) => { BG_DEL = v; });
285
- applyBg("DIFF_BG_ADD_HL", "bgAddHighlight", preset?.bgAddHighlight, (v) => { BG_ADD_W = v; });
286
- applyBg("DIFF_BG_DEL_HL", "bgDelHighlight", preset?.bgDelHighlight, (v) => { BG_DEL_W = v; });
287
- applyBg("DIFF_BG_GUTTER_ADD", "bgGutterAdd", preset?.bgGutterAdd, (v) => { BG_GUTTER_ADD = v; });
288
- applyBg("DIFF_BG_GUTTER_DEL", "bgGutterDel", preset?.bgGutterDel, (v) => { BG_GUTTER_DEL = v; });
289
- 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
+ });
290
317
 
291
318
  // --- Apply foregrounds ---
292
- applyFg("DIFF_FG_ADD", "fgAdd", preset?.fgAdd, (v) => { FG_ADD = v; });
293
- applyFg("DIFF_FG_DEL", "fgDel", preset?.fgDel, (v) => { FG_DEL = v; });
294
- applyFg(null, "fgDim", preset?.fgDim, (v) => { FG_DIM = v; });
295
- applyFg(null, "fgLnum", preset?.fgLnum, (v) => { FG_LNUM = v; });
296
- applyFg(null, "fgRule", preset?.fgRule, (v) => { FG_RULE = v; });
297
- applyFg(null, "fgStripe", preset?.fgStripe, (v) => { FG_STRIPE = v; });
298
- 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
+ });
299
340
 
300
341
  // --- Shiki syntax theme ---
301
342
  const shiki = ov.shikiTheme ?? preset?.shikiTheme;
@@ -370,7 +411,7 @@ const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
370
411
  // ANSI
371
412
  // ---------------------------------------------------------------------------
372
413
 
373
- const RST = "\x1b[0m";
414
+ let RST = "\x1b[0m";
374
415
  const BOLD = "\x1b[1m";
375
416
  const DIM = "\x1b[2m";
376
417
 
@@ -433,8 +474,13 @@ function resolveDiffColors(theme?: any): DiffColors {
433
474
  try {
434
475
  const bgAnsi = theme.getBgAnsi("toolSuccessBg");
435
476
  const parsed = parseAnsiRgb(bgAnsi);
436
- if (parsed) BG_BASE = bgAnsi;
437
- } catch { /* ignore */ }
477
+ if (parsed) {
478
+ BG_BASE = bgAnsi;
479
+ RST = `\x1b[0m${BG_BASE}`;
480
+ }
481
+ } catch {
482
+ /* ignore */
483
+ }
438
484
  }
439
485
 
440
486
  // Auto-derive bg colors from theme on first render (if no explicit preset/overrides)
@@ -1417,66 +1463,128 @@ export default function diffRendererExtension(pi: any): void {
1417
1463
 
1418
1464
  const origEdit = createEditTool(cwd);
1419
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
+
1420
1497
  pi.registerTool({
1421
1498
  ...origEdit,
1422
1499
  name: "edit",
1423
1500
 
1424
1501
  async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1425
1502
  const fp = params.path ?? params.file_path ?? "";
1426
- const oldText = params.oldText ?? params.old_text ?? "";
1427
- const newText = params.newText ?? params.new_text ?? "";
1428
-
1503
+ const operations = getEditOperations(params);
1429
1504
  const result = await origEdit.execute(tid, params, sig, upd, ctx);
1430
1505
 
1431
- if (oldText && oldText !== newText) {
1506
+ if (operations.length === 0) return result;
1507
+
1508
+ const { diffs, summary } = summarizeEditOperations(operations);
1509
+ if (operations.length === 1) {
1432
1510
  let editLine = 0;
1433
1511
  try {
1434
1512
  if (fp && existsSync(fp)) {
1435
1513
  const f = readFileSync(fp, "utf-8");
1436
- const idx = f.indexOf(newText);
1514
+ const idx = f.indexOf(operations[0].newText);
1437
1515
  if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
1438
1516
  }
1439
1517
  } catch {
1440
1518
  editLine = 0;
1441
1519
  }
1442
- const diff = parseDiff(oldText, newText);
1443
- (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;
1444
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
+ };
1445
1530
  return result;
1446
1531
  },
1447
1532
 
1448
1533
  renderCall(args: any, theme: any, ctx: any) {
1449
1534
  const fp = args?.path ?? args?.file_path ?? "";
1450
- const oldText = args?.oldText ?? args?.old_text ?? "";
1451
- const newText = args?.newText ?? args?.new_text ?? "";
1535
+ const operations = getEditOperations(args);
1452
1536
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1453
1537
  const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
1454
1538
 
1455
- if (!(ctx.argsComplete && oldText && oldText !== newText)) {
1539
+ if (!(ctx.argsComplete && operations.length > 0)) {
1456
1540
  text.setText(hdr);
1457
1541
  return text;
1458
1542
  }
1459
1543
 
1460
- const pk = JSON.stringify({ fp, oldText, newText, w: termW() });
1544
+ const pk = JSON.stringify({ fp, operations, w: termW() });
1461
1545
  if (ctx.state._pk !== pk) {
1462
1546
  ctx.state._pk = pk;
1463
1547
  ctx.state._pt = `${hdr} ${theme.fg("muted", "(rendering…)")}`;
1464
1548
  const lg = lang(fp);
1465
- const diff = parseDiff(oldText, newText);
1466
1549
  const dc = resolveDiffColors(theme);
1467
- renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
1468
- .then((rendered) => {
1469
- if (ctx.state._pk !== pk) return;
1470
- ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
1471
- ctx.invalidate();
1472
- })
1473
- .catch(() => {
1474
- if (ctx.state._pk !== pk) return;
1475
- // Fallback: plain word diff
1476
- const diff2 = parseDiff(oldText, newText);
1477
- ctx.state._pt = `${hdr} ${summarize(diff2.added, diff2.removed)}`;
1478
- ctx.invalidate();
1479
- });
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
+ }
1480
1588
  }
1481
1589
 
1482
1590
  text.setText(ctx.state._pt ?? hdr);
@@ -1503,6 +1611,14 @@ export default function diffRendererExtension(pi: any): void {
1503
1611
  text.setText(`${content}${" ".repeat(pad)}`);
1504
1612
  return text;
1505
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
+ }
1506
1622
  text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
1507
1623
  return text;
1508
1624
  },