From cf901894c7b89f9fcb97d1d0ca4fe1e47e7d3d0a Mon Sep 17 00:00:00 2001 From: Omer C <639682+omercnet@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:03:10 +0200 Subject: [PATCH] feat(webview): display file diffs for tool call results (#43) Add visual diff rendering for file edit operations in the chat view. When agents modify files, users now see a clear before/after comparison with syntax highlighting for additions and deletions. Implementation: - Add computeLineDiff() for simple line-by-line diff algorithm - Add renderDiff() to generate HTML with diff styling - Integrate diff rendering into toolCallComplete handler - Add CSS styles for diff visualization (green additions, red deletions) - Handle edge cases: empty files, new files, large diffs (truncated at 500 lines) Tests: - Add 10 unit tests for diff computation and rendering - Test HTML escaping, truncation, and edge cases Closes #43 --- media/main.css | 66 ++++++++++++++++++++++++++ src/test/webview.test.ts | 77 +++++++++++++++++++++++++++++++ src/views/webview/main.ts | 97 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+) diff --git a/media/main.css b/media/main.css index bfbff34..a5e3a99 100644 --- a/media/main.css +++ b/media/main.css @@ -889,3 +889,69 @@ kbd { .thought-content { padding: 10px 12px; } + +/* Diff Display Styles */ +.diff-container { + margin: 8px 0; + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--vscode-panel-border); +} + +.diff-header { + font-weight: bold; + padding: 6px 10px; + background: var(--vscode-editor-lineHighlightBackground); + border-bottom: 1px solid var(--vscode-panel-border); + font-family: var(--vscode-editor-font-family); + font-size: var(--vscode-editor-font-size); +} + +.diff-content { + font-family: var(--vscode-editor-font-family); + font-size: var(--vscode-editor-font-size); + margin: 0; + padding: 0; + overflow-x: auto; +} + +.diff-line { + padding: 1px 10px; + white-space: pre; + line-height: 1.4; +} + +.diff-add { + background-color: var(--vscode-diffEditor-insertedTextBackground); + color: var( + --vscode-diffEditor-insertedTextForeground, + var(--vscode-editor-foreground) + ); +} + +.diff-remove { + background-color: var(--vscode-diffEditor-removedTextBackground); + color: var( + --vscode-diffEditor-removedTextForeground, + var(--vscode-editor-foreground) + ); +} + +.diff-context { + color: var(--vscode-descriptionForeground); +} + +.diff-empty { + padding: 10px; + color: var(--vscode-descriptionForeground); + font-style: italic; + text-align: center; +} + +.diff-truncated { + padding: 6px 10px; + color: var(--vscode-descriptionForeground); + font-style: italic; + background: var(--vscode-editor-lineHighlightBackground); + border-top: 1px solid var(--vscode-panel-border); +} diff --git a/src/test/webview.test.ts b/src/test/webview.test.ts index ab98738..6a31f6b 100644 --- a/src/test/webview.test.ts +++ b/src/test/webview.test.ts @@ -10,6 +10,8 @@ import { ansiToHtml, hasAnsiCodes, getToolKindIcon, + computeLineDiff, + renderDiff, type VsCodeApi, type Tool, type WebviewElements, @@ -1261,4 +1263,79 @@ suite("Webview", () => { assert.ok(html.includes('title="edit"')); }); }); + + suite("computeLineDiff", () => { + test("returns empty array for empty inputs", () => { + const result = computeLineDiff("", ""); + assert.strictEqual(result.length, 0); + }); + + test("marks all lines as add for new file", () => { + const result = computeLineDiff(null, "line1\nline2"); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].type, "add"); + assert.strictEqual(result[0].line, "line1"); + assert.strictEqual(result[1].type, "add"); + assert.strictEqual(result[1].line, "line2"); + }); + + test("marks all lines as remove for deleted file", () => { + const result = computeLineDiff("line1\nline2", null); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].type, "remove"); + assert.strictEqual(result[1].type, "remove"); + }); + + test("marks old as remove and new as add for modified file", () => { + const result = computeLineDiff("old", "new"); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].type, "remove"); + assert.strictEqual(result[0].line, "old"); + assert.strictEqual(result[1].type, "add"); + assert.strictEqual(result[1].line, "new"); + }); + }); + + suite("renderDiff", () => { + test("returns no changes message for empty diff", () => { + const result = renderDiff(undefined, "", ""); + assert.ok(result.includes("diff-container")); + assert.ok(result.includes("No changes")); + }); + + test("renders file path header when provided", () => { + const result = renderDiff("/path/to/file.ts", null, "new content"); + assert.ok(result.includes("diff-header")); + assert.ok(result.includes("/path/to/file.ts")); + }); + + test("renders additions with diff-add class", () => { + const result = renderDiff(undefined, null, "added line"); + assert.ok(result.includes("diff-add")); + assert.ok(result.includes("+ added line")); + }); + + test("renders deletions with diff-remove class", () => { + const result = renderDiff(undefined, "removed line", null); + assert.ok(result.includes("diff-remove")); + assert.ok(result.includes("- removed line")); + }); + + test("escapes HTML in diff content", () => { + const result = renderDiff( + undefined, + null, + "" + ); + assert.ok(result.includes("<script>")); + assert.ok(!result.includes("