فهرست منبع

feat(chat): Improve diff appearance in main chat view (#8932)

Co-authored-by: daniel-lxs <[email protected]>
Hannes Rudolph 3 ماه پیش
والد
کامیت
0fd7023ea7

+ 7 - 1
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/[email protected])
+      '@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/[email protected])(@types/node@24.2.1)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
+      vitest: 3.2.4(@types/[email protected])(@types/node@20.17.57)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
 
   '@vitest/[email protected]':
     dependencies:

+ 71 - 0
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 })
+}

+ 3 - 1
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")

+ 10 - 0
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)
 

+ 15 - 14
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

+ 52 - 7
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

+ 24 - 14
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)

+ 4 - 0
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

+ 2 - 0
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",

+ 5 - 3
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
 		<div className="pt-[5px]">
 			<div className="flex flex-col gap-0 border border-border rounded-md p-1">
 				{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 (
 						<div key={`${file.path}-${ts}`}>
 							<CodeAccordian
 								path={file.path}
-								code={combinedDiff}
+								code={unified}
 								language="diff"
 								isExpanded={expandedFiles[file.path] || false}
 								onToggleExpand={() => handleToggleExpand(file.path)}
+								diffStats={file.diffStats ?? undefined}
 							/>
 						</div>
 					)

+ 49 - 7
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.content ?? tool.diff) as string | undefined
+	}, [tool])
+
 	const followUpData = useMemo(() => {
 		if (message.type === "ask" && message.ask === "followup" && !message.partial) {
 			return safeJsonParse<FollowUpData>(message.text)
@@ -350,7 +355,7 @@ export const ChatRowContent = ({
 				style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
 		)
 
-		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 = ({
 						<div className="pl-6">
 							<CodeAccordian
 								path={tool.path}
-								code={tool.content ?? tool.diff}
+								code={unifiedDiff ?? tool.content ?? tool.diff}
 								language="diff"
 								progressStatus={message.progressStatus}
 								isLoading={message.partial}
 								isExpanded={isExpanded}
 								onToggleExpand={handleToggleExpand}
+								diffStats={tool.diffStats}
 							/>
 						</div>
 					</>
@@ -428,12 +434,47 @@ export const ChatRowContent = ({
 						<div className="pl-6">
 							<CodeAccordian
 								path={tool.path}
-								code={tool.diff}
+								code={unifiedDiff ?? tool.diff}
 								language="diff"
 								progressStatus={message.progressStatus}
 								isLoading={message.partial}
 								isExpanded={isExpanded}
 								onToggleExpand={handleToggleExpand}
+								diffStats={tool.diffStats}
+							/>
+						</div>
+					</>
+				)
+			case "searchAndReplace":
+				return (
+					<>
+						<div style={headerStyle}>
+							{tool.isProtected ? (
+								<span
+									className="codicon codicon-lock"
+									style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
+								/>
+							) : (
+								toolIcon("replace")
+							)}
+							<span style={{ fontWeight: "bold" }}>
+								{tool.isProtected && message.type === "ask"
+									? t("chat:fileOperations.wantsToEditProtected")
+									: message.type === "ask"
+										? t("chat:fileOperations.wantsToSearchReplace")
+										: t("chat:fileOperations.didSearchReplace")}
+							</span>
+						</div>
+						<div className="pl-6">
+							<CodeAccordian
+								path={tool.path}
+								code={unifiedDiff ?? tool.diff}
+								language="diff"
+								progressStatus={message.progressStatus}
+								isLoading={message.partial}
+								isExpanded={isExpanded}
+								onToggleExpand={handleToggleExpand}
+								diffStats={tool.diffStats}
 							/>
 						</div>
 					</>
@@ -465,7 +506,7 @@ export const ChatRowContent = ({
 				return (
 					<UpdateTodoListToolBlock
 						todos={todos}
-						content={(tool as any).content}
+						content={tool.content}
 						onChange={(updatedTodos) => {
 							if (typeof vscode !== "undefined" && vscode?.postMessage) {
 								vscode.postMessage({ type: "updateTodoList", payload: { todos: updatedTodos } })
@@ -496,12 +537,13 @@ export const ChatRowContent = ({
 						<div className="pl-6">
 							<CodeAccordian
 								path={tool.path}
-								code={tool.content}
-								language={getLanguageFromPath(tool.path || "") || "log"}
+								code={unifiedDiff ?? ""}
+								language="diff"
 								isLoading={message.partial}
 								isExpanded={isExpanded}
 								onToggleExpand={handleToggleExpand}
 								onJumpToFile={() => vscode.postMessage({ type: "openFile", text: "./" + tool.path })}
+								diffStats={tool.diffStats}
 							/>
 						</div>
 					</>

+ 1 - 1
webview-ui/src/components/chat/ChatView.tsx

@@ -449,7 +449,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	}, [messages.length])
 
 	useEffect(() => {
-		// 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

+ 139 - 0
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<string, string> = {
+				"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(
+		<ExtensionStateContextProvider>
+			<QueryClientProvider client={queryClient}>
+				<ChatRowContent
+					message={message}
+					isExpanded={isExpanded}
+					isLast={false}
+					isStreaming={false}
+					onToggleExpand={() => {}}
+					onSuggestionClick={() => {}}
+					onBatchFileResponse={() => {}}
+					onFollowUpUnmount={() => {}}
+					isFollowUpAnswered={false}
+				/>
+			</QueryClientProvider>
+		</ExtensionStateContextProvider>,
+	)
+}
+
+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()
+	})
+})

+ 36 - 9
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 (
 		<ToolUseBlock>
 			{hasHeader && (
@@ -67,13 +79,24 @@ const CodeAccordian = ({
 						</>
 					)}
 					<div className="flex-grow-1" />
-					{progressStatus && progressStatus.text && (
-						<>
-							{progressStatus.icon && <span className={`codicon codicon-${progressStatus.icon} mr-1`} />}
-							<span className="mr-1 ml-auto text-vscode-descriptionForeground">
-								{progressStatus.text}
-							</span>
-						</>
+					{/* Prefer diff stats over generic progress indicator if available */}
+					{hasValidStats ? (
+						<div className="flex items-center gap-2 mr-1">
+							<span className="text-xs font-medium text-vscode-charts-green">+{derivedStats!.added}</span>
+							<span className="text-xs font-medium text-vscode-charts-red">-{derivedStats!.removed}</span>
+						</div>
+					) : (
+						progressStatus &&
+						progressStatus.text && (
+							<>
+								{progressStatus.icon && (
+									<span className={`codicon codicon-${progressStatus.icon} mr-1`} />
+								)}
+								<span className="mr-1 ml-auto text-vscode-descriptionForeground">
+									{progressStatus.text}
+								</span>
+							</>
+						)
 					)}
 					{onJumpToFile && path && (
 						<span
@@ -93,8 +116,12 @@ const CodeAccordian = ({
 				</ToolUseBlockHeader>
 			)}
 			{(!hasHeader || isExpanded) && (
-				<div className="overflow-x-auto overflow-y-hidden max-w-full">
-					<CodeBlock source={source} language={inferredLanguage} />
+				<div className="overflow-x-auto overflow-y-auto max-h-[300px] max-w-full">
+					{inferredLanguage === "diff" ? (
+						<DiffView source={source} filePath={path} />
+					) : (
+						<CodeBlock source={source} language={inferredLanguage} />
+					)}
 				</div>
 			)}
 		</ToolUseBlock>

+ 247 - 0
webview-ui/src/components/common/DiffView.tsx

@@ -0,0 +1,247 @@
+import { memo, useMemo, useEffect, useState } from "react"
+import { parseUnifiedDiff, type DiffLine } from "@src/utils/parseUnifiedDiff"
+import { normalizeLanguage } from "@src/utils/highlighter"
+import { getLanguageFromPath } from "@src/utils/getLanguageFromPath"
+import { highlightHunks } from "@src/utils/highlightDiff"
+
+interface DiffViewProps {
+	source: string
+	filePath?: string
+}
+
+// Interface for hunk data
+interface Hunk {
+	lines: DiffLine[]
+	oldText: string
+	newText: string
+	highlightedOldLines?: React.ReactNode[]
+	highlightedNewLines?: React.ReactNode[]
+}
+
+/**
+ * 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
+	const normalizedLang = useMemo(() => normalizeLanguage(getLanguageFromPath(filePath || "") || "txt"), [filePath])
+
+	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)
+	}, [])
+
+	// 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])
+
+	// Parse diff and group into hunks
+	const diffLines = useMemo(() => parseUnifiedDiff(source, filePath), [source, filePath])
+
+	const hunks = useMemo(() => {
+		const result: Hunk[] = []
+		let currentHunk: DiffLine[] = []
+
+		for (const line of diffLines) {
+			if (line.type === "gap") {
+				// Finish current hunk if it has content
+				if (currentHunk.length > 0) {
+					const oldLines: string[] = []
+					const newLines: string[] = []
+
+					for (const hunkLine of currentHunk) {
+						if (hunkLine.type === "deletion" || hunkLine.type === "context") {
+							oldLines.push(hunkLine.content)
+						}
+						if (hunkLine.type === "addition" || hunkLine.type === "context") {
+							newLines.push(hunkLine.content)
+						}
+					}
+
+					result.push({
+						lines: [...currentHunk],
+						oldText: oldLines.join("\n"),
+						newText: newLines.join("\n"),
+					})
+				}
+
+				// Start new hunk with the gap
+				currentHunk = [line]
+			} else {
+				currentHunk.push(line)
+			}
+		}
+
+		// Add the last hunk if it has content
+		if (currentHunk.length > 0 && currentHunk.some((line) => line.type !== "gap")) {
+			const oldLines: string[] = []
+			const newLines: string[] = []
+
+			for (const hunkLine of currentHunk) {
+				if (hunkLine.type === "deletion" || hunkLine.type === "context") {
+					oldLines.push(hunkLine.content)
+				}
+				if (hunkLine.type === "addition" || hunkLine.type === "context") {
+					newLines.push(hunkLine.content)
+				}
+			}
+
+			result.push({
+				lines: [...currentHunk],
+				oldText: oldLines.join("\n"),
+				newText: newLines.join("\n"),
+			})
+		}
+
+		return result
+	}, [diffLines])
+
+	// State for the processed hunks with highlighting
+	const [processedHunks, setProcessedHunks] = useState<Hunk[]>(hunks)
+
+	// Effect to handle async highlighting
+	useEffect(() => {
+		if (!shouldHighlight) {
+			setProcessedHunks(hunks)
+			return
+		}
+
+		const processHunks = async () => {
+			const processed: Hunk[] = []
+
+			for (let i = 0; i < hunks.length; i++) {
+				const hunk = hunks[i]
+				try {
+					const highlighted = await highlightHunks(
+						hunk.oldText,
+						hunk.newText,
+						normalizedLang,
+						isLightTheme ? "light" : "dark",
+						i,
+						filePath,
+					)
+					processed.push({
+						...hunk,
+						highlightedOldLines: highlighted.oldLines,
+						highlightedNewLines: highlighted.newLines,
+					})
+				} catch {
+					// Fall back to unhighlighted on error
+					processed.push(hunk)
+				}
+			}
+
+			setProcessedHunks(processed)
+		}
+
+		processHunks()
+	}, [hunks, shouldHighlight, normalizedLang, isLightTheme, filePath])
+
+	// Render helper that uses precomputed highlighting
+	const renderContent = (line: DiffLine, hunk: Hunk, lineIndexInHunk: number): React.ReactNode => {
+		if (!shouldHighlight || !hunk.highlightedOldLines || !hunk.highlightedNewLines) {
+			return line.content
+		}
+
+		// Find the line index within the old/new text for this hunk
+		const hunkLinesBeforeThis = hunk.lines.slice(0, lineIndexInHunk).filter((l) => l.type !== "gap")
+
+		if (line.type === "deletion") {
+			// Count deletions and context lines before this line
+			const oldLineIndex = hunkLinesBeforeThis.filter((l) => l.type === "deletion" || l.type === "context").length
+			return hunk.highlightedOldLines[oldLineIndex] || line.content
+		} else if (line.type === "addition") {
+			// Count additions and context lines before this line
+			const newLineIndex = hunkLinesBeforeThis.filter((l) => l.type === "addition" || l.type === "context").length
+			return hunk.highlightedNewLines[newLineIndex] || line.content
+		} else if (line.type === "context") {
+			// For context lines, prefer new-side highlighting, fall back to old-side
+			const newLineIndex = hunkLinesBeforeThis.filter((l) => l.type === "addition" || l.type === "context").length
+			const oldLineIndex = hunkLinesBeforeThis.filter((l) => l.type === "deletion" || l.type === "context").length
+			return hunk.highlightedNewLines[newLineIndex] || hunk.highlightedOldLines[oldLineIndex] || line.content
+		}
+
+		return line.content
+	}
+
+	return (
+		<div className="diff-view bg-[var(--vscode-editor-background)] rounded-md overflow-hidden text-[0.95em]">
+			<div className="overflow-x-hidden">
+				<table className="w-full border-collapse table-auto">
+					<tbody>
+						{processedHunks.flatMap((hunk, hunkIndex) =>
+							hunk.lines.map((line, lineIndex) => {
+								const globalIndex = `${hunkIndex}-${lineIndex}`
+
+								// Render compact separator between hunks
+								if (line.type === "gap") {
+									return (
+										<tr key={globalIndex}>
+											<td className="w-[45px] text-right pr-3 pl-2 select-none align-top whitespace-nowrap bg-[var(--vscode-editor-background)]" />
+											<td className="w-[45px] text-right pr-3 select-none align-top whitespace-nowrap bg-[var(--vscode-editor-background)]" />
+											<td className="w-[12px] align-top bg-[var(--vscode-editor-background)]" />
+											{/* +/- column (empty for gap) */}
+											<td className="w-[16px] text-center select-none bg-[var(--vscode-editor-background)]" />
+											<td className="pr-3 whitespace-pre-wrap break-words w-full italic bg-[var(--vscode-editor-background)]">
+												{`${line.hiddenCount ?? 0} hidden lines`}
+											</td>
+										</tr>
+									)
+								}
+
+								// 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 (
+									<tr key={globalIndex}>
+										{/* Old line number */}
+										<td
+											className={`w-[45px] text-right pr-1 pl-1 select-none align-top whitespace-nowrap ${gutterBgClass}`}>
+											{line.oldLineNum || ""}
+										</td>
+										{/* New line number */}
+										<td
+											className={`w-[45px] text-right pr-1 select-none align-top whitespace-nowrap ${gutterBgClass}`}>
+											{line.newLineNum || ""}
+										</td>
+										{/* Narrow colored gutter */}
+										<td className={`w-[12px] ${gutterBgClass} align-top`} />
+										{/* +/- fixed column to prevent wrapping into it */}
+										<td
+											className={`w-[16px] text-center select-none whitespace-nowrap px-1 ${gutterBgClass}`}>
+											{sign}
+										</td>
+										{/* Code content (no +/- prefix here) */}
+										<td
+											className={`pl-1 pr-3 whitespace-pre-wrap break-words w-full ${contentBgClass}`}>
+											{renderContent(line, hunk, lineIndex)}
+										</td>
+									</tr>
+								)
+							}),
+						)}
+					</tbody>
+				</table>
+			</div>
+		</div>
+	)
+})
+
+export default DiffView

+ 26 - 0
webview-ui/src/index.css

@@ -124,6 +124,7 @@
 	--color-vscode-titleBar-inactiveForeground: var(--vscode-titleBar-inactiveForeground);
 
 	--color-vscode-charts-green: var(--vscode-charts-green);
+	--color-vscode-charts-red: var(--vscode-charts-red);
 	--color-vscode-charts-yellow: var(--vscode-charts-yellow);
 
 	--color-vscode-inputValidation-infoForeground: var(--vscode-inputValidation-infoForeground);
@@ -490,3 +491,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);
+}

+ 223 - 0
webview-ui/src/utils/__tests__/highlightDiff.spec.ts

@@ -0,0 +1,223 @@
+import { highlightHunks } from "../highlightDiff"
+import { getHighlighter } from "../highlighter"
+
+// Mock the highlighter
+vi.mock("../highlighter", () => ({
+	getHighlighter: vi.fn(),
+}))
+
+// Mock hast-util-to-jsx-runtime
+vi.mock("hast-util-to-jsx-runtime", () => ({
+	toJsxRuntime: vi.fn((node, _options) => {
+		// Simple mock that returns a string representation
+		if (node.children) {
+			return node.children
+				.map((child: any) => {
+					if (child.type === "text") {
+						return child.value
+					}
+					return `<span>${child.value || ""}</span>`
+				})
+				.join("")
+		}
+		return node.value || "highlighted-content"
+	}),
+}))
+
+const mockHighlighter = {
+	codeToHast: vi.fn((text: string, options: any) => ({
+		children: [
+			{
+				children: [
+					{
+						tagName: "code",
+						properties: { class: `hljs language-${options.lang}` },
+						children: text.split("\n").map((line) => ({
+							tagName: "span",
+							properties: { className: ["line"] },
+							children: [{ type: "text", value: `highlighted(${line})` }],
+						})),
+					},
+				],
+			},
+		],
+	})),
+}
+
+beforeEach(() => {
+	vi.clearAllMocks()
+	;(getHighlighter as any).mockResolvedValue(mockHighlighter)
+})
+
+describe("highlightHunks", () => {
+	it("should highlight simple old and new text", async () => {
+		const result = await highlightHunks(
+			"const x = 1\nconsole.log(x)",
+			"const x = 2\nconsole.log(x)",
+			"javascript",
+			"light",
+		)
+
+		expect(result.oldLines).toHaveLength(2)
+		expect(result.newLines).toHaveLength(2)
+		expect(getHighlighter).toHaveBeenCalledWith("javascript")
+		expect(mockHighlighter.codeToHast).toHaveBeenCalledTimes(2)
+	})
+
+	it("should handle empty text", async () => {
+		const result = await highlightHunks("", "", "javascript", "light")
+
+		expect(result.oldLines).toEqual([""])
+		expect(result.newLines).toEqual([""])
+	})
+
+	it("should handle single-line text", async () => {
+		const result = await highlightHunks("const x = 1", "const x = 2", "javascript", "dark")
+
+		expect(result.oldLines).toHaveLength(1)
+		expect(result.newLines).toHaveLength(1)
+		expect(mockHighlighter.codeToHast).toHaveBeenCalledWith(
+			"const x = 1",
+			expect.objectContaining({
+				lang: "javascript",
+				theme: "github-dark",
+			}),
+		)
+	})
+
+	it("should handle multi-line text with different lengths", async () => {
+		const oldText = "line1\nline2\nline3"
+		const newText = "line1\nmodified line2"
+
+		const result = await highlightHunks(oldText, newText, "txt", "light")
+
+		expect(result.oldLines).toHaveLength(3)
+		expect(result.newLines).toHaveLength(2)
+	})
+
+	it("should map light theme to github-light", async () => {
+		await highlightHunks("test", "test", "javascript", "light")
+
+		expect(mockHighlighter.codeToHast).toHaveBeenCalledWith(
+			"test",
+			expect.objectContaining({
+				theme: "github-light",
+			}),
+		)
+	})
+
+	it("should map dark theme to github-dark", async () => {
+		await highlightHunks("test", "test", "javascript", "dark")
+
+		expect(mockHighlighter.codeToHast).toHaveBeenCalledWith(
+			"test",
+			expect.objectContaining({
+				theme: "github-dark",
+			}),
+		)
+	})
+
+	it("should use correct transformers", async () => {
+		await highlightHunks("test", "test", "javascript", "light")
+
+		expect(mockHighlighter.codeToHast).toHaveBeenCalledWith(
+			"test",
+			expect.objectContaining({
+				transformers: expect.arrayContaining([
+					expect.objectContaining({
+						pre: expect.any(Function),
+						code: expect.any(Function),
+					}),
+				]),
+			}),
+		)
+	})
+
+	it("should handle highlighting errors gracefully", async () => {
+		mockHighlighter.codeToHast.mockImplementation(() => {
+			throw new Error("Highlighting failed")
+		})
+
+		const result = await highlightHunks("const x = 1", "const x = 2", "javascript", "light")
+
+		// Should fall back to plain text
+		expect(result.oldLines).toEqual(["const x = 1"])
+		expect(result.newLines).toEqual(["const x = 2"])
+	})
+
+	it("should handle getHighlighter rejection", async () => {
+		;(getHighlighter as any).mockRejectedValueOnce(new Error("Highlighter failed"))
+
+		const result = await highlightHunks("const x = 1", "const x = 2", "javascript", "light")
+
+		// Should fall back to plain text
+		expect(result.oldLines).toEqual(["const x = 1"])
+		expect(result.newLines).toEqual(["const x = 2"])
+	})
+
+	it("should handle text with trailing newlines", async () => {
+		const result = await highlightHunks("line1\nline2\n", "line1\nline2\n", "txt", "light")
+
+		expect(result.oldLines).toHaveLength(3) // Including empty line from trailing newline
+		expect(result.newLines).toHaveLength(3)
+		// The empty line at the end is preserved as-is (performance optimization)
+		expect(result.oldLines[2]).toBe("")
+		expect(result.newLines[2]).toBe("")
+	})
+
+	it("should preserve whitespace-only lines", async () => {
+		const result = await highlightHunks("line1\n   \nline3", "line1\n\t\nline3", "txt", "light")
+
+		expect(result.oldLines).toHaveLength(3)
+		expect(result.newLines).toHaveLength(3)
+		// Whitespace-only lines are preserved as-is (performance optimization)
+		expect(result.oldLines[1]).toBe("   ")
+		expect(result.newLines[1]).toBe("\t")
+	})
+})
+
+describe("integration scenarios", () => {
+	it("should handle typical single hunk scenario", async () => {
+		const oldText = "function hello() {\n  console.log('old')\n}"
+		const newText = "function hello() {\n  console.log('new')\n}"
+
+		const result = await highlightHunks(oldText, newText, "javascript", "light")
+
+		expect(result.oldLines).toHaveLength(3)
+		expect(result.newLines).toHaveLength(3)
+		// Each line should be processed by the highlighter
+		result.oldLines.forEach((line) => {
+			expect(typeof line === "string" || typeof line === "object").toBe(true)
+		})
+	})
+
+	it("should handle addition-only hunk", async () => {
+		const oldText = ""
+		const newText = "// New comment\nconst x = 1"
+
+		const result = await highlightHunks(oldText, newText, "javascript", "light")
+
+		expect(result.oldLines).toEqual([""])
+		expect(result.newLines).toHaveLength(2)
+	})
+
+	it("should handle deletion-only hunk", async () => {
+		const oldText = "// Deleted comment\nconst x = 1"
+		const newText = ""
+
+		const result = await highlightHunks(oldText, newText, "javascript", "light")
+
+		expect(result.oldLines).toHaveLength(2)
+		expect(result.newLines).toEqual([""])
+	})
+
+	it("should handle context with mixed changes", async () => {
+		const oldText = "line1\nold line\nline3\nold line2"
+		const newText = "line1\nnew line\nline3\nnew line2"
+
+		const result = await highlightHunks(oldText, newText, "txt", "light")
+
+		expect(result.oldLines).toHaveLength(4)
+		expect(result.newLines).toHaveLength(4)
+	})
+})

+ 131 - 0
webview-ui/src/utils/highlightDiff.ts

@@ -0,0 +1,131 @@
+import { ReactNode } from "react"
+import { getHighlighter } from "./highlighter"
+import { toJsxRuntime } from "hast-util-to-jsx-runtime"
+import { Fragment, jsx, jsxs } from "react/jsx-runtime"
+
+/**
+ * Highlight two pieces of code (old and new) in a single pass and return
+ * arrays of ReactNode representing each line
+ */
+export async function highlightHunks(
+	oldText: string,
+	newText: string,
+	lang: string,
+	theme: "light" | "dark",
+	_hunkIndex = 0,
+	_filePath?: string,
+): Promise<{ oldLines: ReactNode[]; newLines: ReactNode[] }> {
+	try {
+		const highlighter = await getHighlighter(lang)
+		const shikiTheme = theme === "light" ? "github-light" : "github-dark"
+
+		// Helper to highlight text and extract lines
+		const highlightAndExtractLines = (text: string): ReactNode[] => {
+			const textLines = text.split("\n")
+
+			if (!text.trim()) {
+				return textLines.map((line) => line || "")
+			}
+
+			try {
+				// Use Shiki's line transformer to get per-line highlighting
+				const hast: any = highlighter.codeToHast(text, {
+					lang,
+					theme: shikiTheme,
+					transformers: [
+						{
+							pre(node: any) {
+								node.properties.style = "padding:0;margin:0;background:none;"
+								return node
+							},
+							code(node: any) {
+								node.properties.class = `hljs language-${lang}`
+								return node
+							},
+							line(node: any, line: number) {
+								// Add a line marker to help with extraction
+								node.properties["data-line"] = line
+								return node
+							},
+						},
+					],
+				})
+
+				// Extract the <code> element's children (which should be line elements)
+				const codeEl = hast?.children?.[0]?.children?.[0]
+				if (!codeEl || !codeEl.children) {
+					return textLines.map((line) => line || "")
+				}
+
+				// Convert each line element to a ReactNode
+				const highlightedLines: ReactNode[] = []
+
+				for (const lineNode of codeEl.children) {
+					if (lineNode.tagName === "span" && lineNode.properties?.className?.includes("line")) {
+						// This is a line span from Shiki
+						const reactNode = toJsxRuntime(
+							{ type: "element", tagName: "span", properties: {}, children: lineNode.children || [] },
+							{ Fragment, jsx, jsxs },
+						)
+						highlightedLines.push(reactNode)
+					}
+				}
+
+				// If we didn't get the expected structure, fall back to simple approach
+				if (highlightedLines.length !== textLines.length) {
+					// For each line, highlight it individually (fallback)
+					return textLines.map((line) => {
+						if (!line.trim()) return line
+
+						try {
+							const lineHast: any = highlighter.codeToHast(line, {
+								lang,
+								theme: shikiTheme,
+								transformers: [
+									{
+										pre(node: any) {
+											node.properties.style = "padding:0;margin:0;background:none;"
+											return node
+										},
+										code(node: any) {
+											node.properties.class = `hljs language-${lang}`
+											return node
+										},
+									},
+								],
+							})
+
+							const lineCodeEl = lineHast?.children?.[0]?.children?.[0]
+							if (!lineCodeEl || !lineCodeEl.children) {
+								return line
+							}
+
+							return toJsxRuntime(
+								{ type: "element", tagName: "span", properties: {}, children: lineCodeEl.children },
+								{ Fragment, jsx, jsxs },
+							)
+						} catch {
+							return line
+						}
+					})
+				}
+
+				return highlightedLines
+			} catch {
+				return textLines.map((line) => line || "")
+			}
+		}
+
+		// Process both old and new text
+		const oldLines = highlightAndExtractLines(oldText)
+		const newLines = highlightAndExtractLines(newText)
+
+		return { oldLines, newLines }
+	} catch {
+		// Fallback to plain text on any error
+		return {
+			oldLines: oldText.split("\n").map((line) => line || ""),
+			newLines: newText.split("\n").map((line) => line || ""),
+		}
+	}
+}

+ 96 - 0
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 []
+	}
+}