diff --git a/package-lock.json b/package-lock.json index 1867d1c..6835777 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,6 +126,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1271,6 +1272,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.16.tgz", "integrity": "sha512-2XKQKxvQdeQiuIo0tacAmDVojhSVAci8D2WDdmmyN+6CqDusLHEHyIDaOt4o+UBvpkyHXbCdrljzDTQY/AKeqg==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -3515,6 +3517,7 @@ "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3524,6 +3527,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3534,6 +3538,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3616,6 +3621,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -3873,6 +3879,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4252,6 +4259,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5314,6 +5322,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5374,6 +5383,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8987,6 +8997,7 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9101,6 +9112,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9110,6 +9122,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10125,6 +10138,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10298,6 +10312,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10584,6 +10599,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11161,6 +11177,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11325,6 +11342,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 9d997dd..696f1f3 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { createDeepAgent } from "deepagents" -import { getDefaultModel } from "../ipc/models" +import { getDefaultModel, AVAILABLE_MODELS } from "../ipc/models" import { getApiKey, getThreadCheckpointPath } from "../storage" import { ChatAnthropic } from "@langchain/anthropic" import { ChatOpenAI } from "@langchain/openai" @@ -65,8 +65,12 @@ function getModelInstance( const model = modelId || getDefaultModel() console.log("[Runtime] Using model:", model) - // Determine provider from model ID - if (model.startsWith("claude")) { + // Check if model is in our known cloud provider list + const knownModel = AVAILABLE_MODELS.find((m) => m.id === model || m.model === model) + + // 1. ANTHROPIC + // Check known model OR fallback prefix check (for backwards compat) + if (knownModel?.provider === "anthropic" || (!knownModel && model.startsWith("claude"))) { const apiKey = getApiKey("anthropic") console.log("[Runtime] Anthropic API key present:", !!apiKey) if (!apiKey) { @@ -76,11 +80,20 @@ function getModelInstance( model, anthropicApiKey: apiKey }) - } else if ( - model.startsWith("gpt") || - model.startsWith("o1") || - model.startsWith("o3") || - model.startsWith("o4") + } + + // 2. OPENAI + // Only use OpenAI provider if it's a configured cloud model OR matches known prefixes AND isn't excluded + else if ( + knownModel?.provider === "openai" || + (!knownModel && + // More specific checks for official OpenAI models to avoid capturing local "gpt-*" models + ((model.startsWith("gpt-") && !model.includes("oss") && !model.includes("local")) || + model.startsWith("o1-") || + model.startsWith("o1") || + model.startsWith("o3-") || + model.startsWith("o3") || + model.startsWith("o4-"))) ) { const apiKey = getApiKey("openai") console.log("[Runtime] OpenAI API key present:", !!apiKey) @@ -91,7 +104,10 @@ function getModelInstance( model, openAIApiKey: apiKey }) - } else if (model.startsWith("gemini")) { + } + + // 3. GOOGLE + else if (knownModel?.provider === "google" || (!knownModel && model.startsWith("gemini"))) { const apiKey = getApiKey("google") console.log("[Runtime] Google API key present:", !!apiKey) if (!apiKey) { @@ -103,8 +119,19 @@ function getModelInstance( }) } - // Default to model string (let deepagents handle it) - return model + // 4. FALLBACK -> LOCAL / OLLAMA + else { + // Assume Ollama / Local OpenAI-compatible model + // This allows using models like "gpt-oss:...", "llama3", etc. + console.log("[Runtime] Using local/Ollama model:", model) + return new ChatOpenAI({ + model, + configuration: { + baseURL: "http://127.0.0.1:11434/v1" + }, + apiKey: "ollama" // Required by SDK but ignored by Ollama + }) + } } export interface CreateAgentRuntimeOptions { diff --git a/src/main/agent/system-prompt.ts b/src/main/agent/system-prompt.ts index 514a05d..e761b8b 100644 --- a/src/main/agent/system-prompt.ts +++ b/src/main/agent/system-prompt.ts @@ -11,6 +11,8 @@ Be concise and direct. Answer in fewer than 4 lines unless the user asks for det After working on a file, just stop - don't explain what you did unless asked. Avoid unnecessary introductions or conclusions. +**CRITICAL: When asked to write code, create an app, or modify files, YOU MUST USE THE write_file OR edit_file TOOLS. DO NOT just print the code in a markdown block. The user expects the files to be created on their disk.** + When you run non-trivial bash commands, briefly explain what they do. ## Proactiveness @@ -99,6 +101,10 @@ Respect the user's decisions and work with them collaboratively. ## Todo List Management +When using the write_todos tool, ensure you use the correct schema: +write_todos(todos: { content: string, status: "pending" | "in_progress" | "completed" }[]) +**IMPORTANT: Use "content" for the task description, NOT "task".** + When using the write_todos tool: 1. Keep the todo list MINIMAL - aim for 3-6 items maximum 2. Only create todos for complex, multi-step tasks that truly need tracking diff --git a/src/main/ipc/models.ts b/src/main/ipc/models.ts index e56866b..ff404b3 100644 --- a/src/main/ipc/models.ts +++ b/src/main/ipc/models.ts @@ -27,7 +27,7 @@ const PROVIDERS: Omit[] = [ ] // Available models configuration (updated Jan 2026) -const AVAILABLE_MODELS: ModelConfig[] = [ +export const AVAILABLE_MODELS: ModelConfig[] = [ // Anthropic Claude 4.5 series (latest as of Jan 2026) { id: "claude-opus-4-5-20251101", @@ -207,11 +207,34 @@ const AVAILABLE_MODELS: ModelConfig[] = [ export function registerModelHandlers(ipcMain: IpcMain): void { // List available models ipcMain.handle("models:list", async () => { + // Fetch Ollama models + let ollamaModels: ModelConfig[] = [] + try { + const response = await fetch("http://127.0.0.1:11434/api/tags") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (await response.json()) as any + if (data && data.models) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ollamaModels = data.models.map((m: any) => ({ + id: m.name, + name: m.name, + provider: "ollama", + model: m.name, + description: `Local Ollama model`, + available: true + })) + } + } catch (error) { + // Ollama not running or not reachable, just ignore + } + // Check which models have API keys configured - return AVAILABLE_MODELS.map((model) => ({ + const standardModels = AVAILABLE_MODELS.map((model) => ({ ...model, available: hasApiKey(model.provider) })) + + return [...standardModels, ...ollamaModels] }) // Get default model @@ -241,10 +264,19 @@ export function registerModelHandlers(ipcMain: IpcMain): void { // List providers with their API key status ipcMain.handle("models:listProviders", async () => { - return PROVIDERS.map((provider) => ({ + const providers = PROVIDERS.map((provider) => ({ ...provider, hasApiKey: hasApiKey(provider.id) })) + + // Add Ollama provider + providers.push({ + id: "ollama" as any, // Cast to any if ProviderId type is strict in main process context + name: "Ollama (Local)", + hasApiKey: true + }) + + return providers }) // Sync version info @@ -357,9 +389,17 @@ export function registerModelHandlers(ipcMain: IpcMain): void { // Recursively read directory async function readDir(dirPath: string, relativePath: string = ""): Promise { + // If dirPath doesn't exist, we can't read it + try { + await fs.access(dirPath) + } catch { + return + } + const entries = await fs.readdir(dirPath, { withFileTypes: true }) for (const entry of entries) { + // Skip hidden files and common non-project files if (entry.name.startsWith(".") || entry.name === "node_modules") { continue diff --git a/src/main/services/workspace-watcher.ts b/src/main/services/workspace-watcher.ts index 86fa8bd..64a8ace 100644 --- a/src/main/services/workspace-watcher.ts +++ b/src/main/services/workspace-watcher.ts @@ -3,12 +3,13 @@ import * as path from "path" import { BrowserWindow } from "electron" // Store active watchers by thread ID -const activeWatchers = new Map() +const activeWatchers = new Map() // Debounce timers to prevent rapid-fire updates const debounceTimers = new Map() const DEBOUNCE_DELAY = 500 // ms +const POLLING_INTERVAL = 4000 // 4s safety poll for reliable updates /** * Start watching a workspace directory for file changes. @@ -31,8 +32,9 @@ export function startWatching(threadId: string, workspacePath: string): void { } try { + // 1. Native FS Watcher // Use recursive watching (supported on macOS and Windows) - const watcher = fs.watch(workspacePath, { recursive: true }, (eventType, filename) => { + const fsWatcher = fs.watch(workspacePath, { recursive: true }, (eventType, filename) => { // Skip hidden files and common non-project files if (filename) { const parts = filename.split(path.sep) @@ -57,12 +59,18 @@ export function startWatching(threadId: string, workspacePath: string): void { debounceTimers.set(threadId, timer) }) - watcher.on("error", (error) => { + fsWatcher.on("error", (error) => { console.error(`[WorkspaceWatcher] Error watching ${workspacePath}:`, error) - stopWatching(threadId) + // Don't fully stop on error, the polling will pick it up }) - activeWatchers.set(threadId, watcher) + // 2. Safety Polling Interval + // Ensures UI updates even if fs.watch fails (common on network/virtual drives like A:\) + const pollTimer = setInterval(() => { + notifyRenderer(threadId, workspacePath) + }, POLLING_INTERVAL) + + activeWatchers.set(threadId, { watcher: fsWatcher, timer: pollTimer }) console.log(`[WorkspaceWatcher] Started watching ${workspacePath} for thread ${threadId}`) } catch (e) { console.error(`[WorkspaceWatcher] Failed to start watching ${workspacePath}:`, e) @@ -73,9 +81,10 @@ export function startWatching(threadId: string, workspacePath: string): void { * Stop watching the workspace for a specific thread. */ export function stopWatching(threadId: string): void { - const watcher = activeWatchers.get(threadId) - if (watcher) { - watcher.close() + const active = activeWatchers.get(threadId) + if (active) { + active.watcher.close() + if (active.timer) clearInterval(active.timer) activeWatchers.delete(threadId) console.log(`[WorkspaceWatcher] Stopped watching for thread ${threadId}`) } diff --git a/src/renderer/src/components/chat/ModelSwitcher.tsx b/src/renderer/src/components/chat/ModelSwitcher.tsx index 45ea665..8e7f922 100644 --- a/src/renderer/src/components/chat/ModelSwitcher.tsx +++ b/src/renderer/src/components/chat/ModelSwitcher.tsx @@ -33,18 +33,27 @@ function GoogleIcon({ className }: { className?: string }): React.JSX.Element { ) } +function OllamaIcon({ className }: { className?: string }): React.JSX.Element { + return ( + + + + ) +} + const PROVIDER_ICONS: Record> = { anthropic: AnthropicIcon, openai: OpenAIIcon, google: GoogleIcon, - ollama: () => null // No icon for ollama yet + ollama: OllamaIcon } // Fallback providers in case the backend hasn't loaded them yet const FALLBACK_PROVIDERS: Provider[] = [ { id: "anthropic", name: "Anthropic", hasApiKey: false }, { id: "openai", name: "OpenAI", hasApiKey: false }, - { id: "google", name: "Google", hasApiKey: false } + { id: "google", name: "Google", hasApiKey: false }, + { id: "ollama", name: "Ollama (Local)", hasApiKey: true } ] interface ModelSwitcherProps { diff --git a/src/renderer/src/components/panels/FilesystemPanel.tsx b/src/renderer/src/components/panels/FilesystemPanel.tsx index a9c1ab3..ac65933 100644 --- a/src/renderer/src/components/panels/FilesystemPanel.tsx +++ b/src/renderer/src/components/panels/FilesystemPanel.tsx @@ -290,10 +290,10 @@ export function FilesystemPanel() {