feat(electrobun): wire EVERYTHING — all settings persist, theme editor, marketplace

All settings wired to SQLite persistence:
- AgentSettings: shell, CWD, permissions, providers (JSON blob)
- SecuritySettings: branch policies (JSON array)
- ProjectSettings: per-project via setProject RPC
- OrchestrationSettings: wake, anchors, notifications
- AdvancedSettings: logging, OTLP, plugins, import/export JSON

Theme Editor:
- 26 color pickers (14 Accents + 12 Neutrals)
- Live CSS var preview as you pick colors
- Save custom theme to SQLite, cancel reverts
- Import/export theme as JSON
- Custom themes in dropdown with delete button

Extensions Marketplace:
- 8-plugin demo catalog (Browse/Installed tabs)
- Search/filter by name or tag
- Install/uninstall with SQLite persistence
- Plugin cards with emoji icons, tags, version

Terminal font hot-swap:
- fontStore.onTermFontChange() → xterm.js options update + fitAddon.fit()
- Resize notification to PTY daemon after font change

All 7 settings categories functional. Every control persists and takes effect.
This commit is contained in:
Hibryda 2026-03-20 05:45:10 +01:00
parent 6002a379e4
commit 5032021915
20 changed files with 1005 additions and 271 deletions

View file

@ -231954,6 +231954,9 @@ class SettingsDb {
const json = JSON.stringify(palette);
this.db.query("INSERT INTO custom_themes (id, name, palette) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, palette = excluded.palette").run(id, name531, json);
}
deleteCustomTheme(id) {
this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id);
}
close() {
this.db.close();
}
@ -232078,6 +232081,32 @@ var rpc = BrowserView.defineRPC({
console.error("[settings.setProject]", err);
return { ok: false };
}
},
"themes.getCustom": () => {
try {
return { themes: settingsDb.getCustomThemes() };
} catch (err) {
console.error("[themes.getCustom]", err);
return { themes: [] };
}
},
"themes.saveCustom": ({ id, name: name531, palette }) => {
try {
settingsDb.saveCustomTheme(id, name531, palette);
return { ok: true };
} catch (err) {
console.error("[themes.saveCustom]", err);
return { ok: false };
}
},
"themes.deleteCustom": ({ id }) => {
try {
settingsDb.deleteCustomTheme(id);
return { ok: true };
} catch (err) {
console.error("[themes.deleteCustom]", err);
return { ok: false };
}
}
},
messages: {}

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Svelte App</title>
<script type="module" crossorigin src="/assets/index-CZZnRPP5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DSZtflYD.css">
<script type="module" crossorigin src="/assets/index-CEtguZVp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dvoh622C.css">
</head>
<body>
<div id="app"></div>

View file

