- 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
228 lines
11 KiB
Svelte
228 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { appRpc } from '../rpc.ts';
|
|
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
|
|
import SegmentedControl from '../ui/SegmentedControl.svelte';
|
|
import Section from '../ui/Section.svelte';
|
|
|
|
type PermMode = 'bypassPermissions' | 'default' | 'plan';
|
|
|
|
const PROVIDERS: { id: ProviderId; label: string; desc: string }[] = [
|
|
{ id: 'claude', label: 'Claude', desc: 'Anthropic — claude-opus/sonnet/haiku' },
|
|
{ id: 'codex', label: 'Codex', desc: 'OpenAI — gpt-5.4' },
|
|
{ id: 'ollama', label: 'Ollama', desc: 'Local — qwen3, llama3, etc.' },
|
|
{ id: 'gemini', label: 'Gemini', desc: 'Google — gemini-2.5-pro' },
|
|
];
|
|
|
|
const PERM_OPTIONS = [
|
|
{ value: 'bypassPermissions', label: 'Bypass' },
|
|
{ value: 'default', label: 'Default' },
|
|
{ value: 'plan', label: 'Plan' },
|
|
];
|
|
|
|
let defaultShell = $state('/bin/bash');
|
|
let defaultCwd = $state('~');
|
|
let permissionMode = $state<PermMode>('bypassPermissions');
|
|
let systemPrompt = $state('');
|
|
|
|
// Detected shells from system.shells RPC
|
|
let detectedShells = $state<Array<{ path: string; name: string }>>([
|
|
{ path: '/bin/bash', name: 'bash' },
|
|
]);
|
|
|
|
interface ProviderState { enabled: boolean; model: string; }
|
|
let providerState = $state<Record<string, ProviderState>>({
|
|
claude: { enabled: true, model: 'claude-opus-4-5' },
|
|
codex: { enabled: false, model: 'gpt-5.4' },
|
|
ollama: { enabled: false, model: 'qwen3:8b' },
|
|
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<Record<string, ProviderAvail>>({});
|
|
|
|
let expandedProvider = $state<string | null>(null);
|
|
|
|
function persist(key: string, value: string) {
|
|
appRpc?.request['settings.set']({ key, value }).catch(console.error);
|
|
}
|
|
|
|
function persistProviders() {
|
|
persist('provider_settings', JSON.stringify(providerState));
|
|
}
|
|
|
|
function setShell(v: string) { defaultShell = v; persist('default_shell', v); }
|
|
function setCwd(v: string) { defaultCwd = v; persist('default_cwd', v); }
|
|
function setPermMode(v: string) { permissionMode = v as PermMode; persist('permission_mode', v); }
|
|
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 };
|
|
persistProviders();
|
|
}
|
|
|
|
onMount(async () => {
|
|
if (!appRpc) return;
|
|
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
|
|
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<string, ProviderAvail> = {};
|
|
for (const p of provRes.providers) {
|
|
avail[p.id] = { available: p.available, hasApiKey: p.hasApiKey, hasCli: p.hasCli };
|
|
}
|
|
providerAvail = avail;
|
|
} catch { /* keep defaults */ }
|
|
});
|
|
</script>
|
|
|
|
<Section heading="Defaults">
|
|
<div class="field">
|
|
<label class="lbl" for="ag-shell">Shell</label>
|
|
<select id="ag-shell" class="text-in" value={defaultShell}
|
|
onchange={e => setShell((e.target as HTMLSelectElement).value)}>
|
|
{#each detectedShells as shell}
|
|
<option value={shell.path} selected={shell.path === defaultShell}>{shell.name} ({shell.path})</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="lbl" for="ag-cwd">Working directory</label>
|
|
<input id="ag-cwd" class="text-in" value={defaultCwd} placeholder="~"
|
|
onchange={e => setCwd((e.target as HTMLInputElement).value)} />
|
|
</div>
|
|
</Section>
|
|
|
|
<Section heading="Permission mode" spaced>
|
|
<SegmentedControl options={PERM_OPTIONS} selected={permissionMode} onSelect={setPermMode} />
|
|
</Section>
|
|
|
|
<Section heading="System prompt template" spaced>
|
|
<textarea class="prompt" value={systemPrompt} rows="3"
|
|
placeholder="Optional prompt prepended to all agent sessions..."
|
|
onchange={e => setPrompt((e.target as HTMLTextAreaElement).value)}></textarea>
|
|
</Section>
|
|
|
|
<Section heading="Providers" spaced>
|
|
<div class="prov-list">
|
|
{#each PROVIDERS as prov}
|
|
{@const state = providerState[prov.id]}
|
|
{@const available = isProviderAvailable(prov.id)}
|
|
<div class="prov-panel" class:disabled={!state.enabled} class:unavailable={!available}>
|
|
<button class="prov-hdr" onclick={() => expandedProvider = expandedProvider === prov.id ? null : prov.id}>
|
|
<span class="prov-name">{prov.label}</span>
|
|
<span class="prov-desc">{prov.desc}</span>
|
|
{#if !available}<span class="prov-badge" title="Not installed">N/A</span>{/if}
|
|
<span class="prov-chev">{expandedProvider === prov.id ? '\u25B4' : '\u25BE'}</span>
|
|
</button>
|
|
{#if expandedProvider === prov.id}
|
|
<div class="prov-body">
|
|
{#if !available}
|
|
<div class="prov-unavail">Not installed — install the CLI or set an API key to enable.</div>
|
|
{/if}
|
|
<label class="toggle-row">
|
|
<span class="lbl">Enabled</span>
|
|
<button class="toggle" class:on={state.enabled} role="switch"
|
|
aria-checked={state.enabled} aria-label="Toggle {prov.label} provider"
|
|
disabled={!available && !state.enabled}
|
|
title={!available ? 'Not installed' : ''}
|
|
onclick={() => toggleProvider(prov.id)}><span class="thumb"></span></button>
|
|
</label>
|
|
<div class="field">
|
|
<label class="lbl" for="model-{prov.id}">Default model</label>
|
|
<input id="model-{prov.id}" class="text-in" value={state.model}
|
|
placeholder={PROVIDER_CAPABILITIES[prov.id].defaultModel}
|
|
onchange={e => setModel(prov.id, (e.target as HTMLInputElement).value)} />
|
|
</div>
|
|
<div class="caps">
|
|
{#if PROVIDER_CAPABILITIES[prov.id].images}<span class="cap">Images</span>{/if}
|
|
{#if PROVIDER_CAPABILITIES[prov.id].web}<span class="cap">Web</span>{/if}
|
|
{#if PROVIDER_CAPABILITIES[prov.id].upload}<span class="cap">Upload</span>{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</Section>
|
|
|
|
<style>
|
|
.field { display: flex; flex-direction: column; gap: 0.2rem; }
|
|
.lbl { font-size: 0.75rem; color: var(--ctp-subtext0); }
|
|
|
|
.text-in {
|
|
padding: 0.3rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family);
|
|
}
|
|
.text-in:focus { outline: none; border-color: var(--ctp-blue); }
|
|
|
|
.prompt {
|
|
padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8rem;
|
|
font-family: var(--term-font-family, monospace); resize: vertical; min-height: 3rem; line-height: 1.4;
|
|
}
|
|
.prompt:focus { outline: none; border-color: var(--ctp-blue); }
|
|
.prompt::placeholder { color: var(--ctp-overlay0); }
|
|
|
|
.prov-list { display: flex; flex-direction: column; gap: 0.3rem; }
|
|
.prov-panel { background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.3rem; 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.45rem 0.625rem; background: transparent; border: none; color: var(--ctp-text); cursor: pointer; text-align: left; font-size: 0.8rem; font-family: var(--ui-font-family); }
|
|
.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; }
|
|
|
|
.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; 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; }
|
|
</style>
|