From d4014a193d81a224b39e7fe07c70f0444f6b170f Mon Sep 17 00:00:00 2001 From: Hibryda Date: Mon, 23 Mar 2026 13:05:07 +0100 Subject: [PATCH] feat(electrobun): project wizard phases 1-5 (WIP) - sanitize.ts: input sanitization (trim, control chars, path traversal) - provider-scanner.ts: detect Claude/Codex/Ollama/Gemini availability - model-fetcher.ts: live model lists from 4 provider APIs - ModelConfigPanel.svelte: per-provider config (thinking, effort, sandbox, temperature) - WizardStep1-3.svelte: split wizard into composable steps - CustomDropdown/Checkbox/Radio: themed UI components - provider-handlers.ts: provider.scan + provider.models RPC - Wire providers into wizard step 3 (live detection + model lists) - Replace native selects in 5 settings panels with CustomDropdown --- ui-electrobun/package-lock.json | 10 + ui-electrobun/package.json | 1 + .../src/bun/handlers/git-handlers.ts | 115 ++- .../src/bun/handlers/provider-handlers.ts | 30 + ui-electrobun/src/bun/index.ts | 4 + ui-electrobun/src/bun/model-fetcher.ts | 107 +++ ui-electrobun/src/bun/provider-scanner.ts | 107 +++ ui-electrobun/src/mainview/App.svelte | 5 +- .../src/mainview/ModelConfigPanel.svelte | 332 ++++++++ .../src/mainview/ProjectWizard.svelte | 795 +++++------------- ui-electrobun/src/mainview/WizardStep1.svelte | 284 +++++++ ui-electrobun/src/mainview/WizardStep2.svelte | 149 ++++ ui-electrobun/src/mainview/WizardStep3.svelte | 154 ++++ .../src/mainview/provider-capabilities.ts | 11 +- ui-electrobun/src/mainview/sanitize.ts | 62 ++ .../mainview/settings/AgentSettings.svelte | 2 + .../settings/AppearanceSettings.svelte | 171 +--- .../settings/OrchestrationSettings.svelte | 16 +- .../mainview/settings/ProjectSettings.svelte | 1 + .../mainview/settings/SecuritySettings.svelte | 47 +- .../src/mainview/ui/CustomCheckbox.svelte | 80 ++ .../src/mainview/ui/CustomDropdown.svelte | 212 +++++ .../src/mainview/ui/CustomRadio.svelte | 86 ++ ui-electrobun/src/mainview/wizard-icons.ts | 52 ++ ui-electrobun/src/shared/pty-rpc-schema.ts | 38 + 25 files changed, 2112 insertions(+), 759 deletions(-) create mode 100644 ui-electrobun/src/bun/handlers/provider-handlers.ts create mode 100644 ui-electrobun/src/bun/model-fetcher.ts create mode 100644 ui-electrobun/src/bun/provider-scanner.ts create mode 100644 ui-electrobun/src/mainview/ModelConfigPanel.svelte create mode 100644 ui-electrobun/src/mainview/WizardStep1.svelte create mode 100644 ui-electrobun/src/mainview/WizardStep2.svelte create mode 100644 ui-electrobun/src/mainview/WizardStep3.svelte create mode 100644 ui-electrobun/src/mainview/sanitize.ts create mode 100644 ui-electrobun/src/mainview/ui/CustomCheckbox.svelte create mode 100644 ui-electrobun/src/mainview/ui/CustomDropdown.svelte create mode 100644 ui-electrobun/src/mainview/ui/CustomRadio.svelte create mode 100644 ui-electrobun/src/mainview/wizard-icons.ts diff --git a/ui-electrobun/package-lock.json b/ui-electrobun/package-lock.json index 20e9832..1804b67 100644 --- a/ui-electrobun/package-lock.json +++ b/ui-electrobun/package-lock.json @@ -30,6 +30,7 @@ "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", "electrobun": "latest", + "lucide-svelte": "^0.577.0", "pdfjs-dist": "^5.5.207" }, "devDependencies": { @@ -2168,6 +2169,15 @@ "node": ">=12" } }, + "node_modules/lucide-svelte": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz", + "integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, diff --git a/ui-electrobun/package.json b/ui-electrobun/package.json index 9048657..f57b740 100644 --- a/ui-electrobun/package.json +++ b/ui-electrobun/package.json @@ -36,6 +36,7 @@ "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", "electrobun": "latest", + "lucide-svelte": "^0.577.0", "pdfjs-dist": "^5.5.207" }, "devDependencies": { diff --git a/ui-electrobun/src/bun/handlers/git-handlers.ts b/ui-electrobun/src/bun/handlers/git-handlers.ts index 50d1d9e..022fac3 100644 --- a/ui-electrobun/src/bun/handlers/git-handlers.ts +++ b/ui-electrobun/src/bun/handlers/git-handlers.ts @@ -1,5 +1,5 @@ /** - * Git RPC handlers — branch listing, clone operations. + * Git RPC handlers — branch listing, clone, probe, and template scaffolding. */ import path from "path"; @@ -98,5 +98,118 @@ export function createGitHandlers() { }); }); }, + + "git.probe": async ({ url }: { url: string }) => { + if (!url || (!url.includes("/") && !url.includes(":"))) { + return { ok: false, branches: [], defaultBranch: '', error: "Invalid URL" }; + } + + return new Promise<{ + ok: boolean; branches: string[]; defaultBranch: string; error?: string; + }>((resolve) => { + const proc = spawn("git", ["ls-remote", "--heads", "--symref", url], { + stdio: "pipe", + timeout: 15_000, + }); + + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); + proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); + + proc.on("close", (code) => { + if (code !== 0) { + resolve({ ok: false, branches: [], defaultBranch: '', error: stderr.trim() || 'Probe failed' }); + return; + } + const branches: string[] = []; + let defaultBranch = 'main'; + + for (const line of stdout.split("\n")) { + // Parse symref for HEAD + const symMatch = line.match(/^ref: refs\/heads\/(\S+)\s+HEAD/); + if (symMatch) { defaultBranch = symMatch[1]; continue; } + + // Parse branch refs + const refMatch = line.match(/\trefs\/heads\/(.+)$/); + if (refMatch && !branches.includes(refMatch[1])) { + branches.push(refMatch[1]); + } + } + resolve({ ok: true, branches, defaultBranch }); + }); + + proc.on("error", (err) => { + resolve({ ok: false, branches: [], defaultBranch: '', error: err.message }); + }); + }); + }, + + "project.createFromTemplate": async ({ + templateId, + targetDir, + projectName, + }: { + templateId: string; + targetDir: string; + projectName: string; + }) => { + const resolved = path.resolve(targetDir.replace(/^~/, process.env.HOME ?? "")); + const projectDir = path.join(resolved, projectName); + + // Don't overwrite existing directory + try { + fs.statSync(projectDir); + return { ok: false, path: '', error: "Directory already exists" }; + } catch { + // Expected + } + + try { + fs.mkdirSync(projectDir, { recursive: true }); + + const scaffolds: Record> = { + blank: { + 'README.md': `# ${projectName}\n`, + '.gitignore': 'node_modules/\ndist/\n.env\n', + }, + 'web-app': { + 'index.html': `\n\n\n \n \n ${projectName}\n \n\n\n

${projectName}

\n \n\n\n`, + 'style.css': `body {\n font-family: system-ui, sans-serif;\n margin: 2rem;\n}\n`, + 'main.js': `console.log('${projectName} loaded');\n`, + 'README.md': `# ${projectName}\n\nA web application.\n`, + '.gitignore': 'node_modules/\ndist/\n.env\n', + }, + 'api-server': { + 'index.ts': `const server = Bun.serve({\n port: 3000,\n fetch(req) {\n const url = new URL(req.url);\n if (url.pathname === '/health') {\n return Response.json({ status: 'ok' });\n }\n return Response.json({ message: 'Hello from ${projectName}' });\n },\n});\nconsole.log(\`Server running on \${server.url}\`);\n`, + 'README.md': `# ${projectName}\n\nA Bun HTTP API server.\n\n## Run\n\n\`\`\`bash\nbun run index.ts\n\`\`\`\n`, + '.gitignore': 'node_modules/\ndist/\n.env\n', + 'package.json': `{\n "name": "${projectName}",\n "version": "0.1.0",\n "scripts": {\n "start": "bun run index.ts",\n "dev": "bun --watch index.ts"\n }\n}\n`, + }, + 'cli-tool': { + 'cli.ts': `#!/usr/bin/env bun\nconst args = process.argv.slice(2);\nif (args.includes('--help') || args.includes('-h')) {\n console.log('Usage: ${projectName} [options]');\n console.log(' --help, -h Show this help');\n console.log(' --version Show version');\n process.exit(0);\n}\nif (args.includes('--version')) {\n console.log('${projectName} 0.1.0');\n process.exit(0);\n}\nconsole.log('Hello from ${projectName}!');\n`, + 'README.md': `# ${projectName}\n\nA command-line tool.\n\n## Run\n\n\`\`\`bash\nbun run cli.ts --help\n\`\`\`\n`, + '.gitignore': 'node_modules/\ndist/\n.env\n', + }, + }; + + const files = scaffolds[templateId] ?? scaffolds['blank']; + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(projectDir, name), content); + } + + // Initialize git repo + try { + execSync('git init', { cwd: projectDir, timeout: 5000 }); + } catch { + // Non-fatal — project created without git + } + + return { ok: true, path: projectDir }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + return { ok: false, path: '', error }; + } + }, }; } diff --git a/ui-electrobun/src/bun/handlers/provider-handlers.ts b/ui-electrobun/src/bun/handlers/provider-handlers.ts new file mode 100644 index 0000000..481d1a6 --- /dev/null +++ b/ui-electrobun/src/bun/handlers/provider-handlers.ts @@ -0,0 +1,30 @@ +/** + * Provider RPC handlers — scanning + model fetching. + */ + +import { scanAllProviders } from '../provider-scanner.ts'; +import { fetchModelsForProvider } from '../model-fetcher.ts'; + +export function createProviderHandlers() { + return { + 'provider.scan': async () => { + try { + const providers = await scanAllProviders(); + return { providers }; + } catch (err) { + console.error('[provider.scan]', err); + return { providers: [] }; + } + }, + + 'provider.models': async ({ provider }: { provider: string }) => { + try { + const models = await fetchModelsForProvider(provider); + return { models }; + } catch (err) { + console.error('[provider.models]', err); + return { models: [] }; + } + }, + }; +} diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index ec1bb2c..66ba69e 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -33,6 +33,7 @@ import { createSearchHandlers } from "./handlers/search-handlers.ts"; import { createPluginHandlers } from "./handlers/plugin-handlers.ts"; import { createRemoteHandlers } from "./handlers/remote-handlers.ts"; import { createGitHandlers } from "./handlers/git-handlers.ts"; +import { createProviderHandlers } from "./handlers/provider-handlers.ts"; /** Current app version — sourced from electrobun.config.ts at build time. */ const APP_VERSION = "0.0.1"; @@ -101,6 +102,7 @@ const searchHandlers = createSearchHandlers(searchDb); const pluginHandlers = createPluginHandlers(); const remoteHandlers = createRemoteHandlers(relayClient, settingsDb); const gitHandlers = createGitHandlers(); +const providerHandlers = createProviderHandlers(); // ── RPC definition ───────────────────────────────────────────────────────── @@ -127,6 +129,8 @@ const rpc = BrowserView.defineRPC({ ...remoteHandlers, // Git ...gitHandlers, + // Providers + ...providerHandlers, // Native folder picker dialog via zenity (proper GTK folder chooser) "files.pickDirectory": async ({ startingFolder }) => { diff --git a/ui-electrobun/src/bun/model-fetcher.ts b/ui-electrobun/src/bun/model-fetcher.ts new file mode 100644 index 0000000..3259987 --- /dev/null +++ b/ui-electrobun/src/bun/model-fetcher.ts @@ -0,0 +1,107 @@ +/** + * Model fetcher — retrieves available models from each provider's API. + * + * Each function returns a sorted list of model IDs. + * Network errors return empty arrays (non-fatal). + */ + +export interface ModelInfo { + id: string; + name: string; + provider: string; +} + +const TIMEOUT = 8000; + +export async function fetchClaudeModels(): Promise { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) return []; + try { + const res = await fetch('https://api.anthropic.com/v1/models', { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + signal: AbortSignal.timeout(TIMEOUT), + }); + if (!res.ok) return []; + const data = await res.json() as { data?: Array<{ id: string; display_name?: string }> }; + return (data.data ?? []) + .map(m => ({ id: m.id, name: m.display_name ?? m.id, provider: 'claude' })) + .sort((a, b) => a.id.localeCompare(b.id)); + } catch { + return []; + } +} + +export async function fetchCodexModels(): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) return []; + try { + const res = await fetch('https://api.openai.com/v1/models', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(TIMEOUT), + }); + if (!res.ok) return []; + const data = await res.json() as { data?: Array<{ id: string }> }; + return (data.data ?? []) + .map(m => ({ id: m.id, name: m.id, provider: 'codex' })) + .sort((a, b) => a.id.localeCompare(b.id)); + } catch { + return []; + } +} + +export async function fetchOllamaModels(): Promise { + try { + const res = await fetch('http://localhost:11434/api/tags', { + signal: AbortSignal.timeout(TIMEOUT), + }); + if (!res.ok) return []; + const data = await res.json() as { models?: Array<{ name: string; model?: string }> }; + return (data.models ?? []) + .map(m => ({ id: m.name, name: m.name, provider: 'ollama' })) + .sort((a, b) => a.id.localeCompare(b.id)); + } catch { + return []; + } +} + +export async function fetchGeminiModels(): Promise { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) return []; + try { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, + { signal: AbortSignal.timeout(TIMEOUT) }, + ); + if (!res.ok) return []; + const data = await res.json() as { + models?: Array<{ name: string; displayName?: string }>; + }; + return (data.models ?? []) + .map(m => ({ + id: m.name.replace('models/', ''), + name: m.displayName ?? m.name, + provider: 'gemini', + })) + .sort((a, b) => a.id.localeCompare(b.id)); + } catch { + return []; + } +} + +/** + * Fetch models for a specific provider. + */ +export async function fetchModelsForProvider( + provider: string, +): Promise { + switch (provider) { + case 'claude': return fetchClaudeModels(); + case 'codex': return fetchCodexModels(); + case 'ollama': return fetchOllamaModels(); + case 'gemini': return fetchGeminiModels(); + default: return []; + } +} diff --git a/ui-electrobun/src/bun/provider-scanner.ts b/ui-electrobun/src/bun/provider-scanner.ts new file mode 100644 index 0000000..9a9a140 --- /dev/null +++ b/ui-electrobun/src/bun/provider-scanner.ts @@ -0,0 +1,107 @@ +/** + * Provider scanner — detects which AI providers are available on this machine. + * + * Checks environment variables and CLI tool availability. + */ + +import { execSync } from 'child_process'; + +export interface ProviderScanResult { + id: string; + available: boolean; + hasApiKey: boolean; + hasCli: boolean; + cliPath: string | null; + version: string | null; +} + +function whichSync(bin: string): string | null { + try { + return execSync(`which ${bin}`, { encoding: 'utf8', timeout: 3000 }).trim() || null; + } catch { + return null; + } +} + +function getVersion(bin: string): string | null { + try { + const out = execSync(`${bin} --version`, { encoding: 'utf8', timeout: 5000 }).trim(); + // Extract first version-like string + const match = out.match(/(\d+\.\d+[\w.-]*)/); + return match ? match[1] : out.slice(0, 40); + } catch { + return null; + } +} + +export async function scanClaude(): Promise { + const hasApiKey = !!process.env.ANTHROPIC_API_KEY; + const cliPath = whichSync('claude'); + return { + id: 'claude', + available: hasApiKey || !!cliPath, + hasApiKey, + hasCli: !!cliPath, + cliPath, + version: cliPath ? getVersion('claude') : null, + }; +} + +export async function scanCodex(): Promise { + const hasApiKey = !!process.env.OPENAI_API_KEY; + const cliPath = whichSync('codex'); + return { + id: 'codex', + available: hasApiKey || !!cliPath, + hasApiKey, + hasCli: !!cliPath, + cliPath, + version: cliPath ? getVersion('codex') : null, + }; +} + +export async function scanOllama(): Promise { + const cliPath = whichSync('ollama'); + let serverUp = false; + try { + const res = await fetch('http://localhost:11434/api/version', { + signal: AbortSignal.timeout(2000), + }); + serverUp = res.ok; + } catch { + // ECONNREFUSED or timeout — server not running + } + return { + id: 'ollama', + available: serverUp || !!cliPath, + hasApiKey: false, + hasCli: !!cliPath, + cliPath, + version: cliPath ? getVersion('ollama') : null, + }; +} + +export async function scanGemini(): Promise { + const hasApiKey = !!process.env.GEMINI_API_KEY; + return { + id: 'gemini', + available: hasApiKey, + hasApiKey, + hasCli: false, + cliPath: null, + version: null, + }; +} + +/** + * Scan all known providers. Returns results for each. + */ +export async function scanAllProviders(): Promise { + const [claude, codex, ollama, gemini] = await Promise.all([ + scanClaude(), + scanCodex(), + scanOllama(), + scanGemini(), + ]); + return [claude, codex, ollama, gemini]; +} diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index 292be0b..524cae3 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -78,9 +78,9 @@ function handleWizardCreated(result: { id: string; name: string; cwd: string; provider?: string; model?: string; systemPrompt?: string; autoStart?: boolean; groupId?: string; - useWorktrees?: boolean; shell?: string; icon?: string; + useWorktrees?: boolean; shell?: string; icon?: string; color?: string; }) { - const accent = ACCENTS[PROJECTS.length % ACCENTS.length]; + const accent = result.color || ACCENTS[PROJECTS.length % ACCENTS.length]; const project: Project = { id: result.id, name: result.name, @@ -512,6 +512,7 @@ onCreated={handleWizardCreated} groupId={activeGroupId} groups={groups.map(g => ({ id: g.id, name: g.name }))} + existingNames={PROJECTS.map(p => p.name)} /> diff --git a/ui-electrobun/src/mainview/ModelConfigPanel.svelte b/ui-electrobun/src/mainview/ModelConfigPanel.svelte new file mode 100644 index 0000000..309cda0 --- /dev/null +++ b/ui-electrobun/src/mainview/ModelConfigPanel.svelte @@ -0,0 +1,332 @@ + + +
+ {#if provider === 'claude'} + +
+ Thinking mode + { claudeThinking = v as typeof claudeThinking; emitClaude(); }} + /> +
+ + +
+ Effort level + { claudeEffort = v; emitClaude(); }} + /> +
+ + +
+ + Temperature + {#if claudeTempLocked} + (locked) + {/if} + +
+ { claudeTemp = parseFloat((e.target as HTMLInputElement).value); emitClaude(); }} /> + {claudeTempLocked ? '1.0' : claudeTemp.toFixed(1)} +
+
+ + +
+ Max tokens + { claudeMaxTokens = parseInt((e.target as HTMLInputElement).value, 10) || 8192; emitClaude(); }} /> +
+ + {:else if provider === 'codex'} + +
+ Sandbox mode +
+ {#each SANDBOX_ITEMS as s} + + {/each} +
+
+ + +
+ Approval policy +
+ {#each APPROVAL_ITEMS as a} + + {/each} +
+
+ + +
+ Reasoning effort +
+ {#each REASONING_ITEMS as r} + + {/each} +
+
+ + {:else if provider === 'ollama'} + +
+ Temperature +
+ { ollamaTemp = parseFloat((e.target as HTMLInputElement).value); emitOllama(); }} /> + {ollamaTemp.toFixed(1)} +
+
+ + +
+ + Context window (num_ctx) + {#if ollamaCtxWarn} + Low context may cause truncation + {/if} + + { ollamaCtx = parseInt((e.target as HTMLInputElement).value, 10) || 32768; emitOllama(); }} /> +
+ + +
+ Max predict (num_predict, 0 = unlimited) + { ollamaPredict = parseInt((e.target as HTMLInputElement).value, 10) || 0; emitOllama(); }} /> +
+ + +
+ Top-K + { ollamaTopK = parseInt((e.target as HTMLInputElement).value, 10) || 40; emitOllama(); }} /> +
+ + +
+ Top-P +
+ { ollamaTopP = parseFloat((e.target as HTMLInputElement).value); emitOllama(); }} /> + {ollamaTopP.toFixed(2)} +
+
+ + {:else if provider === 'gemini'} + +
+ Temperature +
+ { geminiTemp = parseFloat((e.target as HTMLInputElement).value); emitGemini(); }} /> + {geminiTemp.toFixed(1)} +
+
+ + +
+ Thinking configuration +
+ + +
+
+ + +
+ Thinking level +
+ {#each GEMINI_LEVEL_ITEMS as lvl} + + {/each} +
+
+ + +
+ Thinking budget (tokens) + { geminiThinkingBudget = parseInt((e.target as HTMLInputElement).value, 10) || 8192; emitGemini(); }} /> +
+ + +
+ Max output tokens + { geminiMaxOutput = parseInt((e.target as HTMLInputElement).value, 10) || 8192; emitGemini(); }} /> +
+ {/if} +
+ + diff --git a/ui-electrobun/src/mainview/ProjectWizard.svelte b/ui-electrobun/src/mainview/ProjectWizard.svelte index 0b702bb..44275a6 100644 --- a/ui-electrobun/src/mainview/ProjectWizard.svelte +++ b/ui-electrobun/src/mainview/ProjectWizard.svelte @@ -1,20 +1,15 @@ -