feat(electrobun): complete project wizard phases 4-5

- ModelConfigPanel: Claude thinking/effort, Codex sandbox/approval, Ollama
  temp/ctx/predict, Gemini thinkingLevel/budget (mutually exclusive)
- CustomDropdown: themed with keyboard nav, groupBy, display:none pattern
- CustomCheckbox/CustomRadio: themed with Lucide icons
- Replaced native selects in 5 settings panels + wizard steps
- wizard-state.ts: shared type definitions
- Gemini added as 4th provider throughout
This commit is contained in:
Hibryda 2026-03-23 13:12:47 +01:00
parent d4014a193d
commit 41b8d46a19
6 changed files with 210 additions and 247 deletions

View file

@ -13,33 +13,53 @@
let { provider, model, config, onChange }: Props = $props();
// ── Claude config ──────────────────────────────────────
let claudeThinking = $state<'disabled' | 'enabled' | 'adaptive'>((config.thinking as string) ?? 'disabled');
let claudeEffort = $state((config.effort as string) ?? 'medium');
let claudeTemp = $state<number>((config.temperature as number) ?? 1.0);
let claudeMaxTokens = $state<number>((config.max_tokens as number) ?? 8192);
// Temperature is locked to 1.0 when thinking=enabled
let claudeThinking = $state<'disabled' | 'enabled' | 'adaptive'>('disabled');
let claudeEffort = $state('medium');
let claudeTemp = $state<number>(1.0);
let claudeMaxTokens = $state<number>(8192);
let claudeTempLocked = $derived(claudeThinking === 'enabled');
// ── Codex config ───────────────────────────────────────
let codexSandbox = $state((config.sandbox as string) ?? 'workspace-write');
let codexApproval = $state((config.approval as string) ?? 'on-request');
let codexReasoning = $state((config.reasoning as string) ?? 'medium');
let codexSandbox = $state('workspace-write');
let codexApproval = $state('on-request');
let codexReasoning = $state('medium');
// ── Ollama config ──────────────────────────────────────
let ollamaTemp = $state<number>((config.temperature as number) ?? 0.8);
let ollamaCtx = $state<number>((config.num_ctx as number) ?? 32768);
let ollamaPredict = $state<number>((config.num_predict as number) ?? 0);
let ollamaTopK = $state<number>((config.top_k as number) ?? 40);
let ollamaTopP = $state<number>((config.top_p as number) ?? 0.9);
let ollamaTemp = $state<number>(0.8);
let ollamaCtx = $state<number>(32768);
let ollamaPredict = $state<number>(0);
let ollamaTopK = $state<number>(40);
let ollamaTopP = $state<number>(0.9);
let ollamaCtxWarn = $derived(ollamaCtx < 8192);
// ── Gemini config ──────────────────────────────────────
let geminiTemp = $state<number>((config.temperature as number) ?? 1.0);
let geminiThinkingMode = $state<'level' | 'budget'>((config.thinkingMode as string as 'level' | 'budget') ?? 'level');
let geminiThinkingLevel = $state((config.thinkingLevel as string) ?? 'medium');
let geminiThinkingBudget = $state<number>((config.thinkingBudget as number) ?? 8192);
let geminiMaxOutput = $state<number>((config.maxOutputTokens as number) ?? 8192);
let geminiTemp = $state<number>(1.0);
let geminiThinkingMode = $state<'level' | 'budget'>('level');
let geminiThinkingLevel = $state('medium');
let geminiThinkingBudget = $state<number>(8192);
let geminiMaxOutput = $state<number>(8192);
// Sync local state from config prop (re-runs when config changes)
$effect(() => {
const c = config;
claudeThinking = (c.thinking as string as typeof claudeThinking) ?? 'disabled';
claudeEffort = (c.effort as string) ?? 'medium';
claudeTemp = (c.temperature as number) ?? 1.0;
claudeMaxTokens = (c.max_tokens as number) ?? 8192;
codexSandbox = (c.sandbox as string) ?? 'workspace-write';
codexApproval = (c.approval as string) ?? 'on-request';
codexReasoning = (c.reasoning as string) ?? 'medium';
ollamaTemp = (c.temperature as number) ?? 0.8;
ollamaCtx = (c.num_ctx as number) ?? 32768;
ollamaPredict = (c.num_predict as number) ?? 0;
ollamaTopK = (c.top_k as number) ?? 40;
ollamaTopP = (c.top_p as number) ?? 0.9;
geminiTemp = (c.temperature as number) ?? 1.0;
geminiThinkingMode = (c.thinkingMode as string as 'level' | 'budget') ?? 'level';
geminiThinkingLevel = (c.thinkingLevel as string) ?? 'medium';
geminiThinkingBudget = (c.thinkingBudget as number) ?? 8192;
geminiMaxOutput = (c.maxOutputTokens as number) ?? 8192;
});
// ── Emit changes ───────────────────────────────────────
function emitClaude() {
@ -285,48 +305,20 @@
<style>
.mcp-root { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.375rem; }
.mcp-field { display: flex; flex-direction: column; gap: 0.25rem; }
.mcp-label {
font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0);
display: flex; align-items: center; gap: 0.375rem;
}
.mcp-hint {
font-size: 0.6875rem; color: var(--ctp-overlay0); font-weight: 400; font-style: italic;
}
.mcp-warn {
font-size: 0.6875rem; color: var(--ctp-peach); font-weight: 400;
}
.mcp-input {
padding: 0.375rem 0.5rem; width: 100%;
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family);
appearance: textfield; -moz-appearance: textfield;
}
.mcp-input::-webkit-inner-spin-button,
.mcp-input::-webkit-outer-spin-button { -webkit-appearance: none; appearance: none; }
.mcp-label { font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0); display: flex; align-items: center; gap: 0.375rem; }
.mcp-hint { font-size: 0.6875rem; color: var(--ctp-overlay0); font-weight: 400; font-style: italic; }
.mcp-warn { font-size: 0.6875rem; color: var(--ctp-peach); font-weight: 400; }
.mcp-input { padding: 0.375rem 0.5rem; width: 100%; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); appearance: textfield; -moz-appearance: textfield; }
.mcp-input::-webkit-inner-spin-button, .mcp-input::-webkit-outer-spin-button { -webkit-appearance: none; appearance: none; }
.mcp-input:focus { outline: none; border-color: var(--ctp-blue); }
.mcp-slider-row { display: flex; align-items: center; gap: 0.5rem; }
.mcp-slider { flex: 1; accent-color: var(--ctp-blue); }
.mcp-slider:disabled { opacity: 0.4; }
.mcp-slider-val { font-size: 0.8125rem; color: var(--ctp-text); min-width: 2.5rem; text-align: right; }
.mcp-seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
.mcp-seg-btn {
flex: 1; padding: 0.25rem 0.375rem; background: var(--ctp-surface0); border: none;
color: var(--ctp-overlay1); font-size: 0.6875rem; cursor: pointer;
font-family: var(--ui-font-family);
border-right: 1px solid var(--ctp-surface1);
}
.mcp-seg-btn { flex: 1; padding: 0.25rem 0.375rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.6875rem; cursor: pointer; font-family: var(--ui-font-family); border-right: 1px solid var(--ctp-surface1); }
.mcp-seg-btn:last-child { border-right: none; }
.mcp-seg-btn:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
.mcp-seg-btn.active {
background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0));
color: var(--ctp-blue); font-weight: 600;
}
.mcp-seg-btn.active { background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); color: var(--ctp-blue); font-weight: 600; }
</style>

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
import { sanitize, sanitizePath, sanitizeUrl, isValidGitUrl, isValidGithubRepo } from './sanitize.ts';
import { getProjects } from './workspace-store.svelte.ts';
import WizardStep1 from './WizardStep1.svelte';
import WizardStep2 from './WizardStep2.svelte';
import WizardStep3 from './WizardStep3.svelte';
@ -10,6 +9,7 @@
id: string; name: string; cwd: string; provider?: string; model?: string;
systemPrompt?: string; autoStart?: boolean; groupId?: string;
useWorktrees?: boolean; shell?: string; icon?: string; color?: string;
modelConfig?: Record<string, unknown>;
}
interface Props {
@ -17,68 +17,35 @@
onCreated: (project: ProjectResult) => void;
groupId: string;
groups: Array<{ id: string; name: string }>;
existingNames: string[];
}
let { onClose, onCreated, groupId, groups }: Props = $props();
let { onClose, onCreated, groupId, groups, existingNames }: Props = $props();
// ── Wizard step ─────────────────────────────────────────────
type SourceType = 'local' | 'git-clone' | 'github' | 'template' | 'remote';
type AuthMethod = 'password' | 'key' | 'agent' | 'config';
let step = $state(1);
// ── Step 1 state ────────────────────────────────────────────
let sourceType = $state<SourceType>('local');
let localPath = $state('');
let repoUrl = $state('');
let cloneTarget = $state('');
let githubRepo = $state('');
let selectedTemplate = $state('');
let templateTargetDir = $state('~/projects');
let remoteHost = $state('');
let remoteUser = $state('');
let remotePath = $state('');
let remoteAuthMethod = $state<AuthMethod>('agent');
let remotePassword = $state('');
let remoteKeyPath = $state('~/.ssh/id_ed25519');
let remoteSshfs = $state(false);
let localPath = $state(''); let repoUrl = $state(''); let cloneTarget = $state('');
let githubRepo = $state(''); let selectedTemplate = $state(''); let templateTargetDir = $state('~/projects');
let remoteHost = $state(''); let remoteUser = $state(''); let remotePath = $state('');
let remoteAuthMethod = $state<AuthMethod>('agent'); let remotePassword = $state(''); let remoteKeyPath = $state('~/.ssh/id_ed25519');
let remoteSshfs = $state(false); let isGitRepo = $state(false); let gitBranch = $state('');
let pathValid = $state<'idle' | 'checking' | 'valid' | 'invalid' | 'not-dir'>('idle');
let isGitRepo = $state(false);
let gitBranch = $state('');
let gitProbeStatus = $state<'idle' | 'probing' | 'ok' | 'error'>('idle');
let gitProbeBranches = $state<string[]>([]);
let gitProbeBranches = $state<string[]>([]); let githubLoading = $state(false); let cloning = $state(false);
let githubInfo = $state<{ stars: number; description: string; defaultBranch: string } | null>(null);
let githubLoading = $state(false);
let cloning = $state(false);
let templates = $state<Array<{ id: string; name: string; description: string; icon: string }>>([]);
// ── Step 2 state ────────────────────────────────────────────
let projectName = $state('');
let nameError = $state('');
let selectedBranch = $state('');
let branches = $state<string[]>([]);
let useWorktrees = $state(false);
let selectedGroupId = $state(groupId);
let projectIcon = $state('Terminal');
let projectColor = $state('var(--ctp-blue)');
let shellChoice = $state('bash');
// ── Step 3 state ────────────────────────────────────────────
let provider = $state<string>('claude');
let model = $state('');
let permissionMode = $state('default');
let systemPrompt = $state('');
let autoStart = $state(false);
let detectedProviders = $state<Array<{ id: string; available: boolean; hasApiKey: boolean; hasCli: boolean; cliPath: string | null; version: string | null }>>([]);
let providerModels = $state<Array<{ id: string; name: string; provider: string }>>([]);
let modelsLoading = $state(false);
// ── Debounce timers ─────────────────────────────────────────
let projectName = $state(''); let nameError = $state(''); let selectedBranch = $state('');
let branches = $state<string[]>([]); let useWorktrees = $state(false); let selectedGroupId = $state(groupId);
let projectIcon = $state('Terminal'); let projectColor = $state('var(--ctp-blue)'); let shellChoice = $state('bash');
let provider = $state<string>('claude'); let model = $state(''); let permissionMode = $state('default');
let systemPrompt = $state(''); let autoStart = $state(false); let modelConfig = $state<Record<string, unknown>>({});
type ProviderInfo = { id: string; available: boolean; hasApiKey: boolean; hasCli: boolean; cliPath: string | null; version: string | null };
let detectedProviders = $state<ProviderInfo[]>([]); let providerModels = $state<Array<{ id: string; name: string; provider: string }>>([]); let modelsLoading = $state(false);
let pathTimer: ReturnType<typeof setTimeout> | null = null;
let probeTimer: ReturnType<typeof setTimeout> | null = null;
let githubTimer: ReturnType<typeof setTimeout> | null = null;
// ── Templates loading ───────────────────────────────────────
$effect(() => {
if (sourceType === 'template' && templates.length === 0) {
appRpc.request['project.templates']({}).then(r => {
@ -86,8 +53,6 @@
}).catch(console.error);
}
});
// ── Path validation (debounced) ─────────────────────────────
$effect(() => {
if (sourceType === 'local') {
if (pathTimer) clearTimeout(pathTimer);
@ -103,8 +68,6 @@
}, 300);
}
});
// ── Git probe (debounced) ───────────────────────────────────
$effect(() => {
if (sourceType === 'git-clone' && repoUrl.trim()) {
if (probeTimer) clearTimeout(probeTimer);
@ -118,8 +81,6 @@
}, 600);
} else if (sourceType === 'git-clone') { gitProbeStatus = 'idle'; }
});
// ── GitHub validation (debounced) ───────────────────────────
$effect(() => {
if (sourceType === 'github' && githubRepo.trim()) {
if (githubTimer) clearTimeout(githubTimer);
@ -138,16 +99,13 @@
}, 500);
}
});
// ── Unique name validation ──────────────────────────────────
$effect(() => {
const trimmed = projectName.trim().toLowerCase();
if (!trimmed) { nameError = ''; return; }
const existing = getProjects().map(p => p.name.toLowerCase());
nameError = existing.includes(trimmed) ? 'A project with this name already exists' : '';
const lowerNames = existingNames.map(n => n.toLowerCase());
nameError = lowerNames.includes(trimmed) ? 'A project with this name already exists' : '';
});
// ── Provider scan on step 3 entry ───────────────────────────
$effect(() => {
if (step === 3 && detectedProviders.length === 0) {
appRpc.request['provider.scan']({}).then(r => {
@ -156,7 +114,6 @@
}
});
// ── Model list on provider change ───────────────────────────
$effect(() => {
if (step === 3 && provider) {
providerModels = []; modelsLoading = true;
@ -166,7 +123,6 @@
}
});
// ── Step 1 validation ──────────────────────────────────────
let step1Valid = $derived(() => {
switch (sourceType) {
case 'local': return pathValid === 'valid';
@ -178,7 +134,6 @@
}
});
// ── Step navigation ────────────────────────────────────────
async function goToStep2() {
if (sourceType === 'git-clone') {
const url = sanitizeUrl(repoUrl); if (!url) return;
@ -228,6 +183,7 @@
provider: provider as string, model: model || undefined, systemPrompt: systemPrompt || undefined,
autoStart, groupId: selectedGroupId, useWorktrees: useWorktrees || undefined,
shell: shellChoice, icon: projectIcon, color: projectColor,
modelConfig: Object.keys(modelConfig).length > 0 ? modelConfig : undefined,
};
onCreated(project);
resetState();
@ -246,41 +202,27 @@
branches = []; useWorktrees = false; selectedGroupId = groupId;
projectIcon = 'Terminal'; projectColor = 'var(--ctp-blue)'; shellChoice = 'bash';
provider = 'claude'; model = ''; permissionMode = 'default';
systemPrompt = ''; autoStart = false; providerModels = []; modelsLoading = false;
systemPrompt = ''; autoStart = false; providerModels = []; modelsLoading = false; modelConfig = {};
}
// ── Field updater (passed to sub-components) ────────────────
function handleUpdate(field: string, value: unknown) {
const v = value as string & boolean;
switch (field) {
case 'sourceType': sourceType = v as SourceType; break;
case 'localPath': localPath = v; break;
case 'repoUrl': repoUrl = v; break;
case 'cloneTarget': cloneTarget = v; break;
case 'githubRepo': githubRepo = v; break;
case 'selectedTemplate': selectedTemplate = v; break;
case 'templateTargetDir': templateTargetDir = v; break;
case 'remoteHost': remoteHost = v; break;
case 'remoteUser': remoteUser = v; break;
case 'remotePath': remotePath = v; break;
case 'remoteAuthMethod': remoteAuthMethod = v as AuthMethod; break;
case 'remotePassword': remotePassword = v; break;
case 'remoteKeyPath': remoteKeyPath = v; break;
case 'remoteSshfs': remoteSshfs = v as unknown as boolean; break;
case 'projectName': projectName = v; break;
case 'selectedBranch': selectedBranch = v; break;
case 'useWorktrees': useWorktrees = v as unknown as boolean; break;
case 'selectedGroupId': selectedGroupId = v; break;
case 'projectIcon': projectIcon = v; break;
case 'projectColor': projectColor = v; break;
case 'shellChoice': shellChoice = v; break;
case 'provider': provider = v; break;
case 'model': model = v; break;
case 'permissionMode': permissionMode = v; break;
case 'systemPrompt': systemPrompt = v; break;
case 'autoStart': autoStart = v as unknown as boolean; break;
}
}
/* eslint-disable @typescript-eslint/no-explicit-any */
const SETTERS: Record<string, (v: any) => void> = {
sourceType: v => sourceType = v, localPath: v => localPath = v,
repoUrl: v => repoUrl = v, cloneTarget: v => cloneTarget = v,
githubRepo: v => githubRepo = v, selectedTemplate: v => selectedTemplate = v,
templateTargetDir: v => templateTargetDir = v, remoteHost: v => remoteHost = v,
remoteUser: v => remoteUser = v, remotePath: v => remotePath = v,
remoteAuthMethod: v => remoteAuthMethod = v, remotePassword: v => remotePassword = v,
remoteKeyPath: v => remoteKeyPath = v, remoteSshfs: v => remoteSshfs = v,
projectName: v => projectName = v, selectedBranch: v => selectedBranch = v,
useWorktrees: v => useWorktrees = v, selectedGroupId: v => selectedGroupId = v,
projectIcon: v => projectIcon = v, projectColor: v => projectColor = v,
shellChoice: v => shellChoice = v, provider: v => provider = v,
model: v => model = v, permissionMode: v => permissionMode = v,
systemPrompt: v => systemPrompt = v, autoStart: v => autoStart = v,
modelConfig: v => modelConfig = v,
};
function handleUpdate(field: string, value: unknown) { SETTERS[field]?.(value); }
</script>
<div class="wizard-overlay" role="dialog" aria-label="New Project">
@ -318,7 +260,7 @@
<div class="wz-body" style:display={step === 3 ? 'flex' : 'none'}>
<h3 class="wz-step-title">Agent</h3>
<WizardStep3 {provider} {model} {permissionMode} {systemPrompt} {autoStart} {detectedProviders} {providerModels} {modelsLoading} onUpdate={handleUpdate} />
<WizardStep3 {provider} {model} {permissionMode} {systemPrompt} {autoStart} {detectedProviders} {providerModels} {modelsLoading} {modelConfig} onUpdate={handleUpdate} />
<div class="wz-footer">
<button class="wz-btn secondary" onclick={goBack}>Back</button>
<div class="wz-footer-right">
@ -354,4 +296,5 @@
.wz-btn.secondary:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.wz-btn.ghost { background: transparent; border: none; color: var(--ctp-subtext0); }
.wz-btn.ghost:hover { color: var(--ctp-text); }
</style>

View file

@ -5,6 +5,8 @@
Rocket, Bug, Puzzle, Box, Layers, GitBranch, Wifi, Lock,
FlaskConical, Sparkles, FileCode, Wrench, Folder, Bot, Cloud, HardDrive,
} from 'lucide-svelte';
import CustomDropdown from './ui/CustomDropdown.svelte';
import CustomCheckbox from './ui/CustomCheckbox.svelte';
interface Props {
projectName: string;
@ -28,14 +30,6 @@
}: Props = $props();
const SHELLS = ['bash', 'zsh', 'fish', 'sh'];
let branchDDOpen = $state(false);
let groupDDOpen = $state(false);
let shellDDOpen = $state(false);
function closeAllDD() { branchDDOpen = false; groupDDOpen = false; shellDDOpen = false; }
let branchLabel = $derived(selectedBranch || 'main');
let groupLabel = $derived(groups.find(g => g.id === selectedGroupId)?.name ?? 'Select');
const ICON_MAP: Record<string, typeof Terminal> = {
Terminal, Server, Globe, Code, Database, Cpu, Zap, Shield,
@ -52,36 +46,26 @@
{#if isGitRepo && branches.length > 0}
<label class="wz-label">Branch</label>
<div class="wz-dd" style="position:relative;">
<button class="wz-dd-btn" onclick={() => { closeAllDD(); branchDDOpen = !branchDDOpen; }}>
<span>{branchLabel}</span><span class="wz-dd-chev">&#9662;</span>
</button>
<div class="wz-dd-menu" style:display={branchDDOpen ? 'block' : 'none'}>
{#each branches as br}
<button class="wz-dd-item" class:active={selectedBranch === br}
onclick={() => { onUpdate('selectedBranch', br); branchDDOpen = false; }}>{br}</button>
{/each}
</div>
</div>
<label class="wz-toggle-row">
<input type="checkbox" checked={useWorktrees}
onchange={(e) => onUpdate('useWorktrees', (e.target as HTMLInputElement).checked)} />
<span>Use worktrees</span>
</label>
<CustomDropdown
items={branches.map(br => ({ value: br, label: br }))}
selected={selectedBranch}
placeholder="main"
onSelect={v => onUpdate('selectedBranch', v)}
/>
<CustomCheckbox
checked={useWorktrees}
label="Use worktrees"
onChange={v => onUpdate('useWorktrees', v)}
/>
{/if}
<label class="wz-label">Group</label>
<div class="wz-dd" style="position:relative;">
<button class="wz-dd-btn" onclick={() => { closeAllDD(); groupDDOpen = !groupDDOpen; }}>
<span>{groupLabel}</span><span class="wz-dd-chev">&#9662;</span>
</button>
<div class="wz-dd-menu" style:display={groupDDOpen ? 'block' : 'none'}>
{#each groups as g}
<button class="wz-dd-item" class:active={selectedGroupId === g.id}
onclick={() => { onUpdate('selectedGroupId', g.id); groupDDOpen = false; }}>{g.name}</button>
{/each}
</div>
</div>
<CustomDropdown
items={groups.map(g => ({ value: g.id, label: g.name }))}
selected={selectedGroupId}
placeholder="Select group"
onSelect={v => onUpdate('selectedGroupId', v)}
/>
<label class="wz-label">Icon</label>
<div class="wz-icon-grid">
@ -106,17 +90,11 @@
</div>
<label class="wz-label">Shell</label>
<div class="wz-dd" style="position:relative;">
<button class="wz-dd-btn" onclick={() => { closeAllDD(); shellDDOpen = !shellDDOpen; }}>
<span>{shellChoice}</span><span class="wz-dd-chev">&#9662;</span>
</button>
<div class="wz-dd-menu" style:display={shellDDOpen ? 'block' : 'none'}>
{#each SHELLS as sh}
<button class="wz-dd-item" class:active={shellChoice === sh}
onclick={() => { onUpdate('shellChoice', sh); shellDDOpen = false; }}>{sh}</button>
{/each}
</div>
</div>
<CustomDropdown
items={SHELLS.map(sh => ({ value: sh, label: sh }))}
selected={shellChoice}
onSelect={v => onUpdate('shellChoice', v)}
/>
<style>
.wz-label { font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0); margin-top: 0.25rem; }
@ -126,15 +104,6 @@
.wz-input::placeholder { color: var(--ctp-overlay0); }
.wz-hint { font-size: 0.6875rem; }
.wz-hint.error { color: var(--ctp-red); }
.wz-dd { width: 100%; }
.wz-dd-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); cursor: pointer; text-align: left; }
.wz-dd-btn:hover { border-color: var(--ctp-surface2); }
.wz-dd-chev { color: var(--ctp-overlay1); font-size: 0.75rem; }
.wz-dd-menu { position: absolute; top: 100%; left: 0; right: 0; z-index: 20; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; margin-top: 0.125rem; max-height: 10rem; overflow-y: auto; box-shadow: 0 0.25rem 0.75rem color-mix(in srgb, var(--ctp-crust) 50%, transparent); }
.wz-dd-item { width: 100%; padding: 0.3125rem 0.5rem; background: none; border: none; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); cursor: pointer; text-align: left; }
.wz-dd-item:hover { background: var(--ctp-surface0); }
.wz-dd-item.active { color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); }
.wz-toggle-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; color: var(--ctp-text); cursor: pointer; margin-top: 0.25rem; }
.wz-icon-grid { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.wz-icon-btn { width: 2rem; height: 2rem; border-radius: 0.25rem; border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0); cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--ctp-subtext0); }
.wz-icon-btn:hover { border-color: var(--ctp-overlay1); color: var(--ctp-text); }
@ -143,7 +112,4 @@
.wz-color-dot { width: 1.5rem; height: 1.5rem; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color 0.12s, transform 0.12s; }
.wz-color-dot:hover { transform: scale(1.15); }
.wz-color-dot.selected { border-color: var(--ctp-text); box-shadow: 0 0 0 1px var(--ctp-surface0); }
input[type="checkbox"] { -webkit-appearance: none; appearance: none; width: 1rem; height: 1rem; border: 1px solid var(--ctp-surface2); border-radius: 0.1875rem; background: var(--ctp-surface0); cursor: pointer; position: relative; vertical-align: middle; flex-shrink: 0; }
input[type="checkbox"]:checked { background: var(--ctp-blue); border-color: var(--ctp-blue); }
input[type="checkbox"]:checked::after { content: ''; position: absolute; left: 0.25rem; top: 0.0625rem; width: 0.3125rem; height: 0.5625rem; border: solid var(--ctp-base); border-width: 0 2px 2px 0; transform: rotate(45deg); }
</style>

