feat(settings): Sprint 1 — extract AgentSettings from monolith (285 lines)
This commit is contained in:
parent
244d5e3938
commit
48dd35000a
1 changed files with 285 additions and 0 deletions
285
src/lib/settings/categories/AgentSettings.svelte
Normal file
285
src/lib/settings/categories/AgentSettings.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue