diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0db139ff05a0..12fdfe601fc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1020,6 +1020,9 @@ importers: debounce: specifier: ^2.1.1 version: 2.2.0 + diff: + specifier: ^5.2.0 + version: 5.2.0 fast-deep-equal: specifier: ^3.1.3 version: 3.1.3 @@ -1153,6 +1156,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/diff': + specifier: ^5.2.1 + version: 5.2.3 '@types/jest': specifier: ^29.0.0 version: 29.5.14 @@ -14020,7 +14026,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: diff --git a/src/core/diff/stats.ts b/src/core/diff/stats.ts new file mode 100644 index 000000000000..b842f5c04e88 --- /dev/null +++ b/src/core/diff/stats.ts @@ -0,0 +1,71 @@ +import { parsePatch, createTwoFilesPatch } from "diff" + +/** + * Diff utilities for backend (extension) use. + * Source of truth for diff normalization and stats. + */ + +export interface DiffStats { + added: number + removed: number +} + +/** + * Remove non-semantic diff noise like "No newline at end of file" + */ +export function sanitizeUnifiedDiff(diff: string): string { + if (!diff) return diff + return diff.replace(/\r\n/g, "\n").replace(/(^|\n)[ \t]*(?:\\ )?No newline at end of file[ \t]*(?=\n|$)/gi, "$1") +} + +/** + * Compute +/− counts from a unified diff (ignores headers/hunk lines) + */ +export function computeUnifiedDiffStats(diff?: string): DiffStats | null { + if (!diff) return null + + try { + const patches = parsePatch(diff) + if (!patches || patches.length === 0) return null + + let added = 0 + let removed = 0 + + for (const p of patches) { + for (const h of (p as any).hunks ?? []) { + for (const l of h.lines ?? []) { + const ch = (l as string)[0] + if (ch === "+") added++ + else if (ch === "-") removed++ + } + } + } + + if (added > 0 || removed > 0) return { added, removed } + return { added: 0, removed: 0 } + } catch { + // If parsing fails for any reason, signal no stats + return null + } +} + +/** + * Compute diff stats from any supported diff format (unified or search-replace) + * Tries unified diff format first, then falls back to search-replace format + */ +export function computeDiffStats(diff?: string): DiffStats | null { + if (!diff) return null + return computeUnifiedDiffStats(diff) +} + +/** + * Build a unified diff for a brand new file (all content lines are additions). + * Trailing newline is ignored for line counting and emission. + */ +export function convertNewFileToUnifiedDiff(content: string, filePath?: string): string { + const newFileName = filePath || "file" + // Normalize EOLs; rely on library for unified patch formatting + const normalized = (content || "").replace(/\r\n/g, "\n") + // Old file is empty (/dev/null), new file has content; zero context to show all lines as additions + return createTwoFilesPatch("/dev/null", newFileName, "", normalized, undefined, undefined, { context: 0 }) +} diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 2f3ea87d4c6b..21703684b8bc 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -177,7 +177,9 @@ Otherwise, if you have not completed the task and do not need additional informa createPrettyPatch: (filename = "file", oldStr?: string, newStr?: string) => { // strings cannot be undefined or diff throws exception - const patch = diff.createPatch(filename.toPosix(), oldStr || "", newStr || "") + const patch = diff.createPatch(filename.toPosix(), oldStr || "", newStr || "", undefined, undefined, { + context: 3, + }) const lines = patch.split("\n") const prettyPatchLines = lines.slice(4) return prettyPatchLines.join("\n") diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index dcdd13462401..1077b7bf3909 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -13,6 +13,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" export async function applyDiffToolLegacy( cline: Task, @@ -140,6 +141,11 @@ export async function applyDiffToolLegacy( cline.consecutiveMistakeCount = 0 cline.consecutiveMistakeCountForApplyDiff.delete(relPath) + // Generate backend-unified diff for display in chat/webview + const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, originalContent, diffResult.content) + const unifiedPatch = sanitizeUnifiedDiff(unifiedPatchRaw) + const diffStats = computeDiffStats(unifiedPatch) || undefined + // Check if preventFocusDisruption experiment is enabled const provider = cline.providerRef.deref() const state = await provider?.getState() @@ -158,6 +164,8 @@ export async function applyDiffToolLegacy( const completeMessage = JSON.stringify({ ...sharedMessageProps, diff: diffContent, + content: unifiedPatch, + diffStats, isProtected: isWriteProtected, } satisfies ClineSayTool) @@ -194,6 +202,8 @@ export async function applyDiffToolLegacy( const completeMessage = JSON.stringify({ ...sharedMessageProps, diff: diffContent, + content: unifiedPatch, + diffStats, isProtected: isWriteProtected, } satisfies ClineSayTool) diff --git a/src/core/tools/insertContentTool.ts b/src/core/tools/insertContentTool.ts index e7d3a06ab92d..38ca309a3b33 100644 --- a/src/core/tools/insertContentTool.ts +++ b/src/core/tools/insertContentTool.ts @@ -12,6 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { insertGroups } from "../diff/insert-groups" import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" export async function insertContentTool( cline: Task, @@ -101,7 +102,7 @@ export async function insertContentTool( cline.diffViewProvider.originalContent = fileContent const lines = fileExists ? fileContent.split("\n") : [] - const updatedContent = insertGroups(lines, [ + let updatedContent = insertGroups(lines, [ { index: lineNumber - 1, elements: content.split("\n"), @@ -118,31 +119,31 @@ export async function insertContentTool( EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, ) - // For consistency with writeToFileTool, handle new files differently - let diff: string | undefined - let approvalContent: string | undefined - + // Build unified diff for display (normalize EOLs only for diff generation) + let unified: string if (fileExists) { - // For existing files, generate diff and check for changes - diff = formatResponse.createPrettyPatch(relPath, fileContent, updatedContent) - if (!diff) { + const oldForDiff = fileContent.replace(/\r\n/g, "\n") + const newForDiff = updatedContent.replace(/\r\n/g, "\n") + unified = formatResponse.createPrettyPatch(relPath, oldForDiff, newForDiff) + if (!unified) { pushToolResult(`No changes needed for '${relPath}'`) return } - approvalContent = undefined } else { - // For new files, skip diff generation and provide full content - diff = undefined - approvalContent = updatedContent + const newForDiff = updatedContent.replace(/\r\n/g, "\n") + unified = convertNewFileToUnifiedDiff(newForDiff, relPath) } + unified = sanitizeUnifiedDiff(unified) + const diffStats = computeDiffStats(unified) || undefined // Prepare the approval message (same for both flows) const completeMessage = JSON.stringify({ ...sharedMessageProps, - diff, - content: approvalContent, + // Send unified diff as content for render-only webview + content: unified, lineNumber: lineNumber, isProtected: isWriteProtected, + diffStats, } satisfies ClineSayTool) // Show diff view if focus disruption prevention is disabled diff --git a/src/core/tools/multiApplyDiffTool.ts b/src/core/tools/multiApplyDiffTool.ts index a30778c5af0d..08bce08ede1d 100644 --- a/src/core/tools/multiApplyDiffTool.ts +++ b/src/core/tools/multiApplyDiffTool.ts @@ -15,6 +15,7 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization" import { parseXmlForDiff } from "../../utils/xml" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { applyDiffToolLegacy } from "./applyDiffTool" +import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" interface DiffOperation { path: string @@ -282,31 +283,70 @@ Original error: ${errorMessage}` (opResult) => cline.rooProtectedController?.isWriteProtected(opResult.path) || false, ) - // Prepare batch diff data - const batchDiffs = operationsToApprove.map((opResult) => { + // Stream batch diffs progressively for better UX + const batchDiffs: Array<{ + path: string + changeCount: number + key: string + content: string + diffStats?: { added: number; removed: number } + diffs?: Array<{ content: string; startLine?: number }> + }> = [] + + for (const opResult of operationsToApprove) { const readablePath = getReadablePath(cline.cwd, opResult.path) const changeCount = opResult.diffItems?.length || 0 const changeText = changeCount === 1 ? "1 change" : `${changeCount} changes` - return { + let unified = "" + try { + const original = await fs.readFile(opResult.absolutePath!, "utf-8") + const processed = !cline.api.getModel().id.includes("claude") + ? (opResult.diffItems || []).map((item) => ({ + ...item, + content: item.content ? unescapeHtmlEntities(item.content) : item.content, + })) + : opResult.diffItems || [] + + const applyRes = + (await cline.diffStrategy?.applyDiff(original, processed)) ?? ({ success: false } as any) + const newContent = applyRes.success && applyRes.content ? applyRes.content : original + unified = formatResponse.createPrettyPatch(opResult.path, original, newContent) + } catch { + unified = "" + } + + const unifiedSanitized = sanitizeUnifiedDiff(unified) + const stats = computeDiffStats(unifiedSanitized) || undefined + batchDiffs.push({ path: readablePath, changeCount, key: `${readablePath} (${changeText})`, - content: opResult.path, // Full relative path + content: unifiedSanitized, + diffStats: stats, diffs: opResult.diffItems?.map((item) => ({ content: item.content, startLine: item.startLine, })), - } - }) + }) + + // Send a partial update after each file preview is ready + const partialMessage = JSON.stringify({ + tool: "appliedDiff", + batchDiffs, + isProtected: hasProtectedFiles, + } satisfies ClineSayTool) + await cline.ask("tool", partialMessage, true).catch(() => {}) + } + // Final approval message (non-partial) const completeMessage = JSON.stringify({ tool: "appliedDiff", batchDiffs, isProtected: hasProtectedFiles, } satisfies ClineSayTool) - const { response, text, images } = await cline.ask("tool", completeMessage, hasProtectedFiles) + const { response, text, images } = await cline.ask("tool", completeMessage, false) // Process batch response if (response === "yesButtonClicked") { @@ -418,6 +458,7 @@ Original error: ${errorMessage}` try { let originalContent: string | null = await fs.readFile(absolutePath, "utf-8") + let beforeContent: string | null = originalContent let successCount = 0 let formattedError = "" @@ -540,9 +581,13 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} if (operationsToApprove.length === 1) { // Prepare common data for single file operation const diffContents = diffItems.map((item) => item.content).join("\n\n") + const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, beforeContent!, originalContent!) + const unifiedPatch = sanitizeUnifiedDiff(unifiedPatchRaw) const operationMessage = JSON.stringify({ ...sharedMessageProps, diff: diffContents, + content: unifiedPatch, + diffStats: computeDiffStats(unifiedPatch) || undefined, } satisfies ClineSayTool) let toolProgressStatus diff --git a/src/core/tools/writeToFileTool.ts b/src/core/tools/writeToFileTool.ts index 5abd96a20aff..b8e6da0caa29 100644 --- a/src/core/tools/writeToFileTool.ts +++ b/src/core/tools/writeToFileTool.ts @@ -16,6 +16,7 @@ import { detectCodeOmission } from "../../integrations/editor/detect-omission" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" export async function writeToFileTool( cline: Task, @@ -173,6 +174,15 @@ export async function writeToFileTool( if (isPreventFocusDisruptionEnabled) { // Direct file write without diff view + // Set up diffViewProvider properties needed for diff generation and saveDirectly + cline.diffViewProvider.editType = fileExists ? "modify" : "create" + if (fileExists) { + const absolutePath = path.resolve(cline.cwd, relPath) + cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8") + } else { + cline.diffViewProvider.originalContent = "" + } + // Check for code omissions before proceeding if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { if (cline.diffStrategy) { @@ -202,9 +212,15 @@ export async function writeToFileTool( } } + // Build unified diff for both existing and new files + let unified = fileExists + ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) + : convertNewFileToUnifiedDiff(newContent, relPath) + unified = sanitizeUnifiedDiff(unified) const completeMessage = JSON.stringify({ ...sharedMessageProps, - content: newContent, + content: unified, + diffStats: computeDiffStats(unified) || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) @@ -213,15 +229,6 @@ export async function writeToFileTool( return } - // Set up diffViewProvider properties needed for saveDirectly - cline.diffViewProvider.editType = fileExists ? "modify" : "create" - if (fileExists) { - const absolutePath = path.resolve(cline.cwd, relPath) - cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8") - } else { - cline.diffViewProvider.originalContent = "" - } - // Save directly without showing diff view or opening the file await cline.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) } else { @@ -275,12 +282,15 @@ export async function writeToFileTool( } } + // Build unified diff for both existing and new files + let unified = fileExists + ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) + : convertNewFileToUnifiedDiff(newContent, relPath) + unified = sanitizeUnifiedDiff(unified) const completeMessage = JSON.stringify({ ...sharedMessageProps, - content: fileExists ? undefined : newContent, - diff: fileExists - ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) - : undefined, + content: unified, + diffStats: computeDiffStats(unified) || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 7d2759c91905..c3926d5073e6 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -386,6 +386,8 @@ export interface ClineSayTool { path?: string diff?: string content?: string + // Unified diff statistics computed by the extension + diffStats?: { added: number; removed: number } regex?: string filePattern?: string mode?: string @@ -407,6 +409,8 @@ export interface ClineSayTool { changeCount: number key: string content: string + // Per-file unified diff statistics computed by the extension + diffStats?: { added: number; removed: number } diffs?: Array<{ content: string startLine?: number diff --git a/webview-ui/package.json b/webview-ui/package.json index 9fda22097c94..a2d35432a451 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -41,6 +41,7 @@ "cmdk": "^1.0.0", "date-fns": "^4.1.0", "debounce": "^2.1.1", + "diff": "^5.2.0", "fast-deep-equal": "^3.1.3", "fzf": "^0.5.2", "hast-util-to-jsx-runtime": "^2.3.6", @@ -87,6 +88,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", + "@types/diff": "^5.2.1", "@types/jest": "^29.0.0", "@types/katex": "^0.16.7", "@types/node": "20.x", diff --git a/webview-ui/src/components/chat/BatchDiffApproval.tsx b/webview-ui/src/components/chat/BatchDiffApproval.tsx index 24ad8d489d9a..a88914cd88ad 100644 --- a/webview-ui/src/components/chat/BatchDiffApproval.tsx +++ b/webview-ui/src/components/chat/BatchDiffApproval.tsx @@ -6,6 +6,7 @@ interface FileDiff { changeCount: number key: string content: string + diffStats?: { added: number; removed: number } diffs?: Array<{ content: string startLine?: number @@ -35,17 +36,18 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp
{files.map((file) => { - // Combine all diffs into a single diff string for this file - const combinedDiff = file.diffs?.map((diff) => diff.content).join("\n\n") || file.content + // Use backend-provided unified diff only. Stats also provided by backend. + const unified = file.content || "" return (
handleToggleExpand(file.path)} + diffStats={file.diffStats ?? undefined} />
) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 4299240a549a..be51501a4a77 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -15,7 +15,6 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" import { findMatchingResourceOrTemplate } from "@src/utils/mcp" import { vscode } from "@src/utils/vscode" import { formatPathTooltip } from "@src/utils/formatPathTooltip" -import { getLanguageFromPath } from "@src/utils/getLanguageFromPath" import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock" @@ -336,6 +335,12 @@ export const ChatRowContent = ({ [message.ask, message.text], ) + // Unified diff content (provided by backend when relevant) + const unifiedDiff = useMemo(() => { + if (!tool) return undefined + return ((tool as any).content ?? (tool as any).diff) as string | undefined + }, [tool]) + const followUpData = useMemo(() => { if (message.type === "ask" && message.ask === "followup" && !message.partial) { return safeJsonParse(message.text) @@ -350,7 +355,7 @@ export const ChatRowContent = ({ style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}> ) - switch (tool.tool) { + switch (tool.tool as string) { case "editedExistingFile": case "appliedDiff": // Check if this is a batch diff request @@ -391,12 +396,13 @@ export const ChatRowContent = ({
@@ -428,12 +434,47 @@ export const ChatRowContent = ({
+
+ + ) + case "searchAndReplace": + return ( + <> +
+ {tool.isProtected ? ( + + ) : ( + toolIcon("replace") + )} + + {tool.isProtected && message.type === "ask" + ? t("chat:fileOperations.wantsToEditProtected") + : message.type === "ask" + ? t("chat:fileOperations.wantsToSearchReplace") + : t("chat:fileOperations.didSearchReplace")} + +
+
+
@@ -496,12 +537,13 @@ export const ChatRowContent = ({
vscode.postMessage({ type: "openFile", text: "./" + tool.path })} + diffStats={(tool as any).diffStats ?? undefined} />
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 929fa9427aa0..b9e2323cbb36 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -465,7 +465,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // Reset UI states + // Reset UI states only when task changes setExpandedRows({}) everVisibleMessagesTsRef.current.clear() // Clear for new task setCurrentFollowUpTs(null) // Clear follow-up answered state for new task diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx new file mode 100644 index 000000000000..61a6633f8669 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx @@ -0,0 +1,139 @@ +import React from "react" +import { render, screen } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { ChatRowContent } from "../ChatRow" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + "chat:fileOperations.wantsToEdit": "Roo wants to edit this file", + } + return map[key] || key + }, + }), + Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +// Mock CodeBlock (avoid ESM/highlighter costs) +vi.mock("@src/components/common/CodeBlock", () => ({ + default: () => null, +})) + +const queryClient = new QueryClient() + +function renderChatRow(message: any, isExpanded = false) { + return render( + + + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + /> + + , + ) +} + +describe("ChatRow - inline diff stats and actions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("shows + and - counts for editedExistingFile ask", () => { + const diff = "@@ -1,1 +1,1 @@\n-old\n+new\n" + const message: any = { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + text: JSON.stringify({ + tool: "editedExistingFile", + path: "src/file.ts", + diff, + diffStats: { added: 1, removed: 1 }, + }), + } + + renderChatRow(message, false) + + // Plus/minus counts + expect(screen.getByText("+1")).toBeInTheDocument() + expect(screen.getByText("-1")).toBeInTheDocument() + }) + + it("derives counts from searchAndReplace diff", () => { + const diff = "-a\n-b\n+c\n" + const message: any = { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + text: JSON.stringify({ + tool: "searchAndReplace", + path: "src/file.ts", + diff, + diffStats: { added: 1, removed: 2 }, + }), + } + + renderChatRow(message) + + expect(screen.getByText("+1")).toBeInTheDocument() + expect(screen.getByText("-2")).toBeInTheDocument() + }) + + it("counts only added lines for newFileCreated (ignores diff headers)", () => { + const content = "a\nb\nc" + const message: any = { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + text: JSON.stringify({ + tool: "newFileCreated", + path: "src/new-file.ts", + content, + diffStats: { added: 3, removed: 0 }, + }), + } + + renderChatRow(message) + + // Should only count the three content lines as additions + expect(screen.getByText("+3")).toBeInTheDocument() + expect(screen.getByText("-0")).toBeInTheDocument() + }) + + it("counts only added lines for newFileCreated with trailing newline", () => { + const content = "a\nb\nc\n" + const message: any = { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + text: JSON.stringify({ + tool: "newFileCreated", + path: "src/new-file.ts", + content, + diffStats: { added: 3, removed: 0 }, + }), + } + + renderChatRow(message) + + // Trailing newline should not increase the added count + expect(screen.getByText("+3")).toBeInTheDocument() + expect(screen.getByText("-0")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/common/CodeAccordian.tsx b/webview-ui/src/components/common/CodeAccordian.tsx index a86f9c3221b6..ebc4dd09c30d 100644 --- a/webview-ui/src/components/common/CodeAccordian.tsx +++ b/webview-ui/src/components/common/CodeAccordian.tsx @@ -7,6 +7,7 @@ import { formatPathTooltip } from "@src/utils/formatPathTooltip" import { ToolUseBlock, ToolUseBlockHeader } from "./ToolUseBlock" import CodeBlock from "./CodeBlock" import { PathTooltip } from "../ui/PathTooltip" +import DiffView from "./DiffView" interface CodeAccordianProps { path?: string @@ -19,6 +20,8 @@ interface CodeAccordianProps { onToggleExpand: () => void header?: string onJumpToFile?: () => void + // New props for diff stats + diffStats?: { added: number; removed: number } } const CodeAccordian = ({ @@ -32,11 +35,20 @@ const CodeAccordian = ({ onToggleExpand, header, onJumpToFile, + diffStats, }: CodeAccordianProps) => { const inferredLanguage = useMemo(() => language ?? (path ? getLanguageFromPath(path) : "txt"), [path, language]) const source = useMemo(() => code.trim(), [code]) const hasHeader = Boolean(path || isFeedback || header) + // Use provided diff stats only (render-only) + const derivedStats = useMemo(() => { + if (diffStats && (diffStats.added > 0 || diffStats.removed > 0)) return diffStats + return null + }, [diffStats]) + + const hasValidStats = Boolean(derivedStats && (derivedStats.added > 0 || derivedStats.removed > 0)) + return ( {hasHeader && ( @@ -67,13 +79,28 @@ const CodeAccordian = ({ )}
- {progressStatus && progressStatus.text && ( - <> - {progressStatus.icon && } - - {progressStatus.text} + {/* Prefer diff stats over generic progress indicator if available */} + {hasValidStats ? ( +
+ + +{derivedStats!.added} - + + -{derivedStats!.removed} + +
+ ) : ( + progressStatus && + progressStatus.text && ( + <> + {progressStatus.icon && ( + + )} + + {progressStatus.text} + + + ) )} {onJumpToFile && path && ( )} {(!hasHeader || isExpanded) && ( -
- +
+ {inferredLanguage === "diff" ? ( + + ) : ( + + )}
)} diff --git a/webview-ui/src/components/common/DiffView.tsx b/webview-ui/src/components/common/DiffView.tsx new file mode 100644 index 000000000000..d4cf326f5b68 --- /dev/null +++ b/webview-ui/src/components/common/DiffView.tsx @@ -0,0 +1,157 @@ +import { memo, useMemo, useEffect, useState } from "react" +import { parseUnifiedDiff } from "@src/utils/parseUnifiedDiff" +import { toJsxRuntime } from "hast-util-to-jsx-runtime" +import { Fragment, jsx, jsxs } from "react/jsx-runtime" +import { getHighlighter, normalizeLanguage } from "@src/utils/highlighter" +import { getLanguageFromPath } from "@src/utils/getLanguageFromPath" + +interface DiffViewProps { + source: string + filePath?: string +} + +/** + * DiffView component renders unified diffs with side-by-side line numbers + * matching VSCode's diff editor style + */ +const DiffView = memo(({ source, filePath }: DiffViewProps) => { + // Determine language from file path and prepare highlighter + const normalizedLang = useMemo(() => normalizeLanguage(getLanguageFromPath(filePath || "") || "txt"), [filePath]) + const [highlighter, setHighlighter] = useState(null) + const isLightTheme = useMemo(() => { + if (typeof document === "undefined") return false + const cls = document.body.className + return /\bvscode-light\b|\bvscode-high-contrast-light\b/i.test(cls) + }, []) + + useEffect(() => { + let mounted = true + getHighlighter(normalizedLang) + .then((h) => { + if (mounted) setHighlighter(h) + }) + .catch(() => { + // fall back to plain text if highlighting fails + }) + return () => { + mounted = false + } + }, [normalizedLang]) + + // Disable syntax highlighting for large diffs (performance optimization) + const shouldHighlight = useMemo(() => { + const lineCount = source.split("\n").length + return lineCount <= 1000 // Only highlight diffs with <= 1000 lines + }, [source]) + + const renderHighlighted = (code: string): React.ReactNode => { + if (!highlighter || !shouldHighlight) return code + try { + const hast: any = highlighter.codeToHast(code, { + lang: normalizedLang, + theme: isLightTheme ? "github-light" : "github-dark", + transformers: [ + { + pre(node: any) { + node.properties.style = "padding:0;margin:0;background:none;" + return node + }, + code(node: any) { + node.properties.class = `hljs language-${normalizedLang}` + return node + }, + }, + ], + }) + + // Extract just the children to render inline inside our table cell + const codeEl = hast?.children?.[0]?.children?.[0] + const inlineRoot = + codeEl && codeEl.children + ? { type: "element", tagName: "span", properties: {}, children: codeEl.children } + : { type: "element", tagName: "span", properties: {}, children: hast.children || [] } + + return toJsxRuntime(inlineRoot as any, { Fragment, jsx, jsxs }) + } catch { + return code + } + } + + // Parse diff server-provided unified patch into renderable lines + const diffLines = useMemo(() => parseUnifiedDiff(source, filePath), [source, filePath]) + + return ( +
+
+ + + {diffLines.map((line, idx) => { + // Render compact separator between hunks + if (line.type === "gap") { + // Compact separator between hunks + return ( + + + + ) + } + + // Use VSCode's built-in diff editor color variables as classes for gutters + const gutterBgClass = + line.type === "addition" + ? "bg-[var(--vscode-diffEditor-insertedTextBackground)]" + : line.type === "deletion" + ? "bg-[var(--vscode-diffEditor-removedTextBackground)]" + : "bg-[var(--vscode-editorGroup-border)]" + + const contentBgClass = + line.type === "addition" + ? "diff-content-inserted" + : line.type === "deletion" + ? "diff-content-removed" + : "diff-content-context" + + const sign = line.type === "addition" ? "+" : line.type === "deletion" ? "-" : "" + + return ( + + {/* Old line number */} + + {/* New line number */} + + {/* Narrow colored gutter */} + + {/* Code content (no +/- prefix here) */} + + + ) + })} + +
+ + + {/* +/- column (empty for gap) */} + + + {`${line.hiddenCount ?? 0} hidden lines`} +
+ {line.oldLineNum || ""} + + {line.newLineNum || ""} + + {/* +/- fixed column to prevent wrapping into it */} + + {sign} + + {renderHighlighted(line.content)} +
+
+
+ ) +}) + +export default DiffView diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 6f23892ced31..6355ded21be8 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -490,3 +490,28 @@ input[cmdk-input]:focus { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } + +/* DiffView code font: use VS Code editor font and enable ligatures */ +.diff-view, +.diff-view pre, +.diff-view code, +.diff-view .hljs { + font-family: + var(--vscode-editor-font-family), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-variant-ligatures: contextual; + font-feature-settings: + "calt" 1, + "liga" 1; +} + +/* DiffView background tints via CSS classes instead of inline styles */ +.diff-content-inserted { + background-color: color-mix(in srgb, var(--vscode-diffEditor-insertedTextBackground) 70%, transparent); +} +.diff-content-removed { + background-color: color-mix(in srgb, var(--vscode-diffEditor-removedTextBackground) 70%, transparent); +} +.diff-content-context { + background-color: color-mix(in srgb, var(--vscode-editorGroup-border) 100%, transparent); +} diff --git a/webview-ui/src/utils/parseUnifiedDiff.ts b/webview-ui/src/utils/parseUnifiedDiff.ts new file mode 100644 index 000000000000..bed84c4ca9f5 --- /dev/null +++ b/webview-ui/src/utils/parseUnifiedDiff.ts @@ -0,0 +1,96 @@ +import { parsePatch } from "diff" + +export interface DiffLine { + oldLineNum: number | null + newLineNum: number | null + type: "context" | "addition" | "deletion" | "gap" + content: string + hiddenCount?: number +} + +/** + * Parse a unified diff string into a flat list of renderable lines with + * line numbers, addition/deletion/context flags, and compact "gap" separators + * between hunks. + */ +export function parseUnifiedDiff(source: string, filePath?: string): DiffLine[] { + if (!source) return [] + + try { + const patches = parsePatch(source) + if (!patches || patches.length === 0) return [] + + const patch = filePath + ? (patches.find((p) => + [p.newFileName, p.oldFileName].some( + (n) => typeof n === "string" && (n === filePath || (n as string).endsWith("/" + filePath)), + ), + ) ?? patches[0]) + : patches[0] + + if (!patch) return [] + + const lines: DiffLine[] = [] + let prevHunk: any = null + for (const hunk of (patch as any).hunks || []) { + // Insert a compact "hidden lines" separator between hunks + if (prevHunk) { + const gapNew = hunk.newStart - (prevHunk.newStart + prevHunk.newLines) + const gapOld = hunk.oldStart - (prevHunk.oldStart + prevHunk.oldLines) + const hidden = Math.max(gapNew, gapOld) + if (hidden > 0) { + lines.push({ + oldLineNum: null, + newLineNum: null, + type: "gap", + content: "", + hiddenCount: hidden, + }) + } + } + + let oldLine = hunk.oldStart + let newLine = hunk.newStart + + for (const raw of hunk.lines || []) { + const firstChar = (raw as string)[0] + const content = (raw as string).slice(1) + + if (firstChar === "-") { + lines.push({ + oldLineNum: oldLine, + newLineNum: null, + type: "deletion", + content, + }) + oldLine++ + } else if (firstChar === "+") { + lines.push({ + oldLineNum: null, + newLineNum: newLine, + type: "addition", + content, + }) + newLine++ + } else { + // Context line + lines.push({ + oldLineNum: oldLine, + newLineNum: newLine, + type: "context", + content, + }) + oldLine++ + newLine++ + } + } + + prevHunk = hunk + } + + return lines + } catch { + // swallow parse errors and render nothing rather than breaking the UI + return [] + } +}