feat: Enhance apply_diff with XML for multi-file/multi-diff operations & batch UI (#3342)

* 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.

* revert this

* fix: update applyDiff parameter type to accept string or DiffItem

* refactor: keep original file name for apply diff tool

* revert this

* Update src/core/webview/__tests__/ClineProvider.test.ts

* revert this

* fix: keep the original path if the experiment is disabled

* test: add dynamic strategy selection tests for MultiSearchReplaceDiffStrategy and MultiFileSearchReplaceDiffStrategy

* fix: mock applyDiffTool module and ensure legacy tool resolves successfully in tests

* remove this

* ellipsis suggestion

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* refactor: mirror concurrent file reads

---------

Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Sam Hoang Van 2025-06-12 23:36:34 +07:00 committed by GitHub
parent 8b6f5f8baa
commit 7bed94454a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1855 additions and 32 deletions

View file

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

View file

@ -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, EXPERIMENT_IDS } from "../../shared/experiments"
import { applyDiffToolLegacy } from "../tools/applyDiffTool"
/**
* Processes and presents assistant message content to the user interface.
@ -384,9 +386,33 @@ export async function presentAssistantMessage(cline: Task) {
case "write_to_file":
await writeToFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
break
case "apply_diff":
await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
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(
cline,
block,
askApproval,
handleError,
pushToolResult,
removeClosingTag,
)
}
break
}
case "insert_content":
await insertContentTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
break

View file

@ -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:
<apply_diff>
<args>
<file>
<path>eg.file.py</path>
<diff>
<content>
\`\`\`
<<<<<<< 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
\`\`\`
</content>
</diff>
</file>
</args>
</apply_diff>
Search/Replace content with multi edits in one file:
<apply_diff>
<args>
<file>
<path>eg.file.py</path>
<diff>
<content>
\`\`\`
<<<<<<< SEARCH
def calculate_total(items):
sum = 0
=======
def calculate_sum(items):
sum = 0
>>>>>>> REPLACE
\`\`\`
</content>
</diff>
<diff>
<content>
\`\`\`
<<<<<<< SEARCH
total += item
return total
=======
sum += item
return sum
>>>>>>> REPLACE
\`\`\`
</content>
</diff>
</file>
<file>
<path>eg.file2.py</path>
<diff>
<content>
\`\`\`
<<<<<<< SEARCH
def greet(name):
return "Hello " + name
=======
def greet(name):
return f"Hello {name}!"
>>>>>>> REPLACE
\`\`\`
</content>
</diff>
</file>
</args>
</apply_diff>
Usage:
<apply_diff>
<args>
<file>
<path>File path here</path>
<diff>
<content>
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.
</content>
<start_line>1</start_line>
</diff>
</file>
<file>
<path>Another file path</path>
<diff>
<content>
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.
</content>
<start_line>5</start_line>
</diff>
</file>
</args>
</apply_diff>`
}
private unescapeMarkers(content: string): string {
return content
.replace(/^\\<<<<<<</gm, "<<<<<<<")
.replace(/^\\=======/gm, "=======")
.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<DiffResult> {
// 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<DiffResult> {
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. (?<!\\)<<<<<<< SEARCH\s*\n Matches the line "<<<<<<< SEARCH" (ignoring any trailing spaces) the negative lookbehind makes sure it isn't escaped.
3. ((?:\:start_line:\s*(\d+)\s*\n))? Optionally matches a ":start_line:" line. The outer capturing group is group 1 and the inner (\d+) is group 2.
4. ((?:\:end_line:\s*(\d+)\s*\n))? Optionally matches a ":end_line:" line. Group 3 is the whole match and group 4 is the digits.
5. ((?<!\\)-------\s*\n)? Optionally matches the "-------" marker line (group 5).
6. ([\s\S]*?)(?:\n)? Nongreedy match for the "search content" (group 6) up to the next marker.
7. (?:(?<=\n)(?<!\\)=======\s*\n) Matches the "=======" marker on its own line.
8. ([\s\S]*?)(?:\n)? Nongreedy match for the "replace content" (group 7).
9. (?:(?<=\n)(?<!\\)>>>>>>> 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)(?<!\\)<<<<<<< SEARCH\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\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 {}
}
}

View file

@ -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<DiffResult> => {
async applyDiff(_originalContent: string, _diffContents: string | DiffItem[]): Promise<DiffResult> {
return { success: true, content: "mock result" }
},
}

View file

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

View file

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

View file

@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { applyDiffTool } from "../multiApplyDiffTool"
import { EXPERIMENT_IDS, experiments } from "../../../shared/experiments"
// 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
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(),
},
api: {
getModel: vi.fn().mockReturnValue({ id: "test-model" }),
},
} 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,
},
})
// Mock the legacy tool to resolve successfully
;(applyDiffToolLegacy as any).mockResolvedValue(undefined)
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({})
// Mock the legacy tool to resolve successfully
;(applyDiffToolLegacy as any).mockResolvedValue(undefined)
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()
})
})

View file

@ -12,7 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { unescapeHtmlEntities } from "../../utils/text-normalization"
export async function applyDiffTool(
export async function applyDiffToolLegacy(
cline: Task,
block: ToolUse,
askApproval: AskApproval,

View file

@ -0,0 +1,570 @@
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<string, DiffOperation> = {}
let usingLegacyParams = false
let filteredOperationErrors: string[] = []
// Handle partial message first
if (block.partial) {
let filePath = ""
if (argsXmlTag) {
const match = argsXmlTag.match(/<file>.*?<path>([^<]+)<\/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 <file>, <path>, or <diff> tags
3. Invalid characters or encoding in the XML
Expected structure:
<args>
<file>
<path>relative/path/to/file.ext</path>
<diff>
<content>diff content here</content>
<start_line>optional line number</start_line>
</diff>
</file>
</args>
Original error: ${errorMessage}`
throw new Error(detailedError)
}
} 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<OperationResult>) => {
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 += `<error_details>
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 <read_file> 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` : ""}
</error_details>\n\n`
}
} else {
const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : ""
formattedError += `<error_details>
Unable to apply diffs to file: ${absolutePath}
Error: ${diffResult.error}
Recovery suggestions:
1. Use <read_file> 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` : ""}
</error_details>\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
}
}

View file

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

View file

@ -276,6 +276,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
}

View file

@ -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<ExperimentId, boolean> = {
@ -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)
})

View file

@ -3,6 +3,7 @@ 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",
} as const satisfies Record<string, ExperimentId>
@ -18,6 +19,7 @@ interface ExperimentConfig {
export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
MARKETPLACE: { enabled: false },
CONCURRENT_FILE_READS: { enabled: false },
MULTI_FILE_APPLY_DIFF: { enabled: false },
DISABLE_COMPLETION_COMMAND: { enabled: false },
POWER_STEERING: { enabled: false },
}

View file

@ -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<DiffResult>
applyDiff(
originalContent: string,
diffContent: string | DiffItem[],
startLine?: number,
endLine?: number,
): Promise<DiffResult>
getProgressStatus?(toolUse: ToolUse, result?: any): ToolProgressStatus
}

View file

@ -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<Record<string, boolean>>({})
if (!files?.length) {
return null
}
const handleToggleExpand = (filePath: string) => {
setExpandedFiles((prev) => ({
...prev,
[filePath]: !prev[filePath],
}))
}
return (
<div className="pt-[5px]">
{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 (
<div key={`${file.path}-${ts}`} className="mb-2">
<CodeAccordian
path={file.path}
code={combinedDiff}
language="diff"
isExpanded={expandedFiles[file.path] || false}
onToggleExpand={() => handleToggleExpand(file.path)}
/>
</div>
)
})}
</div>
)
})
BatchDiffApproval.displayName = "BatchDiffApproval"

View file

@ -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 (
<>
<div style={headerStyle}>
{toolIcon("diff")}
<span style={{ fontWeight: "bold" }}>
{t("chat:fileOperations.wantsToApplyBatchChanges")}
</span>
</div>
<BatchDiffApproval files={tool.batchDiffs} ts={message.ts} />
</>
)
}
// Regular single file diff
return (
<>
<div style={headerStyle}>

View file

@ -72,6 +72,18 @@ export const ExperimentalSettings = ({
/>
)
}
if (config[0] === "MULTI_FILE_APPLY_DIFF") {
return (
<ExperimentalFeature
key={config[0]}
experimentKey={config[0]}
enabled={experiments[EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF] ?? false}
onChange={(enabled) =>
setExperimentEnabled(EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, enabled)
}
/>
)
}
return (
<ExperimentalFeature
key={config[0]}

View file

@ -225,6 +225,7 @@ describe("mergeExtensionState", () => {
marketplace: false,
concurrentFileReads: true,
disableCompletionCommand: false,
multiFileApplyDiff: true,
} as Record<ExperimentId, boolean>,
}
@ -240,6 +241,7 @@ describe("mergeExtensionState", () => {
marketplace: false,
concurrentFileReads: true,
disableCompletionCommand: false,
multiFileApplyDiff: true,
})
})
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 é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": {

View file

@ -152,7 +152,8 @@
"wantsToInsertWithLineNumber": "Roo इस फ़ाइल की {{lineNumber}} लाइन पर सामग्री डालना चाहता है:",
"wantsToInsertAtEnd": "Roo इस फ़ाइल के अंत में सामग्री जोड़ना चाहता है:",
"wantsToReadAndXMore": "रू इस फ़ाइल को और {{count}} अन्य को पढ़ना चाहता है:",
"wantsToReadMultiple": "Roo कई फ़ाइलें पढ़ना चाहता है:"
"wantsToReadMultiple": "Roo कई फ़ाइलें पढ़ना चाहता है:",
"wantsToApplyBatchChanges": "Roo कई फ़ाइलों में परिवर्तन लागू करना चाहता है:"
},
"directoryOperations": {
"wantsToViewTopLevel": "Roo इस निर्देशिका में शीर्ष स्तर की फ़ाइलें देखना चाहता है:",

View file

@ -500,6 +500,10 @@
"DISABLE_COMPLETION_COMMAND": {
"name": "attempt_completion में कमांड निष्पादन अक्षम करें",
"description": "जब सक्षम किया जाता है, तो attempt_completion टूल कमांड निष्पादित नहीं करेगा। यह कार्य पूर्ण होने पर कमांड निष्पादन को पदावनत करने की तैयारी के लिए एक प्रयोगात्मक सुविधा है।"
},
"MULTI_FILE_APPLY_DIFF": {
"name": "समानांतर फ़ाइल संपादन सक्षम करें",
"description": "जब सक्षम किया जाता है, तो Roo एक ही अनुरोध में कई फ़ाइलों को संपादित कर सकता है। जब अक्षम किया जाता है, तो Roo को एक समय में एक फ़ाइल संपादित करनी होगी। इसे अक्षम करना तब मदद कर सकता है जब आप कम सक्षम मॉडल के साथ काम कर रहे हों या जब आप फ़ाइल संशोधनों पर अधिक नियंत्रण चाहते हों।"
}
},
"promptCaching": {

View file

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

View file

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

View file

@ -152,7 +152,8 @@
"wantsToInsertWithLineNumber": "Rooはこのファイルの{{lineNumber}}行目にコンテンツを挿入したい:",
"wantsToInsertAtEnd": "Rooはこのファイルの末尾にコンテンツを追加したい:",
"wantsToReadAndXMore": "Roo はこのファイルと他に {{count}} 個のファイルを読み込もうとしています:",
"wantsToReadMultiple": "Rooは複数のファイルを読み取ろうとしています"
"wantsToReadMultiple": "Rooは複数のファイルを読み取ろうとしています",
"wantsToApplyBatchChanges": "Rooは複数のファイルに変更を適用したい:"
},
"directoryOperations": {
"wantsToViewTopLevel": "Rooはこのディレクトリのトップレベルファイルを表示したい:",

View file

@ -500,6 +500,10 @@
"DISABLE_COMPLETION_COMMAND": {
"name": "attempt_completionでのコマンド実行を無効にする",
"description": "有効にすると、attempt_completionツールはコマンドを実行しません。これは、タスク完了時のコマンド実行の非推奨化に備えるための実験的な機能です。"
},
"MULTI_FILE_APPLY_DIFF": {
"name": "同時ファイル編集を有効にする",
"description": "有効にすると、Rooは単一のリクエストで複数のファイルを編集できます。無効にすると、Rooはファイルを一つずつ編集する必要があります。これを無効にすることで、能力の低いモデルで作業する場合や、ファイル変更をより細かく制御したい場合に役立ちます。"
}
},
"promptCaching": {

View file

@ -152,7 +152,8 @@
"wantsToInsertWithLineNumber": "Roo가 이 파일의 {{lineNumber}}번 줄에 내용을 삽입하고 싶어합니다:",
"wantsToInsertAtEnd": "Roo가 이 파일의 끝에 내용을 추가하고 싶어합니다:",
"wantsToReadAndXMore": "Roo가 이 파일과 {{count}}개의 파일을 더 읽으려고 합니다:",
"wantsToReadMultiple": "Roo가 여러 파일을 읽으려고 합니다:"
"wantsToReadMultiple": "Roo가 여러 파일을 읽으려고 합니다:",
"wantsToApplyBatchChanges": "Roo가 여러 파일에 변경 사항을 적용하고 싶어합니다:"
},
"directoryOperations": {
"wantsToViewTopLevel": "Roo가 이 디렉토리의 최상위 파일을 보고 싶어합니다:",

View file

@ -500,6 +500,10 @@
"DISABLE_COMPLETION_COMMAND": {
"name": "attempt_completion에서 명령 실행 비활성화",
"description": "활성화하면 attempt_completion 도구가 명령을 실행하지 않습니다. 이는 작업 완료 시 명령 실행을 더 이상 사용하지 않도록 준비하기 위한 실험적 기능입니다."
},
"MULTI_FILE_APPLY_DIFF": {
"name": "동시 파일 편집 활성화",
"description": "활성화하면 Roo가 단일 요청으로 여러 파일을 편집할 수 있습니다. 비활성화하면 Roo는 파일을 하나씩 편집해야 합니다. 이 기능을 비활성화하면 덜 강력한 모델로 작업하거나 파일 수정에 대한 더 많은 제어가 필요할 때 도움이 됩니다."
}
},
"promptCaching": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -147,7 +147,8 @@
"wantsToInsertWithLineNumber": "Roo хочет вставить содержимое в этот файл на строку {{lineNumber}}:",
"wantsToInsertAtEnd": "Roo хочет добавить содержимое в конец этого файла:",
"wantsToReadAndXMore": "Roo хочет прочитать этот файл и еще {{count}}:",
"wantsToReadMultiple": "Roo хочет прочитать несколько файлов:"
"wantsToReadMultiple": "Roo хочет прочитать несколько файлов:",
"wantsToApplyBatchChanges": "Roo хочет применить изменения к нескольким файлам:"
},
"directoryOperations": {
"wantsToViewTopLevel": "Roo хочет просмотреть файлы верхнего уровня в этой директории:",

View file

@ -500,6 +500,10 @@
"MARKETPLACE": {
"name": "Включить Marketplace",
"description": "Когда включено, вы можете устанавливать MCP и пользовательские режимы из Marketplace."
},
"MULTI_FILE_APPLY_DIFF": {
"name": "Включить одновременное редактирование файлов",
"description": "Когда включено, Roo может редактировать несколько файлов в одном запросе. Когда отключено, Roo должен редактировать файлы по одному. Отключение этой функции может помочь при работе с менее способными моделями или когда вы хотите больше контроля над изменениями файлов."
}
},
"promptCaching": {

View file

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

View file

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

View file

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

View file

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

View file

@ -152,7 +152,8 @@
"wantsToInsertWithLineNumber": "需要在第 {{lineNumber}} 行插入内容:",
"wantsToInsertAtEnd": "需要在文件末尾添加内容:",
"wantsToReadAndXMore": "Roo 想读取此文件以及另外 {{count}} 个文件:",
"wantsToReadMultiple": "Roo 想要读取多个文件:"
"wantsToReadMultiple": "Roo 想要读取多个文件:",
"wantsToApplyBatchChanges": "Roo 想要对多个文件应用更改:"
},
"directoryOperations": {
"wantsToViewTopLevel": "需要查看目录文件列表:",

View file

@ -500,6 +500,10 @@
"DISABLE_COMPLETION_COMMAND": {
"name": "禁用 attempt_completion 中的命令执行",
"description": "启用后attempt_completion 工具将不会执行命令。这是一项实验性功能,旨在为将来弃用任务完成时的命令执行做准备。"
},
"MULTI_FILE_APPLY_DIFF": {
"name": "启用并发文件编辑",
"description": "启用后 Roo 可在单个请求中编辑多个文件。禁用后 Roo 必须逐个编辑文件。禁用此功能有助于使用能力较弱的模型或需要更精确控制文件修改时。"
}
},
"promptCaching": {

View file

@ -152,7 +152,8 @@
"wantsToInsertWithLineNumber": "Roo 想要在此檔案第 {{lineNumber}} 行插入內容:",
"wantsToInsertAtEnd": "Roo 想要在此檔案末尾新增內容:",
"wantsToReadAndXMore": "Roo 想要讀取此檔案以及另外 {{count}} 個檔案:",
"wantsToReadMultiple": "Roo 想要讀取多個檔案:"
"wantsToReadMultiple": "Roo 想要讀取多個檔案:",
"wantsToApplyBatchChanges": "Roo 想要對多個檔案套用變更:"
},
"directoryOperations": {
"wantsToViewTopLevel": "Roo 想要檢視此目錄中最上層的檔案:",

View file

@ -500,6 +500,10 @@
"DISABLE_COMPLETION_COMMAND": {
"name": "停用 attempt_completion 中的指令執行",
"description": "啟用後attempt_completion 工具將不會執行指令。這是一項實驗性功能,旨在為未來停用工作完成時的指令執行做準備。"
},
"MULTI_FILE_APPLY_DIFF": {
"name": "啟用並行檔案編輯",
"description": "啟用後 Roo 可在單個請求中編輯多個檔案。停用後 Roo 必須逐個編輯檔案。停用此功能有助於使用能力較弱的模型或需要更精確控制檔案修改時。"
}
},
"promptCaching": {