feat(settings): Sprint 1 — extract AgentSettings from monolith (285 lines)

This commit is contained in:
Hibryda 2026-03-17 05:05:15 +01:00
parent 244d5e3938
commit 48dd35000a

View file

@ -0,0 +1,285 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getSetting, setSetting } from '../../adapters/settings-bridge';
import { getProviders } from '../../providers/registry.svelte';
import type { ProviderSettings } from '../../providers/types';
import { invoke } from '@tauri-apps/api/core';
let defaultShell = $state('');
let defaultCwd = $state('');
let filesSaveOnBlur = $state(false);
let providerSettings = $state<Record<string, ProviderSettings>>({});
let expandedProvider = $state<string | null>(null);
let registeredProviders = $derived(getProviders());
let permissionMode = $state<'bypassPermissions' | 'default' | 'plan'>('bypassPermissions');
let systemPromptTemplate = $state('');
let contextPressureWarn = $state(75);
let contextPressureCritical = $state(90);
let monthlyTokenBudget = $state('');
let routerProfile = $state<'cost_saver' | 'balanced' | 'quality_first'>('balanced');
onMount(async () => {
const [shell, cwd, saveOnBlur, rawProv, rawPerm, rawPrompt, rawWarn, rawCrit, rawBudget, rawRouter] = await Promise.all([
getSetting('default_shell'), getSetting('default_cwd'), getSetting('files_save_on_blur'),
getSetting('provider_settings'), getSetting('default_permission_mode'),
getSetting('system_prompt_template'), getSetting('context_pressure_warn'),
getSetting('context_pressure_critical'), getSetting('budget_monthly_tokens'),
getSetting('router_profile'),
]);
defaultShell = shell ?? '';
defaultCwd = cwd ?? '';
filesSaveOnBlur = saveOnBlur === 'true';
try { if (rawProv) providerSettings = JSON.parse(rawProv); } catch { providerSettings = {}; }
if (rawPerm === 'bypassPermissions' || rawPerm === 'default' || rawPerm === 'plan') permissionMode = rawPerm;
systemPromptTemplate = rawPrompt ?? '';
contextPressureWarn = rawWarn ? parseInt(rawWarn, 10) : 75;
contextPressureCritical = rawCrit ? parseInt(rawCrit, 10) : 90;
monthlyTokenBudget = rawBudget ?? '';
if (rawRouter === 'cost_saver' || rawRouter === 'balanced' || rawRouter === 'quality_first') routerProfile = rawRouter;
});
async function save(key: string, value: string) {
try { await setSetting(key, value); } catch (e) { console.error(`Failed to save ${key}:`, e); }
}
async function browseDirectory(): Promise<string | null> {
return (await invoke<string | null>('pick_directory')) ?? null;
}
async function saveProviderSettings() { await save('provider_settings', JSON.stringify(providerSettings)); }
function toggleProviderEnabled(id: string) {
const cur = providerSettings[id] ?? { enabled: true, config: {} };
providerSettings[id] = { ...cur, enabled: !cur.enabled };
providerSettings = { ...providerSettings };
saveProviderSettings();
}
function setProviderModel(id: string, model: string) {
const cur = providerSettings[id] ?? { enabled: true, config: {} };
providerSettings[id] = { ...cur, defaultModel: model || undefined };
providerSettings = { ...providerSettings };
saveProviderSettings();
}
function isProviderEnabled(id: string): boolean { return providerSettings[id]?.enabled ?? true; }
</script>
<section class="section">
<h2>Defaults</h2>
<div class="fields">
<div class="field" id="setting-default-shell">
<label for="ag-shell" class="lbl">Shell</label>
<input id="ag-shell" value={defaultShell} placeholder="/bin/bash"
onchange={e => { defaultShell = (e.target as HTMLInputElement).value; save('default_shell', defaultShell); }} />
</div>
<div class="field" id="setting-default-cwd">
<label for="ag-cwd" class="lbl">Working directory</label>
<div class="browse-row">
<input id="ag-cwd" value={defaultCwd} placeholder="~"
onchange={e => { defaultCwd = (e.target as HTMLInputElement).value; save('default_cwd', defaultCwd); }} />
<button class="browse-btn" title="Browse..." onclick={async () => { const d = await browseDirectory(); if (d) { defaultCwd = d; save('default_cwd', d); } }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M3 7v13h18V7H3zm0-2h7l2 2h9v1H3V5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
</div>
<h3 class="sub">Editor</h3>
<div class="fields">
<div class="field">
<label class="lbl toggle-row">
<span>Save on blur</span>
<button class="toggle" class:on={filesSaveOnBlur} role="switch" aria-checked={filesSaveOnBlur}
onclick={() => { filesSaveOnBlur = !filesSaveOnBlur; save('files_save_on_blur', String(filesSaveOnBlur)); }}>
<span class="thumb"></span>
</button>
</label>
<span class="hint">Auto-save files when the editor loses focus</span>
</div>
</div>
</section>
<section class="section">
<h2>Agent Defaults</h2>
<div class="fields">
<div class="field" id="setting-permission-mode">
<span class="lbl">Permission mode</span>
<div class="seg-group">
<button class="seg-btn" class:active={permissionMode === 'bypassPermissions'} onclick={() => { permissionMode = 'bypassPermissions'; save('default_permission_mode', permissionMode); }}>Bypass</button>
<button class="seg-btn" class:active={permissionMode === 'default'} onclick={() => { permissionMode = 'default'; save('default_permission_mode', permissionMode); }}>Default</button>
<button class="seg-btn" class:active={permissionMode === 'plan'} onclick={() => { permissionMode = 'plan'; save('default_permission_mode', permissionMode); }}>Plan</button>
</div>
</div>
<div class="field" id="setting-system-prompt">
<label for="ag-prompt" class="lbl">System prompt template</label>
<textarea id="ag-prompt" class="prompt" value={systemPromptTemplate} placeholder="Optional system prompt prepended to agent sessions..." rows={4}
onchange={e => { systemPromptTemplate = (e.target as HTMLTextAreaElement).value; save('system_prompt_template', systemPromptTemplate); }}></textarea>
</div>
<div class="field" id="setting-context-warn">
<label for="ag-warn" class="lbl">Context pressure warning %</label>
<div class="num-row">
<input id="ag-warn" type="number" min="0" max="100" value={contextPressureWarn} class="num"
onchange={e => { const n = parseInt((e.target as HTMLInputElement).value, 10); if (!isNaN(n) && n >= 0 && n <= 100) { contextPressureWarn = n; save('context_pressure_warn', String(n)); } }} />
<span class="unit">%</span>
</div>
<span class="hint">Badge turns yellow at this threshold</span>
</div>
<div class="field" id="setting-context-critical">
<label for="ag-crit" class="lbl">Context pressure critical %</label>
<div class="num-row">
<input id="ag-crit" type="number" min="0" max="100" value={contextPressureCritical} class="num"
onchange={e => { const n = parseInt((e.target as HTMLInputElement).value, 10); if (!isNaN(n) && n >= 0 && n <= 100) { contextPressureCritical = n; save('context_pressure_critical', String(n)); } }} />
<span class="unit">%</span>
</div>
<span class="hint">Badge turns red at this threshold</span>
</div>
</div>
</section>
<section class="section">
<h2>Providers</h2>
<div class="prov-list">
{#each registeredProviders as provider}
<div class="prov-panel" class:disabled={!isProviderEnabled(provider.id)}>
<button class="prov-hdr" onclick={() => { expandedProvider = expandedProvider === provider.id ? null : provider.id; }}>
<span class="prov-name">{provider.name}</span>
<span class="prov-desc">{provider.description}</span>
<span class="prov-chev">{expandedProvider === provider.id ? '\u25B4' : '\u25BE'}</span>
</button>
{#if expandedProvider === provider.id}
<div class="prov-body">
<div class="field">
<label class="lbl toggle-row">
<span>Enabled</span>
<button class="toggle" class:on={isProviderEnabled(provider.id)} role="switch"
aria-checked={isProviderEnabled(provider.id)} aria-label="Toggle {provider.name}"
onclick={() => toggleProviderEnabled(provider.id)}>
<span class="thumb"></span>
</button>
</label>
</div>
{#if provider.capabilities.hasModelSelection}
<div class="field">
<span class="lbl">Default model</span>
<input value={providerSettings[provider.id]?.defaultModel ?? provider.defaultModel ?? ''}
placeholder={provider.defaultModel ?? 'default'}
onchange={e => setProviderModel(provider.id, (e.target as HTMLInputElement).value)} />
</div>
{/if}
<div class="caps-section">
<span class="lbl">Capabilities</span>
<div class="caps">
{#if provider.capabilities.hasProfiles}<span class="cap">Profiles</span>{/if}
{#if provider.capabilities.hasSkills}<span class="cap">Skills</span>{/if}
{#if provider.capabilities.supportsSubagents}<span class="cap">Subagents</span>{/if}
{#if provider.capabilities.supportsCost}<span class="cap">Cost tracking</span>{/if}
{#if provider.capabilities.supportsResume}<span class="cap">Resume</span>{/if}
{#if provider.capabilities.hasSandbox}<span class="cap">Sandbox</span>{/if}
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
</section>
<section class="section">
<h2>Pro Features</h2>
<div class="fields">
<div class="field" id="setting-budget">
<label for="ag-budget" class="lbl">Monthly token budget <span class="pro">Pro</span></label>
<input id="ag-budget" value={monthlyTokenBudget} placeholder="e.g. 10000000"
onchange={e => { monthlyTokenBudget = (e.target as HTMLInputElement).value; save('budget_monthly_tokens', monthlyTokenBudget); }} />
<span class="hint">Maximum tokens per month for cost control</span>
</div>
<div class="field" id="setting-router">
<span class="lbl">Model router profile <span class="pro">Pro</span></span>
<div class="seg-group">
<button class="seg-btn" class:active={routerProfile === 'cost_saver'} onclick={() => { routerProfile = 'cost_saver'; save('router_profile', routerProfile); }}>Cost Saver</button>
<button class="seg-btn" class:active={routerProfile === 'balanced'} onclick={() => { routerProfile = 'balanced'; save('router_profile', routerProfile); }}>Balanced</button>
<button class="seg-btn" class:active={routerProfile === 'quality_first'} onclick={() => { routerProfile = 'quality_first'; save('router_profile', routerProfile); }}>Quality First</button>
</div>
<span class="hint">Routes prompts to cost-optimal or quality-optimal models</span>
</div>
</div>
</section>
<style>
h2 { font-size: 0.875rem; font-weight: 600; color: var(--ctp-text); margin: 0 0 0.625rem; padding-bottom: 0.375rem; border-bottom: 1px solid var(--ctp-surface0); }
.section { margin-bottom: 1.25rem; }
.fields { display: flex; flex-direction: column; gap: 0.625rem; }
.field { display: flex; flex-direction: column; gap: 0.25rem; }
.lbl { font-size: 0.7rem; color: var(--ctp-subtext0); text-transform: uppercase; letter-spacing: 0.03em; }
.hint { font-size: 0.625rem; color: var(--ctp-overlay0); }
.sub { font-size: 0.725rem; font-weight: 600; color: var(--ctp-subtext1); margin: 0.75rem 0 0.5rem; }
.field > input, .prov-body > .field > input, .browse-row input, .prompt {
padding: 0.375rem 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8rem;
}
.prompt { font-family: var(--term-font-family, monospace); resize: vertical; min-height: 4rem; line-height: 1.4; }
.prompt:focus { outline: none; border-color: var(--ctp-blue); }
.prompt::placeholder { color: var(--ctp-overlay0); }
.browse-row { display: flex; gap: 0.25rem; align-items: stretch; }
.browse-row input { flex: 1; min-width: 0; }
.browse-btn {
display: flex; align-items: center; justify-content: center; padding: 0 0.5rem;
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
color: var(--ctp-subtext0); cursor: pointer; flex-shrink: 0;
}
.browse-btn:hover { color: var(--ctp-text); background: var(--ctp-surface1); }
.toggle-row { display: flex; align-items: center; justify-content: space-between; cursor: pointer; }
.toggle {
position: relative; width: 2rem; height: 1.125rem; border: none; border-radius: 0.5625rem;
background: var(--ctp-surface1); cursor: pointer; transition: background 0.2s; padding: 0; flex-shrink: 0;
}
.toggle.on { background: var(--ctp-blue); }
.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;
}
.toggle.on .thumb { transform: translateX(0.875rem); }
.seg-group { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
.seg-btn {
flex: 1; padding: 0.25rem 0.5rem; border: none; background: var(--ctp-surface0);
color: var(--ctp-overlay1); font-size: 0.7rem; font-weight: 500; cursor: pointer; transition: background 0.12s, color 0.12s;
}
.seg-btn:not(:last-child) { border-right: 1px solid var(--ctp-surface1); }
.seg-btn:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
.seg-btn.active { background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); color: var(--ctp-blue); font-weight: 600; }
.num-row { display: flex; align-items: center; gap: 0.25rem; }
.num {
width: 4.5rem; padding: 0.375rem 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8rem; -moz-appearance: textfield;
}
.num::-webkit-inner-spin-button, .num::-webkit-outer-spin-button { -webkit-appearance: none; }
.unit { font-size: 0.7rem; color: var(--ctp-overlay0); }
.pro {
display: inline-block; font-size: 0.5625rem; padding: 0.0625rem 0.3125rem; background: var(--ctp-peach);
color: var(--ctp-base); border-radius: 0.125rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em; vertical-align: middle; margin-left: 0.375rem;
}
.prov-list { display: flex; flex-direction: column; gap: 0.375rem; }
.prov-panel { background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.375rem; overflow: hidden; transition: opacity 0.15s; }
.prov-panel.disabled { opacity: 0.5; }
.prov-hdr {
display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.5rem 0.625rem;
background: transparent; border: none; color: var(--ctp-text); cursor: pointer; text-align: left; font-size: 0.8rem;
}
.prov-hdr:hover { background: var(--ctp-base); }
.prov-name { font-weight: 600; white-space: nowrap; }
.prov-desc { flex: 1; color: var(--ctp-overlay0); font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prov-chev { color: var(--ctp-overlay0); font-size: 0.7rem; flex-shrink: 0; }
.prov-body { padding: 0.5rem 0.625rem; border-top: 1px solid var(--ctp-surface1); display: flex; flex-direction: column; gap: 0.5rem; }
.prov-body > .field > input { background: var(--ctp-base); }
.caps-section { display: flex; flex-direction: column; gap: 0.25rem; }
.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; white-space: nowrap; }
</style>