From 1c5bd665715fadee066c8762f77eceff407b4077 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Thu, 12 Jun 2025 09:46:06 +0700 Subject: [PATCH 01/13] feat: add BatchDiffApproval component for multi-file diff application - Introduced a new component `BatchDiffApproval` to handle the approval of batch changes across multiple files. - Integrated the `BatchDiffApproval` component into `ChatRow` to display batch diff requests. - Updated experimental settings to include a toggle for multi-file apply diff functionality. - Enhanced localization files to support new strings related to batch changes in multiple languages. - Updated tests to cover the new multi-file apply diff feature. --- packages/types/src/experiment.ts | 8 +- .../strategies/multi-file-search-replace.ts | 735 ++++++++++++++++++ .../diff/strategies/multi-search-replace.ts | 23 +- src/core/prompts/__tests__/sections.test.ts | 4 +- src/core/task/Task.ts | 21 +- src/core/task/__tests__/Task.strategy.spec.ts | 107 +++ .../applyDiffTool.experiment.spec.ts | 137 ++++ src/core/tools/applyDiffTool.ts | 621 ++++++++++++--- src/core/tools/applyDiffToolLegacy.ts | 198 +++++ .../webview/__tests__/ClineProvider.test.ts | 2 + src/core/webview/generateSystemPrompt.ts | 12 +- src/shared/ExtensionMessage.ts | 11 + src/shared/__tests__/experiments.test.ts | 16 + src/shared/experiments.ts | 2 + src/shared/tools.ts | 15 +- src/vitest.setup.ts | 462 +++++++++++ .../src/components/chat/BatchDiffApproval.tsx | 56 ++ webview-ui/src/components/chat/ChatRow.tsx | 17 + .../settings/ExperimentalSettings.tsx | 12 + .../__tests__/ExtensionStateContext.test.tsx | 2 + webview-ui/src/i18n/locales/ca/chat.json | 3 +- webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/chat.json | 3 +- webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/chat.json | 1 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/chat.json | 3 +- webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/chat.json | 3 +- webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/chat.json | 3 +- webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/it/chat.json | 3 +- webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/chat.json | 3 +- webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/chat.json | 3 +- webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/chat.json | 3 +- webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/chat.json | 3 +- webview-ui/src/i18n/locales/pl/settings.json | 4 + webview-ui/src/i18n/locales/pt-BR/chat.json | 3 +- .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/chat.json | 3 +- webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/chat.json | 3 +- webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/chat.json | 3 +- webview-ui/src/i18n/locales/vi/settings.json | 4 + webview-ui/src/i18n/locales/zh-CN/chat.json | 3 +- .../src/i18n/locales/zh-CN/settings.json | 4 + webview-ui/src/i18n/locales/zh-TW/chat.json | 3 +- .../src/i18n/locales/zh-TW/settings.json | 4 + 54 files changed, 2412 insertions(+), 166 deletions(-) create mode 100644 src/core/diff/strategies/multi-file-search-replace.ts create mode 100644 src/core/task/__tests__/Task.strategy.spec.ts create mode 100644 src/core/tools/__tests__/applyDiffTool.experiment.spec.ts create mode 100644 src/core/tools/applyDiffToolLegacy.ts create mode 100644 webview-ui/src/components/chat/BatchDiffApproval.tsx diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 195d5a2cdd..59b2524fdb 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -6,12 +6,7 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js" * ExperimentId */ -export const experimentIds = [ - "powerSteering", - "marketplace", - "concurrentFileReads", - "disableCompletionCommand", -] as const +export const experimentIds = ["powerSteering", "concurrentFileReads", "disableCompletionCommand", "marketplace", "multiFileApplyDiff"] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -26,6 +21,7 @@ export const experimentsSchema = z.object({ marketplace: z.boolean(), concurrentFileReads: z.boolean(), disableCompletionCommand: z.boolean(), + multiFileApplyDiff: z.boolean(), }) export type Experiments = z.infer diff --git a/src/core/diff/strategies/multi-file-search-replace.ts b/src/core/diff/strategies/multi-file-search-replace.ts new file mode 100644 index 0000000000..57503da5f4 --- /dev/null +++ b/src/core/diff/strategies/multi-file-search-replace.ts @@ -0,0 +1,735 @@ +import { distance } from "fastest-levenshtein" +import { ToolProgressStatus } from "@roo-code/types" + +import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" +import { ToolUse, DiffStrategy, DiffResult } from "../../../shared/tools" +import { normalizeString } from "../../../utils/text-normalization" + +const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches + +function getSimilarity(original: string, search: string): number { + // Empty searches are no longer supported + if (search === "") { + return 0 + } + + // Use the normalizeString utility to handle smart quotes and other special characters + const normalizedOriginal = normalizeString(original) + const normalizedSearch = normalizeString(search) + + if (normalizedOriginal === normalizedSearch) { + return 1 + } + + // Calculate Levenshtein distance using fastest-levenshtein's distance function + const dist = distance(normalizedOriginal, normalizedSearch) + + // Calculate similarity ratio (0 to 1, where 1 is an exact match) + const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length) + return 1 - dist / maxLength +} + +/** + * Performs a "middle-out" search of `lines` (between [startIndex, endIndex]) to find + * the slice that is most similar to `searchChunk`. Returns the best score, index, and matched text. + */ +function fuzzySearch(lines: string[], searchChunk: string, startIndex: number, endIndex: number) { + let bestScore = 0 + let bestMatchIndex = -1 + let bestMatchContent = "" + + const searchLen = searchChunk.split(/\r?\n/).length + + // Middle-out from the midpoint + const midPoint = Math.floor((startIndex + endIndex) / 2) + let leftIndex = midPoint + let rightIndex = midPoint + 1 + + while (leftIndex >= startIndex || rightIndex <= endIndex - searchLen) { + if (leftIndex >= startIndex) { + const originalChunk = lines.slice(leftIndex, leftIndex + searchLen).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + + if (similarity > bestScore) { + bestScore = similarity + bestMatchIndex = leftIndex + bestMatchContent = originalChunk + } + leftIndex-- + } + + if (rightIndex <= endIndex - searchLen) { + const originalChunk = lines.slice(rightIndex, rightIndex + searchLen).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + + if (similarity > bestScore) { + bestScore = similarity + bestMatchIndex = rightIndex + bestMatchContent = originalChunk + } + rightIndex++ + } + } + + return { bestScore, bestMatchIndex, bestMatchContent } +} + +export class MultiFileSearchReplaceDiffStrategy implements DiffStrategy { + private fuzzyThreshold: number + private bufferLines: number + + getName(): string { + return "MultiFileSearchReplace" + } + + constructor(fuzzyThreshold?: number, bufferLines?: number) { + // Use provided threshold or default to exact matching (1.0) + // Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9) + // so we use it directly here + this.fuzzyThreshold = fuzzyThreshold ?? 1.0 + this.bufferLines = bufferLines ?? BUFFER_LINES + } + + getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string { + return `## apply_diff + +Description: Request to apply targeted modifications to one or more files by searching for specific sections of content and replacing them. This tool supports both single-file and multi-file operations, allowing you to make changes across multiple files in a single request. + +You can perform multiple distinct search and replace operations within a single \`apply_diff\` call by providing multiple SEARCH/REPLACE blocks in the \`diff\` parameter. This is the preferred way to make several targeted changes efficiently. + +The SEARCH section must exactly match existing content including whitespace and indentation. +If you're not confident in the exact content to search for, use the read_file tool first to get the exact content. +When applying the diffs, be extra careful to remember to change any closing brackets or other syntax that may be affected by the diff farther down in the file. +ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks + +Parameters: +- args: Contains one or more file elements, where each file contains: + - path: (required) The path of the file to modify (relative to the current workspace directory ${args.cwd}) + - diff: (required) One or more diff elements containing: + - content: (required) The search/replace block defining the changes. + - start_line: (optional) The line number of original content where the search block starts. + +Diff format: +\`\`\` +<<<<<<< SEARCH +:start_line: (optional) The line number of original content where the search block starts. +------- +[exact content to find including whitespace] +======= +[new content to replace with] +>>>>>>> REPLACE +\`\`\` + +Example: + +Original file: +\`\`\` +1 | def calculate_total(items): +2 | total = 0 +3 | for item in items: +4 | total += item +5 | return total +\`\`\` + +Search/Replace content: + + + + eg.file.py + + +\`\`\` +<<<<<<< SEARCH +def calculate_total(items): + total = 0 + for item in items: + total += item + return total +======= +def calculate_total(items): + """Calculate total with 10% markup""" + return sum(item * 1.1 for item in items) +>>>>>>> REPLACE +\`\`\` + + + + + + +Search/Replace content with multi edits in one file: + + + + eg.file.py + + +\`\`\` +<<<<<<< SEARCH +def calculate_total(items): + sum = 0 +======= +def calculate_sum(items): + sum = 0 +>>>>>>> REPLACE +\`\`\` + + + + +\`\`\` +<<<<<<< SEARCH + total += item + return total +======= + sum += item + return sum +>>>>>>> REPLACE +\`\`\` + + + + + eg.file2.py + + +\`\`\` +<<<<<<< SEARCH +def greet(name): + return "Hello " + name +======= +def greet(name): + return f"Hello {name}!" +>>>>>>> REPLACE +\`\`\` + + + + + + + +Usage: + + + + File path here + + +Your search/replace content here +You can use multi search/replace block in one diff block, but make sure to include the line numbers for each block. +Only use a single line of '=======' between search and replacement content, because multiple '=======' will corrupt the file. + + 1 + + + + Another file path + + +Another search/replace content here +You can apply changes to multiple files in a single request. +Each file requires its own path, start_line, and diff elements. + + 5 + + + +` + } + + private unescapeMarkers(content: string): string { + return content + .replace(/^\\<<<<<<>>>>>>/gm, ">>>>>>>") + .replace(/^\\-------/gm, "-------") + .replace(/^\\:end_line:/gm, ":end_line:") + .replace(/^\\:start_line:/gm, ":start_line:") + } + + private validateMarkerSequencing(diffContent: string): { success: boolean; error?: string } { + enum State { + START, + AFTER_SEARCH, + AFTER_SEPARATOR, + } + + const state = { current: State.START, line: 0 } + + const SEARCH = "<<<<<<< SEARCH" + const SEP = "=======" + const REPLACE = ">>>>>>> REPLACE" + const SEARCH_PREFIX = "<<<<<<< " + const REPLACE_PREFIX = ">>>>>>> " + + const reportMergeConflictError = (found: string, _expected: string) => ({ + success: false, + error: + `ERROR: Special marker '${found}' found in your diff content at line ${state.line}:\n` + + "\n" + + `When removing merge conflict markers like '${found}' from files, you MUST escape them\n` + + "in your SEARCH section by prepending a backslash (\\) at the beginning of the line:\n" + + "\n" + + "CORRECT FORMAT:\n\n" + + "<<<<<<< SEARCH\n" + + "content before\n" + + `\\${found} <-- Note the backslash here in this example\n` + + "content after\n" + + "=======\n" + + "replacement content\n" + + ">>>>>>> REPLACE\n" + + "\n" + + "Without escaping, the system confuses your content with diff syntax markers.\n" + + "You may use multiple diff blocks in a single diff request, but ANY of ONLY the following separators that occur within SEARCH or REPLACE content must be escaped, as follows:\n" + + `\\${SEARCH}\n` + + `\\${SEP}\n` + + `\\${REPLACE}\n`, + }) + + const reportInvalidDiffError = (found: string, expected: string) => ({ + success: false, + error: + `ERROR: Diff block is malformed: marker '${found}' found in your diff content at line ${state.line}. Expected: ${expected}\n` + + "\n" + + "CORRECT FORMAT:\n\n" + + "<<<<<<< SEARCH\n" + + ":start_line: (optional) The line number of original content where the search block starts.\n" + + "-------\n" + + "[exact content to find including whitespace]\n" + + "=======\n" + + "[new content to replace with]\n" + + ">>>>>>> REPLACE\n", + }) + + const reportLineMarkerInReplaceError = (marker: string) => ({ + success: false, + error: + `ERROR: Invalid line marker '${marker}' found in REPLACE section at line ${state.line}\n` + + "\n" + + "Line markers (:start_line: and :end_line:) are only allowed in SEARCH sections.\n" + + "\n" + + "CORRECT FORMAT:\n" + + "<<<<<<< SEARCH\n" + + ":start_line:5\n" + + "content to find\n" + + "=======\n" + + "replacement content\n" + + ">>>>>>> REPLACE\n" + + "\n" + + "INCORRECT FORMAT:\n" + + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + ":start_line:5 <-- Invalid location\n" + + "replacement content\n" + + ">>>>>>> REPLACE\n", + }) + + const lines = diffContent.split("\n") + const searchCount = lines.filter((l) => l.trim() === SEARCH).length + const sepCount = lines.filter((l) => l.trim() === SEP).length + const replaceCount = lines.filter((l) => l.trim() === REPLACE).length + + const likelyBadStructure = searchCount !== replaceCount || sepCount < searchCount + + for (const line of diffContent.split("\n")) { + state.line++ + const marker = line.trim() + + // Check for line markers in REPLACE sections (but allow escaped ones) + if (state.current === State.AFTER_SEPARATOR) { + if (marker.startsWith(":start_line:") && !line.trim().startsWith("\\:start_line:")) { + return reportLineMarkerInReplaceError(":start_line:") + } + if (marker.startsWith(":end_line:") && !line.trim().startsWith("\\:end_line:")) { + return reportLineMarkerInReplaceError(":end_line:") + } + } + + switch (state.current) { + case State.START: + if (marker === SEP) + return likelyBadStructure + ? reportInvalidDiffError(SEP, SEARCH) + : reportMergeConflictError(SEP, SEARCH) + if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH) + if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) + if (marker === SEARCH) state.current = State.AFTER_SEARCH + else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) + break + + case State.AFTER_SEARCH: + if (marker === SEARCH) return reportInvalidDiffError(SEARCH, SEP) + if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) + if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP) + if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) + if (marker === SEP) state.current = State.AFTER_SEPARATOR + break + + case State.AFTER_SEPARATOR: + if (marker === SEARCH) return reportInvalidDiffError(SEARCH, REPLACE) + if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE) + if (marker === SEP) + return likelyBadStructure + ? reportInvalidDiffError(SEP, REPLACE) + : reportMergeConflictError(SEP, REPLACE) + if (marker === REPLACE) state.current = State.START + else if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, REPLACE) + break + } + } + + return state.current === State.START + ? { success: true } + : { + success: false, + error: `ERROR: Unexpected end of sequence: Expected '${ + state.current === State.AFTER_SEARCH ? "=======" : ">>>>>>> REPLACE" + }' was not found.`, + } + } + + async applyDiff( + originalContent: string, + diffContent: string | Array<{ content: string; startLine?: number }>, + _paramStartLine?: number, + _paramEndLine?: number, + ): Promise { + // Handle array-based input for multi-file support + if (Array.isArray(diffContent)) { + // Process each diff item separately and combine results + let resultContent = originalContent + const allFailParts: DiffResult[] = [] + let successCount = 0 + + for (const diffItem of diffContent) { + const singleResult = await this.applySingleDiff(resultContent, diffItem.content, diffItem.startLine) + + if (singleResult.success && singleResult.content) { + resultContent = singleResult.content + successCount++ + } else { + allFailParts.push(singleResult) + } + } + + if (successCount === 0) { + return { + success: false, + error: "Failed to apply any diffs", + failParts: allFailParts, + } + } + + return { + success: true, + content: resultContent, + failParts: allFailParts.length > 0 ? allFailParts : undefined, + } + } + + // Handle string-based input (legacy) + return this.applySingleDiff(originalContent, diffContent, _paramStartLine) + } + + private async applySingleDiff( + originalContent: string, + diffContent: string, + _paramStartLine?: number, + ): Promise { + const validseq = this.validateMarkerSequencing(diffContent) + if (!validseq.success) { + return { + success: false, + error: validseq.error!, + } + } + + /* Regex parts: + 1. (?:^|\n) Ensures the first marker starts at the beginning of the file or right after a newline. + 2. (?>>>>>> REPLACE)(?=\n|$) Matches the final ">>>>>>> REPLACE" marker on its own line (and requires a following newline or the end of file). + */ + let matches = [ + ...diffContent.matchAll( + /(?:^|\n)(?>>>>>> REPLACE)(?=\n|$)/g, + ), + ] + + if (matches.length === 0) { + return { + success: false, + error: `Invalid diff format - missing required sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n:start_line: start line\\n-------\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include start_line/SEARCH/=======/REPLACE sections with correct markers on new lines`, + } + } + + // Detect line ending from original content + const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n" + let resultLines = originalContent.split(/\r?\n/) + let delta = 0 + let diffResults: DiffResult[] = [] + let appliedCount = 0 + + const replacements = matches + .map((match) => ({ + startLine: Number(match[2] ?? 0), + searchContent: match[6], + replaceContent: match[7], + })) + .sort((a, b) => a.startLine - b.startLine) + + for (const replacement of replacements) { + let { searchContent, replaceContent } = replacement + let startLine = replacement.startLine + (replacement.startLine === 0 ? 0 : delta) + + // First unescape any escaped markers in the content + searchContent = this.unescapeMarkers(searchContent) + replaceContent = this.unescapeMarkers(replaceContent) + + // Strip line numbers from search and replace content if every line starts with a line number + const hasAllLineNumbers = + (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) || + (everyLineHasLineNumbers(searchContent) && replaceContent.trim() === "") + + if (hasAllLineNumbers && startLine === 0) { + startLine = parseInt(searchContent.split("\n")[0].split("|")[0]) + } + + if (hasAllLineNumbers) { + searchContent = stripLineNumbers(searchContent) + replaceContent = stripLineNumbers(replaceContent) + } + + // Validate that search and replace content are not identical + if (searchContent === replaceContent) { + diffResults.push({ + success: false, + error: + `Search and replace content are identical - no changes would be made\n\n` + + `Debug Info:\n` + + `- Search and replace must be different to make changes\n` + + `- Use read_file to verify the content you want to change`, + }) + continue + } + + // Split content into lines, handling both \n and \r\n + let searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/) + let replaceLines = replaceContent === "" ? [] : replaceContent.split(/\r?\n/) + + // Validate that search content is not empty + if (searchLines.length === 0) { + diffResults.push({ + success: false, + error: `Empty search content is not allowed\n\nDebug Info:\n- Search content cannot be empty\n- For insertions, provide a specific line using :start_line: and include content to search for\n- For example, match a single line to insert before/after it`, + }) + continue + } + + let endLine = replacement.startLine + searchLines.length - 1 + + // Initialize search variables + let matchIndex = -1 + let bestMatchScore = 0 + let bestMatchContent = "" + let searchChunk = searchLines.join("\n") + + // Determine search bounds + let searchStartIndex = 0 + let searchEndIndex = resultLines.length + + // Validate and handle line range if provided + if (startLine) { + // Convert to 0-based index + const exactStartIndex = startLine - 1 + const searchLen = searchLines.length + const exactEndIndex = exactStartIndex + searchLen - 1 + + // Try exact match first + const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + + if (similarity >= this.fuzzyThreshold) { + matchIndex = exactStartIndex + bestMatchScore = similarity + bestMatchContent = originalChunk + } else { + // Set bounds for buffered search + searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1)) + searchEndIndex = Math.min(resultLines.length, startLine + searchLines.length + this.bufferLines) + } + } + + // If no match found yet, try middle-out search within bounds + if (matchIndex === -1) { + const { + bestScore, + bestMatchIndex, + bestMatchContent: midContent, + } = fuzzySearch(resultLines, searchChunk, searchStartIndex, searchEndIndex) + + matchIndex = bestMatchIndex + bestMatchScore = bestScore + bestMatchContent = midContent + } + + // Try aggressive line number stripping as a fallback if regular matching fails + if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) { + // Strip both search and replace content once (simultaneously) + const aggressiveSearchContent = stripLineNumbers(searchContent, true) + const aggressiveReplaceContent = stripLineNumbers(replaceContent, true) + const aggressiveSearchLines = aggressiveSearchContent ? aggressiveSearchContent.split(/\r?\n/) : [] + const aggressiveSearchChunk = aggressiveSearchLines.join("\n") + + // Try middle-out search again with aggressive stripped content (respecting the same search bounds) + const { + bestScore, + bestMatchIndex, + bestMatchContent: aggContent, + } = fuzzySearch(resultLines, aggressiveSearchChunk, searchStartIndex, searchEndIndex) + + if (bestMatchIndex !== -1 && bestScore >= this.fuzzyThreshold) { + matchIndex = bestMatchIndex + bestMatchScore = bestScore + bestMatchContent = aggContent + + // Replace the original search/replace with their stripped versions + searchContent = aggressiveSearchContent + replaceContent = aggressiveReplaceContent + searchLines = aggressiveSearchLines + replaceLines = replaceContent ? replaceContent.split(/\r?\n/) : [] + } else { + // No match found with either method + const originalContentSection = + startLine !== undefined && endLine !== undefined + ? `\n\nOriginal Content:\n${addLineNumbers( + resultLines + .slice( + Math.max(0, startLine - 1 - this.bufferLines), + Math.min(resultLines.length, endLine + this.bufferLines), + ) + .join("\n"), + Math.max(1, startLine - this.bufferLines), + )}` + : `\n\nOriginal Content:\n${addLineNumbers(resultLines.join("\n"))}` + + const bestMatchSection = bestMatchContent + ? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}` + : `\n\nBest Match Found:\n(no match)` + + const lineRange = startLine ? ` at line: ${startLine}` : "" + + diffResults.push({ + success: false, + error: `No sufficiently similar match found${lineRange} (${Math.floor( + bestMatchScore * 100, + )}% similar, needs ${Math.floor( + this.fuzzyThreshold * 100, + )}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor( + bestMatchScore * 100, + )}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${ + startLine ? `starting at line ${startLine}` : "start to end" + }\n- Tried both standard and aggressive line number stripping\n- Tip: Use the read_file tool to get the latest content of the file before attempting to use the apply_diff tool again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`, + }) + continue + } + } + + // Get the matched lines from the original content + const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length) + + // Get the exact indentation (preserving tabs/spaces) of each line + const originalIndents = matchedLines.map((line) => { + const match = line.match(/^[\t ]*/) + return match ? match[0] : "" + }) + + // Get the exact indentation of each line in the search block + const searchIndents = searchLines.map((line) => { + const match = line.match(/^[\t ]*/) + return match ? match[0] : "" + }) + + // Apply the replacement while preserving exact indentation + const indentedReplaceLines = replaceLines.map((line) => { + // Get the matched line's exact indentation + const matchedIndent = originalIndents[0] || "" + + // Get the current line's indentation relative to the search content + const currentIndentMatch = line.match(/^[\t ]*/) + const currentIndent = currentIndentMatch ? currentIndentMatch[0] : "" + const searchBaseIndent = searchIndents[0] || "" + + // Calculate the relative indentation level + const searchBaseLevel = searchBaseIndent.length + const currentLevel = currentIndent.length + const relativeLevel = currentLevel - searchBaseLevel + + // If relative level is negative, remove indentation from matched indent + // If positive, add to matched indent + const finalIndent = + relativeLevel < 0 + ? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel)) + : matchedIndent + currentIndent.slice(searchBaseLevel) + + return finalIndent + line.trim() + }) + + // Construct the final content + const beforeMatch = resultLines.slice(0, matchIndex) + const afterMatch = resultLines.slice(matchIndex + searchLines.length) + resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch] + + delta = delta - matchedLines.length + replaceLines.length + appliedCount++ + } + + const finalContent = resultLines.join(lineEnding) + + if (appliedCount === 0) { + return { + success: false, + failParts: diffResults, + } + } + + return { + success: true, + content: finalContent, + failParts: diffResults, + } + } + + getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus { + const diffContent = toolUse.params.diff + if (diffContent) { + const icon = "diff-multiple" + + if (toolUse.partial) { + if (Math.floor(diffContent.length / 10) % 10 === 0) { + const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length + return { icon, text: `${searchBlockCount}` } + } + } else if (result) { + const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length + if (result.failParts?.length) { + return { + icon, + text: `${searchBlockCount - result.failParts.length}/${searchBlockCount}`, + } + } else { + return { icon, text: `${searchBlockCount}` } + } + } + } + + return {} + } +} diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts index 9e740a6571..7a82e720d4 100644 --- a/src/core/diff/strategies/multi-search-replace.ts +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-irregular-whitespace */ - import { distance } from "fastest-levenshtein" import { ToolProgressStatus } from "@roo-code/types" @@ -333,10 +331,11 @@ Only use a single line of '=======' between search and replacement content, beca async applyDiff( originalContent: string, - diffContent: string, + _diffContent: string | Array<{ content: string; startLine?: number }>, _paramStartLine?: number, _paramEndLine?: number, ): Promise { + let diffContent: string | undefined = _diffContent as string const validseq = this.validateMarkerSequencing(diffContent) if (!validseq.success) { return { @@ -349,31 +348,31 @@ Only use a single line of '=======' between search and replacement content, beca Regex parts: 1. (?:^|\n) -   Ensures the first marker starts at the beginning of the file or right after a newline. + Ensures the first marker starts at the beginning of the file or right after a newline. 2. (?>>>>>> REPLACE)(?=\n|$) -   Matches the final “>>>>>>> REPLACE” marker on its own line (and requires a following newline or the end of file). + Matches the final “>>>>>>> REPLACE” marker on its own line (and requires a following newline or the end of file). */ let matches = [ diff --git a/src/core/prompts/__tests__/sections.test.ts b/src/core/prompts/__tests__/sections.test.ts index d6515883c8..9460f2a29a 100644 --- a/src/core/prompts/__tests__/sections.test.ts +++ b/src/core/prompts/__tests__/sections.test.ts @@ -1,6 +1,6 @@ import { addCustomInstructions } from "../sections/custom-instructions" import { getCapabilitiesSection } from "../sections/capabilities" -import { DiffStrategy, DiffResult } from "../../../shared/tools" +import { DiffStrategy, DiffResult, DiffItem } from "../../../shared/tools" describe("addCustomInstructions", () => { test("adds vscode language to custom instructions", async () => { @@ -35,7 +35,7 @@ describe("getCapabilitiesSection", () => { const mockDiffStrategy: DiffStrategy = { getName: () => "MockStrategy", getToolDescription: () => "apply_diff tool description", - applyDiff: async (_originalContent: string, _diffContent: string): Promise => { + async applyDiff(_originalContent: string, _diffContents: DiffItem[]): Promise { return { success: true, content: "mock result" } }, } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fa814f0661..e881749e86 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -38,6 +38,7 @@ import { getApiMetrics } from "../../shared/getApiMetrics" import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug } from "../../shared/modes" import { DiffStrategy } from "../../shared/tools" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" // services import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" @@ -68,6 +69,7 @@ import { type AssistantMessageContent, parseAssistantMessage, presentAssistantMe import { truncateConversationIfNeeded } from "../sliding-window" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" +import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "../task-persistence" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { @@ -250,7 +252,24 @@ export class Task extends EventEmitter { TelemetryService.instance.captureTaskCreated(this.taskId) } - this.diffStrategy = new MultiSearchReplaceDiffStrategy(this.fuzzyMatchThreshold) + // Only set up diff strategy if diff is enabled + if (this.diffEnabled) { + // Default to old strategy, will be updated if experiment is enabled + this.diffStrategy = new MultiSearchReplaceDiffStrategy(this.fuzzyMatchThreshold) + + // Check experiment asynchronously and update strategy if needed + provider.getState().then((state) => { + const isMultiFileApplyDiffEnabled = experiments.isEnabled( + state.experiments ?? {}, + EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, + ) + + if (isMultiFileApplyDiffEnabled) { + this.diffStrategy = new MultiFileSearchReplaceDiffStrategy(this.fuzzyMatchThreshold) + } + }) + } + this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit) onCreated?.(this) diff --git a/src/core/task/__tests__/Task.strategy.spec.ts b/src/core/task/__tests__/Task.strategy.spec.ts new file mode 100644 index 0000000000..fbe73a6d65 --- /dev/null +++ b/src/core/task/__tests__/Task.strategy.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { Task } from "../Task" +import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" +import { MultiFileSearchReplaceDiffStrategy } from "../../diff/strategies/multi-file-search-replace" +import { EXPERIMENT_IDS } from "../../../shared/experiments" + +describe("Task - Dynamic Strategy Selection", () => { + let mockProvider: any + let mockApiConfig: any + + beforeEach(() => { + vi.clearAllMocks() + + mockApiConfig = { + apiProvider: "anthropic", + apiKey: "test-key", + } + + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/storage" }, + }, + getState: vi.fn(), + } + }) + + it("should use MultiSearchReplaceDiffStrategy by default", async () => { + mockProvider.getState.mockResolvedValue({ + experiments: { + [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: false, + }, + }) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + enableDiff: true, + task: "test task", + startTask: false, + }) + + // Initially should be MultiSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) + expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace") + }) + + it("should switch to MultiFileSearchReplaceDiffStrategy when experiment is enabled", async () => { + mockProvider.getState.mockResolvedValue({ + experiments: { + [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, + }, + }) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + enableDiff: true, + task: "test task", + startTask: false, + }) + + // Initially should be MultiSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) + + // Wait for async strategy update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Should have switched to MultiFileSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiFileSearchReplaceDiffStrategy) + expect(task.diffStrategy?.getName()).toBe("MultiFileSearchReplace") + }) + + it("should keep MultiSearchReplaceDiffStrategy when experiments are undefined", async () => { + mockProvider.getState.mockResolvedValue({}) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + enableDiff: true, + task: "test task", + startTask: false, + }) + + // Initially should be MultiSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) + + // Wait for async strategy update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Should still be MultiSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) + expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace") + }) + + it("should not create diff strategy when enableDiff is false", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + enableDiff: false, + task: "test task", + startTask: false, + }) + + expect(task.diffEnabled).toBe(false) + expect(task.diffStrategy).toBeUndefined() + }) +}) diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts new file mode 100644 index 0000000000..8543f6b7b3 --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { applyDiffTool } from "../applyDiffTool" +import { applyDiffToolLegacy } from "../applyDiffToolLegacy" +import { Task } from "../../task/Task" +import { EXPERIMENT_IDS, experiments } from "../../../shared/experiments" + +vi.mock("../applyDiffToolLegacy") + +describe("applyDiffTool experiment routing", () => { + let mockCline: any + let mockBlock: any + let mockAskApproval: any + let mockHandleError: any + let mockPushToolResult: any + let mockRemoveClosingTag: any + let mockProvider: any + + beforeEach(() => { + vi.clearAllMocks() + + mockProvider = { + getState: vi.fn(), + } + + mockCline = { + providerRef: { + deref: vi.fn().mockReturnValue(mockProvider), + }, + cwd: "/test", + diffStrategy: { + applyDiff: vi.fn(), + getProgressStatus: vi.fn(), + }, + diffViewProvider: { + reset: vi.fn(), + }, + } as any + + mockBlock = { + params: { + path: "test.ts", + diff: "test diff", + }, + partial: false, + } + + mockAskApproval = vi.fn() + mockHandleError = vi.fn() + mockPushToolResult = vi.fn() + mockRemoveClosingTag = vi.fn((tag, value) => value) + }) + + it("should use legacy tool when MULTI_FILE_APPLY_DIFF experiment is disabled", async () => { + mockProvider.getState.mockResolvedValue({ + experiments: { + [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: false, + }, + }) + + await applyDiffTool( + mockCline, + mockBlock, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(applyDiffToolLegacy).toHaveBeenCalledWith( + mockCline, + mockBlock, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + }) + + it("should use legacy tool when experiments are not defined", async () => { + mockProvider.getState.mockResolvedValue({}) + + await applyDiffTool( + mockCline, + mockBlock, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(applyDiffToolLegacy).toHaveBeenCalledWith( + mockCline, + mockBlock, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + }) + + it("should use new tool when MULTI_FILE_APPLY_DIFF experiment is enabled", async () => { + mockProvider.getState.mockResolvedValue({ + experiments: { + [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, + }, + }) + + // Mock the new tool behavior - it should continue with the new implementation + // Since we're not mocking the entire function, we'll just verify it doesn't call legacy + await applyDiffTool( + mockCline, + mockBlock, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(applyDiffToolLegacy).not.toHaveBeenCalled() + }) + + it("should use new tool when provider is not available", async () => { + mockCline.providerRef.deref.mockReturnValue(null) + + await applyDiffTool( + mockCline, + mockBlock, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // When provider is null, it should continue with new implementation (not call legacy) + expect(applyDiffToolLegacy).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index 500c7a92c3..a57f5e472e 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -11,6 +11,43 @@ import { formatResponse } from "../prompts/responses" import { fileExistsAtPath } from "../../utils/fs" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" +import { parseXml } from "../../utils/xml" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { applyDiffToolLegacy } from "./applyDiffToolLegacy" + +interface DiffOperation { + path: string + diff: Array<{ + content: string + startLine?: number + }> +} + +// Track operation status +interface OperationResult { + path: string + status: "pending" | "approved" | "denied" | "blocked" | "error" + error?: string + result?: string + diffItems?: Array<{ content: string; startLine?: number }> + absolutePath?: string + fileExists?: boolean +} + +// Add proper type definitions +interface ParsedFile { + path: string + diff: ParsedDiff | ParsedDiff[] +} + +interface ParsedDiff { + content: string + start_line?: string +} + +interface ParsedXmlResult { + file: ParsedFile | ParsedFile[] +} export async function applyDiffTool( cline: Task, @@ -20,180 +57,512 @@ export async function applyDiffTool( pushToolResult: PushToolResult, removeClosingTag: RemoveClosingTag, ) { - const relPath: string | undefined = block.params.path - let diffContent: string | undefined = block.params.diff - - if (diffContent && !cline.api.getModel().id.includes("claude")) { - diffContent = unescapeHtmlEntities(diffContent) + // Check if MULTI_FILE_APPLY_DIFF experiment is enabled + const provider = cline.providerRef.deref() + if (provider) { + const state = await provider.getState() + const isMultiFileApplyDiffEnabled = experiments.isEnabled( + state.experiments ?? {}, + EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, + ) + + // If experiment is disabled, use legacy tool + if (!isMultiFileApplyDiffEnabled) { + return applyDiffToolLegacy(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + } } - const sharedMessageProps: ClineSayTool = { - tool: "appliedDiff", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - diff: diffContent, + // Otherwise, continue with new multi-file implementation + const argsXmlTag: string | undefined = block.params.args + const legacyPath: string | undefined = block.params.path + const legacyDiffContent: string | undefined = block.params.diff + const legacyStartLineStr: string | undefined = block.params.start_line + + let operationsMap: Record = {} + let usingLegacyParams = false + let filteredOperationErrors: string[] = [] + + // Handle partial message first + if (block.partial) { + let filePath = "" + if (argsXmlTag) { + const match = argsXmlTag.match(/.*?([^<]+)<\/path>/s) + if (match) { + filePath = match[1] + } + } else if (legacyPath) { + // Use legacy path if argsXmlTag is not present for partial messages + filePath = legacyPath + } + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(cline.cwd, filePath), + } + const partialMessage = JSON.stringify(sharedMessageProps) + await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + return } - try { - if (block.partial) { - // Update GUI message - let toolProgressStatus + if (argsXmlTag) { + // Parse file entries from XML (new way) + try { + const parsed = parseXml(argsXmlTag, ["file.diff.content"]) as ParsedXmlResult + const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block) - } + for (const file of files) { + if (!file.path || !file.diff) continue - if (toolProgressStatus && Object.keys(toolProgressStatus).length === 0) { - return - } + const filePath = file.path - await cline - .ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus) - .catch(() => {}) - - return - } else { - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "path")) - return - } + // Initialize the operation in the map if it doesn't exist + if (!operationsMap[filePath]) { + operationsMap[filePath] = { + path: filePath, + diff: [], + } + } + + // Handle diff as either array or single element + const diffs = Array.isArray(file.diff) ? file.diff : [file.diff] + + for (let i = 0; i < diffs.length; i++) { + const diff = diffs[i] + let diffContent: string + let startLine: number | undefined - if (!diffContent) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "diff")) - return + diffContent = diff.content + startLine = diff.start_line ? parseInt(diff.start_line) : undefined + + operationsMap[filePath].diff.push({ + content: diffContent, + startLine, + }) + } } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const detailedError = `Failed to parse apply_diff XML. This usually means: +1. The XML structure is malformed or incomplete +2. Missing required , , or tags +3. Invalid characters or encoding in the XML + +Expected structure: + + + relative/path/to/file.ext + + diff content here + optional line number + + + + +Original error: ${errorMessage}` + throw new Error(detailedError) + } - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) + // Remove this duplicate check - we already checked at the beginning + } else if (legacyPath && typeof legacyDiffContent === "string") { + // Handle legacy parameters (old way) + usingLegacyParams = true + operationsMap[legacyPath] = { + path: legacyPath, + diff: [ + { + content: legacyDiffContent, // Unescaping will be handled later like new diffs + startLine: legacyStartLineStr ? parseInt(legacyStartLineStr) : undefined, + }, + ], + } + } else { + // Neither new XML args nor old path/diff params are sufficient + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + const errorMsg = await cline.sayAndCreateMissingParamError( + "apply_diff", + "args (or legacy 'path' and 'diff' parameters)", + ) + pushToolResult(errorMsg) + return + } + + // If no operations were extracted, bail out + if (Object.keys(operationsMap).length === 0) { + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + pushToolResult( + await cline.sayAndCreateMissingParamError( + "apply_diff", + usingLegacyParams + ? "legacy 'path' and 'diff' (must be valid and non-empty)" + : "args (must contain at least one valid file element)", + ), + ) + return + } + + // Convert map to array of operations for processing + const operations = Object.values(operationsMap) + + const operationResults: OperationResult[] = operations.map((op) => ({ + path: op.path, + status: "pending", + diffItems: op.diff, + })) + // Function to update operation result + const updateOperationResult = (path: string, updates: Partial) => { + const index = operationResults.findIndex((result) => result.path === path) + if (index !== -1) { + operationResults[index] = { ...operationResults[index], ...updates } + } + } + + try { + // First validate all files and prepare for batch approval + const operationsToApprove: OperationResult[] = [] + + for (const operation of operations) { + const { path: relPath, diff: diffItems } = operation + + // Verify file access is allowed + const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) - return + updateOperationResult(relPath, { + status: "blocked", + error: formatResponse.rooIgnoreError(relPath), + }) + continue } + // Verify file exists const absolutePath = path.resolve(cline.cwd, relPath) const fileExists = await fileExistsAtPath(absolutePath) - if (!fileExists) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` - await cline.say("error", formattedError) - pushToolResult(formattedError) - return + updateOperationResult(relPath, { + status: "blocked", + error: `File does not exist at path: ${absolutePath}`, + }) + continue } - let originalContent: string | null = await fs.readFile(absolutePath, "utf-8") - - // Apply the diff to the original content - const diffResult = (await cline.diffStrategy?.applyDiff( - originalContent, - diffContent, - parseInt(block.params.start_line ?? ""), - )) ?? { - success: false, - error: "No diff strategy available", + // Add to operations that need approval + const opResult = operationResults.find((r) => r.path === relPath) + if (opResult) { + opResult.absolutePath = absolutePath + opResult.fileExists = fileExists + operationsToApprove.push(opResult) } + } - // Release the original content from memory as it's no longer needed - originalContent = null - - if (!diffResult.success) { - cline.consecutiveMistakeCount++ - const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 - cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) - let formattedError = "" - TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount) + // Handle batch approval if there are multiple files + if (operationsToApprove.length > 1) { + // Prepare batch diff data + const batchDiffs = operationsToApprove.map((opResult) => { + const readablePath = getReadablePath(cline.cwd, opResult.path) + const changeCount = opResult.diffItems?.length || 0 + const changeText = changeCount === 1 ? "1 change" : `${changeCount} changes` + + return { + path: readablePath, + changeCount, + key: `${readablePath} (${changeText})`, + content: opResult.path, // Full relative path + diffs: opResult.diffItems?.map((item) => ({ + content: item.content, + startLine: item.startLine, + })), + } + }) - if (diffResult.failParts && diffResult.failParts.length > 0) { - for (const failPart of diffResult.failParts) { - if (failPart.success) { - continue - } + const completeMessage = JSON.stringify({ + tool: "appliedDiff", + batchDiffs, + } satisfies ClineSayTool) - const errorDetails = failPart.details ? JSON.stringify(failPart.details, null, 2) : "" + const { response, text, images } = await cline.ask("tool", completeMessage, false) - formattedError = `\n${ - failPart.error - }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` + // Process batch response + if (response === "yesButtonClicked") { + // Approve all files + if (text) { + await cline.say("user_feedback", text, images) + } + operationsToApprove.forEach((opResult) => { + updateOperationResult(opResult.path, { status: "approved" }) + }) + } else if (response === "noButtonClicked") { + // Deny all files + if (text) { + await cline.say("user_feedback", text, images) + } + cline.didRejectTool = true + operationsToApprove.forEach((opResult) => { + updateOperationResult(opResult.path, { + status: "denied", + result: `Changes to ${opResult.path} were not approved by user`, + }) + }) + } else { + // Handle individual permissions from objectResponse + try { + const parsedResponse = JSON.parse(text || "{}") + // Check if this is our batch diff approval response + if (parsedResponse.action === "applyDiff" && parsedResponse.approvedFiles) { + const approvedFiles = parsedResponse.approvedFiles + let hasAnyDenial = false + + operationsToApprove.forEach((opResult) => { + const approved = approvedFiles[opResult.path] === true + + if (approved) { + updateOperationResult(opResult.path, { status: "approved" }) + } else { + hasAnyDenial = true + updateOperationResult(opResult.path, { + status: "denied", + result: `Changes to ${opResult.path} were not approved by user`, + }) + } + }) + + if (hasAnyDenial) { + cline.didRejectTool = true + } + } else { + // Legacy individual permissions format + const individualPermissions = parsedResponse + let hasAnyDenial = false + + batchDiffs.forEach((batchDiff, index) => { + const opResult = operationsToApprove[index] + const approved = individualPermissions[batchDiff.key] === true + + if (approved) { + updateOperationResult(opResult.path, { status: "approved" }) + } else { + hasAnyDenial = true + updateOperationResult(opResult.path, { + status: "denied", + result: `Changes to ${opResult.path} were not approved by user`, + }) + } + }) + + if (hasAnyDenial) { + cline.didRejectTool = true + } } - } else { - const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : "" - - formattedError = `Unable to apply diff to file: ${absolutePath}\n\n\n${ - diffResult.error - }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` + } catch (error) { + // Fallback: if JSON parsing fails, deny all files + console.error("Failed to parse individual permissions:", error) + cline.didRejectTool = true + operationsToApprove.forEach((opResult) => { + updateOperationResult(opResult.path, { + status: "denied", + result: `Changes to ${opResult.path} were not approved by user`, + }) + }) } + } + } else if (operationsToApprove.length === 1) { + // Single file approval - process immediately + const opResult = operationsToApprove[0] + updateOperationResult(opResult.path, { status: "approved" }) + } + + // Process approved operations + const results: string[] = [] - if (currentCount >= 2) { - await cline.say("diff_error", formattedError) + for (const opResult of operationResults) { + // Skip operations that weren't approved or were blocked + if (opResult.status !== "approved") { + if (opResult.result) { + results.push(opResult.result) + } else if (opResult.error) { + results.push(opResult.error) } + continue + } - cline.recordToolError("apply_diff", formattedError) + const relPath = opResult.path + const diffItems = opResult.diffItems || [] + const absolutePath = opResult.absolutePath! + const fileExists = opResult.fileExists! - pushToolResult(formattedError) - return - } + try { + let originalContent: string | null = await fs.readFile(absolutePath, "utf-8") + let successCount = 0 + let formattedError = "" - cline.consecutiveMistakeCount = 0 - cline.consecutiveMistakeCountForApplyDiff.delete(relPath) + // Pre-process all diff items for HTML entity unescaping if needed + const processedDiffItems = !cline.api.getModel().id.includes("claude") + ? diffItems.map((item) => ({ + ...item, + content: item.content ? unescapeHtmlEntities(item.content) : item.content, + })) + : diffItems + + // Apply all diffs at once with the array-based method + const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, processedDiffItems)) ?? { + success: false, + error: "No diff strategy available - please ensure a valid diff strategy is configured", + } - // Show diff view before asking for approval - cline.diffViewProvider.editType = "modify" - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(diffResult.content, true) - await cline.diffViewProvider.scrollToFirstDiff() + // Release the original content from memory as it's no longer needed + originalContent = null + + if (!diffResult.success) { + cline.consecutiveMistakeCount++ + const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 + cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) + + TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount) + + if (diffResult.failParts && diffResult.failParts.length > 0) { + for (let i = 0; i < diffResult.failParts.length; i++) { + const failPart = diffResult.failParts[i] + if (failPart.success) { + continue + } + + const errorDetails = failPart.details ? JSON.stringify(failPart.details, null, 2) : "" + formattedError += ` +Diff ${i + 1} failed for file: ${relPath} +Error: ${failPart.error} + +Suggested fixes: +1. Verify the search content exactly matches the file content (including whitespace) +2. Check for correct indentation and line endings +3. Use to see the current file content +4. Consider breaking complex changes into smaller diffs +5. Ensure start_line parameter matches the actual content location +${errorDetails ? `\nDetailed error information:\n${errorDetails}\n` : ""} +\n\n` + } + } else { + const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : "" + formattedError += ` +Unable to apply diffs to file: ${absolutePath} +Error: ${diffResult.error} + +Recovery suggestions: +1. Use to examine the current file content +2. Verify the diff format matches the expected search/replace pattern +3. Check that the search content exactly matches what's in the file +4. Consider using line numbers with start_line parameter +5. Break large changes into smaller, more specific diffs +${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} +\n\n` + } + } else { + // Get the content from the result and update success count + originalContent = diffResult.content || originalContent + successCount = diffItems.length - (diffResult.failParts?.length || 0) + } - const completeMessage = JSON.stringify({ - ...sharedMessageProps, - diff: diffContent, - } satisfies ClineSayTool) + // If no diffs were successfully applied, continue to next file + if (successCount === 0) { + if (formattedError) { + const currentCount = cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0 + if (currentCount >= 2) { + await cline.say("diff_error", formattedError) + } + cline.recordToolError("apply_diff", formattedError) + results.push(formattedError) + } + continue + } - let toolProgressStatus + cline.consecutiveMistakeCount = 0 + cline.consecutiveMistakeCountForApplyDiff.delete(relPath) - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block, diffResult) - } + // Show diff view before asking for approval (only for single file or after batch approval) + cline.diffViewProvider.editType = "modify" + await cline.diffViewProvider.open(relPath) + await cline.diffViewProvider.update(originalContent!, true) + await cline.diffViewProvider.scrollToFirstDiff() + + // For batch operations, we've already gotten approval + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(cline.cwd, relPath), + } - const didApprove = await askApproval("tool", completeMessage, toolProgressStatus) + // If single file, ask for approval + let didApprove = true + if (operationsToApprove.length === 1) { + const diffContents = diffItems.map((item) => item.content).join("\n\n") + const operationMessage = JSON.stringify({ + ...sharedMessageProps, + diff: diffContents, + } satisfies ClineSayTool) + + let toolProgressStatus + + if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { + toolProgressStatus = cline.diffStrategy.getProgressStatus( + { + ...block, + params: { ...block.params, diff: diffContents }, + }, + { success: true }, + ) + } - if (!didApprove) { - await cline.diffViewProvider.revertChanges() // Cline likely handles closing the diff view - return - } + didApprove = await askApproval("tool", operationMessage, toolProgressStatus) + } + + if (!didApprove) { + await cline.diffViewProvider.revertChanges() + results.push(`Changes to ${relPath} were not approved by user`) + continue + } - // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges() + // Call saveChanges to update the DiffViewProvider properties + await cline.diffViewProvider.saveChanges() - // Track file edit operation - if (relPath) { + // Track file edit operation await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) - } - // Used to determine if we should wait for busy terminal to update before sending api request - cline.didEditFile = true - let partFailHint = "" + // Used to determine if we should wait for busy terminal to update before sending api request + cline.didEditFile = true + let partFailHint = "" - if (diffResult.failParts && diffResult.failParts.length > 0) { - partFailHint = `But unable to apply all diff parts to file: ${absolutePath}, silently use tool to check newest file version and re-apply diffs\n` - } + if (successCount < diffItems.length) { + partFailHint = `Unable to apply all diff parts to file: ${absolutePath}` + } - // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + // Get the formatted response message + const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) - if (partFailHint) { - pushToolResult(partFailHint + message) - } else { - pushToolResult(message) - } + if (partFailHint) { + results.push(partFailHint + "\n" + message) + } else { + results.push(message) + } - await cline.diffViewProvider.reset() + await cline.diffViewProvider.reset() + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + updateOperationResult(relPath, { + status: "error", + error: `Error processing ${relPath}: ${errorMsg}`, + }) + results.push(`Error processing ${relPath}: ${errorMsg}`) + } + } - return + // Add filtered operation errors to results + if (filteredOperationErrors.length > 0) { + results.push(...filteredOperationErrors) } + + // Push the final result combining all operation results + pushToolResult(results.join("\n\n")) + return } catch (error) { await handleError("applying diff", error) await cline.diffViewProvider.reset() diff --git a/src/core/tools/applyDiffToolLegacy.ts b/src/core/tools/applyDiffToolLegacy.ts new file mode 100644 index 0000000000..0915631e41 --- /dev/null +++ b/src/core/tools/applyDiffToolLegacy.ts @@ -0,0 +1,198 @@ +import path from "path" +import fs from "fs/promises" + +import { TelemetryService } from "@roo-code/telemetry" + +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { getReadablePath } from "../../utils/path" +import { Task } from "../task/Task" +import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { fileExistsAtPath } from "../../utils/fs" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { unescapeHtmlEntities } from "../../utils/text-normalization" + +export async function applyDiffToolLegacy( + cline: Task, + block: ToolUse, + askApproval: AskApproval, + handleError: HandleError, + pushToolResult: PushToolResult, + removeClosingTag: RemoveClosingTag, +) { + const relPath: string | undefined = block.params.path + let diffContent: string | undefined = block.params.diff + + if (diffContent && !cline.api.getModel().id.includes("claude")) { + diffContent = unescapeHtmlEntities(diffContent) + } + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), + diff: diffContent, + } + + try { + if (block.partial) { + // Update GUI message + let toolProgressStatus + + if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { + toolProgressStatus = cline.diffStrategy.getProgressStatus(block) + } + + if (toolProgressStatus && Object.keys(toolProgressStatus).length === 0) { + return + } + + await cline + .ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus) + .catch(() => {}) + + return + } else { + if (!relPath) { + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "path")) + return + } + + if (!diffContent) { + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "diff")) + return + } + + const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) + + if (!accessAllowed) { + await cline.say("rooignore_error", relPath) + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) + return + } + + const absolutePath = path.resolve(cline.cwd, relPath) + const fileExists = await fileExistsAtPath(absolutePath) + + if (!fileExists) { + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` + await cline.say("error", formattedError) + pushToolResult(formattedError) + return + } + + let originalContent: string | null = await fs.readFile(absolutePath, "utf-8") + + // Apply the diff to the original content + const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, diffContent)) ?? { + success: false, + error: "No diff strategy available", + } + + // Release the original content from memory as it's no longer needed + originalContent = null + + if (!diffResult.success) { + cline.consecutiveMistakeCount++ + const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 + cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) + let formattedError = "" + TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount) + + if (diffResult.failParts && diffResult.failParts.length > 0) { + for (const failPart of diffResult.failParts) { + if (failPart.success) { + continue + } + + const errorDetails = failPart.details ? JSON.stringify(failPart.details, null, 2) : "" + + formattedError = `\n${ + failPart.error + }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` + } + } else { + const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : "" + + formattedError = `Unable to apply diff to file: ${absolutePath}\n\n\n${ + diffResult.error + }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` + } + + if (currentCount >= 2) { + await cline.say("diff_error", formattedError) + } + + cline.recordToolError("apply_diff", formattedError) + + pushToolResult(formattedError) + return + } + + cline.consecutiveMistakeCount = 0 + cline.consecutiveMistakeCountForApplyDiff.delete(relPath) + + // Show diff view before asking for approval + cline.diffViewProvider.editType = "modify" + await cline.diffViewProvider.open(relPath) + await cline.diffViewProvider.update(diffResult.content, true) + await cline.diffViewProvider.scrollToFirstDiff() + + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + diff: diffContent, + } satisfies ClineSayTool) + + let toolProgressStatus + + if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { + toolProgressStatus = cline.diffStrategy.getProgressStatus(block, diffResult) + } + + const didApprove = await askApproval("tool", completeMessage, toolProgressStatus) + + if (!didApprove) { + await cline.diffViewProvider.revertChanges() // Cline likely handles closing the diff view + return + } + + // Call saveChanges to update the DiffViewProvider properties + await cline.diffViewProvider.saveChanges() + + // Track file edit operation + if (relPath) { + await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + } + + // Used to determine if we should wait for busy terminal to update before sending api request + cline.didEditFile = true + let partFailHint = "" + + if (diffResult.failParts && diffResult.failParts.length > 0) { + partFailHint = `But unable to apply all diff parts to file: ${absolutePath}, silently use tool to check newest file version and re-apply diffs\n` + } + + // Get the formatted response message + const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + + if (partFailHint) { + pushToolResult(partFailHint + message) + } else { + pushToolResult(message) + } + + await cline.diffViewProvider.reset() + + return + } + } catch (error) { + await handleError("applying diff", error) + await cline.diffViewProvider.reset() + return + } +} diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 6ced4989a4..31710ef732 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -19,6 +19,8 @@ import { ClineProvider } from "../ClineProvider" // Mock setup must come before imports jest.mock("../../prompts/sections/custom-instructions") +// Don't mock generateSystemPrompt - let it call through to test the actual implementation + jest.mock("vscode") jest.mock("delay") diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index ecb326b1e4..2c88b98d2e 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -1,9 +1,11 @@ import { WebviewMessage } from "../../shared/WebviewMessage" import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes" import { buildApiHandler } from "../../api" +import { experiments as experimentsModule, EXPERIMENT_IDS } from "../../shared/experiments" import { SYSTEM_PROMPT } from "../prompts/system" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" +import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" import { ClineProvider } from "./ClineProvider" @@ -24,7 +26,15 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web maxConcurrentFileReads, } = await provider.getState() - const diffStrategy = new MultiSearchReplaceDiffStrategy(fuzzyMatchThreshold) + // Check experiment to determine which diff strategy to use + const isMultiFileApplyDiffEnabled = experimentsModule.isEnabled( + experiments ?? {}, + EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, + ) + + const diffStrategy = isMultiFileApplyDiffEnabled + ? new MultiFileSearchReplaceDiffStrategy(fuzzyMatchThreshold) + : new MultiSearchReplaceDiffStrategy(fuzzyMatchThreshold) const cwd = provider.cwd diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 739815be5a..9138766190 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -275,6 +275,17 @@ export interface ClineSayTool { lineSnippet: string isOutsideWorkspace?: boolean key: string + content?: string + }> + batchDiffs?: Array<{ + path: string + changeCount: number + key: string + content: string + diffs?: Array<{ + content: string + startLine?: number + }> }> question?: string } diff --git a/src/shared/__tests__/experiments.test.ts b/src/shared/__tests__/experiments.test.ts index 60a2f5e361..386677e534 100644 --- a/src/shared/__tests__/experiments.test.ts +++ b/src/shared/__tests__/experiments.test.ts @@ -14,6 +14,15 @@ describe("experiments", () => { }) }) + describe("MULTI_FILE_APPLY_DIFF", () => { + it("is configured correctly", () => { + expect(EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF).toBe("multiFileApplyDiff") + expect(experimentConfigsMap.MULTI_FILE_APPLY_DIFF).toMatchObject({ + enabled: false, + }) + }) + }) + describe("isEnabled", () => { it("returns false when POWER_STEERING experiment is not enabled", () => { const experiments: Record = { @@ -21,6 +30,7 @@ describe("experiments", () => { marketplace: false, concurrentFileReads: false, disableCompletionCommand: false, + multiFileApplyDiff: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) @@ -31,6 +41,7 @@ describe("experiments", () => { marketplace: false, concurrentFileReads: false, disableCompletionCommand: false, + multiFileApplyDiff: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true) }) @@ -41,6 +52,7 @@ describe("experiments", () => { marketplace: false, concurrentFileReads: false, disableCompletionCommand: false, + multiFileApplyDiff: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) @@ -51,6 +63,7 @@ describe("experiments", () => { marketplace: false, concurrentFileReads: false, disableCompletionCommand: false, + multiFileApplyDiff: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.CONCURRENT_FILE_READS)).toBe(false) }) @@ -61,6 +74,7 @@ describe("experiments", () => { marketplace: false, concurrentFileReads: true, disableCompletionCommand: false, + multiFileApplyDiff: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.CONCURRENT_FILE_READS)).toBe(true) }) @@ -81,6 +95,7 @@ describe("experiments", () => { marketplace: false, concurrentFileReads: false, disableCompletionCommand: false, + multiFileApplyDiff: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.MARKETPLACE)).toBe(false) }) @@ -91,6 +106,7 @@ describe("experiments", () => { marketplace: true, concurrentFileReads: false, disableCompletionCommand: false, + multiFileApplyDiff: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.MARKETPLACE)).toBe(true) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index bca295f498..7fe0239208 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -5,6 +5,7 @@ export const EXPERIMENT_IDS = { CONCURRENT_FILE_READS: "concurrentFileReads", DISABLE_COMPLETION_COMMAND: "disableCompletionCommand", POWER_STEERING: "powerSteering", + MULTI_FILE_APPLY_DIFF: "multiFileApplyDiff", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -20,6 +21,7 @@ export const experimentConfigsMap: Record = { CONCURRENT_FILE_READS: { enabled: false }, DISABLE_COMPLETION_COMMAND: { enabled: false }, POWER_STEERING: { enabled: false }, + MULTI_FILE_APPLY_DIFF: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 85a0cb318c..ffaf41f93f 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -62,6 +62,7 @@ export const toolParamNames = [ "start_line", "end_line", "query", + "args", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -241,6 +242,11 @@ export type DiffResult = failParts?: DiffResult[] } & ({ error: string } | { failParts: DiffResult[] })) +export interface DiffItem { + content: string + startLine?: number +} + export interface DiffStrategy { /** * Get the name of this diff strategy for analytics and debugging @@ -258,12 +264,17 @@ export interface DiffStrategy { /** * Apply a diff to the original content * @param originalContent The original file content - * @param diffContent The diff content in the strategy's format + * @param diffContent The diff content in the strategy's format (string for legacy, DiffItem[] for new) * @param startLine Optional line number where the search block starts. If not provided, searches the entire file. * @param endLine Optional line number where the search block ends. If not provided, searches the entire file. * @returns A DiffResult object containing either the successful result or error details */ - applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): Promise + applyDiff( + originalContent: string, + diffContent: string | DiffItem[], + startLine?: number, + endLine?: number, + ): Promise getProgressStatus?(toolUse: ToolUse, result?: any): ToolProgressStatus } diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index fd0bce1cf3..8a0e1b2d59 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -15,3 +15,465 @@ export function allowNetConnect(host?: string | RegExp) { // Global mocks that many tests expect. global.structuredClone = global.structuredClone || ((obj: any) => JSON.parse(JSON.stringify(obj))) +import { vi } from "vitest" + +// Mock vscode module before any imports +vi.mock("vscode", () => { + // Initialize ThemeIcon class first + class ThemeIcon { + static File: any + static Folder: any + constructor( + public id: string, + public color?: any, + ) {} + } + + const vscode: any = { + env: { + appRoot: "/mock/app/root", + appName: "Mock VS Code", + uriScheme: "vscode", + language: "en", + clipboard: { + readText: vi.fn(), + writeText: vi.fn(), + }, + openExternal: vi.fn(), + asExternalUri: vi.fn(), + uiKind: 1, + }, + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + createTextEditorDecorationType: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + showTextDocument: vi.fn(), + createOutputChannel: vi.fn().mockReturnValue({ + appendLine: vi.fn(), + show: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + }), + createWebviewPanel: vi.fn(), + showWarningMessage: vi.fn(), + showQuickPick: vi.fn(), + showInputBox: vi.fn(), + withProgress: vi.fn(), + createStatusBarItem: vi.fn().mockReturnValue({ + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + }), + activeTextEditor: undefined, + visibleTextEditors: [], + onDidChangeActiveTextEditor: vi.fn(), + onDidChangeVisibleTextEditors: vi.fn(), + onDidChangeTextEditorSelection: vi.fn(), + onDidChangeTextEditorVisibleRanges: vi.fn(), + onDidChangeTextEditorOptions: vi.fn(), + onDidChangeTextEditorViewColumn: vi.fn(), + onDidCloseTerminal: vi.fn(), + state: { + focused: true, + }, + onDidChangeWindowState: vi.fn(), + terminals: [], + onDidOpenTerminal: vi.fn(), + onDidChangeActiveTerminal: vi.fn(), + onDidChangeTerminalState: vi.fn(), + activeTerminal: undefined, + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn(), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn(), + }), + workspaceFolders: [], + onDidChangeConfiguration: vi.fn(), + onDidChangeWorkspaceFolders: vi.fn(), + fs: { + readFile: vi.fn(), + writeFile: vi.fn(), + delete: vi.fn(), + createDirectory: vi.fn(), + readDirectory: vi.fn(), + stat: vi.fn(), + rename: vi.fn(), + copy: vi.fn(), + }, + openTextDocument: vi.fn(), + onDidOpenTextDocument: vi.fn(), + onDidCloseTextDocument: vi.fn(), + onDidChangeTextDocument: vi.fn(), + onDidSaveTextDocument: vi.fn(), + onWillSaveTextDocument: vi.fn(), + textDocuments: [], + createFileSystemWatcher: vi.fn().mockReturnValue({ + onDidChange: vi.fn(), + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + }), + findFiles: vi.fn(), + saveAll: vi.fn(), + applyEdit: vi.fn(), + registerTextDocumentContentProvider: vi.fn(), + registerTaskProvider: vi.fn(), + registerFileSystemProvider: vi.fn(), + rootPath: undefined, + name: undefined, + onDidGrantWorkspaceTrust: vi.fn(), + requestWorkspaceTrust: vi.fn(), + onDidChangeWorkspaceTrust: vi.fn(), + isTrusted: true, + trustOptions: undefined, + workspaceFile: undefined, + getWorkspaceFolder: vi.fn(), + asRelativePath: vi.fn(), + updateWorkspaceFolders: vi.fn(), + openNotebookDocument: vi.fn(), + registerNotebookContentProvider: vi.fn(), + registerFileSearchProvider: vi.fn(), + registerTextSearchProvider: vi.fn(), + onDidCreateFiles: vi.fn(), + onDidDeleteFiles: vi.fn(), + onDidRenameFiles: vi.fn(), + onWillCreateFiles: vi.fn(), + onWillDeleteFiles: vi.fn(), + onWillRenameFiles: vi.fn(), + notebookDocuments: [], + onDidOpenNotebookDocument: vi.fn(), + onDidCloseNotebookDocument: vi.fn(), + onDidChangeNotebookDocument: vi.fn(), + onWillSaveNotebookDocument: vi.fn(), + onDidSaveNotebookDocument: vi.fn(), + onDidChangeNotebookCellExecutionState: vi.fn(), + registerNotebookCellStatusBarItemProvider: vi.fn(), + }, + Uri: { + parse: vi.fn((str) => ({ fsPath: str, toString: () => str })), + file: vi.fn((path) => ({ fsPath: path, toString: () => path })), + joinPath: vi.fn(), + }, + Position: class { + constructor( + public line: number, + public character: number, + ) {} + }, + Range: class { + constructor( + public start: { line: number; character: number } | number, + public end?: { line: number; character: number } | number, + public endLine?: number, + public endCharacter?: number, + ) { + if (typeof start === "number") { + // Handle constructor(startLine, startCharacter, endLine, endCharacter) + this.start = { line: start, character: end as number } + this.end = { line: endLine!, character: endCharacter! } + } + } + }, + Location: class { + constructor( + public uri: any, + public range: any, + ) {} + }, + Selection: class { + constructor( + public anchor: { line: number; character: number }, + public active: { line: number; character: number }, + ) {} + }, + TextEdit: { + insert: vi.fn(), + delete: vi.fn(), + replace: vi.fn(), + setEndOfLine: vi.fn(), + }, + WorkspaceEdit: class { + set = vi.fn() + replace = vi.fn() + insert = vi.fn() + delete = vi.fn() + has = vi.fn() + entries = vi.fn() + renameFile = vi.fn() + createFile = vi.fn() + deleteFile = vi.fn() + }, + commands: { + executeCommand: vi.fn(), + registerCommand: vi.fn(), + registerTextEditorCommand: vi.fn(), + getCommands: vi.fn(), + }, + languages: { + registerCompletionItemProvider: vi.fn(), + registerCodeActionsProvider: vi.fn(), + registerCodeLensProvider: vi.fn(), + registerDefinitionProvider: vi.fn(), + registerImplementationProvider: vi.fn(), + registerTypeDefinitionProvider: vi.fn(), + registerHoverProvider: vi.fn(), + registerDocumentHighlightProvider: vi.fn(), + registerReferenceProvider: vi.fn(), + registerRenameProvider: vi.fn(), + registerDocumentSymbolProvider: vi.fn(), + registerDocumentFormattingEditProvider: vi.fn(), + registerDocumentRangeFormattingEditProvider: vi.fn(), + registerOnTypeFormattingEditProvider: vi.fn(), + registerSignatureHelpProvider: vi.fn(), + registerDocumentLinkProvider: vi.fn(), + registerColorProvider: vi.fn(), + registerFoldingRangeProvider: vi.fn(), + registerDeclarationProvider: vi.fn(), + registerSelectionRangeProvider: vi.fn(), + registerCallHierarchyProvider: vi.fn(), + registerLinkedEditingRangeProvider: vi.fn(), + registerInlayHintsProvider: vi.fn(), + registerDocumentSemanticTokensProvider: vi.fn(), + registerDocumentRangeSemanticTokensProvider: vi.fn(), + registerEvaluatableExpressionProvider: vi.fn(), + registerInlineValuesProvider: vi.fn(), + registerWorkspaceSymbolProvider: vi.fn(), + registerDocumentDropEditProvider: vi.fn(), + registerDocumentPasteEditProvider: vi.fn(), + setLanguageConfiguration: vi.fn(), + onDidChangeDiagnostics: vi.fn(), + getDiagnostics: vi.fn(), + createDiagnosticCollection: vi.fn(), + getLanguages: vi.fn(), + setTextDocumentLanguage: vi.fn(), + match: vi.fn(), + onDidEncounterLanguage: vi.fn(), + registerInlineCompletionItemProvider: vi.fn(), + }, + extensions: { + getExtension: vi.fn(), + onDidChange: vi.fn(), + all: [], + }, + EventEmitter: class { + event = vi.fn() + fire = vi.fn() + dispose = vi.fn() + }, + CancellationTokenSource: class { + token = { isCancellationRequested: false, onCancellationRequested: vi.fn() } + cancel = vi.fn() + dispose = vi.fn() + }, + Disposable: class { + static from = vi.fn() + constructor(public dispose: () => void) {} + }, + StatusBarAlignment: { + Left: 1, + Right: 2, + }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, + RelativePattern: class { + constructor( + public base: string, + public pattern: string, + ) {} + }, + ProgressLocation: { + SourceControl: 1, + Window: 10, + Notification: 15, + }, + ViewColumn: { + Active: -1, + Beside: -2, + One: 1, + Two: 2, + Three: 3, + Four: 4, + Five: 5, + Six: 6, + Seven: 7, + Eight: 8, + Nine: 9, + }, + TextDocumentSaveReason: { + Manual: 1, + AfterDelay: 2, + FocusOut: 3, + }, + TextEditorRevealType: { + Default: 0, + InCenter: 1, + InCenterIfOutsideViewport: 2, + AtTop: 3, + }, + OverviewRulerLane: { + Left: 1, + Center: 2, + Right: 4, + Full: 7, + }, + DecorationRangeBehavior: { + OpenOpen: 0, + ClosedClosed: 1, + OpenClosed: 2, + ClosedOpen: 3, + }, + MarkdownString: class { + constructor( + public value: string, + public supportThemeIcons?: boolean, + ) {} + isTrusted = false + supportHtml = false + baseUri: any + appendText = vi.fn() + appendMarkdown = vi.fn() + appendCodeblock = vi.fn() + }, + ThemeColor: class { + constructor(public id: string) {} + }, + ThemeIcon: ThemeIcon, + TreeItem: class { + constructor( + public label: string, + public collapsibleState?: number, + ) {} + id?: string + iconPath?: any + description?: string + contextValue?: string + command?: any + tooltip?: string | any + accessibilityInformation?: any + checkboxState?: any + }, + TreeItemCollapsibleState: { + None: 0, + Collapsed: 1, + Expanded: 2, + }, + ExtensionKind: { + UI: 1, + Workspace: 2, + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + EnvironmentVariableMutatorType: { + Replace: 1, + Append: 2, + Prepend: 3, + }, + UIKind: { + Desktop: 1, + Web: 2, + }, + FileType: { + Unknown: 0, + File: 1, + Directory: 2, + SymbolicLink: 64, + }, + FilePermission: { + Readonly: 1, + }, + FileChangeType: { + Changed: 1, + Created: 2, + Deleted: 3, + }, + GlobPattern: class {}, + } + + // Set static properties after vscode is defined + ThemeIcon.File = new ThemeIcon("file") + ThemeIcon.Folder = new ThemeIcon("folder") + + return vscode +}) + +// Mock other modules that might be needed +vi.mock("../utils/logging", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }), + }, +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + track: vi.fn(), + trackEvent: vi.fn(), + trackError: vi.fn(), + trackPerformance: vi.fn(), + flush: vi.fn(), + dispose: vi.fn(), + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + captureTaskCompleted: vi.fn(), + captureTaskCancelled: vi.fn(), + captureTaskFailed: vi.fn(), + captureToolUse: vi.fn(), + captureApiRequest: vi.fn(), + captureApiResponse: vi.fn(), + captureApiError: vi.fn(), + }, + initialize: vi.fn(), + }, + BaseTelemetryClient: class { + track = vi.fn() + trackEvent = vi.fn() + trackError = vi.fn() + trackPerformance = vi.fn() + flush = vi.fn() + dispose = vi.fn() + }, +})) + +// Add toPosix method to String prototype +declare global { + interface String { + toPosix(): string + } +} + +function toPosixPath(p: string) { + const isExtendedLengthPath = p.startsWith("\\\\?\\") + if (isExtendedLengthPath) { + return p + } + return p.replace(/\\/g, "/") +} + +if (!String.prototype.toPosix) { + String.prototype.toPosix = function (this: string): string { + return toPosixPath(this) + } +} diff --git a/webview-ui/src/components/chat/BatchDiffApproval.tsx b/webview-ui/src/components/chat/BatchDiffApproval.tsx new file mode 100644 index 0000000000..03f06d4106 --- /dev/null +++ b/webview-ui/src/components/chat/BatchDiffApproval.tsx @@ -0,0 +1,56 @@ +import React, { memo, useState } from "react" +import CodeAccordian from "../common/CodeAccordian" + +interface FileDiff { + path: string + changeCount: number + key: string + content: string + diffs?: Array<{ + content: string + startLine?: number + }> +} + +interface BatchDiffApprovalProps { + files: FileDiff[] + ts: number +} + +export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProps) => { + const [expandedFiles, setExpandedFiles] = useState>({}) + + if (!files?.length) { + return null + } + + const handleToggleExpand = (filePath: string) => { + setExpandedFiles((prev) => ({ + ...prev, + [filePath]: !prev[filePath], + })) + } + + return ( +
+ {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 + + return ( +
+ handleToggleExpand(file.path)} + /> +
+ ) + })} +
+ ) +}) + +BatchDiffApproval.displayName = "BatchDiffApproval" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index cc351343b5..ac36a74340 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -31,6 +31,7 @@ import { Mention } from "./Mention" import { CheckpointSaved } from "./checkpoints/CheckpointSaved" import { FollowUpSuggest } from "./FollowUpSuggest" import { BatchFilePermission } from "./BatchFilePermission" +import { BatchDiffApproval } from "./BatchDiffApproval" import { ProgressIndicator } from "./ProgressIndicator" import { Markdown } from "./Markdown" import { CommandExecution } from "./CommandExecution" @@ -293,6 +294,22 @@ export const ChatRowContent = ({ switch (tool.tool) { case "editedExistingFile": case "appliedDiff": + // Check if this is a batch diff request + if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) { + return ( + <> +
+ {toolIcon("diff")} + + {t("chat:fileOperations.wantsToApplyBatchChanges")} + +
+ + + ) + } + + // Regular single file diff return ( <>
diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index d8bc6691d5..4e2546eb38 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -72,6 +72,18 @@ export const ExperimentalSettings = ({ /> ) } + if (config[0] === "MULTI_FILE_APPLY_DIFF") { + return ( + + setExperimentEnabled(EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, enabled) + } + /> + ) + } return ( { marketplace: false, concurrentFileReads: true, disableCompletionCommand: false, + multiFileApplyDiff: true, } as Record, } @@ -240,6 +241,7 @@ describe("mergeExtensionState", () => { marketplace: false, concurrentFileReads: true, disableCompletionCommand: false, + multiFileApplyDiff: true, }) }) }) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index a589109e4f..b3cf1e41b6 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo vol inserir contingut a la línia {{lineNumber}} d'aquest fitxer:", "wantsToInsertAtEnd": "Roo vol afegir contingut al final d'aquest fitxer:", "wantsToReadAndXMore": "En Roo vol llegir aquest fitxer i {{count}} més:", - "wantsToReadMultiple": "Roo vol llegir diversos fitxers:" + "wantsToReadMultiple": "Roo vol llegir diversos fitxers:", + "wantsToApplyBatchChanges": "Roo vol aplicar canvis a múltiples fitxers:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo vol veure els fitxers de nivell superior en aquest directori:", diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index c5a03cdc7f..51d0e9589c 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Desactivar l'execució de comandes a attempt_completion", "description": "Quan està activat, l'eina attempt_completion no executarà comandes. Aquesta és una característica experimental per preparar la futura eliminació de l'execució de comandes en la finalització de tasques." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Habilita operacions de diff multi-rang", + "description": "Permet a Roo aplicar canvis a múltiples fitxers i múltiples rangs dins dels fitxers en una sola petició. Aquesta funció avançada pot aclaparar models menys capaços i s'ha d'usar amb precaució." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index aac9231bf9..5b1edb2939 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -152,7 +152,8 @@ "wantsToInsert": "Roo möchte Inhalte in diese Datei einfügen:", "wantsToInsertWithLineNumber": "Roo möchte Inhalte in diese Datei in Zeile {{lineNumber}} einfügen:", "wantsToInsertAtEnd": "Roo möchte Inhalte am Ende dieser Datei anhängen:", - "wantsToReadMultiple": "Roo möchte mehrere Dateien lesen:" + "wantsToReadMultiple": "Roo möchte mehrere Dateien lesen:", + "wantsToApplyBatchChanges": "Roo möchte Änderungen an mehreren Dateien vornehmen:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo möchte die Dateien auf oberster Ebene in diesem Verzeichnis anzeigen:", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 47b77cd888..56203c920a 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Befehlsausführung in attempt_completion deaktivieren", "description": "Wenn aktiviert, führt das Tool attempt_completion keine Befehle aus. Dies ist eine experimentelle Funktion, um die Abschaffung der Befehlsausführung bei Aufgabenabschluss vorzubereiten." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Multi-Range-Diff-Operationen aktivieren", + "description": "Erlaubt Roo, Änderungen an mehreren Dateien und mehreren Bereichen innerhalb von Dateien in einer einzigen Anfrage anzuwenden. Diese erweiterte Funktion kann weniger fähige Modelle überlasten und sollte mit Vorsicht verwendet werden." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 156ca523ee..866f589a8a 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -156,6 +156,7 @@ "didRead": "Roo read this file:", "wantsToEdit": "Roo wants to edit this file:", "wantsToEditOutsideWorkspace": "Roo wants to edit this file outside of the workspace:", + "wantsToApplyBatchChanges": "Roo wants to apply changes to multiple files:", "wantsToCreate": "Roo wants to create a new file:", "wantsToSearchReplace": "Roo wants to search and replace in this file:", "didSearchReplace": "Roo performed search and replace on this file:", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 37a22bdca5..2dbf163110 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Disable command execution in attempt_completion", "description": "When enabled, the attempt_completion tool will not execute commands. This is an experimental feature to prepare for deprecating command execution in task completion." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Enable multi-range diff operations", + "description": "Allow Roo to apply changes to multiple files and multiple ranges within files in a single request. This advanced feature may overwhelm less capable models and should be used with caution." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 39cf064cf2..af3c3ab24e 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo quiere insertar contenido en este archivo en la línea {{lineNumber}}:", "wantsToInsertAtEnd": "Roo quiere añadir contenido al final de este archivo:", "wantsToReadAndXMore": "Roo quiere leer este archivo y {{count}} más:", - "wantsToReadMultiple": "Roo quiere leer varios archivos:" + "wantsToReadMultiple": "Roo quiere leer varios archivos:", + "wantsToApplyBatchChanges": "Roo quiere aplicar cambios a múltiples archivos:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo quiere ver los archivos de nivel superior en este directorio:", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 3c9d423b8f..c778159f9f 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Desactivar la ejecución de comandos en attempt_completion", "description": "Cuando está activado, la herramienta attempt_completion no ejecutará comandos. Esta es una función experimental para preparar la futura eliminación de la ejecución de comandos en la finalización de tareas." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Habilitar operaciones de diff multi-rango", + "description": "Permite a Roo aplicar cambios a múltiples archivos y múltiples rangos dentro de archivos en una sola solicitud. Esta función avanzada puede abrumar a modelos menos capaces y debe usarse con precaución." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 62d018c5cd..3b305b4397 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -149,7 +149,8 @@ "wantsToInsertWithLineNumber": "Roo veut insérer du contenu dans ce fichier à la ligne {{lineNumber}} :", "wantsToInsertAtEnd": "Roo veut ajouter du contenu à la fin de ce fichier :", "wantsToReadAndXMore": "Roo veut lire ce fichier et {{count}} de plus :", - "wantsToReadMultiple": "Roo souhaite lire plusieurs fichiers :" + "wantsToReadMultiple": "Roo souhaite lire plusieurs fichiers :", + "wantsToApplyBatchChanges": "Roo veut appliquer des modifications à plusieurs fichiers :" }, "instructions": { "wantsToFetch": "Roo veut récupérer des instructions détaillées pour aider à la tâche actuelle" diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 01d8980986..8573474111 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Désactiver l'exécution des commandes dans attempt_completion", "description": "Lorsque cette option est activée, l'outil attempt_completion n'exécutera pas de commandes. Il s'agit d'une fonctionnalité expérimentale visant à préparer la dépréciation de l'exécution des commandes lors de la finalisation des tâches." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Activer les opérations de diff multi-plages", + "description": "Permet à Roo d'appliquer des modifications à plusieurs fichiers et à plusieurs plages dans les fichiers en une seule requête. Cette fonctionnalité avancée peut submerger les modèles moins capables et doit être utilisée avec prudence." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 9c10c57c0d..075980435f 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo इस फ़ाइल की {{lineNumber}} लाइन पर सामग्री डालना चाहता है:", "wantsToInsertAtEnd": "Roo इस फ़ाइल के अंत में सामग्री जोड़ना चाहता है:", "wantsToReadAndXMore": "रू इस फ़ाइल को और {{count}} अन्य को पढ़ना चाहता है:", - "wantsToReadMultiple": "Roo कई फ़ाइलें पढ़ना चाहता है:" + "wantsToReadMultiple": "Roo कई फ़ाइलें पढ़ना चाहता है:", + "wantsToApplyBatchChanges": "Roo कई फ़ाइलों में परिवर्तन लागू करना चाहता है:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo इस निर्देशिका में शीर्ष स्तर की फ़ाइलें देखना चाहता है:", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index c0fd1e9efb..8739469270 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "attempt_completion में कमांड निष्पादन अक्षम करें", "description": "जब सक्षम किया जाता है, तो attempt_completion टूल कमांड निष्पादित नहीं करेगा। यह कार्य पूर्ण होने पर कमांड निष्पादन को पदावनत करने की तैयारी के लिए एक प्रयोगात्मक सुविधा है।" + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "मल्टी-रेंज डिफ़ ऑपरेशन सक्षम करें", + "description": "Roo को एक ही अनुरोध में कई फ़ाइलों और फ़ाइलों के भीतर कई रेंज में परिवर्तन लागू करने की अनुमति दें। यह उन्नत सुविधा कम सक्षम मॉडल को अभिभूत कर सकती है और इसे सावधानी के साथ उपयोग किया जाना चाहिए।" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 26dce52655..a28fa85850 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo vuole inserire contenuto in questo file alla riga {{lineNumber}}:", "wantsToInsertAtEnd": "Roo vuole aggiungere contenuto alla fine di questo file:", "wantsToReadAndXMore": "Roo vuole leggere questo file e altri {{count}}:", - "wantsToReadMultiple": "Roo vuole leggere più file:" + "wantsToReadMultiple": "Roo vuole leggere più file:", + "wantsToApplyBatchChanges": "Roo vuole applicare modifiche a più file:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo vuole visualizzare i file di primo livello in questa directory:", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index d920cddb6b..226c93a551 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Disabilita l'esecuzione dei comandi in attempt_completion", "description": "Se abilitato, lo strumento attempt_completion non eseguirà comandi. Questa è una funzionalità sperimentale per preparare la futura deprecazione dell'esecuzione dei comandi al completamento dell'attività." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Abilita operazioni diff multi-range", + "description": "Consente a Roo di applicare modifiche a più file e più range all'interno dei file in una singola richiesta. Questa funzionalità avanzata può sopraffare modelli meno capaci e dovrebbe essere usata con cautela." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index e76dd9a120..dc60077f86 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Rooはこのファイルの{{lineNumber}}行目にコンテンツを挿入したい:", "wantsToInsertAtEnd": "Rooはこのファイルの末尾にコンテンツを追加したい:", "wantsToReadAndXMore": "Roo はこのファイルと他に {{count}} 個のファイルを読み込もうとしています:", - "wantsToReadMultiple": "Rooは複数のファイルを読み取ろうとしています:" + "wantsToReadMultiple": "Rooは複数のファイルを読み取ろうとしています:", + "wantsToApplyBatchChanges": "Rooは複数のファイルに変更を適用したい:" }, "directoryOperations": { "wantsToViewTopLevel": "Rooはこのディレクトリのトップレベルファイルを表示したい:", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index f164063fac..cc984c107b 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "attempt_completionでのコマンド実行を無効にする", "description": "有効にすると、attempt_completionツールはコマンドを実行しません。これは、タスク完了時のコマンド実行の非推奨化に備えるための実験的な機能です。" + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "マルチレンジdiff操作を有効にする", + "description": "Rooが単一のリクエストで複数のファイルおよびファイル内の複数の範囲に変更を適用できるようにします。この高度な機能は、あまり優秀でないモデルを圧倒する可能性があるため、注意して使用する必要があります。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 563508d2a9..2afe6ff5f4 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo가 이 파일의 {{lineNumber}}번 줄에 내용을 삽입하고 싶어합니다:", "wantsToInsertAtEnd": "Roo가 이 파일의 끝에 내용을 추가하고 싶어합니다:", "wantsToReadAndXMore": "Roo가 이 파일과 {{count}}개의 파일을 더 읽으려고 합니다:", - "wantsToReadMultiple": "Roo가 여러 파일을 읽으려고 합니다:" + "wantsToReadMultiple": "Roo가 여러 파일을 읽으려고 합니다:", + "wantsToApplyBatchChanges": "Roo가 여러 파일에 변경 사항을 적용하고 싶어합니다:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo가 이 디렉토리의 최상위 파일을 보고 싶어합니다:", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 8d311f8fe6..cad4229a49 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "attempt_completion에서 명령 실행 비활성화", "description": "활성화하면 attempt_completion 도구가 명령을 실행하지 않습니다. 이는 작업 완료 시 명령 실행을 더 이상 사용하지 않도록 준비하기 위한 실험적 기능입니다." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "멀티 범위 diff 작업 활성화", + "description": "Roo가 단일 요청으로 여러 파일과 파일 내 여러 범위에 변경사항을 적용할 수 있게 합니다. 이 고급 기능은 덜 강력한 모델을 압도할 수 있으므로 주의해서 사용해야 합니다." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 2a8ba0ac70..5001b9f7cc 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -147,7 +147,8 @@ "wantsToInsertWithLineNumber": "Roo wil inhoud invoegen in dit bestand op regel {{lineNumber}}:", "wantsToInsertAtEnd": "Roo wil inhoud toevoegen aan het einde van dit bestand:", "wantsToReadAndXMore": "Roo wil dit bestand en nog {{count}} andere lezen:", - "wantsToReadMultiple": "Roo wil meerdere bestanden lezen:" + "wantsToReadMultiple": "Roo wil meerdere bestanden lezen:", + "wantsToApplyBatchChanges": "Roo wil wijzigingen toepassen op meerdere bestanden:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo wil de bovenliggende bestanden in deze map bekijken:", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index d234ff7e11..c3c75bdc2d 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Commando-uitvoering in attempt_completion uitschakelen", "description": "Indien ingeschakeld, zal de attempt_completion tool geen commando's uitvoeren. Dit is een experimentele functie ter voorbereiding op het afschaffen van commando-uitvoering bij taakvoltooiing." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Multi-range diff bewerkingen inschakelen", + "description": "Laat Roo toe om wijzigingen toe te passen op meerdere bestanden en meerdere bereiken binnen bestanden in één verzoek. Deze geavanceerde functie kan minder capabele modellen overweldigen en moet met voorzichtigheid worden gebruikt." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index a57dc9d0cc..c02a9ba777 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo chce wstawić zawartość do tego pliku w linii {{lineNumber}}:", "wantsToInsertAtEnd": "Roo chce dodać zawartość na końcu tego pliku:", "wantsToReadAndXMore": "Roo chce przeczytać ten plik i {{count}} więcej:", - "wantsToReadMultiple": "Roo chce odczytać wiele plików:" + "wantsToReadMultiple": "Roo chce odczytać wiele plików:", + "wantsToApplyBatchChanges": "Roo chce zastosować zmiany do wielu plików:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo chce zobaczyć pliki najwyższego poziomu w tym katalogu:", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index c9bc4ac1ab..4e13f952b7 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -500,6 +500,10 @@ "MARKETPLACE": { "name": "Włącz Marketplace", "description": "Gdy włączone, możesz instalować MCP i niestandardowe tryby z Marketplace." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Włącz operacje diff wielozakresowe", + "description": "Pozwala Roo na zastosowanie zmian w wielu plikach i wielu zakresach w plikach w jednym żądaniu. Ta zaawansowana funkcja może przytłoczyć mniej zdolne modele i powinna być używana z ostrożnością." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 05d436de71..f1eca6a982 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo quer inserir conteúdo neste arquivo na linha {{lineNumber}}:", "wantsToInsertAtEnd": "Roo quer adicionar conteúdo ao final deste arquivo:", "wantsToReadAndXMore": "Roo quer ler este arquivo e mais {{count}}:", - "wantsToReadMultiple": "Roo deseja ler múltiplos arquivos:" + "wantsToReadMultiple": "Roo deseja ler múltiplos arquivos:", + "wantsToApplyBatchChanges": "Roo quer aplicar alterações a múltiplos arquivos:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo quer visualizar os arquivos de nível superior neste diretório:", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index b2ab1756c3..f891e84724 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -500,6 +500,10 @@ "MARKETPLACE": { "name": "Ativar Marketplace", "description": "Quando ativado, você pode instalar MCPs e modos personalizados do Marketplace." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Habilitar operações de diff multi-range", + "description": "Permite que o Roo aplique alterações em múltiplos arquivos e múltiplas faixas dentro de arquivos em uma única solicitação. Este recurso avançado pode sobrecarregar modelos menos capazes e deve ser usado com cautela." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 8e24a9e3ea..fcee847f15 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -147,7 +147,8 @@ "wantsToInsertWithLineNumber": "Roo хочет вставить содержимое в этот файл на строку {{lineNumber}}:", "wantsToInsertAtEnd": "Roo хочет добавить содержимое в конец этого файла:", "wantsToReadAndXMore": "Roo хочет прочитать этот файл и еще {{count}}:", - "wantsToReadMultiple": "Roo хочет прочитать несколько файлов:" + "wantsToReadMultiple": "Roo хочет прочитать несколько файлов:", + "wantsToApplyBatchChanges": "Roo хочет применить изменения к нескольким файлам:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo хочет просмотреть файлы верхнего уровня в этой директории:", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 1daf624a71..5aa716b323 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -500,6 +500,10 @@ "MARKETPLACE": { "name": "Включить Marketplace", "description": "Когда включено, вы можете устанавливать MCP и пользовательские режимы из Marketplace." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Включить операции многодиапазонного diff", + "description": "Позволяет Roo применять изменения к нескольким файлам и нескольким диапазонам внутри файлов в одном запросе. Эта продвинутая функция может перегрузить менее способные модели и должна использоваться с осторожностью." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index c0d43f59b5..a3a0a9bfd4 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo bu dosyanın {{lineNumber}}. satırına içerik eklemek istiyor:", "wantsToInsertAtEnd": "Roo bu dosyanın sonuna içerik eklemek istiyor:", "wantsToReadAndXMore": "Roo bu dosyayı ve {{count}} tane daha okumak istiyor:", - "wantsToReadMultiple": "Roo birden fazla dosya okumak istiyor:" + "wantsToReadMultiple": "Roo birden fazla dosya okumak istiyor:", + "wantsToApplyBatchChanges": "Roo birden fazla dosyaya değişiklik uygulamak istiyor:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo bu dizindeki üst düzey dosyaları görüntülemek istiyor:", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 8638045511..f727184e1b 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "attempt_completion'da komut yürütmeyi devre dışı bırak", "description": "Etkinleştirildiğinde, attempt_completion aracı komutları yürütmez. Bu, görev tamamlandığında komut yürütmenin kullanımdan kaldırılmasına hazırlanmak için deneysel bir özelliktir." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Çok aralıklı diff işlemlerini etkinleştir", + "description": "Roo'nun tek bir istekte birden fazla dosya ve dosyalar içindeki birden fazla aralığa değişiklik uygulamasına izin verir. Bu gelişmiş özellik daha az yetenekli modelleri bunaltabilir ve dikkatli kullanılmalıdır." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 5ad270aa89..3349f20002 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo muốn chèn nội dung vào dòng {{lineNumber}} của tệp này:", "wantsToInsertAtEnd": "Roo muốn thêm nội dung vào cuối tệp này:", "wantsToReadAndXMore": "Roo muốn đọc tệp này và {{count}} tệp khác:", - "wantsToReadMultiple": "Roo muốn đọc nhiều tệp:" + "wantsToReadMultiple": "Roo muốn đọc nhiều tệp:", + "wantsToApplyBatchChanges": "Roo muốn áp dụng thay đổi cho nhiều tệp:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo muốn xem các tệp cấp cao nhất trong thư mục này:", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index edc85033fb..7af0678edb 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "Tắt thực thi lệnh trong attempt_completion", "description": "Khi được bật, công cụ attempt_completion sẽ không thực thi lệnh. Đây là một tính năng thử nghiệm để chuẩn bị cho việc ngừng hỗ trợ thực thi lệnh khi hoàn thành tác vụ trong tương lai." + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "Bật các thao tác diff đa phạm vi", + "description": "Cho phép Roo áp dụng thay đổi cho nhiều tệp và nhiều phạm vi trong các tệp trong một yêu cầu duy nhất. Tính năng nâng cao này có thể làm choáng ngợp các mô hình kém khả năng hơn và nên được sử dụng một cách thận trọng." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 058adfd0ad..e396d33827 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "需要在第 {{lineNumber}} 行插入内容:", "wantsToInsertAtEnd": "需要在文件末尾添加内容:", "wantsToReadAndXMore": "Roo 想读取此文件以及另外 {{count}} 个文件:", - "wantsToReadMultiple": "Roo 想要读取多个文件:" + "wantsToReadMultiple": "Roo 想要读取多个文件:", + "wantsToApplyBatchChanges": "Roo想要对多个文件应用更改:" }, "directoryOperations": { "wantsToViewTopLevel": "需要查看目录文件列表:", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 9e422f9a58..9de27a8ada 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "禁用 attempt_completion 中的命令执行", "description": "启用后,attempt_completion 工具将不会执行命令。这是一项实验性功能,旨在为将来弃用任务完成时的命令执行做准备。" + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "启用多范围diff操作", + "description": "允许Roo在单个请求中对多个文件和文件内的多个范围应用更改。这个高级功能可能会让能力较弱的模型不堪重负,应谨慎使用。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 0309948a95..651719e839 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -152,7 +152,8 @@ "wantsToInsertWithLineNumber": "Roo 想要在此檔案第 {{lineNumber}} 行插入內容:", "wantsToInsertAtEnd": "Roo 想要在此檔案末尾新增內容:", "wantsToReadAndXMore": "Roo 想要讀取此檔案以及另外 {{count}} 個檔案:", - "wantsToReadMultiple": "Roo 想要讀取多個檔案:" + "wantsToReadMultiple": "Roo 想要讀取多個檔案:", + "wantsToApplyBatchChanges": "Roo 想要對多個檔案套用變更:" }, "directoryOperations": { "wantsToViewTopLevel": "Roo 想要檢視此目錄中最上層的檔案:", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 04b26b7310..2e9c15470f 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -500,6 +500,10 @@ "DISABLE_COMPLETION_COMMAND": { "name": "停用 attempt_completion 中的指令執行", "description": "啟用後,attempt_completion 工具將不會執行指令。這是一項實驗性功能,旨在為未來停用工作完成時的指令執行做準備。" + }, + "MULTI_FILE_APPLY_DIFF": { + "name": "啟用多範圍diff操作", + "description": "允許Roo在單個請求中對多個檔案和檔案內的多個範圍應用變更。這個進階功能可能會讓能力較弱的模型不堪重負,應謹慎使用。" } }, "promptCaching": { From f05c75bdb02f76fa46f361b5008a5b6e70cab7e5 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 12 Jun 2025 08:46:38 -0500 Subject: [PATCH 02/13] revert this --- .../diff/strategies/multi-search-replace.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts index 7a82e720d4..9e740a6571 100644 --- a/src/core/diff/strategies/multi-search-replace.ts +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-irregular-whitespace */ + import { distance } from "fastest-levenshtein" import { ToolProgressStatus } from "@roo-code/types" @@ -331,11 +333,10 @@ Only use a single line of '=======' between search and replacement content, beca async applyDiff( originalContent: string, - _diffContent: string | Array<{ content: string; startLine?: number }>, + diffContent: string, _paramStartLine?: number, _paramEndLine?: number, ): Promise { - let diffContent: string | undefined = _diffContent as string const validseq = this.validateMarkerSequencing(diffContent) if (!validseq.success) { return { @@ -348,31 +349,31 @@ Only use a single line of '=======' between search and replacement content, beca Regex parts: 1. (?:^|\n) - Ensures the first marker starts at the beginning of the file or right after a newline. +   Ensures the first marker starts at the beginning of the file or right after a newline. 2. (?>>>>>> REPLACE)(?=\n|$) - Matches the final “>>>>>>> REPLACE” marker on its own line (and requires a following newline or the end of file). +   Matches the final “>>>>>>> REPLACE” marker on its own line (and requires a following newline or the end of file). */ let matches = [ From 2557d4a86ca7f78541d204ccd3336e49b4489367 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 12 Jun 2025 08:49:04 -0500 Subject: [PATCH 03/13] fix: update applyDiff parameter type to accept string or DiffItem --- src/core/prompts/__tests__/sections.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/prompts/__tests__/sections.test.ts b/src/core/prompts/__tests__/sections.test.ts index 9460f2a29a..3b29193e99 100644 --- a/src/core/prompts/__tests__/sections.test.ts +++ b/src/core/prompts/__tests__/sections.test.ts @@ -35,7 +35,7 @@ describe("getCapabilitiesSection", () => { const mockDiffStrategy: DiffStrategy = { getName: () => "MockStrategy", getToolDescription: () => "apply_diff tool description", - async applyDiff(_originalContent: string, _diffContents: DiffItem[]): Promise { + async applyDiff(_originalContent: string, _diffContents: string | DiffItem[]): Promise { return { success: true, content: "mock result" } }, } From e56b9228f86da9d850913a89ad0cc650711291da Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 12 Jun 2025 08:56:03 -0500 Subject: [PATCH 04/13] refactor: keep original file name for apply diff tool --- .../presentAssistantMessage.ts | 17 +- .../applyDiffTool.experiment.spec.ts | 4 +- src/core/tools/applyDiffTool.ts | 619 ++++-------------- src/core/tools/applyDiffToolLegacy.ts | 198 ------ src/core/tools/multiApplyDiffTool.ts | 571 ++++++++++++++++ 5 files changed, 711 insertions(+), 698 deletions(-) delete mode 100644 src/core/tools/applyDiffToolLegacy.ts create mode 100644 src/core/tools/multiApplyDiffTool.ts diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 3716906a8d..7a8e215af1 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -11,7 +11,7 @@ import { fetchInstructionsTool } from "../tools/fetchInstructionsTool" import { listFilesTool } from "../tools/listFilesTool" import { getReadFileToolDescription, readFileTool } from "../tools/readFileTool" import { writeToFileTool } from "../tools/writeToFileTool" -import { applyDiffTool } from "../tools/applyDiffTool" +import { applyDiffTool } from "../tools/multiApplyDiffTool" import { insertContentTool } from "../tools/insertContentTool" import { searchAndReplaceTool } from "../tools/searchAndReplaceTool" import { listCodeDefinitionNamesTool } from "../tools/listCodeDefinitionNamesTool" @@ -31,6 +31,8 @@ import { formatResponse } from "../prompts/responses" import { validateToolUse } from "../tools/validateToolUse" import { Task } from "../task/Task" import { codebaseSearchTool } from "../tools/codebaseSearchTool" +import { experiments } from "../../shared/experiments" +import { applyDiffToolLegacy } from "../tools/applyDiffTool" /** * Processes and presents assistant message content to the user interface. @@ -385,7 +387,18 @@ export async function presentAssistantMessage(cline: Task) { await writeToFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break case "apply_diff": - await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + if (experiments.get("MULTI_FILE_APPLY_DIFF")?.enabled) { + await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + } else { + await applyDiffToolLegacy( + cline, + block, + askApproval, + handleError, + pushToolResult, + removeClosingTag, + ) + } break case "insert_content": await insertContentTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index 8543f6b7b3..eaea7625c8 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { applyDiffTool } from "../applyDiffTool" -import { applyDiffToolLegacy } from "../applyDiffToolLegacy" +import { applyDiffTool } from "../multiApplyDiffTool" +import { applyDiffToolLegacy } from "../applyDiffTool" import { Task } from "../../task/Task" import { EXPERIMENT_IDS, experiments } from "../../../shared/experiments" diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index a57f5e472e..0915631e41 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -11,45 +11,8 @@ import { formatResponse } from "../prompts/responses" import { fileExistsAtPath } from "../../utils/fs" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" -import { parseXml } from "../../utils/xml" -import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { applyDiffToolLegacy } from "./applyDiffToolLegacy" - -interface DiffOperation { - path: string - diff: Array<{ - content: string - startLine?: number - }> -} - -// Track operation status -interface OperationResult { - path: string - status: "pending" | "approved" | "denied" | "blocked" | "error" - error?: string - result?: string - diffItems?: Array<{ content: string; startLine?: number }> - absolutePath?: string - fileExists?: boolean -} - -// Add proper type definitions -interface ParsedFile { - path: string - diff: ParsedDiff | ParsedDiff[] -} - -interface ParsedDiff { - content: string - start_line?: string -} - -interface ParsedXmlResult { - file: ParsedFile | ParsedFile[] -} -export async function applyDiffTool( +export async function applyDiffToolLegacy( cline: Task, block: ToolUse, askApproval: AskApproval, @@ -57,512 +20,176 @@ export async function applyDiffTool( pushToolResult: PushToolResult, removeClosingTag: RemoveClosingTag, ) { - // Check if MULTI_FILE_APPLY_DIFF experiment is enabled - const provider = cline.providerRef.deref() - if (provider) { - const state = await provider.getState() - const isMultiFileApplyDiffEnabled = experiments.isEnabled( - state.experiments ?? {}, - EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, - ) - - // If experiment is disabled, use legacy tool - if (!isMultiFileApplyDiffEnabled) { - return applyDiffToolLegacy(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) - } - } - - // Otherwise, continue with new multi-file implementation - const argsXmlTag: string | undefined = block.params.args - const legacyPath: string | undefined = block.params.path - const legacyDiffContent: string | undefined = block.params.diff - const legacyStartLineStr: string | undefined = block.params.start_line - - let operationsMap: Record = {} - let usingLegacyParams = false - let filteredOperationErrors: string[] = [] - - // Handle partial message first - if (block.partial) { - let filePath = "" - if (argsXmlTag) { - const match = argsXmlTag.match(/.*?([^<]+)<\/path>/s) - if (match) { - filePath = match[1] - } - } else if (legacyPath) { - // Use legacy path if argsXmlTag is not present for partial messages - filePath = legacyPath - } + const relPath: string | undefined = block.params.path + let diffContent: string | undefined = block.params.diff - const sharedMessageProps: ClineSayTool = { - tool: "appliedDiff", - path: getReadablePath(cline.cwd, filePath), - } - const partialMessage = JSON.stringify(sharedMessageProps) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return + if (diffContent && !cline.api.getModel().id.includes("claude")) { + diffContent = unescapeHtmlEntities(diffContent) } - if (argsXmlTag) { - // Parse file entries from XML (new way) - try { - const parsed = parseXml(argsXmlTag, ["file.diff.content"]) as ParsedXmlResult - const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) - - for (const file of files) { - if (!file.path || !file.diff) continue - - const filePath = file.path - - // Initialize the operation in the map if it doesn't exist - if (!operationsMap[filePath]) { - operationsMap[filePath] = { - path: filePath, - diff: [], - } - } - - // Handle diff as either array or single element - const diffs = Array.isArray(file.diff) ? file.diff : [file.diff] - - for (let i = 0; i < diffs.length; i++) { - const diff = diffs[i] - let diffContent: string - let startLine: number | undefined - - diffContent = diff.content - startLine = diff.start_line ? parseInt(diff.start_line) : undefined - - operationsMap[filePath].diff.push({ - content: diffContent, - startLine, - }) - } - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const detailedError = `Failed to parse apply_diff XML. This usually means: -1. The XML structure is malformed or incomplete -2. Missing required , , or tags -3. Invalid characters or encoding in the XML - -Expected structure: - - - relative/path/to/file.ext - - diff content here - optional line number - - - - -Original error: ${errorMessage}` - throw new Error(detailedError) - } - - // Remove this duplicate check - we already checked at the beginning - } else if (legacyPath && typeof legacyDiffContent === "string") { - // Handle legacy parameters (old way) - usingLegacyParams = true - operationsMap[legacyPath] = { - path: legacyPath, - diff: [ - { - content: legacyDiffContent, // Unescaping will be handled later like new diffs - startLine: legacyStartLineStr ? parseInt(legacyStartLineStr) : undefined, - }, - ], - } - } else { - // Neither new XML args nor old path/diff params are sufficient - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - const errorMsg = await cline.sayAndCreateMissingParamError( - "apply_diff", - "args (or legacy 'path' and 'diff' parameters)", - ) - pushToolResult(errorMsg) - return - } - - // If no operations were extracted, bail out - if (Object.keys(operationsMap).length === 0) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - pushToolResult( - await cline.sayAndCreateMissingParamError( - "apply_diff", - usingLegacyParams - ? "legacy 'path' and 'diff' (must be valid and non-empty)" - : "args (must contain at least one valid file element)", - ), - ) - return + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), + diff: diffContent, } - // Convert map to array of operations for processing - const operations = Object.values(operationsMap) + try { + if (block.partial) { + // Update GUI message + let toolProgressStatus - const operationResults: OperationResult[] = operations.map((op) => ({ - path: op.path, - status: "pending", - diffItems: op.diff, - })) + if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { + toolProgressStatus = cline.diffStrategy.getProgressStatus(block) + } - // Function to update operation result - const updateOperationResult = (path: string, updates: Partial) => { - const index = operationResults.findIndex((result) => result.path === path) - if (index !== -1) { - operationResults[index] = { ...operationResults[index], ...updates } - } - } + if (toolProgressStatus && Object.keys(toolProgressStatus).length === 0) { + return + } - try { - // First validate all files and prepare for batch approval - const operationsToApprove: OperationResult[] = [] + await cline + .ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus) + .catch(() => {}) + + return + } else { + if (!relPath) { + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "path")) + return + } - for (const operation of operations) { - const { path: relPath, diff: diffItems } = operation + if (!diffContent) { + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "diff")) + return + } - // Verify file access is allowed const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) + if (!accessAllowed) { await cline.say("rooignore_error", relPath) - updateOperationResult(relPath, { - status: "blocked", - error: formatResponse.rooIgnoreError(relPath), - }) - continue + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) + return } - // Verify file exists const absolutePath = path.resolve(cline.cwd, relPath) const fileExists = await fileExistsAtPath(absolutePath) - if (!fileExists) { - updateOperationResult(relPath, { - status: "blocked", - error: `File does not exist at path: ${absolutePath}`, - }) - continue - } - - // Add to operations that need approval - const opResult = operationResults.find((r) => r.path === relPath) - if (opResult) { - opResult.absolutePath = absolutePath - opResult.fileExists = fileExists - operationsToApprove.push(opResult) - } - } - - // Handle batch approval if there are multiple files - if (operationsToApprove.length > 1) { - // Prepare batch diff data - const batchDiffs = operationsToApprove.map((opResult) => { - const readablePath = getReadablePath(cline.cwd, opResult.path) - const changeCount = opResult.diffItems?.length || 0 - const changeText = changeCount === 1 ? "1 change" : `${changeCount} changes` - - return { - path: readablePath, - changeCount, - key: `${readablePath} (${changeText})`, - content: opResult.path, // Full relative path - diffs: opResult.diffItems?.map((item) => ({ - content: item.content, - startLine: item.startLine, - })), - } - }) - - const completeMessage = JSON.stringify({ - tool: "appliedDiff", - batchDiffs, - } satisfies ClineSayTool) - const { response, text, images } = await cline.ask("tool", completeMessage, false) - - // Process batch response - if (response === "yesButtonClicked") { - // Approve all files - if (text) { - await cline.say("user_feedback", text, images) - } - operationsToApprove.forEach((opResult) => { - updateOperationResult(opResult.path, { status: "approved" }) - }) - } else if (response === "noButtonClicked") { - // Deny all files - if (text) { - await cline.say("user_feedback", text, images) - } - cline.didRejectTool = true - operationsToApprove.forEach((opResult) => { - updateOperationResult(opResult.path, { - status: "denied", - result: `Changes to ${opResult.path} were not approved by user`, - }) - }) - } else { - // Handle individual permissions from objectResponse - try { - const parsedResponse = JSON.parse(text || "{}") - // Check if this is our batch diff approval response - if (parsedResponse.action === "applyDiff" && parsedResponse.approvedFiles) { - const approvedFiles = parsedResponse.approvedFiles - let hasAnyDenial = false - - operationsToApprove.forEach((opResult) => { - const approved = approvedFiles[opResult.path] === true - - if (approved) { - updateOperationResult(opResult.path, { status: "approved" }) - } else { - hasAnyDenial = true - updateOperationResult(opResult.path, { - status: "denied", - result: `Changes to ${opResult.path} were not approved by user`, - }) - } - }) - - if (hasAnyDenial) { - cline.didRejectTool = true - } - } else { - // Legacy individual permissions format - const individualPermissions = parsedResponse - let hasAnyDenial = false - - batchDiffs.forEach((batchDiff, index) => { - const opResult = operationsToApprove[index] - const approved = individualPermissions[batchDiff.key] === true - - if (approved) { - updateOperationResult(opResult.path, { status: "approved" }) - } else { - hasAnyDenial = true - updateOperationResult(opResult.path, { - status: "denied", - result: `Changes to ${opResult.path} were not approved by user`, - }) - } - }) - - if (hasAnyDenial) { - cline.didRejectTool = true - } - } - } catch (error) { - // Fallback: if JSON parsing fails, deny all files - console.error("Failed to parse individual permissions:", error) - cline.didRejectTool = true - operationsToApprove.forEach((opResult) => { - updateOperationResult(opResult.path, { - status: "denied", - result: `Changes to ${opResult.path} were not approved by user`, - }) - }) - } + if (!fileExists) { + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` + await cline.say("error", formattedError) + pushToolResult(formattedError) + return } - } else if (operationsToApprove.length === 1) { - // Single file approval - process immediately - const opResult = operationsToApprove[0] - updateOperationResult(opResult.path, { status: "approved" }) - } - // Process approved operations - const results: string[] = [] + let originalContent: string | null = await fs.readFile(absolutePath, "utf-8") - for (const opResult of operationResults) { - // Skip operations that weren't approved or were blocked - if (opResult.status !== "approved") { - if (opResult.result) { - results.push(opResult.result) - } else if (opResult.error) { - results.push(opResult.error) - } - continue + // Apply the diff to the original content + const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, diffContent)) ?? { + success: false, + error: "No diff strategy available", } - const relPath = opResult.path - const diffItems = opResult.diffItems || [] - const absolutePath = opResult.absolutePath! - const fileExists = opResult.fileExists! + // Release the original content from memory as it's no longer needed + originalContent = null - try { - let originalContent: string | null = await fs.readFile(absolutePath, "utf-8") - let successCount = 0 + if (!diffResult.success) { + cline.consecutiveMistakeCount++ + const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 + cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) let formattedError = "" + TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount) - // Pre-process all diff items for HTML entity unescaping if needed - const processedDiffItems = !cline.api.getModel().id.includes("claude") - ? diffItems.map((item) => ({ - ...item, - content: item.content ? unescapeHtmlEntities(item.content) : item.content, - })) - : diffItems - - // Apply all diffs at once with the array-based method - const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, processedDiffItems)) ?? { - success: false, - error: "No diff strategy available - please ensure a valid diff strategy is configured", - } - - // Release the original content from memory as it's no longer needed - originalContent = null - - if (!diffResult.success) { - cline.consecutiveMistakeCount++ - const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 - cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) - - TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount) - - if (diffResult.failParts && diffResult.failParts.length > 0) { - for (let i = 0; i < diffResult.failParts.length; i++) { - const failPart = diffResult.failParts[i] - if (failPart.success) { - continue - } - - const errorDetails = failPart.details ? JSON.stringify(failPart.details, null, 2) : "" - formattedError += ` -Diff ${i + 1} failed for file: ${relPath} -Error: ${failPart.error} - -Suggested fixes: -1. Verify the search content exactly matches the file content (including whitespace) -2. Check for correct indentation and line endings -3. Use to see the current file content -4. Consider breaking complex changes into smaller diffs -5. Ensure start_line parameter matches the actual content location -${errorDetails ? `\nDetailed error information:\n${errorDetails}\n` : ""} -\n\n` + if (diffResult.failParts && diffResult.failParts.length > 0) { + for (const failPart of diffResult.failParts) { + if (failPart.success) { + continue } - } else { - const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : "" - formattedError += ` -Unable to apply diffs to file: ${absolutePath} -Error: ${diffResult.error} - -Recovery suggestions: -1. Use to examine the current file content -2. Verify the diff format matches the expected search/replace pattern -3. Check that the search content exactly matches what's in the file -4. Consider using line numbers with start_line parameter -5. Break large changes into smaller, more specific diffs -${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} -\n\n` + + const errorDetails = failPart.details ? JSON.stringify(failPart.details, null, 2) : "" + + formattedError = `\n${ + failPart.error + }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` } } else { - // Get the content from the result and update success count - originalContent = diffResult.content || originalContent - successCount = diffItems.length - (diffResult.failParts?.length || 0) + const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : "" + + formattedError = `Unable to apply diff to file: ${absolutePath}\n\n\n${ + diffResult.error + }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` } - // If no diffs were successfully applied, continue to next file - if (successCount === 0) { - if (formattedError) { - const currentCount = cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0 - if (currentCount >= 2) { - await cline.say("diff_error", formattedError) - } - cline.recordToolError("apply_diff", formattedError) - results.push(formattedError) - } - continue + if (currentCount >= 2) { + await cline.say("diff_error", formattedError) } - cline.consecutiveMistakeCount = 0 - cline.consecutiveMistakeCountForApplyDiff.delete(relPath) + cline.recordToolError("apply_diff", formattedError) - // Show diff view before asking for approval (only for single file or after batch approval) - cline.diffViewProvider.editType = "modify" - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(originalContent!, true) - await cline.diffViewProvider.scrollToFirstDiff() + pushToolResult(formattedError) + return + } - // For batch operations, we've already gotten approval - const sharedMessageProps: ClineSayTool = { - tool: "appliedDiff", - path: getReadablePath(cline.cwd, relPath), - } + cline.consecutiveMistakeCount = 0 + cline.consecutiveMistakeCountForApplyDiff.delete(relPath) - // If single file, ask for approval - let didApprove = true - if (operationsToApprove.length === 1) { - const diffContents = diffItems.map((item) => item.content).join("\n\n") - const operationMessage = JSON.stringify({ - ...sharedMessageProps, - diff: diffContents, - } satisfies ClineSayTool) - - let toolProgressStatus - - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus( - { - ...block, - params: { ...block.params, diff: diffContents }, - }, - { success: true }, - ) - } + // Show diff view before asking for approval + cline.diffViewProvider.editType = "modify" + await cline.diffViewProvider.open(relPath) + await cline.diffViewProvider.update(diffResult.content, true) + await cline.diffViewProvider.scrollToFirstDiff() - didApprove = await askApproval("tool", operationMessage, toolProgressStatus) - } + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + diff: diffContent, + } satisfies ClineSayTool) - if (!didApprove) { - await cline.diffViewProvider.revertChanges() - results.push(`Changes to ${relPath} were not approved by user`) - continue - } + let toolProgressStatus - // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges() + if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { + toolProgressStatus = cline.diffStrategy.getProgressStatus(block, diffResult) + } - // Track file edit operation - await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + const didApprove = await askApproval("tool", completeMessage, toolProgressStatus) - // Used to determine if we should wait for busy terminal to update before sending api request - cline.didEditFile = true - let partFailHint = "" + if (!didApprove) { + await cline.diffViewProvider.revertChanges() // Cline likely handles closing the diff view + return + } - if (successCount < diffItems.length) { - partFailHint = `Unable to apply all diff parts to file: ${absolutePath}` - } + // Call saveChanges to update the DiffViewProvider properties + await cline.diffViewProvider.saveChanges() - // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + // Track file edit operation + if (relPath) { + await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + } - if (partFailHint) { - results.push(partFailHint + "\n" + message) - } else { - results.push(message) - } + // Used to determine if we should wait for busy terminal to update before sending api request + cline.didEditFile = true + let partFailHint = "" - await cline.diffViewProvider.reset() - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateOperationResult(relPath, { - status: "error", - error: `Error processing ${relPath}: ${errorMsg}`, - }) - results.push(`Error processing ${relPath}: ${errorMsg}`) + if (diffResult.failParts && diffResult.failParts.length > 0) { + partFailHint = `But unable to apply all diff parts to file: ${absolutePath}, silently use tool to check newest file version and re-apply diffs\n` } - } - // Add filtered operation errors to results - if (filteredOperationErrors.length > 0) { - results.push(...filteredOperationErrors) - } + // Get the formatted response message + const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) - // Push the final result combining all operation results - pushToolResult(results.join("\n\n")) - return + if (partFailHint) { + pushToolResult(partFailHint + message) + } else { + pushToolResult(message) + } + + await cline.diffViewProvider.reset() + + return + } } catch (error) { await handleError("applying diff", error) await cline.diffViewProvider.reset() diff --git a/src/core/tools/applyDiffToolLegacy.ts b/src/core/tools/applyDiffToolLegacy.ts deleted file mode 100644 index 0915631e41..0000000000 --- a/src/core/tools/applyDiffToolLegacy.ts +++ /dev/null @@ -1,198 +0,0 @@ -import path from "path" -import fs from "fs/promises" - -import { TelemetryService } from "@roo-code/telemetry" - -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { getReadablePath } from "../../utils/path" -import { Task } from "../task/Task" -import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } from "../../shared/tools" -import { formatResponse } from "../prompts/responses" -import { fileExistsAtPath } from "../../utils/fs" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { unescapeHtmlEntities } from "../../utils/text-normalization" - -export async function applyDiffToolLegacy( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relPath: string | undefined = block.params.path - let diffContent: string | undefined = block.params.diff - - if (diffContent && !cline.api.getModel().id.includes("claude")) { - diffContent = unescapeHtmlEntities(diffContent) - } - - const sharedMessageProps: ClineSayTool = { - tool: "appliedDiff", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - diff: diffContent, - } - - try { - if (block.partial) { - // Update GUI message - let toolProgressStatus - - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block) - } - - if (toolProgressStatus && Object.keys(toolProgressStatus).length === 0) { - return - } - - await cline - .ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus) - .catch(() => {}) - - return - } else { - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "path")) - return - } - - if (!diffContent) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "diff")) - return - } - - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) - return - } - - const absolutePath = path.resolve(cline.cwd, relPath) - const fileExists = await fileExistsAtPath(absolutePath) - - if (!fileExists) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` - await cline.say("error", formattedError) - pushToolResult(formattedError) - return - } - - let originalContent: string | null = await fs.readFile(absolutePath, "utf-8") - - // Apply the diff to the original content - const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, diffContent)) ?? { - success: false, - error: "No diff strategy available", - } - - // Release the original content from memory as it's no longer needed - originalContent = null - - if (!diffResult.success) { - cline.consecutiveMistakeCount++ - const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 - cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) - let formattedError = "" - TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount) - - if (diffResult.failParts && diffResult.failParts.length > 0) { - for (const failPart of diffResult.failParts) { - if (failPart.success) { - continue - } - - const errorDetails = failPart.details ? JSON.stringify(failPart.details, null, 2) : "" - - formattedError = `\n${ - failPart.error - }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` - } - } else { - const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : "" - - formattedError = `Unable to apply diff to file: ${absolutePath}\n\n\n${ - diffResult.error - }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` - } - - if (currentCount >= 2) { - await cline.say("diff_error", formattedError) - } - - cline.recordToolError("apply_diff", formattedError) - - pushToolResult(formattedError) - return - } - - cline.consecutiveMistakeCount = 0 - cline.consecutiveMistakeCountForApplyDiff.delete(relPath) - - // Show diff view before asking for approval - cline.diffViewProvider.editType = "modify" - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(diffResult.content, true) - await cline.diffViewProvider.scrollToFirstDiff() - - const completeMessage = JSON.stringify({ - ...sharedMessageProps, - diff: diffContent, - } satisfies ClineSayTool) - - let toolProgressStatus - - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block, diffResult) - } - - const didApprove = await askApproval("tool", completeMessage, toolProgressStatus) - - if (!didApprove) { - await cline.diffViewProvider.revertChanges() // Cline likely handles closing the diff view - return - } - - // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges() - - // Track file edit operation - if (relPath) { - await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) - } - - // Used to determine if we should wait for busy terminal to update before sending api request - cline.didEditFile = true - let partFailHint = "" - - if (diffResult.failParts && diffResult.failParts.length > 0) { - partFailHint = `But unable to apply all diff parts to file: ${absolutePath}, silently use tool to check newest file version and re-apply diffs\n` - } - - // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) - - if (partFailHint) { - pushToolResult(partFailHint + message) - } else { - pushToolResult(message) - } - - await cline.diffViewProvider.reset() - - return - } - } catch (error) { - await handleError("applying diff", error) - await cline.diffViewProvider.reset() - return - } -} diff --git a/src/core/tools/multiApplyDiffTool.ts b/src/core/tools/multiApplyDiffTool.ts new file mode 100644 index 0000000000..1ce29f3d3e --- /dev/null +++ b/src/core/tools/multiApplyDiffTool.ts @@ -0,0 +1,571 @@ +import path from "path" +import fs from "fs/promises" + +import { TelemetryService } from "@roo-code/telemetry" + +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { getReadablePath } from "../../utils/path" +import { Task } from "../task/Task" +import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { fileExistsAtPath } from "../../utils/fs" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { unescapeHtmlEntities } from "../../utils/text-normalization" +import { parseXml } from "../../utils/xml" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { applyDiffToolLegacy } from "./applyDiffTool" + +interface DiffOperation { + path: string + diff: Array<{ + content: string + startLine?: number + }> +} + +// Track operation status +interface OperationResult { + path: string + status: "pending" | "approved" | "denied" | "blocked" | "error" + error?: string + result?: string + diffItems?: Array<{ content: string; startLine?: number }> + absolutePath?: string + fileExists?: boolean +} + +// Add proper type definitions +interface ParsedFile { + path: string + diff: ParsedDiff | ParsedDiff[] +} + +interface ParsedDiff { + content: string + start_line?: string +} + +interface ParsedXmlResult { + file: ParsedFile | ParsedFile[] +} + +export async function applyDiffTool( + cline: Task, + block: ToolUse, + askApproval: AskApproval, + handleError: HandleError, + pushToolResult: PushToolResult, + removeClosingTag: RemoveClosingTag, +) { + // Check if MULTI_FILE_APPLY_DIFF experiment is enabled + const provider = cline.providerRef.deref() + if (provider) { + const state = await provider.getState() + const isMultiFileApplyDiffEnabled = experiments.isEnabled( + state.experiments ?? {}, + EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, + ) + + // If experiment is disabled, use legacy tool + if (!isMultiFileApplyDiffEnabled) { + return applyDiffToolLegacy(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + } + } + + // Otherwise, continue with new multi-file implementation + const argsXmlTag: string | undefined = block.params.args + const legacyPath: string | undefined = block.params.path + const legacyDiffContent: string | undefined = block.params.diff + const legacyStartLineStr: string | undefined = block.params.start_line + + let operationsMap: Record = {} + let usingLegacyParams = false + let filteredOperationErrors: string[] = [] + + // Handle partial message first + if (block.partial) { + let filePath = "" + if (argsXmlTag) { + const match = argsXmlTag.match(/.*?([^<]+)<\/path>/s) + if (match) { + filePath = match[1] + } + } else if (legacyPath) { + // Use legacy path if argsXmlTag is not present for partial messages + filePath = legacyPath + } + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(cline.cwd, filePath), + } + const partialMessage = JSON.stringify(sharedMessageProps) + await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + return + } + + if (argsXmlTag) { + // Parse file entries from XML (new way) + try { + const parsed = parseXml(argsXmlTag, ["file.diff.content"]) as ParsedXmlResult + const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) + + for (const file of files) { + if (!file.path || !file.diff) continue + + const filePath = file.path + + // Initialize the operation in the map if it doesn't exist + if (!operationsMap[filePath]) { + operationsMap[filePath] = { + path: filePath, + diff: [], + } + } + + // Handle diff as either array or single element + const diffs = Array.isArray(file.diff) ? file.diff : [file.diff] + + for (let i = 0; i < diffs.length; i++) { + const diff = diffs[i] + let diffContent: string + let startLine: number | undefined + + diffContent = diff.content + startLine = diff.start_line ? parseInt(diff.start_line) : undefined + + operationsMap[filePath].diff.push({ + content: diffContent, + startLine, + }) + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const detailedError = `Failed to parse apply_diff XML. This usually means: +1. The XML structure is malformed or incomplete +2. Missing required , , or tags +3. Invalid characters or encoding in the XML + +Expected structure: + + + relative/path/to/file.ext + + diff content here + optional line number + + + + +Original error: ${errorMessage}` + throw new Error(detailedError) + } + + // Remove this duplicate check - we already checked at the beginning + } else if (legacyPath && typeof legacyDiffContent === "string") { + // Handle legacy parameters (old way) + usingLegacyParams = true + operationsMap[legacyPath] = { + path: legacyPath, + diff: [ + { + content: legacyDiffContent, // Unescaping will be handled later like new diffs + startLine: legacyStartLineStr ? parseInt(legacyStartLineStr) : undefined, + }, + ], + } + } else { + // Neither new XML args nor old path/diff params are sufficient + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + const errorMsg = await cline.sayAndCreateMissingParamError( + "apply_diff", + "args (or legacy 'path' and 'diff' parameters)", + ) + pushToolResult(errorMsg) + return + } + + // If no operations were extracted, bail out + if (Object.keys(operationsMap).length === 0) { + cline.consecutiveMistakeCount++ + cline.recordToolError("apply_diff") + pushToolResult( + await cline.sayAndCreateMissingParamError( + "apply_diff", + usingLegacyParams + ? "legacy 'path' and 'diff' (must be valid and non-empty)" + : "args (must contain at least one valid file element)", + ), + ) + return + } + + // Convert map to array of operations for processing + const operations = Object.values(operationsMap) + + const operationResults: OperationResult[] = operations.map((op) => ({ + path: op.path, + status: "pending", + diffItems: op.diff, + })) + + // Function to update operation result + const updateOperationResult = (path: string, updates: Partial) => { + const index = operationResults.findIndex((result) => result.path === path) + if (index !== -1) { + operationResults[index] = { ...operationResults[index], ...updates } + } + } + + try { + // First validate all files and prepare for batch approval + const operationsToApprove: OperationResult[] = [] + + for (const operation of operations) { + const { path: relPath, diff: diffItems } = operation + + // Verify file access is allowed + const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) + if (!accessAllowed) { + await cline.say("rooignore_error", relPath) + updateOperationResult(relPath, { + status: "blocked", + error: formatResponse.rooIgnoreError(relPath), + }) + continue + } + + // Verify file exists + const absolutePath = path.resolve(cline.cwd, relPath) + const fileExists = await fileExistsAtPath(absolutePath) + if (!fileExists) { + updateOperationResult(relPath, { + status: "blocked", + error: `File does not exist at path: ${absolutePath}`, + }) + continue + } + + // Add to operations that need approval + const opResult = operationResults.find((r) => r.path === relPath) + if (opResult) { + opResult.absolutePath = absolutePath + opResult.fileExists = fileExists + operationsToApprove.push(opResult) + } + } + + // Handle batch approval if there are multiple files + if (operationsToApprove.length > 1) { + // Prepare batch diff data + const batchDiffs = operationsToApprove.map((opResult) => { + const readablePath = getReadablePath(cline.cwd, opResult.path) + const changeCount = opResult.diffItems?.length || 0 + const changeText = changeCount === 1 ? "1 change" : `${changeCount} changes` + + return { + path: readablePath, + changeCount, + key: `${readablePath} (${changeText})`, + content: opResult.path, // Full relative path + diffs: opResult.diffItems?.map((item) => ({ + content: item.content, + startLine: item.startLine, + })), + } + }) + + const completeMessage = JSON.stringify({ + tool: "appliedDiff", + batchDiffs, + } satisfies ClineSayTool) + + const { response, text, images } = await cline.ask("tool", completeMessage, false) + + // Process batch response + if (response === "yesButtonClicked") { + // Approve all files + if (text) { + await cline.say("user_feedback", text, images) + } + operationsToApprove.forEach((opResult) => { + updateOperationResult(opResult.path, { status: "approved" }) + }) + } else if (response === "noButtonClicked") { + // Deny all files + if (text) { + await cline.say("user_feedback", text, images) + } + cline.didRejectTool = true + operationsToApprove.forEach((opResult) => { + updateOperationResult(opResult.path, { + status: "denied", + result: `Changes to ${opResult.path} were not approved by user`, + }) + }) + } else { + // Handle individual permissions from objectResponse + try { + const parsedResponse = JSON.parse(text || "{}") + // Check if this is our batch diff approval response + if (parsedResponse.action === "applyDiff" && parsedResponse.approvedFiles) { + const approvedFiles = parsedResponse.approvedFiles + let hasAnyDenial = false + + operationsToApprove.forEach((opResult) => { + const approved = approvedFiles[opResult.path] === true + + if (approved) { + updateOperationResult(opResult.path, { status: "approved" }) + } else { + hasAnyDenial = true + updateOperationResult(opResult.path, { + status: "denied", + result: `Changes to ${opResult.path} were not approved by user`, + }) + } + }) + + if (hasAnyDenial) { + cline.didRejectTool = true + } + } else { + // Legacy individual permissions format + const individualPermissions = parsedResponse + let hasAnyDenial = false + + batchDiffs.forEach((batchDiff, index) => { + const opResult = operationsToApprove[index] + const approved = individualPermissions[batchDiff.key] === true + + if (approved) { + updateOperationResult(opResult.path, { status: "approved" }) + } else { + hasAnyDenial = true + updateOperationResult(opResult.path, { + status: "denied", + result: `Changes to ${opResult.path} were not approved by user`, + }) + } + }) + + if (hasAnyDenial) { + cline.didRejectTool = true + } + } + } catch (error) { + // Fallback: if JSON parsing fails, deny all files + console.error("Failed to parse individual permissions:", error) + cline.didRejectTool = true + operationsToApprove.forEach((opResult) => { + updateOperationResult(opResult.path, { + status: "denied", + result: `Changes to ${opResult.path} were not approved by user`, + }) + }) + } + } + } else if (operationsToApprove.length === 1) { + // Single file approval - process immediately + const opResult = operationsToApprove[0] + updateOperationResult(opResult.path, { status: "approved" }) + } + + // Process approved operations + const results: string[] = [] + + for (const opResult of operationResults) { + // Skip operations that weren't approved or were blocked + if (opResult.status !== "approved") { + if (opResult.result) { + results.push(opResult.result) + } else if (opResult.error) { + results.push(opResult.error) + } + continue + } + + const relPath = opResult.path + const diffItems = opResult.diffItems || [] + const absolutePath = opResult.absolutePath! + const fileExists = opResult.fileExists! + + try { + let originalContent: string | null = await fs.readFile(absolutePath, "utf-8") + let successCount = 0 + let formattedError = "" + + // Pre-process all diff items for HTML entity unescaping if needed + const processedDiffItems = !cline.api.getModel().id.includes("claude") + ? diffItems.map((item) => ({ + ...item, + content: item.content ? unescapeHtmlEntities(item.content) : item.content, + })) + : diffItems + + // Apply all diffs at once with the array-based method + const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, processedDiffItems)) ?? { + success: false, + error: "No diff strategy available - please ensure a valid diff strategy is configured", + } + + // Release the original content from memory as it's no longer needed + originalContent = null + + if (!diffResult.success) { + cline.consecutiveMistakeCount++ + const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 + cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) + + TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount) + + if (diffResult.failParts && diffResult.failParts.length > 0) { + for (let i = 0; i < diffResult.failParts.length; i++) { + const failPart = diffResult.failParts[i] + if (failPart.success) { + continue + } + + const errorDetails = failPart.details ? JSON.stringify(failPart.details, null, 2) : "" + formattedError += ` +Diff ${i + 1} failed for file: ${relPath} +Error: ${failPart.error} + +Suggested fixes: +1. Verify the search content exactly matches the file content (including whitespace) +2. Check for correct indentation and line endings +3. Use to see the current file content +4. Consider breaking complex changes into smaller diffs +5. Ensure start_line parameter matches the actual content location +${errorDetails ? `\nDetailed error information:\n${errorDetails}\n` : ""} +\n\n` + } + } else { + const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : "" + formattedError += ` +Unable to apply diffs to file: ${absolutePath} +Error: ${diffResult.error} + +Recovery suggestions: +1. Use to examine the current file content +2. Verify the diff format matches the expected search/replace pattern +3. Check that the search content exactly matches what's in the file +4. Consider using line numbers with start_line parameter +5. Break large changes into smaller, more specific diffs +${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} +\n\n` + } + } else { + // Get the content from the result and update success count + originalContent = diffResult.content || originalContent + successCount = diffItems.length - (diffResult.failParts?.length || 0) + } + + // If no diffs were successfully applied, continue to next file + if (successCount === 0) { + if (formattedError) { + const currentCount = cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0 + if (currentCount >= 2) { + await cline.say("diff_error", formattedError) + } + cline.recordToolError("apply_diff", formattedError) + results.push(formattedError) + } + continue + } + + cline.consecutiveMistakeCount = 0 + cline.consecutiveMistakeCountForApplyDiff.delete(relPath) + + // Show diff view before asking for approval (only for single file or after batch approval) + cline.diffViewProvider.editType = "modify" + await cline.diffViewProvider.open(relPath) + await cline.diffViewProvider.update(originalContent!, true) + await cline.diffViewProvider.scrollToFirstDiff() + + // For batch operations, we've already gotten approval + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(cline.cwd, relPath), + } + + // If single file, ask for approval + let didApprove = true + if (operationsToApprove.length === 1) { + const diffContents = diffItems.map((item) => item.content).join("\n\n") + const operationMessage = JSON.stringify({ + ...sharedMessageProps, + diff: diffContents, + } satisfies ClineSayTool) + + let toolProgressStatus + + if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { + toolProgressStatus = cline.diffStrategy.getProgressStatus( + { + ...block, + params: { ...block.params, diff: diffContents }, + }, + { success: true }, + ) + } + + didApprove = await askApproval("tool", operationMessage, toolProgressStatus) + } + + if (!didApprove) { + await cline.diffViewProvider.revertChanges() + results.push(`Changes to ${relPath} were not approved by user`) + continue + } + + // Call saveChanges to update the DiffViewProvider properties + await cline.diffViewProvider.saveChanges() + + // Track file edit operation + await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + + // Used to determine if we should wait for busy terminal to update before sending api request + cline.didEditFile = true + let partFailHint = "" + + if (successCount < diffItems.length) { + partFailHint = `Unable to apply all diff parts to file: ${absolutePath}` + } + + // Get the formatted response message + const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + + if (partFailHint) { + results.push(partFailHint + "\n" + message) + } else { + results.push(message) + } + + await cline.diffViewProvider.reset() + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + updateOperationResult(relPath, { + status: "error", + error: `Error processing ${relPath}: ${errorMsg}`, + }) + results.push(`Error processing ${relPath}: ${errorMsg}`) + } + } + + // Add filtered operation errors to results + if (filteredOperationErrors.length > 0) { + results.push(...filteredOperationErrors) + } + + // Push the final result combining all operation results + pushToolResult(results.join("\n\n")) + return + } catch (error) { + await handleError("applying diff", error) + await cline.diffViewProvider.reset() + return + } +} From f4ca7e72b09b90e3b16245c8156ee4ef166e6a84 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 12 Jun 2025 08:57:41 -0500 Subject: [PATCH 05/13] revert this --- src/core/tools/applyDiffTool.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index 0915631e41..d4f7fd883f 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -89,7 +89,11 @@ export async function applyDiffToolLegacy( let originalContent: string | null = await fs.readFile(absolutePath, "utf-8") // Apply the diff to the original content - const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, diffContent)) ?? { + const diffResult = (await cline.diffStrategy?.applyDiff( + originalContent, + diffContent, + parseInt(block.params.start_line ?? ""), + )) ?? { success: false, error: "No diff strategy available", } From bf9edfac3ed78754738108e3366f493bf0cea88e Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 12 Jun 2025 08:59:02 -0500 Subject: [PATCH 06/13] Update src/core/webview/__tests__/ClineProvider.test.ts --- src/core/webview/__tests__/ClineProvider.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 31710ef732..6ced4989a4 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -19,8 +19,6 @@ import { ClineProvider } from "../ClineProvider" // Mock setup must come before imports jest.mock("../../prompts/sections/custom-instructions") -// Don't mock generateSystemPrompt - let it call through to test the actual implementation - jest.mock("vscode") jest.mock("delay") From 56050e861cd2e6c69cf82336d9b7a33e8e8a1b18 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 12 Jun 2025 09:01:23 -0500 Subject: [PATCH 07/13] revert this --- src/vitest.setup.ts | 462 -------------------------------------------- 1 file changed, 462 deletions(-) diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index 8a0e1b2d59..fd0bce1cf3 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -15,465 +15,3 @@ export function allowNetConnect(host?: string | RegExp) { // Global mocks that many tests expect. global.structuredClone = global.structuredClone || ((obj: any) => JSON.parse(JSON.stringify(obj))) -import { vi } from "vitest" - -// Mock vscode module before any imports -vi.mock("vscode", () => { - // Initialize ThemeIcon class first - class ThemeIcon { - static File: any - static Folder: any - constructor( - public id: string, - public color?: any, - ) {} - } - - const vscode: any = { - env: { - appRoot: "/mock/app/root", - appName: "Mock VS Code", - uriScheme: "vscode", - language: "en", - clipboard: { - readText: vi.fn(), - writeText: vi.fn(), - }, - openExternal: vi.fn(), - asExternalUri: vi.fn(), - uiKind: 1, - }, - window: { - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - createTextEditorDecorationType: vi.fn().mockReturnValue({ - dispose: vi.fn(), - }), - showTextDocument: vi.fn(), - createOutputChannel: vi.fn().mockReturnValue({ - appendLine: vi.fn(), - show: vi.fn(), - clear: vi.fn(), - dispose: vi.fn(), - }), - createWebviewPanel: vi.fn(), - showWarningMessage: vi.fn(), - showQuickPick: vi.fn(), - showInputBox: vi.fn(), - withProgress: vi.fn(), - createStatusBarItem: vi.fn().mockReturnValue({ - show: vi.fn(), - hide: vi.fn(), - dispose: vi.fn(), - }), - activeTextEditor: undefined, - visibleTextEditors: [], - onDidChangeActiveTextEditor: vi.fn(), - onDidChangeVisibleTextEditors: vi.fn(), - onDidChangeTextEditorSelection: vi.fn(), - onDidChangeTextEditorVisibleRanges: vi.fn(), - onDidChangeTextEditorOptions: vi.fn(), - onDidChangeTextEditorViewColumn: vi.fn(), - onDidCloseTerminal: vi.fn(), - state: { - focused: true, - }, - onDidChangeWindowState: vi.fn(), - terminals: [], - onDidOpenTerminal: vi.fn(), - onDidChangeActiveTerminal: vi.fn(), - onDidChangeTerminalState: vi.fn(), - activeTerminal: undefined, - }, - workspace: { - getConfiguration: vi.fn().mockReturnValue({ - get: vi.fn(), - has: vi.fn(), - inspect: vi.fn(), - update: vi.fn(), - }), - workspaceFolders: [], - onDidChangeConfiguration: vi.fn(), - onDidChangeWorkspaceFolders: vi.fn(), - fs: { - readFile: vi.fn(), - writeFile: vi.fn(), - delete: vi.fn(), - createDirectory: vi.fn(), - readDirectory: vi.fn(), - stat: vi.fn(), - rename: vi.fn(), - copy: vi.fn(), - }, - openTextDocument: vi.fn(), - onDidOpenTextDocument: vi.fn(), - onDidCloseTextDocument: vi.fn(), - onDidChangeTextDocument: vi.fn(), - onDidSaveTextDocument: vi.fn(), - onWillSaveTextDocument: vi.fn(), - textDocuments: [], - createFileSystemWatcher: vi.fn().mockReturnValue({ - onDidChange: vi.fn(), - onDidCreate: vi.fn(), - onDidDelete: vi.fn(), - dispose: vi.fn(), - }), - findFiles: vi.fn(), - saveAll: vi.fn(), - applyEdit: vi.fn(), - registerTextDocumentContentProvider: vi.fn(), - registerTaskProvider: vi.fn(), - registerFileSystemProvider: vi.fn(), - rootPath: undefined, - name: undefined, - onDidGrantWorkspaceTrust: vi.fn(), - requestWorkspaceTrust: vi.fn(), - onDidChangeWorkspaceTrust: vi.fn(), - isTrusted: true, - trustOptions: undefined, - workspaceFile: undefined, - getWorkspaceFolder: vi.fn(), - asRelativePath: vi.fn(), - updateWorkspaceFolders: vi.fn(), - openNotebookDocument: vi.fn(), - registerNotebookContentProvider: vi.fn(), - registerFileSearchProvider: vi.fn(), - registerTextSearchProvider: vi.fn(), - onDidCreateFiles: vi.fn(), - onDidDeleteFiles: vi.fn(), - onDidRenameFiles: vi.fn(), - onWillCreateFiles: vi.fn(), - onWillDeleteFiles: vi.fn(), - onWillRenameFiles: vi.fn(), - notebookDocuments: [], - onDidOpenNotebookDocument: vi.fn(), - onDidCloseNotebookDocument: vi.fn(), - onDidChangeNotebookDocument: vi.fn(), - onWillSaveNotebookDocument: vi.fn(), - onDidSaveNotebookDocument: vi.fn(), - onDidChangeNotebookCellExecutionState: vi.fn(), - registerNotebookCellStatusBarItemProvider: vi.fn(), - }, - Uri: { - parse: vi.fn((str) => ({ fsPath: str, toString: () => str })), - file: vi.fn((path) => ({ fsPath: path, toString: () => path })), - joinPath: vi.fn(), - }, - Position: class { - constructor( - public line: number, - public character: number, - ) {} - }, - Range: class { - constructor( - public start: { line: number; character: number } | number, - public end?: { line: number; character: number } | number, - public endLine?: number, - public endCharacter?: number, - ) { - if (typeof start === "number") { - // Handle constructor(startLine, startCharacter, endLine, endCharacter) - this.start = { line: start, character: end as number } - this.end = { line: endLine!, character: endCharacter! } - } - } - }, - Location: class { - constructor( - public uri: any, - public range: any, - ) {} - }, - Selection: class { - constructor( - public anchor: { line: number; character: number }, - public active: { line: number; character: number }, - ) {} - }, - TextEdit: { - insert: vi.fn(), - delete: vi.fn(), - replace: vi.fn(), - setEndOfLine: vi.fn(), - }, - WorkspaceEdit: class { - set = vi.fn() - replace = vi.fn() - insert = vi.fn() - delete = vi.fn() - has = vi.fn() - entries = vi.fn() - renameFile = vi.fn() - createFile = vi.fn() - deleteFile = vi.fn() - }, - commands: { - executeCommand: vi.fn(), - registerCommand: vi.fn(), - registerTextEditorCommand: vi.fn(), - getCommands: vi.fn(), - }, - languages: { - registerCompletionItemProvider: vi.fn(), - registerCodeActionsProvider: vi.fn(), - registerCodeLensProvider: vi.fn(), - registerDefinitionProvider: vi.fn(), - registerImplementationProvider: vi.fn(), - registerTypeDefinitionProvider: vi.fn(), - registerHoverProvider: vi.fn(), - registerDocumentHighlightProvider: vi.fn(), - registerReferenceProvider: vi.fn(), - registerRenameProvider: vi.fn(), - registerDocumentSymbolProvider: vi.fn(), - registerDocumentFormattingEditProvider: vi.fn(), - registerDocumentRangeFormattingEditProvider: vi.fn(), - registerOnTypeFormattingEditProvider: vi.fn(), - registerSignatureHelpProvider: vi.fn(), - registerDocumentLinkProvider: vi.fn(), - registerColorProvider: vi.fn(), - registerFoldingRangeProvider: vi.fn(), - registerDeclarationProvider: vi.fn(), - registerSelectionRangeProvider: vi.fn(), - registerCallHierarchyProvider: vi.fn(), - registerLinkedEditingRangeProvider: vi.fn(), - registerInlayHintsProvider: vi.fn(), - registerDocumentSemanticTokensProvider: vi.fn(), - registerDocumentRangeSemanticTokensProvider: vi.fn(), - registerEvaluatableExpressionProvider: vi.fn(), - registerInlineValuesProvider: vi.fn(), - registerWorkspaceSymbolProvider: vi.fn(), - registerDocumentDropEditProvider: vi.fn(), - registerDocumentPasteEditProvider: vi.fn(), - setLanguageConfiguration: vi.fn(), - onDidChangeDiagnostics: vi.fn(), - getDiagnostics: vi.fn(), - createDiagnosticCollection: vi.fn(), - getLanguages: vi.fn(), - setTextDocumentLanguage: vi.fn(), - match: vi.fn(), - onDidEncounterLanguage: vi.fn(), - registerInlineCompletionItemProvider: vi.fn(), - }, - extensions: { - getExtension: vi.fn(), - onDidChange: vi.fn(), - all: [], - }, - EventEmitter: class { - event = vi.fn() - fire = vi.fn() - dispose = vi.fn() - }, - CancellationTokenSource: class { - token = { isCancellationRequested: false, onCancellationRequested: vi.fn() } - cancel = vi.fn() - dispose = vi.fn() - }, - Disposable: class { - static from = vi.fn() - constructor(public dispose: () => void) {} - }, - StatusBarAlignment: { - Left: 1, - Right: 2, - }, - ConfigurationTarget: { - Global: 1, - Workspace: 2, - WorkspaceFolder: 3, - }, - RelativePattern: class { - constructor( - public base: string, - public pattern: string, - ) {} - }, - ProgressLocation: { - SourceControl: 1, - Window: 10, - Notification: 15, - }, - ViewColumn: { - Active: -1, - Beside: -2, - One: 1, - Two: 2, - Three: 3, - Four: 4, - Five: 5, - Six: 6, - Seven: 7, - Eight: 8, - Nine: 9, - }, - TextDocumentSaveReason: { - Manual: 1, - AfterDelay: 2, - FocusOut: 3, - }, - TextEditorRevealType: { - Default: 0, - InCenter: 1, - InCenterIfOutsideViewport: 2, - AtTop: 3, - }, - OverviewRulerLane: { - Left: 1, - Center: 2, - Right: 4, - Full: 7, - }, - DecorationRangeBehavior: { - OpenOpen: 0, - ClosedClosed: 1, - OpenClosed: 2, - ClosedOpen: 3, - }, - MarkdownString: class { - constructor( - public value: string, - public supportThemeIcons?: boolean, - ) {} - isTrusted = false - supportHtml = false - baseUri: any - appendText = vi.fn() - appendMarkdown = vi.fn() - appendCodeblock = vi.fn() - }, - ThemeColor: class { - constructor(public id: string) {} - }, - ThemeIcon: ThemeIcon, - TreeItem: class { - constructor( - public label: string, - public collapsibleState?: number, - ) {} - id?: string - iconPath?: any - description?: string - contextValue?: string - command?: any - tooltip?: string | any - accessibilityInformation?: any - checkboxState?: any - }, - TreeItemCollapsibleState: { - None: 0, - Collapsed: 1, - Expanded: 2, - }, - ExtensionKind: { - UI: 1, - Workspace: 2, - }, - ExtensionMode: { - Production: 1, - Development: 2, - Test: 3, - }, - EnvironmentVariableMutatorType: { - Replace: 1, - Append: 2, - Prepend: 3, - }, - UIKind: { - Desktop: 1, - Web: 2, - }, - FileType: { - Unknown: 0, - File: 1, - Directory: 2, - SymbolicLink: 64, - }, - FilePermission: { - Readonly: 1, - }, - FileChangeType: { - Changed: 1, - Created: 2, - Deleted: 3, - }, - GlobPattern: class {}, - } - - // Set static properties after vscode is defined - ThemeIcon.File = new ThemeIcon("file") - ThemeIcon.Folder = new ThemeIcon("folder") - - return vscode -}) - -// Mock other modules that might be needed -vi.mock("../utils/logging", () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - fatal: vi.fn(), - child: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - fatal: vi.fn(), - }), - }, -})) - -// Mock TelemetryService -vi.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - track: vi.fn(), - trackEvent: vi.fn(), - trackError: vi.fn(), - trackPerformance: vi.fn(), - flush: vi.fn(), - dispose: vi.fn(), - captureTaskCreated: vi.fn(), - captureTaskRestarted: vi.fn(), - captureTaskCompleted: vi.fn(), - captureTaskCancelled: vi.fn(), - captureTaskFailed: vi.fn(), - captureToolUse: vi.fn(), - captureApiRequest: vi.fn(), - captureApiResponse: vi.fn(), - captureApiError: vi.fn(), - }, - initialize: vi.fn(), - }, - BaseTelemetryClient: class { - track = vi.fn() - trackEvent = vi.fn() - trackError = vi.fn() - trackPerformance = vi.fn() - flush = vi.fn() - dispose = vi.fn() - }, -})) - -// Add toPosix method to String prototype -declare global { - interface String { - toPosix(): string - } -} - -function toPosixPath(p: string) { - const isExtendedLengthPath = p.startsWith("\\\\?\\") - if (isExtendedLengthPath) { - return p - } - return p.replace(/\\/g, "/") -} - -if (!String.prototype.toPosix) { - String.prototype.toPosix = function (this: string): string { - return toPosixPath(this) - } -} From 986b3d2bdffddc7655ca13a2753fdb036b6dada0 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 12 Jun 2025 09:19:44 -0500 Subject: [PATCH 08/13] fix: keep the original path if the experiment is disabled --- .../presentAssistantMessage.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7a8e215af1..be24a63d2e 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -31,7 +31,7 @@ import { formatResponse } from "../prompts/responses" import { validateToolUse } from "../tools/validateToolUse" import { Task } from "../task/Task" import { codebaseSearchTool } from "../tools/codebaseSearchTool" -import { experiments } from "../../shared/experiments" +import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" import { applyDiffToolLegacy } from "../tools/applyDiffTool" /** @@ -386,8 +386,20 @@ export async function presentAssistantMessage(cline: Task) { case "write_to_file": await writeToFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break - case "apply_diff": - if (experiments.get("MULTI_FILE_APPLY_DIFF")?.enabled) { + case "apply_diff": { + // Get the provider and state to check experiment settings + const provider = cline.providerRef.deref() + let isMultiFileApplyDiffEnabled = false + + if (provider) { + const state = await provider.getState() + isMultiFileApplyDiffEnabled = experiments.isEnabled( + state.experiments ?? {}, + EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, + ) + } + + if (isMultiFileApplyDiffEnabled) { await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) } else { await applyDiffToolLegacy( @@ -400,6 +412,7 @@ export async function presentAssistantMessage(cline: Task) { ) } break + } case "insert_content": await insertContentTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break From 5eb1ebe6c78e1212c57b026105c9e52de9d441c2 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 12 Jun 2025 09:42:30 -0500 Subject: [PATCH 09/13] test: add dynamic strategy selection tests for MultiSearchReplaceDiffStrategy and MultiFileSearchReplaceDiffStrategy --- src/core/task/__tests__/Task.strategy.spec.ts | 107 ------------------ src/core/task/__tests__/Task.test.ts | 105 +++++++++++++++++ 2 files changed, 105 insertions(+), 107 deletions(-) delete mode 100644 src/core/task/__tests__/Task.strategy.spec.ts diff --git a/src/core/task/__tests__/Task.strategy.spec.ts b/src/core/task/__tests__/Task.strategy.spec.ts deleted file mode 100644 index fbe73a6d65..0000000000 --- a/src/core/task/__tests__/Task.strategy.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { Task } from "../Task" -import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" -import { MultiFileSearchReplaceDiffStrategy } from "../../diff/strategies/multi-file-search-replace" -import { EXPERIMENT_IDS } from "../../../shared/experiments" - -describe("Task - Dynamic Strategy Selection", () => { - let mockProvider: any - let mockApiConfig: any - - beforeEach(() => { - vi.clearAllMocks() - - mockApiConfig = { - apiProvider: "anthropic", - apiKey: "test-key", - } - - mockProvider = { - context: { - globalStorageUri: { fsPath: "/test/storage" }, - }, - getState: vi.fn(), - } - }) - - it("should use MultiSearchReplaceDiffStrategy by default", async () => { - mockProvider.getState.mockResolvedValue({ - experiments: { - [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: false, - }, - }) - - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - enableDiff: true, - task: "test task", - startTask: false, - }) - - // Initially should be MultiSearchReplaceDiffStrategy - expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) - expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace") - }) - - it("should switch to MultiFileSearchReplaceDiffStrategy when experiment is enabled", async () => { - mockProvider.getState.mockResolvedValue({ - experiments: { - [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, - }, - }) - - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - enableDiff: true, - task: "test task", - startTask: false, - }) - - // Initially should be MultiSearchReplaceDiffStrategy - expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) - - // Wait for async strategy update - await new Promise((resolve) => setTimeout(resolve, 10)) - - // Should have switched to MultiFileSearchReplaceDiffStrategy - expect(task.diffStrategy).toBeInstanceOf(MultiFileSearchReplaceDiffStrategy) - expect(task.diffStrategy?.getName()).toBe("MultiFileSearchReplace") - }) - - it("should keep MultiSearchReplaceDiffStrategy when experiments are undefined", async () => { - mockProvider.getState.mockResolvedValue({}) - - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - enableDiff: true, - task: "test task", - startTask: false, - }) - - // Initially should be MultiSearchReplaceDiffStrategy - expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) - - // Wait for async strategy update - await new Promise((resolve) => setTimeout(resolve, 10)) - - // Should still be MultiSearchReplaceDiffStrategy - expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) - expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace") - }) - - it("should not create diff strategy when enableDiff is false", async () => { - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - enableDiff: false, - task: "test task", - startTask: false, - }) - - expect(task.diffEnabled).toBe(false) - expect(task.diffStrategy).toBeUndefined() - }) -}) diff --git a/src/core/task/__tests__/Task.test.ts b/src/core/task/__tests__/Task.test.ts index 8ed57ffcb3..3695a7bd47 100644 --- a/src/core/task/__tests__/Task.test.ts +++ b/src/core/task/__tests__/Task.test.ts @@ -14,6 +14,9 @@ import { ClineProvider } from "../../webview/ClineProvider" import { ApiStreamChunk } from "../../../api/transform/stream" import { ContextProxy } from "../../config/ContextProxy" import { processUserContentMentions } from "../../mentions/processUserContentMentions" +import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" +import { MultiFileSearchReplaceDiffStrategy } from "../../diff/strategies/multi-file-search-replace" +import { EXPERIMENT_IDS } from "../../../shared/experiments" jest.mock("execa", () => ({ execa: jest.fn(), @@ -855,5 +858,107 @@ describe("Cline", () => { }) }) }) + + describe("Dynamic Strategy Selection", () => { + let mockProvider: any + let mockApiConfig: any + + beforeEach(() => { + jest.clearAllMocks() + + mockApiConfig = { + apiProvider: "anthropic", + apiKey: "test-key", + } + + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/storage" }, + }, + getState: jest.fn(), + } + }) + + it("should use MultiSearchReplaceDiffStrategy by default", async () => { + mockProvider.getState.mockResolvedValue({ + experiments: { + [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: false, + }, + }) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + enableDiff: true, + task: "test task", + startTask: false, + }) + + // Initially should be MultiSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) + expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace") + }) + + it("should switch to MultiFileSearchReplaceDiffStrategy when experiment is enabled", async () => { + mockProvider.getState.mockResolvedValue({ + experiments: { + [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, + }, + }) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + enableDiff: true, + task: "test task", + startTask: false, + }) + + // Initially should be MultiSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) + + // Wait for async strategy update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Should have switched to MultiFileSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiFileSearchReplaceDiffStrategy) + expect(task.diffStrategy?.getName()).toBe("MultiFileSearchReplace") + }) + + it("should keep MultiSearchReplaceDiffStrategy when experiments are undefined", async () => { + mockProvider.getState.mockResolvedValue({}) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + enableDiff: true, + task: "test task", + startTask: false, + }) + + // Initially should be MultiSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) + + // Wait for async strategy update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Should still be MultiSearchReplaceDiffStrategy + expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) + expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace") + }) + + it("should not create diff strategy when enableDiff is false", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + enableDiff: false, + task: "test task", + startTask: false, + }) + + expect(task.diffEnabled).toBe(false) + expect(task.diffStrategy).toBeUndefined() + }) + }) }) }) From b7e3202f06a9454ea59403a2c88dbfbb3335f7b4 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 12 Jun 2025 09:57:48 -0500 Subject: [PATCH 10/13] fix: mock applyDiffTool module and ensure legacy tool resolves successfully in tests --- .../applyDiffTool.experiment.spec.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index eaea7625c8..30a37a4e96 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -1,10 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { applyDiffTool } from "../multiApplyDiffTool" -import { applyDiffToolLegacy } from "../applyDiffTool" -import { Task } from "../../task/Task" import { EXPERIMENT_IDS, experiments } from "../../../shared/experiments" -vi.mock("../applyDiffToolLegacy") +// Mock the applyDiffTool module +vi.mock("../applyDiffTool", () => ({ + applyDiffToolLegacy: vi.fn(), +})) + +// Import after mocking to get the mocked version +import { applyDiffToolLegacy } from "../applyDiffTool" describe("applyDiffTool experiment routing", () => { let mockCline: any @@ -34,6 +38,9 @@ describe("applyDiffTool experiment routing", () => { diffViewProvider: { reset: vi.fn(), }, + api: { + getModel: vi.fn().mockReturnValue({ id: "test-model" }), + }, } as any mockBlock = { @@ -57,6 +64,9 @@ describe("applyDiffTool experiment routing", () => { }, }) + // Mock the legacy tool to resolve successfully + ;(applyDiffToolLegacy as any).mockResolvedValue(undefined) + await applyDiffTool( mockCline, mockBlock, @@ -79,6 +89,9 @@ describe("applyDiffTool experiment routing", () => { it("should use legacy tool when experiments are not defined", async () => { mockProvider.getState.mockResolvedValue({}) + // Mock the legacy tool to resolve successfully + ;(applyDiffToolLegacy as any).mockResolvedValue(undefined) + await applyDiffTool( mockCline, mockBlock, From 110f1e87dd9dd61b717d75f3ba92d80d24c937bf Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:15:43 -0500 Subject: [PATCH 11/13] remove this --- src/core/tools/multiApplyDiffTool.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/tools/multiApplyDiffTool.ts b/src/core/tools/multiApplyDiffTool.ts index 1ce29f3d3e..ba36cd3759 100644 --- a/src/core/tools/multiApplyDiffTool.ts +++ b/src/core/tools/multiApplyDiffTool.ts @@ -162,7 +162,6 @@ Original error: ${errorMessage}` throw new Error(detailedError) } - // Remove this duplicate check - we already checked at the beginning } else if (legacyPath && typeof legacyDiffContent === "string") { // Handle legacy parameters (old way) usingLegacyParams = true From 8905bf26f17eb7256bf9ee092378a850dcdf6cc2 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:17:01 -0500 Subject: [PATCH 12/13] ellipsis suggestion Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- webview-ui/src/i18n/locales/zh-CN/chat.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index e396d33827..3841abd499 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -153,7 +153,7 @@ "wantsToInsertAtEnd": "需要在文件末尾添加内容:", "wantsToReadAndXMore": "Roo 想读取此文件以及另外 {{count}} 个文件:", "wantsToReadMultiple": "Roo 想要读取多个文件:", - "wantsToApplyBatchChanges": "Roo想要对多个文件应用更改:" + "wantsToApplyBatchChanges": "Roo 想要对多个文件应用更改:" }, "directoryOperations": { "wantsToViewTopLevel": "需要查看目录文件列表:", From b326ea04050166d3060b00af154d43065d6d76af Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 12 Jun 2025 10:46:30 -0500 Subject: [PATCH 13/13] refactor: mirror concurrent file reads --- src/shared/experiments.ts | 4 ++-- webview-ui/src/i18n/locales/ca/settings.json | 4 ++-- webview-ui/src/i18n/locales/de/settings.json | 4 ++-- webview-ui/src/i18n/locales/en/settings.json | 4 ++-- webview-ui/src/i18n/locales/es/settings.json | 4 ++-- webview-ui/src/i18n/locales/fr/settings.json | 4 ++-- webview-ui/src/i18n/locales/hi/settings.json | 4 ++-- webview-ui/src/i18n/locales/it/settings.json | 4 ++-- webview-ui/src/i18n/locales/ja/settings.json | 4 ++-- webview-ui/src/i18n/locales/ko/settings.json | 4 ++-- webview-ui/src/i18n/locales/nl/settings.json | 4 ++-- webview-ui/src/i18n/locales/pl/settings.json | 4 ++-- webview-ui/src/i18n/locales/pt-BR/settings.json | 4 ++-- webview-ui/src/i18n/locales/ru/settings.json | 4 ++-- webview-ui/src/i18n/locales/tr/settings.json | 4 ++-- webview-ui/src/i18n/locales/vi/settings.json | 4 ++-- webview-ui/src/i18n/locales/zh-CN/settings.json | 4 ++-- webview-ui/src/i18n/locales/zh-TW/settings.json | 4 ++-- 18 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 7fe0239208..f6c387d480 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -3,9 +3,9 @@ import type { AssertEqual, Equals, Keys, Values, ExperimentId } from "@roo-code/ export const EXPERIMENT_IDS = { MARKETPLACE: "marketplace", CONCURRENT_FILE_READS: "concurrentFileReads", + MULTI_FILE_APPLY_DIFF: "multiFileApplyDiff", DISABLE_COMPLETION_COMMAND: "disableCompletionCommand", POWER_STEERING: "powerSteering", - MULTI_FILE_APPLY_DIFF: "multiFileApplyDiff", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -19,9 +19,9 @@ interface ExperimentConfig { export const experimentConfigsMap: Record = { MARKETPLACE: { enabled: false }, CONCURRENT_FILE_READS: { enabled: false }, + MULTI_FILE_APPLY_DIFF: { enabled: false }, DISABLE_COMPLETION_COMMAND: { enabled: false }, POWER_STEERING: { enabled: false }, - MULTI_FILE_APPLY_DIFF: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 51d0e9589c..edadf729ed 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -502,8 +502,8 @@ "description": "Quan està activat, l'eina attempt_completion no executarà comandes. Aquesta és una característica experimental per preparar la futura eliminació de l'execució de comandes en la finalització de tasques." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Habilita operacions de diff multi-rang", - "description": "Permet a Roo aplicar canvis a múltiples fitxers i múltiples rangs dins dels fitxers en una sola petició. Aquesta funció avançada pot aclaparar models menys capaços i s'ha d'usar amb precaució." + "name": "Habilita edicions de fitxers concurrents", + "description": "Quan està activat, Roo pot editar múltiples fitxers en una sola petició. Quan està desactivat, Roo ha d'editar fitxers d'un en un. Desactivar això pot ajudar quan es treballa amb models menys capaços o quan vols més control sobre les modificacions de fitxers." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 56203c920a..6597d8f717 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -502,8 +502,8 @@ "description": "Wenn aktiviert, führt das Tool attempt_completion keine Befehle aus. Dies ist eine experimentelle Funktion, um die Abschaffung der Befehlsausführung bei Aufgabenabschluss vorzubereiten." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Multi-Range-Diff-Operationen aktivieren", - "description": "Erlaubt Roo, Änderungen an mehreren Dateien und mehreren Bereichen innerhalb von Dateien in einer einzigen Anfrage anzuwenden. Diese erweiterte Funktion kann weniger fähige Modelle überlasten und sollte mit Vorsicht verwendet werden." + "name": "Gleichzeitige Dateibearbeitungen aktivieren", + "description": "Wenn aktiviert, kann Roo mehrere Dateien in einer einzigen Anfrage bearbeiten. Wenn deaktiviert, muss Roo Dateien einzeln bearbeiten. Das Deaktivieren kann helfen, wenn du mit weniger fähigen Modellen arbeitest oder mehr Kontrolle über Dateiänderungen haben möchtest." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 2dbf163110..d1c30270cf 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -502,8 +502,8 @@ "description": "When enabled, the attempt_completion tool will not execute commands. This is an experimental feature to prepare for deprecating command execution in task completion." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Enable multi-range diff operations", - "description": "Allow Roo to apply changes to multiple files and multiple ranges within files in a single request. This advanced feature may overwhelm less capable models and should be used with caution." + "name": "Enable concurrent file edits", + "description": "When enabled, Roo can edit multiple files in a single request. When disabled, Roo must edit files one at a time. Disabling this can help when working with less capable models or when you want more control over file modifications." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index c778159f9f..c9a356e4cd 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -502,8 +502,8 @@ "description": "Cuando está activado, la herramienta attempt_completion no ejecutará comandos. Esta es una función experimental para preparar la futura eliminación de la ejecución de comandos en la finalización de tareas." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Habilitar operaciones de diff multi-rango", - "description": "Permite a Roo aplicar cambios a múltiples archivos y múltiples rangos dentro de archivos en una sola solicitud. Esta función avanzada puede abrumar a modelos menos capaces y debe usarse con precaución." + "name": "Habilitar ediciones de archivos concurrentes", + "description": "Cuando está habilitado, Roo puede editar múltiples archivos en una sola solicitud. Cuando está deshabilitado, Roo debe editar archivos de uno en uno. Deshabilitar esto puede ayudar cuando trabajas con modelos menos capaces o cuando quieres más control sobre las modificaciones de archivos." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 8573474111..430c879396 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -502,8 +502,8 @@ "description": "Lorsque cette option est activée, l'outil attempt_completion n'exécutera pas de commandes. Il s'agit d'une fonctionnalité expérimentale visant à préparer la dépréciation de l'exécution des commandes lors de la finalisation des tâches." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Activer les opérations de diff multi-plages", - "description": "Permet à Roo d'appliquer des modifications à plusieurs fichiers et à plusieurs plages dans les fichiers en une seule requête. Cette fonctionnalité avancée peut submerger les modèles moins capables et doit être utilisée avec prudence." + "name": "Activer les éditions de fichiers concurrentes", + "description": "Lorsque cette option est activée, Roo peut éditer plusieurs fichiers en une seule requête. Lorsqu'elle est désactivée, Roo doit éditer les fichiers un par un. Désactiver cette option peut aider lorsque tu travailles avec des modèles moins capables ou lorsque tu veux plus de contrôle sur les modifications de fichiers." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 8739469270..31ab2191d5 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -502,8 +502,8 @@ "description": "जब सक्षम किया जाता है, तो attempt_completion टूल कमांड निष्पादित नहीं करेगा। यह कार्य पूर्ण होने पर कमांड निष्पादन को पदावनत करने की तैयारी के लिए एक प्रयोगात्मक सुविधा है।" }, "MULTI_FILE_APPLY_DIFF": { - "name": "मल्टी-रेंज डिफ़ ऑपरेशन सक्षम करें", - "description": "Roo को एक ही अनुरोध में कई फ़ाइलों और फ़ाइलों के भीतर कई रेंज में परिवर्तन लागू करने की अनुमति दें। यह उन्नत सुविधा कम सक्षम मॉडल को अभिभूत कर सकती है और इसे सावधानी के साथ उपयोग किया जाना चाहिए।" + "name": "समानांतर फ़ाइल संपादन सक्षम करें", + "description": "जब सक्षम किया जाता है, तो Roo एक ही अनुरोध में कई फ़ाइलों को संपादित कर सकता है। जब अक्षम किया जाता है, तो Roo को एक समय में एक फ़ाइल संपादित करनी होगी। इसे अक्षम करना तब मदद कर सकता है जब आप कम सक्षम मॉडल के साथ काम कर रहे हों या जब आप फ़ाइल संशोधनों पर अधिक नियंत्रण चाहते हों।" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 226c93a551..51d1dd640d 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -502,8 +502,8 @@ "description": "Se abilitato, lo strumento attempt_completion non eseguirà comandi. Questa è una funzionalità sperimentale per preparare la futura deprecazione dell'esecuzione dei comandi al completamento dell'attività." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Abilita operazioni diff multi-range", - "description": "Consente a Roo di applicare modifiche a più file e più range all'interno dei file in una singola richiesta. Questa funzionalità avanzata può sopraffare modelli meno capaci e dovrebbe essere usata con cautela." + "name": "Abilita modifiche di file concorrenti", + "description": "Quando abilitato, Roo può modificare più file in una singola richiesta. Quando disabilitato, Roo deve modificare i file uno alla volta. Disabilitare questa opzione può aiutare quando lavori con modelli meno capaci o quando vuoi più controllo sulle modifiche dei file." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index cc984c107b..93cbe43a6e 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -502,8 +502,8 @@ "description": "有効にすると、attempt_completionツールはコマンドを実行しません。これは、タスク完了時のコマンド実行の非推奨化に備えるための実験的な機能です。" }, "MULTI_FILE_APPLY_DIFF": { - "name": "マルチレンジdiff操作を有効にする", - "description": "Rooが単一のリクエストで複数のファイルおよびファイル内の複数の範囲に変更を適用できるようにします。この高度な機能は、あまり優秀でないモデルを圧倒する可能性があるため、注意して使用する必要があります。" + "name": "同時ファイル編集を有効にする", + "description": "有効にすると、Rooは単一のリクエストで複数のファイルを編集できます。無効にすると、Rooはファイルを一つずつ編集する必要があります。これを無効にすることで、能力の低いモデルで作業する場合や、ファイル変更をより細かく制御したい場合に役立ちます。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index cad4229a49..a7ec710f23 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -502,8 +502,8 @@ "description": "활성화하면 attempt_completion 도구가 명령을 실행하지 않습니다. 이는 작업 완료 시 명령 실행을 더 이상 사용하지 않도록 준비하기 위한 실험적 기능입니다." }, "MULTI_FILE_APPLY_DIFF": { - "name": "멀티 범위 diff 작업 활성화", - "description": "Roo가 단일 요청으로 여러 파일과 파일 내 여러 범위에 변경사항을 적용할 수 있게 합니다. 이 고급 기능은 덜 강력한 모델을 압도할 수 있으므로 주의해서 사용해야 합니다." + "name": "동시 파일 편집 활성화", + "description": "활성화하면 Roo가 단일 요청으로 여러 파일을 편집할 수 있습니다. 비활성화하면 Roo는 파일을 하나씩 편집해야 합니다. 이 기능을 비활성화하면 덜 강력한 모델로 작업하거나 파일 수정에 대한 더 많은 제어가 필요할 때 도움이 됩니다." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index c3c75bdc2d..8ec7bb35c4 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -502,8 +502,8 @@ "description": "Indien ingeschakeld, zal de attempt_completion tool geen commando's uitvoeren. Dit is een experimentele functie ter voorbereiding op het afschaffen van commando-uitvoering bij taakvoltooiing." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Multi-range diff bewerkingen inschakelen", - "description": "Laat Roo toe om wijzigingen toe te passen op meerdere bestanden en meerdere bereiken binnen bestanden in één verzoek. Deze geavanceerde functie kan minder capabele modellen overweldigen en moet met voorzichtigheid worden gebruikt." + "name": "Gelijktijdige bestandsbewerkingen inschakelen", + "description": "Wanneer ingeschakeld, kan Roo meerdere bestanden in één verzoek bewerken. Wanneer uitgeschakeld, moet Roo bestanden één voor één bewerken. Het uitschakelen hiervan kan helpen wanneer je werkt met minder capabele modellen of wanneer je meer controle wilt over bestandswijzigingen." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 4e13f952b7..aade54b772 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -502,8 +502,8 @@ "description": "Gdy włączone, możesz instalować MCP i niestandardowe tryby z Marketplace." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Włącz operacje diff wielozakresowe", - "description": "Pozwala Roo na zastosowanie zmian w wielu plikach i wielu zakresach w plikach w jednym żądaniu. Ta zaawansowana funkcja może przytłoczyć mniej zdolne modele i powinna być używana z ostrożnością." + "name": "Włącz równoczesne edycje plików", + "description": "Gdy włączone, Roo może edytować wiele plików w jednym żądaniu. Gdy wyłączone, Roo musi edytować pliki jeden po drugim. Wyłączenie tego może pomóc podczas pracy z mniej zdolnymi modelami lub gdy chcesz mieć większą kontrolę nad modyfikacjami plików." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index f891e84724..8e1a5af79a 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -502,8 +502,8 @@ "description": "Quando ativado, você pode instalar MCPs e modos personalizados do Marketplace." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Habilitar operações de diff multi-range", - "description": "Permite que o Roo aplique alterações em múltiplos arquivos e múltiplas faixas dentro de arquivos em uma única solicitação. Este recurso avançado pode sobrecarregar modelos menos capazes e deve ser usado com cautela." + "name": "Habilitar edições de arquivos concorrentes", + "description": "Quando habilitado, o Roo pode editar múltiplos arquivos em uma única solicitação. Quando desabilitado, o Roo deve editar arquivos um de cada vez. Desabilitar isso pode ajudar ao trabalhar com modelos menos capazes ou quando você quer mais controle sobre modificações de arquivos." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 5aa716b323..acf9235253 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -502,8 +502,8 @@ "description": "Когда включено, вы можете устанавливать MCP и пользовательские режимы из Marketplace." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Включить операции многодиапазонного diff", - "description": "Позволяет Roo применять изменения к нескольким файлам и нескольким диапазонам внутри файлов в одном запросе. Эта продвинутая функция может перегрузить менее способные модели и должна использоваться с осторожностью." + "name": "Включить одновременное редактирование файлов", + "description": "Когда включено, Roo может редактировать несколько файлов в одном запросе. Когда отключено, Roo должен редактировать файлы по одному. Отключение этой функции может помочь при работе с менее способными моделями или когда вы хотите больше контроля над изменениями файлов." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index f727184e1b..2445ea6c91 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -502,8 +502,8 @@ "description": "Etkinleştirildiğinde, attempt_completion aracı komutları yürütmez. Bu, görev tamamlandığında komut yürütmenin kullanımdan kaldırılmasına hazırlanmak için deneysel bir özelliktir." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Çok aralıklı diff işlemlerini etkinleştir", - "description": "Roo'nun tek bir istekte birden fazla dosya ve dosyalar içindeki birden fazla aralığa değişiklik uygulamasına izin verir. Bu gelişmiş özellik daha az yetenekli modelleri bunaltabilir ve dikkatli kullanılmalıdır." + "name": "Eşzamanlı dosya düzenlemelerini etkinleştir", + "description": "Etkinleştirildiğinde, Roo tek bir istekte birden fazla dosyayı düzenleyebilir. Devre dışı bırakıldığında, Roo dosyaları tek tek düzenlemek zorundadır. Bunu devre dışı bırakmak, daha az yetenekli modellerle çalışırken veya dosya değişiklikleri üzerinde daha fazla kontrol istediğinde yardımcı olabilir." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 7af0678edb..9dc39075c7 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -502,8 +502,8 @@ "description": "Khi được bật, công cụ attempt_completion sẽ không thực thi lệnh. Đây là một tính năng thử nghiệm để chuẩn bị cho việc ngừng hỗ trợ thực thi lệnh khi hoàn thành tác vụ trong tương lai." }, "MULTI_FILE_APPLY_DIFF": { - "name": "Bật các thao tác diff đa phạm vi", - "description": "Cho phép Roo áp dụng thay đổi cho nhiều tệp và nhiều phạm vi trong các tệp trong một yêu cầu duy nhất. Tính năng nâng cao này có thể làm choáng ngợp các mô hình kém khả năng hơn và nên được sử dụng một cách thận trọng." + "name": "Bật chỉnh sửa tệp đồng thời", + "description": "Khi được bật, Roo có thể chỉnh sửa nhiều tệp trong một yêu cầu duy nhất. Khi bị tắt, Roo phải chỉnh sửa từng tệp một. Tắt tính năng này có thể hữu ích khi làm việc với các mô hình kém khả năng hơn hoặc khi bạn muốn kiểm soát nhiều hơn đối với các thay đổi tệp." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 9de27a8ada..be8f221a81 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -502,8 +502,8 @@ "description": "启用后,attempt_completion 工具将不会执行命令。这是一项实验性功能,旨在为将来弃用任务完成时的命令执行做准备。" }, "MULTI_FILE_APPLY_DIFF": { - "name": "启用多范围diff操作", - "description": "允许Roo在单个请求中对多个文件和文件内的多个范围应用更改。这个高级功能可能会让能力较弱的模型不堪重负,应谨慎使用。" + "name": "启用并发文件编辑", + "description": "启用后 Roo 可在单个请求中编辑多个文件。禁用后 Roo 必须逐个编辑文件。禁用此功能有助于使用能力较弱的模型或需要更精确控制文件修改时。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 2e9c15470f..901ec23416 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -502,8 +502,8 @@ "description": "啟用後,attempt_completion 工具將不會執行指令。這是一項實驗性功能,旨在為未來停用工作完成時的指令執行做準備。" }, "MULTI_FILE_APPLY_DIFF": { - "name": "啟用多範圍diff操作", - "description": "允許Roo在單個請求中對多個檔案和檔案內的多個範圍應用變更。這個進階功能可能會讓能力較弱的模型不堪重負,應謹慎使用。" + "name": "啟用並行檔案編輯", + "description": "啟用後 Roo 可在單個請求中編輯多個檔案。停用後 Roo 必須逐個編輯檔案。停用此功能有助於使用能力較弱的模型或需要更精確控制檔案修改時。" } }, "promptCaching": {