Skip to content

Commit 44df1c4

Browse files
committed
feat(ui): LCS-paired diff with word-level highlights on edits
The previous diffSummary zipped old_string and new_string by index, so a one-line insertion at the top marked every line below as changed. Switch to the diff library's diffLines for proper LCS pairing — adding one line at the top now produces a one-line hunk. Within paired remove/add rows of equal line count, diffWordsWithSpace identifies which words actually changed. Those words render with a brighter background so the eye lands on the substantive change instead of scanning whole red/green lines for the difference.
1 parent b0c80e1 commit 44df1c4

3 files changed

Lines changed: 172 additions & 40 deletions

File tree

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
"dependencies": {
6161
"@earendil-works/pi-agent-core": "0.74.0",
6262
"@earendil-works/pi-ai": "0.74.0",
63+
"@types/diff": "^7.0.2",
64+
"diff": "^9.0.0",
6365
"glob": "^13.0.1",
6466
"ignore": "^7.0.0",
6567
"ink": "^5.2.1",

src/ui/Message.tsx

Lines changed: 153 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { sep as pathSep, relative as relativePath } from "node:path";
22
import type { AgentMessage } from "@earendil-works/pi-agent-core";
3+
import { diffLines, diffWordsWithSpace } from "diff";
34
import { Box, Text } from "ink";
45
import { type ReactNode, useEffect, useState } from "react";
56
import type { ToolExecution } from "../types.js";
@@ -336,94 +337,206 @@ function nounForReadTool(name: string, count: number): string {
336337
return count === 1 ? "call" : "calls";
337338
}
338339

340+
/** One word-level span inside a paired remove/add line. */
341+
interface WordPart {
342+
text: string;
343+
/** True when this span is the *changed* part (renders with a brighter background). */
344+
highlight: boolean;
345+
}
346+
347+
interface DiffHunk {
348+
type: "remove" | "add";
349+
text: string;
350+
/** Present when this line was paired with a counterpart line — enables word-level highlight. */
351+
wordParts?: WordPart[];
352+
}
353+
339354
interface DiffInfo {
340355
added: number;
341356
removed: number;
342-
hunks: Array<{ minus: string; plus: string }>;
357+
hunks: DiffHunk[];
358+
/** True when the change set exceeded MAX_HUNK_LINES and we clipped the preview. */
359+
truncated: boolean;
343360
}
344361

362+
/** How many change lines we'll render before collapsing to just the +/- counts. */
363+
const MAX_HUNK_LINES = 12;
364+
345365
/**
346366
* Build a diff summary for a completed file-edit tool call from the
347367
* tool's args. We have old_string + new_string right there, so no
348-
* filesystem round-trip needed. Limits the hunk preview to 6 lines
349-
* total (the small-diff sweet spot) so noisy refactors collapse to
350-
* just the +/- counts.
368+
* filesystem round-trip needed. Uses the `diff` library's LCS-based
369+
* line pairing — adding a single line at the top no longer marks the
370+
* whole rest of the file as "changed."
351371
*/
352372
function diffSummary(name: string, args: unknown): DiffInfo | null {
353373
const a = (args ?? {}) as Record<string, unknown>;
354374
if (name === "edit_file") {
355375
const oldStr = typeof a.old_string === "string" ? a.old_string : "";
356376
const newStr = typeof a.new_string === "string" ? a.new_string : "";
357377
if (!oldStr && !newStr) return null;
358-
return countDiff(oldStr, newStr);
378+
return buildDiff(oldStr, newStr);
359379
}
360380
if (name === "multi_edit") {
361381
const edits = Array.isArray(a.edits) ? a.edits : [];
362382
let added = 0;
363383
let removed = 0;
364-
const hunks: DiffInfo["hunks"] = [];
384+
const hunks: DiffHunk[] = [];
385+
let truncated = false;
365386
for (const e of edits) {
366387
if (!e || typeof e !== "object") continue;
367388
const ed = e as Record<string, unknown>;
368389
const oldStr = typeof ed.old_string === "string" ? ed.old_string : "";
369390
const newStr = typeof ed.new_string === "string" ? ed.new_string : "";
370-
const sub = countDiff(oldStr, newStr);
391+
const sub = buildDiff(oldStr, newStr);
371392
added += sub.added;
372393
removed += sub.removed;
394+
truncated = truncated || sub.truncated;
373395
hunks.push(...sub.hunks);
374396
}
375397
if (added === 0 && removed === 0) return null;
376-
return { added, removed, hunks: hunks.slice(0, 6) };
398+
return {
399+
added,
400+
removed,
401+
hunks: hunks.slice(0, MAX_HUNK_LINES),
402+
truncated: truncated || hunks.length > MAX_HUNK_LINES,
403+
};
377404
}
378405
if (name === "write_file") {
379406
const content = typeof a.content === "string" ? a.content : "";
380407
if (!content) return null;
381408
const lines = content.split("\n").length;
382-
return { added: lines, removed: 0, hunks: [] };
409+
return { added: lines, removed: 0, hunks: [], truncated: false };
383410
}
384411
return null;
385412
}
386413

387-
function countDiff(oldStr: string, newStr: string): DiffInfo {
388-
const oldLines = oldStr ? oldStr.split("\n") : [];
389-
const newLines = newStr ? newStr.split("\n") : [];
390-
const hunks: DiffInfo["hunks"] = [];
391-
const maxRows = Math.max(oldLines.length, newLines.length);
392-
for (let i = 0; i < maxRows; i++) {
393-
const minus = oldLines[i] ?? "";
394-
const plus = newLines[i] ?? "";
395-
if (minus === plus) continue;
396-
hunks.push({ minus, plus });
414+
/**
415+
* LCS-based line diff, then pair adjacent remove+add changes so we can
416+
* surface a word-level highlight on each paired line. When a pair has
417+
* the same number of lines on each side, we line-align them and run
418+
* diffWordsWithSpace per row — that's the cleanest case and matches
419+
* the user expectation of "show me what actually changed in this row."
420+
*/
421+
function buildDiff(oldStr: string, newStr: string): DiffInfo {
422+
const changes = diffLines(oldStr, newStr);
423+
const hunks: DiffHunk[] = [];
424+
let added = 0;
425+
let removed = 0;
426+
const lineCount = (s: string) => (s ? s.replace(/\n$/, "").split("\n").length : 0);
427+
428+
for (let i = 0; i < changes.length; i++) {
429+
const c = changes[i];
430+
if (c.added) added += lineCount(c.value);
431+
if (c.removed) removed += lineCount(c.value);
432+
433+
const next = changes[i + 1];
434+
const isPair = c.removed && next?.added;
435+
if (isPair) {
436+
const removeLines = c.value.replace(/\n$/, "").split("\n");
437+
const addLines = next.value.replace(/\n$/, "").split("\n");
438+
if (removeLines.length === addLines.length) {
439+
// Paired row-by-row → word-level diff per row.
440+
for (let j = 0; j < removeLines.length; j++) {
441+
const parts = diffWordsWithSpace(removeLines[j], addLines[j]);
442+
hunks.push({
443+
type: "remove",
444+
text: removeLines[j],
445+
wordParts: parts
446+
.filter((p) => !p.added)
447+
.map((p) => ({ text: p.value, highlight: !!p.removed })),
448+
});
449+
hunks.push({
450+
type: "add",
451+
text: addLines[j],
452+
wordParts: parts
453+
.filter((p) => !p.removed)
454+
.map((p) => ({ text: p.value, highlight: !!p.added })),
455+
});
456+
}
457+
} else {
458+
// Asymmetric pair — show all removes then all adds without word diff.
459+
for (const line of removeLines) hunks.push({ type: "remove", text: line });
460+
for (const line of addLines) hunks.push({ type: "add", text: line });
461+
}
462+
i++; // Consume the paired add change.
463+
continue;
464+
}
465+
466+
if (c.removed || c.added) {
467+
const type: DiffHunk["type"] = c.added ? "add" : "remove";
468+
for (const line of c.value.replace(/\n$/, "").split("\n")) {
469+
hunks.push({ type, text: line });
470+
}
471+
}
472+
// Context (neither added nor removed) is dropped — the +N/-M
473+
// counts plus the change lines themselves give enough orientation
474+
// for the small previews we render.
397475
}
398-
return { added: newLines.length, removed: oldLines.length, hunks: hunks.slice(0, 6) };
476+
477+
const truncated = hunks.length > MAX_HUNK_LINES;
478+
return { added, removed, hunks: hunks.slice(0, MAX_HUNK_LINES), truncated };
399479
}
400480

401481
/**
402-
* Render the +N -M summary line, then up to 6 alternating - / + lines
403-
* for the actual diff. Beyond 6 lines, just shows the counts — keeps
404-
* the transcript from drowning in a 200-line refactor.
482+
* Render the +N -M summary line, then up to MAX_HUNK_LINES change lines.
483+
* Removed lines render in red, added lines in green. Within a paired
484+
* remove/add row, the actually-changed words get a brighter background
485+
* so the eye lands on the substantive change immediately.
405486
*/
406487
function DiffSummary({ diff, width, keyPrefix }: { diff: DiffInfo; width: number; keyPrefix: string }) {
407-
const counts = ` +${diff.added} -${diff.removed}`;
488+
const counts = diff.truncated
489+
? ` +${diff.added} -${diff.removed} (preview truncated)`
490+
: ` +${diff.added} -${diff.removed}`;
491+
const lineWidth = Math.max(20, width - 8);
408492
return (
409493
<Box flexDirection="column" marginLeft={2}>
410494
<Text dimColor>{counts}</Text>
411-
{diff.hunks.map((h) => (
412-
<Box key={`${keyPrefix}-h-${h.minus.slice(0, 16)}-${h.plus.slice(0, 16)}`} flexDirection="column">
413-
{h.minus ? (
414-
<Text color="red">
415-
{" - "}
416-
{truncate(h.minus, Math.max(20, width - 8))}
417-
</Text>
418-
) : null}
419-
{h.plus ? (
420-
<Text color="green">
421-
{" + "}
422-
{truncate(h.plus, Math.max(20, width - 8))}
423-
</Text>
424-
) : null}
425-
</Box>
426-
))}
495+
{diff.hunks.map((h, i) => {
496+
const isRemove = h.type === "remove";
497+
const sign = isRemove ? " - " : " + ";
498+
const lineColor = isRemove ? "red" : "green";
499+
const hlBg = isRemove ? "redBright" : "greenBright";
500+
const key = `${keyPrefix}-h-${i}-${h.type}-${h.text.slice(0, 24)}`;
501+
if (h.wordParts && h.wordParts.length > 0) {
502+
// Truncate at the part boundary that crosses the width budget.
503+
let used = 0;
504+
const visibleParts: WordPart[] = [];
505+
for (const p of h.wordParts) {
506+
const remaining = lineWidth - used;
507+
if (remaining <= 0) break;
508+
if (p.text.length <= remaining) {
509+
visibleParts.push(p);
510+
used += p.text.length;
511+
} else {
512+
visibleParts.push({ ...p, text: `${p.text.slice(0, Math.max(0, remaining - 1))}…` });
513+
break;
514+
}
515+
}
516+
return (
517+
<Box key={key}>
518+
<Text color={lineColor}>{sign}</Text>
519+
<Text>
520+
{visibleParts.map((p, j) => (
521+
<Text
522+
key={`${key}-w-${j}`}
523+
color={lineColor}
524+
backgroundColor={p.highlight ? hlBg : undefined}
525+
>
526+
{p.text}
527+
</Text>
528+
))}
529+
</Text>
530+
</Box>
531+
);
532+
}
533+
return (
534+
<Text key={key} color={lineColor}>
535+
{sign}
536+
{truncate(h.text, lineWidth)}
537+
</Text>
538+
);
539+
})}
427540
</Box>
428541
);
429542
}

0 commit comments

Comments
 (0)