Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
db288a6
feat(chat): improve diff appearance in main chat view
hannesrudolph Oct 30, 2025
b09b3ff
ui(chat): constrain expanded diff height with scroll (max-h 300px) to…
hannesrudolph Oct 30, 2025
d689b6e
fix(chat): rely on computeDiffStats for new file unified diffs; remov…
hannesrudolph Oct 30, 2025
0118ead
ui(diff): VSCode-style highlighting in [DiffView](webview-ui/src/comp…
hannesrudolph Oct 30, 2025
3ea5a34
feat(diff): enhance DiffView with syntax highlighting and improved st…
hannesrudolph Oct 30, 2025
1a75e1f
feat(webview-ui): normalize diffs to unified format and enhance DiffV…
hannesrudolph Oct 30, 2025
2e666b3
fix(diff): update regex in convertSearchReplaceToUnifiedDiff for impr…
hannesrudolph Oct 30, 2025
3a69fac
fix(webview-ui): use theme foreground for +/- indicators in DiffView …
hannesrudolph Oct 30, 2025
40f9435
fix(webview-ui): resolve CodeQL warning in stripCData by handling HTM…
hannesrudolph Oct 30, 2025
80dd8fc
fix(webview-ui): stripCData removes raw and HTML-encoded CDATA marker…
hannesrudolph Oct 30, 2025
43b76c9
fix(webview-ui): correct new-file diff line counting by ignoring trai…
hannesrudolph Oct 30, 2025
e4f1866
webview-ui: remove redundant CDATA replacements; improve unified diff…
hannesrudolph Oct 30, 2025
223e04d
apply_diff: stream unified diffs to UI during batch preview; include …
hannesrudolph Nov 3, 2025
6be5714
refactor(diffUtils): remove SEARCH/REPLACE handling and normalize dif…
hannesrudolph Nov 4, 2025
55973b5
fix(webview): accept 'searchAndReplace' in ChatRow switch via string …
hannesrudolph Nov 4, 2025
266650e
fix(webview-ui): Address PR review findings for diff view improvements
hannesrudolph Nov 4, 2025
504d12c
fix(tools): Generate unified diff for write_to_file in background edi…
hannesrudolph Nov 4, 2025
7ed3238
fix(tools): Load original content before generating diff in backgroun…
hannesrudolph Nov 4, 2025
35c1f03
Moved business logic out of webview and cleaned up
hannesrudolph Nov 6, 2025
02e8779
change display to match checkpoint view diff before and after lines.
hannesrudolph Nov 6, 2025
1ff1032
Cleanup and formatting
hannesrudolph Nov 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 71 additions & 0 deletions src/core/diff/stats.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
4 changes: 3 additions & 1 deletion src/core/prompts/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions src/core/tools/applyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -158,6 +164,8 @@ export async function applyDiffToolLegacy(
const completeMessage = JSON.stringify({
...sharedMessageProps,
diff: diffContent,
content: unifiedPatch,
diffStats,
isProtected: isWriteProtected,
} satisfies ClineSayTool)

Expand Down Expand Up @@ -194,6 +202,8 @@ export async function applyDiffToolLegacy(
const completeMessage = JSON.stringify({
...sharedMessageProps,
diff: diffContent,
content: unifiedPatch,
diffStats,
isProtected: isWriteProtected,
} satisfies ClineSayTool)

Expand Down
29 changes: 15 additions & 14 deletions src/core/tools/insertContentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand All @@ -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
Expand Down
59 changes: 52 additions & 7 deletions src/core/tools/multiApplyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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 = ""

Expand Down Expand Up @@ -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
Expand Down
38 changes: 24 additions & 14 deletions src/core/tools/writeToFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading