diff --git a/packages/coding-agent/src/modes/interactive/components/diff.ts b/packages/coding-agent/src/modes/interactive/components/diff.ts index 54e88273d..6c052df7b 100644 --- a/packages/coding-agent/src/modes/interactive/components/diff.ts +++ b/packages/coding-agent/src/modes/interactive/components/diff.ts @@ -20,14 +20,22 @@ function replaceTabs(text: string): string { /** * Compute word-level diff and render with inverse on changed parts. - * Uses diffWords which groups whitespace with adjacent words for cleaner highlighting. - * Strips leading whitespace from inverse to avoid highlighting indentation. + * Separates common indentation before diffing so a leading insertion cannot consume it. */ function renderIntraLineDiff(oldContent: string, newContent: string): { removedLine: string; addedLine: string } { - const wordDiff = Diff.diffWords(oldContent, newContent); + let commonIndentLength = 0; + while ( + commonIndentLength < oldContent.length && + oldContent[commonIndentLength] === newContent[commonIndentLength] && + /\s/.test(oldContent[commonIndentLength]) + ) { + commonIndentLength++; + } - let removedLine = ""; - let addedLine = ""; + const commonIndent = oldContent.slice(0, commonIndentLength); + const wordDiff = Diff.diffWords(oldContent.slice(commonIndentLength), newContent.slice(commonIndentLength)); + let removedLine = commonIndent; + let addedLine = commonIndent; let isFirstRemoved = true; let isFirstAdded = true; diff --git a/packages/coding-agent/test/diff.test.ts b/packages/coding-agent/test/diff.test.ts new file mode 100644 index 000000000..00403a84c --- /dev/null +++ b/packages/coding-agent/test/diff.test.ts @@ -0,0 +1,35 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { renderDiff } from "../src/modes/interactive/components/diff.ts"; +import { initTheme, theme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +describe("renderDiff", () => { + beforeAll(() => initTheme("dark")); + + it.each([ + { + name: "text is inserted at the start of a line", + diff: "-83 await commitRecordingChunk({\n+83 const result = await commitRecordingChunk({", + expected: ["-83 await commitRecordingChunk({", "+83 const result = await commitRecordingChunk({"], + }, + { + name: "indented text is replaced", + diff: "-12 const value = oldValue\n+12 const value = newValue", + expected: ["-12 const value = oldValue", "+12 const value = newValue"], + }, + ])("preserves indentation when $name", ({ diff, expected }) => { + const rendered = renderDiff(diff); + + expect(stripAnsi(rendered).split("\n")).toEqual(expected); + }); + + it("does not highlight shared indentation as an insertion", () => { + const rendered = renderDiff( + "-83 await commitRecordingChunk({\n+83 const result = await commitRecordingChunk({", + ); + const inverseInsertion = theme.inverse("const result = "); + + expect(rendered.indexOf("+83 ")).toBeLessThan(rendered.indexOf(inverseInsertion)); + expect(rendered).toContain(inverseInsertion); + }); +});