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:
@@ -4,4 +4,6 @@ coverage
|
|||||||
# generated files
|
# generated files
|
||||||
drizzle/
|
drizzle/
|
||||||
**/pnpm-lock.yaml
|
**/pnpm-lock.yaml
|
||||||
**/snapshots/**
|
**/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
|
Apache License
|
||||||
Version 2.0, January 2004
|
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).
|
**Dyad** is open-source (Apache 2.0 licensed).
|
||||||
|
|
||||||
If you're interested in contributing to dyad, please read our [contributing](./CONTRIBUTING.md) doc.
|
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") {
|
async setSmartContextMode(mode: "balanced" | "off" | "conservative") {
|
||||||
await this.page
|
await this.page
|
||||||
|
.getByTestId("smart-context-selector")
|
||||||
.getByRole("button", {
|
.getByRole("button", {
|
||||||
name: mode.charAt(0).toUpperCase() + mode.slice(1),
|
name: mode.charAt(0).toUpperCase() + mode.slice(1),
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleTurboEdits() {
|
async setTurboEditsMode(mode: "off" | "classic" | "search-replace") {
|
||||||
await this.page.getByRole("switch", { name: "Turbo Edits" }).click();
|
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();
|
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();
|
const currentAppName = await this.getCurrentAppName();
|
||||||
if (!currentAppName) {
|
if (!currentAppName) {
|
||||||
throw new Error("No app selected");
|
throw new Error("No app selected");
|
||||||
@@ -374,10 +383,17 @@ export class PageObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await expect(() => {
|
await expect(() => {
|
||||||
const filesData = generateAppFilesSnapshotData(appPath, appPath);
|
let filesData = generateAppFilesSnapshotData(appPath, appPath);
|
||||||
|
|
||||||
// Sort by relative path to ensure deterministic output
|
// Sort by relative path to ensure deterministic output
|
||||||
filesData.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
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
|
const snapshotContent = filesData
|
||||||
.map(
|
.map(
|
||||||
@@ -1232,3 +1248,7 @@ function prettifyDump(
|
|||||||
})
|
})
|
||||||
.join("\n\n");
|
.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({
|
updateSettings({
|
||||||
enableProLazyEditsMode: !settings?.enableProLazyEditsMode,
|
enableProLazyEditsMode: newValue !== "off",
|
||||||
|
proLazyEditsMode: newValue,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,7 +106,6 @@ export function ProModeSelector() {
|
|||||||
<SelectorRow
|
<SelectorRow
|
||||||
id="pro-enabled"
|
id="pro-enabled"
|
||||||
label="Enable Dyad Pro"
|
label="Enable Dyad Pro"
|
||||||
description="Use Dyad Pro AI credits"
|
|
||||||
tooltip="Uses Dyad Pro AI credits for the main AI model and Pro modes."
|
tooltip="Uses Dyad Pro AI credits for the main AI model and Pro modes."
|
||||||
isTogglable={hasProKey}
|
isTogglable={hasProKey}
|
||||||
settingEnabled={Boolean(settings?.enableDyadPro)}
|
settingEnabled={Boolean(settings?.enableDyadPro)}
|
||||||
@@ -113,21 +113,17 @@ export function ProModeSelector() {
|
|||||||
/>
|
/>
|
||||||
<SelectorRow
|
<SelectorRow
|
||||||
id="web-search"
|
id="web-search"
|
||||||
label="Web Search"
|
label="Web Access"
|
||||||
description="Search the web for information"
|
tooltip="Allows Dyad to access the web (e.g. search for information)"
|
||||||
tooltip="Uses the web to search for information"
|
|
||||||
isTogglable={proModeTogglable}
|
isTogglable={proModeTogglable}
|
||||||
settingEnabled={Boolean(settings?.enableProWebSearch)}
|
settingEnabled={Boolean(settings?.enableProWebSearch)}
|
||||||
toggle={toggleWebSearch}
|
toggle={toggleWebSearch}
|
||||||
/>
|
/>
|
||||||
<SelectorRow
|
|
||||||
id="lazy-edits"
|
<TurboEditsSelector
|
||||||
label="Turbo Edits"
|
|
||||||
description="Makes file edits faster and cheaper"
|
|
||||||
tooltip="Uses a faster, cheaper model to generate full file updates."
|
|
||||||
isTogglable={proModeTogglable}
|
isTogglable={proModeTogglable}
|
||||||
settingEnabled={Boolean(settings?.enableProLazyEditsMode)}
|
settings={settings}
|
||||||
toggle={toggleLazyEdits}
|
onValueChange={handleTurboEditsChange}
|
||||||
/>
|
/>
|
||||||
<SmartContextSelector
|
<SmartContextSelector
|
||||||
isTogglable={proModeTogglable}
|
isTogglable={proModeTogglable}
|
||||||
@@ -144,7 +140,6 @@ export function ProModeSelector() {
|
|||||||
function SelectorRow({
|
function SelectorRow({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
description,
|
|
||||||
tooltip,
|
tooltip,
|
||||||
isTogglable,
|
isTogglable,
|
||||||
settingEnabled,
|
settingEnabled,
|
||||||
@@ -152,7 +147,6 @@ function SelectorRow({
|
|||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
isTogglable: boolean;
|
isTogglable: boolean;
|
||||||
settingEnabled: boolean;
|
settingEnabled: boolean;
|
||||||
@@ -160,30 +154,23 @@ function SelectorRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className={!isTogglable ? "text-muted-foreground/50" : ""}
|
className={!isTogglable ? "text-muted-foreground/50" : ""}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-center gap-1">
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Info
|
||||||
<Info
|
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
||||||
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
/>
|
||||||
/>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent side="right" className="max-w-72">
|
||||||
<TooltipContent side="right" className="max-w-72">
|
{tooltip}
|
||||||
{tooltip}
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
<p
|
|
||||||
className={`text-xs ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"} max-w-55`}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id={id}
|
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({
|
function SmartContextSelector({
|
||||||
isTogglable,
|
isTogglable,
|
||||||
settings,
|
settings,
|
||||||
@@ -223,30 +299,27 @@ function SmartContextSelector({
|
|||||||
const currentValue = getCurrentValue();
|
const currentValue = getCurrentValue();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Label className={!isTogglable ? "text-muted-foreground/50" : ""}>
|
<Label className={!isTogglable ? "text-muted-foreground/50" : ""}>
|
||||||
Smart Context
|
Smart Context
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-center gap-1">
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Info
|
||||||
<Info
|
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
||||||
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
/>
|
||||||
/>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent side="right" className="max-w-72">
|
||||||
<TooltipContent side="right" className="max-w-72">
|
Selects the most relevant files as context to save credits working
|
||||||
Improve efficiency and save credits working on large codebases.
|
on large codebases.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<p
|
|
||||||
className={`text-xs ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
Optimizes your AI's code context
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex rounded-md border border-input">
|
<div
|
||||||
|
className="inline-flex rounded-md border border-input"
|
||||||
|
data-testid="smart-context-selector"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant={currentValue === "off" ? "default" : "ghost"}
|
variant={currentValue === "off" ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DyadAddDependency } from "./DyadAddDependency";
|
|||||||
import { DyadExecuteSql } from "./DyadExecuteSql";
|
import { DyadExecuteSql } from "./DyadExecuteSql";
|
||||||
import { DyadAddIntegration } from "./DyadAddIntegration";
|
import { DyadAddIntegration } from "./DyadAddIntegration";
|
||||||
import { DyadEdit } from "./DyadEdit";
|
import { DyadEdit } from "./DyadEdit";
|
||||||
|
import { DyadSearchReplace } from "./DyadSearchReplace";
|
||||||
import { DyadCodebaseContext } from "./DyadCodebaseContext";
|
import { DyadCodebaseContext } from "./DyadCodebaseContext";
|
||||||
import { DyadThink } from "./DyadThink";
|
import { DyadThink } from "./DyadThink";
|
||||||
import { CodeHighlight } from "./CodeHighlight";
|
import { CodeHighlight } from "./CodeHighlight";
|
||||||
@@ -129,6 +130,7 @@ function preprocessUnclosedTags(content: string): {
|
|||||||
"dyad-problem-report",
|
"dyad-problem-report",
|
||||||
"dyad-chat-summary",
|
"dyad-chat-summary",
|
||||||
"dyad-edit",
|
"dyad-edit",
|
||||||
|
"dyad-search-replace",
|
||||||
"dyad-codebase-context",
|
"dyad-codebase-context",
|
||||||
"dyad-web-search-result",
|
"dyad-web-search-result",
|
||||||
"dyad-web-search",
|
"dyad-web-search",
|
||||||
@@ -201,6 +203,7 @@ function parseCustomTags(content: string): ContentPiece[] {
|
|||||||
"dyad-problem-report",
|
"dyad-problem-report",
|
||||||
"dyad-chat-summary",
|
"dyad-chat-summary",
|
||||||
"dyad-edit",
|
"dyad-edit",
|
||||||
|
"dyad-search-replace",
|
||||||
"dyad-codebase-context",
|
"dyad-codebase-context",
|
||||||
"dyad-web-search-result",
|
"dyad-web-search-result",
|
||||||
"dyad-web-search",
|
"dyad-web-search",
|
||||||
@@ -437,6 +440,21 @@ function renderCustomTag(
|
|||||||
</DyadEdit>
|
</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":
|
case "dyad-codebase-context":
|
||||||
return (
|
return (
|
||||||
<DyadCodebaseContext
|
<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,
|
extractCodebase,
|
||||||
readFileWithCache,
|
readFileWithCache,
|
||||||
} from "../../utils/codebase";
|
} from "../../utils/codebase";
|
||||||
import { processFullResponseActions } from "../processors/response_processor";
|
import {
|
||||||
|
dryRunSearchReplace,
|
||||||
|
processFullResponseActions,
|
||||||
|
} from "../processors/response_processor";
|
||||||
import { streamTestResponse } from "./testing_chat_handlers";
|
import { streamTestResponse } from "./testing_chat_handlers";
|
||||||
import { getTestResponse } from "./testing_chat_handlers";
|
import { getTestResponse } from "./testing_chat_handlers";
|
||||||
import { getModelClient, ModelClient } from "../utils/get_model_client";
|
import { getModelClient, ModelClient } from "../utils/get_model_client";
|
||||||
@@ -75,6 +78,7 @@ import { inArray } from "drizzle-orm";
|
|||||||
import { replacePromptReference } from "../utils/replacePromptReference";
|
import { replacePromptReference } from "../utils/replacePromptReference";
|
||||||
import { mcpManager } from "../utils/mcp_manager";
|
import { mcpManager } from "../utils/mcp_manager";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { isTurboEditsV2Enabled } from "@/lib/schemas";
|
||||||
|
|
||||||
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
|
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
|
||||||
|
|
||||||
@@ -563,6 +567,7 @@ ${componentSnippet}
|
|||||||
settings.selectedChatMode === "agent"
|
settings.selectedChatMode === "agent"
|
||||||
? "build"
|
? "build"
|
||||||
: settings.selectedChatMode,
|
: settings.selectedChatMode,
|
||||||
|
enableTurboEditsV2: isTurboEditsV2Enabled(settings),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add information about mentioned apps if any
|
// 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({
|
systemPromptOverride: constructSystemPrompt({
|
||||||
aiRules: await readAiRules(getDyadAppPath(updatedChat.app.path)),
|
aiRules: await readAiRules(getDyadAppPath(updatedChat.app.path)),
|
||||||
chatMode: "agent",
|
chatMode: "agent",
|
||||||
|
enableTurboEditsV2: false,
|
||||||
}),
|
}),
|
||||||
files: files,
|
files: files,
|
||||||
dyadDisableFiles: true,
|
dyadDisableFiles: true,
|
||||||
@@ -939,6 +945,53 @@ This conversation includes one or more image attachments. When the user uploads
|
|||||||
});
|
});
|
||||||
fullResponse = result.fullResponse;
|
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 (
|
if (
|
||||||
!abortController.signal.aborted &&
|
!abortController.signal.aborted &&
|
||||||
settings.selectedChatMode !== "ask" &&
|
settings.selectedChatMode !== "ask" &&
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getDyadAddDependencyTags,
|
getDyadAddDependencyTags,
|
||||||
getDyadChatSummaryTag,
|
getDyadChatSummaryTag,
|
||||||
getDyadCommandTags,
|
getDyadCommandTags,
|
||||||
|
getDyadSearchReplaceTags,
|
||||||
} from "../utils/dyad_tag_parser";
|
} from "../utils/dyad_tag_parser";
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
import { isServerFunction } from "../../supabase_admin/supabase_utils";
|
import { isServerFunction } from "../../supabase_admin/supabase_utils";
|
||||||
@@ -153,19 +154,23 @@ const getProposalHandler = async (
|
|||||||
const proposalTitle = getDyadChatSummaryTag(messageContent);
|
const proposalTitle = getDyadChatSummaryTag(messageContent);
|
||||||
|
|
||||||
const proposalWriteFiles = getDyadWriteTags(messageContent);
|
const proposalWriteFiles = getDyadWriteTags(messageContent);
|
||||||
|
const proposalSearchReplaceFiles =
|
||||||
|
getDyadSearchReplaceTags(messageContent);
|
||||||
const proposalRenameFiles = getDyadRenameTags(messageContent);
|
const proposalRenameFiles = getDyadRenameTags(messageContent);
|
||||||
const proposalDeleteFiles = getDyadDeleteTags(messageContent);
|
const proposalDeleteFiles = getDyadDeleteTags(messageContent);
|
||||||
const proposalExecuteSqlQueries = getDyadExecuteSqlTags(messageContent);
|
const proposalExecuteSqlQueries = getDyadExecuteSqlTags(messageContent);
|
||||||
const packagesAdded = getDyadAddDependencyTags(messageContent);
|
const packagesAdded = getDyadAddDependencyTags(messageContent);
|
||||||
|
|
||||||
const filesChanged = [
|
const filesChanged = [
|
||||||
...proposalWriteFiles.map((tag) => ({
|
...proposalWriteFiles
|
||||||
name: path.basename(tag.path),
|
.concat(proposalSearchReplaceFiles)
|
||||||
path: tag.path,
|
.map((tag) => ({
|
||||||
summary: tag.description ?? "(no change summary found)", // Generic summary
|
name: path.basename(tag.path),
|
||||||
type: "write" as const,
|
path: tag.path,
|
||||||
isServerFunction: isServerFunction(tag.path),
|
summary: tag.description ?? "(no change summary found)", // Generic summary
|
||||||
})),
|
type: "write" as const,
|
||||||
|
isServerFunction: isServerFunction(tag.path),
|
||||||
|
})),
|
||||||
...proposalRenameFiles.map((tag) => ({
|
...proposalRenameFiles.map((tag) => ({
|
||||||
name: path.basename(tag.to),
|
name: path.basename(tag.to),
|
||||||
path: tag.to,
|
path: tag.to,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { validateChatContext } from "../utils/context_paths_utils";
|
|||||||
import { readSettings } from "@/main/settings";
|
import { readSettings } from "@/main/settings";
|
||||||
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
|
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
|
||||||
import { parseAppMentions } from "@/shared/parse_mention_apps";
|
import { parseAppMentions } from "@/shared/parse_mention_apps";
|
||||||
|
import { isTurboEditsV2Enabled } from "@/lib/schemas";
|
||||||
|
|
||||||
const logger = log.scope("token_count_handlers");
|
const logger = log.scope("token_count_handlers");
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export function registerTokenCountHandlers() {
|
|||||||
let systemPrompt = constructSystemPrompt({
|
let systemPrompt = constructSystemPrompt({
|
||||||
aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
|
aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
|
||||||
chatMode: settings.selectedChatMode,
|
chatMode: settings.selectedChatMode,
|
||||||
|
enableTurboEditsV2: isTurboEditsV2Enabled(settings),
|
||||||
});
|
});
|
||||||
let supabaseContext = "";
|
let supabaseContext = "";
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import {
|
|||||||
getDyadDeleteTags,
|
getDyadDeleteTags,
|
||||||
getDyadAddDependencyTags,
|
getDyadAddDependencyTags,
|
||||||
getDyadExecuteSqlTags,
|
getDyadExecuteSqlTags,
|
||||||
|
getDyadSearchReplaceTags,
|
||||||
} from "../utils/dyad_tag_parser";
|
} from "../utils/dyad_tag_parser";
|
||||||
|
import { applySearchReplace } from "../../pro/main/ipc/processors/search_replace_processor";
|
||||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||||
|
|
||||||
import { FileUploadsState } from "../utils/file_uploads_state";
|
import { FileUploadsState } from "../utils/file_uploads_state";
|
||||||
@@ -50,6 +52,46 @@ async function readFileFromFunctionPath(input: string): Promise<string> {
|
|||||||
return readFile(input, "utf8");
|
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(
|
export async function processFullResponseActions(
|
||||||
fullResponse: string,
|
fullResponse: string,
|
||||||
chatId: number,
|
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
|
// Process all file writes
|
||||||
for (const tag of dyadWriteTags) {
|
for (const tag of dyadWriteTags) {
|
||||||
const filePath = tag.path;
|
const filePath = tag.path;
|
||||||
|
|||||||
@@ -137,3 +137,48 @@ export function getDyadCommandTags(fullResponse: string): string[] {
|
|||||||
|
|
||||||
return commands;
|
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:
|
enableLazyEdits:
|
||||||
settings.selectedChatMode === "ask"
|
settings.selectedChatMode === "ask"
|
||||||
? false
|
? false
|
||||||
: settings.enableProLazyEditsMode,
|
: settings.enableProLazyEditsMode &&
|
||||||
|
settings.proLazyEditsMode !== "v2",
|
||||||
enableSmartFilesContext,
|
enableSmartFilesContext,
|
||||||
// Keep in sync with getCurrentValue in ProModeSelector.tsx
|
// Keep in sync with getCurrentValue in ProModeSelector.tsx
|
||||||
smartContextMode: settings.proSmartContextOption ?? "balanced",
|
smartContextMode: settings.proSmartContextOption ?? "balanced",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ export const UserSettingsSchema = z.object({
|
|||||||
maxChatTurnsInContext: z.number().optional(),
|
maxChatTurnsInContext: z.number().optional(),
|
||||||
thinkingBudget: z.enum(["low", "medium", "high"]).optional(),
|
thinkingBudget: z.enum(["low", "medium", "high"]).optional(),
|
||||||
enableProLazyEditsMode: z.boolean().optional(),
|
enableProLazyEditsMode: z.boolean().optional(),
|
||||||
|
proLazyEditsMode: z.enum(["off", "v1", "v2"]).optional(),
|
||||||
enableProSmartFilesContextMode: z.boolean().optional(),
|
enableProSmartFilesContextMode: z.boolean().optional(),
|
||||||
enableProWebSearch: z.boolean().optional(),
|
enableProWebSearch: z.boolean().optional(),
|
||||||
proSmartContextOption: z.enum(["balanced", "conservative"]).optional(),
|
proSmartContextOption: z.enum(["balanced", "conservative"]).optional(),
|
||||||
@@ -273,6 +274,14 @@ export function hasDyadProKey(settings: UserSettings): boolean {
|
|||||||
return !!settings.providerSettings?.auto?.apiKey?.value;
|
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
|
// Define interfaces for the props
|
||||||
export interface SecurityRisk {
|
export interface SecurityRisk {
|
||||||
type: "warning" | "danger";
|
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 path from "node:path";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import log from "electron-log";
|
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");
|
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 = ({
|
export const constructSystemPrompt = ({
|
||||||
aiRules,
|
aiRules,
|
||||||
chatMode = "build",
|
chatMode = "build",
|
||||||
|
enableTurboEditsV2,
|
||||||
}: {
|
}: {
|
||||||
aiRules: string | undefined;
|
aiRules: string | undefined;
|
||||||
chatMode?: "build" | "ask" | "agent";
|
chatMode?: "build" | "ask" | "agent";
|
||||||
|
enableTurboEditsV2: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const systemPrompt = getSystemPromptForChatMode(chatMode);
|
const systemPrompt = getSystemPromptForChatMode({
|
||||||
|
chatMode,
|
||||||
|
enableTurboEditsV2,
|
||||||
|
});
|
||||||
return systemPrompt.replace("[[AI_RULES]]", aiRules ?? DEFAULT_AI_RULES);
|
return systemPrompt.replace("[[AI_RULES]]", aiRules ?? DEFAULT_AI_RULES);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSystemPromptForChatMode = (
|
export const getSystemPromptForChatMode = ({
|
||||||
chatMode: "build" | "ask" | "agent",
|
chatMode,
|
||||||
) => {
|
enableTurboEditsV2,
|
||||||
|
}: {
|
||||||
|
chatMode: "build" | "ask" | "agent";
|
||||||
|
enableTurboEditsV2: boolean;
|
||||||
|
}) => {
|
||||||
if (chatMode === "agent") {
|
if (chatMode === "agent") {
|
||||||
return AGENT_MODE_SYSTEM_PROMPT;
|
return AGENT_MODE_SYSTEM_PROMPT;
|
||||||
}
|
}
|
||||||
if (chatMode === "ask") {
|
if (chatMode === "ask") {
|
||||||
return ASK_MODE_SYSTEM_PROMPT;
|
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) => {
|
export const readAiRules = async (dyadAppPath: string) => {
|
||||||
|
|||||||
@@ -127,6 +127,22 @@ export default Index;
|
|||||||
</dyad-write>
|
</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);
|
console.error("LASTMESSAGE", lastMessage);
|
||||||
// Check if the last message is "[dump]" to write messages to file and return path
|
// Check if the last message is "[dump]" to write messages to file and return path
|
||||||
if (
|
if (
|
||||||
|
|||||||
Reference in New Issue
Block a user