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/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 3716906a8d..be24a63d2e 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, 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 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/prompts/__tests__/sections.test.ts b/src/core/prompts/__tests__/sections.test.ts index d6515883c8..3b29193e99 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: string | 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.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() + }) + }) }) }) 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..30a37a4e96 --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -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() + }) +}) diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index 500c7a92c3..d4f7fd883f 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -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, diff --git a/src/core/tools/multiApplyDiffTool.ts b/src/core/tools/multiApplyDiffTool.ts new file mode 100644 index 0000000000..ba36cd3759 --- /dev/null +++ b/src/core/tools/multiApplyDiffTool.ts @@ -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 = {} + 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) + } + + } 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 + } +} 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..f6c387d480 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -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 @@ -18,6 +19,7 @@ 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 }, } 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/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..edadf729ed 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 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/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..6597d8f717 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": "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/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..d1c30270cf 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 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/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..c9a356e4cd 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 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/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..430c879396 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 é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/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..31ab2191d5 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 एक ही अनुरोध में कई फ़ाइलों को संपादित कर सकता है। जब अक्षम किया जाता है, तो 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..51d1dd640d 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 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/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..93cbe43a6e 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": "同時ファイル編集を有効にする", + "description": "有効にすると、Rooは単一のリクエストで複数のファイルを編集できます。無効にすると、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..a7ec710f23 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": "동시 파일 편집 활성화", + "description": "활성화하면 Roo가 단일 요청으로 여러 파일을 편집할 수 있습니다. 비활성화하면 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..8ec7bb35c4 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": "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/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..aade54b772 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 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/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..8e1a5af79a 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 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/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..acf9235253 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": "Включить одновременное редактирование файлов", + "description": "Когда включено, Roo может редактировать несколько файлов в одном запросе. Когда отключено, 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..2445ea6c91 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": "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/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..9dc39075c7 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 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/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 058adfd0ad..3841abd499 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..be8f221a81 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": "启用并发文件编辑", + "description": "启用后 Roo 可在单个请求中编辑多个文件。禁用后 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..901ec23416 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": "啟用並行檔案編輯", + "description": "啟用後 Roo 可在單個請求中編輯多個檔案。停用後 Roo 必須逐個編輯檔案。停用此功能有助於使用能力較弱的模型或需要更精確控制檔案修改時。" } }, "promptCaching": {