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:
parent
6002a379e4
commit
5032021915
20 changed files with 1005 additions and 271 deletions
|
|
@ -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: {}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (100–100k)</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; }
|
||||
|
|
|
|||
199
ui-electrobun/src/mainview/settings/MarketplaceTab.svelte
Normal file
199
ui-electrobun/src/mainview/settings/MarketplaceTab.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
236
ui-electrobun/src/mainview/settings/ThemeEditor.svelte
Normal file
236
ui-electrobun/src/mainview/settings/ThemeEditor.svelte
Normal 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>
|
||||
|
|
@ -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) ────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue