feat(electrobun): ProjectWizard — 3-step project creation with 5 source types

Step 1 — Source: local folder (path browser + validation), git clone,
GitHub URL, template (4 built-in), remote SSH
Step 2 — Configure: name, branch selector, worktree toggle, group, icon, shell
Step 3 — Agent: provider, model, permission mode, system prompt, auto-start

- ProjectWizard.svelte: 3-step wizard with display toggle (rule 55)
- PathBrowser.svelte: inline directory browser with breadcrumbs + shortcuts
- git-handlers.ts: git.branches + git.clone RPC handlers
- files.statEx RPC: path validation + git detection + writable check
- 39 new i18n keys, 172 total TranslationKey entries
- App.svelte: wizard overlay replaces simple add-project card
This commit is contained in:
Hibryda 2026-03-22 11:17:05 +01:00
parent 1d2975b07b
commit 45bca3b96f
9 changed files with 1203 additions and 45 deletions

View file

@ -8,6 +8,7 @@
import StatusBar from './StatusBar.svelte';
import SearchOverlay from './SearchOverlay.svelte';
import SplashScreen from './SplashScreen.svelte';
import ProjectWizard from './ProjectWizard.svelte';
import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts';
@ -71,34 +72,37 @@
]);
// ── Add/Remove project UI state ──────────────────────────────────
let showAddProject = $state(false);
let newProjectName = $state('');
let newProjectCwd = $state('');
let showWizard = $state(false);
let projectToDelete = $state<string | null>(null);
async function addProject() {
const name = newProjectName.trim();
const cwd = newProjectCwd.trim();
if (!name || !cwd) return;
const id = `p-${Date.now()}`;
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;
}) {
const accent = ACCENTS[PROJECTS.length % ACCENTS.length];
const project: Project = {
id, name, cwd, accent,
status: 'idle', costUsd: 0, tokens: 0, messages: [],
provider: 'claude', groupId: activeGroupId,
id: result.id,
name: result.name,
cwd: result.cwd,
accent,
status: 'idle',
costUsd: 0,
tokens: 0,
messages: [],
provider: result.provider ?? 'claude',
model: result.model,
groupId: result.groupId ?? activeGroupId,
};
PROJECTS = [...PROJECTS, project];
trackProject(id);
trackProject(project.id);
await appRpc.request['settings.setProject']({
id,
config: JSON.stringify(project),
appRpc.request['settings.setProject']({
id: project.id,
config: JSON.stringify({ ...project, ...result }),
}).catch(console.error);
showAddProject = false;
newProjectName = '';
newProjectCwd = '';
showWizard = false;
}
async function confirmDeleteProject() {
@ -357,7 +361,7 @@
switch (detail) {
case 'settings': settingsOpen = !settingsOpen; break;
case 'search': searchOpen = !searchOpen; break;
case 'new-project': showAddProject = true; break;
case 'new-project': showWizard = true; break;
case 'toggle-sidebar': settingsOpen = !settingsOpen; break;
default: console.log(`[palette] unhandled command: ${detail}`);
}
@ -439,7 +443,7 @@
<!-- Add project button -->
<button
class="sidebar-icon"
onclick={() => showAddProject = !showAddProject}
onclick={() => showWizard = !showWizard}
aria-label="Add project"
title="Add project"
>
@ -501,28 +505,14 @@
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
</div>
<!-- Add project card -->
<div class="add-card" role="listitem" style:display={showAddProject ? 'flex' : 'none'}>
<div class="add-card-form">
<input
class="add-input"
type="text"
placeholder="Project name"
bind:value={newProjectName}
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
/>
<input
class="add-input"
type="text"
placeholder="Working directory (e.g. ~/code/myproject)"
bind:value={newProjectCwd}
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
/>
<div class="add-card-actions">
<button class="add-cancel" onclick={() => showAddProject = false}>Cancel</button>
<button class="add-confirm" onclick={addProject}>Add</button>
</div>
</div>
<!-- Project wizard overlay (display toggle) -->
<div style:display={showWizard ? 'contents' : 'none'}>
<ProjectWizard
onClose={() => showWizard = false}
onCreated={handleWizardCreated}
groupId={activeGroupId}
groups={groups.map(g => ({ id: g.id, name: g.name }))}
/>
</div>
<!-- Delete project confirmation -->

View file

@ -0,0 +1,252 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
import { t } from './i18n.svelte.ts';
interface Props {
onSelect: (path: string) => void;
onClose: () => void;
}
let { onSelect, onClose }: Props = $props();
const HOME = '/home/' + (typeof window !== 'undefined' ? '' : '');
const SHORTCUTS = [
{ label: 'Home', path: '~' },
{ label: 'Desktop', path: '~/Desktop' },
{ label: 'Documents', path: '~/Documents' },
{ label: '/tmp', path: '/tmp' },
];
let currentPath = $state('~');
let entries = $state<Array<{ name: string; type: 'file' | 'dir'; size: number }>>([]);
let loading = $state(false);
let filter = $state('');
let error = $state('');
// Breadcrumb segments
let breadcrumbs = $derived(() => {
const parts = currentPath.split('/').filter(Boolean);
const crumbs: Array<{ label: string; path: string }> = [];
let acc = '';
for (const part of parts) {
acc += (acc === '' && currentPath.startsWith('~') && crumbs.length === 0) ? part : '/' + part;
if (crumbs.length === 0 && currentPath.startsWith('~')) acc = part;
crumbs.push({ label: part, path: acc.startsWith('~') ? acc : '/' + acc });
}
return crumbs;
});
let filteredEntries = $derived(
entries.filter(e => e.type === 'dir' && (filter === '' || e.name.toLowerCase().includes(filter.toLowerCase())))
);
async function loadDir(dirPath: string) {
loading = true;
error = '';
try {
const expandedPath = dirPath.replace(/^~/, process.env.HOME ?? '/home');
const result = await appRpc.request['files.list']({ path: expandedPath });
if (result?.error) {
error = result.error;
entries = [];
} else {
entries = result?.entries ?? [];
}
currentPath = dirPath;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
entries = [];
} finally {
loading = false;
}
}
function navigateTo(dirPath: string) {
filter = '';
loadDir(dirPath);
}
function selectFolder(name: string) {
const sep = currentPath.endsWith('/') ? '' : '/';
const newPath = currentPath + sep + name;
navigateTo(newPath);
}
function confirmSelect() {
onSelect(currentPath);
}
// Load initial directory
$effect(() => {
loadDir('~');
});
</script>
<div class="path-browser">
<!-- Header -->
<div class="pb-header">
<span class="pb-title">{t('wizard.step1.browse' as any)}</span>
<button class="pb-close" onclick={onClose} aria-label={t('common.close' as any)}></button>
</div>
<!-- Shortcuts -->
<div class="pb-shortcuts">
{#each SHORTCUTS as sc}
<button class="pb-shortcut" onclick={() => navigateTo(sc.path)}>{sc.label}</button>
{/each}
</div>
<!-- Breadcrumbs -->
<div class="pb-breadcrumbs">
{#each breadcrumbs() as crumb, i}
{#if i > 0}<span class="pb-sep">/</span>{/if}
<button class="pb-crumb" onclick={() => navigateTo(crumb.path)}>{crumb.label}</button>
{/each}
</div>
<!-- Filter -->
<input
class="pb-filter"
type="text"
placeholder="Filter..."
bind:value={filter}
/>
<!-- Directory list -->
<div class="pb-list">
{#if loading}
<div class="pb-loading">Loading...</div>
{:else if error}
<div class="pb-error">{error}</div>
{:else if filteredEntries.length === 0}
<div class="pb-empty">No directories</div>
{:else}
{#each filteredEntries as entry}
<button class="pb-entry" onclick={() => selectFolder(entry.name)}>
<span class="pb-icon">📁</span>
<span class="pb-name">{entry.name}</span>
</button>
{/each}
{/if}
</div>
<!-- Footer: current path + select -->
<div class="pb-footer">
<span class="pb-current" title={currentPath}>{currentPath}</span>
<button class="pb-select-btn" onclick={confirmSelect}>Select</button>
</div>
</div>
<style>
.path-browser {
position: absolute;
top: 2.5rem;
left: 0;
right: 0;
z-index: 10;
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
display: flex;
flex-direction: column;
max-height: 20rem;
box-shadow: 0 0.5rem 1.5rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
}
.pb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.pb-title { font-size: 0.75rem; font-weight: 600; color: var(--ctp-text); }
.pb-close {
background: none; border: none; color: var(--ctp-overlay1); cursor: pointer;
font-size: 0.75rem; padding: 0.125rem 0.25rem; border-radius: 0.25rem;
}
.pb-close:hover { color: var(--ctp-text); background: var(--ctp-surface0); }
.pb-shortcuts {
display: flex; gap: 0.25rem; padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.pb-shortcut {
padding: 0.125rem 0.375rem; font-size: 0.625rem; border-radius: 0.25rem;
background: var(--ctp-surface0); border: none; color: var(--ctp-subtext0);
cursor: pointer; font-family: var(--ui-font-family);
}
.pb-shortcut:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
.pb-breadcrumbs {
display: flex; align-items: center; gap: 0.125rem;
padding: 0.25rem 0.5rem; flex-wrap: wrap;
font-size: 0.6875rem; color: var(--ctp-subtext0);
}
.pb-sep { color: var(--ctp-overlay0); }
.pb-crumb {
background: none; border: none; color: var(--ctp-blue); cursor: pointer;
font-size: 0.6875rem; padding: 0; font-family: var(--ui-font-family);
}
.pb-crumb:hover { text-decoration: underline; }
.pb-filter {
margin: 0.25rem 0.5rem; padding: 0.25rem 0.375rem;
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem; color: var(--ctp-text);
font-size: 0.75rem; font-family: var(--ui-font-family);
}
.pb-filter:focus { outline: none; border-color: var(--ctp-blue); }
.pb-filter::placeholder { color: var(--ctp-overlay0); }
.pb-list {
flex: 1; min-height: 0; overflow-y: auto;
padding: 0.25rem 0;
}
.pb-list::-webkit-scrollbar { width: 0.25rem; }
.pb-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.125rem; }
.pb-entry {
display: flex; align-items: center; gap: 0.375rem;
width: 100%; padding: 0.25rem 0.5rem;
background: none; border: none; color: var(--ctp-text);
cursor: pointer; font-size: 0.75rem; text-align: left;
font-family: var(--ui-font-family);
}
.pb-entry:hover { background: var(--ctp-surface0); }
.pb-icon { font-size: 0.875rem; flex-shrink: 0; }
.pb-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pb-loading, .pb-error, .pb-empty {
padding: 1rem; text-align: center; font-size: 0.75rem;
color: var(--ctp-overlay0);
}
.pb-error { color: var(--ctp-red); }
.pb-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 0.375rem 0.5rem; border-top: 1px solid var(--ctp-surface0);
gap: 0.5rem;
}
.pb-current {
font-size: 0.6875rem; color: var(--ctp-subtext0);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1; min-width: 0;
}
.pb-select-btn {
padding: 0.25rem 0.625rem; font-size: 0.75rem; border-radius: 0.25rem;
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
border: 1px solid var(--ctp-blue); color: var(--ctp-blue);
cursor: pointer; font-family: var(--ui-font-family); flex-shrink: 0;
}
.pb-select-btn:hover { background: color-mix(in srgb, var(--ctp-blue) 35%, transparent); }
</style>

View file

@ -0,0 +1,626 @@
<script lang="ts">
import { t } from './i18n.svelte.ts';
import { appRpc } from './rpc.ts';
import PathBrowser from './PathBrowser.svelte';
interface ProjectResult {
id: string;
name: string;
cwd: string;
provider?: string;
model?: string;
systemPrompt?: string;
autoStart?: boolean;
groupId?: string;
useWorktrees?: boolean;
shell?: string;
icon?: string;
}
interface Props {
onClose: () => void;
onCreated: (project: ProjectResult) => void;
groupId: string;
groups: Array<{ id: string; name: string }>;
}
let { onClose, onCreated, groupId, groups }: Props = $props();
// ── Wizard step ─────────────────────────────────────────────
type SourceType = 'local' | 'git-clone' | 'github' | 'template' | 'remote';
let step = $state(1);
// ── Step 1: Source ──────────────────────────────────────────
let sourceType = $state<SourceType>('local');
let localPath = $state('');
let repoUrl = $state('');
let cloneTarget = $state('');
let githubRepo = $state('');
let selectedTemplate = $state('');
let remoteHost = $state('');
let remoteUser = $state('');
let remotePath = $state('');
// Path validation
let pathValid = $state<'idle' | 'checking' | 'valid' | 'invalid' | 'not-dir'>('idle');
let isGitRepo = $state(false);
let gitBranch = $state('');
let showBrowser = $state(false);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// ── Step 2: Configure ──────────────────────────────────────
let projectName = $state('');
let selectedBranch = $state('');
let branches = $state<string[]>([]);
let useWorktrees = $state(false);
let selectedGroupId = $state(groupId);
let projectIcon = $state('📁');
let shellChoice = $state('bash');
const SHELLS = ['bash', 'zsh', 'fish', 'sh'];
const EMOJI_GRID = ['📁', '🚀', '⚡', '🔧', '🌐', '📦', '🎯', '💡', '🔬', '🎨', '📊', '🛡️', '🤖', '🧪', '🏗️', '📝'];
// ── Step 3: Agent ──────────────────────────────────────────
let provider = $state<'claude' | 'codex' | 'ollama'>('claude');
let model = $state('');
let permissionMode = $state('default');
let systemPrompt = $state('');
let autoStart = $state(false);
let cloning = $state(false);
// ── Templates ───────────────────────────────────────────────
let templates = $state<Array<{ id: string; name: string; description: string; icon: string }>>([]);
$effect(() => {
if (sourceType === 'template' && templates.length === 0) {
appRpc.request['project.templates']({}).then(r => {
if (r?.templates) templates = r.templates;
}).catch(console.error);
}
});
// ── Path validation (debounced 300ms) ──────────────────────
function validatePath(p: string) {
if (debounceTimer) clearTimeout(debounceTimer);
if (!p.trim()) { pathValid = 'idle'; isGitRepo = false; return; }
pathValid = 'checking';
debounceTimer = setTimeout(async () => {
try {
const result = await appRpc.request['files.statEx']({ path: p });
if (!result?.exists) {
pathValid = 'invalid';
isGitRepo = false;
} else if (!result.isDirectory) {
pathValid = 'not-dir';
isGitRepo = false;
} else {
pathValid = 'valid';
isGitRepo = result.isGitRepo;
gitBranch = result.gitBranch ?? '';
// Auto-populate name from folder name
if (!projectName) {
const parts = p.replace(/\/+$/, '').split('/');
projectName = parts[parts.length - 1] || '';
}
}
} catch {
pathValid = 'invalid';
isGitRepo = false;
}
}, 300);
}
$effect(() => {
if (sourceType === 'local') validatePath(localPath);
});
// ── Branch loading ─────────────────────────────────────────
async function loadBranches(repoPath: string) {
try {
const result = await appRpc.request['git.branches']({ path: repoPath });
if (result?.branches) {
branches = result.branches;
selectedBranch = result.current || '';
}
} catch { /* ignore */ }
}
// ── Step navigation ────────────────────────────────────────
let step1Valid = $derived(() => {
switch (sourceType) {
case 'local': return pathValid === 'valid';
case 'git-clone': return repoUrl.trim().length > 0 && cloneTarget.trim().length > 0;
case 'github': return /^[\w.-]+\/[\w.-]+$/.test(githubRepo.trim());
case 'template': return selectedTemplate !== '';
case 'remote': return remoteHost.trim().length > 0 && remoteUser.trim().length > 0 && remotePath.trim().length > 0;
default: return false;
}
});
async function goToStep2() {
if (sourceType === 'git-clone') {
cloning = true;
try {
const result = await appRpc.request['git.clone']({
url: repoUrl.trim(),
target: cloneTarget.trim(),
});
if (!result?.ok) {
cloning = false;
return; // stay on step 1
}
localPath = cloneTarget.trim();
isGitRepo = true;
} catch {
cloning = false;
return;
}
cloning = false;
} else if (sourceType === 'github') {
const url = `https://github.com/${githubRepo.trim()}.git`;
const target = `~/projects/${githubRepo.trim().split('/')[1] || 'project'}`;
cloning = true;
try {
const result = await appRpc.request['git.clone']({ url, target });
if (!result?.ok) { cloning = false; return; }
localPath = target;
isGitRepo = true;
} catch { cloning = false; return; }
cloning = false;
}
// Auto-populate name
if (!projectName && localPath) {
const parts = localPath.replace(/\/+$/, '').split('/');
projectName = parts[parts.length - 1] || '';
}
// Load branches if git repo
if (isGitRepo && localPath) {
await loadBranches(localPath);
}
step = 2;
}
function goToStep3() { step = 3; }
function goBack() { step = Math.max(1, step - 1); }
async function createProject() {
const cwd = sourceType === 'remote'
? `ssh://${remoteUser}@${remoteHost}:${remotePath}`
: localPath.trim();
const project: ProjectResult = {
id: `p-${Date.now()}`,
name: projectName.trim() || 'Untitled',
cwd,
provider,
model: model || undefined,
systemPrompt: systemPrompt || undefined,
autoStart,
groupId: selectedGroupId,
useWorktrees: useWorktrees || undefined,
shell: shellChoice,
icon: projectIcon,
};
onCreated(project);
}
function handleBrowserSelect(path: string) {
localPath = path;
showBrowser = false;
validatePath(path);
}
// Validation indicator
function validationIcon(state: typeof pathValid): string {
switch (state) {
case 'valid': return '✓';
case 'invalid': return '✗';
case 'not-dir': return '⚠';
case 'checking': return '…';
default: return '';
}
}
function validationColor(state: typeof pathValid): string {
switch (state) {
case 'valid': return 'var(--ctp-green)';
case 'invalid': return 'var(--ctp-red)';
case 'not-dir': return 'var(--ctp-peach)';
default: return 'var(--ctp-overlay0)';
}
}
</script>
<div class="wizard-overlay" role="dialog" aria-label={t('wizard.title' as any)}>
<div class="wizard-panel">
<!-- Header -->
<div class="wz-header">
<h2 class="wz-title">{t('wizard.title' as any)}</h2>
<div class="wz-steps">
{#each [1, 2, 3] as s}
<span class="wz-step-dot" class:active={step === s} class:done={step > s}>{s}</span>
{/each}
</div>
<button class="wz-close" onclick={onClose} aria-label={t('common.close' as any)}></button>
</div>
<!-- Step 1: Source -->
<div class="wz-body" style:display={step === 1 ? 'flex' : 'none'}>
<h3 class="wz-step-title">{t('wizard.step1.title' as any)}</h3>
<!-- Source type radios -->
<div class="wz-radios">
{#each [
{ value: 'local', label: t('wizard.step1.local' as any), icon: '📁' },
{ value: 'git-clone', label: t('wizard.step1.gitClone' as any), icon: '🔀' },
{ value: 'github', label: t('wizard.step1.github' as any), icon: '🐙' },
{ value: 'template', label: t('wizard.step1.template' as any), icon: '📋' },
{ value: 'remote', label: t('wizard.step1.remote' as any), icon: '🖥️' },
] as opt}
<label class="wz-radio" class:selected={sourceType === opt.value}>
<input type="radio" name="source" value={opt.value}
checked={sourceType === opt.value}
onchange={() => sourceType = opt.value as SourceType} />
<span class="wz-radio-icon">{opt.icon}</span>
<span>{opt.label}</span>
</label>
{/each}
</div>
<!-- Source-specific inputs -->
<div class="wz-source-fields">
{#if sourceType === 'local'}
<label class="wz-label">{t('wizard.step1.pathLabel' as any)}</label>
<div class="wz-path-row" style="position: relative;">
<input class="wz-input" type="text"
placeholder={t('wizard.step1.pathPlaceholder' as any)}
bind:value={localPath}
oninput={() => validatePath(localPath)} />
<button class="wz-browse-btn" onclick={() => showBrowser = !showBrowser}
aria-label={t('wizard.step1.browse' as any)}>🔍</button>
{#if pathValid !== 'idle'}
<span class="wz-validation" style:color={validationColor(pathValid)}>
{validationIcon(pathValid)}
</span>
{/if}
{#if showBrowser}
<PathBrowser onSelect={handleBrowserSelect} onClose={() => showBrowser = false} />
{/if}
</div>
{#if pathValid === 'valid' && isGitRepo}
<span class="wz-badge git-badge">{t('wizard.step1.gitDetected' as any)} ({gitBranch})</span>
{/if}
{#if pathValid === 'invalid'}
<span class="wz-hint error">{t('wizard.step1.invalidPath' as any)}</span>
{/if}
{#if pathValid === 'not-dir'}
<span class="wz-hint warn">{t('wizard.step1.notDir' as any)}</span>
{/if}
{:else if sourceType === 'git-clone'}
<label class="wz-label">{t('wizard.step1.repoUrl' as any)}</label>
<input class="wz-input" type="text" placeholder="https://github.com/user/repo.git" bind:value={repoUrl} />
<label class="wz-label">{t('wizard.step1.targetDir' as any)}</label>
<input class="wz-input" type="text" placeholder="~/projects/my-repo" bind:value={cloneTarget} />
{:else if sourceType === 'github'}
<label class="wz-label">{t('wizard.step1.githubRepo' as any)}</label>
<input class="wz-input" type="text" placeholder="owner/repo" bind:value={githubRepo} />
{:else if sourceType === 'template'}
<div class="wz-template-grid">
{#each templates as tmpl}
<button class="wz-template-card" class:selected={selectedTemplate === tmpl.id}
onclick={() => selectedTemplate = tmpl.id}>
<span class="wz-template-icon">{tmpl.icon}</span>
<span class="wz-template-name">{tmpl.name}</span>
<span class="wz-template-desc">{tmpl.description}</span>
</button>
{/each}
</div>
{:else if sourceType === 'remote'}
<label class="wz-label">{t('wizard.step1.hostLabel' as any)}</label>
<input class="wz-input" type="text" placeholder="192.168.1.100" bind:value={remoteHost} />
<label class="wz-label">{t('wizard.step1.userLabel' as any)}</label>
<input class="wz-input" type="text" placeholder="user" bind:value={remoteUser} />
<label class="wz-label">{t('wizard.step1.pathLabel' as any)}</label>
<input class="wz-input" type="text" placeholder="/home/user/project" bind:value={remotePath} />
{/if}
</div>
<!-- Step 1 footer -->
<div class="wz-footer">
<button class="wz-btn secondary" onclick={onClose}>{t('common.cancel' as any)}</button>
<div class="wz-footer-right">
{#if cloning}
<span class="wz-cloning">{t('wizard.cloning' as any)}</span>
{/if}
<button class="wz-btn primary" disabled={!step1Valid() || cloning} onclick={goToStep2}>
{t('wizard.next' as any)}
</button>
</div>
</div>
</div>
<!-- Step 2: Configure -->
<div class="wz-body" style:display={step === 2 ? 'flex' : 'none'}>
<h3 class="wz-step-title">{t('wizard.step2.title' as any)}</h3>
<label class="wz-label">{t('wizard.step2.name' as any)}</label>
<input class="wz-input" type="text" bind:value={projectName} placeholder="my-project" />
{#if isGitRepo && branches.length > 0}
<label class="wz-label">{t('wizard.step2.branch' as any)}</label>
<select class="wz-select" bind:value={selectedBranch}>
{#each branches as br}
<option value={br}>{br}</option>
{/each}
</select>
<label class="wz-toggle-row">
<input type="checkbox" bind:checked={useWorktrees} />
<span>{t('wizard.step2.worktree' as any)}</span>
</label>
{/if}
<label class="wz-label">{t('wizard.step2.group' as any)}</label>
<select class="wz-select" bind:value={selectedGroupId}>
{#each groups as g}
<option value={g.id}>{g.name}</option>
{/each}
</select>
<label class="wz-label">{t('wizard.step2.icon' as any)}</label>
<div class="wz-emoji-grid">
{#each EMOJI_GRID as emoji}
<button class="wz-emoji" class:selected={projectIcon === emoji}
onclick={() => projectIcon = emoji}>{emoji}</button>
{/each}
</div>
<label class="wz-label">{t('wizard.step2.shell' as any)}</label>
<select class="wz-select" bind:value={shellChoice}>
{#each SHELLS as sh}
<option value={sh}>{sh}</option>
{/each}
</select>
<div class="wz-footer">
<button class="wz-btn secondary" onclick={goBack}>{t('wizard.back' as any)}</button>
<button class="wz-btn primary" onclick={goToStep3}>{t('wizard.next' as any)}</button>
</div>
</div>
<!-- Step 3: Agent -->
<div class="wz-body" style:display={step === 3 ? 'flex' : 'none'}>
<h3 class="wz-step-title">{t('wizard.step3.title' as any)}</h3>
<label class="wz-label">{t('wizard.step3.provider' as any)}</label>
<div class="wz-segmented">
{#each (['claude', 'codex', 'ollama'] as const) as prov}
<button class="wz-seg-btn" class:active={provider === prov}
onclick={() => provider = prov}>{prov}</button>
{/each}
</div>
<label class="wz-label">{t('wizard.step3.model' as any)}</label>
<input class="wz-input" type="text" bind:value={model}
placeholder={provider === 'claude' ? 'claude-sonnet-4-20250514' : provider === 'codex' ? 'gpt-5.4' : 'qwen3:8b'} />
<label class="wz-label">{t('wizard.step3.permission' as any)}</label>
<div class="wz-segmented">
{#each ['restricted', 'default', 'bypassPermissions'] as pm}
<button class="wz-seg-btn" class:active={permissionMode === pm}
onclick={() => permissionMode = pm}>{pm}</button>
{/each}
</div>
<label class="wz-label">{t('wizard.step3.systemPrompt' as any)}</label>
<textarea class="wz-textarea" bind:value={systemPrompt} rows="3"
placeholder="Optional system instructions..."></textarea>
<label class="wz-toggle-row">
<input type="checkbox" bind:checked={autoStart} />
<span>{t('wizard.step3.autoStart' as any)}</span>
</label>
<div class="wz-footer">
<button class="wz-btn secondary" onclick={goBack}>{t('wizard.back' as any)}</button>
<div class="wz-footer-right">
<button class="wz-btn ghost" onclick={createProject}>{t('wizard.skip' as any)}</button>
<button class="wz-btn primary" onclick={createProject}>{t('wizard.create' as any)}</button>
</div>
</div>
</div>
</div>
</div>
<style>
.wizard-overlay {
position: fixed; inset: 0; z-index: 100;
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
display: flex; align-items: center; justify-content: center;
}
.wizard-panel {
width: min(32rem, 90vw);
max-height: 85vh;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.75rem;
display: flex; flex-direction: column;
overflow: hidden;
box-shadow: 0 1rem 3rem color-mix(in srgb, var(--ctp-crust) 50%, transparent);
}
.wz-header {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.wz-title { font-size: 1rem; font-weight: 700; color: var(--ctp-text); margin: 0; flex: 1; }
.wz-steps { display: flex; gap: 0.375rem; }
.wz-step-dot {
width: 1.5rem; height: 1.5rem; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.6875rem; font-weight: 600;
background: var(--ctp-surface0); color: var(--ctp-subtext0);
border: 1.5px solid var(--ctp-surface1);
}
.wz-step-dot.active { background: color-mix(in srgb, var(--ctp-blue) 20%, transparent); border-color: var(--ctp-blue); color: var(--ctp-blue); }
.wz-step-dot.done { background: color-mix(in srgb, var(--ctp-green) 20%, transparent); border-color: var(--ctp-green); color: var(--ctp-green); }
.wz-close {
background: none; border: none; color: var(--ctp-overlay1); cursor: pointer;
font-size: 0.875rem; padding: 0.25rem; border-radius: 0.25rem;
}
.wz-close:hover { color: var(--ctp-text); background: var(--ctp-surface0); }
.wz-body {
flex: 1; min-height: 0; overflow-y: auto;
flex-direction: column; gap: 0.5rem;
padding: 1rem;
}
.wz-step-title { font-size: 0.875rem; font-weight: 600; color: var(--ctp-text); margin: 0 0 0.25rem; }
.wz-label { font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0); margin-top: 0.25rem; }
.wz-input, .wz-select, .wz-textarea {
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);
width: 100%;
}
.wz-input:focus, .wz-select:focus, .wz-textarea:focus { outline: none; border-color: var(--ctp-blue); }
.wz-input::placeholder, .wz-textarea::placeholder { color: var(--ctp-overlay0); }
.wz-textarea { resize: vertical; min-height: 3rem; }
.wz-select { cursor: pointer; }
.wz-radios { display: flex; flex-wrap: wrap; gap: 0.375rem; }
.wz-radio {
display: flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.625rem; border-radius: 0.375rem;
border: 1px solid var(--ctp-surface1); cursor: pointer;
font-size: 0.75rem; color: var(--ctp-subtext0);
transition: border-color 0.12s, background 0.12s;
}
.wz-radio:hover { border-color: var(--ctp-overlay1); color: var(--ctp-text); }
.wz-radio.selected { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); color: var(--ctp-text); }
.wz-radio input[type="radio"] { display: none; }
.wz-radio-icon { font-size: 1rem; }
.wz-path-row { display: flex; gap: 0.375rem; align-items: center; }
.wz-path-row .wz-input { flex: 1; }
.wz-browse-btn {
padding: 0.375rem; background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
cursor: pointer; font-size: 0.875rem; flex-shrink: 0;
}
.wz-browse-btn:hover { background: var(--ctp-surface1); }
.wz-validation { font-size: 0.875rem; font-weight: 700; flex-shrink: 0; }
.wz-badge {
display: inline-flex; align-items: center; gap: 0.25rem;
padding: 0.125rem 0.5rem; border-radius: 0.25rem;
font-size: 0.6875rem; width: fit-content;
}
.git-badge {
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
color: var(--ctp-green); border: 1px solid color-mix(in srgb, var(--ctp-green) 30%, transparent);
}
.wz-hint { font-size: 0.6875rem; }
.wz-hint.error { color: var(--ctp-red); }
.wz-hint.warn { color: var(--ctp-peach); }
.wz-source-fields { display: flex; flex-direction: column; gap: 0.375rem; margin-top: 0.25rem; }
.wz-template-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.375rem; }
.wz-template-card {
display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
padding: 0.75rem 0.5rem; border-radius: 0.375rem;
border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0);
cursor: pointer; text-align: center;
}
.wz-template-card:hover { border-color: var(--ctp-overlay1); }
.wz-template-card.selected { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); }
.wz-template-icon { font-size: 1.5rem; }
.wz-template-name { font-size: 0.75rem; font-weight: 600; color: var(--ctp-text); }
.wz-template-desc { font-size: 0.625rem; color: var(--ctp-subtext0); }
.wz-emoji-grid { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.wz-emoji {
width: 2rem; height: 2rem; border-radius: 0.25rem;
border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0);
cursor: pointer; font-size: 1rem;
display: flex; align-items: center; justify-content: center;
}
.wz-emoji:hover { border-color: var(--ctp-overlay1); }
.wz-emoji.selected { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 15%, 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-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-footer {
display: flex; align-items: center; justify-content: space-between;
padding-top: 0.75rem; margin-top: auto;
border-top: 1px solid var(--ctp-surface0);
}
.wz-footer-right { display: flex; gap: 0.375rem; align-items: center; }
.wz-cloning { font-size: 0.75rem; color: var(--ctp-blue); font-style: italic; }
.wz-btn {
padding: 0.375rem 0.75rem; border-radius: 0.25rem;
font-size: 0.8125rem; font-weight: 500; cursor: pointer;
font-family: var(--ui-font-family); border: 1px solid transparent;
}
.wz-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.wz-btn.primary {
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
border-color: var(--ctp-blue); color: var(--ctp-blue);
}
.wz-btn.primary:hover:not(:disabled) { background: color-mix(in srgb, var(--ctp-blue) 35%, transparent); }
.wz-btn.secondary {
background: transparent; border-color: var(--ctp-surface1); color: var(--ctp-subtext0);
}
.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

@ -137,4 +137,42 @@ export type TranslationKey =
| 'terminal.closeTab'
| 'terminal.collapse'
| 'terminal.expand'
| 'terminal.shell';
| 'terminal.shell'
| 'wizard.back'
| 'wizard.cloning'
| 'wizard.create'
| 'wizard.next'
| 'wizard.skip'
| 'wizard.step1.browse'
| 'wizard.step1.gitClone'
| 'wizard.step1.gitDetected'
| 'wizard.step1.github'
| 'wizard.step1.githubRepo'
| 'wizard.step1.hostLabel'
| 'wizard.step1.invalidPath'
| 'wizard.step1.local'
| 'wizard.step1.notDir'
| 'wizard.step1.pathLabel'
| 'wizard.step1.pathPlaceholder'
| 'wizard.step1.remote'
| 'wizard.step1.repoUrl'
| 'wizard.step1.targetDir'
| 'wizard.step1.template'
| 'wizard.step1.title'
| 'wizard.step1.userLabel'
| 'wizard.step1.validDir'
| 'wizard.step2.branch'
| 'wizard.step2.group'
| 'wizard.step2.icon'
| 'wizard.step2.name'
| 'wizard.step2.newGroup'
| 'wizard.step2.shell'
| 'wizard.step2.title'
| 'wizard.step2.worktree'
| 'wizard.step3.autoStart'
| 'wizard.step3.model'
| 'wizard.step3.permission'
| 'wizard.step3.provider'
| 'wizard.step3.systemPrompt'
| 'wizard.step3.title'
| 'wizard.title';