Make fuzzy diff matching configurable (and default to off)

This commit is contained in:
Matt Rubens
2024-12-18 12:25:57 -05:00
parent 1beb3a3cf6
commit 3aca5e813e
10 changed files with 137 additions and 21 deletions

View File

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

View File

@@ -67,6 +67,7 @@ export class Cline {
private didEditFile: boolean = false private didEditFile: boolean = false
customInstructions?: string customInstructions?: string
diffStrategy?: DiffStrategy diffStrategy?: DiffStrategy
diffEnabled: boolean = false
apiConversationHistory: Anthropic.MessageParam[] = [] apiConversationHistory: Anthropic.MessageParam[] = []
clineMessages: ClineMessage[] = [] clineMessages: ClineMessage[] = []
@@ -97,10 +98,11 @@ export class Cline {
provider: ClineProvider, provider: ClineProvider,
apiConfiguration: ApiConfiguration, apiConfiguration: ApiConfiguration,
customInstructions?: string, customInstructions?: string,
diffEnabled?: boolean, enableDiff?: boolean,
task?: string, fuzzyMatchThreshold?: number,
images?: string[], task?: string | undefined,
historyItem?: HistoryItem, images?: string[] | undefined,
historyItem?: HistoryItem | undefined,
) { ) {
this.providerRef = new WeakRef(provider) this.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration) this.api = buildApiHandler(apiConfiguration)
@@ -109,8 +111,9 @@ export class Cline {
this.browserSession = new BrowserSession(provider.context) this.browserSession = new BrowserSession(provider.context)
this.diffViewProvider = new DiffViewProvider(cwd) this.diffViewProvider = new DiffViewProvider(cwd)
this.customInstructions = customInstructions this.customInstructions = customInstructions
if (diffEnabled && this.api.getModel().id) { this.diffEnabled = enableDiff ?? false
this.diffStrategy = getDiffStrategy(this.api.getModel().id) if (this.diffEnabled && this.api.getModel().id) {
this.diffStrategy = getDiffStrategy(this.api.getModel().id, fuzzyMatchThreshold ?? 1.0)
} }
if (historyItem) { if (historyItem) {
this.taskId = historyItem.id this.taskId = historyItem.id

View File

@@ -248,7 +248,7 @@ describe('Cline', () => {
// Setup mock API configuration // Setup mock API configuration
mockApiConfig = { mockApiConfig = {
apiProvider: 'anthropic', apiProvider: 'anthropic',
apiModelId: 'claude-3-sonnet' apiModelId: 'claude-3-5-sonnet-20241022'
}; };
// Mock provider methods // Mock provider methods
@@ -278,20 +278,77 @@ describe('Cline', () => {
mockProvider, mockProvider,
mockApiConfig, mockApiConfig,
'custom instructions', 'custom instructions',
false, // diffEnabled false,
'test task', // task 0.95, // 95% threshold
undefined, // images 'test task'
undefined // historyItem
); );
expect(cline.customInstructions).toBe('custom instructions'); 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', () => { it('should require either task or historyItem', () => {
expect(() => { expect(() => {
new Cline( new Cline(
mockProvider, mockProvider,
mockApiConfig mockApiConfig,
undefined, // customInstructions
false, // diffEnabled
undefined, // fuzzyMatchThreshold
undefined // task
); );
}).toThrow('Either historyItem or task/images must be provided'); }).toThrow('Either historyItem or task/images must be provided');
}); });

View File

@@ -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') * @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus')
* @returns The appropriate diff strategy for the model * @returns The appropriate diff strategy for the model
*/ */
export function getDiffStrategy(model: string): DiffStrategy { export function getDiffStrategy(model: string, fuzzyMatchThreshold?: number): DiffStrategy {
// For now, return SearchReplaceDiffStrategy for all models (with a fuzzy threshold of 0.9) // For now, return SearchReplaceDiffStrategy for all models
// This architecture allows for future optimizations based on model capabilities // This architecture allows for future optimizations based on model capabilities
return new SearchReplaceDiffStrategy(0.9) return new SearchReplaceDiffStrategy(fuzzyMatchThreshold ?? 1.0)
} }
export type { DiffStrategy } export type { DiffStrategy }

View File

@@ -58,7 +58,9 @@ export class SearchReplaceDiffStrategy implements DiffStrategy {
private bufferLines: number; private bufferLines: number;
constructor(fuzzyThreshold?: number, 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.fuzzyThreshold = fuzzyThreshold ?? 1.0;
this.bufferLines = bufferLines ?? BUFFER_LINES; this.bufferLines = bufferLines ?? BUFFER_LINES;
} }

View File

@@ -70,6 +70,7 @@ type GlobalStateKey =
| "diffEnabled" | "diffEnabled"
| "alwaysAllowMcp" | "alwaysAllowMcp"
| "browserLargeViewport" | "browserLargeViewport"
| "fuzzyMatchThreshold"
export const GlobalFileNames = { export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json", apiConversationHistory: "api_conversation_history.json",
@@ -217,7 +218,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const { const {
apiConfiguration, apiConfiguration,
customInstructions, customInstructions,
diffEnabled diffEnabled,
fuzzyMatchThreshold
} = await this.getState() } = await this.getState()
this.cline = new Cline( this.cline = new Cline(
@@ -225,6 +227,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration, apiConfiguration,
customInstructions, customInstructions,
diffEnabled, diffEnabled,
fuzzyMatchThreshold,
task, task,
images images
) )
@@ -235,7 +238,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const { const {
apiConfiguration, apiConfiguration,
customInstructions, customInstructions,
diffEnabled diffEnabled,
fuzzyMatchThreshold
} = await this.getState() } = await this.getState()
this.cline = new Cline( this.cline = new Cline(
@@ -243,6 +247,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration, apiConfiguration,
customInstructions, customInstructions,
diffEnabled, diffEnabled,
fuzzyMatchThreshold,
undefined, undefined,
undefined, undefined,
historyItem historyItem
@@ -613,6 +618,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("browserLargeViewport", browserLargeViewport) await this.updateGlobalState("browserLargeViewport", browserLargeViewport)
await this.postStateToWebview() await this.postStateToWebview()
break break
case "fuzzyMatchThreshold":
await this.updateGlobalState("fuzzyMatchThreshold", message.value)
await this.postStateToWebview()
break
} }
}, },
null, null,
@@ -1062,6 +1071,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
diffEnabled, diffEnabled,
soundVolume, soundVolume,
browserLargeViewport, browserLargeViewport,
fuzzyMatchThreshold,
] = await Promise.all([ ] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>, this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | 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("diffEnabled") as Promise<boolean | undefined>,
this.getGlobalState("soundVolume") as Promise<number | undefined>, this.getGlobalState("soundVolume") as Promise<number | undefined>,
this.getGlobalState("browserLargeViewport") as Promise<boolean | undefined>, this.getGlobalState("browserLargeViewport") as Promise<boolean | undefined>,
this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
]) ])
let apiProvider: ApiProvider let apiProvider: ApiProvider
@@ -1158,6 +1169,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
diffEnabled: diffEnabled ?? false, diffEnabled: diffEnabled ?? false,
soundVolume, soundVolume,
browserLargeViewport: browserLargeViewport ?? false, browserLargeViewport: browserLargeViewport ?? false,
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
} }
} }

