From 89dcf1f81ed01f274b54a367092fc49702c479b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:11:24 +0000 Subject: [PATCH 1/9] Initial plan From 1c6a368e83d8e6da1d9fed4eea35af45c2b2d686 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:24:18 +0000 Subject: [PATCH 2/9] feat: add configurable shell selection with powershell-first defaults Agent-Logs-Url: https://github.com/codomium/FreeCode/sessions/8072f19d-aa04-4bb8-ac06-e402de917903 Co-authored-by: codomium <255525663+codomium@users.noreply.github.com> --- electron-app/main.js | 236 +++++++++++++++++++++++-------- electron-app/renderer/chat.css | 10 ++ electron-app/renderer/chat.js | 32 ++++- electron-app/renderer/index.html | 23 +++ vscode-extension/extension.js | 144 +++++++++++++------ vscode-extension/package.json | 21 +++ 6 files changed, 368 insertions(+), 98 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index d4ef330..946c008 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -24,7 +24,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'); // ── Constants ───────────────────────────────────────────────────────────────── @@ -99,6 +99,7 @@ const DEFAULT_SETTINGS = { autoAttachActiveFile: false, pinnedFiles: [], workspacePath: os.homedir(), + defaultShell: 'auto', // 'auto' | 'powershell' | 'wsl' | 'ubuntu' | 'bash' | 'cmd' customProviders: [], // [{ id, name, baseUrl, apiKey, models:[{id,name}], headers:[{name,value}] }] }; @@ -668,6 +669,7 @@ 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, @@ -1084,7 +1086,7 @@ ipcMain.on('renderer-message', async (event, msg) => { // ── Save a single setting ───────────────────────────────────────────── case 'saveSettings': { - const allowed = ['model','permissionMode','maxTurns','showToolOutput','nvidiaApiKey','workspacePath']; + const allowed = ['model','permissionMode','maxTurns','showToolOutput','nvidiaApiKey','workspacePath','defaultShell']; if (msg.key && allowed.includes(msg.key)) { saveSetting(msg.key, msg.value); applyEnvFromSettings(); @@ -1101,7 +1103,68 @@ 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; } @@ -1262,44 +1325,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 -EA 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; } @@ -1319,6 +1452,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, @@ -1342,30 +1479,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'); @@ -1411,7 +1533,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 || []); @@ -1434,7 +1558,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/renderer/chat.css b/electron-app/renderer/chat.css index df75246..38fb701 100644 --- a/electron-app/renderer/chat.css +++ b/electron-app/renderer/chat.css @@ -2032,6 +2032,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 +3210,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; diff --git a/electron-app/renderer/chat.js b/electron-app/renderer/chat.js index 6b5de08..5a9a6ee 100644 --- a/electron-app/renderer/chat.js +++ b/electron-app/renderer/chat.js @@ -196,6 +196,9 @@ const settingMode = document.getElementById('setting-mode'); 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 settingsSetKeyBtn = document.getElementById('settings-set-key-btn'); const settingNvidiaKey = document.getElementById('setting-nvidia-key'); const settingsSaveNvidiaBtn = document.getElementById('settings-save-nvidia-btn'); @@ -1953,7 +1956,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}`; @@ -1961,6 +1964,13 @@ break; } + case 'shellsDetected': { + if (settingShellAvailability) { + settingShellAvailability.textContent = formatShellAvailability(msg.shells || {}); + } + break; + } + case 'workspaceChanged': currentWorkspacePath = msg.path || ''; if (settingWorkspace) settingWorkspace.value = currentWorkspacePath; @@ -3347,6 +3357,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 (settingNvidiaKey) settingNvidiaKey.placeholder = msg.hasNvidiaKey ? '••••••• (set — enter to change)' : 'nvapi-… (leave blank to clear)'; // Custom providers if (Array.isArray(msg.customProviders)) { @@ -3356,6 +3367,11 @@ } } + function formatShellAvailability(shells) { + const icon = (ok) => (ok ? '✅' : '❌'); + return `PowerShell ${icon(!!shells.powershell)} WSL ${icon(!!shells.wsl)} Ubuntu ${icon(!!shells.ubuntu)} Bash ${icon(!!shells.bash)} cmd ${icon(!!shells.cmd)}`; + } + function openSettingsPanel() { if (!settingsPanel) return; settingsPanel.classList.add('visible'); @@ -3402,6 +3418,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 @@ -4641,4 +4670,3 @@ vscode.postMessage({ type: 'ready' }); }()); - diff --git a/electron-app/renderer/index.html b/electron-app/renderer/index.html index 63cba41..4c9c92e 100644 --- a/electron-app/renderer/index.html +++ b/electron-app/renderer/index.html @@ -563,6 +563,29 @@

