agent-orchestrator/ui-electrobun/src/mainview/settings/AppearanceSettings.svelte
Hibryda d4014a193d 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
2026-03-23 13:05:07 +01:00

269 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../rpc.ts';
import { THEMES, THEME_GROUPS, getPalette, type ThemeId, type ThemeMeta } from '../themes.ts';
import { themeStore } from '../theme-store.svelte.ts';
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' },
{ value: 'Inter', label: 'Inter' },
{ value: 'IBM Plex Sans', label: 'IBM Plex Sans' },
{ value: 'Noto Sans', label: 'Noto Sans' },
{ value: 'Roboto', label: 'Roboto' },
{ value: 'Ubuntu', label: 'Ubuntu' },
];
const TERM_FONTS = [
{ value: '', label: 'Default (JetBrains Mono)' },
{ value: 'JetBrains Mono', label: 'JetBrains Mono' },
{ value: 'Fira Code', label: 'Fira Code' },
{ value: 'Cascadia Code', label: 'Cascadia Code' },
{ value: 'Source Code Pro', label: 'Source Code Pro' },
{ value: 'IBM Plex Mono', label: 'IBM Plex Mono' },
{ value: 'monospace', label: 'monospace' },
];
// ── Local reactive state ───────────────────────────────────────────────────
let themeId = $state<ThemeId>(themeStore.currentTheme);
let uiFont = $state(fontStore.uiFontFamily);
let uiFontSize = $state(fontStore.uiFontSize);
let termFont = $state(fontStore.termFontFamily);
let termFontSize = $state(fontStore.termFontSize);
let cursorStyle = $state('block');
let cursorBlink = $state(true);
let scrollback = $state(1000);
interface CustomThemeMeta { id: string; name: string; }
let customThemes = $state<CustomThemeMeta[]>([]);
let showEditor = $state(false);
// Keep local state in sync when store changes
$effect(() => { themeId = themeStore.currentTheme; });
$effect(() => { uiFont = fontStore.uiFontFamily; });
$effect(() => { uiFontSize = fontStore.uiFontSize; });
$effect(() => { termFont = fontStore.termFontFamily; });
$effect(() => { termFontSize = fontStore.termFontSize; });
// ── 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 {
setLocale(tag);
}
// ── All themes (built-in + custom) ────────────────────────────────────────
let allThemes = $derived<ThemeMeta[]>([
...THEMES,
...customThemes.map(t => ({ id: t.id as ThemeId, label: t.name, group: 'Custom', isDark: true })),
]);
let allGroups = $derived([...THEME_GROUPS, ...(customThemes.length > 0 ? ['Custom'] : [])]);
// ── 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;
themeStore.setTheme(id);
appRpc?.request['settings.set']({ key: 'theme', value: id }).catch(console.error);
}
function selectUIFont(value: string): void {
uiFont = value;
fontStore.setUIFont(value, uiFontSize);
}
function selectTermFont(value: string): void {
termFont = value;
fontStore.setTermFont(value, termFontSize);
}
function adjustUISize(delta: number): void {
uiFontSize = Math.max(8, Math.min(24, uiFontSize + delta));
fontStore.setUIFont(uiFont, uiFontSize);
}
function adjustTermSize(delta: number): void {
termFontSize = Math.max(8, Math.min(24, termFontSize + delta));
fontStore.setTermFont(termFont, termFontSize);
}
function persistCursorStyle(v: string) {
cursorStyle = v;
appRpc?.request['settings.set']({ key: 'cursor_style', value: v }).catch(console.error);
}
function persistCursorBlink(v: boolean) {
cursorBlink = v;
appRpc?.request['settings.set']({ key: 'cursor_blink', value: String(v) }).catch(console.error);
}
function persistScrollback(v: number) {
scrollback = v;
appRpc?.request['settings.set']({ key: 'scrollback', value: String(v) }).catch(console.error);
}
// CustomDropdown handles its own open/close state
async function deleteCustomTheme(id: string) {
await appRpc?.request['themes.deleteCustom']({ id }).catch(console.error);
customThemes = customThemes.filter(t => t.id !== id);
if (themeId === id) selectTheme('mocha');
}
function onEditorSave(id: string, name: string) {
customThemes = [...customThemes, { id, name }];
showEditor = false;
selectTheme(id as ThemeId);
}
function onEditorCancel() { showEditor = false; }
onMount(async () => {
if (!appRpc) return;
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
if (settings['cursor_style']) cursorStyle = settings['cursor_style'];
if (settings['cursor_blink']) cursorBlink = settings['cursor_blink'] !== 'false';
if (settings['scrollback']) scrollback = parseInt(settings['scrollback'], 10) || 1000;
const res = await appRpc.request['themes.getCustom']({}).catch(() => ({ themes: [] }));
customThemes = res.themes.map(t => ({ id: t.id, name: t.name }));
});
</script>
<div class="section">
{#if showEditor}
<ThemeEditor
baseThemeId={themeId}
initialPalette={getPalette(themeId)}
onSave={onEditorSave}
onCancel={onEditorCancel}
/>
{:else}
<h3 class="sh">{t('settings.theme')}</h3>
<div class="field">
<CustomDropdown
items={themeItems}
selected={themeId}
onSelect={v => selectTheme(v as ThemeId)}
groupBy={true}
/>
<div class="theme-actions">
<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="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>
<span>{uiFontSize}px</span>
<button onclick={() => adjustUISize(1)} aria-label="Increase UI font size">+</button>
</div>
</div>
<h3 class="sh">{t('settings.termFont')}</h3>
<div class="field row">
<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>
<span>{termFontSize}px</span>
<button onclick={() => adjustTermSize(1)} aria-label="Increase terminal font size">+</button>
</div>
</div>
<h3 class="sh">{t('settings.termCursor')}</h3>
<div class="field row">
<div class="seg">
{#each ['block', 'line', 'underline'] as s}
<button class:active={cursorStyle === s} onclick={() => persistCursorStyle(s)}>{s[0].toUpperCase() + s.slice(1)}</button>
{/each}
</div>
<label class="toggle-row">
<span>Blink</span>
<button class="toggle" class:on={cursorBlink} onclick={() => persistCursorBlink(!cursorBlink)}>{cursorBlink ? 'On' : 'Off'}</button>
</label>
</div>
<h3 class="sh">{t('settings.scrollback')}</h3>
<div class="field row">
<input type="number" class="num-in" min="100" max="100000" step="100" value={scrollback}
onchange={e => persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 1000)}
aria-label="Scrollback lines" />
<span class="hint">{t('settings.scrollbackHint')}</span>
</div>
<h3 class="sh">{t('settings.language')}</h3>
<div class="field">
<CustomDropdown
items={langItems}
selected={currentLocale}
onSelect={v => selectLang(v)}
/>
</div>
{/if}
</div>
<style>
.section { display: flex; flex-direction: column; gap: 0.5rem; }
.sh { margin: 0.375rem 0 0.125rem; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
.field { position: relative; }
.row { display: flex; align-items: center; gap: 0.5rem; }
.flex1 { flex: 1; min-width: 0; }
.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);
border-radius: 0.2rem; color: var(--ctp-subtext1); font-size: 0.75rem; cursor: pointer;
font-family: var(--ui-font-family);
}
.theme-action-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
.stepper { display: flex; align-items: center; gap: 0.25rem; flex-shrink: 0; }
.stepper button { width: 1.375rem; height: 1.375rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.2rem; color: var(--ctp-text); font-size: 0.875rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.stepper button:hover { background: var(--ctp-surface1); }
.stepper span { font-size: 0.8125rem; color: var(--ctp-text); min-width: 2.5rem; text-align: center; }
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
.seg button { flex: 1; padding: 0.25rem 0.5rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.75rem; cursor: pointer; }
.seg button:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
.seg button.active { background: var(--ctp-blue); color: var(--ctp-base); }
.toggle-row { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: var(--ctp-subtext0); cursor: pointer; }
.toggle { padding: 0.1875rem 0.5rem; background: var(--ctp-surface0); border: none; border-radius: 0.2rem; color: var(--ctp-subtext0); cursor: pointer; font-size: 0.75rem; }
.toggle.on { background: var(--ctp-green); color: var(--ctp-base); }
.num-in { width: 5rem; padding: 0.3rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; }
.num-in:focus { outline: none; border-color: var(--ctp-blue); }
.hint { font-size: 0.6875rem; color: var(--ctp-overlay0); }
</style>