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

@@ -5,3 +5,5 @@ coverage
drizzle/
**/pnpm-lock.yaml
**/snapshots/**
# test fixtures
e2e-tests/fixtures/**

View File

@@ -1,3 +1,7 @@
Portions of this software are licensed as follows:
* All content that resides under the "src/pro/" directory of this repository, if that directory exists, is licensed under the license defined in "src/pro/LICENSE".
* Content outside of the above mentioned directories or restrictions above is available under the Apache 2 license as defined below.
Apache License
Version 2.0, January 2004

View File

@@ -27,3 +27,8 @@ Join our growing community of AI app builders on **Reddit**: [r/dyadbuilders](ht
**Dyad** is open-source (Apache 2.0 licensed).
If you're interested in contributing to dyad, please read our [contributing](./CONTRIBUTING.md) doc.
## License
- All the code in this repo outside of `src/pro` is open-source and licensed under Apache 2.0 - see [LICENSE](./LICENSE).
- All the code in this repo within `src/pro` is fair-source and licensed under [Functional Source License 1.1 Apache 2.0](https://fsl.software/) - see [LICENSE](./src/pro/LICENSE).

View File

@@ -0,0 +1,10 @@
Example with turbo edit v2
<dyad-search-replace path="src/pages/Index.tsx">
<<<<<<< SEARCH
// Intentionally DO NOT MATCH ANYTHING TO TRIGGER FALLBACK
<h1 className="text-4xl font-bold mb-4">Welcome to Your Blank App</h1>
=======
<h1 className="text-4xl font-bold mb-4">Welcome to the UPDATED App</h1>
>>>>>>> REPLACE
</dyad-search-replace>
End of turbo edit

View File

@@ -0,0 +1,9 @@
Example with turbo edit v2
<dyad-search-replace path="src/pages/Index.tsx">
<<<<<<< SEARCH
<h1 className="text-4xl font-bold mb-4">Welcome to Your Blank App</h1>
=======
<h1 className="text-4xl font-bold mb-4">Welcome to the UPDATED App</h1>
>>>>>>> REPLACE
</dyad-search-replace>
End of turbo edit

View File

@@ -73,14 +73,23 @@ class ProModesDialog {
async setSmartContextMode(mode: "balanced" | "off" | "conservative") {
await this.page
.getByTestId("smart-context-selector")
.getByRole("button", {
name: mode.charAt(0).toUpperCase() + mode.slice(1),
})
.click();
}
async toggleTurboEdits() {
await this.page.getByRole("switch", { name: "Turbo Edits" }).click();
async setTurboEditsMode(mode: "off" | "classic" | "search-replace") {
await this.page
.getByTestId("turbo-edits-selector")
.getByRole("button", {
name:
mode === "search-replace"
? "Search & replace"
: mode.charAt(0).toUpperCase() + mode.slice(1),
})
.click();
}
}
@@ -362,7 +371,7 @@ export class PageObject {
await expect(this.page.getByRole("dialog")).toMatchAriaSnapshot();
}
async snapshotAppFiles({ name }: { name: string }) {
async snapshotAppFiles({ name, files }: { name: string; files?: string[] }) {
const currentAppName = await this.getCurrentAppName();
if (!currentAppName) {
throw new Error("No app selected");
@@ -374,10 +383,17 @@ export class PageObject {
}
await expect(() => {
const filesData = generateAppFilesSnapshotData(appPath, appPath);
let filesData = generateAppFilesSnapshotData(appPath, appPath);
// Sort by relative path to ensure deterministic output
filesData.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
if (files) {
filesData = filesData.filter((file) =>
files.some(
(f) => normalizePath(f) === normalizePath(file.relativePath),
),
);
}
const snapshotContent = filesData
.map(
@@ -1232,3 +1248,7 @@ function prettifyDump(
})
.join("\n\n");
}
function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}

View File

@@ -0,0 +1,28 @@
{
"selectedModel": {
"name": "auto",
"provider": "auto"
},
"providerSettings": {
"auto": {
"apiKey": {
"value": "testdyadkey",
"encryptionType": "plaintext"
}
}
},
"telemetryConsent": "unset",
"telemetryUserId": "[UUID]",
"hasRunBefore": true,
"enableDyadPro": true,
"experiments": {},
"lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build",
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"releaseChannel": "stable",
"isTestMode": true
}

View File

@@ -0,0 +1,29 @@
{
"selectedModel": {
"name": "auto",
"provider": "auto"
},
"providerSettings": {
"auto": {
"apiKey": {
"value": "testdyadkey",
"encryptionType": "plaintext"
}
}
},
"telemetryConsent": "unset",
"telemetryUserId": "[UUID]",
"hasRunBefore": true,
"enableDyadPro": true,
"experiments": {},
"lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true,
"proLazyEditsMode": "v1",
"enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build",
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"releaseChannel": "stable",
"isTestMode": true
}

View File

@@ -0,0 +1,29 @@
{
"selectedModel": {
"name": "auto",
"provider": "auto"
},
"providerSettings": {
"auto": {
"apiKey": {
"value": "testdyadkey",
"encryptionType": "plaintext"
}
}
},
"telemetryConsent": "unset",
"telemetryUserId": "[UUID]",
"hasRunBefore": true,
"enableDyadPro": true,
"experiments": {},
"lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true,
"proLazyEditsMode": "v2",
"enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build",
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"releaseChannel": "stable",
"isTestMode": true
}

View File

@@ -0,0 +1,29 @@
{
"selectedModel": {
"name": "auto",
"provider": "auto"
},
"providerSettings": {
"auto": {
"apiKey": {
"value": "testdyadkey",
"encryptionType": "plaintext"
}
}
},
"telemetryConsent": "unset",
"telemetryUserId": "[UUID]",
"hasRunBefore": true,
"enableDyadPro": true,
"experiments": {},
"lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": false,
"proLazyEditsMode": "off",
"enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build",
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"releaseChannel": "stable",
"isTestMode": true
}

View File

@@ -0,0 +1,2 @@
=== src/pages/Index.tsx ===
// FILE IS REPLACED WITH FALLBACK WRITE.

View File

@@ -0,0 +1,20 @@
=== src/pages/Index.tsx ===
// Update this page (the content is just a fallback if you fail to update the page)
import { MadeWithDyad } from "@/components/made-with-dyad";
const Index = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to the UPDATED App</h1>
<p className="text-xl text-gray-600">
Start building your amazing project here!
</p>
</div>
<MadeWithDyad />
</div>
);
};
export default Index;

View File

@@ -0,0 +1,15 @@
- paragraph: tc=turbo-edits-v2
- paragraph: Example with turbo edit v2
- img
- text: Search & Replace Index.tsx
- img
- text: src/pages/Index.tsx
- paragraph: End of turbo edit
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,153 @@
===
role: system
message:
${BUILD_SYSTEM_PREFIX}
# Tech Stack
- You are building a React application.
- Use TypeScript.
- Use React Router. KEEP the routes in src/App.tsx
- Always put source code in the src folder.
- Put pages into src/pages/
- Put components into src/components/
- The main page (default page) is src/pages/Index.tsx
- UPDATE the main page to include the new components. OTHERWISE, the user can NOT see any components!
- ALWAYS try to use the shadcn/ui library.
- Tailwind CSS: always use Tailwind CSS for styling components. Utilize Tailwind classes extensively for layout, spacing, colors, and other design aspects.
Available packages and libraries:
- The lucide-react package is installed for icons.
- You ALREADY have ALL the shadcn/ui components and their dependencies installed. So you don't need to install them again.
- You have ALL the necessary Radix UI components installed.
- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them.
${BUILD_SYSTEM_POSTFIX}
# 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>
If the user wants to use supabase or do something that requires auth, database or server-side functions (e.g. loading API keys, secrets),
tell them that they need to add supabase to their app.
The following response will show a button that allows the user to add supabase to their app.
<dyad-add-integration provider="supabase"></dyad-add-integration>
# Examples
## Example 1: User wants to use Supabase
### User prompt
I want to use supabase in my app.
### Assistant response
You need to first add Supabase to your app.
<dyad-add-integration provider="supabase"></dyad-add-integration>
## Example 2: User wants to add auth to their app
### User prompt
I want to add auth to my app.
### Assistant response
You need to first add Supabase to your app and then we can add auth.
<dyad-add-integration provider="supabase"></dyad-add-integration>
===
role: user
message: [dump]

View File

@@ -0,0 +1,25 @@
- paragraph: tc=turbo-edits-v2-trigger-fallback
- paragraph: Example with turbo edit v2
- img
- text: Search & Replace Index.tsx
- img
- text: src/pages/Index.tsx
- paragraph: End of turbo edit
- img
- text: Warning Could not apply Turbo Edits properly for some of the files; re-generating code......
- img
- img
- text: Index.tsx
- button "Edit":
- img
- img
- text: "src/pages/Index.tsx Summary: Rewrite file."
- paragraph: "[[dyad-dump-path=*]]"
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
import { test } from "./helpers/test_helper";
test("switching turbo edits saves the right setting", async ({ po }) => {
await po.setUpDyadPro();
const proModesDialog = await po.openProModesDialog({
location: "home-chat-input-container",
});
await po.snapshotSettings();
await proModesDialog.setTurboEditsMode("classic");
await po.snapshotSettings();
await proModesDialog.setTurboEditsMode("search-replace");
await po.snapshotSettings();
await proModesDialog.setTurboEditsMode("off");
await po.snapshotSettings();
});

View File

@@ -0,0 +1,49 @@
import { testSkipIfWindows } from "./helpers/test_helper";
testSkipIfWindows("turbo edits v2 - search-replace dump", async ({ po }) => {
await po.setUpDyadPro();
const proModesDialog = await po.openProModesDialog({
location: "home-chat-input-container",
});
await proModesDialog.setTurboEditsMode("search-replace");
await proModesDialog.close();
await po.sendPrompt("[dump]");
await po.snapshotServerDump("request");
await po.snapshotServerDump("all-messages");
});
testSkipIfWindows("turbo edits v2 - search-replace approve", async ({ po }) => {
await po.setUpDyadPro();
const proModesDialog = await po.openProModesDialog({
location: "home-chat-input-container",
});
await proModesDialog.setTurboEditsMode("search-replace");
await proModesDialog.close();
await po.sendPrompt("tc=turbo-edits-v2");
await po.snapshotMessages();
await po.approveProposal();
await po.snapshotAppFiles({
name: "after-search-replace",
files: ["src/pages/Index.tsx"],
});
});
testSkipIfWindows(
"turbo edits v2 - search-replace fallback",
async ({ po }) => {
await po.setUpDyadPro();
const proModesDialog = await po.openProModesDialog({
location: "home-chat-input-container",
});
await proModesDialog.setTurboEditsMode("search-replace");
await proModesDialog.close();
await po.sendPrompt("tc=turbo-edits-v2-trigger-fallback");
await po.snapshotServerDump("request");
await po.snapshotMessages({ replaceDumpPath: true });
await po.approveProposal();
await po.snapshotAppFiles({
name: "after-search-replace-fallback",
files: ["src/pages/Index.tsx"],
});
},
);

View File

@@ -25,9 +25,10 @@ export function ProModeSelector() {
});
};
const toggleLazyEdits = () => {
const handleTurboEditsChange = (newValue: "off" | "v1" | "v2") => {
updateSettings({
enableProLazyEditsMode: !settings?.enableProLazyEditsMode,
enableProLazyEditsMode: newValue !== "off",
proLazyEditsMode: newValue,
});
};
@@ -105,7 +106,6 @@ export function ProModeSelector() {
<SelectorRow
id="pro-enabled"
label="Enable Dyad Pro"
description="Use Dyad Pro AI credits"
tooltip="Uses Dyad Pro AI credits for the main AI model and Pro modes."
isTogglable={hasProKey}
settingEnabled={Boolean(settings?.enableDyadPro)}
@@ -113,21 +113,17 @@ export function ProModeSelector() {
/>
<SelectorRow
id="web-search"
label="Web Search"
description="Search the web for information"
tooltip="Uses the web to search for information"
label="Web Access"
tooltip="Allows Dyad to access the web (e.g. search for information)"
isTogglable={proModeTogglable}
settingEnabled={Boolean(settings?.enableProWebSearch)}
toggle={toggleWebSearch}
/>
<SelectorRow
id="lazy-edits"
label="Turbo Edits"
description="Makes file edits faster and cheaper"
tooltip="Uses a faster, cheaper model to generate full file updates."
<TurboEditsSelector
isTogglable={proModeTogglable}
settingEnabled={Boolean(settings?.enableProLazyEditsMode)}
toggle={toggleLazyEdits}
settings={settings}
onValueChange={handleTurboEditsChange}
/>
<SmartContextSelector
isTogglable={proModeTogglable}
@@ -144,7 +140,6 @@ export function ProModeSelector() {
function SelectorRow({
id,
label,
description,
tooltip,
isTogglable,
settingEnabled,
@@ -152,7 +147,6 @@ function SelectorRow({
}: {
id: string;
label: string;
description: string;
tooltip: string;
isTogglable: boolean;
settingEnabled: boolean;
@@ -160,14 +154,13 @@ function SelectorRow({
}) {
return (
<div className="flex items-center justify-between">
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label
htmlFor={id}
className={!isTogglable ? "text-muted-foreground/50" : ""}
>
{label}
</Label>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Info
@@ -178,12 +171,6 @@ function SelectorRow({
{tooltip}
</TooltipContent>
</Tooltip>
<p
className={`text-xs ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"} max-w-55`}
>
{description}
</p>
</div>
</div>
<Switch
id={id}
@@ -195,6 +182,95 @@ function SelectorRow({
);
}
function TurboEditsSelector({
isTogglable,
settings,
onValueChange,
}: {
isTogglable: boolean;
settings: UserSettings | null;
onValueChange: (value: "off" | "v1" | "v2") => void;
}) {
// Determine current value based on settings
const getCurrentValue = (): "off" | "v1" | "v2" => {
if (!settings?.enableProLazyEditsMode) {
return "off";
}
if (settings?.proLazyEditsMode === "v1") {
return "v1";
}
if (settings?.proLazyEditsMode === "v2") {
return "v2";
}
// Keep in sync with getModelClient in get_model_client.ts
// If enabled but no option set (undefined/falsey), it's v1
return "v1";
};
const currentValue = getCurrentValue();
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className={!isTogglable ? "text-muted-foreground/50" : ""}>
Turbo Edits
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
/>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
Edits files efficiently without full rewrites.
<br />
<ul className="list-disc ml-4">
<li>
<b>Classic:</b> Uses a smaller model to complete edits.
</li>
<li>
<b>Search & replace:</b> Find and replaces specific text blocks.
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
<div
className="inline-flex rounded-md border border-input"
data-testid="turbo-edits-selector"
>
<Button
variant={currentValue === "off" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("off")}
disabled={!isTogglable}
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Off
</Button>
<Button
variant={currentValue === "v1" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("v1")}
disabled={!isTogglable}
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Classic
</Button>
<Button
variant={currentValue === "v2" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("v2")}
disabled={!isTogglable}
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
>
Search & replace
</Button>
</div>
</div>
);
}
function SmartContextSelector({
isTogglable,
settings,
@@ -223,12 +299,11 @@ function SmartContextSelector({
const currentValue = getCurrentValue();
return (
<div className="space-y-3">
<div className="space-y-1.5">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className={!isTogglable ? "text-muted-foreground/50" : ""}>
Smart Context
</Label>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Info
@@ -236,17 +311,15 @@ function SmartContextSelector({
/>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
Improve efficiency and save credits working on large codebases.
Selects the most relevant files as context to save credits working
on large codebases.
</TooltipContent>
</Tooltip>
<p
className={`text-xs ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
</div>
<div
className="inline-flex rounded-md border border-input"
data-testid="smart-context-selector"
>
Optimizes your AI's code context
</p>
</div>
</div>
<div className="inline-flex rounded-md border border-input">
<Button
variant={currentValue === "off" ? "default" : "ghost"}
size="sm"

View File

@@ -8,6 +8,7 @@ import { DyadAddDependency } from "./DyadAddDependency";
import { DyadExecuteSql } from "./DyadExecuteSql";
import { DyadAddIntegration } from "./DyadAddIntegration";
import { DyadEdit } from "./DyadEdit";
import { DyadSearchReplace } from "./DyadSearchReplace";
import { DyadCodebaseContext } from "./DyadCodebaseContext";
import { DyadThink } from "./DyadThink";
import { CodeHighlight } from "./CodeHighlight";
@@ -129,6 +130,7 @@ function preprocessUnclosedTags(content: string): {
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-search-replace",
"dyad-codebase-context",
"dyad-web-search-result",
"dyad-web-search",
@@ -201,6 +203,7 @@ function parseCustomTags(content: string): ContentPiece[] {
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-search-replace",
"dyad-codebase-context",
"dyad-web-search-result",
"dyad-web-search",
@@ -437,6 +440,21 @@ function renderCustomTag(
</DyadEdit>
);
case "dyad-search-replace":
return (
<DyadSearchReplace
node={{
properties: {
path: attributes.path || "",
description: attributes.description || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadSearchReplace>
);
case "dyad-codebase-context":
return (
<DyadCodebaseContext

View File

@@ -0,0 +1,151 @@
import type React from "react";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
Loader,
CircleX,
Search,
ArrowLeftRight,
} from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
import { CustomTagState } from "./stateTypes";
import { parseSearchReplaceBlocks } from "@/pro/shared/search_replace_parser";
interface DyadSearchReplaceProps {
children?: ReactNode;
node?: any;
path?: string;
description?: string;
}
export const DyadSearchReplace: React.FC<DyadSearchReplaceProps> = ({
children,
node,
path: pathProp,
description: descriptionProp,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
const path = pathProp || node?.properties?.path || "";
const description = descriptionProp || node?.properties?.description || "";
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const aborted = state === "aborted";
const blocks = useMemo(
() => parseSearchReplaceBlocks(String(children ?? "")),
[children],
);
const fileName = path ? path.split("/").pop() : "";
return (
<div
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress
? "border-amber-500"
: aborted
? "border-red-500"
: "border-border"
}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center">
<Search size={16} />
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded ml-1 font-medium">
Search & Replace
</span>
</div>
{fileName && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fileName}
</span>
)}
{inProgress && (
<div className="flex items-center text-amber-600 text-xs">
<Loader size={14} className="mr-1 animate-spin" />
<span>Applying changes...</span>
</div>
)}
{aborted && (
<div className="flex items-center text-red-600 text-xs">
<CircleX size={14} className="mr-1" />
<span>Did not finish</span>
</div>
)}
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{path && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{path}
</div>
)}
{description && (
<div className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-medium">Summary: </span>
{description}
</div>
)}
{isContentVisible && (
<div
className="text-xs cursor-text"
onClick={(e) => e.stopPropagation()}
>
{blocks.length === 0 ? (
<CodeHighlight className="language-typescript">
{children}
</CodeHighlight>
) : (
<div className="space-y-3">
{blocks.map((b, i) => (
<div key={i} className="border rounded-lg">
<div className="flex items-center justify-between px-3 py-2 bg-(--background-lighter) rounded-t-lg text-[11px]">
<div className="flex items-center gap-2">
<ArrowLeftRight size={14} />
<span className="font-medium">Change {i + 1}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-0">
<div className="p-3 border-t md:border-r">
<div className="text-[11px] mb-1 text-muted-foreground font-medium">
Search
</div>
<CodeHighlight className="language-typescript">
{b.searchContent}
</CodeHighlight>
</div>
<div className="p-3 border-t">
<div className="text-[11px] mb-1 text-muted-foreground font-medium">
Replace
</div>
<CodeHighlight className="language-typescript">
{b.replaceContent}
</CodeHighlight>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};

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,13 +154,17 @@ 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) => ({
...proposalWriteFiles
.concat(proposalSearchReplaceFiles)
.map((tag) => ({
name: path.basename(tag.path),
path: tag.path,
summary: tag.description ?? "(no change summary found)", // Generic summary

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",

View File

@@ -232,6 +232,7 @@ export const UserSettingsSchema = z.object({
maxChatTurnsInContext: z.number().optional(),
thinkingBudget: z.enum(["low", "medium", "high"]).optional(),
enableProLazyEditsMode: z.boolean().optional(),
proLazyEditsMode: z.enum(["off", "v1", "v2"]).optional(),
enableProSmartFilesContextMode: z.boolean().optional(),
enableProWebSearch: z.boolean().optional(),
proSmartContextOption: z.enum(["balanced", "conservative"]).optional(),
@@ -273,6 +274,14 @@ export function hasDyadProKey(settings: UserSettings): boolean {
return !!settings.providerSettings?.auto?.apiKey?.value;
}
export function isTurboEditsV2Enabled(settings: UserSettings): boolean {
return Boolean(
isDyadProEnabled(settings) &&
settings.enableProLazyEditsMode === true &&
settings.proLazyEditsMode === "v2",
);
}
// Define interfaces for the props
export interface SecurityRisk {
type: "warning" | "danger";

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] ?? "",
}));
}

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import fs from "node:fs";
import log from "electron-log";
import { TURBO_EDITS_V2_SYSTEM_PROMPT } from "../pro/main/prompts/turbo_edits_v2_prompt";
const logger = log.scope("system_prompt");
@@ -505,24 +506,36 @@ When tools are not used, simply state: **"Ok, looks like I don't need any tools,
export const constructSystemPrompt = ({
aiRules,
chatMode = "build",
enableTurboEditsV2,
}: {
aiRules: string | undefined;
chatMode?: "build" | "ask" | "agent";
enableTurboEditsV2: boolean;
}) => {
const systemPrompt = getSystemPromptForChatMode(chatMode);
const systemPrompt = getSystemPromptForChatMode({
chatMode,
enableTurboEditsV2,
});
return systemPrompt.replace("[[AI_RULES]]", aiRules ?? DEFAULT_AI_RULES);
};
export const getSystemPromptForChatMode = (
chatMode: "build" | "ask" | "agent",
) => {
export const getSystemPromptForChatMode = ({
chatMode,
enableTurboEditsV2,
}: {
chatMode: "build" | "ask" | "agent";
enableTurboEditsV2: boolean;
}) => {
if (chatMode === "agent") {
return AGENT_MODE_SYSTEM_PROMPT;
}
if (chatMode === "ask") {
return ASK_MODE_SYSTEM_PROMPT;
}
return BUILD_SYSTEM_PROMPT;
return (
BUILD_SYSTEM_PROMPT +
(enableTurboEditsV2 ? TURBO_EDITS_V2_SYSTEM_PROMPT : "")
);
};
export const readAiRules = async (dyadAppPath: string) => {

View File

@@ -127,6 +127,22 @@ export default Index;
</dyad-write>
`;
}
if (
lastMessage &&
typeof lastMessage.content === "string" &&
lastMessage.content.startsWith(
"There was an issue with the following `dyad-search-replace` tags",
)
) {
// Fix errors in create-ts-errors.md and introduce a new error
messageContent =
`
<dyad-write path="src/pages/Index.tsx" description="Rewrite file.">
// FILE IS REPLACED WITH FALLBACK WRITE.
</dyad-write>` +
"\n\n" +
generateDump(req);
}
console.error("LASTMESSAGE", lastMessage);
// Check if the last message is "[dump]" to write messages to file and return path
if (