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

Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
Hannes Rudolph 2025-11-06 14:18:01 -07:00 committed by GitHub
parent d631aa643c
commit 0fd7023ea7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1141 additions and 57 deletions

8
pnpm-lock.yaml generated
View file

@ -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:

71
src/core/diff/stats.ts Normal file
View file

@ -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 })
}

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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",

View file

@ -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>
)

View file

@ -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>
</>

View file

@ -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

View file

@ -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()
})
})

View file

@ -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>

View file

@ -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

View file

@ -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);
}

View file

@ -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)
})
})

View file

@ -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 || ""),
}
}
}

View file

@ -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 []
}
}