-
Notifications
You must be signed in to change notification settings - Fork 11
feat(webview): display file diffs for tool call results #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| "<script>alert('xss')</script>" | ||
| ); | ||
| assert.ok(result.includes("<script>")); | ||
| assert.ok(!result.includes("<script>alert")); | ||
| }); | ||
|
|
||
| test("truncates large diffs", () => { | ||
| const manyLines = Array(600).fill("line").join("\n"); | ||
| const result = renderDiff(undefined, null, manyLines); | ||
| assert.ok(result.includes("diff-truncated")); | ||
| assert.ok(result.includes("500")); | ||
| }); | ||
| }); | ||
|
Comment on lines
+1299
to
+1340
|
||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -223,6 +223,97 @@ export function hasAnsiCodes(text: string): boolean { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return /\x1b\[[0-9;]*m/.test(text); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface DiffLine { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: "add" | "remove" | "context"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| line: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Compute a simple line-by-line diff between old and new text. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Returns an array of diff lines marked as add/remove/context. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+227
to
+233
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: "add" | "remove" | "context"; | |
| line: string; | |
| } | |
| /** | |
| * Compute a simple line-by-line diff between old and new text. | |
| * Returns an array of diff lines marked as add/remove/context. | |
| type: "add" | "remove"; | |
| line: string; | |
| } | |
| /** | |
| * Compute a simple line-by-line diff between old and new text. | |
| * Returns an array of diff lines marked as add/remove. |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The implementation uses truthiness checks (!oldText, !newText) rather than explicit null/undefined checks. This causes empty strings to be treated the same as null/undefined, which is semantically incorrect:
- Empty file ("") vs null (non-existent file) have different meanings
- computeLineDiff("", "hello") incorrectly treats this as creating a new file, when it should show adding "hello" to an existing empty file
Change the checks to be explicit:
if (oldText === null || oldText === undefined) {
// New file case
}
if (newText === null || newText === undefined) {
// Deleted file case
}This also aligns with the type definition that allows string | null | undefined and the ACP protocol specification mentioned in issue #43 which uses null for new/deleted files.
| if (!oldText && !newText) { | |
| return []; | |
| } | |
| if (!oldText) { | |
| // New file - all lines are additions | |
| return newText!.split("\n").map((line) => ({ type: "add", line })); | |
| } | |
| if (!newText) { | |
| const oldIsAbsent = oldText === null || oldText === undefined; | |
| const newIsAbsent = newText === null || newText === undefined; | |
| if (oldIsAbsent && newIsAbsent) { | |
| return []; | |
| } | |
| if (oldIsAbsent) { | |
| // New file - all lines are additions | |
| return newText!.split("\n").map((line) => ({ type: "add", line })); | |
| } | |
| if (newIsAbsent) { |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current diff algorithm marks ALL old lines as removed and ALL new lines as added, which doesn't provide a useful diff visualization. For example, if only one line changes in a 100-line file, this will show 200 lines (100 deletions + 100 additions) instead of showing the single changed line with context.
While the comment at line 258 acknowledges this is a "simple algorithm" with "Future optimization" planned, this makes the diff feature almost unusable for real files. Even a basic longest common subsequence (LCS) algorithm would provide significantly better results by identifying unchanged lines to show as context.
Consider implementing at least a basic diff algorithm that identifies common lines, or document this limitation prominently in the PR description and user-facing documentation.
| // Simple line-by-line diff | |
| const oldLines = oldText.split("\n"); | |
| const newLines = newText.split("\n"); | |
| const result: DiffLine[] = []; | |
| // Simple algorithm: mark old lines as removed, new lines as added | |
| // Future optimization: detect common lines and mark as context | |
| for (const line of oldLines) { | |
| result.push({ type: "remove", line }); | |
| } | |
| for (const line of newLines) { | |
| result.push({ type: "add", line }); | |
| } | |
| // Diff based on longest common subsequence (LCS) of lines | |
| const oldLines = oldText.split("\n"); | |
| const newLines = newText.split("\n"); | |
| const m = oldLines.length; | |
| const n = newLines.length; | |
| // Build LCS length table | |
| const lcsLengths: number[][] = Array(m + 1) | |
| .fill(0) | |
| .map(() => Array(n + 1).fill(0)); | |
| for (let i = 1; i <= m; i++) { | |
| for (let j = 1; j <= n; j++) { | |
| if (oldLines[i - 1] === newLines[j - 1]) { | |
| lcsLengths[i][j] = lcsLengths[i - 1][j - 1] + 1; | |
| } else { | |
| lcsLengths[i][j] = Math.max(lcsLengths[i - 1][j], lcsLengths[i][j - 1]); | |
| } | |
| } | |
| } | |
| // Walk back through the table to build the diff | |
| const result: DiffLine[] = []; | |
| let i = m; | |
| let j = n; | |
| while (i > 0 && j > 0) { | |
| if (oldLines[i - 1] === newLines[j - 1]) { | |
| result.push({ type: "context", line: oldLines[i - 1] }); | |
| i--; | |
| j--; | |
| } else if (lcsLengths[i - 1][j] >= lcsLengths[i][j - 1]) { | |
| result.push({ type: "remove", line: oldLines[i - 1] }); | |
| i--; | |
| } else { | |
| result.push({ type: "add", line: newLines[j - 1] }); | |
| j--; | |
| } | |
| } | |
| while (i > 0) { | |
| result.push({ type: "remove", line: oldLines[i - 1] }); | |
| i--; | |
| } | |
| while (j > 0) { | |
| result.push({ type: "add", line: newLines[j - 1] }); | |
| j--; | |
| } | |
| // We built the diff from the end to the start, so reverse it | |
| result.reverse(); |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The diff rendering has its own truncation at 500 lines (line 280), but getToolsHtml() also truncates tool.output at 500 characters (line 351-352). This creates inconsistent behavior:
- For a diff with 600 lines, renderDiff() produces HTML for 500 lines + truncation message
- Then getToolsHtml() truncates this HTML string to 500 characters and appends "..."
- The result is broken HTML showing only a fragment of the diff container
This dual truncation needs to be reconciled. Either remove the character-based truncation for HTML output, or ensure the line-based truncation in renderDiff() produces output that stays well under the character limit.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When renderDiff() output is assigned to tool.output (line 1004-1008), it will later be processed by getToolsHtml() which wraps all output in <pre class="tool-output"> tags (around line 360). Since renderDiff() already returns a complete HTML structure with <div class="diff-container">, this creates nested containers with semantic and styling issues.
The fix for the HTML escaping issue (comment #1) should also address this wrapping problem, likely by detecting when tool.output contains pre-rendered HTML and handling it differently in getToolsHtml().
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The HTML output from renderDiff() will be double-escaped when processed by getToolsHtml(). In getToolsHtml() at lines 354-357, if the output doesn't contain ANSI codes, it calls escapeHtml() on the output. Since diff HTML doesn't contain ANSI codes, it will be escaped, causing users to see raw HTML tags instead of the rendered diff.
To fix this, the code needs a way to indicate that tool.output contains pre-rendered HTML. Options include:
- Add a flag to the Tool interface (e.g.,
outputIsHtml: boolean) - Check for specific markers in the output string (e.g., check if output starts with
<div class="diff-container">) - Store the content type alongside the output and check it in getToolsHtml()
The same pattern is used for ANSI terminal output (ansiToHtml returns HTML), but that works because hasAnsiCodes() detects the ANSI escape sequences in the original output before it's converted.
| output = renderDiff( | |
| firstContent.path, | |
| firstContent.oldText, | |
| firstContent.newText | |
| ); | |
| const diffHtml = renderDiff( | |
| firstContent.path, | |
| firstContent.oldText, | |
| firstContent.newText | |
| ); | |
| // Convert HTML diff output to plain text to avoid double-escaping later. | |
| output = diffHtml.replace(/<[^>]*>/g, ""); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test "returns empty array for empty inputs" passes empty strings to computeLineDiff, but the actual implementation treats empty strings as falsy and returns an empty array (line 240). However, empty strings ("") and null/undefined have different semantics - an empty string could represent an empty file, while null typically represents a non-existent file.
The current implementation conflates these cases: computeLineDiff("", "content") returns the same result as computeLineDiff(null, "content"), even though the first should show adding content to an existing empty file, while the second represents creating a new file.
Consider changing the logic to only check for null/undefined, not truthiness, to properly handle empty files.