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

View File

@@ -30,7 +30,10 @@ import {
extractCodebase,
readFileWithCache,
} from "../../utils/codebase";
import { processFullResponseActions } from "../processors/response_processor";
import {
dryRunSearchReplace,
processFullResponseActions,
} from "../processors/response_processor";
import { streamTestResponse } from "./testing_chat_handlers";
import { getTestResponse } from "./testing_chat_handlers";
import { getModelClient, ModelClient } from "../utils/get_model_client";
@@ -75,6 +78,7 @@ import { inArray } from "drizzle-orm";
import { replacePromptReference } from "../utils/replacePromptReference";
import { mcpManager } from "../utils/mcp_manager";
import z from "zod";
import { isTurboEditsV2Enabled } from "@/lib/schemas";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
@@ -563,6 +567,7 @@ ${componentSnippet}
settings.selectedChatMode === "agent"
? "build"
: settings.selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings),
});
// Add information about mentioned apps if any
@@ -898,6 +903,7 @@ This conversation includes one or more image attachments. When the user uploads
systemPromptOverride: constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(updatedChat.app.path)),
chatMode: "agent",
enableTurboEditsV2: false,
}),
files: files,
dyadDisableFiles: true,
@@ -939,6 +945,53 @@ This conversation includes one or more image attachments. When the user uploads
});
fullResponse = result.fullResponse;
if (
settings.selectedChatMode !== "ask" &&
isTurboEditsV2Enabled(settings)
) {
const issues = await dryRunSearchReplace({
fullResponse,
appPath: getDyadAppPath(updatedChat.app.path),
});
if (issues.length > 0) {
logger.warn(
`Detected search-replace issues: ${issues.map((i) => i.error).join(", ")}`,
);
const formattedSearchReplaceIssues = issues
.map(({ filePath, error }) => {
return `File path: ${filePath}\nError: ${error}`;
})
.join("\n\n");
const originalFullResponse = fullResponse;
fullResponse += `<dyad-output type="warning" message="Could not apply Turbo Edits properly for some of the files; re-generating code...">${formattedSearchReplaceIssues}</dyad-output>`;
const { fullStream: fixSearchReplaceStream } =
await simpleStreamText({
// Build messages: reuse chat history and original full response, then ask to fix search-replace issues.
chatMessages: [
...chatMessages,
{ role: "assistant", content: originalFullResponse },
{
role: "user",
content: `There was an issue with the following \`dyad-search-replace\` tags. Please fix them by generating the code changes using \`dyad-write\` tags instead.
${formattedSearchReplaceIssues}`,
},
],
modelClient,
files: files,
});
const result = await processStreamChunks({
fullStream: fixSearchReplaceStream,
fullResponse,
abortController,
chatId: req.chatId,
processResponseChunkUpdate,
});
fullResponse = result.fullResponse;
}
}
if (
!abortController.signal.aborted &&
settings.selectedChatMode !== "ask" &&

View File

@@ -18,6 +18,7 @@ import {
getDyadAddDependencyTags,
getDyadChatSummaryTag,
getDyadCommandTags,
getDyadSearchReplaceTags,
} from "../utils/dyad_tag_parser";
import log from "electron-log";
import { isServerFunction } from "../../supabase_admin/supabase_utils";
@@ -153,19 +154,23 @@ const getProposalHandler = async (
const proposalTitle = getDyadChatSummaryTag(messageContent);
const proposalWriteFiles = getDyadWriteTags(messageContent);
const proposalSearchReplaceFiles =
getDyadSearchReplaceTags(messageContent);
const proposalRenameFiles = getDyadRenameTags(messageContent);
const proposalDeleteFiles = getDyadDeleteTags(messageContent);
const proposalExecuteSqlQueries = getDyadExecuteSqlTags(messageContent);
const packagesAdded = getDyadAddDependencyTags(messageContent);
const filesChanged = [
...proposalWriteFiles.map((tag) => ({
name: path.basename(tag.path),
path: tag.path,
summary: tag.description ?? "(no change summary found)", // Generic summary
type: "write" as const,
isServerFunction: isServerFunction(tag.path),
})),
...proposalWriteFiles
.concat(proposalSearchReplaceFiles)
.map((tag) => ({
name: path.basename(tag.path),
path: tag.path,
summary: tag.description ?? "(no change summary found)", // Generic summary
type: "write" as const,
isServerFunction: isServerFunction(tag.path),
})),
...proposalRenameFiles.map((tag) => ({
name: path.basename(tag.to),
path: tag.to,

View File

@@ -22,6 +22,7 @@ import { validateChatContext } from "../utils/context_paths_utils";
import { readSettings } from "@/main/settings";
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
import { isTurboEditsV2Enabled } from "@/lib/schemas";
const logger = log.scope("token_count_handlers");
@@ -63,6 +64,7 @@ export function registerTokenCountHandlers() {
let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
chatMode: settings.selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings),
});
let supabaseContext = "";

View File

@@ -25,7 +25,9 @@ import {
getDyadDeleteTags,
getDyadAddDependencyTags,
getDyadExecuteSqlTags,
getDyadSearchReplaceTags,
} from "../utils/dyad_tag_parser";
import { applySearchReplace } from "../../pro/main/ipc/processors/search_replace_processor";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { FileUploadsState } from "../utils/file_uploads_state";
@@ -50,6 +52,46 @@ async function readFileFromFunctionPath(input: string): Promise<string> {
return readFile(input, "utf8");
}
export async function dryRunSearchReplace({
fullResponse,
appPath,
}: {
fullResponse: string;
appPath: string;
}) {
const issues: { filePath: string; error: string }[] = [];
const dyadSearchReplaceTags = getDyadSearchReplaceTags(fullResponse);
for (const tag of dyadSearchReplaceTags) {
const filePath = tag.path;
const fullFilePath = safeJoin(appPath, filePath);
try {
if (!fs.existsSync(fullFilePath)) {
issues.push({
filePath,
error: `Search-replace target file does not exist: ${filePath}`,
});
continue;
}
const original = await readFile(fullFilePath, "utf8");
const result = applySearchReplace(original, tag.content);
if (!result.success || typeof result.content !== "string") {
issues.push({
filePath,
error: "Unable to apply search-replace to file",
});
continue;
}
} catch (error) {
issues.push({
filePath,
error: error?.toString() ?? "Unknown error",
});
}
}
return issues;
}
export async function processFullResponseActions(
fullResponse: string,
chatId: number,
@@ -312,6 +354,53 @@ export async function processFullResponseActions(
}
}
// Process all search-replace edits
const dyadSearchReplaceTags = getDyadSearchReplaceTags(fullResponse);
for (const tag of dyadSearchReplaceTags) {
const filePath = tag.path;
const fullFilePath = safeJoin(appPath, filePath);
try {
if (!fs.existsSync(fullFilePath)) {
// Do not show warning to user because we already attempt to do a <dyad-write> tag to fix it.
logger.warn(`Search-replace target file does not exist: ${filePath}`);
continue;
}
const original = await readFile(fullFilePath, "utf8");
const result = applySearchReplace(original, tag.content);
if (!result.success || typeof result.content !== "string") {
// Do not show warning to user because we already attempt to do a <dyad-write> tag to fix it.
logger.warn(
`Failed to apply search-replace to ${filePath}: ${result.error ?? "unknown"}`,
);
continue;
}
// Write modified content
fs.writeFileSync(fullFilePath, result.content);
writtenFiles.push(filePath);
// If server function, redeploy
if (isServerFunction(filePath)) {
try {
await deploySupabaseFunctions({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: path.basename(path.dirname(filePath)),
content: result.content,
});
} catch (error) {
errors.push({
message: `Failed to deploy Supabase function after search-replace: ${filePath}`,
error: error,
});
}
}
} catch (error) {
errors.push({
message: `Error applying search-replace to ${filePath}`,
error: error,
});
}
}
// Process all file writes
for (const tag of dyadWriteTags) {
const filePath = tag.path;

View File

@@ -137,3 +137,48 @@ export function getDyadCommandTags(fullResponse: string): string[] {
return commands;
}
export function getDyadSearchReplaceTags(fullResponse: string): {
path: string;
content: string;
description?: string;
}[] {
const dyadSearchReplaceRegex =
/<dyad-search-replace([^>]*)>([\s\S]*?)<\/dyad-search-replace>/gi;
const pathRegex = /path="([^"]+)"/;
const descriptionRegex = /description="([^"]+)"/;
let match;
const tags: { path: string; content: string; description?: string }[] = [];
while ((match = dyadSearchReplaceRegex.exec(fullResponse)) !== null) {
const attributesString = match[1] || "";
let content = match[2].trim();
const pathMatch = pathRegex.exec(attributesString);
const descriptionMatch = descriptionRegex.exec(attributesString);
if (pathMatch && pathMatch[1]) {
const path = pathMatch[1];
const description = descriptionMatch?.[1];
// Handle markdown code fences if present
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
}
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
contentLines.pop();
}
content = contentLines.join("\n");
tags.push({ path: normalizePath(path), content, description });
} else {
logger.warn(
"Found <dyad-search-replace> tag without a valid 'path' attribute:",
match[0],
);
}
}
return tags;
}

View File

@@ -89,7 +89,8 @@ export async function getModelClient(
enableLazyEdits:
settings.selectedChatMode === "ask"
? false
: settings.enableProLazyEditsMode,
: settings.enableProLazyEditsMode &&
settings.proLazyEditsMode !== "v2",
enableSmartFilesContext,
// Keep in sync with getCurrentValue in ProModeSelector.tsx
smartContextMode: settings.proSmartContextOption ?? "balanced",