From 244d5e393899938509273911cf28cfea524159c3 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Tue, 17 Mar 2026 04:58:57 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=200=20=E2=80=94=20settings=20pan?= =?UTF-8?q?el=20infrastructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsPanel.svelte: VS Code-style shell with sidebar categories, search bar (fuzzy on registry metadata), keyboard navigation (arrows, Escape), deep-link to setting anchors. settings-registry.ts: static metadata for 35+ settings with key, label, description, category, anchorId, keywords, scopeable, pro flags. Supports fuzzy search and category filtering. settings-scope.svelte.ts: centralized scope resolution store. scopedGet/scopedSet resolve Global→Project cascade. Override chain for ScopeCascade display. Cache invalidation on project switch. settings/categories/ directory ready for Sprint 1 extraction. --- src/lib/settings/SettingsPanel.svelte | 294 ++++++++++++++++++++++++ src/lib/settings/settings-registry.ts | 89 +++++++ src/lib/stores/settings-scope.svelte.ts | 107 +++++++++ 3 files changed, 490 insertions(+) create mode 100644 src/lib/settings/SettingsPanel.svelte create mode 100644 src/lib/settings/settings-registry.ts create mode 100644 src/lib/stores/settings-scope.svelte.ts diff --git a/src/lib/settings/SettingsPanel.svelte b/src/lib/settings/SettingsPanel.svelte new file mode 100644 index 0000000..0ff8106 --- /dev/null +++ b/src/lib/settings/SettingsPanel.svelte @@ -0,0 +1,294 @@ + + +
+
+ Settings + + {#if onClose} + + {/if} +
+ + {#if showSearch} +
+ {#each searchResults.slice(0, 10) as result} + + {/each} + {#if searchResults.length === 0} +
No settings match "{searchQuery}"
+ {/if} +
+ {:else} +
+ + +
+ +
+

{CATEGORIES.find(c => c.id === activeCategory)?.label}

+

Settings for {activeCategory} will be rendered here.

+

{SETTINGS_REGISTRY.filter(s => s.category === activeCategory).length} settings

+
+
+
+ {/if} +
+ + diff --git a/src/lib/settings/settings-registry.ts b/src/lib/settings/settings-registry.ts new file mode 100644 index 0000000..3bf1fee --- /dev/null +++ b/src/lib/settings/settings-registry.ts @@ -0,0 +1,89 @@ +// Settings Registry — static metadata for all configurable settings. +// Used by SettingsSearch for fuzzy search + deep-linking. +// Every setting component MUST have a corresponding entry here. +// CI lint rule enforces this (see .github/workflows/). + +export type SettingsCategory = + | 'appearance' + | 'agents' + | 'security' + | 'projects' + | 'orchestration' + | 'advanced'; + +export interface SettingEntry { + key: string; + label: string; + description: string; + category: SettingsCategory; + /** HTML element ID for scroll-to-anchor */ + anchorId: string; + /** Keywords for fuzzy search (beyond label + description) */ + keywords: string[]; + /** Whether this setting can be overridden per-project */ + scopeable: boolean; + /** Whether this requires Pro edition */ + pro: boolean; +} + +export const SETTINGS_REGISTRY: SettingEntry[] = [ + // --- Appearance --- + { key: 'theme', label: 'Theme', description: 'Color theme for the entire application', category: 'appearance', anchorId: 'setting-theme', keywords: ['color', 'dark', 'light', 'catppuccin', 'mocha'], scopeable: false, pro: false }, + { key: 'ui_font_family', label: 'UI Font', description: 'Font family for interface elements', category: 'appearance', anchorId: 'setting-ui-font', keywords: ['font', 'sans-serif', 'text'], scopeable: false, pro: false }, + { key: 'ui_font_size', label: 'UI Font Size', description: 'Font size for interface elements in pixels', category: 'appearance', anchorId: 'setting-ui-font-size', keywords: ['font', 'size', 'text'], scopeable: false, pro: false }, + { key: 'term_font_family', label: 'Terminal Font', description: 'Font family for terminal and code display', category: 'appearance', anchorId: 'setting-term-font', keywords: ['font', 'monospace', 'terminal', 'code'], scopeable: false, pro: false }, + { key: 'term_font_size', label: 'Terminal Font Size', description: 'Font size for terminal display in pixels', category: 'appearance', anchorId: 'setting-term-font-size', keywords: ['font', 'size', 'terminal'], scopeable: false, pro: false }, + { key: 'term_cursor_style', label: 'Cursor Style', description: 'Terminal cursor shape: block, line, or underline', category: 'appearance', anchorId: 'setting-cursor-style', keywords: ['cursor', 'terminal', 'block', 'line', 'underline'], scopeable: false, pro: false }, + { key: 'term_cursor_blink', label: 'Cursor Blink', description: 'Whether the terminal cursor blinks', category: 'appearance', anchorId: 'setting-cursor-blink', keywords: ['cursor', 'blink', 'terminal'], scopeable: false, pro: false }, + { key: 'term_scrollback', label: 'Scrollback Lines', description: 'Number of lines to keep in terminal scrollback buffer', category: 'appearance', anchorId: 'setting-scrollback', keywords: ['scrollback', 'buffer', 'history', 'terminal'], scopeable: false, pro: false }, + + // --- Agents --- + { key: 'default_provider', label: 'Default Provider', description: 'Default AI provider for new agent sessions', category: 'agents', anchorId: 'setting-default-provider', keywords: ['provider', 'claude', 'codex', 'ollama', 'aider'], scopeable: true, pro: false }, + { key: 'default_model', label: 'Default Model', description: 'Default model for new agent sessions', category: 'agents', anchorId: 'setting-default-model', keywords: ['model', 'opus', 'sonnet', 'haiku', 'gpt'], scopeable: true, pro: false }, + { key: 'default_permission_mode', label: 'Permission Mode', description: 'Default permission mode for agent sessions', category: 'agents', anchorId: 'setting-permission-mode', keywords: ['permission', 'bypass', 'ask', 'plan'], scopeable: true, pro: false }, + { key: 'system_prompt_template', label: 'System Prompt Template', description: 'Default system prompt prepended to agent sessions', category: 'agents', anchorId: 'setting-system-prompt', keywords: ['prompt', 'system', 'template', 'instructions'], scopeable: true, pro: false }, + { key: 'context_pressure_warn', label: 'Context Pressure Warning', description: 'Percentage of context window that triggers a warning badge', category: 'agents', anchorId: 'setting-context-warn', keywords: ['context', 'pressure', 'warning', 'tokens'], scopeable: false, pro: false }, + { key: 'context_pressure_critical', label: 'Context Pressure Critical', description: 'Percentage of context window that triggers a critical badge', category: 'agents', anchorId: 'setting-context-critical', keywords: ['context', 'pressure', 'critical', 'tokens'], scopeable: false, pro: false }, + { key: 'budget_monthly_tokens', label: 'Monthly Token Budget', description: 'Maximum tokens per month for this project (Pro)', category: 'agents', anchorId: 'setting-budget', keywords: ['budget', 'tokens', 'cost', 'limit'], scopeable: true, pro: true }, + { key: 'router_profile', label: 'Model Router Profile', description: 'Smart model routing profile: cost_saver, balanced, or quality_first (Pro)', category: 'agents', anchorId: 'setting-router', keywords: ['router', 'model', 'cost', 'quality', 'balanced'], scopeable: true, pro: true }, + + // --- Security --- + { key: 'branch_policies', label: 'Branch Policies', description: 'Protected branch patterns that block agent sessions', category: 'security', anchorId: 'setting-branch-policies', keywords: ['branch', 'policy', 'protect', 'main', 'release'], scopeable: false, pro: true }, + { key: 'telemetry_enabled', label: 'Telemetry', description: 'Send anonymous usage telemetry via OpenTelemetry', category: 'security', anchorId: 'setting-telemetry', keywords: ['telemetry', 'privacy', 'otel', 'tracking'], scopeable: false, pro: false }, + { key: 'data_retention_days', label: 'Data Retention', description: 'Days to keep session data before auto-cleanup', category: 'security', anchorId: 'setting-retention', keywords: ['retention', 'cleanup', 'privacy', 'data'], scopeable: false, pro: false }, + + // --- Orchestration --- + { key: 'stall_threshold_min', label: 'Stall Threshold', description: 'Minutes of inactivity before an agent is marked stalled', category: 'orchestration', anchorId: 'setting-stall-threshold', keywords: ['stall', 'timeout', 'inactive', 'health'], scopeable: true, pro: false }, + { key: 'anchor_budget_scale', label: 'Anchor Budget Scale', description: 'Token budget for session anchors: small, medium, large, or full', category: 'orchestration', anchorId: 'setting-anchor-budget', keywords: ['anchor', 'budget', 'compaction', 'context'], scopeable: true, pro: false }, + { key: 'auto_anchor', label: 'Auto-Anchor', description: 'Automatically create anchors on first context compaction', category: 'orchestration', anchorId: 'setting-auto-anchor', keywords: ['anchor', 'auto', 'compaction'], scopeable: true, pro: false }, + { key: 'wake_strategy', label: 'Wake Strategy', description: 'Auto-wake strategy for idle manager agents', category: 'orchestration', anchorId: 'setting-wake-strategy', keywords: ['wake', 'auto', 'strategy', 'persistent', 'on-demand', 'smart'], scopeable: true, pro: false }, + { key: 'wake_threshold', label: 'Wake Threshold', description: 'Score threshold for smart wake strategy (0-1)', category: 'orchestration', anchorId: 'setting-wake-threshold', keywords: ['wake', 'threshold', 'score'], scopeable: true, pro: false }, + { key: 'notification_desktop', label: 'Desktop Notifications', description: 'Show OS desktop notifications for agent events', category: 'orchestration', anchorId: 'setting-notif-desktop', keywords: ['notification', 'desktop', 'os', 'alert'], scopeable: false, pro: false }, + { key: 'notification_types', label: 'Notification Types', description: 'Which event types trigger notifications', category: 'orchestration', anchorId: 'setting-notif-types', keywords: ['notification', 'types', 'complete', 'error', 'crash'], scopeable: false, pro: false }, + { key: 'memory_ttl_days', label: 'Memory TTL', description: 'Days before agent memory fragments expire (Pro)', category: 'orchestration', anchorId: 'setting-memory-ttl', keywords: ['memory', 'ttl', 'expire', 'retention'], scopeable: true, pro: true }, + { key: 'memory_auto_extract', label: 'Auto-Extract Memory', description: 'Automatically extract decisions and patterns from sessions (Pro)', category: 'orchestration', anchorId: 'setting-memory-extract', keywords: ['memory', 'extract', 'auto', 'decisions'], scopeable: true, pro: true }, + + // --- Advanced --- + { key: 'default_shell', label: 'Default Shell', description: 'Shell executable for new terminal sessions', category: 'advanced', anchorId: 'setting-default-shell', keywords: ['shell', 'bash', 'zsh', 'fish', 'terminal'], scopeable: false, pro: false }, + { key: 'default_cwd', label: 'Default Working Directory', description: 'Starting directory for new terminal sessions', category: 'advanced', anchorId: 'setting-default-cwd', keywords: ['directory', 'cwd', 'working', 'path'], scopeable: false, pro: false }, + { key: 'otlp_endpoint', label: 'OTLP Endpoint', description: 'OpenTelemetry collector endpoint for trace export', category: 'advanced', anchorId: 'setting-otlp', keywords: ['telemetry', 'otel', 'otlp', 'tempo', 'traces'], scopeable: false, pro: false }, + { key: 'log_level', label: 'Log Level', description: 'Minimum log level for console output', category: 'advanced', anchorId: 'setting-log-level', keywords: ['log', 'debug', 'info', 'warn', 'error', 'trace'], scopeable: false, pro: false }, + { key: 'plugin_auto_update', label: 'Plugin Auto-Update', description: 'Automatically check for plugin updates on startup', category: 'advanced', anchorId: 'setting-plugin-auto-update', keywords: ['plugin', 'update', 'auto', 'marketplace'], scopeable: false, pro: false }, + { key: 'relay_urls', label: 'Relay URLs', description: 'Remote machine relay WebSocket endpoints', category: 'advanced', anchorId: 'setting-relay-urls', keywords: ['relay', 'remote', 'machine', 'websocket', 'multi-machine'], scopeable: false, pro: false }, + { key: 'export_format', label: 'Export Format', description: 'Default format for session exports (markdown)', category: 'advanced', anchorId: 'setting-export-format', keywords: ['export', 'format', 'markdown', 'report'], scopeable: false, pro: true }, +]; + +/** Get all settings for a category. */ +export function getSettingsForCategory(category: SettingsCategory): SettingEntry[] { + return SETTINGS_REGISTRY.filter(s => s.category === category); +} + +/** Get all scopeable settings. */ +export function getScopeableSettings(): SettingEntry[] { + return SETTINGS_REGISTRY.filter(s => s.scopeable); +} + +/** Get all Pro-gated settings. */ +export function getProSettings(): SettingEntry[] { + return SETTINGS_REGISTRY.filter(s => s.pro); +} diff --git a/src/lib/stores/settings-scope.svelte.ts b/src/lib/stores/settings-scope.svelte.ts new file mode 100644 index 0000000..bca44a0 --- /dev/null +++ b/src/lib/stores/settings-scope.svelte.ts @@ -0,0 +1,107 @@ +// Settings Scope Store — centralized resolution of per-project overrides. +// Provides scoped get/set: reads project-level override if exists, falls back to global. +// Single source of truth for which scope is active and how overrides cascade. + +import { getSetting, setSetting } from '../adapters/settings-bridge'; + +type Scope = 'global' | 'project'; + +interface ScopeOverride { + key: string; + globalValue: string | null; + projectValue: string | null; + effectiveValue: string | null; + source: Scope; +} + +let activeProjectId = $state(null); +let overrideCache = $state>(new Map()); + +/** Set the active project for scoped settings resolution. */ +export function setActiveProject(projectId: string | null) { + activeProjectId = projectId; + overrideCache = new Map(); +} + +/** Get the currently active project ID. */ +export function getActiveProjectForSettings(): string | null { + return activeProjectId; +} + +/** Get a setting value with scope resolution. + * If activeProjectId is set and a project-specific override exists, return that. + * Otherwise return the global value. */ +export async function scopedGet(key: string): Promise { + // Check cache first + const cached = overrideCache.get(key); + if (cached) return cached.effectiveValue; + + const globalValue = await getSetting(key); + + if (!activeProjectId) { + overrideCache.set(key, { key, globalValue, projectValue: null, effectiveValue: globalValue, source: 'global' }); + return globalValue; + } + + const projectKey = `project:${activeProjectId}:${key}`; + const projectValue = await getSetting(projectKey); + + const effectiveValue = projectValue ?? globalValue; + const source: Scope = projectValue !== null ? 'project' : 'global'; + + overrideCache.set(key, { key, globalValue, projectValue, effectiveValue, source }); + return effectiveValue; +} + +/** Set a setting value at the specified scope. */ +export async function scopedSet(key: string, value: string, scope: Scope = 'global'): Promise { + if (scope === 'project' && activeProjectId) { + const projectKey = `project:${activeProjectId}:${key}`; + await setSetting(projectKey, value); + } else { + await setSetting(key, value); + } + // Invalidate cache for this key + overrideCache.delete(key); +} + +/** Remove a project-level override, reverting to global. */ +export async function removeOverride(key: string): Promise { + if (!activeProjectId) return; + const projectKey = `project:${activeProjectId}:${key}`; + await setSetting(projectKey, ''); + overrideCache.delete(key); +} + +/** Get the full override chain for a setting (for ScopeCascade display). */ +export async function getOverrideChain(key: string): Promise { + const cached = overrideCache.get(key); + if (cached) return cached; + + const globalValue = await getSetting(key); + let projectValue: string | null = null; + + if (activeProjectId) { + const projectKey = `project:${activeProjectId}:${key}`; + projectValue = await getSetting(projectKey); + } + + const effectiveValue = projectValue ?? globalValue; + const source: Scope = projectValue !== null ? 'project' : 'global'; + const override: ScopeOverride = { key, globalValue, projectValue, effectiveValue, source }; + overrideCache.set(key, override); + return override; +} + +/** Check if a setting has a project-level override. */ +export function hasProjectOverride(key: string): boolean { + const cached = overrideCache.get(key); + return cached?.source === 'project'; +} + +/** Clear the entire cache (call on project switch). */ +export function invalidateSettingsCache() { + overrideCache = new Map(); +} + +export type { Scope, ScopeOverride };