Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/early-pigs-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"roo-cline": patch
---

Make fuzzy diff matching configurable (and default to off)
15 changes: 9 additions & 6 deletions src/core/Cline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export class Cline {
private didEditFile: boolean = false
customInstructions?: string
diffStrategy?: DiffStrategy
diffEnabled: boolean = false

apiConversationHistory: Anthropic.MessageParam[] = []
clineMessages: ClineMessage[] = []
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
69 changes: 63 additions & 6 deletions src/core/__tests__/Cline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
});
Expand Down
6 changes: 3 additions & 3 deletions src/core/diff/DiffStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 3 additions & 1 deletion src/core/diff/strategies/search-replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
16 changes: 14 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type GlobalStateKey =
| "diffEnabled"
| "alwaysAllowMcp"
| "browserLargeViewport"
| "fuzzyMatchThreshold"

export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
Expand Down Expand Up @@ -217,14 +218,16 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const {
apiConfiguration,
customInstructions,
diffEnabled
diffEnabled,
fuzzyMatchThreshold
} = await this.getState()

this.cline = new Cline(
this,
apiConfiguration,
customInstructions,
diffEnabled,
fuzzyMatchThreshold,
task,
images
)
Expand All @@ -235,14 +238,16 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const {
apiConfiguration,
customInstructions,
diffEnabled
diffEnabled,
fuzzyMatchThreshold
} = await this.getState()

this.cline = new Cline(
this,
apiConfiguration,
customInstructions,
diffEnabled,
fuzzyMatchThreshold,
undefined,
undefined,
historyItem
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1158,6 +1169,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
diffEnabled: diffEnabled ?? false,
soundVolume,
browserLargeViewport: browserLargeViewport ?? false,
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface ExtensionState {
soundVolume?: number
diffEnabled?: boolean
browserLargeViewport?: boolean
fuzzyMatchThreshold?: number
}

export interface ClineMessage {
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface WebviewMessage {
| "restartMcpServer"
| "toggleToolAlwaysAllow"
| "toggleMcpServer"
| "fuzzyMatchThreshold"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
Expand Down
37 changes: 34 additions & 3 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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 }}>
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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>
Expand Down
Loading