smart context v3 (#1022)
<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Adds Smart Context v3 with selectable modes (Off, Conservative,
Balanced) and surfaces token savings in chat. Also improves token
estimation by counting per-file tokens when Smart Context is enabled.
- **New Features**
- Smart Context selector in Pro settings with three options.
Conservative is the default when enabled without an explicit choice.
- New setting: proSmartContextOption ("balanced"); undefined implies
Conservative.
- Engine now receives enable_smart_files_context and smart_context_mode.
- Chat shows a DyadTokenSavings card when the message contains
token-savings?original-tokens=...&smart-context-tokens=..., with percent
saved and a tooltip for exact tokens.
- Token estimation uses extracted file contents for accuracy when Pro +
Smart Context is on; otherwise falls back to formatted codebase output.
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -41,7 +41,7 @@ test("manage context - smart context", async ({ po }) => {
|
||||
// Disabling smart context will automatically disable
|
||||
// the auto-includes.
|
||||
const proModesDialog = await po.openProModesDialog();
|
||||
await proModesDialog.toggleSmartContext();
|
||||
await proModesDialog.setSmartContextMode("off");
|
||||
await proModesDialog.close();
|
||||
|
||||
await po.sendPrompt("[dump]");
|
||||
|
||||
@@ -2,8 +2,6 @@ import { testSkipIfWindows } from "./helpers/test_helper";
|
||||
|
||||
testSkipIfWindows("send message to engine", async ({ po }) => {
|
||||
await po.setUpDyadPro();
|
||||
// By default, it's using auto which points to Flash 2.5 and doesn't
|
||||
// use engine.
|
||||
await po.selectModel({ provider: "Google", model: "Gemini 2.5 Pro" });
|
||||
await po.sendPrompt("[dump] tc=turbo-edits");
|
||||
|
||||
@@ -11,6 +9,23 @@ testSkipIfWindows("send message to engine", async ({ po }) => {
|
||||
await po.snapshotMessages({ replaceDumpPath: true });
|
||||
});
|
||||
|
||||
testSkipIfWindows(
|
||||
"send message to engine - smart context balanced",
|
||||
async ({ po }) => {
|
||||
await po.setUpDyadPro();
|
||||
const proModesDialog = await po.openProModesDialog({
|
||||
location: "home-chat-input-container",
|
||||
});
|
||||
await proModesDialog.setSmartContextMode("balanced");
|
||||
await proModesDialog.close();
|
||||
await po.selectModel({ provider: "Google", model: "Gemini 2.5 Pro" });
|
||||
await po.sendPrompt("[dump] tc=turbo-edits");
|
||||
|
||||
await po.snapshotServerDump("request");
|
||||
await po.snapshotMessages({ replaceDumpPath: true });
|
||||
},
|
||||
);
|
||||
|
||||
testSkipIfWindows("send message to engine - openai gpt-4.1", async ({ po }) => {
|
||||
await po.setUpDyadPro();
|
||||
// By default, it's using auto which points to Flash 2.5 and doesn't
|
||||
@@ -52,7 +67,7 @@ testSkipIfWindows(
|
||||
const proModesDialog = await po.openProModesDialog({
|
||||
location: "home-chat-input-container",
|
||||
});
|
||||
await proModesDialog.toggleSmartContext();
|
||||
await proModesDialog.setSmartContextMode("off");
|
||||
await proModesDialog.close();
|
||||
await po.sendPrompt("[dump] tc=turbo-edits");
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ testSkipIfWindows("claude 4 sonnet", async ({ po }) => {
|
||||
location: "home-chat-input-container",
|
||||
});
|
||||
await proModesDialog.toggleTurboEdits();
|
||||
await proModesDialog.toggleSmartContext();
|
||||
await proModesDialog.setSmartContextMode("off");
|
||||
await proModesDialog.close();
|
||||
|
||||
await po.selectModel({ provider: "Anthropic", model: "Claude 4 Sonnet" });
|
||||
|
||||
@@ -67,8 +67,12 @@ class ProModesDialog {
|
||||
public close: () => Promise<void>,
|
||||
) {}
|
||||
|
||||
async toggleSmartContext() {
|
||||
await this.page.getByRole("switch", { name: "Smart Context" }).click();
|
||||
async setSmartContextMode(mode: "balanced" | "off" | "conservative") {
|
||||
await this.page
|
||||
.getByRole("button", {
|
||||
name: mode.charAt(0).toUpperCase() + mode.slice(1),
|
||||
})
|
||||
.click();
|
||||
}
|
||||
|
||||
async toggleTurboEdits() {
|
||||
|
||||
15
e2e-tests/smart_context_options.spec.ts
Normal file
15
e2e-tests/smart_context_options.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test } from "./helpers/test_helper";
|
||||
|
||||
test("switching smart context mode saves the right setting", async ({ po }) => {
|
||||
await po.setUpDyadPro();
|
||||
const proModesDialog = await po.openProModesDialog({
|
||||
location: "home-chat-input-container",
|
||||
});
|
||||
await po.snapshotSettings();
|
||||
await proModesDialog.setSmartContextMode("balanced");
|
||||
await po.snapshotSettings();
|
||||
await proModesDialog.setSmartContextMode("off");
|
||||
await po.snapshotSettings();
|
||||
await proModesDialog.setSmartContextMode("conservative");
|
||||
await po.snapshotSettings();
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
- paragraph: "[dump] tc=turbo-edits"
|
||||
- paragraph: "[[dyad-dump-path=*]]"
|
||||
- img
|
||||
- text: less than a minute ago
|
||||
- button "Retry":
|
||||
- img
|
||||
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
"enableProSmartFilesContextMode": true,
|
||||
"proSmartContextOption": "balanced",
|
||||
"selectedTemplateId": "react",
|
||||
"selectedChatMode": "build",
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -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": false,
|
||||
"selectedTemplateId": "react",
|
||||
"selectedChatMode": "build",
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Sparkles, Info } from "lucide-react";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { hasDyadProKey } from "@/lib/schemas";
|
||||
import { hasDyadProKey, type UserSettings } from "@/lib/schemas";
|
||||
|
||||
export function ProModeSelector() {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
@@ -25,10 +25,25 @@ export function ProModeSelector() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSmartContext = () => {
|
||||
const handleSmartContextChange = (
|
||||
newValue: "off" | "conservative" | "balanced",
|
||||
) => {
|
||||
if (newValue === "off") {
|
||||
updateSettings({
|
||||
enableProSmartFilesContextMode: !settings?.enableProSmartFilesContextMode,
|
||||
enableProSmartFilesContextMode: false,
|
||||
proSmartContextOption: undefined,
|
||||
});
|
||||
} else if (newValue === "conservative") {
|
||||
updateSettings({
|
||||
enableProSmartFilesContextMode: true,
|
||||
proSmartContextOption: undefined, // Conservative is the default when enabled but no option set
|
||||
});
|
||||
} else if (newValue === "balanced") {
|
||||
updateSettings({
|
||||
enableProSmartFilesContextMode: true,
|
||||
proSmartContextOption: "balanced",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleProEnabled = () => {
|
||||
@@ -99,14 +114,10 @@ export function ProModeSelector() {
|
||||
settingEnabled={Boolean(settings?.enableProLazyEditsMode)}
|
||||
toggle={toggleLazyEdits}
|
||||
/>
|
||||
<SelectorRow
|
||||
id="smart-context"
|
||||
label="Smart Context"
|
||||
description="Optimizes your AI's code context"
|
||||
tooltip="Improve efficiency and save credits working on large codebases."
|
||||
<SmartContextSelector
|
||||
isTogglable={proModeTogglable}
|
||||
settingEnabled={Boolean(settings?.enableProSmartFilesContextMode)}
|
||||
toggle={toggleSmartContext}
|
||||
settings={settings}
|
||||
onValueChange={handleSmartContextChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,3 +179,83 @@ function SelectorRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmartContextSelector({
|
||||
isTogglable,
|
||||
settings,
|
||||
onValueChange,
|
||||
}: {
|
||||
isTogglable: boolean;
|
||||
settings: UserSettings | null;
|
||||
onValueChange: (value: "off" | "conservative" | "balanced") => void;
|
||||
}) {
|
||||
// Determine current value based on settings
|
||||
const getCurrentValue = (): "off" | "conservative" | "balanced" => {
|
||||
if (!settings?.enableProSmartFilesContextMode) {
|
||||
return "off";
|
||||
}
|
||||
if (settings?.proSmartContextOption === "balanced") {
|
||||
return "balanced";
|
||||
}
|
||||
// If enabled but no option set (undefined/falsey), it's conservative
|
||||
return "conservative";
|
||||
};
|
||||
|
||||
const currentValue = getCurrentValue();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-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>
|
||||
</div>
|
||||
<div className="inline-flex rounded-md border border-input">
|
||||
<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 === "conservative" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("conservative")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Conservative
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentValue === "balanced" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("balanced")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Balanced
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
||||
import { Brain, ChevronDown, ChevronUp, Loader } from "lucide-react";
|
||||
import { VanillaMarkdownParser } from "./DyadMarkdownParser";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
import { DyadTokenSavings } from "./DyadTokenSavings";
|
||||
|
||||
interface DyadThinkProps {
|
||||
node?: any;
|
||||
@@ -13,6 +14,26 @@ export const DyadThink: React.FC<DyadThinkProps> = ({ children, node }) => {
|
||||
const inProgress = state === "pending";
|
||||
const [isExpanded, setIsExpanded] = useState(inProgress);
|
||||
|
||||
// Check if content matches token savings format
|
||||
const tokenSavingsMatch =
|
||||
typeof children === "string"
|
||||
? children.match(
|
||||
/^dyad-token-savings\?original-tokens=([0-9.]+)&smart-context-tokens=([0-9.]+)$/,
|
||||
)
|
||||
: null;
|
||||
|
||||
// If it's token savings format, render DyadTokenSavings component
|
||||
if (tokenSavingsMatch) {
|
||||
const originalTokens = parseFloat(tokenSavingsMatch[1]);
|
||||
const smartContextTokens = parseFloat(tokenSavingsMatch[2]);
|
||||
return (
|
||||
<DyadTokenSavings
|
||||
originalTokens={originalTokens}
|
||||
smartContextTokens={smartContextTokens}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Collapse when transitioning from in-progress to not-in-progress
|
||||
useEffect(() => {
|
||||
if (!inProgress && isExpanded) {
|
||||
|
||||
36
src/components/chat/DyadTokenSavings.tsx
Normal file
36
src/components/chat/DyadTokenSavings.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
|
||||
|
||||
interface DyadTokenSavingsProps {
|
||||
originalTokens: number;
|
||||
smartContextTokens: number;
|
||||
}
|
||||
|
||||
export const DyadTokenSavings: React.FC<DyadTokenSavingsProps> = ({
|
||||
originalTokens,
|
||||
smartContextTokens,
|
||||
}) => {
|
||||
const tokensSaved = originalTokens - smartContextTokens;
|
||||
const percentageSaved = Math.round((tokensSaved / originalTokens) * 100);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-green-50 dark:bg-green-950 hover:bg-green-100 dark:hover:bg-green-900 rounded-lg px-4 py-2 border border-green-200 dark:border-green-800 my-2 cursor-pointer">
|
||||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||
<Zap size={16} className="text-green-600 dark:text-green-400" />
|
||||
<span className="text-xs font-medium">
|
||||
Saved {percentageSaved}% of codebase tokens with Smart Context
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
<div className="text-left">
|
||||
Saved {Math.round(tokensSaved).toLocaleString()} tokens
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -86,13 +86,23 @@ export function registerTokenCountHandlers() {
|
||||
|
||||
if (chat.app) {
|
||||
const appPath = getDyadAppPath(chat.app.path);
|
||||
codebaseInfo = (
|
||||
await extractCodebase({
|
||||
const { formattedOutput, files } = await extractCodebase({
|
||||
appPath,
|
||||
chatContext: validateChatContext(chat.app.chatContext),
|
||||
})
|
||||
).formattedOutput;
|
||||
});
|
||||
codebaseInfo = formattedOutput;
|
||||
if (settings.enableDyadPro && settings.enableProSmartFilesContextMode) {
|
||||
codebaseTokens = estimateTokens(
|
||||
files
|
||||
// It doesn't need to be the exact format but it's just to get a token estimate
|
||||
.map(
|
||||
(file) => `<dyad-file=${file.path}>${file.content}</dyad-file>`,
|
||||
)
|
||||
.join("\n\n"),
|
||||
);
|
||||
} else {
|
||||
codebaseTokens = estimateTokens(codebaseInfo);
|
||||
}
|
||||
logger.log(
|
||||
`Extracted codebase information from ${appPath}, tokens: ${codebaseTokens}`,
|
||||
);
|
||||
|
||||
@@ -84,6 +84,7 @@ export async function getModelClient(
|
||||
? false
|
||||
: settings.enableProLazyEditsMode,
|
||||
enableSmartFilesContext: settings.enableProSmartFilesContextMode,
|
||||
smartContextMode: settings.proSmartContextOption,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ or to provide a custom fetch implementation for e.g. testing.
|
||||
dyadOptions: {
|
||||
enableLazyEdits?: boolean;
|
||||
enableSmartFilesContext?: boolean;
|
||||
smartContextMode?: "balanced";
|
||||
};
|
||||
settings: UserSettings;
|
||||
}
|
||||
@@ -149,6 +150,7 @@ export function createDyadEngine(
|
||||
enable_lazy_edits: options.dyadOptions.enableLazyEdits,
|
||||
enable_smart_files_context:
|
||||
options.dyadOptions.enableSmartFilesContext,
|
||||
smart_context_mode: options.dyadOptions.smartContextMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ export const UserSettingsSchema = z.object({
|
||||
thinkingBudget: z.enum(["low", "medium", "high"]).optional(),
|
||||
enableProLazyEditsMode: z.boolean().optional(),
|
||||
enableProSmartFilesContextMode: z.boolean().optional(),
|
||||
proSmartContextOption: z.enum(["balanced"]).optional(),
|
||||
selectedTemplateId: z.string(),
|
||||
enableSupabaseWriteSqlMigration: z.boolean().optional(),
|
||||
selectedChatMode: ChatModeSchema.optional(),
|
||||
|
||||
Reference in New Issue
Block a user