Просмотр исходного кода

Make fuzzy diff matching configurable (and default to off)

Matt Rubens 1 год назад
Родитель
Сommit
3aca5e813e

+ 5 - 0
.changeset/early-pigs-carry.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Make fuzzy diff matching configurable (and default to off)

+ 9 - 6
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

+ 63 - 6
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');
         });

+ 3 - 3
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 }

+ 3 - 1
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;
     }

+ 14 - 2
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<ApiProvider | undefined>,
 			this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1101,6 +1111,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
 			this.getGlobalState("soundVolume") as Promise<number | undefined>,
 			this.getGlobalState("browserLargeViewport") as Promise<boolean | undefined>,
+			this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
 		])
 
 		let apiProvider: ApiProvider
@@ -1158,6 +1169,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			diffEnabled: diffEnabled ?? false,
 			soundVolume,
 			browserLargeViewport: browserLargeViewport ?? false,
+			fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
 		}
 	}
 

+ 1 - 0
src/shared/ExtensionMessage.ts

@@ -54,6 +54,7 @@ export interface ExtensionState {
 	soundVolume?: number
 	diffEnabled?: boolean
 	browserLargeViewport?: boolean
+	fuzzyMatchThreshold?: number
 }
 
 export interface ClineMessage {

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -39,6 +39,7 @@ export interface WebviewMessage {
 		| "restartMcpServer"
 		| "toggleToolAlwaysAllow"
 		| "toggleMcpServer"
+		| "fuzzyMatchThreshold"
 	text?: string
 	disabled?: boolean
 	askResponse?: ClineAskResponse

+ 34 - 3
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<string | undefined>(undefined)
 	const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(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.
 					</p>
+
+					{diffEnabled && (
+						<div style={{ marginTop: 10 }}>
+							<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
+								<span style={{ fontWeight: "500", minWidth: '100px' }}>Match precision</span>
+								<input
+									type="range"
+									min="0.9"
+									max="1"
+									step="0.005"
+									value={fuzzyMatchThreshold ?? 1.0}
+									onChange={(e) => {
+										setFuzzyMatchThreshold(parseFloat(e.target.value));
+									}}
+									style={{
+										flexGrow: 1,
+										accentColor: 'var(--vscode-button-background)',
+										height: '2px'
+									}}
+								/>
+								<span style={{ minWidth: '35px', textAlign: 'left' }}>
+									{Math.round((fuzzyMatchThreshold || 1) * 100)}%
+								</span>
+							</div>
+							<p style={{ fontSize: "12px", marginBottom: 10, color: "var(--vscode-descriptionForeground)" }}>
+								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.
+							</p>
+						</div>
+					)}
 				</div>
 
 				<div style={{ marginBottom: 5 }}>
@@ -351,7 +382,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 						{soundEnabled && (
 							<div style={{ marginLeft: 0 }}>
 								<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
-									<span style={{ fontWeight: "500", minWidth: '50px' }}>Volume</span>
+									<span style={{ fontWeight: "500", minWidth: '100px' }}>Volume</span>
 									<input
 										type="range"
 										min="0"

+ 4 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -32,6 +32,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setSoundVolume: (value: number) => void
 	setDiffEnabled: (value: boolean) => void
 	setBrowserLargeViewport: (value: boolean) => void
+	setFuzzyMatchThreshold: (value: number) => void
 }
 
 const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>