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:
@@ -5,3 +5,5 @@ coverage
|
||||
drizzle/
|
||||
**/pnpm-lock.yaml
|
||||
**/snapshots/**
|
||||
# test fixtures
|
||||
e2e-tests/fixtures/**
|
||||
4
LICENSE
4
LICENSE
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
10
e2e-tests/fixtures/engine/turbo-edits-v2-trigger-fallback.md
Normal file
10
e2e-tests/fixtures/engine/turbo-edits-v2-trigger-fallback.md
Normal 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
|
||||
9
e2e-tests/fixtures/engine/turbo-edits-v2.md
Normal file
9
e2e-tests/fixtures/engine/turbo-edits-v2.md
Normal 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
|
||||
@@ -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, "/");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
=== src/pages/Index.tsx ===
|
||||
// FILE IS REPLACED WITH FALLBACK WRITE.
|
||||
@@ -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;
|
||||
@@ -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
@@ -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]
|
||||
@@ -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
15
e2e-tests/turbo_edits_options.spec.ts
Normal file
15
e2e-tests/turbo_edits_options.spec.ts
Normal 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();
|
||||
});
|
||||
49
e2e-tests/turbo_edits_v2.spec.ts
Normal file
49
e2e-tests/turbo_edits_v2.spec.ts
Normal 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"],
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
151
src/components/chat/DyadSearchReplace.tsx
Normal file
151
src/components/chat/DyadSearchReplace.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
105
src/pro/LICENSE
Normal 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.
|
||||
301
src/pro/main/ipc/processors/search_replace_processor.spec.ts
Normal file
301
src/pro/main/ipc/processors/search_replace_processor.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
175
src/pro/main/ipc/processors/search_replace_processor.ts
Normal file
175
src/pro/main/ipc/processors/search_replace_processor.ts
Normal 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) };
|
||||
}
|
||||
94
src/pro/main/prompts/turbo_edits_v2_prompt.ts
Normal file
94
src/pro/main/prompts/turbo_edits_v2_prompt.ts
Normal 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>
|
||||
|
||||
`;
|
||||
17
src/pro/shared/search_replace_parser.ts
Normal file
17
src/pro/shared/search_replace_parser.ts
Normal 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] ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user