Turbo edits v2 (#1653)

Fixes #1222 #1646 

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



<!-- CURSOR_SUMMARY -->
---

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

View File

@@ -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,30 +154,23 @@ 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
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
/>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
{tooltip}
</TooltipContent>
</Tooltip>
<p
className={`text-xs ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"} max-w-55`}
>
{description}
</p>
</div>
<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">
{tooltip}
</TooltipContent>
</Tooltip>
</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,30 +299,27 @@ 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
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
/>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
Improve efficiency and save credits working on large codebases.
</TooltipContent>
</Tooltip>
<p
className={`text-xs ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
>
Optimizes your AI's code context
</p>
</div>
<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">
Selects the most relevant files as context to save credits working
on large codebases.
</TooltipContent>
</Tooltip>
</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
variant={currentValue === "off" ? "default" : "ghost"}
size="sm"

View File

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

View File

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