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
This commit is contained in:
Hibryda 2026-03-23 13:05:07 +01:00
parent b7fc3a0f9b
commit d4014a193d
25 changed files with 2112 additions and 759 deletions

View file

@ -9,6 +9,7 @@
{ id: 'claude', label: 'Claude', desc: 'Anthropic — claude-opus/sonnet/haiku' },
{ id: 'codex', label: 'Codex', desc: 'OpenAI — gpt-5.4' },
{ id: 'ollama', label: 'Ollama', desc: 'Local — qwen3, llama3, etc.' },
{ id: 'gemini', label: 'Gemini', desc: 'Google — gemini-2.5-pro' },
];
let defaultShell = $state('/bin/bash');
@ -21,6 +22,7 @@
claude: { enabled: true, model: 'claude-opus-4-5' },
codex: { enabled: false, model: 'gpt-5.4' },
ollama: { enabled: false, model: 'qwen3:8b' },
gemini: { enabled: false, model: 'gemini-2.5-pro' },
});
let expandedProvider = $state<string | null>(null);

View file

@ -6,6 +6,7 @@
import { fontStore } from '../font-store.svelte.ts';
import { t, getLocale, setLocale, AVAILABLE_LOCALES } from '../i18n.svelte.ts';
import ThemeEditor from './ThemeEditor.svelte';
import CustomDropdown from '../ui/CustomDropdown.svelte';
const UI_FONTS = [
{ value: '', label: 'System Default' },
@ -47,18 +48,13 @@
$effect(() => { termFont = fontStore.termFontFamily; });
$effect(() => { termFontSize = fontStore.termFontSize; });
// ── Dropdown open state ────────────────────────────────────────────────────
let themeOpen = $state(false);
let uiFontOpen = $state(false);
let termFontOpen = $state(false);
let langOpen = $state(false);
// ── Dropdown open state (managed by CustomDropdown now) ────────────────────
// ── Language ──────────────────────────────────────────────────────────────
let currentLocale = $derived(getLocale());
let langLabel = $derived(AVAILABLE_LOCALES.find(l => l.tag === currentLocale)?.nativeLabel ?? 'English');
function selectLang(tag: string): void {
langOpen = false;
setLocale(tag);
}
@ -69,26 +65,27 @@
]);
let allGroups = $derived([...THEME_GROUPS, ...(customThemes.length > 0 ? ['Custom'] : [])]);
// ── Derived labels ─────────────────────────────────────────────────────────
let themeLabel = $derived(allThemes.find(t => t.id === themeId)?.label ?? 'Catppuccin Mocha');
let uiFontLabel = $derived(UI_FONTS.find(f => f.value === uiFont)?.label ?? 'System Default');
let termFontLabel = $derived(TERM_FONTS.find(f => f.value === termFont)?.label ?? 'Default (JetBrains Mono)');
// ── Dropdown items for CustomDropdown ──────────────────────────────────────
let themeItems = $derived(allThemes.map(t => ({ value: t.id, label: t.label, group: t.group })));
let uiFontItems = UI_FONTS.map(f => ({ value: f.value, label: f.label }));
let termFontItems = TERM_FONTS.map(f => ({ value: f.value, label: f.label }));
let langItems = AVAILABLE_LOCALES.map(l => ({ value: l.tag, label: l.nativeLabel }));
// ── Actions ────────────────────────────────────────────────────────────────
function selectTheme(id: ThemeId): void {
themeId = id; themeOpen = false;
themeId = id;
themeStore.setTheme(id);
appRpc?.request['settings.set']({ key: 'theme', value: id }).catch(console.error);
}
function selectUIFont(value: string): void {
uiFont = value; uiFontOpen = false;
uiFont = value;
fontStore.setUIFont(value, uiFontSize);
}
function selectTermFont(value: string): void {
termFont = value; termFontOpen = false;
termFont = value;
fontStore.setTermFont(value, termFontSize);
}
@ -117,10 +114,7 @@
appRpc?.request['settings.set']({ key: 'scrollback', value: String(v) }).catch(console.error);
}
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; langOpen = false; }
function handleOutsideClick(e: MouseEvent): void {
if (!(e.target as HTMLElement).closest('.dd-wrap')) closeAll();
}
// CustomDropdown handles its own open/close state
async function deleteCustomTheme(id: string) {
await appRpc?.request['themes.deleteCustom']({ id }).catch(console.error);
@ -148,8 +142,7 @@
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="section" onclick={handleOutsideClick} onkeydown={e => e.key === 'Escape' && closeAll()}>
<div class="section">
{#if showEditor}
<ThemeEditor
@ -162,58 +155,27 @@
<h3 class="sh">{t('settings.theme')}</h3>
<div class="field">
<div class="dd-wrap">
<button class="dd-btn" onclick={() => { themeOpen = !themeOpen; uiFontOpen = false; termFontOpen = false; }}>
{themeLabel}
<svg class="chev" class:open={themeOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{#if themeOpen}
<ul class="dd-list" role="listbox">
{#each allGroups as group}
<li class="dd-group-label" role="presentation">{group}</li>
{#each allThemes.filter(t => t.group === group) as t}
<li class="dd-item" class:sel={themeId === t.id}
role="option" aria-selected={themeId === t.id} tabindex="0"
onclick={() => selectTheme(t.id)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTheme(t.id)}
>
<span class="dd-item-label">{t.label}</span>
{#if t.group === 'Custom'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="del-btn" title="Delete theme"
onclick={e => { e.stopPropagation(); deleteCustomTheme(t.id); }}
onkeydown={e => e.key === 'Enter' && (e.stopPropagation(), deleteCustomTheme(t.id))}
role="button" tabindex="0" aria-label="Delete {t.label}">✕</span>
{/if}
</li>
{/each}
{/each}
</ul>
{/if}
</div>
<CustomDropdown
items={themeItems}
selected={themeId}
onSelect={v => selectTheme(v as ThemeId)}
groupBy={true}
/>
<div class="theme-actions">
<button class="theme-action-btn" onclick={() => { themeOpen = false; showEditor = true; }}>Edit Theme</button>
<button class="theme-action-btn" onclick={() => { themeOpen = false; showEditor = true; }}>+ Custom</button>
<button class="theme-action-btn" onclick={() => showEditor = true}>Edit Theme</button>
<button class="theme-action-btn" onclick={() => showEditor = true}>+ Custom</button>
</div>
</div>
<h3 class="sh">{t('settings.uiFont')}</h3>
<div class="field row">
<div class="dd-wrap flex1">
<button class="dd-btn" onclick={() => { uiFontOpen = !uiFontOpen; themeOpen = false; termFontOpen = false; }}>
{uiFontLabel}
<svg class="chev" class:open={uiFontOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{#if uiFontOpen}
<ul class="dd-list" role="listbox">
{#each UI_FONTS as f}
<li class="dd-item" class:sel={uiFont === f.value} role="option" aria-selected={uiFont === f.value}
tabindex="0" onclick={() => selectUIFont(f.value)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectUIFont(f.value)}
>{f.label}</li>
{/each}
</ul>
{/if}
<div class="flex1">
<CustomDropdown
items={uiFontItems}
selected={uiFont}
onSelect={v => selectUIFont(v)}
placeholder="System Default"
/>
</div>
<div class="stepper">
<button onclick={() => adjustUISize(-1)} aria-label="Decrease UI font size"></button>
@ -224,21 +186,13 @@
<h3 class="sh">{t('settings.termFont')}</h3>
<div class="field row">
<div class="dd-wrap flex1">
<button class="dd-btn" onclick={() => { termFontOpen = !termFontOpen; themeOpen = false; uiFontOpen = false; }}>
{termFontLabel}
<svg class="chev" class:open={termFontOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{#if termFontOpen}
<ul class="dd-list" role="listbox">
{#each TERM_FONTS as f}
<li class="dd-item" class:sel={termFont === f.value} role="option" aria-selected={termFont === f.value}
tabindex="0" onclick={() => selectTermFont(f.value)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTermFont(f.value)}
>{f.label}</li>
{/each}
</ul>
{/if}
<div class="flex1">
<CustomDropdown
items={termFontItems}
selected={termFont}
onSelect={v => selectTermFont(v)}
placeholder="Default (JetBrains Mono)"
/>
</div>
<div class="stepper">
<button onclick={() => adjustTermSize(-1)} aria-label="Decrease terminal font size"></button>
@ -270,25 +224,11 @@
<h3 class="sh">{t('settings.language')}</h3>
<div class="field">
<div class="dd-wrap">
<button class="dd-btn" onclick={() => { langOpen = !langOpen; themeOpen = false; uiFontOpen = false; termFontOpen = false; }}>
{langLabel}
<svg class="chev" class:open={langOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{#if langOpen}
<ul class="dd-list" role="listbox">
{#each AVAILABLE_LOCALES as loc}
<li class="dd-item" class:sel={currentLocale === loc.tag} role="option" aria-selected={currentLocale === loc.tag}
tabindex="0" onclick={() => selectLang(loc.tag)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectLang(loc.tag)}
>
<span class="dd-item-label">{loc.nativeLabel}</span>
<span style="color: var(--ctp-overlay0); font-size: 0.6875rem;">{loc.label}</span>
</li>
{/each}
</ul>
{/if}
</div>
<CustomDropdown
items={langItems}
selected={currentLocale}
onSelect={v => selectLang(v)}
/>
</div>
{/if}
@ -301,39 +241,6 @@
.row { display: flex; align-items: center; gap: 0.5rem; }
.flex1 { flex: 1; min-width: 0; }
.dd-wrap { position: relative; }
.dd-btn {
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 0.375rem;
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
padding: 0.3rem 0.5rem; color: var(--ctp-text); font-family: var(--ui-font-family);
font-size: 0.8125rem; cursor: pointer; text-align: left;
}
.dd-btn:hover { border-color: var(--ctp-surface2); }
.chev { width: 0.75rem; height: 0.75rem; color: var(--ctp-overlay1); transition: transform 0.15s; flex-shrink: 0; }
.chev.open { transform: rotate(180deg); }
.dd-list {
position: absolute; top: calc(100% + 0.125rem); left: 0; right: 0; z-index: 50;
list-style: none; margin: 0; padding: 0.25rem;
background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.3rem;
max-height: 14rem; overflow-y: auto;
box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
}
.dd-group-label {
padding: 0.25rem 0.5rem 0.125rem; font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ctp-overlay0); border-top: 1px solid var(--ctp-surface0);
}
.dd-group-label:first-child { border-top: none; }
.dd-item {
display: flex; align-items: center; justify-content: space-between;
padding: 0.3rem 0.5rem; border-radius: 0.2rem; font-size: 0.8125rem; color: var(--ctp-subtext1);
cursor: pointer; outline: none; list-style: none;
}
.dd-item:hover, .dd-item:focus { background: var(--ctp-surface0); color: var(--ctp-text); }
.dd-item.sel { background: color-mix(in srgb, var(--ctp-mauve) 15%, transparent); color: var(--ctp-mauve); font-weight: 500; }
.dd-item-label { flex: 1; }
.del-btn { font-size: 0.7rem; color: var(--ctp-overlay0); padding: 0.1rem 0.2rem; border-radius: 0.15rem; }
.del-btn:hover { color: var(--ctp-red); background: color-mix(in srgb, var(--ctp-red) 10%, transparent); }
.theme-actions { display: flex; gap: 0.375rem; margin-top: 0.25rem; }
.theme-action-btn {
padding: 0.2rem 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../rpc.ts';
import CustomCheckbox from '../ui/CustomCheckbox.svelte';
type WakeStrategy = 'persistent' | 'on-demand' | 'smart';
type AnchorScale = 'small' | 'medium' | 'large' | 'full';
@ -135,11 +136,12 @@
</label>
<div class="notif-types" style="margin-top: 0.375rem;">
{#each NOTIF_TYPES as t}
<label class="notif-chip" class:active={notifTypes.has(t)}>
<input type="checkbox" checked={notifTypes.has(t)} onchange={() => toggleNotif(t)} aria-label="Notify on {t}" />
{t}
</label>
{#each NOTIF_TYPES as nt}
<CustomCheckbox
checked={notifTypes.has(nt)}
label={nt}
onChange={() => toggleNotif(nt)}
/>
{/each}
</div>
</div>
@ -167,8 +169,4 @@
.toggle.on .thumb { transform: translateX(0.875rem); }
.notif-types { display: flex; flex-wrap: wrap; gap: 0.375rem; }
.notif-chip { display: flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; cursor: pointer; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); color: var(--ctp-subtext0); transition: all 0.12s; }
.notif-chip input { display: none; }
.notif-chip.active { background: color-mix(in srgb, var(--ctp-blue) 15%, var(--ctp-surface0)); border-color: var(--ctp-blue); color: var(--ctp-blue); }
.notif-chip:hover { border-color: var(--ctp-surface2); color: var(--ctp-subtext1); }
</style>

View file

@ -3,6 +3,7 @@
import { appRpc } from '../rpc.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
import { setRetentionConfig } from '../agent-store.svelte.ts';
import CustomCheckbox from '../ui/CustomCheckbox.svelte';
const ANCHOR_SCALES = ['small', 'medium', 'large', 'full'] as const;
type AnchorScale = typeof ANCHOR_SCALES[number];

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../rpc.ts';
import CustomDropdown from '../ui/CustomDropdown.svelte';
const KNOWN_KEYS: Record<string, string> = {
ANTHROPIC_API_KEY: 'Anthropic API Key',
@ -16,7 +17,7 @@
let newKey = $state('');
let newValue = $state('');
let keyDropOpen = $state(false);
// Dropdown state managed by CustomDropdown
let saving = $state(false);
interface BranchPolicy { pattern: string; action: 'block' | 'warn'; }
@ -28,7 +29,7 @@
let newAction = $state<'block' | 'warn'>('warn');
let availableKeys = $derived(Object.keys(KNOWN_KEYS).filter(k => !storedKeys.includes(k)));
let newKeyLabel = $derived(newKey ? (KNOWN_KEYS[newKey] ?? newKey) : 'Select key...');
let keyDropItems = $derived(availableKeys.map(k => ({ value: k, label: KNOWN_KEYS[k] ?? k })));
function persistPolicies() {
appRpc?.request['settings.set']({ key: 'branch_policies', value: JSON.stringify(branchPolicies) }).catch(console.error);
@ -61,9 +62,7 @@
persistPolicies();
}
function handleOutsideClick(e: MouseEvent) {
if (!(e.target as HTMLElement).closest('.dd-wrap')) keyDropOpen = false;
}
// CustomDropdown handles its own outside click
onMount(async () => {
if (!appRpc) return;
@ -74,8 +73,7 @@
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="section" onclick={handleOutsideClick} onkeydown={e => e.key === 'Escape' && (keyDropOpen = false)}>
<div class="section">
<div class="prototype-notice">
Prototype — secrets are stored locally in plain SQLite, not in the system keyring.
@ -107,24 +105,13 @@
{/if}
<div class="add-secret">
<div class="dd-wrap">
<button class="dd-btn small" onclick={() => keyDropOpen = !keyDropOpen}>
{newKeyLabel}
<svg class="chev" class:open={keyDropOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{#if keyDropOpen}
<ul class="dd-list" role="listbox">
{#each availableKeys as k}
<li class="dd-item" role="option" aria-selected={newKey === k} tabindex="0"
onclick={() => { newKey = k; keyDropOpen = false; }}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && (newKey = k, keyDropOpen = false)}
>{KNOWN_KEYS[k]}</li>
{/each}
{#if availableKeys.length === 0}
<li class="dd-item disabled-item">All known keys stored</li>
{/if}
</ul>
{/if}
<div class="dd-key-wrap">
<CustomDropdown
items={keyDropItems}
selected={newKey}
placeholder="Select key..."
onSelect={v => newKey = v}
/>
</div>
<input class="text-in flex1" type="password" bind:value={newValue} placeholder="Value" aria-label="Secret value" />
<button class="save-btn" onclick={handleSaveSecret} disabled={!newKey || !newValue || saving}>
@ -183,15 +170,7 @@
.add-policy { display: flex; align-items: center; gap: 0.375rem; margin-top: 0.25rem; }
.flex1 { flex: 1; min-width: 0; }
.dd-wrap { position: relative; flex-shrink: 0; }
.dd-btn { display: flex; align-items: center; justify-content: space-between; gap: 0.25rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-subtext1); font-family: var(--ui-font-family); cursor: pointer; white-space: nowrap; }
.dd-btn.small { padding: 0.275rem 0.5rem; font-size: 0.75rem; min-width: 8rem; }
.chev { width: 0.625rem; height: 0.625rem; color: var(--ctp-overlay1); transition: transform 0.15s; }
.chev.open { transform: rotate(180deg); }
.dd-list { position: absolute; top: calc(100% + 0.125rem); left: 0; z-index: 50; list-style: none; margin: 0; padding: 0.2rem; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; min-width: 10rem; box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent); }
.dd-item { padding: 0.3rem 0.5rem; border-radius: 0.2rem; font-size: 0.8rem; color: var(--ctp-subtext1); cursor: pointer; outline: none; }
.dd-item:hover, .dd-item:focus { background: var(--ctp-surface0); color: var(--ctp-text); }
.disabled-item { opacity: 0.4; cursor: not-allowed; }
.dd-key-wrap { flex-shrink: 0; min-width: 10rem; }
.text-in { padding: 0.275rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8rem; font-family: var(--ui-font-family); }
.text-in:focus { outline: none; border-color: var(--ctp-blue); }