View file

@ -1,5 +1,9 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
import CustomDropdown from './ui/CustomDropdown.svelte';
import CustomCheckbox from './ui/CustomCheckbox.svelte';
import ModelConfigPanel from './ModelConfigPanel.svelte';
import type { ProviderId } from './provider-capabilities';
interface ProviderInfo {
id: string;
@ -25,23 +29,24 @@
detectedProviders: ProviderInfo[];
providerModels: ModelInfo[];
modelsLoading: boolean;
modelConfig: Record<string, unknown>;
onUpdate: (field: string, value: unknown) => void;
}
let {
provider, model, permissionMode, systemPrompt, autoStart,
detectedProviders, providerModels, modelsLoading, onUpdate,
detectedProviders, providerModels, modelsLoading, modelConfig, onUpdate,
}: Props = $props();
let modelDDOpen = $state(false);
let availableProviders = $derived(
detectedProviders.length > 0
? detectedProviders.filter(p => p.available)
: [{ id: 'claude' }, { id: 'codex' }, { id: 'ollama' }] as ProviderInfo[]
: [{ id: 'claude' }, { id: 'codex' }, { id: 'ollama' }, { id: 'gemini' }] as ProviderInfo[]
);
let modelLabel = $derived(model || 'Select model...');
let modelItems = $derived(
providerModels.map(m => ({ value: m.id, label: m.name || m.id }))
);
function providerBadge(p: ProviderInfo): string {
const parts: string[] = [];
@ -69,7 +74,7 @@
<div class="wz-provider-grid">
{#each availableProviders as p}
<button class="wz-provider-card" class:active={provider === p.id}
onclick={() => onUpdate('provider', p.id)}>
onclick={() => { onUpdate('provider', p.id); onUpdate('modelConfig', {}); }}>
<span class="wz-provider-name">{p.id}</span>
{#if 'hasApiKey' in p}
<span class="wz-provider-badge">{providerBadge(p as ProviderInfo)}</span>
@ -80,20 +85,12 @@
<label class="wz-label">Model</label>
{#if providerModels.length > 0}
<div class="wz-dd" style="position:relative;">
<button class="wz-dd-btn" onclick={() => modelDDOpen = !modelDDOpen}>
<span>{modelLabel}</span>
<span class="wz-dd-chev">{modelsLoading ? '\u2026' : '\u25BE'}</span>
</button>
<div class="wz-dd-menu" style:display={modelDDOpen ? 'block' : 'none'}>
{#each providerModels as m}
<button class="wz-dd-item" class:active={model === m.id}
onclick={() => { onUpdate('model', m.id); modelDDOpen = false; }}>
{m.name || m.id}
</button>
{/each}
</div>
</div>
<CustomDropdown
items={modelItems}
selected={model}
placeholder={modelsLoading ? 'Loading...' : 'Select model...'}
onSelect={v => onUpdate('model', v)}
/>
{:else}
<input class="wz-input" type="text" value={model}
oninput={(e) => onUpdate('model', (e.target as HTMLInputElement).value)}
@ -101,6 +98,14 @@
{#if modelsLoading}<span class="wz-hint" style="color: var(--ctp-blue);">Loading models&hellip;</span>{/if}
{/if}
<!-- Per-model configuration -->
<ModelConfigPanel
provider={provider as ProviderId}
{model}
config={modelConfig}
onChange={c => onUpdate('modelConfig', c)}
/>
<label class="wz-label">Permission mode</label>
<div class="wz-segmented">
{#each ['restricted', 'default', 'bypassPermissions'] as pm}
@ -114,11 +119,11 @@
oninput={(e) => onUpdate('systemPrompt', (e.target as HTMLTextAreaElement).value)}
rows="3" placeholder="Optional system instructions..."></textarea>
<label class="wz-toggle-row">
<input type="checkbox" checked={autoStart}
onchange={(e) => onUpdate('autoStart', (e.target as HTMLInputElement).checked)} />
<span>Auto-start agent on create</span>
</label>
<CustomCheckbox
checked={autoStart}
label="Auto-start agent on create"
onChange={v => onUpdate('autoStart', v)}
/>
<style>
.wz-label { font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0); margin-top: 0.25rem; }
@ -134,21 +139,9 @@
.wz-provider-card.active { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); }
.wz-provider-name { font-size: 0.8125rem; font-weight: 600; color: var(--ctp-text); text-transform: capitalize; }
.wz-provider-badge { font-size: 0.5625rem; color: var(--ctp-subtext0); }
.wz-dd { width: 100%; }
.wz-dd-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); cursor: pointer; text-align: left; }
.wz-dd-btn:hover { border-color: var(--ctp-surface2); }
.wz-dd-chev { color: var(--ctp-overlay1); font-size: 0.75rem; }
.wz-dd-menu { position: absolute; top: 100%; left: 0; right: 0; z-index: 20; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; margin-top: 0.125rem; max-height: 12rem; overflow-y: auto; box-shadow: 0 0.25rem 0.75rem color-mix(in srgb, var(--ctp-crust) 50%, transparent); }
.wz-dd-item { width: 100%; padding: 0.3125rem 0.5rem; background: none; border: none; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); cursor: pointer; text-align: left; }
.wz-dd-item:hover { background: var(--ctp-surface0); }
.wz-dd-item.active { color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); }
.wz-segmented { display: flex; gap: 0; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
.wz-seg-btn { flex: 1; padding: 0.375rem 0.5rem; font-size: 0.75rem; background: var(--ctp-surface0); border: none; color: var(--ctp-subtext0); cursor: pointer; font-family: var(--ui-font-family); border-right: 1px solid var(--ctp-surface1); }
.wz-seg-btn:last-child { border-right: none; }
.wz-seg-btn:hover { color: var(--ctp-text); }
.wz-seg-btn.active { background: color-mix(in srgb, var(--ctp-blue) 20%, transparent); color: var(--ctp-blue); }
.wz-toggle-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; color: var(--ctp-text); cursor: pointer; margin-top: 0.25rem; }
input[type="checkbox"] { -webkit-appearance: none; appearance: none; width: 1rem; height: 1rem; border: 1px solid var(--ctp-surface2); border-radius: 0.1875rem; background: var(--ctp-surface0); cursor: pointer; position: relative; vertical-align: middle; flex-shrink: 0; }
input[type="checkbox"]:checked { background: var(--ctp-blue); border-color: var(--ctp-blue); }
input[type="checkbox"]:checked::after { content: ''; position: absolute; left: 0.25rem; top: 0.0625rem; width: 0.3125rem; height: 0.5625rem; border: solid var(--ctp-base); border-width: 0 2px 2px 0; transform: rotate(45deg); }
</style>

View file

@ -110,7 +110,8 @@
<ChevronDown size={14} class="dd-chev {open ? 'dd-chev-open' : ''}" />
</button>
<div class="dd-backdrop" style:display={open ? 'block' : 'none'} onclick={handleBackdropClick}></div>
<!-- svelte-ignore a11y_no_static_element_interactions a11y-no-static-element-interactions -->
<div class="dd-backdrop" style:display={open ? 'block' : 'none'} onclick={handleBackdropClick} onkeydown={e => e.key === 'Escape' && close()}></div>
<div class="dd-menu" style:display={open ? 'flex' : 'none'} bind:this={menuRef} role="listbox">
{#if groupBy && groups().length > 0}

View file

@ -0,0 +1,68 @@
/**
* Wizard state management field update dispatch and default values.
*
* Extracted from ProjectWizard.svelte to keep it under 300 lines.
*/
export type SourceType = 'local' | 'git-clone' | 'github' | 'template' | 'remote';
export type AuthMethod = 'password' | 'key' | 'agent' | 'config';
export type PathState = 'idle' | 'checking' | 'valid' | 'invalid' | 'not-dir';
export type ProbeState = 'idle' | 'probing' | 'ok' | 'error';
export interface WizardState {
step: number;
sourceType: SourceType;
localPath: string;
repoUrl: string;
cloneTarget: string;
githubRepo: string;
selectedTemplate: string;
templateTargetDir: string;
remoteHost: string;
remoteUser: string;
remotePath: string;
remoteAuthMethod: AuthMethod;
remotePassword: string;
remoteKeyPath: string;
remoteSshfs: boolean;
pathValid: PathState;
isGitRepo: boolean;
gitBranch: string;
gitProbeStatus: ProbeState;
gitProbeBranches: string[];
githubInfo: { stars: number; description: string; defaultBranch: string } | null;
githubLoading: boolean;
cloning: boolean;
projectName: string;
nameError: string;
selectedBranch: string;
branches: string[];
useWorktrees: boolean;
selectedGroupId: string;
projectIcon: string;
projectColor: string;
shellChoice: string;
provider: string;
model: string;
permissionMode: string;
systemPrompt: string;
autoStart: boolean;
providerModels: Array<{ id: string; name: string; provider: string }>;
modelsLoading: boolean;
}
export function getDefaults(groupId: string): WizardState {
return {
step: 1, sourceType: 'local', localPath: '', repoUrl: '', cloneTarget: '',
githubRepo: '', selectedTemplate: '', templateTargetDir: '~/projects',
remoteHost: '', remoteUser: '', remotePath: '',
remoteAuthMethod: 'agent', remotePassword: '', remoteKeyPath: '~/.ssh/id_ed25519',
remoteSshfs: false, pathValid: 'idle', isGitRepo: false, gitBranch: '',
gitProbeStatus: 'idle', gitProbeBranches: [], githubInfo: null, githubLoading: false,
cloning: false, projectName: '', nameError: '', selectedBranch: '',
branches: [], useWorktrees: false, selectedGroupId: groupId,
projectIcon: 'Terminal', projectColor: 'var(--ctp-blue)', shellChoice: 'bash',
provider: 'claude', model: '', permissionMode: 'default',
systemPrompt: '', autoStart: false, providerModels: [], modelsLoading: false,
};
}