// Renders an edit diff with word-level comparison. The line diff comes from // the ported LCS alignment; each paired removed/added line pair is compared at // word level by the ported change-block engine, and the changed words are // marked — ANSI backgrounds with a theme, `[-word-]` / `{+word+}` markers // without one (the git word-diff convention). The whole preview obeys the // shared 2000-char bound. import type { Theme } from "@earendil-works/pi-coding-agent"; import { boundPreviewLines } from "./bound.ts"; import { previewStyle } from "./style.ts"; import { suffixAlignedPairs } from "./word-diff/alignment.ts"; import { changedWordRanges, type TextRange } from "./word-diff/change-block.ts"; export interface DiffPreviewInput { /** Text after the edit. */ readonly after: string; /** Text before the edit. */ readonly before: string; /** File the edit targets; the row title already shows it (call context). */ readonly path?: string; } type DiffRun = | { readonly kind: "context"; readonly text: string } | { readonly kind: "change"; readonly removed: readonly string[]; readonly added: readonly string[]; }; const ADD_EMPHASIS_OPEN = "\x1b[48;2;64;132;82m"; const REMOVE_EMPHASIS_OPEN = "\x1b[48;2;148;62;70m"; const EMPHASIS_CLOSE = "\x1b[49m"; const REMOVED_MARKER_OPEN = "[-"; const REMOVED_MARKER_CLOSE = "-]"; const ADDED_MARKER_OPEN = "{+"; const ADDED_MARKER_CLOSE = "+}"; export function renderDiffPreview( input: DiffPreviewInput, theme?: Theme ): string[] { if (input.before === input.after) { return [previewStyle(theme, "muted", "no changes")]; } const lines: string[] = []; for (const run of diffRuns( splitLines(input.before), splitLines(input.after) )) { if (run.kind === "context") { lines.push(previewStyle(theme, "toolDiffContext", ` ${run.text}`)); continue; } const paired = Math.min(run.removed.length, run.added.length); for (let index = 0; index < paired; index += 1) { const removedLine = run.removed[index] ?? ""; const addedLine = run.added[index] ?? ""; const ranges = changedWordRanges(removedLine, addedLine); lines.push( renderChangedLine("removed", removedLine, ranges.removed, theme) ); lines.push(renderChangedLine("added", addedLine, ranges.added, theme)); } for (let index = paired; index < run.removed.length; index += 1) { lines.push( renderChangedLine("removed", run.removed[index] ?? "", [], theme) ); } for (let index = paired; index < run.added.length; index += 1) { lines.push(renderChangedLine("added", run.added[index] ?? "", [], theme)); } } return [...boundPreviewLines(lines).lines]; } function renderChangedLine( kind: "removed" | "added", text: string, ranges: readonly TextRange[], theme: Theme | undefined ): string { const added = kind === "added"; const markerOpen = added ? ADDED_MARKER_OPEN : REMOVED_MARKER_OPEN; const markerClose = added ? ADDED_MARKER_CLOSE : REMOVED_MARKER_CLOSE; const emphasisOpen = added ? ADD_EMPHASIS_OPEN : REMOVE_EMPHASIS_OPEN; const content = previewStyle( theme, "toolOutput", injectMarkers( text, ranges, theme === undefined ? markerOpen : emphasisOpen, theme === undefined ? markerClose : EMPHASIS_CLOSE ) ); return `${previewStyle(theme, added ? "toolDiffAdded" : "toolDiffRemoved", added ? "+ " : "- ")}${content}`; } function injectMarkers( text: string, ranges: readonly TextRange[], open: string, close: string ): string { let out = ""; let cursor = 0; for (const [start, end] of ranges) { if (start > cursor) { out += text.slice(cursor, start); } out += open; out += text.slice(start, end); out += close; cursor = end; } out += text.slice(cursor); return out; } function diffRuns(before: string[], after: string[]): DiffRun[] { const pairs = suffixAlignedPairs( before.length, after.length, (beforeOffset, afterOffset) => before[beforeOffset] === after[afterOffset] ? 1 : Number.NEGATIVE_INFINITY ); const runs: DiffRun[] = []; let beforeIndex = 0; let afterIndex = 0; for (const [nextBeforeIndex, nextAfterIndex] of pairs) { if (beforeIndex < nextBeforeIndex || afterIndex < nextAfterIndex) { runs.push({ kind: "change", removed: before.slice(beforeIndex, nextBeforeIndex), added: after.slice(afterIndex, nextAfterIndex), }); } runs.push({ kind: "context", text: before[nextBeforeIndex] ?? "" }); beforeIndex = nextBeforeIndex + 1; afterIndex = nextAfterIndex + 1; } if (beforeIndex < before.length || afterIndex < after.length) { runs.push({ kind: "change", removed: before.slice(beforeIndex), added: after.slice(afterIndex), }); } return runs; } function splitLines(text: string): string[] { const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); if (lines.length > 1 && (lines.at(-1) ?? "") === "") { lines.pop(); } return lines; }