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("