Turbo edits v2 (#1653)

Fixes #1222 #1646 

TODOs
- [x] description?
- [x] collect errors across all files for turbo edits
- [x] be forgiving around whitespaces
- [x] write e2e tests
- [x] do more manual testing across different models



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds Turbo Edits v2 search-replace flow with settings/UI selector,
parser/renderer, dry-run validation + fallback, proposal integration,
and comprehensive tests; updates licensing.
> 
> - **Engine/Processing**:
> - Add `dyad-search-replace` end-to-end: parsing
(`getDyadSearchReplaceTags`), markdown rendering (`DyadSearchReplace`),
and application (`applySearchReplace`) with dry-run validation and
fallback to `dyad-write`.
> - Inject Turbo Edits v2 system prompt; toggle via
`isTurboEditsV2Enabled`; disable classic lazy edits when v2 is on.
> - Include search-replace edits in proposals and full-response
processing.
> - **Settings/UI**:
> - Introduce `proLazyEditsMode` (`off`|`v1`|`v2`) and helper selectors;
update `ProModeSelector` with Turbo Edits and Smart Context selectors
(`data-testid`s).
> - **LLM/token flow**:
> - Construct system prompt conditionally; update token counting and
chat stream to validate and repair search-replace responses.
> - **Tests**:
> - Add unit tests for search-replace processor; e2e tests for Turbo
Edits v2 and options; fixtures and snapshots.
> - **Licensing/Docs**:
> - Add `src/pro/LICENSE` (FSL 1.1 ALv2 future), update root `LICENSE`
and README license section.
> - **Tooling**:
> - Update `.prettierignore`; enhance test helpers (selectors, path
normalization, snapshot filtering).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7aefa02bfae2fe22a25c7d87f3c4c326f820f1e6. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Will Chen
2025-10-28 11:36:20 -07:00
committed by GitHub
parent 8a3bc53832
commit a8f3c97396
36 changed files with 2537 additions and 72 deletions

105
src/pro/LICENSE Normal file
View File

@@ -0,0 +1,105 @@
# Functional Source License, Version 1.1, ALv2 Future License
## Abbreviation
FSL-1.1-ALv2
## Notice
Copyright 2025 Dyad Tech, Inc.
## Terms and Conditions
### Licensor ("We")
The party offering the Software under these Terms and Conditions.
### The Software
The "Software" is each version of the software that we make available under
these Terms and Conditions, as indicated by our inclusion of these Terms and
Conditions with the Software.
### License Grant
Subject to your compliance with this License Grant and the Patents,
Redistribution and Trademark clauses below, we hereby grant you the right to
use, copy, modify, create derivative works, publicly perform, publicly display
and redistribute the Software for any Permitted Purpose identified below.
### Permitted Purpose
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
means making the Software available to others in a commercial product or
service that:
1. substitutes for the Software;
2. substitutes for any other product or service we offer using the Software
that exists as of the date we make the Software available; or
3. offers the same or substantially similar functionality as the Software.
Permitted Purposes specifically include using the Software:
1. for your internal use and access;
2. for non-commercial education;
3. for non-commercial research; and
4. in connection with professional services that you provide to a licensee
using the Software in accordance with these Terms and Conditions.
### Patents
To the extent your use for a Permitted Purpose would necessarily infringe our
patents, the license grant above includes a license under our patents. If you
make a claim against any party that the Software infringes or contributes to
the infringement of any patent, then your patent license to the Software ends
immediately.
### Redistribution
The Terms and Conditions apply to all copies, modifications and derivatives of
the Software.
If you redistribute any copies, modifications or derivatives of the Software,
you must include a copy of or a link to these Terms and Conditions and not
remove any copyright notices provided in or with the Software.
### Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
### Trademarks
Except for displaying the License Details and identifying us as the origin of
the Software, you have no right under these Terms and Conditions to use our
trademarks, trade names, service marks or product names.
## Grant of Future License
We hereby irrevocably grant you an additional license to use the Software under
the Apache License, Version 2.0 that is effective on the second anniversary of
the date we make the Software available. On or after that date, you may use the
Software under the Apache License, Version 2.0, in which case the following
will apply:
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.

View File

@@ -0,0 +1,301 @@
import { describe, it, expect } from "vitest";
import { applySearchReplace } from "@/pro/main/ipc/processors/search_replace_processor";
import { parseSearchReplaceBlocks } from "@/pro/shared/search_replace_parser";
describe("search_replace_processor - parseSearchReplaceBlocks", () => {
it("parses multiple blocks with start_line in ascending order", () => {
const diff = `
<<<<<<< SEARCH
line one
=======
LINE ONE
>>>>>>> REPLACE
<<<<<<< SEARCH
line four
=======
LINE FOUR
>>>>>>> REPLACE
`;
const blocks = parseSearchReplaceBlocks(diff);
expect(blocks.length).toBe(2);
expect(blocks[0].searchContent.trim()).toBe("line one");
expect(blocks[0].replaceContent.trim()).toBe("LINE ONE");
});
});
describe("search_replace_processor - applySearchReplace", () => {
it("applies single block with exact start_line match", () => {
const original = [
"def calculate_total(items):",
" total = 0",
" for item in items:",
" total += item",
" return total",
"",
].join("\n");
const diff = `
<<<<<<< SEARCH
def calculate_total(items):
total = 0
=======
def calculate_sum(items):
total = 0
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
expect(content).toContain("def calculate_sum(items):");
expect(content).not.toContain("def calculate_total(items):");
});
it("falls back to global exact search when start_line missing", () => {
const original = ["alpha", "beta", "gamma"].join("\n");
const diff = `
<<<<<<< SEARCH
beta
=======
BETA
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
expect(content).toBe(["alpha", "BETA", "gamma"].join("\n"));
});
it("applies multiple blocks in order and accounts for line deltas", () => {
const original = ["1", "2", "3", "4", "5"].join("\n");
const diff = `
<<<<<<< SEARCH
1
=======
ONE\nONE-EXTRA
>>>>>>> REPLACE
<<<<<<< SEARCH
4
=======
FOUR
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
expect(content).toBe(
["ONE", "ONE-EXTRA", "2", "3", "FOUR", "5"].join("\n"),
);
});
it("detects and strips line-numbered content, inferring start line when omitted", () => {
const original = ["a", "b", "c", "d"].join("\n");
const diff = `
<<<<<<< SEARCH
a\nb
=======
A\nB
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
expect(content).toBe(["A", "B", "c", "d"].join("\n"));
});
it("preserves indentation relative to matched block", () => {
const original = [
"function test() {",
" if (x) {",
" doThing();",
" }",
"}",
].join("\n");
const diff = `
<<<<<<< SEARCH
if (x) {
doThing();
=======
if (x) {
doOther();
doAnother();
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
// The replacement lines should keep the base indent of two spaces (from matched block)
expect(content).toContain(" if (x) {");
expect(content).toContain(" doOther();");
expect(content).toContain(" doAnother();");
});
it("supports deletions when replace content is empty", () => {
const original = ["x", "y", "z"].join("\n");
const diff = `
<<<<<<< SEARCH
y
=======
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
expect(content).toBe(["x", "z"].join("\n"));
});
it("preserves CRLF line endings", () => {
const original = ["a", "b", "c"].join("\r\n");
const diff = `
<<<<<<< SEARCH
b
=======
B
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
expect(content).toBe(["a", "B", "c"].join("\r\n"));
});
it("unescapes markers inside content and matches literally", () => {
const original = ["begin", ">>>>>>> REPLACE", "end"].join("\n");
const diff = `
<<<<<<< SEARCH
\\>>>>>>> REPLACE
=======
LITERAL MARKER
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
expect(content).toBe(["begin", "LITERAL MARKER", "end"].join("\n"));
});
it("errors when SEARCH block does not match any content", () => {
const original = "foo\nbar\nbaz";
const diff = `
<<<<<<< SEARCH
NOT IN FILE
=======
STILL NOT
>>>>>>> REPLACE
`;
const { success, error } = applySearchReplace(original, diff);
expect(success).toBe(false);
expect(error).toMatch(/Search block did not match any content/i);
});
it("matches despite differing indentation and trailing whitespace", () => {
const original = [
"\tfunction example() {",
"\t doThing(); ", // extra trailing spaces
"\t}",
].join("\n");
const diff = `
<<<<<<< SEARCH
function example() {
doThing();
}
=======
function example() {
doOther();
}
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
expect(content).toContain("doOther();");
expect(content).not.toContain("doThing();");
});
it("matches when search uses spaces and target uses tabs (and vice versa)", () => {
const original = ["\tif (ready) {", "\t\tstart();", "\t}"].join("\n");
const diff = `
<<<<<<< SEARCH
if (ready) {
start();
}
=======
if (ready) {
launch();
}
>>>>>>> REPLACE
`;
const { success, content } = applySearchReplace(original, diff);
expect(success).toBe(true);
expect(content).toContain("launch();");
expect(content).not.toContain("start();");
});
it("errors when SEARCH and REPLACE blocks are identical", () => {
const original = ["x", "y", "z"].join("\n");
const diff = `
<<<<<<< SEARCH
middle
=======
middle
>>>>>>> REPLACE
`;
const { success, error } = applySearchReplace(original, diff);
expect(success).toBe(false);
expect(error).toMatch(/Search and replace blocks are identical/i);
});
it("errors when SEARCH block matches multiple locations (ambiguous)", () => {
const original = ["foo", "bar", "baz", "bar", "qux"].join("\n");
const diff = `
<<<<<<< SEARCH
bar
=======
BAR
>>>>>>> REPLACE
`;
const { success, error } = applySearchReplace(original, diff);
expect(success).toBe(false);
expect(error).toMatch(/(ambiguous|multiple)/i);
});
it("errors when SEARCH block fuzzy matches multiple locations (ambiguous)", () => {
const original = [
"\tif (ready) {",
"\t\tstart(); ",
"\t}",
" if (ready) {",
" start(); ",
" }",
].join("\n");
const diff = `
<<<<<<< SEARCH
if (ready) {
start();
}
=======
if (ready) {
launch();
}
>>>>>>> REPLACE
`;
const { success, error } = applySearchReplace(original, diff);
expect(success).toBe(false);
expect(error).toMatch(/fuzzy matched/i);
});
it("errors when SEARCH block is empty", () => {
const original = ["a", "b"].join("\n");
const diff = `
<<<<<<< SEARCH
=======
REPLACEMENT
>>>>>>> REPLACE
`;
const { success, error } = applySearchReplace(original, diff);
expect(success).toBe(false);
expect(error).toMatch(/empty SEARCH block is not allowed/i);
});
});

View File

@@ -0,0 +1,175 @@
/* eslint-disable no-irregular-whitespace */
import { parseSearchReplaceBlocks } from "@/pro/shared/search_replace_parser";
function unescapeMarkers(content: string): string {
return content
.replace(/^\\<<<<<<</gm, "<<<<<<<")
.replace(/^\\=======/gm, "=======")
.replace(/^\\>>>>>>>/gm, ">>>>>>>");
}
export function applySearchReplace(
originalContent: string,
diffContent: string,
): {
success: boolean;
content?: string;
error?: string;
} {
const blocks = parseSearchReplaceBlocks(diffContent);
if (blocks.length === 0) {
return {
success: false,
error:
"Invalid diff format - missing required sections. Expected <<<<<<< SEARCH / ======= / >>>>>>> REPLACE",
};
}
const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n";
let resultLines = originalContent.split(/\r?\n/);
let appliedCount = 0;
for (const block of blocks) {
let { searchContent, replaceContent } = block;
// Normalize markers and strip line numbers if present on all lines
searchContent = unescapeMarkers(searchContent);
replaceContent = unescapeMarkers(replaceContent);
let searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/);
let replaceLines =
replaceContent === "" ? [] : replaceContent.split(/\r?\n/);
if (searchLines.length === 0) {
return {
success: false,
error: "Invalid diff format - empty SEARCH block is not allowed",
};
}
// If search and replace are identical, it's a no-op and should be treated as an error
if (searchLines.join("\n") === replaceLines.join("\n")) {
return {
success: false,
error: "Search and replace blocks are identical",
};
}
let matchIndex = -1;
const target = searchLines.join("\n");
const hay = resultLines.join("\n");
// Try exact string matching first and detect ambiguity
const exactPositions: number[] = [];
let fromIndex = 0;
while (true) {
const found = hay.indexOf(target, fromIndex);
if (found === -1) break;
exactPositions.push(found);
fromIndex = found + 1;
}
if (exactPositions.length > 1) {
return {
success: false,
error:
"Search block matched multiple locations in the target file (ambiguous)",
};
}
if (exactPositions.length === 1) {
const pos = exactPositions[0];
matchIndex = hay.substring(0, pos).split("\n").length - 1;
}
if (matchIndex === -1) {
// Lenient fallback: ignore leading indentation and trailing whitespace
const normalizeForMatch = (line: string) =>
line.replace(/^[\t ]*/, "").replace(/[\t ]+$/, "");
const normalizedSearch = searchLines.map(normalizeForMatch);
const candidates: number[] = [];
for (let i = 0; i <= resultLines.length - searchLines.length; i++) {
let allMatch = true;
for (let j = 0; j < searchLines.length; j++) {
if (normalizeForMatch(resultLines[i + j]) !== normalizedSearch[j]) {
allMatch = false;
break;
}
}
if (allMatch) {
candidates.push(i);
if (candidates.length > 1) break; // we only care if >1 for ambiguity
}
}
if (candidates.length > 1) {
return {
success: false,
error:
"Search block fuzzy matched multiple locations in the target file (ambiguous)",
};
}
if (candidates.length === 0) {
return {
success: false,
error: "Search block did not match any content in the target file",
};
}
matchIndex = candidates[0];
}
const matchedLines = resultLines.slice(
matchIndex,
matchIndex + searchLines.length,
);
// Preserve indentation relative to first matched line
const originalIndents = matchedLines.map((line) => {
const m = line.match(/^[\t ]*/);
return m ? m[0] : "";
});
const searchIndents = searchLines.map((line) => {
const m = line.match(/^[\t ]*/);
return m ? m[0] : "";
});
const indentedReplaceLines = replaceLines.map((line) => {
const matchedIndent = originalIndents[0] || "";
const currentIndentMatch = line.match(/^[\t ]*/);
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : "";
const searchBaseIndent = searchIndents[0] || "";
const searchBaseLevel = searchBaseIndent.length;
const currentLevel = currentIndent.length;
const relativeLevel = currentLevel - searchBaseLevel;
const finalIndent =
relativeLevel < 0
? matchedIndent.slice(
0,
Math.max(0, matchedIndent.length + relativeLevel),
)
: matchedIndent + currentIndent.slice(searchBaseLevel);
return finalIndent + line.trim();
});
const beforeMatch = resultLines.slice(0, matchIndex);
const afterMatch = resultLines.slice(matchIndex + searchLines.length);
resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch];
appliedCount++;
}
if (appliedCount === 0) {
return {
success: false,
error: "No search/replace blocks could be applied",
};
}
return { success: true, content: resultLines.join(lineEnding) };
}

View File

@@ -0,0 +1,94 @@
// This approach is inspired by Roo Code
// https://github.com/RooCodeInc/Roo-Code/blob/fceb4130478b20de2bc854c8dd0aad743f844b53/src/core/diff/strategies/multi-search-replace.ts#L4
// but we've modified it to be simpler and not rely on line numbers.
//
// Also, credit to https://aider.chat/ for popularizing this approach
export const TURBO_EDITS_V2_SYSTEM_PROMPT = `
# Search-replace file edits
- Request to apply PRECISE, TARGETED modifications to an existing file by searching for specific sections of content and replacing them. This tool is for SURGICAL EDITS ONLY - specific changes to existing code.
- You can perform multiple distinct search and replace operations within a single \`dyad-search-replace\` call by providing multiple SEARCH/REPLACE blocks. This is the preferred way to make several targeted changes efficiently.
- The SEARCH section must match exactly ONE existing content section - it must be unique within the file, including whitespace and indentation.
- When applying the diffs, be extra careful to remember to change any closing brackets or other syntax that may be affected by the diff farther down in the file.
- ALWAYS make as many changes in a single 'dyad-search-replace' call as possible using multiple SEARCH/REPLACE blocks.
- Do not use both \`dyad-write\` and \`dyad-search-replace\` on the same file within a single response.
- Include a brief description of the changes you are making in the \`description\` parameter.
Diff format:
\`\`\`
<<<<<<< SEARCH
[exact content to find including whitespace]
=======
[new content to replace with]
>>>>>>> REPLACE
\`\`\`
Example:
Original file:
\`\`\`
def calculate_total(items):
total = 0
for item in items:
total += item
return total
\`\`\`
Search/Replace content:
\`\`\`
<<<<<<< SEARCH
def calculate_total(items):
total = 0
for item in items:
total += item
return total
=======
def calculate_total(items):
"""Calculate total with 10% markup"""
return sum(item * 1.1 for item in items)
>>>>>>> REPLACE
\`\`\`
Search/Replace content with multiple edits:
\`\`\`
<<<<<<< SEARCH
def calculate_total(items):
sum = 0
=======
def calculate_sum(items):
sum = 0
>>>>>>> REPLACE
<<<<<<< SEARCH
total += item
return total
=======
sum += item
return sum
>>>>>>> REPLACE
\`\`\`
Usage:
<dyad-search-replace path="path/to/file.js" description="Brief description of the changes you are making">
<<<<<<< SEARCH
def calculate_total(items):
sum = 0
=======
def calculate_sum(items):
sum = 0
>>>>>>> REPLACE
<<<<<<< SEARCH
total += item
return total
=======
sum += item
return sum
>>>>>>> REPLACE
</dyad-search-replace>
`;

View File

@@ -0,0 +1,17 @@
export type SearchReplaceBlock = {
searchContent: string;
replaceContent: string;
};
const BLOCK_REGEX =
/(?:^|\n)<<<<<<<\s+SEARCH>?\s*\n([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>>\s+REPLACE)(?=\n|$)/g;
export function parseSearchReplaceBlocks(
diffContent: string,
): SearchReplaceBlock[] {
const matches = [...diffContent.matchAll(BLOCK_REGEX)];
return matches.map((m) => ({
searchContent: m[1] ?? "",
replaceContent: m[2] ?? "",
}));
}