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
|
|
@ -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 ─────────────────────────────────
|
||||
|
||||
const { cols, rows } = term;
|
||||
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');
|
||||
}
|
||||
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(() => {});
|
||||
});
|
||||
|
||||
// ── Receive output from daemon (via Bun) ───────────────────────────────
|
||||
// ── Connect to PTY daemon (fire-and-forget from onMount) ───────────────
|
||||
|
||||
// "pty.output" messages are pushed by Bun whenever the PTY produces data.
|
||||
electrobun.rpc?.addMessageListener("pty.output", ({ sessionId: sid, data }) => {
|
||||
void (async () => {
|
||||
const { cols, rows } = term;
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
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) ────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue