feat(electrobun): settings overhaul — fonts, shells, providers, retention, chords

- Settings drawer: responsive width clamp(24rem, 45vw, 50rem)
- System font detection: fc-list for UI fonts (preferred sans-serif starred)
  and mono fonts (Nerd Fonts starred), fallback to hardcoded lists
- Scrollback: default 5000, min 1000, step 500
- Shell detection: system.shells RPC, pre-selects $SHELL login shell
- Provider enablement: provider.scan gates toggle, unavailable shown as N/A
- Session retention: count 0-100 (0=Keep all), age 0-365 (0=Forever)
- Chord keybindings: Ctrl+K → Ctrl+S style multi-key sequences,
  1s prefix wait, arrow separator display, 26 tests passing
This commit is contained in:
Hibryda 2026-03-25 01:42:34 +01:00
parent afaa2253de
commit 1de6c93e01
9 changed files with 346 additions and 187 deletions

View file

@ -25,6 +25,11 @@
let permissionMode = $state<PermMode>('bypassPermissions');
let systemPrompt = $state('');
// Detected shells from system.shells RPC
let detectedShells = $state<Array<{ path: string; name: string }>>([
{ path: '/bin/bash', name: 'bash' },
]);
interface ProviderState { enabled: boolean; model: string; }
let providerState = $state<Record<string, ProviderState>>({
claude: { enabled: true, model: 'claude-opus-4-5' },
@ -33,6 +38,10 @@
gemini: { enabled: false, model: 'gemini-2.5-pro' },
});
// Provider availability from provider.scan RPC
interface ProviderAvail { available: boolean; hasApiKey: boolean; hasCli: boolean }
let providerAvail = $state<Record<string, ProviderAvail>>({});
let expandedProvider = $state<string | null>(null);
function persist(key: string, value: string) {
@ -49,11 +58,20 @@
function setPrompt(v: string) { systemPrompt = v; persist('system_prompt_template', v); }
function toggleProvider(id: string) {
// Don't allow enabling unavailable providers
const avail = providerAvail[id];
if (avail && !avail.available && !providerState[id].enabled) return;
providerState[id] = { ...providerState[id], enabled: !providerState[id].enabled };
providerState = { ...providerState };
persistProviders();
}
function isProviderAvailable(id: string): boolean {
const avail = providerAvail[id];
if (!avail) return true; // Not scanned yet — assume available
return avail.available;
}
function setModel(id: string, model: string) {
providerState[id] = { ...providerState[id], model };
providerState = { ...providerState };
@ -63,21 +81,48 @@
onMount(async () => {
if (!appRpc) return;
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
if (settings['default_shell']) defaultShell = settings['default_shell'];
if (settings['default_cwd']) defaultCwd = settings['default_cwd'];
if (settings['permission_mode']) permissionMode = settings['permission_mode'] as PermMode;
if (settings['system_prompt_template']) systemPrompt = settings['system_prompt_template'];
if (settings['provider_settings']) {
try { providerState = JSON.parse(settings['provider_settings']); } catch { /* ignore */ }
}
// Detect installed shells and pre-select login shell
try {
const shellRes = await appRpc.request['system.shells']({});
if (shellRes.shells.length > 0) detectedShells = shellRes.shells;
// Use persisted shell, else login shell from $SHELL, else first detected
if (settings['default_shell']) {
defaultShell = settings['default_shell'];
} else {
defaultShell = shellRes.loginShell || detectedShells[0]?.path || '/bin/bash';
}
} catch {
if (settings['default_shell']) defaultShell = settings['default_shell'];
}
// Scan provider availability
try {
const provRes = await appRpc.request['provider.scan']({});
const avail: Record<string, ProviderAvail> = {};
for (const p of provRes.providers) {
avail[p.id] = { available: p.available, hasApiKey: p.hasApiKey, hasCli: p.hasCli };
}
providerAvail = avail;
} catch { /* keep defaults */ }
});
</script>
<Section heading="Defaults">
<div class="field">
<label class="lbl" for="ag-shell">Shell</label>
<input id="ag-shell" class="text-in" value={defaultShell} placeholder="/bin/bash"
onchange={e => setShell((e.target as HTMLInputElement).value)} />
<select id="ag-shell" class="text-in" value={defaultShell}
onchange={e => setShell((e.target as HTMLSelectElement).value)}>
{#each detectedShells as shell}
<option value={shell.path} selected={shell.path === defaultShell}>{shell.name} ({shell.path})</option>
{/each}
</select>
</div>
<div class="field">
@ -101,18 +146,25 @@
<div class="prov-list">
{#each PROVIDERS as prov}
{@const state = providerState[prov.id]}
<div class="prov-panel" class:disabled={!state.enabled}>
{@const available = isProviderAvailable(prov.id)}
<div class="prov-panel" class:disabled={!state.enabled} class:unavailable={!available}>
<button class="prov-hdr" onclick={() => expandedProvider = expandedProvider === prov.id ? null : prov.id}>
<span class="prov-name">{prov.label}</span>
<span class="prov-desc">{prov.desc}</span>
{#if !available}<span class="prov-badge" title="Not installed">N/A</span>{/if}
<span class="prov-chev">{expandedProvider === prov.id ? '\u25B4' : '\u25BE'}</span>
</button>
{#if expandedProvider === prov.id}
<div class="prov-body">
{#if !available}
<div class="prov-unavail">Not installed — install the CLI or set an API key to enable.</div>
{/if}
<label class="toggle-row">
<span class="lbl">Enabled</span>
<button class="toggle" class:on={state.enabled} role="switch"
aria-checked={state.enabled} aria-label="Toggle {prov.label} provider"
disabled={!available && !state.enabled}
title={!available ? 'Not installed' : ''}
onclick={() => toggleProvider(prov.id)}><span class="thumb"></span></button>
</label>
<div class="field">
@ -167,6 +219,10 @@
.thumb { position: absolute; top: 0.125rem; left: 0.125rem; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--ctp-text); transition: transform 0.2s; display: block; }
.toggle.on .thumb { transform: translateX(0.875rem); }
.prov-panel.unavailable { opacity: 0.6; border-style: dashed; }
.prov-badge { padding: 0.0625rem 0.375rem; background: color-mix(in srgb, var(--ctp-yellow) 15%, transparent); color: var(--ctp-yellow); border-radius: 0.75rem; font-size: 0.6rem; font-weight: 600; flex-shrink: 0; }
.prov-unavail { padding: 0.25rem 0.375rem; font-size: 0.7rem; color: var(--ctp-overlay0); font-style: italic; }
.caps { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.cap { padding: 0.125rem 0.5rem; background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); color: var(--ctp-blue); border-radius: 0.75rem; font-size: 0.65rem; font-weight: 500; }
</style>