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:
@@ -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"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DyadAddDependency } from "./DyadAddDependency";
|
||||
import { DyadExecuteSql } from "./DyadExecuteSql";
|
||||
import { DyadAddIntegration } from "./DyadAddIntegration";
|
||||
import { DyadEdit } from "./DyadEdit";
|
||||
import { DyadSearchReplace } from "./DyadSearchReplace";
|
||||
import { DyadCodebaseContext } from "./DyadCodebaseContext";
|
||||
import { DyadThink } from "./DyadThink";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
@@ -129,6 +130,7 @@ function preprocessUnclosedTags(content: string): {
|
||||
"dyad-problem-report",
|
||||
"dyad-chat-summary",
|
||||
"dyad-edit",
|
||||
"dyad-search-replace",
|
||||
"dyad-codebase-context",
|
||||
"dyad-web-search-result",
|
||||
"dyad-web-search",
|
||||
@@ -201,6 +203,7 @@ function parseCustomTags(content: string): ContentPiece[] {
|
||||
"dyad-problem-report",
|
||||
"dyad-chat-summary",
|
||||
"dyad-edit",
|
||||
"dyad-search-replace",
|
||||
"dyad-codebase-context",
|
||||
"dyad-web-search-result",
|
||||
"dyad-web-search",
|
||||
@@ -437,6 +440,21 @@ function renderCustomTag(
|
||||
</DyadEdit>
|
||||
);
|
||||
|
||||
case "dyad-search-replace":
|
||||
return (
|
||||
<DyadSearchReplace
|
||||
node={{
|
||||
properties: {
|
||||
path: attributes.path || "",
|
||||
description: attributes.description || "",
|
||||
state: getState({ isStreaming, inProgress }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadSearchReplace>
|
||||
);
|
||||
|
||||
case "dyad-codebase-context":
|
||||
return (
|
||||
<DyadCodebaseContext
|
||||
|
||||
151
src/components/chat/DyadSearchReplace.tsx
Normal file
151
src/components/chat/DyadSearchReplace.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Loader,
|
||||
CircleX,
|
||||
Search,
|
||||
ArrowLeftRight,
|
||||
} from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
import { parseSearchReplaceBlocks } from "@/pro/shared/search_replace_parser";
|
||||
|
||||
interface DyadSearchReplaceProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const DyadSearchReplace: React.FC<DyadSearchReplaceProps> = ({
|
||||
children,
|
||||
node,
|
||||
path: pathProp,
|
||||
description: descriptionProp,
|
||||
}) => {
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
|
||||
const path = pathProp || node?.properties?.path || "";
|
||||
const description = descriptionProp || node?.properties?.description || "";
|
||||
const state = node?.properties?.state as CustomTagState;
|
||||
const inProgress = state === "pending";
|
||||
const aborted = state === "aborted";
|
||||
|
||||
const blocks = useMemo(
|
||||
() => parseSearchReplaceBlocks(String(children ?? "")),
|
||||
[children],
|
||||
);
|
||||
|
||||
const fileName = path ? path.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
|
||||
inProgress
|
||||
? "border-amber-500"
|
||||
: aborted
|
||||
? "border-red-500"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => setIsContentVisible(!isContentVisible)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
<Search size={16} />
|
||||
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded ml-1 font-medium">
|
||||
Search & Replace
|
||||
</span>
|
||||
</div>
|
||||
{fileName && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
{inProgress && (
|
||||
<div className="flex items-center text-amber-600 text-xs">
|
||||
<Loader size={14} className="mr-1 animate-spin" />
|
||||
<span>Applying changes...</span>
|
||||
</div>
|
||||
)}
|
||||
{aborted && (
|
||||
<div className="flex items-center text-red-600 text-xs">
|
||||
<CircleX size={14} className="mr-1" />
|
||||
<span>Did not finish</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-medium">Summary: </span>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{isContentVisible && (
|
||||
<div
|
||||
className="text-xs cursor-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{blocks.length === 0 ? (
|
||||
<CodeHighlight className="language-typescript">
|
||||
{children}
|
||||
</CodeHighlight>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((b, i) => (
|
||||
<div key={i} className="border rounded-lg">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-(--background-lighter) rounded-t-lg text-[11px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowLeftRight size={14} />
|
||||
<span className="font-medium">Change {i + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-0">
|
||||
<div className="p-3 border-t md:border-r">
|
||||
<div className="text-[11px] mb-1 text-muted-foreground font-medium">
|
||||
Search
|
||||
</div>
|
||||
<CodeHighlight className="language-typescript">
|
||||
{b.searchContent}
|
||||
</CodeHighlight>
|
||||
</div>
|
||||
<div className="p-3 border-t">
|
||||
<div className="text-[11px] mb-1 text-muted-foreground font-medium">
|
||||
Replace
|
||||
</div>
|
||||
<CodeHighlight className="language-typescript">
|
||||
{b.replaceContent}
|
||||
</CodeHighlight>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user