feat: Sprint 0 — settings panel infrastructure

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.
This commit is contained in:
Hibryda 2026-03-17 04:58:57 +01:00
parent 6ca168e336
commit 244d5e3938
3 changed files with 490 additions and 0 deletions

View file

@ -0,0 +1,294 @@
<script lang="ts">
// SPDX-License-Identifier: MIT
// Settings Panel — VS Code-style sidebar + content layout with search.
import { onMount } from 'svelte';
import { SETTINGS_REGISTRY, type SettingsCategory, type SettingEntry } from './settings-registry';
interface Props {
onClose?: () => void;
}
let { onClose }: Props = $props();
const CATEGORIES: { id: SettingsCategory; label: string; icon: string }[] = [
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
{ id: 'agents', label: 'Agents', icon: '🤖' },
{ id: 'security', label: 'Security', icon: '🛡' },
{ id: 'projects', label: 'Projects', icon: '📁' },
{ id: 'orchestration', label: 'Orchestration', icon: '🔗' },
{ id: 'advanced', label: 'Advanced', icon: '⚡' },
];
let activeCategory = $state<SettingsCategory>('appearance');
let searchQuery = $state('');
let searchResults = $derived(searchQuery.length >= 2
? SETTINGS_REGISTRY.filter(s => {
const q = searchQuery.toLowerCase();
return s.label.toLowerCase().includes(q)
|| s.description.toLowerCase().includes(q)
|| s.keywords.some(k => k.includes(q));
})
: []
);
let showSearch = $derived(searchQuery.length >= 2);
let sidebarRef: HTMLElement | undefined = $state();
function selectCategory(id: SettingsCategory) {
activeCategory = id;
searchQuery = '';
}
function navigateToResult(entry: SettingEntry) {
activeCategory = entry.category;
searchQuery = '';
requestAnimationFrame(() => {
const el = document.getElementById(entry.anchorId);
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
function handleSidebarKeydown(e: KeyboardEvent) {
const idx = CATEGORIES.findIndex(c => c.id === activeCategory);
if (e.key === 'ArrowDown' && idx < CATEGORIES.length - 1) {
e.preventDefault();
selectCategory(CATEGORIES[idx + 1].id);
} else if (e.key === 'ArrowUp' && idx > 0) {
e.preventDefault();
selectCategory(CATEGORIES[idx - 1].id);
} else if (e.key === 'Escape' && onClose) {
onClose();
}
}
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (searchQuery) {
searchQuery = '';
} else if (onClose) {
onClose();
}
} else if (e.key === 'Enter' && searchResults.length > 0) {
navigateToResult(searchResults[0]);
}
}
let searchInput: HTMLInputElement | undefined = $state();
onMount(() => { searchInput?.focus(); });
</script>
<div class="settings-panel" onkeydown={handleSidebarKeydown}>
<div class="settings-header">
<span class="settings-title">Settings</span>
<input
bind:this={searchInput}
class="settings-search"
type="text"
placeholder="Search settings..."
bind:value={searchQuery}
onkeydown={handleSearchKeydown}
aria-label="Search settings"
/>
{#if onClose}
<button class="settings-close" onclick={onClose} aria-label="Close settings">x</button>
{/if}
</div>
{#if showSearch}
<div class="search-results">
{#each searchResults.slice(0, 10) as result}
<button class="search-result" onclick={() => navigateToResult(result)}>
<span class="sr-label">{result.label}</span>
{#if result.pro}<span class="sr-pro">Pro</span>{/if}
<span class="sr-cat">{result.category}</span>
<span class="sr-desc">{result.description}</span>
</button>
{/each}
{#if searchResults.length === 0}
<div class="search-empty">No settings match "{searchQuery}"</div>
{/if}
</div>
{:else}
<div class="settings-body">
<nav class="settings-sidebar" bind:this={sidebarRef} role="tablist" aria-label="Settings categories">
{#each CATEGORIES as cat}
<button
class="sidebar-item"
class:active={activeCategory === cat.id}
onclick={() => selectCategory(cat.id)}
role="tab"
aria-selected={activeCategory === cat.id}
>
<span class="sidebar-icon">{cat.icon}</span>
<span class="sidebar-label">{cat.label}</span>
</button>
{/each}
</nav>
<div class="settings-content" role="tabpanel">
<!-- Category components will be rendered here via dynamic import or {#if} -->
<div class="category-placeholder">
<h2>{CATEGORIES.find(c => c.id === activeCategory)?.label}</h2>
<p>Settings for {activeCategory} will be rendered here.</p>
<p class="setting-count">{SETTINGS_REGISTRY.filter(s => s.category === activeCategory).length} settings</p>
</div>
</div>
</div>
{/if}
</div>
<style>
.settings-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--ctp-base);
color: var(--ctp-text);
}
.settings-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.settings-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--ctp-subtext0);
white-space: nowrap;
}
.settings-search {
flex: 1;
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem;
padding: 0.375rem 0.625rem;
color: var(--ctp-text);
font-size: 0.8125rem;
outline: none;
}
.settings-search:focus {
border-color: var(--ctp-blue);
}
.settings-search::placeholder {
color: var(--ctp-overlay0);
}
.settings-close {
background: none;
border: none;
color: var(--ctp-overlay0);
cursor: pointer;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.settings-close:hover { color: var(--ctp-text); }
.search-results {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.search-result {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.375rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
border-radius: 0.25rem;
color: var(--ctp-text);
cursor: pointer;
text-align: left;
font-size: 0.8125rem;
}
.search-result:hover { background: var(--ctp-surface0); }
.sr-label { font-weight: 600; }
.sr-pro {
font-size: 0.625rem;
padding: 0.0625rem 0.25rem;
background: var(--ctp-peach);
color: var(--ctp-base);
border-radius: 0.125rem;
font-weight: 700;
text-transform: uppercase;
}
.sr-cat {
font-size: 0.6875rem;
color: var(--ctp-overlay0);
margin-left: auto;
}
.sr-desc {
width: 100%;
font-size: 0.75rem;
color: var(--ctp-subtext0);
}
.search-empty {
padding: 2rem;
text-align: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
}
.settings-body {
display: flex;
flex: 1;
overflow: hidden;
}
.settings-sidebar {
display: flex;
flex-direction: column;
width: 10rem;
flex-shrink: 0;
padding: 0.5rem 0;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
overflow-y: auto;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: none;
border: none;
color: var(--ctp-subtext0);
cursor: pointer;
font-size: 0.8125rem;
text-align: left;
border-left: 2px solid transparent;
transition: background 0.1s, color 0.1s;
}
.sidebar-item:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.sidebar-item.active {
color: var(--ctp-blue);
border-left-color: var(--ctp-blue);
background: color-mix(in srgb, var(--ctp-blue) 8%, transparent);
}
.sidebar-icon { font-size: 1rem; }
.sidebar-label { font-weight: 500; }
.settings-content {
flex: 1;
overflow-y: auto;
padding: 1rem 1.5rem;
}
.category-placeholder {
color: var(--ctp-subtext0);
}
.category-placeholder h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--ctp-text);
margin-bottom: 0.5rem;
}
.setting-count {
font-size: 0.75rem;
color: var(--ctp-overlay0);
margin-top: 0.25rem;
}
</style>

View file

@ -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);
}

View file

@ -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<string | null>(null);
let overrideCache = $state<Map<string, ScopeOverride>>(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<string | null> {
// 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<void> {
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<void> {
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<ScopeOverride> {
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 };