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:
Will Chen
2025-08-20 14:16:07 -07:00
committed by GitHub
parent 34215db141
commit 4e9a927a7b
18 changed files with 764 additions and 26 deletions

View File

@@ -41,7 +41,7 @@ test("manage context - smart context", async ({ po }) => {
// Disabling smart context will automatically disable // Disabling smart context will automatically disable
// the auto-includes. // the auto-includes.
const proModesDialog = await po.openProModesDialog(); const proModesDialog = await po.openProModesDialog();
await proModesDialog.toggleSmartContext(); await proModesDialog.setSmartContextMode("off");
await proModesDialog.close(); await proModesDialog.close();
await po.sendPrompt("[dump]"); await po.sendPrompt("[dump]");

View File

@@ -2,8 +2,6 @@ import { testSkipIfWindows } from "./helpers/test_helper";
testSkipIfWindows("send message to engine", async ({ po }) => { testSkipIfWindows("send message to engine", async ({ po }) => {
await po.setUpDyadPro(); 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.selectModel({ provider: "Google", model: "Gemini 2.5 Pro" });
await po.sendPrompt("[dump] tc=turbo-edits"); await po.sendPrompt("[dump] tc=turbo-edits");
@@ -11,6 +9,23 @@ testSkipIfWindows("send message to engine", async ({ po }) => {
await po.snapshotMessages({ replaceDumpPath: true }); 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 }) => { testSkipIfWindows("send message to engine - openai gpt-4.1", async ({ po }) => {
await po.setUpDyadPro(); await po.setUpDyadPro();
// By default, it's using auto which points to Flash 2.5 and doesn't // 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({ const proModesDialog = await po.openProModesDialog({
location: "home-chat-input-container", location: "home-chat-input-container",
}); });
await proModesDialog.toggleSmartContext(); await proModesDialog.setSmartContextMode("off");
await proModesDialog.close(); await proModesDialog.close();
await po.sendPrompt("[dump] tc=turbo-edits"); await po.sendPrompt("[dump] tc=turbo-edits");

View File

@@ -7,7 +7,7 @@ testSkipIfWindows("claude 4 sonnet", async ({ po }) => {
location: "home-chat-input-container", location: "home-chat-input-container",
}); });
await proModesDialog.toggleTurboEdits(); await proModesDialog.toggleTurboEdits();
await proModesDialog.toggleSmartContext(); await proModesDialog.setSmartContextMode("off");
await proModesDialog.close(); await proModesDialog.close();
await po.selectModel({ provider: "Anthropic", model: "Claude 4 Sonnet" }); await po.selectModel({ provider: "Anthropic", model: "Claude 4 Sonnet" });

View File

@@ -67,8 +67,12 @@ class ProModesDialog {
public close: () => Promise<void>, public close: () => Promise<void>,
) {} ) {}
async toggleSmartContext() { async setSmartContextMode(mode: "balanced" | "off" | "conservative") {
await this.page.getByRole("switch", { name: "Smart Context" }).click(); await this.page
.getByRole("button", {
name: mode.charAt(0).toUpperCase() + mode.slice(1),
})
.click();
} }
async toggleTurboEdits() { async toggleTurboEdits() {

View 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();
});

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -14,7 +14,7 @@ import { Label } from "@/components/ui/label";
import { Sparkles, Info } from "lucide-react"; import { Sparkles, Info } from "lucide-react";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { hasDyadProKey } from "@/lib/schemas"; import { hasDyadProKey, type UserSettings } from "@/lib/schemas";
export function ProModeSelector() { export function ProModeSelector() {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
@@ -25,10 +25,25 @@ export function ProModeSelector() {
}); });
}; };
const toggleSmartContext = () => { const handleSmartContextChange = (
updateSettings({ newValue: "off" | "conservative" | "balanced",
enableProSmartFilesContextMode: !settings?.enableProSmartFilesContextMode, ) => {
}); if (newValue === "off") {
updateSettings({
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 = () => { const toggleProEnabled = () => {
@@ -99,14 +114,10 @@ export function ProModeSelector() {
settingEnabled={Boolean(settings?.enableProLazyEditsMode)} settingEnabled={Boolean(settings?.enableProLazyEditsMode)}
toggle={toggleLazyEdits} toggle={toggleLazyEdits}
/> />
<SelectorRow <SmartContextSelector
id="smart-context"
label="Smart Context"
description="Optimizes your AI's code context"
tooltip="Improve efficiency and save credits working on large codebases."
isTogglable={proModeTogglable} isTogglable={proModeTogglable}
settingEnabled={Boolean(settings?.enableProSmartFilesContextMode)} settings={settings}
toggle={toggleSmartContext} onValueChange={handleSmartContextChange}
/> />
</div> </div>
</div> </div>
@@ -168,3 +179,83 @@ function SelectorRow({
</div> </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>
);
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { Brain, ChevronDown, ChevronUp, Loader } from "lucide-react"; import { Brain, ChevronDown, ChevronUp, Loader } from "lucide-react";
import { VanillaMarkdownParser } from "./DyadMarkdownParser"; import { VanillaMarkdownParser } from "./DyadMarkdownParser";
import { CustomTagState } from "./stateTypes"; import { CustomTagState } from "./stateTypes";
import { DyadTokenSavings } from "./DyadTokenSavings";
interface DyadThinkProps { interface DyadThinkProps {
node?: any; node?: any;
@@ -13,6 +14,26 @@ export const DyadThink: React.FC<DyadThinkProps> = ({ children, node }) => {
const inProgress = state === "pending"; const inProgress = state === "pending";
const [isExpanded, setIsExpanded] = useState(inProgress); 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 // Collapse when transitioning from in-progress to not-in-progress
useEffect(() => { useEffect(() => {
if (!inProgress && isExpanded) { if (!inProgress && isExpanded) {

View 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>
);
};

View File

@@ -86,13 +86,23 @@ export function registerTokenCountHandlers() {
if (chat.app) { if (chat.app) {
const appPath = getDyadAppPath(chat.app.path); const appPath = getDyadAppPath(chat.app.path);
codebaseInfo = ( const { formattedOutput, files } = await extractCodebase({
await extractCodebase({ appPath,
appPath, chatContext: validateChatContext(chat.app.chatContext),
chatContext: validateChatContext(chat.app.chatContext), });
}) codebaseInfo = formattedOutput;
).formattedOutput; if (settings.enableDyadPro && settings.enableProSmartFilesContextMode) {
codebaseTokens = estimateTokens(codebaseInfo); 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( logger.log(
`Extracted codebase information from ${appPath}, tokens: ${codebaseTokens}`, `Extracted codebase information from ${appPath}, tokens: ${codebaseTokens}`,
); );

View File

@@ -84,6 +84,7 @@ export async function getModelClient(
? false ? false
: settings.enableProLazyEditsMode, : settings.enableProLazyEditsMode,
enableSmartFilesContext: settings.enableProSmartFilesContextMode, enableSmartFilesContext: settings.enableProSmartFilesContextMode,
smartContextMode: settings.proSmartContextOption,
}, },
settings, settings,
}) })

View File

@@ -44,6 +44,7 @@ or to provide a custom fetch implementation for e.g. testing.
dyadOptions: { dyadOptions: {
enableLazyEdits?: boolean; enableLazyEdits?: boolean;
enableSmartFilesContext?: boolean; enableSmartFilesContext?: boolean;
smartContextMode?: "balanced";
}; };
settings: UserSettings; settings: UserSettings;
} }
@@ -149,6 +150,7 @@ export function createDyadEngine(
enable_lazy_edits: options.dyadOptions.enableLazyEdits, enable_lazy_edits: options.dyadOptions.enableLazyEdits,
enable_smart_files_context: enable_smart_files_context:
options.dyadOptions.enableSmartFilesContext, options.dyadOptions.enableSmartFilesContext,
smart_context_mode: options.dyadOptions.smartContextMode,
}; };
} }

View File

@@ -160,6 +160,7 @@ export const UserSettingsSchema = z.object({
thinkingBudget: z.enum(["low", "medium", "high"]).optional(), thinkingBudget: z.enum(["low", "medium", "high"]).optional(),
enableProLazyEditsMode: z.boolean().optional(), enableProLazyEditsMode: z.boolean().optional(),
enableProSmartFilesContextMode: z.boolean().optional(), enableProSmartFilesContextMode: z.boolean().optional(),
proSmartContextOption: z.enum(["balanced"]).optional(),
selectedTemplateId: z.string(), selectedTemplateId: z.string(),
enableSupabaseWriteSqlMigration: z.boolean().optional(), enableSupabaseWriteSqlMigration: z.boolean().optional(),
selectedChatMode: ChatModeSchema.optional(), selectedChatMode: ChatModeSchema.optional(),