diff --git a/.changeset/early-pigs-carry.md b/.changeset/early-pigs-carry.md new file mode 100644 index 00000000000..61da7a00fa3 --- /dev/null +++ b/.changeset/early-pigs-carry.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Make fuzzy diff matching configurable (and default to off) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index cfbd4fd1057..e3e0f8db923 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -67,6 +67,7 @@ export class Cline { private didEditFile: boolean = false customInstructions?: string diffStrategy?: DiffStrategy + diffEnabled: boolean = false apiConversationHistory: Anthropic.MessageParam[] = [] clineMessages: ClineMessage[] = [] @@ -97,10 +98,11 @@ export class Cline { provider: ClineProvider, apiConfiguration: ApiConfiguration, customInstructions?: string, - diffEnabled?: boolean, - task?: string, - images?: string[], - historyItem?: HistoryItem, + enableDiff?: boolean, + fuzzyMatchThreshold?: number, + task?: string | undefined, + images?: string[] | undefined, + historyItem?: HistoryItem | undefined, ) { this.providerRef = new WeakRef(provider) this.api = buildApiHandler(apiConfiguration) @@ -109,8 +111,9 @@ export class Cline { this.browserSession = new BrowserSession(provider.context) this.diffViewProvider = new DiffViewProvider(cwd) this.customInstructions = customInstructions - if (diffEnabled && this.api.getModel().id) { - this.diffStrategy = getDiffStrategy(this.api.getModel().id) + this.diffEnabled = enableDiff ?? false + if (this.diffEnabled && this.api.getModel().id) { + this.diffStrategy = getDiffStrategy(this.api.getModel().id, fuzzyMatchThreshold ?? 1.0) } if (historyItem) { this.taskId = historyItem.id diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 8365c61aa41..ed755bd3997 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -248,7 +248,7 @@ describe('Cline', () => { // Setup mock API configuration mockApiConfig = { apiProvider: 'anthropic', - apiModelId: 'claude-3-sonnet' + apiModelId: 'claude-3-5-sonnet-20241022' }; // Mock provider methods @@ -278,20 +278,77 @@ describe('Cline', () => { mockProvider, mockApiConfig, 'custom instructions', - false, // diffEnabled - 'test task', // task - undefined, // images - undefined // historyItem + false, + 0.95, // 95% threshold + 'test task' ); expect(cline.customInstructions).toBe('custom instructions'); + expect(cline.diffEnabled).toBe(false); + }); + + it('should use default fuzzy match threshold when not provided', () => { + const cline = new Cline( + mockProvider, + mockApiConfig, + 'custom instructions', + true, + undefined, + 'test task' + ); + + expect(cline.diffEnabled).toBe(true); + // The diff strategy should be created with default threshold (1.0) + expect(cline.diffStrategy).toBeDefined(); + }); + + it('should use provided fuzzy match threshold', () => { + const getDiffStrategySpy = jest.spyOn(require('../diff/DiffStrategy'), 'getDiffStrategy'); + + const cline = new Cline( + mockProvider, + mockApiConfig, + 'custom instructions', + true, + 0.9, // 90% threshold + 'test task' + ); + + expect(cline.diffEnabled).toBe(true); + expect(cline.diffStrategy).toBeDefined(); + expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 0.9); + + getDiffStrategySpy.mockRestore(); + }); + + it('should pass default threshold to diff strategy when not provided', () => { + const getDiffStrategySpy = jest.spyOn(require('../diff/DiffStrategy'), 'getDiffStrategy'); + + const cline = new Cline( + mockProvider, + mockApiConfig, + 'custom instructions', + true, + undefined, + 'test task' + ); + + expect(cline.diffEnabled).toBe(true); + expect(cline.diffStrategy).toBeDefined(); + expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 1.0); + + getDiffStrategySpy.mockRestore(); }); it('should require either task or historyItem', () => { expect(() => { new Cline( mockProvider, - mockApiConfig + mockApiConfig, + undefined, // customInstructions + false, // diffEnabled + undefined, // fuzzyMatchThreshold + undefined // task ); }).toThrow('Either historyItem or task/images must be provided'); }); diff --git a/src/core/diff/DiffStrategy.ts b/src/core/diff/DiffStrategy.ts index 355424e48d8..c6118564e98 100644 --- a/src/core/diff/DiffStrategy.ts +++ b/src/core/diff/DiffStrategy.ts @@ -6,10 +6,10 @@ import { SearchReplaceDiffStrategy } from './strategies/search-replace' * @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): DiffStrategy { - // For now, return SearchReplaceDiffStrategy for all models (with a fuzzy threshold of 0.9) +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(0.9) + return new SearchReplaceDiffStrategy(fuzzyMatchThreshold ?? 1.0) } export type { DiffStrategy } diff --git a/src/core/diff/strategies/search-replace.ts b/src/core/diff/strategies/search-replace.ts index 4681351b4e6..3990848e00d 100644 --- a/src/core/diff/strategies/search-replace.ts +++ b/src/core/diff/strategies/search-replace.ts @@ -58,7 +58,9 @@ export class SearchReplaceDiffStrategy implements DiffStrategy { private bufferLines: number; constructor(fuzzyThreshold?: number, bufferLines?: number) { - // Default to exact matching (1.0) unless fuzzy threshold specified + // 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; } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f42cd193258..2245d8036fc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -70,6 +70,7 @@ type GlobalStateKey = | "diffEnabled" | "alwaysAllowMcp" | "browserLargeViewport" + | "fuzzyMatchThreshold" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -217,7 +218,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { const { apiConfiguration, customInstructions, - diffEnabled + diffEnabled, + fuzzyMatchThreshold } = await this.getState() this.cline = new Cline( @@ -225,6 +227,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiConfiguration, customInstructions, diffEnabled, + fuzzyMatchThreshold, task, images ) @@ -235,7 +238,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { const { apiConfiguration, customInstructions, - diffEnabled + diffEnabled, + fuzzyMatchThreshold } = await this.getState() this.cline = new Cline( @@ -243,6 +247,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiConfiguration, customInstructions, diffEnabled, + fuzzyMatchThreshold, undefined, undefined, historyItem @@ -613,6 +618,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("browserLargeViewport", browserLargeViewport) await this.postStateToWebview() break + case "fuzzyMatchThreshold": + await this.updateGlobalState("fuzzyMatchThreshold", message.value) + await this.postStateToWebview() + break } }, null, @@ -1062,6 +1071,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, soundVolume, browserLargeViewport, + fuzzyMatchThreshold, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1101,6 +1111,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("diffEnabled") as Promise, this.getGlobalState("soundVolume") as Promise, this.getGlobalState("browserLargeViewport") as Promise, + this.getGlobalState("fuzzyMatchThreshold") as Promise, ]) let apiProvider: ApiProvider @@ -1158,6 +1169,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled: diffEnabled ?? false, soundVolume, browserLargeViewport: browserLargeViewport ?? false, + fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0, } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 820acff980b..dcd352f8154 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -54,6 +54,7 @@ export interface ExtensionState { soundVolume?: number diffEnabled?: boolean browserLargeViewport?: boolean + fuzzyMatchThreshold?: number } export interface ClineMessage { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4c3562bc6ac..7a0983afdba 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -39,6 +39,7 @@ export interface WebviewMessage { | "restartMcpServer" | "toggleToolAlwaysAllow" | "toggleMcpServer" + | "fuzzyMatchThreshold" 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 30b652e3aa2..465fd5a9750 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -33,16 +33,17 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setSoundVolume, diffEnabled, setDiffEnabled, - browserLargeViewport = false, + browserLargeViewport, setBrowserLargeViewport, openRouterModels, setAllowedCommands, allowedCommands, + fuzzyMatchThreshold, + setFuzzyMatchThreshold, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) const [commandInput, setCommandInput] = useState("") - const handleSubmit = () => { const apiValidationResult = validateApiConfiguration(apiConfiguration) const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) @@ -65,6 +66,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "soundVolume", value: soundVolume }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) vscode.postMessage({ type: "browserLargeViewport", bool: browserLargeViewport }) + vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 }) onDone() } } @@ -166,6 +168,35 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { }}> When enabled, Cline will be able to edit files more quickly and will automatically reject truncated full-file writes. Works best with the latest Claude 3.5 Sonnet model.

+ + {diffEnabled && ( +
+
+ Match precision + { + setFuzzyMatchThreshold(parseFloat(e.target.value)); + }} + style={{ + flexGrow: 1, + accentColor: 'var(--vscode-button-background)', + height: '2px' + }} + /> + + {Math.round((fuzzyMatchThreshold || 1) * 100)}% + +
+

+ This slider controls how precisely code sections must match when applying diffs. Lower values allow more flexible matching but increase the risk of incorrect replacements. Use values below 100% with extreme caution. +

+
+ )}
@@ -351,7 +382,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { {soundEnabled && (
- Volume + Volume void setDiffEnabled: (value: boolean) => void setBrowserLargeViewport: (value: boolean) => void + setFuzzyMatchThreshold: (value: number) => void } const ExtensionStateContext = createContext(undefined) @@ -46,6 +47,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode soundEnabled: false, soundVolume: 0.5, diffEnabled: false, + fuzzyMatchThreshold: 1.0, }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -133,6 +135,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode mcpServers, filePaths, soundVolume: state.soundVolume, + fuzzyMatchThreshold: state.fuzzyMatchThreshold, setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value @@ -149,6 +152,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })), setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })), setBrowserLargeViewport: (value) => setState((prevState) => ({ ...prevState, browserLargeViewport: value })), + setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })), } return {children}