Allow manual context management (#376)
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import { ContextFilesPicker } from "./ContextFilesPicker";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ProModeSelector } from "./ProModeSelector";
|
||||
|
||||
export function ChatInputControls() {
|
||||
export function ChatInputControls({
|
||||
showContextFilesPicker = false,
|
||||
}: {
|
||||
showContextFilesPicker?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="pb-2 flex gap-2">
|
||||
<ModelPicker />
|
||||
{showContextFilesPicker && <ContextFilesPicker />}
|
||||
<ProModeSelector />
|
||||
</div>
|
||||
);
|
||||
|
||||
281
src/components/ContextFilesPicker.tsx
Normal file
281
src/components/ContextFilesPicker.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
import { FileCode, InfoIcon, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useContextPaths } from "@/hooks/useContextPaths";
|
||||
|
||||
export function ContextFilesPicker() {
|
||||
const { settings } = useSettings();
|
||||
const {
|
||||
contextPaths,
|
||||
smartContextAutoIncludes,
|
||||
updateContextPaths,
|
||||
updateSmartContextAutoIncludes,
|
||||
} = useContextPaths();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [newPath, setNewPath] = useState("");
|
||||
const [newAutoIncludePath, setNewAutoIncludePath] = useState("");
|
||||
|
||||
const addPath = () => {
|
||||
if (
|
||||
newPath.trim() === "" ||
|
||||
contextPaths.find((p) => p.globPath === newPath)
|
||||
) {
|
||||
setNewPath("");
|
||||
return;
|
||||
}
|
||||
const newPaths = [
|
||||
...contextPaths.map(({ globPath }) => ({ globPath })),
|
||||
{
|
||||
globPath: newPath,
|
||||
},
|
||||
];
|
||||
updateContextPaths(newPaths);
|
||||
setNewPath("");
|
||||
};
|
||||
|
||||
const removePath = (pathToRemove: string) => {
|
||||
const newPaths = contextPaths
|
||||
.filter((p) => p.globPath !== pathToRemove)
|
||||
.map(({ globPath }) => ({ globPath }));
|
||||
updateContextPaths(newPaths);
|
||||
};
|
||||
|
||||
const addAutoIncludePath = () => {
|
||||
if (
|
||||
newAutoIncludePath.trim() === "" ||
|
||||
smartContextAutoIncludes.find((p) => p.globPath === newAutoIncludePath)
|
||||
) {
|
||||
setNewAutoIncludePath("");
|
||||
return;
|
||||
}
|
||||
const newPaths = [
|
||||
...smartContextAutoIncludes.map(({ globPath }) => ({ globPath })),
|
||||
{
|
||||
globPath: newAutoIncludePath,
|
||||
},
|
||||
];
|
||||
updateSmartContextAutoIncludes(newPaths);
|
||||
setNewAutoIncludePath("");
|
||||
};
|
||||
|
||||
const removeAutoIncludePath = (pathToRemove: string) => {
|
||||
const newPaths = smartContextAutoIncludes
|
||||
.filter((p) => p.globPath !== pathToRemove)
|
||||
.map(({ globPath }) => ({ globPath }));
|
||||
updateSmartContextAutoIncludes(newPaths);
|
||||
};
|
||||
|
||||
const isSmartContextEnabled =
|
||||
settings?.enableDyadPro && settings?.enableProSmartFilesContextMode;
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2">
|
||||
<FileCode className="size-4" />
|
||||
<span>Context</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96" align="start">
|
||||
<div className="relative space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium">Codebase Context</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1 cursor-help">
|
||||
Select the files to use as context.{" "}
|
||||
<InfoIcon className="size-4" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
{isSmartContextEnabled ? (
|
||||
<p>
|
||||
With Smart Context, Dyad uses the most relevant files as
|
||||
context.
|
||||
</p>
|
||||
) : (
|
||||
<p>By default, Dyad uses your whole codebase.</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-sm items-center space-x-2">
|
||||
<Input
|
||||
data-testid="manual-context-files-input"
|
||||
type="text"
|
||||
placeholder="src/**/*.tsx"
|
||||
value={newPath}
|
||||
onChange={(e) => setNewPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
addPath();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={addPath}
|
||||
data-testid="manual-context-files-add-button"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
{contextPaths.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{contextPaths.map((p) => (
|
||||
<div
|
||||
key={p.globPath}
|
||||
className="flex items-center justify-between gap-2 rounded-md border p-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate font-mono text-sm">
|
||||
{p.globPath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{p.globPath}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{p.files} files, ~{p.tokens} tokens
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePath(p.globPath)}
|
||||
data-testid="manual-context-files-remove-button"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isSmartContextEnabled
|
||||
? "Dyad will use Smart Context to automatically find the most relevant files to use as context."
|
||||
: "Dyad will use the entire codebase as context."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
{isSmartContextEnabled && (
|
||||
<div className="pt-2">
|
||||
<div>
|
||||
<h3 className="font-medium">Smart Context Auto-includes</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1 cursor-help">
|
||||
These files will always be included in the context.{" "}
|
||||
<InfoIcon className="ml-2 size-4" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
Auto-include files are always included in the context
|
||||
in addition to the files selected as relevant by Smart
|
||||
Context.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-sm items-center space-x-2 mt-4">
|
||||
<Input
|
||||
data-testid="auto-include-context-files-input"
|
||||
type="text"
|
||||
placeholder="src/**/*.config.ts"
|
||||
value={newAutoIncludePath}
|
||||
onChange={(e) => setNewAutoIncludePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
addAutoIncludePath();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={addAutoIncludePath}
|
||||
data-testid="auto-include-context-files-add-button"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
{smartContextAutoIncludes.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
{smartContextAutoIncludes.map((p) => (
|
||||
<div
|
||||
key={p.globPath}
|
||||
className="flex items-center justify-between gap-2 rounded-md border p-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate font-mono text-sm">
|
||||
{p.globPath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{p.globPath}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{p.files} files, ~{p.tokens} tokens
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeAutoIncludePath(p.globPath)}
|
||||
data-testid="auto-include-context-files-remove-button"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -341,7 +341,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-2 pr-1 flex items-center justify-between">
|
||||
<ChatInputControls />
|
||||
<ChatInputControls showContextFilesPicker={true} />
|
||||
<button
|
||||
onClick={() => setShowTokenBar(!showTokenBar)}
|
||||
className="flex items-center px-2 py-1 text-xs text-muted-foreground hover:bg-muted rounded"
|
||||
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -15,6 +15,7 @@ export const apps = sqliteTable("apps", {
|
||||
githubOrg: text("github_org"),
|
||||
githubRepo: text("github_repo"),
|
||||
supabaseProjectId: text("supabase_project_id"),
|
||||
chatContext: text("chat_context", { mode: "json" }),
|
||||
});
|
||||
|
||||
export const chats = sqliteTable("chats", {
|
||||
|
||||
73
src/hooks/useContextPaths.ts
Normal file
73
src/hooks/useContextPaths.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { GlobPath, ContextPathResults } from "@/lib/schemas";
|
||||
|
||||
export function useContextPaths() {
|
||||
const queryClient = useQueryClient();
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
|
||||
const {
|
||||
data: contextPathsData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<ContextPathResults, Error>({
|
||||
queryKey: ["context-paths", appId],
|
||||
queryFn: async () => {
|
||||
if (!appId) return { contextPaths: [], smartContextAutoIncludes: [] };
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return ipcClient.getChatContextResults({ appId });
|
||||
},
|
||||
enabled: !!appId,
|
||||
});
|
||||
|
||||
const updateContextPathsMutation = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
{ contextPaths: GlobPath[]; smartContextAutoIncludes?: GlobPath[] }
|
||||
>({
|
||||
mutationFn: async ({ contextPaths, smartContextAutoIncludes }) => {
|
||||
if (!appId) throw new Error("No app selected");
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return ipcClient.setChatContext({
|
||||
appId,
|
||||
chatContext: {
|
||||
contextPaths,
|
||||
smartContextAutoIncludes: smartContextAutoIncludes || [],
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["context-paths", appId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateContextPaths = async (paths: GlobPath[]) => {
|
||||
const currentAutoIncludes =
|
||||
contextPathsData?.smartContextAutoIncludes || [];
|
||||
return updateContextPathsMutation.mutateAsync({
|
||||
contextPaths: paths,
|
||||
smartContextAutoIncludes: currentAutoIncludes.map(({ globPath }) => ({
|
||||
globPath,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const updateSmartContextAutoIncludes = async (paths: GlobPath[]) => {
|
||||
const currentContextPaths = contextPathsData?.contextPaths || [];
|
||||
return updateContextPathsMutation.mutateAsync({
|
||||
contextPaths: currentContextPaths.map(({ globPath }) => ({ globPath })),
|
||||
smartContextAutoIncludes: paths,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
contextPaths: contextPathsData?.contextPaths || [],
|
||||
smartContextAutoIncludes: contextPathsData?.smartContextAutoIncludes || [],
|
||||
isLoading,
|
||||
error,
|
||||
updateContextPaths,
|
||||
updateSmartContextAutoIncludes,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { readFile, writeFile, unlink } from "fs/promises";
|
||||
import { getMaxTokens } from "../utils/token_utils";
|
||||
import { MAX_CHAT_TURNS_IN_CONTEXT } from "@/constants/settings_constants";
|
||||
import { streamTextWithBackup } from "../utils/stream_utils";
|
||||
import { validateChatContext } from "../utils/context_paths_utils";
|
||||
|
||||
const logger = log.scope("chat_stream_handlers");
|
||||
|
||||
@@ -226,7 +227,10 @@ export function registerChatStreamHandlers() {
|
||||
if (updatedChat.app) {
|
||||
const appPath = getDyadAppPath(updatedChat.app.path);
|
||||
try {
|
||||
const out = await extractCodebase(appPath);
|
||||
const out = await extractCodebase({
|
||||
appPath,
|
||||
chatContext: validateChatContext(updatedChat.app.chatContext),
|
||||
});
|
||||
codebaseInfo = out.formattedOutput;
|
||||
files = out.files;
|
||||
logger.log(`Extracted codebase information from ${appPath}`);
|
||||
|
||||
98
src/ipc/handlers/context_paths_handlers.ts
Normal file
98
src/ipc/handlers/context_paths_handlers.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { db } from "@/db";
|
||||
import { apps } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AppChatContext,
|
||||
AppChatContextSchema,
|
||||
ContextPathResults,
|
||||
} from "@/lib/schemas";
|
||||
import { estimateTokens } from "../utils/token_utils";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
import log from "electron-log";
|
||||
import { getDyadAppPath } from "@/paths/paths";
|
||||
import { extractCodebase } from "@/utils/codebase";
|
||||
import { validateChatContext } from "../utils/context_paths_utils";
|
||||
|
||||
const logger = log.scope("context_paths_handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
|
||||
export function registerContextPathsHandlers() {
|
||||
handle(
|
||||
"get-context-paths",
|
||||
async (_, { appId }: { appId: number }): Promise<ContextPathResults> => {
|
||||
z.object({ appId: z.number() }).parse({ appId });
|
||||
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
if (!app.path) {
|
||||
throw new Error("App path not set");
|
||||
}
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
const results: ContextPathResults = {
|
||||
contextPaths: [],
|
||||
smartContextAutoIncludes: [],
|
||||
};
|
||||
const { contextPaths, smartContextAutoIncludes } = validateChatContext(
|
||||
app.chatContext,
|
||||
);
|
||||
for (const contextPath of contextPaths) {
|
||||
const { formattedOutput, files } = await extractCodebase({
|
||||
appPath,
|
||||
chatContext: {
|
||||
contextPaths: [contextPath],
|
||||
smartContextAutoIncludes: [],
|
||||
},
|
||||
});
|
||||
const totalTokens = estimateTokens(formattedOutput);
|
||||
|
||||
results.contextPaths.push({
|
||||
...contextPath,
|
||||
files: files.length,
|
||||
tokens: totalTokens,
|
||||
});
|
||||
}
|
||||
|
||||
for (const contextPath of smartContextAutoIncludes) {
|
||||
const { formattedOutput, files } = await extractCodebase({
|
||||
appPath,
|
||||
chatContext: {
|
||||
contextPaths: [contextPath],
|
||||
smartContextAutoIncludes: [],
|
||||
},
|
||||
});
|
||||
const totalTokens = estimateTokens(formattedOutput);
|
||||
|
||||
results.smartContextAutoIncludes.push({
|
||||
...contextPath,
|
||||
files: files.length,
|
||||
tokens: totalTokens,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
},
|
||||
);
|
||||
|
||||
handle(
|
||||
"set-context-paths",
|
||||
async (
|
||||
_,
|
||||
{ appId, chatContext }: { appId: number; chatContext: AppChatContext },
|
||||
) => {
|
||||
const schema = z.object({
|
||||
appId: z.number(),
|
||||
chatContext: AppChatContextSchema,
|
||||
});
|
||||
schema.parse({ appId, chatContext });
|
||||
|
||||
await db.update(apps).set({ chatContext }).where(eq(apps.id, appId));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { chats, apps } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { LargeLanguageModel } from "@/lib/schemas";
|
||||
import { validateChatContext } from "../utils/context_paths_utils";
|
||||
|
||||
// Shared function to get system debug info
|
||||
async function getSystemDebugInfo({
|
||||
@@ -175,7 +176,12 @@ export function registerDebugHandlers() {
|
||||
|
||||
// Extract codebase
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const codebase = (await extractCodebase(appPath)).formattedOutput;
|
||||
const codebase = (
|
||||
await extractCodebase({
|
||||
appPath,
|
||||
chatContext: validateChatContext(app.chatContext),
|
||||
})
|
||||
).formattedOutput;
|
||||
|
||||
return {
|
||||
debugInfo,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { getDyadAppPath } from "../../paths/paths";
|
||||
import { withLock } from "../utils/lock_utils";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
import { ApproveProposalResult } from "../ipc_types";
|
||||
import { validateChatContext } from "../utils/context_paths_utils";
|
||||
|
||||
const logger = log.scope("proposal_handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
@@ -41,6 +42,7 @@ interface CodebaseTokenCache {
|
||||
messageContent: string;
|
||||
tokenCount: number;
|
||||
timestamp: number;
|
||||
chatContext: string;
|
||||
}
|
||||
|
||||
// Cache expiration time (5 minutes)
|
||||
@@ -74,6 +76,7 @@ async function getCodebaseTokenCount(
|
||||
messageId: number,
|
||||
messageContent: string,
|
||||
appPath: string,
|
||||
chatContext: unknown,
|
||||
): Promise<number> {
|
||||
// Clean up expired cache entries first
|
||||
cleanupExpiredCacheEntries();
|
||||
@@ -86,6 +89,7 @@ async function getCodebaseTokenCount(
|
||||
cacheEntry &&
|
||||
cacheEntry.messageId === messageId &&
|
||||
cacheEntry.messageContent === messageContent &&
|
||||
cacheEntry.chatContext === JSON.stringify(chatContext) &&
|
||||
now - cacheEntry.timestamp < CACHE_EXPIRATION_MS
|
||||
) {
|
||||
logger.log(`Using cached codebase token count for chatId: ${chatId}`);
|
||||
@@ -94,8 +98,12 @@ async function getCodebaseTokenCount(
|
||||
|
||||
// Calculate and cache the token count
|
||||
logger.log(`Calculating codebase token count for chatId: ${chatId}`);
|
||||
const codebase = (await extractCodebase(getDyadAppPath(appPath)))
|
||||
.formattedOutput;
|
||||
const codebase = (
|
||||
await extractCodebase({
|
||||
appPath: getDyadAppPath(appPath),
|
||||
chatContext: validateChatContext(chatContext),
|
||||
})
|
||||
).formattedOutput;
|
||||
const tokenCount = estimateTokens(codebase);
|
||||
|
||||
// Store in cache
|
||||
@@ -105,6 +113,7 @@ async function getCodebaseTokenCount(
|
||||
messageContent,
|
||||
tokenCount,
|
||||
timestamp: now,
|
||||
chatContext: JSON.stringify(chatContext),
|
||||
});
|
||||
|
||||
return tokenCount;
|
||||
@@ -277,6 +286,7 @@ const getProposalHandler = async (
|
||||
latestAssistantMessage.id,
|
||||
latestAssistantMessage.content || "",
|
||||
chat.app.path,
|
||||
chat.app.chatContext,
|
||||
);
|
||||
|
||||
const totalTokens = messagesTokenCount + codebaseTokenCount;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TokenCountParams } from "../ipc_types";
|
||||
import { TokenCountResult } from "../ipc_types";
|
||||
import { estimateTokens, getContextWindow } from "../utils/token_utils";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
import { validateChatContext } from "../utils/context_paths_utils";
|
||||
|
||||
const logger = log.scope("token_count_handlers");
|
||||
|
||||
@@ -73,7 +74,12 @@ export function registerTokenCountHandlers() {
|
||||
|
||||
if (chat.app) {
|
||||
const appPath = getDyadAppPath(chat.app.path);
|
||||
codebaseInfo = (await extractCodebase(appPath)).formattedOutput;
|
||||
codebaseInfo = (
|
||||
await extractCodebase({
|
||||
appPath,
|
||||
chatContext: validateChatContext(chat.app.chatContext),
|
||||
})
|
||||
).formattedOutput;
|
||||
codebaseTokens = estimateTokens(codebaseInfo);
|
||||
logger.log(
|
||||
`Extracted codebase information from ${appPath}, tokens: ${codebaseTokens}`,
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
type ChatSummary,
|
||||
ChatSummariesSchema,
|
||||
type UserSettings,
|
||||
type ContextPathResults,
|
||||
} from "../lib/schemas";
|
||||
import type {
|
||||
App,
|
||||
AppOutput,
|
||||
Chat,
|
||||
ChatResponseEnd,
|
||||
@@ -32,8 +32,9 @@ import type {
|
||||
RenameBranchParams,
|
||||
UserBudgetInfo,
|
||||
CopyAppParams,
|
||||
App,
|
||||
} from "./ipc_types";
|
||||
import type { ProposalResult } from "@/lib/schemas";
|
||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
export interface ChatStreamCallbacks {
|
||||
@@ -847,4 +848,17 @@ export class IpcClient {
|
||||
public async getUserBudget(): Promise<UserBudgetInfo | null> {
|
||||
return this.ipcRenderer.invoke("get-user-budget");
|
||||
}
|
||||
|
||||
public async getChatContextResults(params: {
|
||||
appId: number;
|
||||
}): Promise<ContextPathResults> {
|
||||
return this.ipcRenderer.invoke("get-context-paths", params);
|
||||
}
|
||||
|
||||
public async setChatContext(params: {
|
||||
appId: number;
|
||||
chatContext: AppChatContext;
|
||||
}): Promise<void> {
|
||||
return this.ipcRenderer.invoke("set-context-paths", params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { registerReleaseNoteHandlers } from "./handlers/release_note_handlers";
|
||||
import { registerImportHandlers } from "./handlers/import_handlers";
|
||||
import { registerSessionHandlers } from "./handlers/session_handlers";
|
||||
import { registerProHandlers } from "./handlers/pro_handlers";
|
||||
import { registerContextPathsHandlers } from "./handlers/context_paths_handlers";
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// Register all IPC handlers by category
|
||||
@@ -43,4 +44,5 @@ export function registerIpcHandlers() {
|
||||
registerImportHandlers();
|
||||
registerSessionHandlers();
|
||||
registerProHandlers();
|
||||
registerContextPathsHandlers();
|
||||
}
|
||||
|
||||
25
src/ipc/utils/context_paths_utils.ts
Normal file
25
src/ipc/utils/context_paths_utils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AppChatContext, AppChatContextSchema } from "@/lib/schemas";
|
||||
import log from "electron-log";
|
||||
|
||||
const logger = log.scope("context_paths_utils");
|
||||
|
||||
export function validateChatContext(chatContext: unknown): AppChatContext {
|
||||
if (!chatContext) {
|
||||
return {
|
||||
contextPaths: [],
|
||||
smartContextAutoIncludes: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate that the contextPaths data matches the expected schema
|
||||
return AppChatContextSchema.parse(chatContext);
|
||||
} catch (error) {
|
||||
logger.warn("Invalid contextPaths data:", error);
|
||||
// Return empty array as fallback if validation fails
|
||||
return {
|
||||
contextPaths: [],
|
||||
smartContextAutoIncludes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,28 @@ export const DyadProBudgetSchema = z.object({
|
||||
});
|
||||
export type DyadProBudget = z.infer<typeof DyadProBudgetSchema>;
|
||||
|
||||
export const GlobPathSchema = z.object({
|
||||
globPath: z.string(),
|
||||
});
|
||||
|
||||
export type GlobPath = z.infer<typeof GlobPathSchema>;
|
||||
|
||||
export const AppChatContextSchema = z.object({
|
||||
contextPaths: z.array(GlobPathSchema),
|
||||
smartContextAutoIncludes: z.array(GlobPathSchema),
|
||||
});
|
||||
export type AppChatContext = z.infer<typeof AppChatContextSchema>;
|
||||
|
||||
export type ContextPathResult = GlobPath & {
|
||||
files: number;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
export type ContextPathResults = {
|
||||
contextPaths: ContextPathResult[];
|
||||
smartContextAutoIncludes: ContextPathResult[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Zod schema for user settings
|
||||
*/
|
||||
|
||||
@@ -77,6 +77,8 @@ const validInvokeChannels = [
|
||||
"rename-branch",
|
||||
"clear-session-data",
|
||||
"get-user-budget",
|
||||
"get-context-paths",
|
||||
"set-context-paths",
|
||||
// Test-only channels
|
||||
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
|
||||
// We can't detect with IS_TEST_BUILD in the preload script because
|
||||
|
||||
@@ -4,6 +4,9 @@ import path from "node:path";
|
||||
import { isIgnored } from "isomorphic-git";
|
||||
import log from "electron-log";
|
||||
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
|
||||
import { glob } from "glob";
|
||||
import { AppChatContext } from "../lib/schemas";
|
||||
import { readSettings } from "@/main/settings";
|
||||
|
||||
const logger = log.scope("utils/codebase");
|
||||
|
||||
@@ -315,15 +318,31 @@ ${content}
|
||||
}
|
||||
}
|
||||
|
||||
export type CodebaseFile = {
|
||||
path: string;
|
||||
content: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract and format codebase files as a string to be included in prompts
|
||||
* @param appPath - Path to the codebase to extract
|
||||
* @returns Object containing formatted output and individual files
|
||||
*/
|
||||
export async function extractCodebase(appPath: string): Promise<{
|
||||
export async function extractCodebase({
|
||||
appPath,
|
||||
chatContext,
|
||||
}: {
|
||||
appPath: string;
|
||||
chatContext: AppChatContext;
|
||||
}): Promise<{
|
||||
formattedOutput: string;
|
||||
files: { path: string; content: string }[];
|
||||
files: CodebaseFile[];
|
||||
}> {
|
||||
const settings = readSettings();
|
||||
const isSmartContextEnabled =
|
||||
settings?.enableDyadPro && settings?.enableProSmartFilesContextMode;
|
||||
|
||||
try {
|
||||
await fsAsync.access(appPath);
|
||||
} catch {
|
||||
@@ -335,14 +354,67 @@ export async function extractCodebase(appPath: string): Promise<{
|
||||
const startTime = Date.now();
|
||||
|
||||
// Collect all relevant files
|
||||
const files = await collectFiles(appPath, appPath);
|
||||
let files = await collectFiles(appPath, appPath);
|
||||
|
||||
// Collect files from contextPaths and smartContextAutoIncludes
|
||||
const { contextPaths, smartContextAutoIncludes } = chatContext;
|
||||
const includedFiles = new Set<string>();
|
||||
const autoIncludedFiles = new Set<string>();
|
||||
|
||||
// Add files from contextPaths
|
||||
if (contextPaths && contextPaths.length > 0) {
|
||||
for (const p of contextPaths) {
|
||||
const pattern = createFullGlobPath({
|
||||
appPath,
|
||||
globPath: p.globPath,
|
||||
});
|
||||
const matches = await glob(pattern, {
|
||||
nodir: true,
|
||||
absolute: true,
|
||||
ignore: "**/node_modules/**",
|
||||
});
|
||||
matches.forEach((file) => {
|
||||
const normalizedFile = path.normalize(file);
|
||||
includedFiles.add(normalizedFile);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add files from smartContextAutoIncludes
|
||||
if (
|
||||
isSmartContextEnabled &&
|
||||
smartContextAutoIncludes &&
|
||||
smartContextAutoIncludes.length > 0
|
||||
) {
|
||||
for (const p of smartContextAutoIncludes) {
|
||||
const pattern = createFullGlobPath({
|
||||
appPath,
|
||||
globPath: p.globPath,
|
||||
});
|
||||
const matches = await glob(pattern, {
|
||||
nodir: true,
|
||||
absolute: true,
|
||||
});
|
||||
matches.forEach((file) => {
|
||||
const normalizedFile = path.normalize(file);
|
||||
autoIncludedFiles.add(normalizedFile);
|
||||
includedFiles.add(normalizedFile); // Also add to included files
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only filter files if contextPaths are provided
|
||||
// If only smartContextAutoIncludes are provided, keep all files and just mark auto-includes as forced
|
||||
if (contextPaths && contextPaths.length > 0) {
|
||||
files = files.filter((file) => includedFiles.has(path.normalize(file)));
|
||||
}
|
||||
|
||||
// Sort files by modification time (oldest first)
|
||||
// This is important for cache-ability.
|
||||
const sortedFiles = await sortFilesByModificationTime(files);
|
||||
const sortedFiles = await sortFilesByModificationTime([...new Set(files)]);
|
||||
|
||||
// Format files and collect individual file contents
|
||||
const filesArray: { path: string; content: string }[] = [];
|
||||
const filesArray: CodebaseFile[] = [];
|
||||
const formatPromises = sortedFiles.map(async (file) => {
|
||||
const formattedContent = await formatFile(file, appPath);
|
||||
|
||||
@@ -352,6 +424,9 @@ export async function extractCodebase(appPath: string): Promise<{
|
||||
// Why? Normalize Windows-style paths which causes lots of weird issues (e.g. Git commit)
|
||||
.split(path.sep)
|
||||
.join("/");
|
||||
|
||||
const isForced = autoIncludedFiles.has(path.normalize(file));
|
||||
|
||||
const fileContent = isOmittedFile(relativePath)
|
||||
? OMITTED_FILE_CONTENT
|
||||
: await readFileWithCache(file);
|
||||
@@ -359,6 +434,7 @@ export async function extractCodebase(appPath: string): Promise<{
|
||||
filesArray.push({
|
||||
path: relativePath,
|
||||
content: fileContent,
|
||||
force: isForced,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -413,3 +489,15 @@ async function sortFilesByModificationTime(files: string[]): Promise<string[]> {
|
||||
// Sort by modification time (oldest first)
|
||||
return fileStats.sort((a, b) => a.mtime - b.mtime).map((item) => item.file);
|
||||
}
|
||||
|
||||
function createFullGlobPath({
|
||||
appPath,
|
||||
globPath,
|
||||
}: {
|
||||
appPath: string;
|
||||
globPath: string;
|
||||
}): string {
|
||||
// By default the glob package treats "\" as an escape character.
|
||||
// We want the path to use forward slash for all platforms.
|
||||
return `${appPath.replace(/\\/g, "/")}/${globPath}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user