View File

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

View File

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

View File

@@ -33,16 +33,17 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setSoundVolume, setSoundVolume,
diffEnabled, diffEnabled,
setDiffEnabled, setDiffEnabled,
browserLargeViewport = false, browserLargeViewport,
setBrowserLargeViewport, setBrowserLargeViewport,
openRouterModels, openRouterModels,
setAllowedCommands, setAllowedCommands,
allowedCommands, allowedCommands,
fuzzyMatchThreshold,
setFuzzyMatchThreshold,
} = useExtensionState() } = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined) const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const [commandInput, setCommandInput] = useState("") const [commandInput, setCommandInput] = useState("")
const handleSubmit = () => { const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration) const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels) const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
@@ -65,6 +66,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "soundVolume", value: soundVolume }) vscode.postMessage({ type: "soundVolume", value: soundVolume })
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
vscode.postMessage({ type: "browserLargeViewport", bool: browserLargeViewport }) vscode.postMessage({ type: "browserLargeViewport", bool: browserLargeViewport })
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
onDone() 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. 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> </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>
<div style={{ marginBottom: 5 }}> <div style={{ marginBottom: 5 }}>
@@ -351,7 +382,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
{soundEnabled && ( {soundEnabled && (
<div style={{ marginLeft: 0 }}> <div style={{ marginLeft: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}> <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 <input
type="range" type="range"
min="0" min="0"

View File

@@ -32,6 +32,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setSoundVolume: (value: number) => void setSoundVolume: (value: number) => void
setDiffEnabled: (value: boolean) => void setDiffEnabled: (value: boolean) => void
setBrowserLargeViewport: (value: boolean) => void setBrowserLargeViewport: (value: boolean) => void
setFuzzyMatchThreshold: (value: number) => void
} }
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined) const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -46,6 +47,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
soundEnabled: false, soundEnabled: false,
soundVolume: 0.5, soundVolume: 0.5,
diffEnabled: false, diffEnabled: false,
fuzzyMatchThreshold: 1.0,
}) })
const [didHydrateState, setDidHydrateState] = useState(false) const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false) const [showWelcome, setShowWelcome] = useState(false)
@@ -133,6 +135,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
mcpServers, mcpServers,
filePaths, filePaths,
soundVolume: state.soundVolume, soundVolume: state.soundVolume,
fuzzyMatchThreshold: state.fuzzyMatchThreshold,
setApiConfiguration: (value) => setState((prevState) => ({ setApiConfiguration: (value) => setState((prevState) => ({
...prevState, ...prevState,
apiConfiguration: value apiConfiguration: value
@@ -149,6 +152,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })), setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })), setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
setBrowserLargeViewport: (value) => setState((prevState) => ({ ...prevState, browserLargeViewport: value })), setBrowserLargeViewport: (value) => setState((prevState) => ({ ...prevState, browserLargeViewport: value })),
setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),
} }
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider> return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>