Skip to content

Commit 3aca5e8

Browse files
committed
Make fuzzy diff matching configurable (and default to off)
1 parent 1beb3a3 commit 3aca5e8

File tree

10 files changed

+137
-21
lines changed

10 files changed

+137
-21
lines changed

.changeset/early-pigs-carry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Make fuzzy diff matching configurable (and default to off)

src/core/Cline.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export class Cline {
6767
private didEditFile: boolean = false
6868
customInstructions?: string
6969
diffStrategy?: DiffStrategy
70+
diffEnabled: boolean = false
7071

7172
apiConversationHistory: Anthropic.MessageParam[] = []
7273
clineMessages: ClineMessage[] = []
@@ -97,10 +98,11 @@ export class Cline {
9798
provider: ClineProvider,
9899
apiConfiguration: ApiConfiguration,
99100
customInstructions?: string,
100-
diffEnabled?: boolean,
101-
task?: string,
102-
images?: string[],
103-
historyItem?: HistoryItem,
101+
enableDiff?: boolean,
102+
fuzzyMatchThreshold?: number,
103+
task?: string | undefined,
104+
images?: string[] | undefined,
105+
historyItem?: HistoryItem | undefined,
104106
) {
105107
this.providerRef = new WeakRef(provider)
106108
this.api = buildApiHandler(apiConfiguration)
@@ -109,8 +111,9 @@ export class Cline {
109111
this.browserSession = new BrowserSession(provider.context)
110112
this.diffViewProvider = new DiffViewProvider(cwd)
111113
this.customInstructions = customInstructions
112-
if (diffEnabled && this.api.getModel().id) {
113-
this.diffStrategy = getDiffStrategy(this.api.getModel().id)
114+
this.diffEnabled = enableDiff ?? false
115+
if (this.diffEnabled && this.api.getModel().id) {
116+
this.diffStrategy = getDiffStrategy(this.api.getModel().id, fuzzyMatchThreshold ?? 1.0)
114117
}
115118
if (historyItem) {
116119
this.taskId = historyItem.id

src/core/__tests__/Cline.test.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ describe('Cline', () => {
248248
// Setup mock API configuration
249249
mockApiConfig = {
250250
apiProvider: 'anthropic',
251-
apiModelId: 'claude-3-sonnet'
251+
apiModelId: 'claude-3-5-sonnet-20241022'
252252
};
253253

254254
// Mock provider methods
@@ -278,20 +278,77 @@ describe('Cline', () => {
278278
mockProvider,
279279
mockApiConfig,
280280
'custom instructions',
281-
false, // diffEnabled
282-
'test task', // task
283-
undefined, // images
284-
undefined // historyItem
281+
false,
282+
0.95, // 95% threshold
283+
'test task'
285284
);
286285

287286
expect(cline.customInstructions).toBe('custom instructions');
287+
expect(cline.diffEnabled).toBe(false);
288+
});
289+
290+
it('should use default fuzzy match threshold when not provided', () => {
291+
const cline = new Cline(
292+
mockProvider,
293+
mockApiConfig,
294+
'custom instructions',
295+
true,
296+
undefined,
297+
'test task'
298+
);
299+
300+
expect(cline.diffEnabled).toBe(true);
301+
// The diff strategy should be created with default threshold (1.0)
302+
expect(cline.diffStrategy).toBeDefined();
303+
});
304+
305+
it('should use provided fuzzy match threshold', () => {
306+
const getDiffStrategySpy = jest.spyOn(require('../diff/DiffStrategy'), 'getDiffStrategy');
307+
308+
const cline = new Cline(
309+
mockProvider,
310+
mockApiConfig,
311+
'custom instructions',
312+
true,
313+
0.9, // 90% threshold
314+
'test task'
315+
);
316+
317+
expect(cline.diffEnabled).toBe(true);
318+
expect(cline.diffStrategy).toBeDefined();
319+
expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 0.9);
320+
321+
getDiffStrategySpy.mockRestore();
322+
});
323+
324+
it('should pass default threshold to diff strategy when not provided', () => {
325+
const getDiffStrategySpy = jest.spyOn(require('../diff/DiffStrategy'), 'getDiffStrategy');
326+
327+
const cline = new Cline(
328+
mockProvider,
329+
mockApiConfig,
330+
'custom instructions',
331+
true,
332+
undefined,
333+
'test task'
334+
);
335+
336+
expect(cline.diffEnabled).toBe(true);
337+
expect(cline.diffStrategy).toBeDefined();
338+
expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 1.0);
339+
340+
getDiffStrategySpy.mockRestore();
288341
});
289342

290343
it('should require either task or historyItem', () => {
291344
expect(() => {
292345
new Cline(
293346
mockProvider,
294-
mockApiConfig
347+
mockApiConfig,
348+
undefined, // customInstructions
349+
false, // diffEnabled
350+
undefined, // fuzzyMatchThreshold
351+
undefined // task
295352
);
296353
}).toThrow('Either historyItem or task/images must be provided');
297354
});

src/core/diff/DiffStrategy.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { SearchReplaceDiffStrategy } from './strategies/search-replace'
66
* @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus')
77
* @returns The appropriate diff strategy for the model
88
*/
9-
export function getDiffStrategy(model: string): DiffStrategy {
10-
// For now, return SearchReplaceDiffStrategy for all models (with a fuzzy threshold of 0.9)
9+
export function getDiffStrategy(model: string, fuzzyMatchThreshold?: number): DiffStrategy {
10+
// For now, return SearchReplaceDiffStrategy for all models
1111
// This architecture allows for future optimizations based on model capabilities
12-
return new SearchReplaceDiffStrategy(0.9)
12+
return new SearchReplaceDiffStrategy(fuzzyMatchThreshold ?? 1.0)
1313
}
1414

1515
export type { DiffStrategy }

src/core/diff/strategies/search-replace.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ export class SearchReplaceDiffStrategy implements DiffStrategy {
5858
private bufferLines: number;
5959

6060
constructor(fuzzyThreshold?: number, bufferLines?: number) {
61-
// Default to exact matching (1.0) unless fuzzy threshold specified
61+
// Use provided threshold or default to exact matching (1.0)
62+
// Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9)
63+
// so we use it directly here
6264
this.fuzzyThreshold = fuzzyThreshold ?? 1.0;
6365
this.bufferLines = bufferLines ?? BUFFER_LINES;
6466
}

src/core/webview/ClineProvider.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ type GlobalStateKey =
7070
| "diffEnabled"
7171
| "alwaysAllowMcp"
7272
| "browserLargeViewport"
73+
| "fuzzyMatchThreshold"
7374

7475
export const GlobalFileNames = {
7576
apiConversationHistory: "api_conversation_history.json",
@@ -217,14 +218,16 @@ export class ClineProvider implements vscode.WebviewViewProvider {
217218
const {
218219
apiConfiguration,
219220
customInstructions,
220-
diffEnabled
221+
diffEnabled,
222+
fuzzyMatchThreshold
221223
} = await this.getState()
222224

223225
this.cline = new Cline(
224226
this,
225227
apiConfiguration,
226228
customInstructions,
227229
diffEnabled,
230+
fuzzyMatchThreshold,
228231
task,
229232
images
230233
)
@@ -235,14 +238,16 @@ export class ClineProvider implements vscode.WebviewViewProvider {
235238
const {
236239
apiConfiguration,
237240
customInstructions,
238-
diffEnabled
241+
diffEnabled,
242+
fuzzyMatchThreshold
239243
} = await this.getState()
240244

241245
this.cline = new Cline(
242246
this,
243247
apiConfiguration,
244248
customInstructions,
245249
diffEnabled,
250+
fuzzyMatchThreshold,
246251
undefined,
247252
undefined,
248253
historyItem
@@ -613,6 +618,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
613618
await this.updateGlobalState("browserLargeViewport", browserLargeViewport)
614619
await this.postStateToWebview()
615620
break
621+
case "fuzzyMatchThreshold":
622+
await this.updateGlobalState("fuzzyMatchThreshold", message.value)
623+
await this.postStateToWebview()
624+
break
616625
}
617626
},
618627
null,
@@ -1062,6 +1071,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
10621071
diffEnabled,
10631072
soundVolume,
10641073
browserLargeViewport,
1074+
fuzzyMatchThreshold,
10651075
] = await Promise.all([
10661076
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
10671077
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1101,6 +1111,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
11011111
this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
11021112
this.getGlobalState("soundVolume") as Promise<number | undefined>,
11031113
this.getGlobalState("browserLargeViewport") as Promise<boolean | undefined>,
1114+
this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
11041115
])
11051116

11061117
let apiProvider: ApiProvider
@@ -1158,6 +1169,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
11581169
diffEnabled: diffEnabled ?? false,
11591170
soundVolume,
11601171
browserLargeViewport: browserLargeViewport ?? false,
1172+
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
11611173
}
11621174
}
11631175

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface ExtensionState {
5454
soundVolume?: number
5555
diffEnabled?: boolean
5656
browserLargeViewport?: boolean
57+
fuzzyMatchThreshold?: number
5758
}
5859

5960
export interface ClineMessage {

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface WebviewMessage {
3939
| "restartMcpServer"
4040
| "toggleToolAlwaysAllow"
4141
| "toggleMcpServer"
42+
| "fuzzyMatchThreshold"
4243
text?: string
4344
disabled?: boolean
4445
askResponse?: ClineAskResponse

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,17 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
3333
setSoundVolume,
3434
diffEnabled,
3535
setDiffEnabled,
36-
browserLargeViewport = false,
36+
browserLargeViewport,
3737
setBrowserLargeViewport,
3838
openRouterModels,
3939
setAllowedCommands,
4040
allowedCommands,
41+
fuzzyMatchThreshold,
42+
setFuzzyMatchThreshold,
4143
} = useExtensionState()
4244
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
4345
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
4446
const [commandInput, setCommandInput] = useState("")
45-
4647
const handleSubmit = () => {
4748
const apiValidationResult = validateApiConfiguration(apiConfiguration)
4849
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
@@ -65,6 +66,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
6566
vscode.postMessage({ type: "soundVolume", value: soundVolume })
6667
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
6768
vscode.postMessage({ type: "browserLargeViewport", bool: browserLargeViewport })
69+
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
6870
onDone()
6971
}
7072
}
@@ -166,6 +168,35 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
166168
}}>
167169
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.
168170
</p>
171+
172+
{diffEnabled && (
173+
<div style={{ marginTop: 10 }}>
174+
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
175+
<span style={{ fontWeight: "500", minWidth: '100px' }}>Match precision</span>
176+
<input
177+
type="range"
178+
min="0.9"
179+
max="1"
180+
step="0.005"
181+
value={fuzzyMatchThreshold ?? 1.0}
182+
onChange={(e) => {
183+
setFuzzyMatchThreshold(parseFloat(e.target.value));
184+
}}
185+
style={{
186+
flexGrow: 1,
187+
accentColor: 'var(--vscode-button-background)',
188+
height: '2px'
189+
}}
190+
/>
191+
<span style={{ minWidth: '35px', textAlign: 'left' }}>
192+
{Math.round((fuzzyMatchThreshold || 1) * 100)}%
193+
</span>
194+
</div>
195+
<p style={{ fontSize: "12px", marginBottom: 10, color: "var(--vscode-descriptionForeground)" }}>
196+
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.
197+
</p>
198+
</div>
199+
)}
169200
</div>
170201

171202
<div style={{ marginBottom: 5 }}>
@@ -351,7 +382,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
351382
{soundEnabled && (
352383
<div style={{ marginLeft: 0 }}>
353384
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
354-
<span style={{ fontWeight: "500", minWidth: '50px' }}>Volume</span>
385+
<span style={{ fontWeight: "500", minWidth: '100px' }}>Volume</span>
355386
<input
356387
type="range"
357388
min="0"

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface ExtensionStateContextType extends ExtensionState {
3232
setSoundVolume: (value: number) => void
3333
setDiffEnabled: (value: boolean) => void
3434
setBrowserLargeViewport: (value: boolean) => void
35+
setFuzzyMatchThreshold: (value: number) => void
3536
}
3637

3738
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -46,6 +47,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
4647
soundEnabled: false,
4748
soundVolume: 0.5,
4849
diffEnabled: false,
50+
fuzzyMatchThreshold: 1.0,
4951
})
5052
const [didHydrateState, setDidHydrateState] = useState(false)
5153
const [showWelcome, setShowWelcome] = useState(false)
@@ -133,6 +135,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
133135
mcpServers,
134136
filePaths,
135137
soundVolume: state.soundVolume,
138+
fuzzyMatchThreshold: state.fuzzyMatchThreshold,
136139
setApiConfiguration: (value) => setState((prevState) => ({
137140
...prevState,
138141
apiConfiguration: value
@@ -149,6 +152,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
149152
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
150153
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
151154
setBrowserLargeViewport: (value) => setState((prevState) => ({ ...prevState, browserLargeViewport: value })),
155+
setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),
152156
}
153157

154158
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

0 commit comments

Comments
 (0)