@ -151,6 +151,37 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
return { ok: false };
}
},
// ── Custom Themes handlers ───────────────────────────────────────────
"themes.getCustom": () => {
try {
return { themes: settingsDb.getCustomThemes() };
} catch (err) {
console.error("[themes.getCustom]", err);
return { themes: [] };
}
},
"themes.saveCustom": ({ id, name, palette }) => {
try {
settingsDb.saveCustomTheme(id, name, palette);
return { ok: true };
} catch (err) {
console.error("[themes.saveCustom]", err);
return { ok: false };
}
},
"themes.deleteCustom": ({ id }) => {
try {
settingsDb.deleteCustomTheme(id);
return { ok: true };
} catch (err) {
console.error("[themes.deleteCustom]", err);
return { ok: false };
}
},
},
messages: {

View file

@ -162,6 +162,10 @@ export class SettingsDb {
.run(id, name, json);
}
deleteCustomTheme(id: string): void {
this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
close(): void {

View file

@ -5,6 +5,7 @@
import ProjectSettings from './settings/ProjectSettings.svelte';
import OrchestrationSettings from './settings/OrchestrationSettings.svelte';
import AdvancedSettings from './settings/AdvancedSettings.svelte';
import MarketplaceTab from './settings/MarketplaceTab.svelte';
interface Props {
open: boolean;
@ -13,7 +14,7 @@
let { open, onClose }: Props = $props();
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced';
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced' | 'marketplace';
interface Category {
id: CategoryId;
@ -28,6 +29,7 @@
{ id: 'projects', label: 'Projects', icon: '📁' },
{ id: 'orchestration', label: 'Orchestration', icon: '⚙' },
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
];
let activeCategory = $state<CategoryId>('appearance');
@ -92,6 +94,8 @@
<OrchestrationSettings />
{:else if activeCategory === 'advanced'}
<AdvancedSettings />
{:else if activeCategory === 'marketplace'}
<MarketplaceTab />
{/if}
</div>
</div>
@ -126,7 +130,6 @@
to { transform: translateX(0); opacity: 1; }
}
/* Header */
.drawer-header {
height: 3rem;
display: flex;
@ -164,7 +167,6 @@
color: var(--ctp-text);
}
/* Body: two-column layout */
.drawer-body {
flex: 1;
min-height: 0;
@ -172,7 +174,6 @@
overflow: hidden;
}
/* Category nav sidebar */
.cat-nav {
width: 8.5rem;
flex-shrink: 0;
@ -226,7 +227,6 @@
text-overflow: ellipsis;
}
/* Content area */
.cat-content {
flex: 1;
min-width: 0;

View file

@ -5,6 +5,9 @@
import { FitAddon } from '@xterm/addon-fit';
import { ImageAddon } from '@xterm/addon-image';
import { electrobun } from './main.ts';
import { fontStore } from './font-store.svelte.ts';
import { themeStore } from './theme-store.svelte.ts';
import { getXtermTheme } from './themes.ts';
interface Props {
sessionId: string;
@ -14,34 +17,11 @@
let { sessionId, cwd }: Props = $props();
// Catppuccin Mocha terminal theme
const THEME = {
background: '#1e1e2e',
foreground: '#cdd6f4',
cursor: '#f5e0dc',
cursorAccent: '#1e1e2e',
selectionBackground: '#585b7066',
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#f5c2e7',
cyan: '#94e2d5',
white: '#bac2de',
brightBlack: '#585b70',
brightRed: '#f38ba8',
brightGreen: '#a6e3a1',
brightYellow: '#f9e2af',
brightBlue: '#89b4fa',
brightMagenta: '#f5c2e7',
brightCyan: '#94e2d5',
brightWhite: '#a6adc8',
};
let termEl: HTMLDivElement;
let term: Terminal;
let fitAddon: FitAddon;
let unsubFont: (() => void) | null = null;
let ro: ResizeObserver | null = null;
/** Decode a base64 string from the daemon into a Uint8Array. */
function decodeBase64(b64: string): Uint8Array {
@ -51,11 +31,15 @@
return bytes;
}
onMount(async () => {
onMount(() => {
const currentTheme = themeStore.currentTheme;
const termFamily = fontStore.termFontFamily || 'JetBrains Mono, Fira Code, monospace';
const termSize = fontStore.termFontSize || 13;
term = new Terminal({
theme: THEME,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: 13,
theme: getXtermTheme(currentTheme),
fontFamily: termFamily,
fontSize: termSize,
cursorBlink: true,
allowProposedApi: true,
scrollback: 5000,
@ -65,7 +49,6 @@
term.loadAddon(fitAddon);
term.loadAddon(new CanvasAddon());
// Sixel / iTerm2 / Kitty inline image support
term.loadAddon(new ImageAddon({
enableSizeReports: true,
sixelSupport: true,
@ -77,25 +60,34 @@
term.open(termEl);
fitAddon.fit();
// ── Connect to PTY daemon via Bun RPC ──────────────────────────────────
// ── Subscribe to terminal font changes ─────────────────────────────────
unsubFont = fontStore.onTermFontChange((family: string, size: number) => {
term.options.fontFamily = family || 'JetBrains Mono, Fira Code, monospace';
term.options.fontSize = size;
fitAddon.fit();
electrobun.rpc?.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {});
});
// ── Connect to PTY daemon (fire-and-forget from onMount) ───────────────
void (async () => {
const { cols, rows } = term;
const result = await electrobun.rpc?.request["pty.create"]({ sessionId, cols, rows, cwd });
const result = await electrobun.rpc?.request['pty.create']({ sessionId, cols, rows, cwd });
if (!result?.ok) {
term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`);
term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m');
}
})();
// ── Receive output from daemon (via Bun) ───────────────────────────────
// ── Receive output from daemon ─────────────────────────────────────────
// "pty.output" messages are pushed by Bun whenever the PTY produces data.
electrobun.rpc?.addMessageListener("pty.output", ({ sessionId: sid, data }) => {
electrobun.rpc?.addMessageListener('pty.output', ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
if (sid !== sessionId) return;
term.write(decodeBase64(data));
});
// "pty.closed" fires when the shell exits.
electrobun.rpc?.addMessageListener("pty.closed", ({ sessionId: sid, exitCode }) => {
electrobun.rpc?.addMessageListener('pty.closed', ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => {
if (sid !== sessionId) return;
term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`);
});
@ -103,7 +95,7 @@
// ── Send user input to daemon ──────────────────────────────────────────
term.onData((data: string) => {
electrobun.rpc?.request["pty.write"]({ sessionId, data }).catch((err) => {
electrobun.rpc?.request['pty.write']({ sessionId, data }).catch((err: unknown) => {
console.error('[pty.write] error:', err);
});
});
@ -111,22 +103,17 @@
// ── Sync resize events to daemon ───────────────────────────────────────
term.onResize(({ cols: c, rows: r }) => {
electrobun.rpc?.request["pty.resize"]({ sessionId, cols: c, rows: r }).catch(() => {});
electrobun.rpc?.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {});
});
// Refit on container resize and notify the daemon.
const ro = new ResizeObserver(() => {
fitAddon.fit();
});
ro = new ResizeObserver(() => { fitAddon.fit(); });
ro.observe(termEl);
return () => ro.disconnect();
});
onDestroy(() => {
// Unsubscribe from daemon output (session stays alive so it can be
// reconnected if the tab is re-opened, matching the "persists" requirement).
electrobun.rpc?.request["pty.unsubscribe"]({ sessionId }).catch(() => {});
unsubFont?.();
ro?.disconnect();
electrobun.rpc?.request['pty.unsubscribe']({ sessionId }).catch(() => {});
term?.dispose();
});
</script>
@ -140,7 +127,6 @@
min-height: 10rem;
}
/* xterm.js base styles */
:global(.xterm) {
padding: 0.5rem;
}

View file

@ -1,15 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
const LOG_LEVELS: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error'];
// Demo plugin list
interface Plugin {
id: string;
name: string;
version: string;
enabled: boolean;
}
interface Plugin { id: string; name: string; version: string; enabled: boolean; }
let plugins = $state<Plugin[]>([
{ id: 'quanta-plugin', name: 'Quanta Plugin', version: '1.2.0', enabled: true },
@ -24,9 +21,22 @@
let appVersion = $state('3.0.0-dev');
let updateChecking = $state(false);
let updateResult = $state<string | null>(null);
let importError = $state<string | null>(null);
function persist(key: string, value: string) {
appRpc?.request['settings.set']({ key, value }).catch(console.error);
}
function setLogLevel(v: LogLevel) { logLevel = v; persist('log_level', v); }
function setOtlp(v: string) { otlpEndpoint = v; persist('otlp_endpoint', v); }
function setRelayUrls(v: string) { relayUrls = v; persist('relay_urls', v); }
function setConnTimeout(v: number) { connTimeout = v; persist('connection_timeout', String(v)); }
function togglePlugin(id: string) {
plugins = plugins.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p);
const states: Record<string, boolean> = {};
for (const p of plugins) states[p.id] = p.enabled;
persist('plugin_states', JSON.stringify(states));
}
function checkForUpdates() {
@ -38,38 +48,88 @@
}, 1200);
}
function handleExport() {
const data = JSON.stringify({ logLevel, otlpEndpoint, relayUrls, connTimeout }, null, 2);
async function handleExport() {
if (!appRpc) return;
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
const data = JSON.stringify({ version: 1, settings }, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'agor-settings.json'; a.click();
URL.revokeObjectURL(url);
}
function handleImport() {
importError = null;
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.onchange = async () => {
const file = input.files?.[0];
if (!file || !appRpc) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
const settings: Record<string, string> = parsed.settings ?? parsed;
if (typeof settings !== 'object') throw new Error('Invalid format');
for (const [key, value] of Object.entries(settings)) {
if (typeof value === 'string') {
await appRpc.request['settings.set']({ key, value });
}
}
// Refresh local state
await loadSettings();
} catch (err) {
importError = err instanceof Error ? err.message : 'Import failed';
}
};
input.click();
}
async function loadSettings() {
if (!appRpc) return;
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
if (settings['log_level']) logLevel = settings['log_level'] as LogLevel;
if (settings['otlp_endpoint']) otlpEndpoint = settings['otlp_endpoint'];
if (settings['relay_urls']) relayUrls = settings['relay_urls'];
if (settings['connection_timeout']) connTimeout = parseInt(settings['connection_timeout'], 10) || 30;
if (settings['plugin_states']) {
try {
const states: Record<string, boolean> = JSON.parse(settings['plugin_states']);
plugins = plugins.map(p => ({ ...p, enabled: states[p.id] ?? p.enabled }));
} catch { /* ignore */ }
}
}
onMount(loadSettings);
</script>
<div class="section">
<h3 class="sh">Logging</h3>
<div class="seg">
{#each LOG_LEVELS as l}
<button class:active={logLevel === l} onclick={() => logLevel = l}>{l}</button>
<button class:active={logLevel === l} onclick={() => setLogLevel(l)}>{l}</button>
{/each}
</div>
<h3 class="sh" style="margin-top: 0.75rem;">Telemetry</h3>
<div class="field">
<label class="lbl" for="adv-otlp">OTLP endpoint</label>
<input id="adv-otlp" class="text-in" bind:value={otlpEndpoint} placeholder="http://localhost:4318" />
<input id="adv-otlp" class="text-in" value={otlpEndpoint} placeholder="http://localhost:4318"
onchange={e => setOtlp((e.target as HTMLInputElement).value)} />
</div>
<h3 class="sh" style="margin-top: 0.75rem;">Relay</h3>
<div class="field">
<label class="lbl" for="adv-relay">Relay URLs (one per line)</label>
<textarea id="adv-relay" class="prompt" rows="2" bind:value={relayUrls} placeholder="wss://relay.example.com:9800"></textarea>
<textarea id="adv-relay" class="prompt" rows="2" value={relayUrls}
placeholder="wss://relay.example.com:9800"
onchange={e => setRelayUrls((e.target as HTMLTextAreaElement).value)}></textarea>
</div>
<div class="field row">
<label class="lbl" for="adv-timeout">Connection timeout</label>
<input id="adv-timeout" class="num-in" type="number" min="5" max="120" bind:value={connTimeout} />
<input id="adv-timeout" class="num-in" type="number" min="5" max="120" value={connTimeout}
onchange={e => setConnTimeout(parseInt((e.target as HTMLInputElement).value, 10) || 30)} />
<span class="unit">seconds</span>
</div>
@ -81,14 +141,9 @@
<span class="plug-name">{plug.name}</span>
<span class="plug-ver">v{plug.version}</span>
</div>
<button
class="toggle"
class:on={plug.enabled}
role="switch"
aria-checked={plug.enabled}
aria-label="Toggle {plug.name}"
onclick={() => togglePlugin(plug.id)}
><span class="thumb"></span></button>
<button class="toggle" class:on={plug.enabled} role="switch"
aria-checked={plug.enabled} aria-label="Toggle {plug.name}"
onclick={() => togglePlugin(plug.id)}><span class="thumb"></span></button>
</div>
{/each}
{#if plugins.length === 0}
@ -110,8 +165,11 @@
<h3 class="sh" style="margin-top: 0.75rem;">Settings Data</h3>
<div class="data-row">
<button class="action-btn" onclick={handleExport}>Export settings</button>
<button class="action-btn secondary">Import settings</button>
<button class="action-btn secondary" onclick={handleImport}>Import settings</button>
</div>
{#if importError}
<p class="import-error">{importError}</p>
{/if}
</div>
<style>
@ -127,8 +185,8 @@
.prompt:focus { outline: none; border-color: var(--ctp-blue); }
.prompt::placeholder { color: var(--ctp-overlay0); }
.num-in { width: 4rem; 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; -moz-appearance: textfield; }
.num-in::-webkit-inner-spin-button, .num-in::-webkit-outer-spin-button { -webkit-appearance: none; }
.num-in { width: 4rem; 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; appearance: textfield; -moz-appearance: textfield; }
.num-in::-webkit-inner-spin-button, .num-in::-webkit-outer-spin-button { -webkit-appearance: none; appearance: none; }
.num-in:focus { outline: none; border-color: var(--ctp-blue); }
.unit { font-size: 0.75rem; color: var(--ctp-overlay0); }
@ -153,6 +211,7 @@
.update-row { display: flex; align-items: center; gap: 0.625rem; }
.version-label { font-size: 0.75rem; color: var(--ctp-overlay1); font-family: var(--term-font-family, monospace); }
.update-result { font-size: 0.75rem; color: var(--ctp-green); margin: 0.125rem 0 0; }
.import-error { font-size: 0.75rem; color: var(--ctp-red); margin: 0.125rem 0 0; }
.data-row { display: flex; gap: 0.5rem; }

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
type PermMode = 'bypassPermissions' | 'default' | 'plan';
@ -14,10 +16,7 @@
let permissionMode = $state<PermMode>('bypassPermissions');
let systemPrompt = $state('');
interface ProviderState {
enabled: boolean;
model: string;
}
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' },
@ -26,15 +25,48 @@
let expandedProvider = $state<string | null>(null);
// ── Persistence helpers ──────────────────────────────────────────────────
function persist(key: string, value: string) {
appRpc?.request['settings.set']({ key, value }).catch(console.error);
}
function persistProviders() {
persist('provider_settings', JSON.stringify(providerState));
}
// ── Actions ──────────────────────────────────────────────────────────────
function setShell(v: string) { defaultShell = v; persist('default_shell', v); }
function setCwd(v: string) { defaultCwd = v; persist('default_cwd', v); }
function setPermMode(v: PermMode) { permissionMode = v; persist('permission_mode', v); }
function setPrompt(v: string) { systemPrompt = v; persist('system_prompt_template', v); }
function toggleProvider(id: string) {
providerState[id] = { ...providerState[id], enabled: !providerState[id].enabled };
providerState = { ...providerState };
persistProviders();
}
function setModel(id: string, model: string) {
providerState[id] = { ...providerState[id], model };
providerState = { ...providerState };
persistProviders();
}
// ── Restore on mount ─────────────────────────────────────────────────────
onMount(async () => {
if (!appRpc) return;
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
if (settings['default_shell']) defaultShell = settings['default_shell'];
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 */ }
}
});
</script>
<div class="section">
@ -42,23 +74,27 @@
<div class="field">
<label class="lbl" for="ag-shell">Shell</label>
<input id="ag-shell" class="text-in" bind:value={defaultShell} placeholder="/bin/bash" />
<input id="ag-shell" class="text-in" value={defaultShell} placeholder="/bin/bash"
onchange={e => setShell((e.target as HTMLInputElement).value)} />
</div>
<div class="field">
<label class="lbl" for="ag-cwd">Working directory</label>
<input id="ag-cwd" class="text-in" bind:value={defaultCwd} placeholder="~" />
<input id="ag-cwd" class="text-in" value={defaultCwd} placeholder="~"
onchange={e => setCwd((e.target as HTMLInputElement).value)} />
</div>
<h3 class="sh" style="margin-top: 0.75rem;">Permission mode</h3>
<div class="seg">
<button class:active={permissionMode === 'bypassPermissions'} onclick={() => permissionMode = 'bypassPermissions'}>Bypass</button>
<button class:active={permissionMode === 'default'} onclick={() => permissionMode = 'default'}>Default</button>
<button class:active={permissionMode === 'plan'} onclick={() => permissionMode = 'plan'}>Plan</button>
<button class:active={permissionMode === 'bypassPermissions'} onclick={() => setPermMode('bypassPermissions')}>Bypass</button>
<button class:active={permissionMode === 'default'} onclick={() => setPermMode('default')}>Default</button>
<button class:active={permissionMode === 'plan'} onclick={() => setPermMode('plan')}>Plan</button>
</div>
<h3 class="sh" style="margin-top: 0.75rem;">System prompt template</h3>
<textarea class="prompt" bind:value={systemPrompt} rows="3" placeholder="Optional prompt prepended to all agent sessions..."></textarea>
<textarea class="prompt" value={systemPrompt} rows="3"
placeholder="Optional prompt prepended to all agent sessions..."
onchange={e => setPrompt((e.target as HTMLTextAreaElement).value)}></textarea>
<h3 class="sh" style="margin-top: 0.75rem;">Providers</h3>
<div class="prov-list">
@ -74,24 +110,15 @@
<div class="prov-body">
<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"
onclick={() => toggleProvider(prov.id)}
><span class="thumb"></span></button>
<button class="toggle" class:on={state.enabled} role="switch"
aria-checked={state.enabled} aria-label="Toggle {prov.label} provider"
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}
<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)}
/>
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}

View file

@ -1,7 +1,10 @@
<script lang="ts">
import { THEMES, THEME_GROUPS, type ThemeId } from '../themes.ts';
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { THEMES, THEME_GROUPS, getPalette, type ThemeId, type ThemeMeta } from '../themes.ts';
import { themeStore } from '../theme-store.svelte.ts';
import { fontStore } from '../font-store.svelte.ts';
import ThemeEditor from './ThemeEditor.svelte';
const UI_FONTS = [
{ value: '', label: 'System Default' },
@ -22,7 +25,7 @@
{ value: 'monospace', label: 'monospace' },
];
// ── Local reactive state (mirrors store) ───────────────────────────────────
// ── Local reactive state ───────────────────────────────────────────────────
let themeId = $state<ThemeId>(themeStore.currentTheme);
let uiFont = $state(fontStore.uiFontFamily);
let uiFontSize = $state(fontStore.uiFontSize);
@ -32,7 +35,11 @@
let cursorBlink = $state(true);
let scrollback = $state(1000);
// Keep local state in sync when store changes (e.g. after initTheme)
interface CustomThemeMeta { id: string; name: string; }
let customThemes = $state<CustomThemeMeta[]>([]);
let showEditor = $state(false);
// Keep local state in sync when store changes
$effect(() => { themeId = themeStore.currentTheme; });
$effect(() => { uiFont = fontStore.uiFontFamily; });
$effect(() => { uiFontSize = fontStore.uiFontSize; });
@ -44,28 +51,33 @@
let uiFontOpen = $state(false);
let termFontOpen = $state(false);
// ── All themes (built-in + custom) ────────────────────────────────────────
let allThemes = $derived<ThemeMeta[]>([
...THEMES,
...customThemes.map(t => ({ id: t.id as ThemeId, label: t.name, group: 'Custom', isDark: true })),
]);
let allGroups = $derived([...THEME_GROUPS, ...(customThemes.length > 0 ? ['Custom'] : [])]);
// ── Derived labels ─────────────────────────────────────────────────────────
let themeLabel = $derived(THEMES.find(t => t.id === themeId)?.label ?? 'Catppuccin Mocha');
let themeLabel = $derived(allThemes.find(t => t.id === themeId)?.label ?? 'Catppuccin Mocha');
let uiFontLabel = $derived(UI_FONTS.find(f => f.value === uiFont)?.label ?? 'System Default');
let termFontLabel = $derived(TERM_FONTS.find(f => f.value === termFont)?.label ?? 'Default (JetBrains Mono)');
// ── Actions ────────────────────────────────────────────────────────────────
function selectTheme(id: ThemeId): void {
themeId = id;
themeOpen = false;
themeId = id; themeOpen = false;
themeStore.setTheme(id);
appRpc?.request['settings.set']({ key: 'theme', value: id }).catch(console.error);
}
function selectUIFont(value: string): void {
uiFont = value;
uiFontOpen = false;
uiFont = value; uiFontOpen = false;
fontStore.setUIFont(value, uiFontSize);
}
function selectTermFont(value: string): void {
termFont = value;
termFontOpen = false;
termFont = value; termFontOpen = false;
fontStore.setTermFont(value, termFontSize);
}
@ -79,19 +91,64 @@
fontStore.setTermFont(termFont, termFontSize);
}
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; }
function persistCursorStyle(v: string) {
cursorStyle = v;
appRpc?.request['settings.set']({ key: 'cursor_style', value: v }).catch(console.error);
}
function persistCursorBlink(v: boolean) {
cursorBlink = v;
appRpc?.request['settings.set']({ key: 'cursor_blink', value: String(v) }).catch(console.error);
}
function persistScrollback(v: number) {
scrollback = v;
appRpc?.request['settings.set']({ key: 'scrollback', value: String(v) }).catch(console.error);
}
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; }
function handleOutsideClick(e: MouseEvent): void {
if (!(e.target as HTMLElement).closest('.dd-wrap')) closeAll();
}
function handleKey(e: KeyboardEvent): void {
if (e.key === 'Escape') closeAll();
async function deleteCustomTheme(id: string) {
await appRpc?.request['themes.deleteCustom']({ id }).catch(console.error);
customThemes = customThemes.filter(t => t.id !== id);
if (themeId === id) selectTheme('mocha');
}
function onEditorSave(id: string, name: string) {
customThemes = [...customThemes, { id, name }];
showEditor = false;
selectTheme(id as ThemeId);
}
function onEditorCancel() { showEditor = false; }
onMount(async () => {
if (!appRpc) return;
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
if (settings['cursor_style']) cursorStyle = settings['cursor_style'];
if (settings['cursor_blink']) cursorBlink = settings['cursor_blink'] !== 'false';
if (settings['scrollback']) scrollback = parseInt(settings['scrollback'], 10) || 1000;
const res = await appRpc.request['themes.getCustom']({}).catch(() => ({ themes: [] }));
customThemes = res.themes.map(t => ({ id: t.id, name: t.name }));
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="section" onclick={handleOutsideClick} onkeydown={handleKey}>
<div class="section" onclick={handleOutsideClick} onkeydown={e => e.key === 'Escape' && closeAll()}>
{#if showEditor}
<ThemeEditor
baseThemeId={themeId}
initialPalette={getPalette(themeId)}
onSave={onEditorSave}
onCancel={onEditorCancel}
/>
{:else}
<h3 class="sh">Theme</h3>
<div class="field">
<div class="dd-wrap">
@ -101,20 +158,32 @@
</button>
{#if themeOpen}
<ul class="dd-list" role="listbox">
{#each THEME_GROUPS as group}
{#each allGroups as group}
<li class="dd-group-label" role="presentation">{group}</li>
{#each THEMES.filter(t => t.group === group) as t}
{#each allThemes.filter(t => t.group === group) as t}
<li class="dd-item" class:sel={themeId === t.id}
role="option" aria-selected={themeId === t.id}
tabindex="0"
role="option" aria-selected={themeId === t.id} tabindex="0"
onclick={() => selectTheme(t.id)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTheme(t.id)}
>{t.label}</li>
>
<span class="dd-item-label">{t.label}</span>
{#if t.group === 'Custom'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="del-btn" title="Delete theme"
onclick={e => { e.stopPropagation(); deleteCustomTheme(t.id); }}
onkeydown={e => e.key === 'Enter' && (e.stopPropagation(), deleteCustomTheme(t.id))}
role="button" tabindex="0" aria-label="Delete {t.label}">✕</span>
{/if}
</li>
{/each}
{/each}
</ul>
{/if}
</div>
<div class="theme-actions">
<button class="theme-action-btn" onclick={() => { themeOpen = false; showEditor = true; }}>Edit Theme</button>
<button class="theme-action-btn" onclick={() => { themeOpen = false; showEditor = true; }}>+ Custom</button>
</div>
</div>
<h3 class="sh">UI Font</h3>
@ -127,10 +196,8 @@
{#if uiFontOpen}
<ul class="dd-list" role="listbox">
{#each UI_FONTS as f}
<li class="dd-item" class:sel={uiFont === f.value}
role="option" aria-selected={uiFont === f.value}
tabindex="0"
onclick={() => selectUIFont(f.value)}
<li class="dd-item" class:sel={uiFont === f.value} role="option" aria-selected={uiFont === f.value}
tabindex="0" onclick={() => selectUIFont(f.value)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectUIFont(f.value)}
>{f.label}</li>
{/each}
@ -154,10 +221,8 @@
{#if termFontOpen}
<ul class="dd-list" role="listbox">
{#each TERM_FONTS as f}
<li class="dd-item" class:sel={termFont === f.value}
role="option" aria-selected={termFont === f.value}
tabindex="0"
onclick={() => selectTermFont(f.value)}
<li class="dd-item" class:sel={termFont === f.value} role="option" aria-selected={termFont === f.value}
tabindex="0" onclick={() => selectTermFont(f.value)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTermFont(f.value)}
>{f.label}</li>
{/each}
@ -175,20 +240,24 @@
<div class="field row">
<div class="seg">
{#each ['block', 'line', 'underline'] as s}
<button class:active={cursorStyle === s} onclick={() => cursorStyle = s}>{s[0].toUpperCase() + s.slice(1)}</button>
<button class:active={cursorStyle === s} onclick={() => persistCursorStyle(s)}>{s[0].toUpperCase() + s.slice(1)}</button>
{/each}
</div>
<label class="toggle-row">
<span>Blink</span>
<button class="toggle" class:on={cursorBlink} onclick={() => cursorBlink = !cursorBlink}>{cursorBlink ? 'On' : 'Off'}</button>
<button class="toggle" class:on={cursorBlink} onclick={() => persistCursorBlink(!cursorBlink)}>{cursorBlink ? 'On' : 'Off'}</button>
</label>
</div>
<h3 class="sh">Scrollback</h3>
<div class="field row">
<input type="number" class="num-in" min="100" max="100000" step="100" bind:value={scrollback} aria-label="Scrollback lines" />
<input type="number" class="num-in" min="100" max="100000" step="100" value={scrollback}
onchange={e => persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 1000)}
aria-label="Scrollback lines" />
<span class="hint">lines (100100k)</span>
</div>
{/if}
</div>
<style>
@ -216,18 +285,28 @@
box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
}
.dd-group-label {
padding: 0.25rem 0.5rem 0.125rem;
font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ctp-overlay0);
border-top: 1px solid var(--ctp-surface0);
padding: 0.25rem 0.5rem 0.125rem; font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ctp-overlay0); border-top: 1px solid var(--ctp-surface0);
}
.dd-group-label:first-child { border-top: none; }
.dd-item {
display: flex; align-items: center; justify-content: space-between;
padding: 0.3rem 0.5rem; border-radius: 0.2rem; font-size: 0.8125rem; color: var(--ctp-subtext1);
cursor: pointer; outline: none; list-style: none;
}
.dd-item:hover, .dd-item:focus { background: var(--ctp-surface0); color: var(--ctp-text); }
.dd-item.sel { background: color-mix(in srgb, var(--ctp-mauve) 15%, transparent); color: var(--ctp-mauve); font-weight: 500; }
.dd-item-label { flex: 1; }
.del-btn { font-size: 0.7rem; color: var(--ctp-overlay0); padding: 0.1rem 0.2rem; border-radius: 0.15rem; }
.del-btn:hover { color: var(--ctp-red); background: color-mix(in srgb, var(--ctp-red) 10%, transparent); }
.theme-actions { display: flex; gap: 0.375rem; margin-top: 0.25rem; }
.theme-action-btn {
padding: 0.2rem 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem; color: var(--ctp-subtext1); font-size: 0.75rem; cursor: pointer;
font-family: var(--ui-font-family);
}
.theme-action-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
.stepper { display: flex; align-items: center; gap: 0.25rem; flex-shrink: 0; }
.stepper button { width: 1.375rem; height: 1.375rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.2rem; color: var(--ctp-text); font-size: 0.875rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }

View file

@ -0,0 +1,199 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
interface CatalogPlugin {
id: string;
name: string;
version: string;
description: string;
author: string;
icon: string;
tags: string[];
free: boolean;
}
const DEMO_CATALOG: CatalogPlugin[] = [
{ id: 'secret-scanner', name: 'Secret Scanner', version: '1.0.0', author: 'Quanta', icon: '🔍', tags: ['security'], free: true, description: 'Detects exposed secrets and API keys in agent sessions before they leak.' },
{ id: 'productivity-insights', name: 'Productivity Insights', version: '1.1.0', author: 'Quanta', icon: '📊', tags: ['analytics'], free: true, description: 'Session analytics, cost breakdowns, and agent efficiency metrics.' },
{ id: 'git-guardian', name: 'Git Guardian', version: '0.9.2', author: 'Community',icon: '🛡️', tags: ['security', 'git'], free: true, description: 'Enforces branch policies and blocks commits to protected branches.' },
{ id: 'context-compressor', name: 'Context Compressor', version: '1.0.3', author: 'Quanta', icon: '🗜️', tags: ['performance'], free: true, description: 'Intelligently compresses context windows to reduce token usage.' },
{ id: 'slack-notifier', name: 'Slack Notifier', version: '0.5.0', author: 'Community',icon: '💬', tags: ['notifications'], free: true, description: 'Sends agent completion and error notifications to Slack channels.' },
{ id: 'multi-model-router', name: 'Multi-Model Router', version: '1.2.0', author: 'Quanta', icon: '🔀', tags: ['routing', 'ai'], free: true, description: 'Routes tasks to the cheapest capable model based on complexity scoring.' },
{ id: 'audit-exporter', name: 'Audit Exporter', version: '0.8.1', author: 'Community',icon: '📋', tags: ['compliance'], free: true, description: 'Exports audit logs to SIEM systems (Splunk, Datadog, CloudWatch).' },
{ id: 'test-runner-bridge', name: 'Test Runner Bridge', version: '1.0.0', author: 'Quanta', icon: '🧪', tags: ['testing'], free: true, description: 'Runs test suites on agent-modified code and surfaces failures inline.' },
];
type Tab = 'browse' | 'installed';
let activeTab = $state<Tab>('browse');
let searchQuery = $state('');
let installed = $state<Set<string>>(new Set());
let installing = $state<Set<string>>(new Set());
let filtered = $derived(
DEMO_CATALOG.filter(p => {
if (activeTab === 'installed' && !installed.has(p.id)) return false;
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return p.name.toLowerCase().includes(q)
|| p.description.toLowerCase().includes(q)
|| p.tags.some(t => t.includes(q));
})
);
async function installPlugin(id: string) {
installing = new Set([...installing, id]);
const states: Record<string, boolean> = {};
for (const pid of installed) states[pid] = true;
states[id] = true;
await appRpc?.request['settings.set']({ key: 'marketplace_installed', value: JSON.stringify(states) }).catch(console.error);
installed = new Set([...installed, id]);
installing = new Set([...installing].filter(x => x !== id));
}
async function uninstallPlugin(id: string) {
installed = new Set([...installed].filter(x => x !== id));
const states: Record<string, boolean> = {};
for (const pid of installed) states[pid] = true;
await appRpc?.request['settings.set']({ key: 'marketplace_installed', value: JSON.stringify(states) }).catch(console.error);
}
onMount(async () => {
if (!appRpc) return;
const res = await appRpc.request['settings.get']({ key: 'marketplace_installed' }).catch(() => ({ value: null }));
if (res.value) {
try {
const states: Record<string, boolean> = JSON.parse(res.value);
installed = new Set(Object.entries(states).filter(([, v]) => v).map(([k]) => k));
} catch { /* ignore */ }
}
});
</script>
<div class="marketplace">
<!-- Tab bar -->
<div class="tab-bar">
<button class="tab" class:active={activeTab === 'browse'} onclick={() => activeTab = 'browse'}>Browse</button>
<button class="tab" class:active={activeTab === 'installed'} onclick={() => activeTab = 'installed'}>
Installed {installed.size > 0 ? `(${installed.size})` : ''}
</button>
<div class="search-wrap">
<input class="search-in" bind:value={searchQuery} placeholder="Search plugins…" aria-label="Search marketplace" />
{#if searchQuery}
<button class="search-clear" onclick={() => searchQuery = ''} aria-label="Clear search"></button>
{/if}
</div>
</div>
<!-- Plugin grid -->
<div class="plugin-grid">
{#each filtered as plugin}
<div class="plugin-card">
<div class="card-top">
<span class="plugin-icon" aria-hidden="true">{plugin.icon}</span>
<div class="plugin-meta">
<span class="plugin-name">{plugin.name}</span>
<span class="plugin-author">{plugin.author} · v{plugin.version}</span>
</div>
{#if installed.has(plugin.id)}
<button class="uninstall-btn" onclick={() => uninstallPlugin(plugin.id)} aria-label="Uninstall {plugin.name}">
Installed ✓
</button>
{:else}
<button class="install-btn"
disabled={installing.has(plugin.id)}
onclick={() => installPlugin(plugin.id)}
aria-label="Install {plugin.name}"
>
{installing.has(plugin.id) ? '…' : 'Install'}
</button>
{/if}
</div>
<p class="plugin-desc">{plugin.description}</p>
<div class="tag-row">
{#each plugin.tags as tag}
<span class="tag">{tag}</span>
{/each}
{#if plugin.free}
<span class="tag free">free</span>
{/if}
</div>
</div>
{/each}
{#if filtered.length === 0}
<p class="empty-hint">
{activeTab === 'installed' ? 'No plugins installed yet.' : 'No plugins match your search.'}
</p>
{/if}
</div>
</div>
<style>
.marketplace { display: flex; flex-direction: column; gap: 0.625rem; }
.tab-bar { display: flex; align-items: center; gap: 0.25rem; border-bottom: 1px solid var(--ctp-surface1); padding-bottom: 0.375rem; }
.tab { padding: 0.25rem 0.625rem; background: transparent; border: none; border-radius: 0.25rem; color: var(--ctp-subtext0); font-size: 0.8125rem; cursor: pointer; font-family: var(--ui-font-family); white-space: nowrap; }
.tab:hover { color: var(--ctp-text); background: var(--ctp-surface0); }
.tab.active { color: var(--ctp-mauve); background: color-mix(in srgb, var(--ctp-mauve) 10%, var(--ctp-surface0)); font-weight: 600; }
.search-wrap { position: relative; flex: 1; display: flex; align-items: center; }
.search-in {
width: 100%; padding: 0.25rem 1.75rem 0.25rem 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(--ui-font-family);
}
.search-in:focus { outline: none; border-color: var(--ctp-blue); }
.search-in::placeholder { color: var(--ctp-overlay0); }
.search-clear { position: absolute; right: 0.375rem; background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 0.75rem; padding: 0.125rem; }
.search-clear:hover { color: var(--ctp-text); }
.plugin-grid { display: flex; flex-direction: column; gap: 0.5rem; }
.plugin-card {
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem; padding: 0.625rem;
transition: border-color 0.12s;
}
.plugin-card:hover { border-color: var(--ctp-surface2); }
.card-top { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.375rem; }
.plugin-icon { font-size: 1.5rem; flex-shrink: 0; line-height: 1; margin-top: 0.1rem; }
.plugin-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.125rem; }
.plugin-name { font-size: 0.875rem; font-weight: 600; color: var(--ctp-text); }
.plugin-author { font-size: 0.7rem; color: var(--ctp-overlay1); }
.install-btn {
padding: 0.25rem 0.75rem; background: var(--ctp-blue); border: none; border-radius: 0.25rem;
color: var(--ctp-base); font-size: 0.75rem; font-weight: 600; cursor: pointer;
font-family: var(--ui-font-family); flex-shrink: 0; white-space: nowrap;
}
.install-btn:hover:not(:disabled) { filter: brightness(1.1); }
.install-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.uninstall-btn {
padding: 0.25rem 0.75rem; background: color-mix(in srgb, var(--ctp-green) 12%, var(--ctp-surface0));
border: 1px solid var(--ctp-green); border-radius: 0.25rem; color: var(--ctp-green);
font-size: 0.75rem; font-weight: 600; cursor: pointer; font-family: var(--ui-font-family);
flex-shrink: 0; white-space: nowrap;
}
.uninstall-btn:hover {
background: color-mix(in srgb, var(--ctp-red) 12%, var(--ctp-surface0));
border-color: var(--ctp-red); color: var(--ctp-red);
}
.plugin-desc { font-size: 0.775rem; color: var(--ctp-subtext0); margin: 0 0 0.375rem; line-height: 1.4; }
.tag-row { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.tag {
padding: 0.1rem 0.4rem; background: color-mix(in srgb, var(--ctp-mauve) 10%, transparent);
color: var(--ctp-mauve); border-radius: 0.75rem; font-size: 0.65rem; font-weight: 500;
}
.tag.free {
background: color-mix(in srgb, var(--ctp-green) 10%, transparent); color: var(--ctp-green);
}
.empty-hint { font-size: 0.8rem; color: var(--ctp-overlay0); text-align: center; padding: 1.5rem 0; margin: 0; font-style: italic; }
</style>

View file

@ -1,4 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
type WakeStrategy = 'persistent' | 'on-demand' | 'smart';
type AnchorScale = 'small' | 'medium' | 'large' | 'full';
@ -24,18 +27,67 @@
let notifDesktop = $state(true);
let notifTypes = $state<Set<string>>(new Set(['complete', 'error', 'crash']));
function persist(key: string, value: string) {
appRpc?.request['settings.set']({ key, value }).catch(console.error);
}
function setWakeStrategy(v: WakeStrategy) {
wakeStrategy = v;
persist('wake_strategy', v);
}
function setWakeThreshold(v: number) {
wakeThreshold = v;
persist('wake_threshold', String(v));
}
function setAutoAnchor(v: boolean) {
autoAnchor = v;
persist('auto_anchor', String(v));
}
function setAnchorBudget(v: AnchorScale) {
anchorBudget = v;
persist('anchor_budget', v);
}
function setStallThreshold(v: number) {
stallThreshold = v;
persist('stall_threshold_global', String(v));
}
function setNotifDesktop(v: boolean) {
notifDesktop = v;
persist('notification_desktop', String(v));
}
function toggleNotif(t: string) {
const next = new Set(notifTypes);
next.has(t) ? next.delete(t) : next.add(t);
notifTypes = next;
persist('notification_types', JSON.stringify([...next]));
}
onMount(async () => {
if (!appRpc) return;
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
if (settings['wake_strategy']) wakeStrategy = settings['wake_strategy'] as WakeStrategy;
if (settings['wake_threshold']) wakeThreshold = parseInt(settings['wake_threshold'], 10) || 50;
if (settings['auto_anchor']) autoAnchor = settings['auto_anchor'] !== 'false';
if (settings['anchor_budget']) anchorBudget = settings['anchor_budget'] as AnchorScale;
if (settings['stall_threshold_global']) stallThreshold = parseInt(settings['stall_threshold_global'], 10) || 15;
if (settings['notification_desktop']) notifDesktop = settings['notification_desktop'] !== 'false';
if (settings['notification_types']) {
try { notifTypes = new Set(JSON.parse(settings['notification_types'])); } catch { /* ignore */ }
}
});
</script>
<div class="section">
<h3 class="sh">Wake Strategy</h3>
<div class="seg">
{#each Object.keys(WAKE_LABELS) as s}
<button class:active={wakeStrategy === s} onclick={() => wakeStrategy = s as WakeStrategy}>{WAKE_LABELS[s as WakeStrategy]}</button>
<button class:active={wakeStrategy === s} onclick={() => setWakeStrategy(s as WakeStrategy)}>{WAKE_LABELS[s as WakeStrategy]}</button>
{/each}
</div>
<p class="desc">{WAKE_DESCS[wakeStrategy]}</p>
@ -43,7 +95,8 @@
{#if wakeStrategy === 'smart'}
<div class="slider-row">
<label class="lbl" for="wake-thresh">Wake threshold</label>
<input id="wake-thresh" type="range" min="0" max="100" step="5" bind:value={wakeThreshold} />
<input id="wake-thresh" type="range" min="0" max="100" step="5" value={wakeThreshold}
oninput={e => setWakeThreshold(parseInt((e.target as HTMLInputElement).value, 10))} />
<span class="slider-val">{wakeThreshold}%</span>
</div>
{/if}
@ -53,13 +106,13 @@
<span class="lbl">Auto-anchor on first compaction</span>
<button class="toggle" class:on={autoAnchor} role="switch" aria-checked={autoAnchor}
aria-label="Toggle auto-anchor"
onclick={() => autoAnchor = !autoAnchor}><span class="thumb"></span></button>
onclick={() => setAutoAnchor(!autoAnchor)}><span class="thumb"></span></button>
</label>
<span class="lbl" style="margin-top: 0.375rem;">Anchor budget scale</span>
<div class="seg" style="margin-top: 0.25rem;">
{#each ANCHOR_SCALES as s}
<button class:active={anchorBudget === s} onclick={() => anchorBudget = s}>
<button class:active={anchorBudget === s} onclick={() => setAnchorBudget(s)}>
{s[0].toUpperCase() + s.slice(1)}
</button>
{/each}
@ -68,7 +121,8 @@
<h3 class="sh" style="margin-top: 0.875rem;">Health Monitoring</h3>
<div class="slider-row">
<label class="lbl" for="stall-thresh">Stall threshold</label>
<input id="stall-thresh" type="range" min="5" max="60" step="5" bind:value={stallThreshold} />
<input id="stall-thresh" type="range" min="5" max="60" step="5" value={stallThreshold}
oninput={e => setStallThreshold(parseInt((e.target as HTMLInputElement).value, 10))} />
<span class="slider-val">{stallThreshold} min</span>
</div>
@ -77,18 +131,13 @@
<span class="lbl">Desktop notifications</span>
<button class="toggle" class:on={notifDesktop} role="switch" aria-checked={notifDesktop}
aria-label="Toggle desktop notifications"
onclick={() => notifDesktop = !notifDesktop}><span class="thumb"></span></button>
onclick={() => setNotifDesktop(!notifDesktop)}><span class="thumb"></span></button>
</label>
<div class="notif-types" style="margin-top: 0.375rem;">
{#each NOTIF_TYPES as t}
<label class="notif-chip" class:active={notifTypes.has(t)}>
<input
type="checkbox"
checked={notifTypes.has(t)}
onchange={() => toggleNotif(t)}
aria-label="Notify on {t}"
/>
<input type="checkbox" checked={notifTypes.has(t)} onchange={() => toggleNotif(t)} aria-label="Notify on {t}" />
{t}
</label>
{/each}

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
const ANCHOR_SCALES = ['small', 'medium', 'large', 'full'] as const;
@ -6,7 +8,6 @@
const PROVIDERS = Object.keys(PROVIDER_CAPABILITIES) as ProviderId[];
// Demo projects
interface ProjectConfig {
id: string;
name: string;
@ -21,26 +22,12 @@
let projects = $state<ProjectConfig[]>([
{
id: 'p1',
name: 'agent-orchestrator',
provider: 'claude',
model: 'claude-opus-4-5',
useWorktrees: false,
useSandbox: false,
stallThreshold: 15,
anchorScale: 'medium',
customContext: '',
id: 'p1', name: 'agent-orchestrator', provider: 'claude', model: 'claude-opus-4-5',
useWorktrees: false, useSandbox: false, stallThreshold: 15, anchorScale: 'medium', customContext: '',
},
{
id: 'p2',
name: 'quanta-discord-bot',
provider: 'claude',
model: 'claude-sonnet-4-5',
useWorktrees: false,
useSandbox: false,
stallThreshold: 15,
anchorScale: 'medium',
customContext: '',
id: 'p2', name: 'quanta-discord-bot', provider: 'claude', model: 'claude-sonnet-4-5',
useWorktrees: false, useSandbox: false, stallThreshold: 15, anchorScale: 'medium', customContext: '',
},
]);
@ -49,11 +36,26 @@
function updateProj(patch: Partial<ProjectConfig>) {
projects = projects.map(p => p.id === selectedId ? { ...p, ...patch } : p);
const updated = projects.find(p => p.id === selectedId)!;
appRpc?.request['settings.setProject']({
id: selectedId,
config: JSON.stringify(updated),
}).catch(console.error);
}
onMount(async () => {
if (!appRpc) return;
const res = await appRpc.request['settings.getProjects']({}).catch(() => ({ projects: [] }));
if (res.projects.length > 0) {
const loaded: ProjectConfig[] = res.projects.flatMap(({ config }) => {
try { return [JSON.parse(config) as ProjectConfig]; } catch { return []; }
});
if (loaded.length > 0) projects = loaded;
}
});
</script>
<div class="section">
<!-- Project selector -->
<h3 class="sh">Project</h3>
<div class="proj-tabs">
{#each projects as p}

View file

@ -1,4 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
const KNOWN_KEYS: Record<string, string> = {
ANTHROPIC_API_KEY: 'Anthropic API Key',
OPENAI_API_KEY: 'OpenAI API Key',
@ -16,10 +19,7 @@
let keyDropOpen = $state(false);
let saving = $state(false);
interface BranchPolicy {
pattern: string;
action: 'block' | 'warn';
}
interface BranchPolicy { pattern: string; action: 'block' | 'warn'; }
let branchPolicies = $state<BranchPolicy[]>([
{ pattern: 'main', action: 'block' },
{ pattern: 'prod*', action: 'warn' },
@ -30,6 +30,10 @@
let availableKeys = $derived(Object.keys(KNOWN_KEYS).filter(k => !storedKeys.includes(k)));
let newKeyLabel = $derived(newKey ? (KNOWN_KEYS[newKey] ?? newKey) : 'Select key...');
function persistPolicies() {
appRpc?.request['settings.set']({ key: 'branch_policies', value: JSON.stringify(branchPolicies) }).catch(console.error);
}
function handleSaveSecret() {
if (!newKey || !newValue) return;
saving = true;
@ -49,15 +53,25 @@
if (!newPattern.trim()) return;
branchPolicies = [...branchPolicies, { pattern: newPattern.trim(), action: newAction }];
newPattern = ''; newAction = 'warn';
persistPolicies();
}
function removeBranchPolicy(idx: number) {
branchPolicies = branchPolicies.filter((_, i) => i !== idx);
persistPolicies();
}
function handleOutsideClick(e: MouseEvent) {
if (!(e.target as HTMLElement).closest('.dd-wrap')) keyDropOpen = false;
}
onMount(async () => {
if (!appRpc) return;
const res = await appRpc.request['settings.get']({ key: 'branch_policies' }).catch(() => ({ value: null }));
if (res.value) {
try { branchPolicies = JSON.parse(res.value); } catch { /* ignore */ }
}
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->

View file

@ -0,0 +1,236 @@
<script lang="ts">
import { appRpc } from '../main.ts';
import { themeStore } from '../theme-store.svelte.ts';
import { CSS_VAR_MAP, getPalette, applyCssVars, type ThemePalette, type ThemeId } from '../themes.ts';
interface Props {
baseThemeId: ThemeId;
/** Seed palette — passed from parent so $state init avoids a reactive read. */
initialPalette: ThemePalette;
onSave: (id: string, name: string) => void;
onCancel: () => void;
}
let { baseThemeId, initialPalette, onSave, onCancel }: Props = $props();
// ── Palette color keys in two groups ──────────────────────────────────────
const ACCENT_KEYS: (keyof ThemePalette)[] = [
'rosewater', 'flamingo', 'pink', 'mauve', 'red', 'maroon',
'peach', 'yellow', 'green', 'teal', 'sky', 'sapphire', 'blue', 'lavender',
];
const NEUTRAL_KEYS: (keyof ThemePalette)[] = [
'text', 'subtext1', 'subtext0',
'overlay2', 'overlay1', 'overlay0',
'surface2', 'surface1', 'surface0',
'base', 'mantle', 'crust',
];
// ── State ─────────────────────────────────────────────────────────────────
// initialPalette is passed by value from the parent — safe to spread here.
let palette = $state<ThemePalette>({ ...initialPalette });
let themeName = $state('My Custom Theme');
let saving = $state(false);
let nameError = $state('');
// ── Apply live preview as colors change ───────────────────────────────────
function applyPreview() {
const style = document.documentElement.style;
for (const [varName, key] of CSS_VAR_MAP) {
style.setProperty(varName, palette[key]);
}
}
function setColor(key: keyof ThemePalette, value: string) {
palette = { ...palette, [key]: value };
applyPreview();
}
function resetToBase() {
palette = { ...initialPalette };
applyPreview();
}
async function handleSave() {
if (!themeName.trim()) { nameError = 'Theme name is required'; return; }
nameError = '';
saving = true;
const id = `custom-${Date.now()}`;
const palObj: Record<string, string> = {};
for (const [, key] of CSS_VAR_MAP) palObj[key] = palette[key];
try {
await appRpc?.request['themes.saveCustom']({ id, name: themeName.trim(), palette: palObj });
onSave(id, themeName.trim());
} catch (err) {
console.error('[ThemeEditor] save failed:', err);
} finally {
saving = false;
}
}
function handleCancel() {
// Revert CSS vars to previous theme
applyCssVars(themeStore.currentTheme);
onCancel();
}
function exportTheme() {
const palObj: Record<string, string> = {};
for (const [, key] of CSS_VAR_MAP) palObj[key] = palette[key];
const data = JSON.stringify({ name: themeName, palette: palObj }, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${themeName.replace(/\s+/g, '-').toLowerCase()}.json`; a.click();
URL.revokeObjectURL(url);
}
function importTheme() {
const input = document.createElement('input');
input.type = 'file'; input.accept = '.json,application/json';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
if (parsed.name) themeName = parsed.name;
if (parsed.palette && typeof parsed.palette === 'object') {
palette = { ...basePalette };
for (const [k, v] of Object.entries(parsed.palette)) {
if (k in palette && typeof v === 'string') {
(palette as unknown as Record<string, string>)[k] = v;
}
}
palette = { ...palette };
applyPreview();
}
} catch { /* ignore */ }
};
input.click();
}
function capitalize(s: string) { return s[0].toUpperCase() + s.slice(1); }
</script>
<div class="editor">
<div class="editor-toolbar">
<input class="name-in" bind:value={themeName} placeholder="Theme name" aria-label="Theme name" />
<button class="tool-btn" onclick={exportTheme} title="Export theme JSON">Export</button>
<button class="tool-btn" onclick={importTheme} title="Import theme JSON">Import</button>
<button class="tool-btn" onclick={resetToBase} title="Reset to base palette">Reset</button>
</div>
{#if nameError}
<p class="name-err">{nameError}</p>
{/if}
<div class="group-label">Accents</div>
<div class="color-grid">
{#each ACCENT_KEYS as key}
<div class="color-row">
<label class="color-lbl" for="cp-{key}">{capitalize(key)}</label>
<div class="color-ctrl">
<input id="cp-{key}" type="color" class="color-swatch" value={palette[key]}
oninput={e => setColor(key, (e.target as HTMLInputElement).value)} />
<input type="text" class="hex-in" value={palette[key]}
onchange={e => {
const v = (e.target as HTMLInputElement).value.trim();
if (/^#[0-9a-fA-F]{6}$/.test(v)) setColor(key, v);
}}
/>
</div>
</div>
{/each}
</div>
<div class="group-label" style="margin-top: 0.625rem;">Neutrals</div>
<div class="color-grid">
{#each NEUTRAL_KEYS as key}
<div class="color-row">
<label class="color-lbl" for="cp-{key}">{capitalize(key)}</label>
<div class="color-ctrl">
<input id="cp-{key}" type="color" class="color-swatch" value={palette[key]}
oninput={e => setColor(key, (e.target as HTMLInputElement).value)} />
<input type="text" class="hex-in" value={palette[key]}
onchange={e => {
const v = (e.target as HTMLInputElement).value.trim();
if (/^#[0-9a-fA-F]{6}$/.test(v)) setColor(key, v);
}}
/>
</div>
</div>
{/each}
</div>
<div class="editor-actions">
<button class="save-btn" onclick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save as Custom Theme'}
</button>
<button class="cancel-btn" onclick={handleCancel}>Cancel</button>
</div>
</div>
<style>
.editor { display: flex; flex-direction: column; gap: 0.5rem; }
.editor-toolbar { display: flex; align-items: center; gap: 0.375rem; }
.name-in {
flex: 1; 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);
}
.name-in:focus { outline: none; border-color: var(--ctp-blue); }
.name-err { font-size: 0.75rem; color: var(--ctp-red); margin: 0; }
.tool-btn {
padding: 0.25rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem; color: var(--ctp-subtext1); font-size: 0.75rem; cursor: pointer;
font-family: var(--ui-font-family); white-space: nowrap;
}
.tool-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
.group-label {
font-size: 0.6875rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ctp-overlay0); margin-bottom: 0.125rem;
}
.color-grid { display: flex; flex-direction: column; gap: 0.25rem; }
.color-row { display: flex; align-items: center; gap: 0.5rem; }
.color-lbl { font-size: 0.75rem; color: var(--ctp-subtext0); width: 5rem; flex-shrink: 0; }
.color-ctrl { display: flex; align-items: center; gap: 0.375rem; flex: 1; }
.color-swatch {
width: 1.75rem; height: 1.75rem; padding: 0.125rem; border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem; background: var(--ctp-surface0); cursor: pointer; flex-shrink: 0;
}
.color-swatch::-webkit-color-swatch-wrapper { padding: 0; }
.color-swatch::-webkit-color-swatch { border: none; border-radius: 0.125rem; }
.hex-in {
flex: 1; padding: 0.2rem 0.375rem; background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1); border-radius: 0.2rem;
color: var(--ctp-text); font-size: 0.75rem; font-family: var(--term-font-family, monospace);
}
.hex-in:focus { outline: none; border-color: var(--ctp-blue); }
.editor-actions { display: flex; gap: 0.5rem; margin-top: 0.375rem; padding-top: 0.5rem; border-top: 1px solid var(--ctp-surface1); }
.save-btn {
flex: 1; padding: 0.35rem 0.75rem; background: var(--ctp-blue); border: none;
border-radius: 0.25rem; color: var(--ctp-base); font-size: 0.8rem; font-weight: 600;
cursor: pointer; font-family: var(--ui-font-family);
}
.save-btn:hover:not(:disabled) { filter: brightness(1.1); }
.save-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.cancel-btn {
padding: 0.35rem 0.75rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem; color: var(--ctp-subtext1); font-size: 0.8rem; cursor: pointer;
font-family: var(--ui-font-family);
}
.cancel-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
</style>

View file

@ -71,6 +71,24 @@ export type PtyRPCRequests = {
params: { id: string; config: string };
response: { ok: boolean };
};
// ── Custom Themes RPC ──────────────────────────────────────────────────────
/** Return all user-saved custom themes. */
"themes.getCustom": {
params: Record<string, never>;
response: { themes: Array<{ id: string; name: string; palette: Record<string, string> }> };
};
/** Save (upsert) a custom theme by id. */
"themes.saveCustom": {
params: { id: string; name: string; palette: Record<string, string> };
response: { ok: boolean };
};
/** Delete a custom theme by id. */
"themes.deleteCustom": {
params: { id: string };
response: { ok: boolean };
};
};
// ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────

View file

@ -13,7 +13,8 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"allowImportingTsExtensions": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "build", "../../package/dist"]