FreeCode

+
+
Terminal & Shell
+
+ + +
+
Used by the AI agent for all terminal commands.
+
+ Available shells +
+ Not detected yet. + +
+
+
+
API Keys
diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js index 7df0284..16eada4 100644 --- a/vscode-extension/extension.js +++ b/vscode-extension/extension.js @@ -762,7 +762,9 @@ class ClaudeCodeViewProvider { async _runPrompt(message, contextFilePaths, fileRefs) { this._isCancelled = false; - let fullPrompt = message; + const shellHint = detectPreferredTerminalShell(); + 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 || []); @@ -796,7 +798,7 @@ class ClaudeCodeViewProvider { } } if (fileContents.length > 0) { - fullPrompt = message + '\n\n[Context files:]' + fileContents.join(''); + fullPrompt = basePrompt + '\n\n[Context files:]' + fileContents.join(''); } } @@ -1189,32 +1191,105 @@ function deactivate() { const MAX_TERMINAL_BYTES = 512 * 1024; // 512 KB -/** - * Minimal PowerShell POSIX shims — same as bash.mjs but used here for the - * integrated terminal panel in the extension webview. - */ -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 wc { param([string]$flag) $lines = @($input); if ($flag -eq "-l") { $lines.Count } elseif ($flag -eq "-c") { ($lines -join "`n").Length } else { $lines.Count } }', - '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('; '); +const TERMINAL_POWERSHELL_SHIMS_MODULE = `# freecode-shims.psm1 — FreeCode PowerShell POSIX compatibility shims +function which { param([string]$cmd) $r = Get-Command $cmd -EA 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 * +`; + +let _preferredTerminalShell = null; +let _preferredTerminalShellName = null; + +function isPowerShellAvailable() { + const { spawnSync } = require('child_process'); + 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 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 detectPreferredTerminalShell() { + const config = vscode.workspace.getConfiguration('openClaudeCode'); + const pref = config.get('defaultShell', 'auto'); + + if (_preferredTerminalShell && _preferredTerminalShellName === pref) { + return _preferredTerminalShell; + } + + if (pref !== 'auto') { + _preferredTerminalShellName = pref; + _preferredTerminalShell = resolveShellByName(pref); + return _preferredTerminalShell; + } + + if (process.platform !== 'win32') { + _preferredTerminalShellName = 'bash'; + _preferredTerminalShell = resolveShellByName('bash'); + return _preferredTerminalShell; + } + + _preferredTerminalShellName = 'auto'; + _preferredTerminalShell = isPowerShellAvailable() + ? resolveShellByName('powershell') + : resolveShellByName('cmd'); + return _preferredTerminalShell; +} + +function ensurePowerShellShimsModule() { + const fs = require('fs'); + const baseDir = extensionContext?.globalStorageUri?.fsPath || __dirname; + try { fs.mkdirSync(baseDir, { recursive: true }); } catch { /* ignore */ } + const shimPath = path.join(baseDir, 'freecode-shims.psm1'); + if (!fs.existsSync(shimPath)) { + fs.writeFileSync(shimPath, TERMINAL_POWERSHELL_SHIMS_MODULE, 'utf8'); + } + return shimPath; +} + +function quotePowerShellPath(p) { + return String(p || '').replace(/'/g, "''"); +} /** * Execute `command` in the workspace shell and stream output to the webview. - * On Windows: tries WSL first, then falls back to PowerShell with POSIX shims. - * On Unix: uses bash. + * Uses openClaudeCode.defaultShell when explicitly set. + * In auto mode: PowerShell is preferred on Windows; bash on Unix. * @param {string} command * @param {string|null} reqId * @param {Function} send — webview.postMessage wrapper */ function runTerminalCommand(command, reqId, send) { - const { spawn, spawnSync } = require('child_process'); + const { spawn } = require('child_process'); const os = require('os'); const cwd = (() => { @@ -1225,27 +1300,16 @@ function runTerminalCommand(command, reqId, send) { } })(); - let shellExe, shellArgs; + const shell = detectPreferredTerminalShell(); + let shellExe = shell.exe; + let shellArgs = [...shell.args, command]; - if (process.platform === 'win32') { - // Try WSL first for a proper POSIX bash environment - let wslOk = false; - try { - const r = spawnSync('wsl.exe', ['--status'], { encoding: 'utf-8', timeout: 3000, windowsHide: true }); - wslOk = r.status === 0; - } catch { /* ignore */ } - - if (wslOk) { - shellExe = 'wsl.exe'; - shellArgs = ['bash', '-c', command]; - } else { - // PowerShell with POSIX shims prepended - shellExe = 'powershell.exe'; - shellArgs = ['-NoProfile', '-NonInteractive', '-Command', `${TERMINAL_PS_SHIMS}; ${command}`]; - } - } else { - shellExe = 'bash'; - shellArgs = ['-c', command]; + if (shell.type === 'powershell') { + const modulePath = ensurePowerShellShimsModule(); + shellArgs = [...shell.args, `Import-Module '${quotePowerShellPath(modulePath)}' -Force; ${command}`]; + } else if (shell.type === 'cmd') { + shellExe = 'cmd.exe'; + shellArgs = ['/c', command]; } const proc = spawn(shellExe, shellArgs, { diff --git a/vscode-extension/package.json b/vscode-extension/package.json index c3274dc..ac22b70 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -167,6 +167,27 @@ ], "description": "Permission mode for file and shell operations" }, + "openClaudeCode.defaultShell": { + "type": "string", + "enum": [ + "auto", + "powershell", + "wsl", + "ubuntu", + "bash", + "cmd" + ], + "enumDescriptions": [ + "Auto-detect best available shell (PowerShell preferred on Windows)", + "Windows PowerShell (default on Windows)", + "WSL — Windows Subsystem for Linux (generic)", + "WSL Ubuntu distro specifically", + "Bash (macOS/Linux default)", + "Windows Command Prompt (cmd.exe)" + ], + "default": "auto", + "description": "Default shell used by the AI agent for running commands. PowerShell is the default on Windows." + }, "openClaudeCode.maxTurns": { "type": "number", "default": 20, From c6d3e88065ee87f77ddb1aecd05f2f8d0d2ce0fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:29:46 +0000 Subject: [PATCH 3/9] chore: address validation feedback and finalize shell updates Agent-Logs-Url: https://github.com/codomium/FreeCode/sessions/8072f19d-aa04-4bb8-ac06-e402de917903 Co-authored-by: codomium <255525663+codomium@users.noreply.github.com> --- electron-app/main.js | 2 +- electron-app/renderer/chat.js | 4 ++-- vscode-extension/extension.js | 41 +---------------------------------- 3 files changed, 4 insertions(+), 43 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index 946c008..61dd71d 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -1333,7 +1333,7 @@ ipcMain.on('renderer-message', async (event, msg) => { let _preferredShell = null; const POWERSHELL_SHIMS_MODULE = `# freecode-shims.psm1 — FreeCode PowerShell POSIX compatibility shims -function which { param([string]$cmd) $r = Get-Command $cmd -EA SilentlyContinue; if ($r) { $r.Source } else { Write-Error "which: $cmd: not found"; exit 1 } } +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 } } diff --git a/electron-app/renderer/chat.js b/electron-app/renderer/chat.js index 5a9a6ee..533ab0b 100644 --- a/electron-app/renderer/chat.js +++ b/electron-app/renderer/chat.js @@ -3368,8 +3368,8 @@ } function formatShellAvailability(shells) { - const icon = (ok) => (ok ? '✅' : '❌'); - return `PowerShell ${icon(!!shells.powershell)} WSL ${icon(!!shells.wsl)} Ubuntu ${icon(!!shells.ubuntu)} Bash ${icon(!!shells.bash)} cmd ${icon(!!shells.cmd)}`; + 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)}`; } function openSettingsPanel() { diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js index 16eada4..a9f472d 100644 --- a/vscode-extension/extension.js +++ b/vscode-extension/extension.js @@ -1191,27 +1191,6 @@ function deactivate() { const MAX_TERMINAL_BYTES = 512 * 1024; // 512 KB -const TERMINAL_POWERSHELL_SHIMS_MODULE = `# freecode-shims.psm1 — FreeCode PowerShell POSIX compatibility shims -function which { param([string]$cmd) $r = Get-Command $cmd -EA 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 * -`; - let _preferredTerminalShell = null; let _preferredTerminalShellName = null; @@ -1265,21 +1244,6 @@ function detectPreferredTerminalShell() { return _preferredTerminalShell; } -function ensurePowerShellShimsModule() { - const fs = require('fs'); - const baseDir = extensionContext?.globalStorageUri?.fsPath || __dirname; - try { fs.mkdirSync(baseDir, { recursive: true }); } catch { /* ignore */ } - const shimPath = path.join(baseDir, 'freecode-shims.psm1'); - if (!fs.existsSync(shimPath)) { - fs.writeFileSync(shimPath, TERMINAL_POWERSHELL_SHIMS_MODULE, 'utf8'); - } - return shimPath; -} - -function quotePowerShellPath(p) { - return String(p || '').replace(/'/g, "''"); -} - /** * Execute `command` in the workspace shell and stream output to the webview. * Uses openClaudeCode.defaultShell when explicitly set. @@ -1304,10 +1268,7 @@ function runTerminalCommand(command, reqId, send) { let shellExe = shell.exe; let shellArgs = [...shell.args, command]; - if (shell.type === 'powershell') { - const modulePath = ensurePowerShellShimsModule(); - shellArgs = [...shell.args, `Import-Module '${quotePowerShellPath(modulePath)}' -Force; ${command}`]; - } else if (shell.type === 'cmd') { + if (shell.type === 'cmd') { shellExe = 'cmd.exe'; shellArgs = ['/c', command]; } From bedb4bf38ca6f976f79d827661f11a5885d24cfd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:19:46 +0000 Subject: [PATCH 4/9] feat: implement prompt history, @folder/@url/@symbols, diff auto-show, MCP marketplace Agent-Logs-Url: https://github.com/codomium/FreeCode/sessions/8aa8656d-2df8-4564-80f3-4db5a1ffed9f Co-authored-by: codomium <255525663+codomium@users.noreply.github.com> --- electron-app/main.js | 166 ++++++++++++ electron-app/renderer/chat.css | 86 +++++++ electron-app/renderer/chat.js | 423 +++++++++++++++++++++++++++++-- electron-app/renderer/index.html | 21 +- 4 files changed, 675 insertions(+), 21 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index 61dd71d..144773b 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -101,6 +101,7 @@ const DEFAULT_SETTINGS = { workspacePath: os.homedir(), defaultShell: 'auto', // 'auto' | 'powershell' | 'wsl' | 'ubuntu' | 'bash' | 'cmd' customProviders: [], // [{ id, name, baseUrl, apiKey, models:[{id,name}], headers:[{name,value}] }] + mcpServers: {}, // { serverId: { command, args } } }; function getSettings() { @@ -224,9 +225,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; @@ -675,6 +700,7 @@ ipcMain.on('renderer-message', async (event, msg) => { showToolOutput: s.showToolOutput !== false, hasNvidiaKey: !!(s.nvidiaApiKey || process.env.NVIDIA_API_KEY), customProviders: Array.isArray(s.customProviders) ? s.customProviders : [], + mcpServers: (s.mcpServers && typeof s.mcpServers === 'object') ? s.mcpServers : {}, }); // Report which shell the integrated terminal will use @@ -870,6 +896,123 @@ 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; + } + try { + const content = await new Promise((resolve, reject) => { + const httpMod = targetUrl.startsWith('https://') ? require('https') : require('http'); + const reqOpts = new URL(targetUrl); + 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'); + // Strip HTML tags for readability + text = text.replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, "'") + .replace(/\s{3,}/g, '\n\n') + .trim(); + // Limit to ~20KB + if (text.length > 20000) text = text.slice(0, 20000) + '\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: [] }); @@ -1179,6 +1322,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; + } + // ── Open settings / userData folder in system explorer ──────────────── case 'openSettingsFolder': { shell.openPath(getUserData()); diff --git a/electron-app/renderer/chat.css b/electron-app/renderer/chat.css index 38fb701..5e174b2 100644 --- a/electron-app/renderer/chat.css +++ b/electron-app/renderer/chat.css @@ -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); @@ -3410,3 +3431,68 @@ 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); +} diff --git a/electron-app/renderer/chat.js b/electron-app/renderer/chat.js index 533ab0b..67c32ba 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 @@ -1545,6 +1574,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; @@ -1947,6 +2035,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); @@ -2235,14 +2335,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) { @@ -2255,7 +2359,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'; @@ -2339,6 +2443,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) { @@ -2373,15 +2508,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 @@ -2515,6 +2676,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 @@ -2583,7 +2748,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({ @@ -2610,11 +2776,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(); @@ -2622,7 +2791,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); @@ -2644,6 +2814,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 }); } @@ -2714,7 +2914,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' + @@ -2774,6 +2974,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; @@ -3365,6 +3580,169 @@ 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 || ''; + 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) { @@ -4092,6 +4470,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 4c9c92e..cb686f7 100644 --- a/electron-app/renderer/index.html +++ b/electron-app/renderer/index.html @@ -252,6 +252,10 @@

FreeCode

Enter send message
Shift+Enter newline
@file add file to context
+
@folder inject directory tree
+
@url:https://… fetch page
+
@symbols workspace symbols
+
Ctrl+↑/↓ history cycling
📄 pick file from workspace
@@ -291,7 +295,7 @@

FreeCode

@@ -343,6 +347,7 @@

FreeCode

Ctrl+L focus Esc stop @ add file + Ctrl+↑↓ history
@@ -602,6 +607,20 @@

FreeCode

+ +
+
MCP Servers + Model Context Protocol — extend the agent with extra tools +
+
+ +
+
+ Enabled servers are started automatically when you send a message. Requires the server package to be installed (e.g. via npm install -g). + Changes take effect on the next conversation turn. +
+
+
Custom Providers From 697d8d162137cf67baecda300763ef2aa9d0ca06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:21:53 +0000 Subject: [PATCH 5/9] fix: safer HTML stripping in fetchUrl to address CodeQL findings Agent-Logs-Url: https://github.com/codomium/FreeCode/sessions/8aa8656d-2df8-4564-80f3-4db5a1ffed9f Co-authored-by: codomium <255525663+codomium@users.noreply.github.com> --- electron-app/main.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index 144773b..88eb678 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -939,7 +939,7 @@ ipcMain.on('renderer-message', async (event, msg) => { const httpMod = targetUrl.startsWith('https://') ? require('https') : require('http'); const reqOpts = new URL(targetUrl); 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' } }, + port: reqOpts.port, headers: { 'User-Agent': 'OpenClaudeCode/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)`); @@ -949,14 +949,15 @@ ipcMain.on('renderer-message', async (event, msg) => { res.on('data', (c) => chunks.push(c)); res.on('end', () => { let text = Buffer.concat(chunks).toString('utf8'); - // Strip HTML tags for readability - text = text.replace(//gi, '') - .replace(//gi, '') - .replace(/<[^>]+>/g, ' ') - .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') - .replace(/"/g, '"').replace(/'/g, "'") - .replace(/\s{3,}/g, '\n\n') - .trim(); + // 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 to ~20KB if (text.length > 20000) text = text.slice(0, 20000) + '\n…(truncated)'; resolve(text); From 304174a996855528d377f4d08e91c16bb6e21a45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:25:00 +0000 Subject: [PATCH 6/9] security: SSRF protection in fetchUrl, stricter URL regex, MCP workspace warning Agent-Logs-Url: https://github.com/codomium/FreeCode/sessions/8aa8656d-2df8-4564-80f3-4db5a1ffed9f Co-authored-by: codomium <255525663+codomium@users.noreply.github.com> --- electron-app/main.js | 18 +++++++++++++++--- electron-app/renderer/chat.js | 8 ++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index 88eb678..2230196 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -934,10 +934,22 @@ ipcMain.on('renderer-message', async (event, msg) => { 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 reqOpts = new URL(targetUrl); const req = httpMod.get({ hostname: reqOpts.hostname, path: reqOpts.pathname + (reqOpts.search || ''), port: reqOpts.port, headers: { 'User-Agent': 'OpenClaudeCode/1.0 (context-fetch)', 'Accept': 'text/html,text/plain' } }, (res) => { @@ -958,8 +970,8 @@ ipcMain.on('renderer-message', async (event, msg) => { text = text.replace(/<[^>]*>/g, ' '); // Collapse whitespace text = text.replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); - // Limit to ~20KB - if (text.length > 20000) text = text.slice(0, 20000) + '\n…(truncated)'; + // Limit content size + if (text.length > MAX_URL_CONTENT_BYTES) text = text.slice(0, MAX_URL_CONTENT_BYTES) + '\n…(truncated)'; resolve(text); }); } diff --git a/electron-app/renderer/chat.js b/electron-app/renderer/chat.js index 67c32ba..ebf3ea8 100644 --- a/electron-app/renderer/chat.js +++ b/electron-app/renderer/chat.js @@ -2454,9 +2454,9 @@ } // @url:
or @url
— fetch URL and inject as context - const urlMention = val.match(/@url[:\s]+(https?:\/\/\S+)/); + const urlMention = val.match(/@url[:\s]+(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/); if (urlMention) { - inputEl.value = val.replace(/@url[:\s]+https?:\/\/\S+/g, '').trim(); + 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]); @@ -3722,6 +3722,10 @@ 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 { From f0c87518bab71fa02b3be3469291a948badfe2aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:00:55 +0000 Subject: [PATCH 7/9] feat: rebrand to freeCode + voice input, file-watcher toast, tool-error inject, CLAUDE.md offer Agent-Logs-Url: https://github.com/codomium/FreeCode/sessions/d0d2cae7-7283-4d47-977b-c3e390aed2d3 Co-authored-by: codomium <255525663+codomium@users.noreply.github.com> --- electron-app/main.js | 18 +-- electron-app/package.json | 19 +-- electron-app/renderer/chat.css | 103 +++++++++++++++- electron-app/renderer/chat.js | 194 +++++++++++++++++++++++++++++-- electron-app/renderer/index.html | 30 ++++- v2/src/core/agent-loop.mjs | 11 +- 6 files changed, 345 insertions(+), 30 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index 2230196..60415c1 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. */ @@ -157,7 +159,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. */ @@ -475,7 +477,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'), @@ -624,7 +626,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, @@ -1487,7 +1489,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); diff --git a/electron-app/package.json b/electron-app/package.json index 6799389..880d820 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,8 +1,8 @@ { - "name": "open-claude-code", - "productName": "Open Claude Code", + "name": "freecode", + "productName": "freeCode", "version": "1.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": "Open Claude Code", - "copyright": "Copyright © 2025 Open Claude Code", + "appId": "com.freecode.app", + "productName": "freeCode", + "copyright": "Copyright © 2025 freeCode — AI coding freedom for vibe coders", "directories": { "output": "dist" }, @@ -71,10 +72,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 5e174b2..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 { @@ -3496,3 +3496,104 @@ select:focus { border-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 ebf3ea8..3b3c3f1 100644 --- a/electron-app/renderer/chat.js +++ b/electron-app/renderer/chat.js @@ -212,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'); @@ -1275,7 +1283,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); @@ -1284,14 +1292,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 @@ -1302,7 +1316,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'); @@ -1315,12 +1329,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`); @@ -1815,8 +1850,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 @@ -1832,6 +1870,7 @@ syncPlanFromTodos(msg.input.todos); } break; + } case 'compaction': addSystemMessage(`⟳ Context compacted (pass ${msg.count})`); @@ -1876,6 +1915,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': @@ -2141,6 +2190,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(() => { @@ -3369,6 +3426,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 ──────────────────────────────────────────────── diff --git a/electron-app/renderer/index.html b/electron-app/renderer/index.html index cb686f7..5371b13 100644 --- a/electron-app/renderer/index.html +++ b/electron-app/renderer/index.html @@ -176,8 +176,8 @@