diff --git a/.changeset/tame-buses-unite.md b/.changeset/tame-buses-unite.md
new file mode 100644
index 00000000000..80f1b7cbf17
--- /dev/null
+++ b/.changeset/tame-buses-unite.md
@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Add option for parallel diff edits
diff --git a/.clinerules b/.clinerules
index 9eaef00abef..1c86ff29857 100644
--- a/.clinerules
+++ b/.clinerules
@@ -1 +1,72 @@
-- Before attempting completion, always make sure that any code changes have test coverage and that the tests pass.
\ No newline at end of file
+# Code Quality Rules
+
+1. Test Coverage:
+ - Before attempting completion, always make sure that any code changes have test coverage
+ - Ensure all tests pass before submitting changes
+
+2. Git Commits:
+ - When finishing a task, always output a git commit command
+ - Include a descriptive commit message that follows conventional commit format
+
+# Adding a New Settings Checkbox
+
+To add a new settings checkbox that persists its state, follow these steps:
+
+1. Add the message type to WebviewMessage.ts:
+ - Add the setting name to the WebviewMessage type's type union
+ - Example: `| "multisearchDiffEnabled"`
+
+2. Add the setting to ExtensionStateContext.tsx:
+ - Add the setting to the ExtensionStateContextType interface
+ - Add the setter function to the interface
+ - Add the setting to the initial state in useState
+ - Add the setting to the contextValue object
+ - Example:
+ ```typescript
+ interface ExtensionStateContextType {
+ multisearchDiffEnabled: boolean;
+ setMultisearchDiffEnabled: (value: boolean) => void;
+ }
+ ```
+
+3. Add the setting to ClineProvider.ts:
+ - Add the setting name to the GlobalStateKey type union
+ - Add the setting to the Promise.all array in getState
+ - Add the setting to the return value in getState with a default value
+ - Add the setting to the destructured variables in getStateToPostToWebview
+ - Add the setting to the return value in getStateToPostToWebview
+ - Add a case in setWebviewMessageListener to handle the setting's message type
+ - Example:
+ ```typescript
+ case "multisearchDiffEnabled":
+ await this.updateGlobalState("multisearchDiffEnabled", message.bool)
+ await this.postStateToWebview()
+ break
+ ```
+
+4. Add the checkbox UI to SettingsView.tsx:
+ - Import the setting and its setter from ExtensionStateContext
+ - Add the VSCodeCheckbox component with the setting's state and onChange handler
+ - Add appropriate labels and description text
+ - Example:
+ ```typescript
+ setMultisearchDiffEnabled(e.target.checked)}
+ >
+ Enable multi-search diff matching
+
+ ```
+
+5. Add the setting to handleSubmit in SettingsView.tsx:
+ - Add a vscode.postMessage call to send the setting's value when clicking Done
+ - Example:
+ ```typescript
+ vscode.postMessage({ type: "multisearchDiffEnabled", bool: multisearchDiffEnabled })
+ ```
+
+These steps ensure that:
+- The setting's state is properly typed throughout the application
+- The setting persists between sessions
+- The setting's value is properly synchronized between the webview and extension
+- The setting has a proper UI representation in the settings view
diff --git a/src/core/Cline.ts b/src/core/Cline.ts
index 63704c22f8b..479f1c49e0d 100644
--- a/src/core/Cline.ts
+++ b/src/core/Cline.ts
@@ -103,6 +103,7 @@ export class Cline {
task?: string | undefined,
images?: string[] | undefined,
historyItem?: HistoryItem | undefined,
+ multisearchDiffEnabled?: boolean,
) {
this.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration)
@@ -113,7 +114,7 @@ export class Cline {
this.customInstructions = customInstructions
this.diffEnabled = enableDiff ?? false
if (this.diffEnabled && this.api.getModel().id) {
- this.diffStrategy = getDiffStrategy(this.api.getModel().id, fuzzyMatchThreshold ?? 1.0)
+ this.diffStrategy = getDiffStrategy(this.api.getModel().id, fuzzyMatchThreshold ?? 1.0, multisearchDiffEnabled ?? false)
}
if (historyItem) {
this.taskId = historyItem.id
diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts
index ed755bd3997..8206b38bf1a 100644
--- a/src/core/__tests__/Cline.test.ts
+++ b/src/core/__tests__/Cline.test.ts
@@ -316,8 +316,8 @@ describe('Cline', () => {
expect(cline.diffEnabled).toBe(true);
expect(cline.diffStrategy).toBeDefined();
- expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 0.9);
-
+ expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 0.9, false);
+
getDiffStrategySpy.mockRestore();
});
@@ -335,8 +335,8 @@ describe('Cline', () => {
expect(cline.diffEnabled).toBe(true);
expect(cline.diffStrategy).toBeDefined();
- expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 1.0);
-
+ expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 1.0, false);
+
getDiffStrategySpy.mockRestore();
});
diff --git a/src/core/diff/DiffStrategy.ts b/src/core/diff/DiffStrategy.ts
index c6118564e98..1bee16641e2 100644
--- a/src/core/diff/DiffStrategy.ts
+++ b/src/core/diff/DiffStrategy.ts
@@ -1,16 +1,19 @@
import type { DiffStrategy } from './types'
import { UnifiedDiffStrategy } from './strategies/unified'
import { SearchReplaceDiffStrategy } from './strategies/search-replace'
+import { SearchReplaceMultisearchDiffStrategy } from './strategies/search-replace-multisearch'
/**
* Get the appropriate diff strategy for the given model
* @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus')
* @returns The appropriate diff strategy for the model
*/
-export function getDiffStrategy(model: string, fuzzyMatchThreshold?: number): DiffStrategy {
- // For now, return SearchReplaceDiffStrategy for all models
- // This architecture allows for future optimizations based on model capabilities
- return new SearchReplaceDiffStrategy(fuzzyMatchThreshold ?? 1.0)
+export function getDiffStrategy(model: string, fuzzyMatchThreshold?: number, multisearchDiffEnabled?: boolean): DiffStrategy {
+ // Use SearchReplaceMultisearchDiffStrategy when multisearch diff is enabled
+ // Otherwise fall back to regular SearchReplaceDiffStrategy
+ return multisearchDiffEnabled
+ ? new SearchReplaceMultisearchDiffStrategy(fuzzyMatchThreshold ?? 1.0)
+ : new SearchReplaceDiffStrategy(fuzzyMatchThreshold ?? 1.0)
}
export type { DiffStrategy }
-export { UnifiedDiffStrategy, SearchReplaceDiffStrategy }
+export { UnifiedDiffStrategy, SearchReplaceDiffStrategy, SearchReplaceMultisearchDiffStrategy }
diff --git a/src/core/diff/__tests__/utils.test.ts b/src/core/diff/__tests__/utils.test.ts
new file mode 100644
index 00000000000..0439c4b763b
--- /dev/null
+++ b/src/core/diff/__tests__/utils.test.ts
@@ -0,0 +1,61 @@
+import { levenshteinDistance, getSimilarity } from "../utils"
+
+describe("levenshteinDistance", () => {
+ it("should return 0 for identical strings", () => {
+ expect(levenshteinDistance("hello", "hello")).toBe(0)
+ })
+
+ it("should handle single character differences", () => {
+ expect(levenshteinDistance("hello", "hallo")).toBe(1)
+ })
+
+ it("should handle insertions", () => {
+ expect(levenshteinDistance("hello", "hello!")).toBe(1)
+ })
+
+ it("should handle deletions", () => {
+ expect(levenshteinDistance("hello!", "hello")).toBe(1)
+ })
+
+ it("should handle completely different strings", () => {
+ expect(levenshteinDistance("hello", "world")).toBe(4)
+ })
+
+ it("should handle empty strings", () => {
+ expect(levenshteinDistance("", "")).toBe(0)
+ expect(levenshteinDistance("hello", "")).toBe(5)
+ expect(levenshteinDistance("", "hello")).toBe(5)
+ })
+})
+
+describe("getSimilarity", () => {
+ it("should return 1 for identical strings", () => {
+ expect(getSimilarity("hello world", "hello world")).toBe(1)
+ })
+
+ it("should handle empty search string", () => {
+ expect(getSimilarity("hello world", "")).toBe(1)
+ })
+
+ it("should normalize whitespace", () => {
+ expect(getSimilarity("hello world", "hello world")).toBe(1)
+ expect(getSimilarity("hello\tworld", "hello world")).toBe(1)
+ expect(getSimilarity("hello\nworld", "hello world")).toBe(1)
+ })
+
+ it("should preserve case sensitivity", () => {
+ expect(getSimilarity("Hello World", "hello world")).toBeLessThan(1)
+ })
+
+ it("should handle partial matches", () => {
+ const similarity = getSimilarity("hello world", "hello there")
+ expect(similarity).toBeGreaterThan(0)
+ expect(similarity).toBeLessThan(1)
+ })
+
+ it("should handle completely different strings", () => {
+ const similarity = getSimilarity("hello world", "goodbye universe")
+ expect(similarity).toBeGreaterThan(0)
+ expect(similarity).toBeLessThan(0.5)
+ })
+})
\ No newline at end of file
diff --git a/src/core/diff/strategies/__tests__/search-replace-multisearch.test.ts b/src/core/diff/strategies/__tests__/search-replace-multisearch.test.ts
new file mode 100644
index 00000000000..9fc000449c4
--- /dev/null
+++ b/src/core/diff/strategies/__tests__/search-replace-multisearch.test.ts
@@ -0,0 +1,569 @@
+import { SearchReplaceMultisearchDiffStrategy } from '../search-replace-multisearch'
+
+describe('SearchReplaceMultisearchDiffStrategy', () => {
+ describe('constructor', () => {
+ it('should use default values when no parameters provided', () => {
+ const strategy = new SearchReplaceMultisearchDiffStrategy()
+ expect(strategy['fuzzyThreshold']).toBe(1.0)
+ expect(strategy['bufferLines']).toBe(20)
+ })
+
+ it('should use provided values', () => {
+ const strategy = new SearchReplaceMultisearchDiffStrategy(0.9, 10)
+ expect(strategy['fuzzyThreshold']).toBe(0.9)
+ expect(strategy['bufferLines']).toBe(10)
+ })
+ })
+
+ describe('fuzzy matching', () => {
+ let strategy: SearchReplaceMultisearchDiffStrategy
+
+ beforeEach(() => {
+ strategy = new SearchReplaceMultisearchDiffStrategy(0.8) // 80% similarity threshold
+ })
+
+ it('should match content with small differences (>80% similar)', () => {
+ const originalContent = 'function getData() {\n const results = fetchData();\n return results.filter(Boolean);\n}\n'
+ const diffContent = `<<<<<<< SEARCH (1)
+function getData() {
+ const result = fetchData();
+ return results.filter(Boolean);
+}
+=======
+function getData() {
+ const data = fetchData();
+ return data.filter(Boolean);
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.content).toBe('function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n')
+ }
+ })
+
+ it('should not match when content is too different (<80% similar)', () => {
+ const originalContent = 'function processUsers(data) {\n return data.map(user => user.name);\n}\n'
+ const diffContent = `<<<<<<< SEARCH (1)
+function handleItems(items) {
+ return items.map(item => item.username);
+}
+=======
+function processData(data) {
+ return data.map(d => d.value);
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(false)
+ })
+
+ it('should normalize whitespace in similarity comparison', () => {
+ const originalContent = 'function sum(a, b) {\n return a + b;\n}\n'
+ const diffContent = `<<<<<<< SEARCH (1)
+function sum(a, b) {
+ return a + b;
+}
+=======
+function sum(a, b) {
+ return a + b + 1;
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.content).toBe('function sum(a, b) {\n return a + b + 1;\n}\n')
+ }
+ })
+ })
+
+ describe('buffer zone search', () => {
+ let strategy: SearchReplaceMultisearchDiffStrategy
+
+ beforeEach(() => {
+ strategy = new SearchReplaceMultisearchDiffStrategy(1.0, 5) // Exact matching with 5 line buffer
+ })
+
+ it('should find matches within buffer zone', () => {
+ const originalContent = `
+function one() {
+ return 1;
+}
+
+function two() {
+ return 2;
+}
+
+function three() {
+ return 3;
+}`.trim()
+
+ const diffContent = `<<<<<<< SEARCH (5)
+function three() {
+ return 3;
+}
+=======
+function three() {
+ return "three";
+}
+>>>>>>> REPLACE`
+
+ // Even though we target line 5, it should find the match at lines 9-11
+ // because it's within the 5-line buffer zone
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.content).toBe(`function one() {
+ return 1;
+}
+
+function two() {
+ return 2;
+}
+
+function three() {
+ return "three";
+}`)
+ }
+ })
+
+ it('should not find matches outside buffer zone', () => {
+ const originalContent = `
+function one() {
+ return 1;
+}
+
+function two() {
+ return 2;
+}
+
+function three() {
+ return 3;
+}
+
+function four() {
+ return 4;
+}
+
+function five() {
+ return 5;
+}`.trim()
+
+ const diffContent = `<<<<<<< SEARCH (5)
+function five() {
+ return 5;
+}
+=======
+function five() {
+ return "five";
+}
+>>>>>>> REPLACE`
+
+ // Targeting line 5, function five() is more than 5 lines away
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('multiple search/replace blocks', () => {
+ let strategy: SearchReplaceMultisearchDiffStrategy
+
+ beforeEach(() => {
+ strategy = new SearchReplaceMultisearchDiffStrategy()
+ })
+
+ it('should handle overlapping search blocks', () => {
+ const originalContent = `
+function process() {
+ const data = getData();
+ const result = transform(data);
+ return format(result);
+}`.trim()
+
+ const diffContent = `<<<<<<< SEARCH (1)
+function process() {
+ const data = getData();
+ const result = transform(data);
+=======
+function process() {
+ const input = getData();
+ const output = transform(input);
+>>>>>>> REPLACE
+
+<<<<<<< SEARCH (3)
+ const result = transform(data);
+ return format(result);
+}
+=======
+ const output = transform(input);
+ return format(output);
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error).toContain('Start line must be greater than previous block')
+ }
+ })
+
+ it('should handle multiple potential matches', () => {
+ const originalContent = `
+function log(msg) {
+ console.log(msg);
+}
+
+function debug(msg) {
+ console.log(msg);
+}
+
+function error(msg) {
+ console.log(msg);
+}`.trim()
+
+ const diffContent = `<<<<<<< SEARCH (2)
+function log(msg) {
+ console.log(msg);
+}
+=======
+function log(msg) {
+ console.log('[LOG]', msg);
+}
+>>>>>>> REPLACE
+
+<<<<<<< SEARCH (6)
+function debug(msg) {
+ console.log(msg);
+}
+=======
+function debug(msg) {
+ console.log('[DEBUG]', msg);
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.content).toBe(`function log(msg) {
+ console.log('[LOG]', msg);
+}
+
+function debug(msg) {
+ console.log('[DEBUG]', msg);
+}
+
+function error(msg) {
+ console.log(msg);
+}`)
+ }
+ })
+
+ it('should handle replacements affecting later matches', () => {
+ const originalContent = `
+const config = {
+ port: 3000,
+ host: 'localhost',
+ timeout: 5000
+};
+
+function getPort() {
+ return config.port;
+}
+
+function getTimeout() {
+ return config.timeout;
+}`.trim()
+
+ const diffContent = `<<<<<<< SEARCH (1)
+const config = {
+ port: 3000,
+ host: 'localhost',
+ timeout: 5000
+};
+=======
+const CONFIG = {
+ PORT: 3000,
+ HOST: 'localhost',
+ TIMEOUT: 5000
+};
+>>>>>>> REPLACE
+
+<<<<<<< SEARCH (8)
+function getPort() {
+ return config.port;
+}
+=======
+function getPort() {
+ return CONFIG.PORT;
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.content).toBe(`const CONFIG = {
+ PORT: 3000,
+ HOST: 'localhost',
+ TIMEOUT: 5000
+};
+
+function getPort() {
+ return CONFIG.PORT;
+}
+
+function getTimeout() {
+ return config.timeout;
+}`)
+ }
+ })
+ })
+
+ describe('line number adjustments', () => {
+ let strategy: SearchReplaceMultisearchDiffStrategy
+
+ beforeEach(() => {
+ strategy = new SearchReplaceMultisearchDiffStrategy()
+ })
+
+ it('should adjust line numbers for subsequent blocks when lines are added', () => {
+ const originalContent = `
+function one() {
+ return 1;
+}
+
+function two() {
+ return 2;
+}
+
+function three() {
+ return 3;
+}`.trim()
+
+ const diffContent = `<<<<<<< SEARCH (1)
+function one() {
+ return 1;
+}
+=======
+function one() {
+ console.log("Starting...");
+ return 1;
+}
+>>>>>>> REPLACE
+
+<<<<<<< SEARCH (5)
+function two() {
+ return 2;
+}
+=======
+function two() {
+ console.log("Processing...");
+ return 2;
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.content).toBe(`function one() {
+ console.log("Starting...");
+ return 1;
+}
+
+function two() {
+ console.log("Processing...");
+ return 2;
+}
+
+function three() {
+ return 3;
+}`)
+ }
+ })
+
+ it('should adjust line numbers for subsequent blocks when lines are removed', () => {
+ const originalContent = `
+function one() {
+ // Debug line 1
+ // Debug line 2
+ return 1;
+}
+
+function two() {
+ return 2;
+}`.trim()
+
+ const diffContent = `<<<<<<< SEARCH (1)
+function one() {
+ // Debug line 1
+ // Debug line 2
+ return 1;
+}
+=======
+function one() {
+ return 1;
+}
+>>>>>>> REPLACE
+
+<<<<<<< SEARCH (7)
+function two() {
+ return 2;
+}
+=======
+function two() {
+ console.log("Processing...");
+ return 2;
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.content).toBe(`function one() {
+ return 1;
+}
+
+function two() {
+ console.log("Processing...");
+ return 2;
+}`)
+ }
+ })
+ })
+
+ describe('error handling', () => {
+ let strategy: SearchReplaceMultisearchDiffStrategy
+
+ beforeEach(() => {
+ strategy = new SearchReplaceMultisearchDiffStrategy()
+ })
+
+ it('should return error for invalid line range', () => {
+ const originalContent = 'function test() {\n return true;\n}\n'
+ const diffContent = `<<<<<<< SEARCH (5)
+function test() {
+ return true;
+}
+=======
+function test() {
+ return false;
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error).toContain('Line range 5-7 is invalid')
+ }
+ })
+
+ it('should return error for empty search content', () => {
+ const originalContent = 'function test() {\n return true;\n}\n'
+ const diffContent = `<<<<<<< SEARCH (1)
+=======
+function test() {
+ return false;
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error).toContain('Empty search content is not allowed')
+ }
+ })
+
+ it('should return error with debug info when no match found', () => {
+ const originalContent = 'function test() {\n return true;\n}\n'
+ const diffContent = `<<<<<<< SEARCH (1)
+function test() {
+ return different;
+}
+=======
+function test() {
+ return false;
+}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error).toContain('Debug Info:')
+ expect(result.error).toContain('Similarity Score:')
+ expect(result.error).toContain('Required Threshold:')
+ }
+ })
+ })
+
+ describe('indentation handling', () => {
+ let strategy: SearchReplaceMultisearchDiffStrategy
+
+ beforeEach(() => {
+ strategy = new SearchReplaceMultisearchDiffStrategy()
+ })
+
+ it('should preserve indentation when adding lines', () => {
+ const originalContent = `
+class Example {
+ constructor() {
+ this.value = 0;
+ }
+}`.trim()
+
+ const diffContent = `<<<<<<< SEARCH (2)
+ constructor() {
+ this.value = 0;
+ }
+=======
+ constructor() {
+ // Initialize value
+ this.value = 0;
+ this.ready = true;
+ }
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.content).toBe(`class Example {
+ constructor() {
+ // Initialize value
+ this.value = 0;
+ this.ready = true;
+ }
+}`)
+ }
+ })
+
+ it('should handle mixed indentation styles', () => {
+ const originalContent = `class Example {
+\tconstructor() {
+\t this.value = 0;
+\t}
+}`
+
+ const diffContent = `<<<<<<< SEARCH (2)
+\tconstructor() {
+\t this.value = 0;
+\t}
+=======
+\tconstructor() {
+\t // Add comment
+\t this.value = 1;
+\t}
+>>>>>>> REPLACE`
+
+ const result = strategy.applyDiff(originalContent, diffContent)
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.content).toBe(`class Example {
+\tconstructor() {
+\t // Add comment
+\t this.value = 1;
+\t}
+}`)
+ }
+ })
+ })
+})
diff --git a/src/core/diff/strategies/search-replace-multisearch.ts b/src/core/diff/strategies/search-replace-multisearch.ts
new file mode 100644
index 00000000000..43c60e5d59d
--- /dev/null
+++ b/src/core/diff/strategies/search-replace-multisearch.ts
@@ -0,0 +1,248 @@
+import { DiffStrategy, DiffResult } from "../types"
+import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
+import { getSimilarity } from "../utils"
+
+const BUFFER_LINES = 20; // Number of extra context lines to show before and after matches
+
+export class SearchReplaceMultisearchDiffStrategy implements DiffStrategy {
+ private fuzzyThreshold: number;
+ private bufferLines: number;
+
+ constructor(fuzzyThreshold?: number, bufferLines?: number) {
+ // Use provided threshold or default to exact matching (1.0)
+ this.fuzzyThreshold = fuzzyThreshold ?? 1.0;
+ this.bufferLines = bufferLines ?? BUFFER_LINES;
+ }
+
+ getToolDescription(cwd: string): string {
+ return `## apply_diff
+Description: Request to replace existing code using a search and replace block.
+This tool allows for precise, surgical replaces to files by specifying exactly what content to search for and what to replace it with.
+The tool will maintain proper indentation and formatting while making changes.
+Multiple search/replace blocks can be specified in a single diff, but they must be in order.
+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.
+
+Parameters:
+- path: (required) The path of the file to modify (relative to the current working directory ${cwd})
+- diff: (required) The search/replace block defining the changes.
+
+Line Number Behavior:
+- Line numbers are specified in the SEARCH marker: <<<<<<< SEARCH (start_line)
+- For multiple blocks, line numbers are automatically adjusted based on lines added/removed by previous blocks
+- Example: If block 1 adds 2 lines and block 2's target was at line 10, it will be automatically adjusted to line 12
+
+Diff format:
+\`\`\`
+<<<<<<< SEARCH (start_line)
+[exact content to find including whitespace]
+=======
+[new content to replace with]
+>>>>>>> REPLACE
+\`\`\`
+
+Example with multiple blocks:
+\`\`\`
+<<<<<<< SEARCH (1)
+function one() {
+ return 1;
+}
+=======
+function one() {
+ console.log("Starting...");
+ return 1;
+}
+>>>>>>> REPLACE
+<<<<<<< SEARCH (5)
+function two() {
+ return 2;
+}
+=======
+function two() {
+ console.log("Processing...");
+ return 2;
+}
+>>>>>>> REPLACE
+\`\`\`
+
+In this example:
+1. First block starts at line 1 and matches 3 lines (the function definition)
+2. First block adds 1 line (console.log), so subsequent line numbers are shifted by +1
+3. Second block starts at line 5, but is automatically adjusted to line 6 due to the previous +1 shift
+
+Usage:
+
+File path here
+
+[search/replace blocks here]
+
+`
+ }
+
+ applyDiff(originalContent: string, diffContent: string): DiffResult {
+ // Extract all search and replace blocks with start line numbers and compute end lines
+ const blockPattern = /<<<<<<< SEARCH \((\d+)(?:-\d+)?\)\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g;
+ const rawBlocks = Array.from(diffContent.matchAll(blockPattern));
+ const blocks = rawBlocks.map(([full, startStr, searchContent, replaceContent]) => {
+ const start = parseInt(startStr, 10);
+ const searchLines = searchContent.split(/\r?\n/);
+ const end = start + searchLines.length - 1;
+ return [full, startStr, end.toString(), searchContent, replaceContent];
+ });
+
+ // Validate blocks
+ if (blocks.length === 0) {
+ return {
+ success: false,
+ error: "Invalid diff format - missing required SEARCH/REPLACE sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH (start-end)\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include both SEARCH and REPLACE sections with correct markers and line numbers"
+ };
+ }
+
+ let prevEnd = -1;
+
+ // Then validate individual blocks
+ for (const block of blocks) {
+ const [_, startStr, endStr, searchContent] = block;
+ // Check for empty search content
+ const searchLines = searchContent.split(/\r?\n/);
+ if (searchLines.length === 0 || searchLines.every(line => line.trim() === '')) {
+ return {
+ success: false,
+ error: "Empty search content is not allowed\n\nDebug Info:\n- Each SEARCH block must contain content to match"
+ };
+ }
+
+ // Validate line numbers
+ const startLine = parseInt(startStr, 10);
+ const endLine = parseInt(endStr, 10);
+ if (startLine < 1 || endLine < startLine || startLine <= prevEnd) {
+ return {
+ success: false,
+ error: `Invalid line range ${startLine}-${endLine}\n\nDebug Info:\n- Start line must be >= 1\n- End line must be >= start line\n- Start line must be greater than previous block's end line`
+ };
+ }
+
+ prevEnd = endLine;
+ }
+
+ // Process blocks sequentially
+ let lineAdjustment = 0;
+ let currentContent = originalContent;
+ const lineEnding = currentContent.includes('\r\n') ? '\r\n' : '\n';
+
+ for (const [_, startStr, endStr, searchContent, replaceContent] of blocks) {
+ let currentSearchContent = searchContent;
+ let currentReplaceContent = replaceContent;
+
+ // Parse line numbers and apply adjustment
+ const startLine = parseInt(startStr, 10);
+ const endLine = parseInt(endStr, 10);
+ const adjustedStartLine = startLine + lineAdjustment;
+ const adjustedEndLine = endLine + lineAdjustment;
+
+ // Split content into lines for validation
+ const originalLines = currentContent.split(/\r?\n/);
+
+ // Validate line range
+ if (adjustedStartLine < 1 || adjustedEndLine > originalLines.length) {
+ return {
+ success: false,
+ error: `Line range ${startLine}-${endLine} is invalid\n\nDebug Info:\n- Original Range: lines ${startLine}-${endLine}\n- Adjusted Range: lines ${adjustedStartLine}-${adjustedEndLine}\n- Line Adjustment: ${lineAdjustment}\n- File Bounds: lines 1-${originalLines.length}`
+ };
+ }
+
+ // Strip line numbers if present
+ if (everyLineHasLineNumbers(currentSearchContent) && everyLineHasLineNumbers(currentReplaceContent)) {
+ currentSearchContent = stripLineNumbers(currentSearchContent);
+ currentReplaceContent = stripLineNumbers(currentReplaceContent);
+ }
+
+ // Split search and replace content into lines
+ const searchLines = currentSearchContent.split(/\r?\n/);
+ const replaceLines = currentReplaceContent.split(/\r?\n/);
+
+ // Initialize search variables
+ let matchIndex = -1;
+ let bestMatchScore = 0;
+ let bestMatchContent = "";
+ const searchChunk = searchLines.join('\n');
+
+ // Try exact match at adjusted line first
+ const exactMatchChunk = originalLines.slice(adjustedStartLine - 1, adjustedEndLine).join('\n');
+ const exactMatchScore = getSimilarity(exactMatchChunk, searchChunk);
+ if (exactMatchScore >= this.fuzzyThreshold) {
+ matchIndex = adjustedStartLine - 1;
+ bestMatchScore = exactMatchScore;
+ bestMatchContent = exactMatchChunk;
+ }
+
+ // If exact match fails, try buffer zone
+ if (matchIndex === -1) {
+ // Search within buffer zone
+ const searchStartIndex = Math.max(0, adjustedStartLine - this.bufferLines - 1);
+ const searchEndIndex = Math.min(originalLines.length - searchLines.length + 1, adjustedEndLine + this.bufferLines);
+
+ // Sequential search through buffer zone
+ for (let i = searchStartIndex; i <= searchEndIndex; i++) {
+ const originalChunk = originalLines.slice(i, i + searchLines.length).join('\n');
+ const similarity = getSimilarity(originalChunk, searchChunk);
+ if (similarity >= this.fuzzyThreshold && similarity > bestMatchScore) {
+ bestMatchScore = similarity;
+ matchIndex = i;
+ bestMatchContent = originalChunk;
+ }
+ }
+ }
+
+ // If no match found, fail with debug info
+ if (matchIndex === -1) {
+ return {
+ success: false,
+ error: `No matches found within buffer zone\n\nDebug Info:\n- Buffer Zone: ${this.bufferLines} lines\n- Target Line: ${startLine}\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: lines ${adjustedStartLine}-${adjustedEndLine}\n- Content to match:\n${searchChunk}\n- Best match found:\n${bestMatchContent}`
+ };
+ }
+
+ // Get matched lines and handle indentation
+ const matchedLines = originalLines.slice(matchIndex, matchIndex + searchLines.length);
+ const originalIndents = matchedLines.map((line: string) => {
+ const match = line.match(/^[\t ]*/);
+ return match ? match[0] : '';
+ });
+
+ const searchIndents = searchLines.map((line: string) => {
+ const match = line.match(/^[\t ]*/);
+ return match ? match[0] : '';
+ });
+
+ const indentedReplaceLines = replaceLines.map((line: string, i: number) => {
+ const matchedIndent = originalIndents[0] || '';
+ const currentIndentMatch = line.match(/^[\t ]*/);
+ const currentIndent = currentIndentMatch ? currentIndentMatch[0] : '';
+ const searchBaseIndent = searchIndents[0] || '';
+
+ const searchBaseLevel = searchBaseIndent.length;
+ const currentLevel = currentIndent.length;
+ const relativeLevel = currentLevel - searchBaseLevel;
+
+ const finalIndent = relativeLevel < 0
+ ? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel))
+ : matchedIndent + currentIndent.slice(searchBaseLevel);
+
+ return finalIndent + line.trim();
+ });
+
+ // Update content and line adjustment for next iteration
+ const beforeMatch = originalLines.slice(0, matchIndex);
+ const afterMatch = originalLines.slice(matchIndex + searchLines.length);
+ currentContent = [...beforeMatch, ...indentedReplaceLines, ...afterMatch].join(lineEnding);
+
+ // Update line adjustment for next block
+ lineAdjustment += replaceLines.length - searchLines.length;
+ }
+
+ return {
+ success: true,
+ content: currentContent
+ };
+ }
+}
diff --git a/src/core/diff/strategies/search-replace.ts b/src/core/diff/strategies/search-replace.ts
index 3990848e00d..3d08d166363 100644
--- a/src/core/diff/strategies/search-replace.ts
+++ b/src/core/diff/strategies/search-replace.ts
@@ -1,58 +1,9 @@
import { DiffStrategy, DiffResult } from "../types"
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
+import { getSimilarity } from "../utils"
const BUFFER_LINES = 20; // Number of extra context lines to show before and after matches
-function levenshteinDistance(a: string, b: string): number {
- const matrix: number[][] = [];
-
- // Initialize matrix
- for (let i = 0; i <= a.length; i++) {
- matrix[i] = [i];
- }
- for (let j = 0; j <= b.length; j++) {
- matrix[0][j] = j;
- }
-
- // Fill matrix
- for (let i = 1; i <= a.length; i++) {
- for (let j = 1; j <= b.length; j++) {
- if (a[i-1] === b[j-1]) {
- matrix[i][j] = matrix[i-1][j-1];
- } else {
- matrix[i][j] = Math.min(
- matrix[i-1][j-1] + 1, // substitution
- matrix[i][j-1] + 1, // insertion
- matrix[i-1][j] + 1 // deletion
- );
- }
- }
- }
-
- return matrix[a.length][b.length];
-}
-
-function getSimilarity(original: string, search: string): number {
- if (search === '') {
- return 1;
- }
-
- // Normalize strings by removing extra whitespace but preserve case
- const normalizeStr = (str: string) => str.replace(/\s+/g, ' ').trim();
-
- const normalizedOriginal = normalizeStr(original);
- const normalizedSearch = normalizeStr(search);
-
- if (normalizedOriginal === normalizedSearch) { return 1; }
-
- // Calculate Levenshtein distance
- const distance = levenshteinDistance(normalizedOriginal, normalizedSearch);
-
- // Calculate similarity ratio (0 to 1, where 1 is exact match)
- const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length);
- return 1 - (distance / maxLength);
-}
-
export class SearchReplaceDiffStrategy implements DiffStrategy {
private fuzzyThreshold: number;
private bufferLines: number;
diff --git a/src/core/diff/utils.ts b/src/core/diff/utils.ts
new file mode 100644
index 00000000000..2b75c009890
--- /dev/null
+++ b/src/core/diff/utils.ts
@@ -0,0 +1,49 @@
+export function levenshteinDistance(a: string, b: string): number {
+ const matrix: number[][] = [];
+
+ // Initialize matrix
+ for (let i = 0; i <= a.length; i++) {
+ matrix[i] = [i];
+ }
+ for (let j = 0; j <= b.length; j++) {
+ matrix[0][j] = j;
+ }
+
+ // Fill matrix
+ for (let i = 1; i <= a.length; i++) {
+ for (let j = 1; j <= b.length; j++) {
+ if (a[i-1] === b[j-1]) {
+ matrix[i][j] = matrix[i-1][j-1];
+ } else {
+ matrix[i][j] = Math.min(
+ matrix[i-1][j-1] + 1, // substitution
+ matrix[i][j-1] + 1, // insertion
+ matrix[i-1][j] + 1 // deletion
+ );
+ }
+ }
+ }
+
+ return matrix[a.length][b.length];
+}
+
+export function getSimilarity(original: string, search: string): number {
+ if (search === '') {
+ return 1;
+ }
+
+ // Normalize strings by removing extra whitespace but preserve case
+ const normalizeStr = (str: string) => str.replace(/\s+/g, ' ').trim();
+
+ const normalizedOriginal = normalizeStr(original);
+ const normalizedSearch = normalizeStr(search);
+
+ if (normalizedOriginal === normalizedSearch) { return 1; }
+
+ // Calculate Levenshtein distance
+ const distance = levenshteinDistance(normalizedOriginal, normalizedSearch);
+
+ // Calculate similarity ratio (0 to 1, where 1 is exact match)
+ const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length);
+ return 1 - (distance / maxLength);
+}
\ No newline at end of file
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index 2245d8036fc..a5ff929b182 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -71,6 +71,7 @@ type GlobalStateKey =
| "alwaysAllowMcp"
| "browserLargeViewport"
| "fuzzyMatchThreshold"
+ | "multisearchDiffEnabled"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
@@ -219,7 +220,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customInstructions,
diffEnabled,
- fuzzyMatchThreshold
+ fuzzyMatchThreshold,
+ multisearchDiffEnabled
} = await this.getState()
this.cline = new Cline(
@@ -229,7 +231,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
diffEnabled,
fuzzyMatchThreshold,
task,
- images
+ images,
+ undefined,
+ multisearchDiffEnabled
)
}
@@ -239,7 +243,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customInstructions,
diffEnabled,
- fuzzyMatchThreshold
+ fuzzyMatchThreshold,
+ multisearchDiffEnabled
} = await this.getState()
this.cline = new Cline(
@@ -250,7 +255,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
fuzzyMatchThreshold,
undefined,
undefined,
- historyItem
+ historyItem,
+ multisearchDiffEnabled
)
}
@@ -622,6 +628,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("fuzzyMatchThreshold", message.value)
await this.postStateToWebview()
break
+ case "multisearchDiffEnabled":
+ await this.updateGlobalState("multisearchDiffEnabled", message.bool)
+ await this.postStateToWebview()
+ break
}
},
null,
@@ -937,9 +947,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
async getStateToPostToWebview() {
- const {
- apiConfiguration,
- lastShownAnnouncementId,
+ const {
+ apiConfiguration,
+ lastShownAnnouncementId,
customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
@@ -951,6 +961,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
taskHistory,
soundVolume,
browserLargeViewport,
+ multisearchDiffEnabled,
} = await this.getState()
const allowedCommands = vscode.workspace
@@ -977,6 +988,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
allowedCommands,
soundVolume: soundVolume ?? 0.5,
browserLargeViewport: browserLargeViewport ?? false,
+ multisearchDiffEnabled: multisearchDiffEnabled ?? false,
}
}
@@ -1072,6 +1084,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
soundVolume,
browserLargeViewport,
fuzzyMatchThreshold,
+ multisearchDiffEnabled,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise,
this.getGlobalState("apiModelId") as Promise,
@@ -1112,6 +1125,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("soundVolume") as Promise,
this.getGlobalState("browserLargeViewport") as Promise,
this.getGlobalState("fuzzyMatchThreshold") as Promise,
+ this.getGlobalState("multisearchDiffEnabled") as Promise,
])
let apiProvider: ApiProvider
@@ -1170,6 +1184,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
soundVolume,
browserLargeViewport: browserLargeViewport ?? false,
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
+ multisearchDiffEnabled: multisearchDiffEnabled ?? false,
}
}
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index dcd352f8154..a1843471d93 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/src/shared/ExtensionMessage.ts
@@ -18,6 +18,7 @@ export interface ExtensionMessage {
| "partialMessage"
| "openRouterModels"
| "mcpServers"
+ | "multilineDiffEnabled"
text?: string
action?:
| "chatButtonClicked"
@@ -34,6 +35,7 @@ export interface ExtensionMessage {
partialMessage?: ClineMessage
openRouterModels?: Record
mcpServers?: McpServer[]
+ bool?: boolean
}
export interface ExtensionState {
@@ -55,6 +57,7 @@ export interface ExtensionState {
diffEnabled?: boolean
browserLargeViewport?: boolean
fuzzyMatchThreshold?: number
+ multisearchDiffEnabled?: boolean
}
export interface ClineMessage {
diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts
index 7a0983afdba..fb55d84adfd 100644
--- a/src/shared/WebviewMessage.ts
+++ b/src/shared/WebviewMessage.ts
@@ -40,6 +40,7 @@ export interface WebviewMessage {
| "toggleToolAlwaysAllow"
| "toggleMcpServer"
| "fuzzyMatchThreshold"
+ | "multisearchDiffEnabled"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx
index 465fd5a9750..5db9252ffae 100644
--- a/webview-ui/src/components/settings/SettingsView.tsx
+++ b/webview-ui/src/components/settings/SettingsView.tsx
@@ -40,6 +40,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
allowedCommands,
fuzzyMatchThreshold,
setFuzzyMatchThreshold,
+ multisearchDiffEnabled,
+ setMultisearchDiffEnabled,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined)
@@ -67,6 +69,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
vscode.postMessage({ type: "browserLargeViewport", bool: browserLargeViewport })
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
+ vscode.postMessage({ type: "multisearchDiffEnabled", bool: multisearchDiffEnabled })
onDone()
}
}
@@ -365,6 +368,22 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
+ {diffEnabled && (
+
+
setMultisearchDiffEnabled(e.target.checked)}>
+ Enable parallel diff edits
+
+
+ When enabled, Cline will attempt to apply multiple diff edits in parallel, making editing faster.
+
+
+ )}
+
setSoundEnabled(e.target.checked)}>
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx
index c2a1cf0d290..e2a99f3081b 100644
--- a/webview-ui/src/context/ExtensionStateContext.tsx
+++ b/webview-ui/src/context/ExtensionStateContext.tsx
@@ -33,6 +33,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setDiffEnabled: (value: boolean) => void
setBrowserLargeViewport: (value: boolean) => void
setFuzzyMatchThreshold: (value: number) => void
+ setMultisearchDiffEnabled: (value: boolean) => void
}
const ExtensionStateContext = createContext(undefined)
@@ -48,6 +49,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
soundVolume: 0.5,
diffEnabled: false,
fuzzyMatchThreshold: 1.0,
+ multisearchDiffEnabled: false,
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@@ -136,6 +138,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
filePaths,
soundVolume: state.soundVolume,
fuzzyMatchThreshold: state.fuzzyMatchThreshold,
+ multisearchDiffEnabled: state.multisearchDiffEnabled,
setApiConfiguration: (value) => setState((prevState) => ({
...prevState,
apiConfiguration: value
@@ -153,6 +156,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
setBrowserLargeViewport: (value) => setState((prevState) => ({ ...prevState, browserLargeViewport: value })),
setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),
+ setMultisearchDiffEnabled: (value: boolean) => setState((prevState) => ({ ...prevState, multisearchDiffEnabled: value })),
}
return {children}