diff --git a/README.md b/README.md index 66a4032..c899c98 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -# FreeCode +# freeCode + +> **Freedom to code. Built for vibe coders. πŸš€** +> Professional architecture Β· Agentic superpowers Β· Better than Cursor An open-source, **VS Code-inspired AI coding assistant** with full tool access β€” read files, edit code, run commands, search the web, and more. Runs everywhere: as a VS Code extension, a standalone Windows desktop app, or a Node.js CLI. @@ -14,9 +17,65 @@ An open-source, **VS Code-inspired AI coding assistant** with full tool access --- +## What's New in v2.4 β€” vibe-coder edition πŸŽ‰ + +### 🎀 Voice Input + +Click the **🎀** mic button in the input bar to dictate your prompt using the browser's Web Speech API. + +- Click once to **start recording**, click again (or wait) to stop +- **Interim transcription** streams live into the input field as you speak β€” you see the words appear in real time +- Pulsing **red glow animation** while recording so you always know the mic is active +- Button is automatically hidden on browsers/OSes that don't support the Speech API β€” no broken UI + +### πŸ“ File-Change Watcher Toast + +When any file you've opened in the editor (or added to context) is modified externally β€” by another process, `git pull`, or a background tool β€” a **toast notification** slides up from the bottom of the screen: + +``` +πŸ“ server.js was modified externally [Re-read] [βœ•] +``` + +- **Re-read** β€” instantly re-adds the file to the context chips so the agent has the latest content +- Auto-dismisses after 8 seconds if you ignore it +- Zero configuration β€” freeCode watches the workspace continuously + +### ❌ Tool Error β€” Inject Error as Context + +When an agent tool call fails (file not found, `old_string` mismatch, shell error, etc.), the tool card now: + +- Shows a **red border + `βœ— failed` badge** so errors are impossible to miss +- Adds a **"↩ Retry with error context"** button that pre-fills the input: + +``` +Fix this error that just occurred: + +[Tool Error in Edit] +old_string not found in file: src/server.ts +``` + +One click β†’ the agent gets the exact error text and self-corrects automatically. + +### πŸ’Ύ CLAUDE.md Auto-Update Offer + +After any session where the agent edited files, freeCode offers to write a project memory file: + +``` +πŸ’Ύ Update CLAUDE.md with a summary of this session? [Yes, update] [Not now] +``` + +Clicking **Yes, update** sends a structured prompt asking the agent to update (or create) `CLAUDE.md` with: +- Decisions made and architectural patterns established +- Files changed and conventions to follow in future sessions +- A concise developer-focused summary β€” not a chat log + +This turns every coding session into **persistent project knowledge**, making future sessions smarter automatically β€” a feature no other AI coding tool offers out of the box. + +--- + ## Windows Terminal β€” PowerShell First -On **Windows**, FreeCode always runs commands through **PowerShell** (`powershell.exe`). +On **Windows**, freeCode always runs commands through **PowerShell** (`powershell.exe`). WSL (Windows Subsystem for Linux) is intentionally **not used**, even when it is installed. ### Why PowerShell instead of WSL? @@ -29,7 +88,7 @@ WSL (Windows Subsystem for Linux) is intentionally **not used**, even when it is ### POSIX shims -Because many AI-generated commands use Unix utilities (`grep`, `cat`, `touch`, `ls`, `find`, `sed`, …), FreeCode automatically injects **PowerShell POSIX shims** before every command. These thin wrappers map the most common Unix commands to their PowerShell equivalents so commands like: +Because many AI-generated commands use Unix utilities (`grep`, `cat`, `touch`, `ls`, `find`, `sed`, …), freeCode automatically injects **PowerShell POSIX shims** before every command. These thin wrappers map the most common Unix commands to their PowerShell equivalents so commands like: ```powershell grep "error" log.txt @@ -362,11 +421,11 @@ The Read tool card now shows `filename.js Β· lines 1–50` in the header so you ```bash cd vscode-extension npm install -npm run package # builds open-claude-code-1.5.0.vsix -code --install-extension open-claude-code-1.5.0.vsix +npm run package # builds freecode-1.5.0.vsix +code --install-extension freecode-1.5.0.vsix ``` -Set your API key: **Open Claude Code: Set API Key** in the Command Palette. +Set your API key: **freeCode: Set API Key** in the Command Palette. ### Standalone Windows app @@ -546,11 +605,11 @@ The Read tool card now shows `filename.js Β· lines 1–50` in the header so you ```bash cd vscode-extension npm install -npm run package # builds open-claude-code-1.5.0.vsix -code --install-extension open-claude-code-1.5.0.vsix +npm run package # builds freecode-1.5.0.vsix +code --install-extension freecode-1.5.0.vsix ``` -Set your API key: **Open Claude Code: Set API Key** in the Command Palette. +Set your API key: **freeCode: Set API Key** in the Command Palette. ### Standalone Windows app @@ -761,11 +820,11 @@ The Read tool card now shows `filename.js Β· lines 1–50` in the header so you ```bash cd vscode-extension npm install -npm run package # builds open-claude-code-1.5.0.vsix -code --install-extension open-claude-code-1.5.0.vsix +npm run package # builds freecode-1.5.0.vsix +code --install-extension freecode-1.5.0.vsix ``` -Set your API key: **Open Claude Code: Set API Key** in the Command Palette. +Set your API key: **freeCode: Set API Key** in the Command Palette. ### Standalone Windows app diff --git a/electron-app/README.md b/electron-app/README.md index ff07deb..3f0264b 100644 --- a/electron-app/README.md +++ b/electron-app/README.md @@ -1,11 +1,55 @@ -# FreeCode β€” Standalone Windows App +# freeCode β€” AI Coding Freedom for Vibe Coders πŸš€ -A standalone Windows 11 desktop application that implements the **FreeCode** AI coding assistant without requiring Visual Studio Code. +> **Freedom to code. Professional architecture. Agentic superpowers.** + +A standalone Windows 11 desktop application that implements the **freeCode** AI coding assistant without requiring Visual Studio Code. Dedicated to vibe coders who want a professional-grade AI coding tool that surpasses Cursor, Windsurf, and Claude Code. Built with [Electron](https://www.electronjs.org/), it reuses the same agent loop (`v2/src`) from the VS Code extension and presents it in a full **VS Code-inspired 3-column IDE layout**. --- +## What's New in v2.4 β€” vibe-coder edition πŸŽ‰ + +### 🎀 Voice Input + +Click the **🎀** mic button in the input bar to dictate your prompt using the Web Speech API. + +- Click once to **start recording**, click again to stop +- **Interim transcription** streams live into the input field as you speak +- Pulsing **red glow animation** while recording +- Automatically hidden when the Speech API is unavailable + +### πŸ“ File-Change Watcher Toast + +When a file you have open or in context is modified externally β€” by `git pull`, another editor, or a background build tool β€” a **toast notification** appears: + +``` +πŸ“ server.js was modified externally [Re-read] [βœ•] +``` + +- **Re-read** β€” re-adds the file to the context chips instantly +- Auto-dismisses after 8 seconds +- Requires no configuration β€” freeCode watches the workspace continuously and now includes the full file path in `fileWatchEvent` for accurate matching + +### ❌ Tool Error β€” Inject Error as Context + +When an agent tool call fails, the tool card now: + +- Renders a **red border + `βœ— failed` badge** so errors stand out clearly +- Shows a **"↩ Retry with error context"** button that pre-populates the input with the exact error message so the agent can self-correct with one click + +### πŸ’Ύ CLAUDE.md Auto-Update Offer + +After sessions where the agent edited files, freeCode offers to create or update a `CLAUDE.md` memory file: + +``` +πŸ’Ύ Update CLAUDE.md with a summary of this session? [Yes, update] [Not now] +``` + +**Yes, update** instructs the agent to record the session's decisions, patterns, and changed files β€” making every future session smarter automatically. + +--- + ## What's New in v2.3 ### πŸ”Œ Custom Providers UI β€” Redesigned Settings Cards @@ -384,8 +428,8 @@ This produces two outputs in `dist/`: | File | Description | |---|---| -| `Open Claude Code Setup 1.0.0.exe` | NSIS installer with Start Menu / Desktop shortcuts | -| `OpenClaudeCode-1.0.0-portable.exe` | Single-file portable executable (no install required) | +| `freeCode Setup 1.0.0.exe` | NSIS installer with Start Menu / Desktop shortcuts | +| `freeCode-1.0.0-portable.exe` | Single-file portable executable (no install required) | > **Note:** Building requires `electron-builder` and an internet connection on first run to download the Electron binary for Windows. @@ -401,7 +445,7 @@ electron-app/ β”‚ # β€” handles IPC: readFile, writeFile, createFile, β”‚ # createDir, renameFile, deleteFile, watchWorkspace β”‚ # β€” permission-request/response IPC bridge (default mode) -β”‚ # β€” stores settings & history in %APPDATA%\FreeCode\ +β”‚ # β€” stores settings & history in %APPDATA%\freeCode\ β”œβ”€β”€ preload.js # Electron preload β€” exposes electronBridge IPC to renderer └── renderer/ β”œβ”€β”€ index.html # 3-column IDE layout (chat | editor | explorer) @@ -423,7 +467,7 @@ All persistent data is stored in the Electron `userData` directory: | Platform | Path | |---|---| -| Windows | `%APPDATA%\Open Claude Code\` | +| Windows | `%APPDATA%\freeCode\` | | File | Contents | |---|---| @@ -698,8 +742,8 @@ This produces two outputs in `dist/`: | File | Description | |---|---| -| `Open Claude Code Setup 1.0.0.exe` | NSIS installer with Start Menu / Desktop shortcuts | -| `OpenClaudeCode-1.0.0-portable.exe` | Single-file portable executable (no install required) | +| `freeCode Setup 1.0.0.exe` | NSIS installer with Start Menu / Desktop shortcuts | +| `freeCode-1.0.0-portable.exe` | Single-file portable executable (no install required) | > **Note:** Building requires `electron-builder` and an internet connection on first run to download the Electron binary for Windows. @@ -714,7 +758,7 @@ electron-app/ β”‚ # β€” runs agent loop (v2/src) in-process β”‚ # β€” handles IPC: readFile, writeFile, createFile, β”‚ # createDir, renameFile, deleteFile, watchWorkspace -β”‚ # β€” stores settings & history in %APPDATA%\FreeCode\ +β”‚ # β€” stores settings & history in %APPDATA%\freeCode\ β”œβ”€β”€ preload.js # Electron preload β€” exposes electronBridge IPC to renderer └── renderer/ β”œβ”€β”€ index.html # 3-column IDE layout (chat | editor | explorer) @@ -733,7 +777,7 @@ All persistent data is stored in the Electron `userData` directory: | Platform | Path | |---|---| -| Windows | `%APPDATA%\Open Claude Code\` | +| Windows | `%APPDATA%\freeCode\` | | File | Contents | |---|---| diff --git a/electron-app/main.js b/electron-app/main.js index a8dc765..8c5f4df 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -1,8 +1,10 @@ 'use strict'; /** - * main.js β€” Open Claude Code Electron Main Process + * main.js β€” freeCode Electron Main Process * - * Standalone Windows 11 application that implements the Open Claude Code agent. + * Standalone application for freeCode β€” AI coding freedom for vibe coders. + * Implements the freeCode agent with professional architecture and agentic superpowers, + * surpassing tools like Cursor, Windsurf, and Claude Code. * Ports the VSCode extension's functionality (extension.js) to Electron, replacing * VSCode-specific APIs with Electron equivalents: * @@ -14,7 +16,7 @@ * β€’ vscode.window.activeTextEditor β†’ not available (standalone app) * β€’ vscode.languages.getDiagnostics() β†’ not available (standalone app) * - * The Open Claude Code agent loop from v2/src runs **in-process** (imported + * The freeCode agent loop from v2/src runs **in-process** (imported * dynamically as ES modules) rather than as a subprocess. This avoids the * Node.js binary path issue on Windows and simplifies the architecture. */ @@ -24,7 +26,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const crypto = require('crypto'); -const { execFile } = require('child_process'); +const { execFile, spawnSync } = require('child_process'); const { pathToFileURL } = require('url'); const { MultiAgentOrchestrator } = require('./multi-agent-orchestrator'); @@ -101,12 +103,14 @@ const DEFAULT_SETTINGS = { autoAttachActiveFile: false, pinnedFiles: [], workspacePath: os.homedir(), + defaultShell: 'auto', // 'auto' | 'powershell' | 'wsl' | 'ubuntu' | 'bash' | 'cmd' multiAgentEnabled: false, multiAgentStrategy: 'parallel', multiAgentMaxProviders: 3, systemPromptPreset: 'expert-engineer', providers: [], customProviders: [], // [{ id, name, baseUrl, apiKey, models:[{id,name}], headers:[{name,value}] }] + mcpServers: {}, // { serverId: { command, args } } }; function getSettings() { @@ -170,7 +174,7 @@ function hasApiKey() { // ── In-Process Agent Bridge ─────────────────────────────────────────────────── /** - * Runs the Open Claude Code agent loop in the Electron main process. + * Runs the freeCode agent loop in the Electron main process. * Mirrors the message protocol of agent-bridge.mjs but uses IPC instead of * stdin/stdout to communicate with the renderer. */ @@ -238,9 +242,33 @@ class InProcessAgentBridge { const model = this._model || appSettings.model || 'claude-sonnet-4-6'; + // Connect MCP servers saved in app settings + const mcpClients = []; + const appMcpServers = appSettings.mcpServers || {}; + // Merge with any in ~/.claude/settings.json (already loaded in `settings`) + const allMcpServers = Object.assign({}, settings.mcpServers || {}, appMcpServers); + if (Object.keys(allMcpServers).length > 0) { + const { McpClient } = await import(v2url('mcp/client.mjs')); + for (const [name, config] of Object.entries(allMcpServers)) { + try { + const client = new McpClient(config); + await client.connect(); + const mcpTools = await client.listTools(); + tools.registerMcpTools(mcpTools, (toolName, toolArgs) => client.callTool(toolName, toolArgs)); + mcpClients.push(client); + } catch (err) { + console.warn(`MCP server "${name}" failed to connect: ${err.message}`); + } + } + } + + const mcpResourceTool = tools.get('ReadMcpResource'); + if (mcpResourceTool) mcpResourceTool._mcpClients = mcpClients; + this._loop = createAgentLoop({ model, tools, permissions, settings, hooks }); this._loop.state._agentLoader = agentLoader; this._loop.state._skillsLoader = skillsLoader; + this._loop.state._mcpClients = mcpClients; this._loop.state._hooks = settings.hooks; this._loop.state._permissionMode = permMode; @@ -476,7 +504,7 @@ function createWindow() { height: 800, minWidth: 600, minHeight: 500, - title: 'Open Claude Code', + title: 'freeCode', icon: path.join(__dirname, 'renderer', 'icon.ico'), webPreferences: { preload: path.join(__dirname, 'preload.js'), @@ -625,7 +653,7 @@ function showApiKeyDialog() { resizable: false, minimizable: false, maximizable: false, - title: 'Set API Key β€” Open Claude Code', + title: 'Set API Key β€” freeCode', webPreferences: { preload: path.join(__dirname, 'dialog-preload.js'), contextIsolation: true, @@ -695,15 +723,17 @@ ipcMain.on('renderer-message', async (event, msg) => { activeSession: activeMessages, activeSessionId, workspacePath: s.workspacePath || os.homedir(), + defaultShell: s.defaultShell || 'auto', // full settings for settings panel maxTurns: s.maxTurns || 20, showToolOutput: s.showToolOutput !== false, hasNvidiaKey: !!(s.nvidiaApiKey || process.env.NVIDIA_API_KEY), - customProviders: Array.isArray(s.providers) ? s.providers : [], - providers: Array.isArray(s.providers) ? s.providers : [], + customProviders: Array.isArray(s.providers) ? s.providers : (Array.isArray(s.customProviders) ? s.customProviders : []), + providers: Array.isArray(s.providers) ? s.providers : (Array.isArray(s.customProviders) ? s.customProviders : []), multiAgentEnabled: !!s.multiAgentEnabled, multiAgentStrategy: s.multiAgentStrategy || 'parallel', systemPromptPreset: s.systemPromptPreset || 'expert-engineer', + mcpServers: (s.mcpServers && typeof s.mcpServers === 'object') ? s.mcpServers : {}, }); // Report which shell the integrated terminal will use @@ -899,6 +929,136 @@ ipcMain.on('renderer-message', async (event, msg) => { break; } + // ── Folder tree for @folder context chip ───────────────────────────── + case 'getFolderTree': { + const rootDir = getSettings().workspacePath || os.homedir(); + const SKIP_DIRS = new Set(['node_modules','.git','dist','.next','__pycache__','.cache','.idea','.vscode','build','out','.DS_Store']); + function buildTextTree(dir, prefix, depth) { + if (depth > 4) return ''; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return ''; } + entries.sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + let out = ''; + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (e.isDirectory() && SKIP_DIRS.has(e.name)) continue; + const isLast = i === entries.length - 1; + const connector = isLast ? '└── ' : 'β”œβ”€β”€ '; + const childPrefix = prefix + (isLast ? ' ' : 'β”‚ '); + out += prefix + connector + e.name + (e.isDirectory() ? '/' : '') + '\n'; + if (e.isDirectory()) { + out += buildTextTree(path.join(dir, e.name), childPrefix, depth + 1); + } + } + return out; + } + const tree = path.basename(rootDir) + '/\n' + buildTextTree(rootDir, '', 0); + send({ type: 'folderTree', content: tree }); + break; + } + + // ── Fetch URL for @url context chip ─────────────────────────────────── + case 'fetchUrl': { + const targetUrl = String(msg.url || ''); + if (!targetUrl || !/^https?:\/\//.test(targetUrl)) { + send({ type: 'urlContent', url: targetUrl, content: '(invalid URL)', error: 'Invalid URL' }); + break; + } + // SSRF guard: block requests to loopback, private, link-local and metadata addresses + const PRIVATE_IP_RE = /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|::1|fc00:|fd|fe80:|0x|0177)/i; + let reqOpts; + try { reqOpts = new URL(targetUrl); } catch { + send({ type: 'urlContent', url: targetUrl, content: '(malformed URL)', error: 'Malformed URL' }); + break; + } + const hostname = reqOpts.hostname.toLowerCase(); + if (PRIVATE_IP_RE.test(hostname) || hostname === '[::1]') { + send({ type: 'urlContent', url: targetUrl, content: '(blocked: private/loopback addresses are not allowed)', error: 'Blocked hostname' }); + break; + } + const MAX_URL_CONTENT_BYTES = 20000; + try { + const content = await new Promise((resolve, reject) => { + const httpMod = targetUrl.startsWith('https://') ? require('https') : require('http'); + const req = httpMod.get({ hostname: reqOpts.hostname, path: reqOpts.pathname + (reqOpts.search || ''), + port: reqOpts.port, headers: { 'User-Agent': 'freeCode/1.0 (context-fetch)', 'Accept': 'text/html,text/plain' } }, + (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + resolve(`(redirect to ${res.headers.location} β€” please use the final URL directly)`); + return; + } + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + let text = Buffer.concat(chunks).toString('utf8'); + // Remove embedded scripts and styles completely (content is used as + // AI context text, not rendered as HTML β€” strip tags for readability). + // Use character-class negation to avoid false tag reconstruction: + text = text.replace(/]*>[\s\S]*?<\/script[^>]*>/gi, ' '); + text = text.replace(/]*>[\s\S]*?<\/style[^>]*>/gi, ' '); + // Remove remaining tags (non-greedy, no nesting needed for plain text) + text = text.replace(/<[^>]*>/g, ' '); + // Collapse whitespace + text = text.replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); + // Limit content size + if (text.length > MAX_URL_CONTENT_BYTES) text = text.slice(0, MAX_URL_CONTENT_BYTES) + '\n…(truncated)'; + resolve(text); + }); + } + ); + req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); + req.on('error', reject); + }); + send({ type: 'urlContent', url: targetUrl, content }); + } catch (err) { + send({ type: 'urlContent', url: targetUrl, content: `(failed to fetch: ${err.message})`, error: err.message }); + } + break; + } + + // ── Grep workspace for function/class symbols ───────────────────────── + case 'getSymbols': { + const wsPath = getSettings().workspacePath || os.homedir(); + const SYMBOL_EXTS = new Set(['.js','.ts','.jsx','.tsx','.mjs','.cjs','.py','.go','.rs','.java','.cpp','.c','.cs','.rb','.php','.swift','.kt']); + const SKIP_DIRS2 = new Set(['node_modules','.git','dist','.next','__pycache__','.cache','build','out']); + const results = []; + function scanDir(dir, depth) { + if (depth > 6 || results.length > 500) return; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (e.isDirectory()) { + if (!SKIP_DIRS2.has(e.name)) scanDir(path.join(dir, e.name), depth + 1); + } else { + const ext = path.extname(e.name).toLowerCase(); + if (!SYMBOL_EXTS.has(ext)) continue; + try { + const relPath = path.relative(wsPath, path.join(dir, e.name)); + const content = fs.readFileSync(path.join(dir, e.name), 'utf8'); + const lines = content.split('\n'); + for (let i = 0; i < lines.length && results.length < 500; i++) { + const line = lines[i]; + // Match common function/class/method declarations + if (/^\s*(export\s+)?(default\s+)?(async\s+)?function\s+\w|^\s*(export\s+)?(abstract\s+)?class\s+\w|^\s*def\s+\w|^\s*func\s+\w|^\s*(pub\s+)?(async\s+)?fn\s+\w/ + .test(line)) { + results.push(`${relPath}:${i + 1}: ${line.trim()}`); + } + } + } catch { /* skip */ } + } + } + } + scanDir(wsPath, 0); + const content = results.length > 0 + ? results.join('\n') + : '(no function/class symbols found in workspace)'; + send({ type: 'symbolsContext', content }); + break; + } + // ── Workspace diagnostics (not available in standalone app) ────────── case 'getWorkspaceDiagnostics': { send({ type: 'workspaceDiagnostics', diagnostics: [] }); @@ -1115,7 +1275,7 @@ ipcMain.on('renderer-message', async (event, msg) => { // ── Save a single setting ───────────────────────────────────────────── case 'saveSettings': { - const allowed = ['model','permissionMode','maxTurns','showToolOutput','nvidiaApiKey','workspacePath','multiAgentEnabled','multiAgentStrategy','providers','systemPromptPreset']; + const allowed = ['model','permissionMode','maxTurns','showToolOutput','nvidiaApiKey','workspacePath','defaultShell','multiAgentEnabled','multiAgentStrategy','providers','systemPromptPreset']; if (msg.key && allowed.includes(msg.key)) { saveSetting(msg.key, msg.value); applyEnvFromSettings(); @@ -1132,10 +1292,71 @@ ipcMain.on('renderer-message', async (event, msg) => { path: msg.value, }); } + if (msg.key === 'defaultShell') { + _preferredShell = null; + const sh = detectPreferredShell(); + send({ type: 'shellInfo', shell: sh.type }); + } } break; } + case 'detectShells': { + const results = {}; + + try { + const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'echo ok'], { + encoding: 'utf-8', timeout: 2000, windowsHide: true, + }); + results.powershell = r.status === 0 && String(r.stdout || '').trim() === 'ok'; + } catch { + results.powershell = false; + } + + try { + const r = spawnSync('wsl.exe', ['--status'], { + encoding: 'utf-8', timeout: 3000, windowsHide: true, + }); + results.wsl = r.status === 0; + } catch { + results.wsl = false; + } + + try { + const r = spawnSync('wsl.exe', ['-d', 'Ubuntu', '--', 'echo', 'ok'], { + encoding: 'utf-8', timeout: 3000, windowsHide: true, + }); + results.ubuntu = r.status === 0 && String(r.stdout || '').trim() === 'ok'; + } catch { + results.ubuntu = false; + } + + if (process.platform !== 'win32') { + try { + const r = spawnSync('bash', ['-c', 'echo ok'], { + encoding: 'utf-8', timeout: 1000, + }); + results.bash = r.status === 0; + } catch { + results.bash = false; + } + } else { + results.bash = false; + } + + try { + const r = spawnSync('cmd.exe', ['/c', 'echo ok'], { + encoding: 'utf-8', timeout: 2000, windowsHide: true, + }); + results.cmd = r.status === 0; + } catch { + results.cmd = false; + } + + send({ type: 'shellsDetected', shells: results }); + break; + } + // ── Save custom providers list ───────────────────────────────────────── case 'saveCustomProviders': { const providers = Array.isArray(msg.providers) ? msg.providers : []; @@ -1161,6 +1382,29 @@ ipcMain.on('renderer-message', async (event, msg) => { break; } + // ── Save MCP servers config ──────────────────────────────────────────── + case 'saveMcpServers': { + const mcpServers = (msg.mcpServers && typeof msg.mcpServers === 'object') ? msg.mcpServers : {}; + // Write to ~/.claude/settings.json so the v2 agent-loop picks it up + try { + const claudeDir = path.join(require('os').homedir(), '.claude'); + const settingsFile = path.join(claudeDir, 'settings.json'); + fs.mkdirSync(claudeDir, { recursive: true }); + let claudeSettings = {}; + try { claudeSettings = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch { /* new file */ } + claudeSettings.mcpServers = mcpServers; + fs.writeFileSync(settingsFile, JSON.stringify(claudeSettings, null, 2), 'utf8'); + } catch (err) { + console.warn('saveMcpServers: could not write ~/.claude/settings.json:', err.message); + } + // Also save in app settings for persistence across restarts + saveSetting('mcpServers', mcpServers); + // Reinit bridge so new servers are connected on the next turn + if (agentBridge) { captureAgentMessages(); agentBridge.reinit(); agentBridge = null; } + send({ type: 'mcpServersSaved', mcpServers }); + break; + } + case 'validateProvider': { const p = msg.provider && typeof msg.provider === 'object' ? msg.provider : null; const id = p?.id || ''; @@ -1320,7 +1564,9 @@ ipcMain.on('renderer-message', async (event, msg) => { if (watchPath && fs.existsSync(watchPath)) { try { global._workspaceWatcher = fs.watch(watchPath, { recursive: true }, (event, filename) => { - send({ type: 'fileWatchEvent', event, filename: filename || '' }); + // Include full path so the renderer can match it against open tabs + const fullPath = filename ? path.join(watchPath, filename) : ''; + send({ type: 'fileWatchEvent', event, filename: filename || '', path: fullPath }); }); } catch (watchErr) { console.warn('watchWorkspace: fs.watch failed for', watchPath, ':', watchErr.message); @@ -1337,44 +1583,114 @@ ipcMain.on('renderer-message', async (event, msg) => { // ── Handle run-in-terminal ──────────────────────────────────────────────────── /** - * Detect the preferred shell on Windows. - * Priority: Ubuntu WSL distro β†’ generic WSL β†’ PowerShell β†’ bash (non-Windows). + * Detect the preferred shell. + * Uses explicit user preference when set; otherwise auto-detects + * with PowerShell-first behavior on Windows. * Result is cached after first call. */ let _preferredShell = null; + +const POWERSHELL_SHIMS_MODULE = `# freeCode-shims.psm1 β€” freeCode PowerShell POSIX compatibility shims +function which { param([string]$cmd) $r = Get-Command $cmd -ErrorAction SilentlyContinue; if ($r) { $r.Source } else { Write-Error "which: $cmd: not found"; exit 1 } } +function grep { param([string]$pat, [Parameter(ValueFromRemainingArguments)][string[]]$f) if ($f) { Select-String -Pattern $pat -Path $f | ForEach-Object { "$($_.Path):$($_.LineNumber):$($_.Line)" } } else { $input | Select-String -Pattern $pat | ForEach-Object { $_.Line } } } +function cat { param([Parameter(ValueFromRemainingArguments)][string[]]$paths) if ($paths) { Get-Content $paths } else { $input } } +function touch { param([string]$p) if (Test-Path $p) { (Get-Item $p).LastWriteTime = Get-Date } else { New-Item -ItemType File -Path $p -Force | Out-Null } } +function wc { param([string]$flag="-l") $lines = @($input); switch ($flag) { "-l" { $lines.Count } "-c" { ($lines -join "\`n").Length } "-w" { ($lines -join " ").Split() | Where-Object { $_ } | Measure-Object | Select-Object -Expand Count } default { $lines.Count } } } +function head { param([int]$n=10, [string]$file="") if ($file) { Get-Content $file -TotalCount $n } else { $input | Select-Object -First $n } } +function tail { param([int]$n=10, [string]$file="") if ($file) { Get-Content $file -Tail $n } else { $input | Select-Object -Last $n } } +function find { param([string]$path=".", [string]$name="*") Get-ChildItem -Path $path -Recurse -Filter $name -ErrorAction SilentlyContinue | Select-Object -Expand FullName } +function pwd { (Get-Location).Path } +function ls { param([Parameter(ValueFromRemainingArguments)][string[]]$a) Get-ChildItem @a } +function mkdir { param([Parameter(ValueFromRemainingArguments)][string[]]$a) New-Item -ItemType Directory @a -Force } +function rm { param([switch]$r, [switch]$f, [Parameter(ValueFromRemainingArguments)][string[]]$paths) Remove-Item $paths -Recurse:$r -Force:$f -ErrorAction SilentlyContinue } +function cp { param([string]$src, [string]$dst) Copy-Item -Path $src -Destination $dst -Recurse -Force } +function mv { param([string]$src, [string]$dst) Move-Item -Path $src -Destination $dst -Force } +function echo { param([Parameter(ValueFromRemainingArguments)][string[]]$a) Write-Output ($a -join " ") } +function sed { param([string]$expr, [string]$file="") if ($expr -match "^s/(.+)/(.+)/") { $pat=$Matches[1]; $rep=$Matches[2]; if ($file) { (Get-Content $file) -replace $pat,$rep | Set-Content $file } else { $input | ForEach-Object { $_ -replace $pat,$rep } } } } +function awk { param([string]$prog, [Parameter(ValueFromRemainingArguments)][string[]]$files) Write-Warning "awk: limited PowerShell shim β€” consider installing gawk via winget" } +Export-ModuleMember -Function * +`; + +function resolveShellByName(name) { + const map = { + powershell: { + type: 'powershell', + exe: 'powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-Command'], + syntax: 'powershell', + }, + wsl: { + type: 'wsl', + exe: 'wsl.exe', + args: ['bash', '-c'], + syntax: 'bash', + }, + ubuntu: { + type: 'ubuntu', + exe: 'wsl.exe', + args: ['-d', 'Ubuntu', '--', 'bash', '-c'], + syntax: 'bash', + }, + bash: { + type: 'bash', + exe: 'bash', + args: ['-c'], + syntax: 'bash', + }, + cmd: { + type: 'cmd', + exe: 'cmd.exe', + args: ['/c'], + syntax: 'cmd', + }, + }; + return map[name] || map.powershell; +} + +function isPowerShellAvailable() { + try { + const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'exit 0'], { + encoding: 'utf-8', timeout: 2000, windowsHide: true, + }); + return r.status === 0; + } catch { + return false; + } +} + +function ensurePowerShellShimsModule() { + const modPath = path.join(getUserData(), 'freeCode-shims.psm1'); + if (!fs.existsSync(modPath)) { + fs.writeFileSync(modPath, POWERSHELL_SHIMS_MODULE, 'utf8'); + } + return modPath; +} + +function quotePowerShellPath(p) { + return String(p || '').replace(/'/g, "''"); +} + function detectPreferredShell() { if (_preferredShell) return _preferredShell; - if (process.platform !== 'win32') { - _preferredShell = { type: 'bash', exe: 'bash', args: [] }; + + const s = getSettings(); + const pref = s.defaultShell || 'auto'; + if (pref !== 'auto') { + _preferredShell = resolveShellByName(pref); return _preferredShell; } - const { spawnSync } = require('child_process'); - - // Try Ubuntu WSL distro first - try { - const r = spawnSync('wsl.exe', ['-d', 'Ubuntu', '--', 'echo', 'ok'], { - encoding: 'utf-8', timeout: 4000, windowsHide: true, - }); - if (r.status === 0 && (r.stdout || '').trim() === 'ok') { - _preferredShell = { type: 'ubuntu', exe: 'wsl.exe', args: ['-d', 'Ubuntu', '--', 'bash', '-c'] }; - return _preferredShell; - } - } catch { /* ignore */ } + if (process.platform !== 'win32') { + _preferredShell = resolveShellByName('bash'); + return _preferredShell; + } - // Try generic WSL - try { - const r = spawnSync('wsl.exe', ['--status'], { - encoding: 'utf-8', timeout: 4000, windowsHide: true, - }); - if (r.status === 0) { - _preferredShell = { type: 'wsl', exe: 'wsl.exe', args: ['bash', '-c'] }; - return _preferredShell; - } - } catch { /* ignore */ } + if (isPowerShellAvailable()) { + _preferredShell = resolveShellByName('powershell'); + return _preferredShell; + } - // Fall back to PowerShell - _preferredShell = { type: 'powershell', exe: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command'] }; + _preferredShell = resolveShellByName('cmd'); return _preferredShell; } @@ -1394,6 +1710,10 @@ function handleRunInTerminal(code) { spawn('wsl.exe', [], { cwd, detached: true, stdio: 'ignore', windowsHide: false, }); + } else if (shell.type === 'cmd') { + spawn('cmd.exe', ['/k', code], { + cwd, detached: true, stdio: 'ignore', windowsHide: false, + }); } else { spawn('powershell.exe', [ '-NoProfile', '-NoExit', '-Command', code, @@ -1417,30 +1737,15 @@ function handleTerminalRun(command, reqId, send) { const cwd = getSettings().workspacePath || os.homedir(); const shell = detectPreferredShell(); - let shellExe, shellArgs; - if (shell.type === 'ubuntu') { - shellExe = 'wsl.exe'; - shellArgs = ['-d', 'Ubuntu', '--', 'bash', '-c', command]; - } else if (shell.type === 'wsl') { - shellExe = 'wsl.exe'; - shellArgs = ['bash', '-c', command]; - } else if (shell.type === 'powershell') { - // Inject POSIX shims so common Unix tools work in PS - const TERMINAL_PS_SHIMS = [ - 'function which { param([string]$cmd) $r = Get-Command $cmd -ErrorAction SilentlyContinue; if ($r) { $r.Source } else { Write-Error "which: $cmd not found" } }', - 'function grep { param([string]$pattern, [Parameter(ValueFromRemainingArguments)][string[]]$paths) if ($paths) { Select-String -Pattern $pattern -Path $paths | ForEach-Object { "$($_.Path):$($_.LineNumber):$($_.Line)" } } else { $input | Select-String -Pattern $pattern | ForEach-Object { $_.Line } } }', - 'function cat { param([Parameter(ValueFromRemainingArguments)][string[]]$paths) Get-Content $paths }', - 'function touch { param([string]$path) if (Test-Path $path) { (Get-Item $path).LastWriteTime = Get-Date } else { New-Item -ItemType File -Path $path -Force | Out-Null } }', - 'function head { param([string]$flag="", [string]$file="") $n=10; if ($flag -match "^-(\\d+)$") { $n=[int]$Matches[1]; $file="" } elseif ($flag -eq "-n") { $n=[int]$file; $file="" } elseif ($flag -ne "") { $file=$flag }; if ($file) { Get-Content $file | Select-Object -First $n } else { $input | Select-Object -First $n } }', - 'function tail { param([string]$flag="", [string]$file="") $n=10; if ($flag -match "^-(\\d+)$") { $n=[int]$Matches[1]; $file="" } elseif ($flag -eq "-n") { $n=[int]$file; $file="" } elseif ($flag -ne "") { $file=$flag }; if ($file) { Get-Content $file | Select-Object -Last $n } else { $input | Select-Object -Last $n } }', - 'function find { param([string]$basePath=".", [string]$nameFlag="", [string]$namePattern="*") if ($nameFlag -ne "-name") { $namePattern=$nameFlag }; Get-ChildItem -Path $basePath -Recurse -Filter $namePattern -Force | ForEach-Object { $_.FullName } }', - 'function pwd { (Get-Location).Path }', - ].join('; '); - shellExe = 'powershell.exe'; - shellArgs = ['-NoProfile', '-NonInteractive', '-Command', `${TERMINAL_PS_SHIMS}; ${command}`]; - } else { - shellExe = 'bash'; - shellArgs = ['-c', command]; + let shellExe = shell.exe; + let shellArgs = [...shell.args, command]; + if (shell.type === 'powershell') { + const modulePath = ensurePowerShellShimsModule(); + const prelude = `Import-Module '${quotePowerShellPath(modulePath)}' -Force; `; + shellArgs = [...shell.args, `${prelude}${command}`]; + } else if (shell.type === 'cmd') { + shellExe = 'cmd.exe'; + shellArgs = ['/c', command]; } const { spawn } = require('child_process'); @@ -1486,7 +1791,9 @@ function handleTerminalRun(command, reqId, send) { async function handleRunPrompt(message, contextFilePaths, fileRefs, send) { isCancelled = false; - let fullPrompt = message; + const shellHint = detectPreferredShell(); + const basePrompt = `[Execution shell: ${shellHint.type} (${shellHint.syntax}). Generate shell commands that match this shell syntax.]\n\n${message}`; + let fullPrompt = basePrompt; // Inject context file contents const allPaths = new Set(contextFilePaths || []); @@ -1509,7 +1816,7 @@ async function handleRunPrompt(message, contextFilePaths, fileRefs, send) { } catch { /* skip unreadable */ } } if (fileContents.length > 0) { - fullPrompt = message + '\n\n[Context files:]' + fileContents.join(''); + fullPrompt = basePrompt + '\n\n[Context files:]' + fileContents.join(''); } } diff --git a/electron-app/package.json b/electron-app/package.json index 865c82b..8fe3577 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,8 +1,8 @@ { - "name": "open-claude-code", - "productName": "FreeCode AI IDE", + "name": "freecode", + "productName": "freeCode", "version": "2.0.0", - "description": "Open Claude Code β€” standalone AI coding assistant for Windows 11", + "description": "freeCode β€” AI coding freedom for vibe coders. Professional architecture, agentic superpowers.", "main": "main.js", "scripts": { "start": "electron .", @@ -11,7 +11,8 @@ "build:portable": "electron-builder --win portable" }, "keywords": [ - "claude", + "freecode", + "vibe-coding", "ai", "coding-assistant", "electron", @@ -23,9 +24,9 @@ "electron-builder": "^26.8.1" }, "build": { - "appId": "com.openclaudecode.app", - "productName": "FreeCode AI IDE", - "copyright": "Copyright Β© 2026 FreeCode AI IDE", + "appId": "com.freecode.app", + "productName": "freeCode", + "copyright": "Copyright Β© 2026 freeCode β€” AI coding freedom for vibe coders", "directories": { "output": "dist" }, @@ -72,10 +73,10 @@ "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, "createStartMenuShortcut": true, - "shortcutName": "Open Claude Code" + "shortcutName": "freeCode" }, "portable": { - "artifactName": "OpenClaudeCode-${version}-portable.exe" + "artifactName": "freeCode-${version}-portable.exe" }, "publish": null } diff --git a/electron-app/renderer/chat.css b/electron-app/renderer/chat.css index df75246..c2e203f 100644 --- a/electron-app/renderer/chat.css +++ b/electron-app/renderer/chat.css @@ -1,5 +1,5 @@ /* ============================================================ - Claude Code β€” Cursor-style Chat Panel + freeCode β€” AI coding freedom for vibe coders ============================================================ */ *, *::before, *::after { @@ -1583,6 +1583,27 @@ select:focus { border-color: var(--accent); } color: var(--error-text); } +/* Folder tree chip */ +.context-chip.chip-folder { + background: rgba(80,180,80,0.1); + border-color: rgba(80,180,80,0.3); + color: #5ab55a; +} + +/* URL chip */ +.context-chip.chip-url { + background: rgba(0,140,220,0.1); + border-color: rgba(0,140,220,0.3); + color: #3ab0e8; +} + +/* Symbols chip */ +.context-chip.chip-symbols { + background: rgba(160,80,220,0.1); + border-color: rgba(160,80,220,0.3); + color: #b56ee8; +} + /* Active state for context inject buttons (auto-attach on) */ .input-btn.active { color: var(--accent); @@ -2032,6 +2053,11 @@ select:focus { border-color: var(--accent); } padding: 0 12px 8px; } +#setting-shell-availability { + flex: 1; + padding: 0; +} + .settings-link { font-size: 11px; color: var(--accent); @@ -3205,6 +3231,11 @@ select:focus { border-color: var(--accent); } border-color: rgba(78,201,176,0.35); color: #4ec9b0; } +.terminal-shell-badge[data-shell="cmd"] { + background: rgba(255,176,64,0.12); + border-color: rgba(255,176,64,0.35); + color: #ffb040; +} .terminal-btn { background: none; @@ -3400,3 +3431,169 @@ select:focus { border-color: var(--accent); } margin-top: 10px; background: var(--bg-elevated); } + +/* ── MCP Marketplace ──────────────────────────────────────────── */ +#mcp-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 8px; +} + +.mcp-entry { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + font-size: 12px; + transition: border-color 0.15s; +} +.mcp-entry:hover { border-color: var(--accent); } +.mcp-entry.mcp-entry-enabled { + border-color: rgba(80,200,120,0.5); + background: rgba(80,200,120,0.05); +} + +.mcp-entry-left { + display: flex; + align-items: flex-start; + gap: 10px; + flex: 1; + min-width: 0; +} + +.mcp-entry-icon { + font-size: 22px; + flex-shrink: 0; + line-height: 1; + margin-top: 2px; +} + +.mcp-entry-info { + flex: 1; + min-width: 0; +} + +.mcp-entry-name { + font-weight: 600; + color: var(--text); + font-size: 13px; + margin-bottom: 2px; +} + +.mcp-entry-desc { + color: var(--text-dim); + font-size: 11px; + line-height: 1.4; +} + +.mcp-entry-hint { + font-size: 10px; + color: var(--accent); + margin-top: 3px; + font-family: var(--font-code); +} + +/* ── Welcome screen tagline ───────────────────────────────────────── */ +.welcome-tagline { + font-size: 15px; + color: var(--text); + margin-bottom: 6px; + line-height: 1.5; + text-align: center; +} +.welcome-sub { + font-size: 11px; + color: var(--text-dim); + letter-spacing: 0.03em; +} + +/* ── Voice button recording state ────────────────────────────────── */ +.input-btn.voice-recording { + color: var(--error-text); + border-color: rgba(244,135,113,0.5); + background: rgba(244,135,113,0.1); + animation: voicePulse 1s ease-in-out infinite; +} +@keyframes voicePulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +/* ── Toast / notification banners ────────────────────────────────── */ +#file-watch-toast, +#claudemd-banner { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: var(--bg-elevated); + border: 1px solid var(--accent); + border-radius: 10px; + font-size: 12px; + color: var(--text); + box-shadow: 0 4px 24px rgba(0,0,0,0.45); + max-width: 520px; + white-space: nowrap; +} +#file-watch-toast code, +#claudemd-banner code { + font-family: var(--font-code); + color: var(--accent); + background: rgba(0,122,204,0.12); + padding: 1px 5px; + border-radius: 4px; +} +.toast-btn { + background: var(--accent); + color: #fff; + border: none; + padding: 4px 12px; + border-radius: 6px; + font-size: 11px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s; +} +.toast-btn:hover { background: var(--accent-hover); } +.toast-dismiss { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + padding: 4px 8px; + border-radius: 6px; + font-size: 11px; + cursor: pointer; + flex-shrink: 0; + transition: all 0.15s; +} +.toast-dismiss:hover { background: var(--bg-input); color: var(--text); } + +/* ── Tool card error state ────────────────────────────────────────── */ +.tool-card.tool-error { + border-color: rgba(244,135,113,0.45); + background: rgba(58,26,26,0.55); +} +.tool-card-inject-btn { + margin-top: 6px; + background: rgba(244,135,113,0.1); + border: 1px solid rgba(244,135,113,0.4); + color: var(--error-text); + padding: 3px 10px; + border-radius: 6px; + font-size: 11px; + cursor: pointer; + transition: all 0.15s; +} +.tool-card-inject-btn:hover { + background: rgba(244,135,113,0.2); +} + diff --git a/electron-app/renderer/chat.js b/electron-app/renderer/chat.js index 7366f80..6e1e3f4 100644 --- a/electron-app/renderer/chat.js +++ b/electron-app/renderer/chat.js @@ -52,7 +52,36 @@ let pendingApply = null; // { code, language } let activeToolCards = {}; // toolName -> dom element let sessionMessages = []; // tracked messages for history saving - let lastUserMessage = ''; // for ↑ recall and regenerate + let lastUserMessage = ''; // for regenerate + + // ── Prompt history (Ctrl+Up / Ctrl+Down cycling) ───────────────────────── + const PROMPT_HISTORY_MAX = 50; + let promptHistory = []; // newest first + let promptHistoryIdx = -1; // -1 = not cycling; 0..N = current position + let promptHistoryDraft = ''; // text saved before cycling began + + (function loadPromptHistory() { + try { + const raw = localStorage.getItem('freecode_prompt_history'); + if (raw) promptHistory = JSON.parse(raw).slice(0, PROMPT_HISTORY_MAX); + } catch { /* ignore */ } + }()); + + function savePromptHistory() { + try { + localStorage.setItem('freecode_prompt_history', JSON.stringify(promptHistory.slice(0, PROMPT_HISTORY_MAX))); + } catch { /* ignore */ } + } + + function pushPromptHistory(text) { + if (!text || !text.trim()) return; + // Deduplicate: remove existing copy so it moves to front + promptHistory = promptHistory.filter(h => h !== text); + promptHistory.unshift(text); + if (promptHistory.length > PROMPT_HISTORY_MAX) promptHistory.length = PROMPT_HISTORY_MAX; + savePromptHistory(); + promptHistoryIdx = -1; + } let autoScroll = true; // auto-scroll while streaming let pinnedFiles = []; // files pinned across sessions let currentSessionId = null; // null for new sessions; history entry ID when continuing a saved session @@ -183,6 +212,14 @@ const gitBtn = document.getElementById('git-btn'); const errorsBtn = document.getElementById('errors-btn'); const autoAttachBtn = document.getElementById('auto-attach-btn'); + const voiceBtn = document.getElementById('voice-btn'); + const fileWatchToast = document.getElementById('file-watch-toast'); + const fileWatchMsg = document.getElementById('file-watch-toast-msg'); + const fileWatchRereadBtn = document.getElementById('file-watch-reread-btn'); + const fileWatchDismiss = document.getElementById('file-watch-dismiss-btn'); + const claudemdBanner = document.getElementById('claudemd-banner'); + const claudemdYesBtn = document.getElementById('claudemd-yes-btn'); + const claudemdNoBtn = document.getElementById('claudemd-no-btn'); const contextWarningEl = document.getElementById('context-warning'); const contextWarningTextEl = document.getElementById('context-warning-text'); const contextWarningNewBtn = document.getElementById('context-warning-new'); @@ -197,6 +234,9 @@ const settingPersona = document.getElementById('setting-persona'); const settingMaxTurns = document.getElementById('setting-max-turns'); const settingShowToolOutput = document.getElementById('setting-show-tool-output'); + const settingDefaultShell = document.getElementById('setting-default-shell'); + const settingShellAvailability = document.getElementById('setting-shell-availability'); + const settingsDetectShellsBtn = document.getElementById('settings-detect-shells-btn'); const settingMultiAgentEnabled = document.getElementById('setting-multi-agent-enabled'); const settingMultiAgentStrategy = document.getElementById('setting-multi-agent-strategy'); const settingMultiAgentStrategyRow = document.getElementById('setting-multi-agent-strategy-row'); @@ -1247,7 +1287,7 @@ return id; } - function updateToolCard(toolName, result, input) { + function updateToolCard(toolName, result, input, isError) { const id = activeToolCards[toolName]; if (!id) return; const card = document.getElementById(id); @@ -1256,14 +1296,20 @@ if (statusEl) { statusEl.textContent = ''; const doneSpan = document.createElement('span'); - doneSpan.style.color = 'var(--success)'; - doneSpan.textContent = 'βœ“ done'; + if (isError) { + doneSpan.style.color = 'var(--error-text)'; + doneSpan.textContent = 'βœ— failed'; + card.classList.add('tool-error'); + } else { + doneSpan.style.color = 'var(--success)'; + doneSpan.textContent = 'βœ“ done'; + } statusEl.appendChild(doneSpan); } const resultEl = document.getElementById(`${id}-result`); const storedInput = toolCardInputs[id] || input || {}; if (resultEl) { - if (toolName === 'Edit' && + if (!isError && toolName === 'Edit' && storedInput.old_string !== undefined && storedInput.new_string !== undefined) { // Show inline diff: red for removed lines, green for added lines @@ -1274,7 +1320,7 @@ resultEl.innerHTML = ''; resultEl.className = 'tool-result tool-result-diff'; resultEl.appendChild(buildDiffView(diff)); - } else if (toolName === 'Bash') { + } else if (!isError && toolName === 'Bash') { // Bash: live output is already rendered via appendToolStream; // on completion just remove the waiting placeholder if still there const em = resultEl.querySelector('em'); @@ -1287,12 +1333,33 @@ resultEl.appendChild(pre); } } else { - // Default: plain text preview + // Default: plain text preview (also used for errors) const preview = result && result.length > 800 ? result.slice(0, 800) + '\n…' : (result || ''); resultEl.textContent = preview; // safe β€” textContent only } + // For tool errors, add an "Inject error context" button so the user + // can ask the agent to fix the problem automatically + if (isError && result) { + const injectBtn = document.createElement('button'); + injectBtn.className = 'tool-card-inject-btn'; + injectBtn.textContent = '↩ Retry with error context'; + injectBtn.title = 'Inject this error into the next message so the agent can fix it automatically'; + injectBtn.addEventListener('click', () => { + if (inputEl && !isLoading) { + const errorCtx = `[Tool Error in ${toolName}]\n${result}`; + const existing = inputEl.value.trim(); + inputEl.value = existing + ? `${existing}\n\n${errorCtx}` + : `Fix this error that just occurred:\n\n${errorCtx}`; + inputEl.style.height = 'auto'; + inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; + inputEl.focus(); + } + }); + resultEl.appendChild(injectBtn); + } } // Remove stdin bar (command has finished) const stdinBar = document.getElementById(`${id}-stdin`); @@ -1546,6 +1613,65 @@ vscode.postMessage({ type: 'getOpenEditors' }); } + // ── @folder β€” inject workspace directory tree as context chip ──────────── + function requestFolderContext() { + vscode.postMessage({ type: 'getFolderTree' }); + } + function addFolderContext(tree) { + if (contextFiles.find(f => f.isFolder)) { + contextFiles = contextFiles.filter(f => !f.isFolder); + } + contextFiles.push({ + name: 'πŸ“ folder tree', + path: '__folder__', + isFolder: true, + content: tree, + isGit: false, + isErrors: false, + }); + renderContextFiles(); + } + + // ── @url β€” fetch URL and inject content as context chip ─────────────────── + function requestUrlContext(url) { + vscode.postMessage({ type: 'fetchUrl', url }); + } + function addUrlContext(url, content) { + const shortName = url.replace(/^https?:\/\//, '').slice(0, 40) + (url.length > 43 ? '…' : ''); + if (contextFiles.find(f => f.urlSource === url)) { + contextFiles = contextFiles.filter(f => f.urlSource !== url); + } + contextFiles.push({ + name: `🌐 ${shortName}`, + path: `__url__:${url}`, + urlSource: url, + isUrl: true, + isGit: false, + isErrors: false, + content, + }); + renderContextFiles(); + } + + // ── @symbols β€” grep workspace for function/class names ─────────────────── + function requestSymbolsContext() { + vscode.postMessage({ type: 'getSymbols' }); + } + function addSymbolsContext(symbols) { + if (contextFiles.find(f => f.isSymbols)) { + contextFiles = contextFiles.filter(f => !f.isSymbols); + } + contextFiles.push({ + name: 'πŸ” symbols', + path: '__symbols__', + isSymbols: true, + isGit: false, + isErrors: false, + content: symbols, + }); + renderContextFiles(); + } + // ── Image paste chip ────────────────────────────────────────────────────── function addImageContext(name, dataUrl) { if (!contextFilesEl) return; @@ -1728,8 +1854,11 @@ appendToolStream(msg.tool, msg.chunk || ''); break; - case 'result': - updateToolCard(msg.tool, typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result, null, 2), msg.input); + case 'result': { + const resultStr = typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result, null, 2); + // isError: true when the tool call returned an error (not a normal result) + const isToolError = !!msg.isError; + updateToolCard(msg.tool, resultStr, msg.input, isToolError); if (pendingDiffEdit && pendingDiffEdit.tool === msg.tool) { if (pendingDiffEdit.beforeContent !== null) { // Before content is already available β€” trigger after read @@ -1745,6 +1874,7 @@ syncPlanFromTodos(msg.input.todos); } break; + } case 'compaction': addSystemMessage(`⟳ Context compacted (pass ${msg.count})`); @@ -1789,6 +1919,16 @@ vscode.postMessage({ type: 'updateSession', id: currentSessionId, messages: [...sessionMessages] }); } } + // Offer to update CLAUDE.md after sessions where the agent touched files + // (non-trivial sessions: β‰₯3 messages where a Write/Edit tool was used) + if (sessionMessages.length >= 3 && claudemdBanner && claudemdBanner.style.display === 'none') { + const assistantMsgs = sessionMessages.filter(m => m.type === 'assistant'); + const editedFiles = openTabs.some(t => t.isDiff); + if (editedFiles && assistantMsgs.length >= 1) { + // Delay slightly so it appears after the final assistant message renders + setTimeout(() => showClaudemdBanner([...sessionMessages]), 800); + } + } break; case 'tokenUpdate': @@ -1948,6 +2088,18 @@ renderAutocomplete(msg.files || []); break; + case 'folderTree': + addFolderContext(msg.content || ''); + break; + + case 'urlContent': + addUrlContext(msg.url || '', msg.content || ''); + break; + + case 'symbolsContext': + addSymbolsContext(msg.content || ''); + break; + case 'autoAttachState': autoAttachActive = !!msg.enabled; if (autoAttachBtn) autoAttachBtn.classList.toggle('active', autoAttachActive); @@ -1957,7 +2109,7 @@ // Update the terminal shell badge to show the active shell type const badge = document.getElementById('terminal-shell-badge'); if (badge) { - const labels = { ubuntu: '🐧 Ubuntu', wsl: 'βš™ WSL', powershell: 'πŸ’™ PS', bash: '$ bash' }; + const labels = { ubuntu: '🐧 Ubuntu', wsl: 'βš™ WSL', powershell: 'πŸ’™ PS', bash: '$ bash', cmd: '⊞ cmd' }; badge.textContent = labels[msg.shell] || msg.shell; badge.dataset.shell = msg.shell || ''; badge.title = `Active shell: ${msg.shell}`; @@ -1965,6 +2117,13 @@ break; } + case 'shellsDetected': { + if (settingShellAvailability) { + settingShellAvailability.textContent = formatShellAvailability(msg.shells || {}); + } + break; + } + case 'workspaceChanged': currentWorkspacePath = msg.path || ''; if (settingWorkspace) settingWorkspace.value = currentWorkspacePath; @@ -2035,6 +2194,14 @@ break; case 'fileWatchEvent': + // Show "modified externally" toast for files the agent knows about + if (msg.path) { + const isAgentFile = openTabs.some(t => t.path === msg.path) || + contextFiles.some(f => f.path === msg.path); + if (isAgentFile) { + showFileWatchToast(msg.path); + } + } // Debounced tree refresh on workspace changes if (fileWatchDebounce) clearTimeout(fileWatchDebounce); fileWatchDebounce = setTimeout(() => { @@ -2246,14 +2413,18 @@ for (const f of contextFiles) { const chip = document.createElement('div'); chip.className = 'context-chip' - + (f.pinned ? ' pinned' : '') - + (f.isGit ? ' chip-git' : '') - + (f.isErrors ? ' chip-errors' : ''); + + (f.pinned ? ' pinned' : '') + + (f.isGit ? ' chip-git' : '') + + (f.isErrors ? ' chip-errors' : '') + + (f.isFolder ? ' chip-folder' : '') + + (f.isUrl ? ' chip-url' : '') + + (f.isSymbols ? ' chip-symbols' : ''); if (f.title) chip.title = f.title; const nameSpan = document.createElement('span'); // Special chips carry their icon in the name; files get an icon prefix - const icon = f.isImage ? 'πŸ–Ό ' : (f.isCodebase || f.isGit || f.isErrors) ? '' : (f.pinned ? 'πŸ“Œ ' : 'πŸ“„ '); + const isSpecial = f.isImage || f.isCodebase || f.isGit || f.isErrors || f.isFolder || f.isUrl || f.isSymbols; + const icon = f.isImage ? 'πŸ–Ό ' : isSpecial ? '' : (f.pinned ? 'πŸ“Œ ' : 'πŸ“„ '); nameSpan.textContent = icon + f.name; if (f.isImage && f.dataUrl) { @@ -2266,7 +2437,7 @@ chip.appendChild(img); } - if (!f.isCodebase && !f.isImage && !f.isGit && !f.isErrors) { + if (!f.isCodebase && !f.isImage && !f.isGit && !f.isErrors && !f.isFolder && !f.isUrl && !f.isSymbols) { const pinBtn = document.createElement('button'); pinBtn.className = 'pin-btn'; pinBtn.title = f.pinned ? 'Unpin file' : 'Pin to all sessions'; @@ -2350,6 +2521,37 @@ return; } + // @folder β€” inject workspace directory tree as context chip + if (/@folder\b/.test(val)) { + inputEl.value = val.replace(/@folder\b/g, '').trim(); + inputEl.style.height = 'auto'; + inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; + requestFolderContext(); + hideAutocomplete(); + return; + } + + // @url:
or @url
β€” fetch URL and inject as context + const urlMention = val.match(/@url[:\s]+(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/); + if (urlMention) { + inputEl.value = val.replace(/@url[:\s]+https?:\/\/[^\s<>"{}|\\^`\[\]]+/g, '').trim(); + inputEl.style.height = 'auto'; + inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; + requestUrlContext(urlMention[1]); + hideAutocomplete(); + return; + } + + // @symbols β€” grep workspace for function/class symbols + if (/@symbols\b/.test(val)) { + inputEl.value = val.replace(/@symbols\b/g, '').trim(); + inputEl.style.height = 'auto'; + inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; + requestSymbolsContext(); + hideAutocomplete(); + return; + } + // Slash command autocomplete (when first char is /) const slashMatch = val.match(/^(\/[\w]*)$/); if (slashMatch) { @@ -2384,15 +2586,41 @@ } if (e.key === 'Escape') { hideAutocomplete(); return; } } - // Up arrow in empty input β†’ recall last message - if (e.key === 'ArrowUp' && !inputEl.value && !autocompleteEl.classList.contains('visible')) { - if (lastUserMessage) { - e.preventDefault(); - inputEl.value = lastUserMessage; - inputEl.style.height = 'auto'; - inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; - inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); + // Ctrl+Up / Ctrl+Down β€” cycle through prompt history + if ((e.ctrlKey || e.metaKey) && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && !autocompleteEl.classList.contains('visible')) { + e.preventDefault(); + if (promptHistory.length === 0) return; + if (e.key === 'ArrowUp') { + if (promptHistoryIdx === -1) promptHistoryDraft = inputEl.value; + promptHistoryIdx = Math.min(promptHistoryIdx + 1, promptHistory.length - 1); + } else { + promptHistoryIdx = promptHistoryIdx - 1; + } + const text = promptHistoryIdx < 0 ? promptHistoryDraft : (promptHistory[promptHistoryIdx] || ''); + inputEl.value = text; + inputEl.style.height = 'auto'; + inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); + return; + } + // Any other key resets history cycling so the draft is correct next time + if (!e.ctrlKey && !e.metaKey && e.key !== 'Shift' && e.key !== 'Alt' && + e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') { + if (promptHistoryIdx !== -1) { + promptHistoryDraft = ''; + promptHistoryIdx = -1; } + } + // Up arrow in empty input β†’ quick recall last message (keep legacy behavior) + if (e.key === 'ArrowUp' && !inputEl.value && !autocompleteEl.classList.contains('visible') && + !e.ctrlKey && !e.metaKey && promptHistory.length > 0) { + e.preventDefault(); + promptHistoryDraft = ''; + promptHistoryIdx = 0; + inputEl.value = promptHistory[0] || ''; + inputEl.style.height = 'auto'; + inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); return; } // Escape cancels loading @@ -2526,6 +2754,10 @@ const rawText = inputEl.value.trim(); if (!rawText || isLoading) return; + // Save to prompt history + pushPromptHistory(rawText); + promptHistoryIdx = -1; + // ── /team slash command: transform into a structured multi-agent prompt ── // Syntax: first line = "/team [build|review|fullstack|debug]" // remaining lines = the actual task description @@ -2594,7 +2826,8 @@ setLoading(true, `${cfg.label}: Thinking…`); const sendContextFiles = contextFiles - .filter(f => !f.isImage && !f.isCodebase && !f.isGit && !f.isErrors) + .filter(f => !f.isImage && !f.isCodebase && !f.isGit && !f.isErrors && + !f.isFolder && !f.isUrl && !f.isSymbols) .map(f => f.path); vscode.postMessage({ @@ -2621,11 +2854,14 @@ setSending(true); setLoading(true, 'Thinking…'); - // Append inline content from special context chips (@git, @errors) + // Append inline content from special context chips (@git, @errors, @folder, @url, @symbols) const specialParts = []; for (const f of contextFiles) { - if (f.isGit && f.content) specialParts.push('\n\n[Git Context]\n' + f.content); - if (f.isErrors && f.content) specialParts.push('\n\n[Workspace Problems]\n' + f.content); + if (f.isGit && f.content) specialParts.push('\n\n[Git Context]\n' + f.content); + if (f.isErrors && f.content) specialParts.push('\n\n[Workspace Problems]\n' + f.content); + if (f.isFolder && f.content) specialParts.push('\n\n[Project Directory Tree]\n' + f.content); + if (f.isUrl && f.content) specialParts.push(`\n\n[Web Page: ${f.urlSource || ''}]\n` + f.content); + if (f.isSymbols && f.content) specialParts.push('\n\n[Workspace Symbols]\n' + f.content); } // Inject active plan context so the agent always remembers the to-do list const planCtx = buildPlanContext(); @@ -2633,7 +2869,8 @@ // Separate file paths from image/codebase/special context entries const sendContextFiles = contextFiles - .filter(f => !f.isImage && !f.isCodebase && !f.isGit && !f.isErrors) + .filter(f => !f.isImage && !f.isCodebase && !f.isGit && !f.isErrors && + !f.isFolder && !f.isUrl && !f.isSymbols) .map(f => f.path); const hasCodebase = contextFiles.some(f => f.isCodebase); @@ -2655,6 +2892,36 @@ let acSelectedIdx = -1; function showFileAutocomplete(query) { + // Show @ special mention suggestions first if query matches a keyword prefix + const AT_SPECIALS = [ + { key: 'codebase', desc: 'Inject full codebase into context', icon: 'πŸ“¦' }, + { key: 'folder', desc: 'Inject workspace directory tree', icon: 'πŸ“' }, + { key: 'git', desc: 'Inject git status & diff summary', icon: 'βŽ‡' }, + { key: 'errors', desc: 'Inject workspace errors/warnings', icon: '⚠' }, + { key: 'symbols', desc: 'Inject function/class symbols list',icon: 'πŸ”' }, + { key: 'url', desc: 'Fetch a URL and inject its content (@url:https://…)', icon: '🌐' }, + ]; + const lq = query.toLowerCase(); + const matched = AT_SPECIALS.filter(s => s.key.startsWith(lq)); + if (matched.length > 0 && !query.includes('/') && !query.includes('\\') && !query.includes('.')) { + acItems = matched.map(s => ({ isAtSpecial: true, ...s })); + acSelectedIdx = 0; + autocompleteEl.innerHTML = ''; + for (let i = 0; i < matched.length; i++) { + const s = matched[i]; + const item = document.createElement('div'); + item.className = 'ac-item' + (i === 0 ? ' selected' : ''); + item.innerHTML = ` + ${s.icon} + @${escapeHtml(s.key)} + ${escapeHtml(s.desc)} + `; + item.addEventListener('click', () => { acSelectedIdx = i; selectAcItem(); }); + autocompleteEl.appendChild(item); + } + autocompleteEl.classList.add('visible'); + return; + } vscode.postMessage({ type: 'fileSearch', query }); } @@ -2725,7 +2992,7 @@ case '/help': addSystemMessage( 'Keyboard shortcuts: Enter=send Β· Shift+Enter=newline Β· @=add file Β· ' + - '@codebase=full codebase Β· /=commands Β· ↑=recall last message Β· ' + + '@codebase=full codebase Β· /=commands Β· ↑=recall last Β· Ctrl+↑/↓=history Β· ' + 'Ctrl+L=focus input Β· Ctrl+F=search Β· Ctrl+K=inline edit Β· Ctrl+`=terminal Β· Esc=stop\n\n' + 'Template commands: /explain Β· /fix Β· /refactor Β· /test Β· /review Β· /docs Β· /commit Β· /optimize\n\n' + 'Multi-agent: /team build Β· /team review Β· /team fullstack Β· /team debug\n' + @@ -2785,6 +3052,21 @@ return; } + // @ special mention selection β€” inject the mention text which the input handler will process + if (item.isAtSpecial) { + const val = inputEl.value; + const cursorPos = inputEl.selectionStart; + const before = val.slice(0, cursorPos); + // Replace partial @query with the full @keyword + const replaced = before.replace(/@[\w./\\-]*$/, '@' + item.key + ' '); + inputEl.value = replaced + val.slice(cursorPos); + inputEl.setSelectionRange(replaced.length, replaced.length); + hideAutocomplete(); + // Trigger the input event so the @ handler fires + inputEl.dispatchEvent(new Event('input')); + return; + } + // File selection β€” replace @query in input const file = item; const val = inputEl.value; @@ -3165,6 +3447,127 @@ }); } + // ── Voice Input (Web Speech API) ────────────────────────────────────────── + let voiceRecognition = null; + let voiceActive = false; + + if (voiceBtn) { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (SpeechRecognition) { + voiceRecognition = new SpeechRecognition(); + voiceRecognition.continuous = false; + voiceRecognition.interimResults = true; + voiceRecognition.lang = navigator.language || 'en-US'; + + let voiceInterim = ''; + let voiceBase = ''; // text that was in the input before recording started + + voiceRecognition.onstart = () => { + voiceActive = true; + voiceBase = inputEl ? inputEl.value : ''; + voiceBtn.classList.add('voice-recording'); + voiceBtn.title = 'Recording… click to stop'; + }; + voiceRecognition.onresult = (e) => { + let interim = ''; + let final = ''; + for (let i = e.resultIndex; i < e.results.length; i++) { + if (e.results[i].isFinal) final += e.results[i][0].transcript; + else interim += e.results[i][0].transcript; + } + voiceInterim = interim; + if (inputEl) { + inputEl.value = voiceBase + final + interim; + inputEl.style.height = 'auto'; + inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; + } + if (final) voiceBase = voiceBase + final; + }; + voiceRecognition.onerror = (e) => { + if (e.error !== 'aborted') addSystemMessage(`🎀 Voice error: ${e.error}`); + }; + voiceRecognition.onend = () => { + voiceActive = false; + voiceBtn.classList.remove('voice-recording'); + voiceBtn.title = 'Voice input β€” click to record'; + voiceInterim = ''; + }; + + voiceBtn.addEventListener('click', () => { + if (voiceActive) { + voiceRecognition.stop(); + } else { + try { voiceRecognition.start(); } catch { /* already running */ } + } + }); + } else { + // Hide the button if Speech API is not available + voiceBtn.style.display = 'none'; + } + } + + // ── File-Change Watcher Toast ───────────────────────────────────────────── + let watchedChangedFiles = []; // { path } queue of external changes + let watchToastVisible = false; + let watchToastDismissTimer = null; + + function showFileWatchToast(filePath) { + watchedChangedFiles.push(filePath); + if (!fileWatchToast || !fileWatchMsg) return; + const name = filePath.split(/[\\/]/).pop(); + fileWatchMsg.innerHTML = `πŸ“ ${escapeHtml(name)} was modified externally`; + fileWatchToast.style.display = 'flex'; + watchToastVisible = true; + clearTimeout(watchToastDismissTimer); + watchToastDismissTimer = setTimeout(dismissFileWatchToast, 8000); + } + + function dismissFileWatchToast() { + if (fileWatchToast) fileWatchToast.style.display = 'none'; + watchToastVisible = false; + watchedChangedFiles = []; + clearTimeout(watchToastDismissTimer); + } + + if (fileWatchRereadBtn) { + fileWatchRereadBtn.addEventListener('click', () => { + for (const fp of watchedChangedFiles) { + vscode.postMessage({ type: 'readFile', path: fp, purpose: 'context' }); + addContextFile(fp.split(/[\\/]/).pop(), fp); + } + dismissFileWatchToast(); + }); + } + if (fileWatchDismiss) fileWatchDismiss.addEventListener('click', dismissFileWatchToast); + + // ── CLAUDE.md Session Summary Offer ────────────────────────────────────── + let claudemdPendingMessages = []; + + function showClaudemdBanner(msgs) { + claudemdPendingMessages = msgs; + if (!claudemdBanner) return; + claudemdBanner.style.display = 'flex'; + } + function dismissClaudemdBanner() { + if (claudemdBanner) claudemdBanner.style.display = 'none'; + claudemdPendingMessages = []; + } + + if (claudemdYesBtn) { + claudemdYesBtn.addEventListener('click', () => { + dismissClaudemdBanner(); + // Ask the agent to write a CLAUDE.md summary + const summaryPrompt = + 'Please update (or create) the `CLAUDE.md` file in the current workspace root with a concise summary of what we accomplished in this session: decisions made, architectural patterns established, files changed, and any conventions to remember for future sessions. Keep it brief and developer-focused.'; + if (!isLoading) { + setSending(true); + setLoading(true, 'Updating CLAUDE.md…'); + vscode.postMessage({ type: 'send', message: summaryPrompt, contextFiles: [], fileRefs: [] }); + } + }); + } + if (claudemdNoBtn) claudemdNoBtn.addEventListener('click', dismissClaudemdBanner); + // ── Settings Panel ──────────────────────────────────────────────────────── // ── Custom Providers state ──────────────────────────────────────────────── @@ -3403,6 +3806,7 @@ if (settingMode) settingMode.value = msg.mode || 'default'; if (settingMaxTurns) settingMaxTurns.value = msg.maxTurns || 20; if (settingShowToolOutput) settingShowToolOutput.checked = msg.showToolOutput !== false; + if (settingDefaultShell) settingDefaultShell.value = msg.defaultShell || 'auto'; if (settingPersona) settingPersona.value = msg.systemPromptPreset || 'expert-engineer'; if (settingNvidiaKey) settingNvidiaKey.placeholder = msg.hasNvidiaKey ? 'β€’β€’β€’β€’β€’β€’β€’ (set β€” enter to change)' : 'nvapi-… (leave blank to clear)'; multiAgentEnabled = !!msg.multiAgentEnabled; @@ -3415,6 +3819,178 @@ renderCustomProviders(); injectCustomProviderModels(); } + // MCP servers + if (msg.mcpServers) { + mcpEnabledServers = msg.mcpServers || {}; + renderMcpMarketplace(); + } + } + + // ── MCP Server Marketplace ──────────────────────────────────────────────── + + /** + * Popular MCP servers registry. + * Each entry: { id, name, description, icon, command, args, installHint } + * - command/args: how the server is launched (passed to mcpServers config) + * - installHint: how to install if not present + */ + const MCP_MARKETPLACE = [ + { + id: 'filesystem', + name: 'Filesystem', + description: 'Read & write files outside the workspace; directory listings.', + icon: 'πŸ“', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '{workspace}'], + installHint: 'npx -y @modelcontextprotocol/server-filesystem', + }, + { + id: 'github', + name: 'GitHub', + description: 'Search repos, read files, manage issues and pull requests via the GitHub API.', + icon: 'πŸ™', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + installHint: 'Requires GITHUB_PERSONAL_ACCESS_TOKEN env var.', + }, + { + id: 'brave-search', + name: 'Brave Search', + description: 'Web and local search powered by the Brave Search API.', + icon: '🦁', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + installHint: 'Requires BRAVE_API_KEY env var.', + }, + { + id: 'memory', + name: 'Memory', + description: 'Persistent key-value memory so the agent remembers facts across sessions.', + icon: '🧠', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-memory'], + installHint: 'npx -y @modelcontextprotocol/server-memory', + }, + { + id: 'postgres', + name: 'PostgreSQL', + description: 'Connect to a Postgres database β€” run queries, inspect schema.', + icon: '🐘', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-postgres', 'postgresql://localhost/mydb'], + installHint: 'Edit connection string after enabling.', + }, + { + id: 'sqlite', + name: 'SQLite', + description: 'Query and explore a local SQLite database file.', + icon: 'πŸ—„', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sqlite', '--db-path', '{workspace}/data.db'], + installHint: 'Edit --db-path after enabling.', + }, + { + id: 'puppeteer', + name: 'Puppeteer', + description: 'Control a headless Chrome browser β€” screenshot, click, scrape pages.', + icon: 'πŸ€–', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-puppeteer'], + installHint: 'npx -y @modelcontextprotocol/server-puppeteer', + }, + { + id: 'slack', + name: 'Slack', + description: 'Read channels, post messages, and search Slack workspaces.', + icon: 'πŸ’¬', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-slack'], + installHint: 'Requires SLACK_BOT_TOKEN and SLACK_TEAM_ID env vars.', + }, + ]; + + /** Currently enabled servers: { serverId: {command, args} } β€” mirrors mcpServers in settings.json */ + let mcpEnabledServers = {}; + + const mcpListEl = document.getElementById('mcp-list'); + + function renderMcpMarketplace() { + if (!mcpListEl) return; + mcpListEl.innerHTML = ''; + for (const srv of MCP_MARKETPLACE) { + const enabled = !!mcpEnabledServers[srv.id]; + const row = document.createElement('div'); + row.className = 'mcp-entry' + (enabled ? ' mcp-entry-enabled' : ''); + + const left = document.createElement('div'); + left.className = 'mcp-entry-left'; + + const iconEl = document.createElement('span'); + iconEl.className = 'mcp-entry-icon'; + iconEl.textContent = srv.icon; + + const info = document.createElement('div'); + info.className = 'mcp-entry-info'; + const nameEl = document.createElement('div'); + nameEl.className = 'mcp-entry-name'; + nameEl.textContent = srv.name; + const descEl = document.createElement('div'); + descEl.className = 'mcp-entry-desc'; + descEl.textContent = srv.description; + if (srv.installHint) { + const hint = document.createElement('div'); + hint.className = 'mcp-entry-hint'; + hint.textContent = srv.installHint; + info.appendChild(nameEl); + info.appendChild(descEl); + info.appendChild(hint); + } else { + info.appendChild(nameEl); + info.appendChild(descEl); + } + + left.appendChild(iconEl); + left.appendChild(info); + + const toggleLabel = document.createElement('label'); + toggleLabel.className = 'toggle-switch'; + const toggleInput = document.createElement('input'); + toggleInput.type = 'checkbox'; + toggleInput.checked = enabled; + toggleInput.addEventListener('change', () => { + if (toggleInput.checked) { + // Substitute {workspace} placeholder with actual workspace path + const ws = currentWorkspacePath || ''; + if (!ws && srv.args.some(a => a.includes('{workspace}'))) { + // Warn but still enable β€” user can set workspace path later + addSystemMessage(`⚠ MCP server "${srv.name}" uses {workspace} but no workspace folder is set. Set a workspace path in Settings > Workspace first.`); + } + const args = srv.args.map(a => a.replace('{workspace}', ws)); + mcpEnabledServers[srv.id] = { command: srv.command, args }; + } else { + delete mcpEnabledServers[srv.id]; + } + row.classList.toggle('mcp-entry-enabled', toggleInput.checked); + saveMcpServers(); + }); + const toggleSlider = document.createElement('span'); + toggleSlider.className = 'toggle-slider'; + toggleLabel.appendChild(toggleInput); + toggleLabel.appendChild(toggleSlider); + + row.appendChild(left); + row.appendChild(toggleLabel); + mcpListEl.appendChild(row); + } + } + + function saveMcpServers() { + vscode.postMessage({ type: 'saveMcpServers', mcpServers: mcpEnabledServers }); + } + + function formatShellAvailability(shells) { + const status = (ok) => (ok ? 'available' : 'unavailable'); + return `PowerShell: ${status(!!shells.powershell)} Β· WSL: ${status(!!shells.wsl)} Β· Ubuntu: ${status(!!shells.ubuntu)} Β· Bash: ${status(!!shells.bash)} Β· cmd: ${status(!!shells.cmd)}`; updateMultiAgentUiState(); } @@ -3489,6 +4065,19 @@ }); } + if (settingDefaultShell) { + settingDefaultShell.addEventListener('change', () => { + vscode.postMessage({ type: 'saveSettings', key: 'defaultShell', value: settingDefaultShell.value || 'auto' }); + }); + } + + if (settingsDetectShellsBtn) { + settingsDetectShellsBtn.addEventListener('click', () => { + if (settingShellAvailability) settingShellAvailability.textContent = 'Detecting…'; + vscode.postMessage({ type: 'detectShells' }); + }); + } + if (settingsPickWorkspace) { settingsPickWorkspace.addEventListener('click', () => { // Use existing pickFile flow indirectly; here we fire workspace open via main menu equivalent @@ -4150,6 +4739,11 @@ } renderTabBar(); activateTab(filePath); + + // Auto-show the editor panel so diffs are immediately visible + if (panelEditorEl && panelEditorEl.classList.contains('panel-collapsed')) { + panelEditorEl.classList.remove('panel-collapsed'); + } } /** Make a tab active and render its content. */ diff --git a/electron-app/renderer/index.html b/electron-app/renderer/index.html index 1bc11ef..14557c6 100644 --- a/electron-app/renderer/index.html +++ b/electron-app/renderer/index.html @@ -176,8 +176,8 @@