From 1de6c93e0131bcde9447598d103ed244125b0790 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 25 Mar 2026 01:42:34 +0100 Subject: [PATCH] =?UTF-8?q?feat(electrobun):=20settings=20overhaul=20?= =?UTF-8?q?=E2=80=94=20fonts,=20shells,=20providers,=20retention,=20chords?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/bun/handlers/git-handlers.ts | 52 +++- .../src/mainview/SettingsDrawer.svelte | 2 +- .../src/mainview/keybinding-store.svelte.ts | 68 ++++- .../mainview/settings/AgentSettings.svelte | 64 ++++- .../settings/AppearanceSettings.svelte | 44 +++- .../mainview/settings/KeyboardSettings.svelte | 239 ++++++------------ .../mainview/settings/ProjectSettings.svelte | 8 +- ui-electrobun/src/shared/pty-rpc-schema.ts | 11 +- .../tests/unit/keybinding-store.test.ts | 45 +++- 9 files changed, 346 insertions(+), 187 deletions(-) diff --git a/ui-electrobun/src/bun/handlers/git-handlers.ts b/ui-electrobun/src/bun/handlers/git-handlers.ts index e2b546b..a3b0afe 100644 --- a/ui-electrobun/src/bun/handlers/git-handlers.ts +++ b/ui-electrobun/src/bun/handlers/git-handlers.ts @@ -29,7 +29,57 @@ export function createGitHandlers() { // not installed } } - return { shells }; + const loginShell = process.env.SHELL ?? '/bin/bash'; + return { shells, loginShell }; + }, + + "system.fonts": async () => { + const PREFERRED_SANS = new Set([ + 'Inter', 'Roboto', 'Noto Sans', 'Ubuntu', 'Open Sans', 'Lato', + 'Source Sans 3', 'IBM Plex Sans', 'Fira Sans', 'PT Sans', + 'Cantarell', 'DejaVu Sans', 'Liberation Sans', + ]); + try { + const allRaw = execSync( + 'fc-list :style=Regular --format="%{family}\\n" | sort -u', + { encoding: 'utf8', timeout: 5000 }, + ); + const monoRaw = execSync( + 'fc-list :spacing=mono --format="%{family}\\n" | sort -u', + { encoding: 'utf8', timeout: 5000 }, + ); + + const monoSet = new Set(); + const monoFonts: Array<{ family: string; isNerdFont: boolean }> = []; + for (const line of monoRaw.split('\n')) { + const family = line.split(',')[0].trim(); // fc-list returns comma-separated aliases + if (!family || monoSet.has(family)) continue; + monoSet.add(family); + monoFonts.push({ family, isNerdFont: family.includes('Nerd') }); + } + monoFonts.sort((a, b) => { + if (a.isNerdFont !== b.isNerdFont) return a.isNerdFont ? -1 : 1; + return a.family.localeCompare(b.family); + }); + + const uiSet = new Set(); + const uiFonts: Array<{ family: string; preferred: boolean }> = []; + for (const line of allRaw.split('\n')) { + const family = line.split(',')[0].trim(); + if (!family || uiSet.has(family) || monoSet.has(family)) continue; + uiSet.add(family); + uiFonts.push({ family, preferred: PREFERRED_SANS.has(family) }); + } + uiFonts.sort((a, b) => { + if (a.preferred !== b.preferred) return a.preferred ? -1 : 1; + return a.family.localeCompare(b.family); + }); + + return { uiFonts, monoFonts }; + } catch (err) { + console.error('[system.fonts] fc-list failed:', err); + return { uiFonts: [], monoFonts: [] }; + } }, "ssh.checkSshfs": async () => { diff --git a/ui-electrobun/src/mainview/SettingsDrawer.svelte b/ui-electrobun/src/mainview/SettingsDrawer.svelte index 32b9815..9868d23 100644 --- a/ui-electrobun/src/mainview/SettingsDrawer.svelte +++ b/ui-electrobun/src/mainview/SettingsDrawer.svelte @@ -136,7 +136,7 @@ } .drawer-panel { - width: 30rem; + width: clamp(24rem, 45vw, 50rem); max-width: 95vw; background: var(--ctp-mantle); border-right: 1px solid var(--ctp-surface0); diff --git a/ui-electrobun/src/mainview/keybinding-store.svelte.ts b/ui-electrobun/src/mainview/keybinding-store.svelte.ts index 2137c6b..9f5f2ad 100644 --- a/ui-electrobun/src/mainview/keybinding-store.svelte.ts +++ b/ui-electrobun/src/mainview/keybinding-store.svelte.ts @@ -1,6 +1,7 @@ /** * Svelte 5 rune-based keybinding store. * Manages global keyboard shortcuts with user-customizable chords. + * Supports multi-key chord sequences (e.g. "Ctrl+K Ctrl+S"). * Persists overrides via settings RPC (only non-default bindings saved). * * Usage: @@ -30,6 +31,7 @@ export interface Keybinding { id: string; label: string; category: "Global" | "Navigation" | "Terminal" | "Settings"; + /** Space-separated chord sequence, e.g. "Ctrl+K" or "Ctrl+K Ctrl+S". */ chord: string; defaultChord: string; } @@ -57,6 +59,7 @@ const DEFAULTS: Keybinding[] = [ // ── Chord serialisation helpers ─────────────────────────────────────────────── +/** Convert a KeyboardEvent to a single-step chord string like "Ctrl+Shift+K". */ export function chordFromEvent(e: KeyboardEvent): string { const parts: string[] = []; if (e.ctrlKey || e.metaKey) parts.push("Ctrl"); @@ -70,8 +73,24 @@ export function chordFromEvent(e: KeyboardEvent): string { return parts.join("+"); } -function matchesChord(e: KeyboardEvent, chord: string): boolean { - return chordFromEvent(e) === chord; +/** Split a chord string into its sequence parts. "Ctrl+K Ctrl+S" → ["Ctrl+K", "Ctrl+S"] */ +export function chordParts(chord: string): string[] { + return chord.split(" ").filter(Boolean); +} + +/** Format a chord for display: "Ctrl+K Ctrl+S" → "Ctrl+K → Ctrl+S" */ +export function formatChord(chord: string): string { + const parts = chordParts(chord); + return parts.join(" \u2192 "); +} + +/** Check if a chord string is the first key of any multi-key chord binding. */ +function isChordPrefix(firstKey: string, bindings: Keybinding[]): boolean { + for (const b of bindings) { + const parts = chordParts(b.chord); + if (parts.length > 1 && parts[0] === firstKey) return true; + } + return false; } // ── Store ──────────────────────────────────────────────────────────────────── @@ -82,6 +101,15 @@ function createKeybindingStore() { const handlers = new Map void>(); let listenerInstalled = false; + // Chord sequence state + let pendingPrefix: string | null = null; + let prefixTimer: ReturnType | null = null; + + function clearPrefix() { + pendingPrefix = null; + if (prefixTimer) { clearTimeout(prefixTimer); prefixTimer = null; } + } + /** Load persisted overrides and merge with defaults. */ async function init(rpcInstance: SettingsRpc): Promise { rpc = rpcInstance; @@ -135,8 +163,39 @@ function createKeybindingStore() { const chord = chordFromEvent(e); if (!chord) return; + // If we have a pending prefix, try to complete the chord sequence + if (pendingPrefix) { + const fullChord = `${pendingPrefix} ${chord}`; + clearPrefix(); + + for (const b of bindings) { + if (b.chord === fullChord) { + const handler = handlers.get(b.id); + if (handler) { + e.preventDefault(); + handler(); + return; + } + } + } + // No match for the full sequence — fall through to single-key check + } + + // Check if this chord is a prefix for any multi-key binding + if (isChordPrefix(chord, bindings)) { + e.preventDefault(); + pendingPrefix = chord; + // Wait 1 second for the second key; timeout clears prefix + prefixTimer = setTimeout(clearPrefix, 1000); + + // Also check if this chord alone matches a single-key binding + // (handled on timeout or if no second key matches) + return; + } + + // Single-key chord match for (const b of bindings) { - if (b.chord === chord) { + if (b.chord === chord && chordParts(b.chord).length === 1) { const handler = handlers.get(b.id); if (handler) { e.preventDefault(); @@ -151,17 +210,20 @@ function createKeybindingStore() { return () => { document.removeEventListener("keydown", handleKeydown, { capture: true }); listenerInstalled = false; + clearPrefix(); }; } return { get bindings() { return bindings; }, + get pendingPrefix() { return pendingPrefix; }, init, setChord, resetChord, resetAll, on, installListener, + clearPrefix, }; } diff --git a/ui-electrobun/src/mainview/settings/AgentSettings.svelte b/ui-electrobun/src/mainview/settings/AgentSettings.svelte index c3f8300..0617055 100644 --- a/ui-electrobun/src/mainview/settings/AgentSettings.svelte +++ b/ui-electrobun/src/mainview/settings/AgentSettings.svelte @@ -25,6 +25,11 @@ let permissionMode = $state('bypassPermissions'); let systemPrompt = $state(''); + // Detected shells from system.shells RPC + let detectedShells = $state>([ + { path: '/bin/bash', name: 'bash' }, + ]); + interface ProviderState { enabled: boolean; model: string; } let providerState = $state>({ 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>({}); + let expandedProvider = $state(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 = {}; + for (const p of provRes.providers) { + avail[p.id] = { available: p.available, hasApiKey: p.hasApiKey, hasCli: p.hasCli }; + } + providerAvail = avail; + } catch { /* keep defaults */ } });
- setShell((e.target as HTMLInputElement).value)} /> +
@@ -101,18 +146,25 @@
{#each PROVIDERS as prov} {@const state = providerState[prov.id]} -
+ {@const available = isProviderAvailable(prov.id)} +
{#if expandedProvider === prov.id}
+ {#if !available} +
Not installed — install the CLI or set an API key to enable.
+ {/if}
@@ -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; } diff --git a/ui-electrobun/src/mainview/settings/AppearanceSettings.svelte b/ui-electrobun/src/mainview/settings/AppearanceSettings.svelte index 3b0c168..5bce4b5 100644 --- a/ui-electrobun/src/mainview/settings/AppearanceSettings.svelte +++ b/ui-electrobun/src/mainview/settings/AppearanceSettings.svelte @@ -8,7 +8,8 @@ import ThemeEditor from './ThemeEditor.svelte'; import CustomDropdown from '../ui/CustomDropdown.svelte'; - const UI_FONTS = [ + // Fallback lists — replaced by system detection on mount + const FALLBACK_UI_FONTS = [ { value: '', label: 'System Default' }, { value: 'Inter', label: 'Inter' }, { value: 'IBM Plex Sans', label: 'IBM Plex Sans' }, @@ -17,7 +18,7 @@ { value: 'Ubuntu', label: 'Ubuntu' }, ]; - const TERM_FONTS = [ + const FALLBACK_TERM_FONTS = [ { value: '', label: 'Default (JetBrains Mono)' }, { value: 'JetBrains Mono', label: 'JetBrains Mono' }, { value: 'Fira Code', label: 'Fira Code' }, @@ -27,6 +28,9 @@ { value: 'monospace', label: 'monospace' }, ]; + let uiFontItems = $state(FALLBACK_UI_FONTS.map(f => ({ value: f.value, label: f.label }))); + let termFontItems = $state(FALLBACK_TERM_FONTS.map(f => ({ value: f.value, label: f.label }))); + // ── Local reactive state ─────────────────────────────────────────────────── let themeId = $state(themeStore.currentTheme); let uiFont = $state(fontStore.uiFontFamily); @@ -35,7 +39,7 @@ let termFontSize = $state(fontStore.termFontSize); let cursorStyle = $state('block'); let cursorBlink = $state(true); - let scrollback = $state(1000); + let scrollback = $state(5000); interface CustomThemeMeta { id: string; name: string; } let customThemes = $state([]); @@ -67,8 +71,7 @@ // ── 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 })); + // uiFontItems and termFontItems are $state — populated by system.fonts on mount let langItems = AVAILABLE_LOCALES.map(l => ({ value: l.tag, label: l.nativeLabel })); // ── Actions ──────────────────────────────────────────────────────────────── @@ -135,10 +138,35 @@ 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; + if (settings['scrollback']) scrollback = parseInt(settings['scrollback'], 10) || 5000; const res = await appRpc.request['themes.getCustom']({}).catch(() => ({ themes: [] })); customThemes = res.themes.map(t => ({ id: t.id, name: t.name })); + + // Detect system fonts + try { + const fontRes = await appRpc.request['system.fonts']({}); + if (fontRes.uiFonts.length > 0) { + uiFontItems = [ + { value: '', label: 'System Default' }, + ...fontRes.uiFonts.map(f => ({ + value: f.family, + label: f.preferred ? `\u2605 ${f.family}` : f.family, + })), + ]; + } + if (fontRes.monoFonts.length > 0) { + termFontItems = [ + { value: '', label: 'Default (JetBrains Mono)' }, + ...fontRes.monoFonts.map(f => ({ + value: f.family, + label: f.isNerdFont ? `\u2B50 ${f.family}` : f.family, + })), + ]; + } + } catch { + // Keep fallback font lists + } }); @@ -216,8 +244,8 @@

{t('settings.scrollback')}

- persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 1000)} + persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 5000)} aria-label="Scrollback lines" /> {t('settings.scrollbackHint')}
diff --git a/ui-electrobun/src/mainview/settings/KeyboardSettings.svelte b/ui-electrobun/src/mainview/settings/KeyboardSettings.svelte index 9afdb54..61a1e04 100644 --- a/ui-electrobun/src/mainview/settings/KeyboardSettings.svelte +++ b/ui-electrobun/src/mainview/settings/KeyboardSettings.svelte @@ -1,10 +1,13 @@