feat(electrobun): port all Tauri features — full settings, popup menus, provider capabilities
New components (8): - provider-capabilities.ts: per-provider feature flags (claude/codex/ollama) - settings/AppearanceSettings.svelte: theme, fonts, cursor, scrollback - settings/AgentSettings.svelte: shell, CWD, permissions, providers - settings/SecuritySettings.svelte: keyring, secrets, branch policies - settings/ProjectSettings.svelte: per-project provider/model/worktree/sandbox - settings/OrchestrationSettings.svelte: wake strategy, notifications, anchors - settings/AdvancedSettings.svelte: logging, OTLP, plugins, import/export Updated: - ChatInput: radial context indicator (78% demo, color-coded arc), 4 popup menus (upload/context/web/slash), provider-gated icons - SettingsDrawer: 6-category sidebar shell - AgentPane: passes provider + contextPct to ChatInput
This commit is contained in:
parent
54d6f0b94a
commit
0b9e8b305a
15 changed files with 1510 additions and 441 deletions
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-DioorjbE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index--nz-Qfge.css">
|
||||
<script type="module" crossorigin src="/assets/index-I2iZIyVf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-x9Y0o9Mz.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
costUsd,
|
||||
tokens,
|
||||
model = 'claude-opus-4-5',
|
||||
provider = 'claude',
|
||||
contextPct = 0,
|
||||
onSend,
|
||||
}: Props = $props();
|
||||
|
||||
|
|
@ -178,6 +180,8 @@
|
|||
<ChatInput
|
||||
value={promptText}
|
||||
{model}
|
||||
{provider}
|
||||
{contextPct}
|
||||
onSend={handleSend}
|
||||
onInput={(v) => (promptText = v)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { getCapabilities } from './provider-capabilities';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
contextPct?: number;
|
||||
disabled?: boolean;
|
||||
onSend?: () => void;
|
||||
onInput?: (v: string) => void;
|
||||
}
|
||||
|
||||
let { value, model = 'claude-opus-4-5', disabled = false, onSend, onInput }: Props = $props();
|
||||
let {
|
||||
value,
|
||||
model = 'claude-opus-4-5',
|
||||
provider = 'claude',
|
||||
contextPct = 78,
|
||||
disabled = false,
|
||||
onSend,
|
||||
onInput,
|
||||
}: Props = $props();
|
||||
|
||||
let textareaEl: HTMLTextAreaElement;
|
||||
|
||||
// Which popup is open: 'upload' | 'context' | 'web' | 'slash' | null
|
||||
let openPopup = $state<string | null>(null);
|
||||
|
||||
let caps = $derived(getCapabilities(provider));
|
||||
|
||||
// Radial progress arc path
|
||||
let arcPath = $derived.by(() => {
|
||||
const pct = Math.min(100, Math.max(0, contextPct));
|
||||
const r = 8;
|
||||
const cx = 10, cy = 10;
|
||||
const angle = (pct / 100) * 2 * Math.PI;
|
||||
const startX = cx;
|
||||
const startY = cy - r;
|
||||
const endX = cx + r * Math.sin(angle);
|
||||
const endY = cy - r * Math.cos(angle);
|
||||
const large = pct > 50 ? 1 : 0;
|
||||
if (pct >= 100) {
|
||||
return `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx - 0.001} ${cy - r} Z`;
|
||||
}
|
||||
return `M ${startX} ${startY} A ${r} ${r} 0 ${large} 1 ${endX} ${endY}`;
|
||||
});
|
||||
|
||||
let arcColor = $derived(
|
||||
contextPct >= 75 ? 'var(--ctp-red)' :
|
||||
contextPct >= 50 ? 'var(--ctp-yellow)' :
|
||||
'var(--ctp-green)'
|
||||
);
|
||||
|
||||
function autoResize() {
|
||||
if (!textareaEl) return;
|
||||
textareaEl.style.height = 'auto';
|
||||
|
|
@ -22,15 +62,35 @@
|
|||
e.preventDefault();
|
||||
if (value.trim()) onSend?.();
|
||||
}
|
||||
if (e.key === 'Escape') openPopup = null;
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
onInput?.((e.target as HTMLTextAreaElement).value);
|
||||
autoResize();
|
||||
}
|
||||
|
||||
function togglePopup(name: string) {
|
||||
openPopup = openPopup === name ? null : name;
|
||||
}
|
||||
|
||||
function closePopup() { openPopup = null; }
|
||||
|
||||
function handleOuterKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') openPopup = null;
|
||||
}
|
||||
|
||||
const UPLOAD_ITEMS = ['Upload from computer', 'Paste from clipboard'];
|
||||
const CONTEXT_ITEMS = ['Add file', 'Add folder', 'Add URL', 'Add image'];
|
||||
const WEB_ITEMS = ['Search the web', 'Fetch URL'];
|
||||
const SLASH_COMMANDS = ['/help', '/clear', '/compact', '/review', '/test', '/commit'];
|
||||
</script>
|
||||
|
||||
<div class="chat-input-outer">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="chat-input-outer"
|
||||
onkeydown={handleOuterKeydown}
|
||||
>
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
class="chat-textarea"
|
||||
|
|
@ -43,44 +103,130 @@
|
|||
></textarea>
|
||||
|
||||
<div class="footer-strip">
|
||||
<!-- Left: action buttons -->
|
||||
<!-- Upload file -->
|
||||
<button class="footer-btn" aria-label="Upload file" title="Upload from computer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Add context -->
|
||||
<button class="footer-btn" aria-label="Add context" title="Add context">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Browse web -->
|
||||
<button class="footer-btn" aria-label="Browse the web" title="Browse the web">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Slash commands -->
|
||||
<button class="footer-btn" aria-label="Slash commands" title="Slash commands (/)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="7" y1="20" x2="17" y2="4"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if caps.upload}
|
||||
<div class="popup-wrap">
|
||||
<button
|
||||
class="footer-btn"
|
||||
class:active={openPopup === 'upload'}
|
||||
aria-label="Upload file"
|
||||
title="Upload"
|
||||
onclick={() => togglePopup('upload')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if openPopup === 'upload'}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div class="popup-menu" role="menu" tabindex="-1" onkeydown={e => e.key === 'Escape' && closePopup()}>
|
||||
{#each UPLOAD_ITEMS as item}
|
||||
<button class="popup-item" role="menuitem" onclick={closePopup}>{item}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Context state indicator -->
|
||||
<span class="context-indicator" title="Context window usage">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
||||
<!-- Add context -->
|
||||
{#if caps.context}
|
||||
<div class="popup-wrap">
|
||||
<button
|
||||
class="footer-btn"
|
||||
class:active={openPopup === 'context'}
|
||||
aria-label="Add context"
|
||||
title="Add context"
|
||||
onclick={() => togglePopup('context')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if openPopup === 'context'}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div class="popup-menu" role="menu" tabindex="-1" onkeydown={e => e.key === 'Escape' && closePopup()}>
|
||||
{#each CONTEXT_ITEMS as item}
|
||||
<button class="popup-item" role="menuitem" onclick={closePopup}
|
||||
class:disabled-item={item === 'Add image' && !caps.images}
|
||||
>{item}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Browse web -->
|
||||
{#if caps.web}
|
||||
<div class="popup-wrap">
|
||||
<button
|
||||
class="footer-btn"
|
||||
class:active={openPopup === 'web'}
|
||||
aria-label="Browse the web"
|
||||
title="Browse the web"
|
||||
onclick={() => togglePopup('web')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if openPopup === 'web'}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div class="popup-menu" role="menu" tabindex="-1" onkeydown={e => e.key === 'Escape' && closePopup()}>
|
||||
{#each WEB_ITEMS as item}
|
||||
<button class="popup-item" role="menuitem" onclick={closePopup}>{item}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Slash commands -->
|
||||
{#if caps.slash}
|
||||
<div class="popup-wrap">
|
||||
<button
|
||||
class="footer-btn"
|
||||
class:active={openPopup === 'slash'}
|
||||
aria-label="Slash commands"
|
||||
title="Slash commands (/)"
|
||||
onclick={() => togglePopup('slash')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="7" y1="20" x2="17" y2="4"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if openPopup === 'slash'}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div class="popup-menu" role="menu" tabindex="-1" onkeydown={e => e.key === 'Escape' && closePopup()}>
|
||||
{#each SLASH_COMMANDS as cmd}
|
||||
<button class="popup-item popup-slash" role="menuitem" onclick={closePopup}>{cmd}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Context radial progress indicator -->
|
||||
<div class="context-indicator" title="Context window: {contextPct}% used">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<!-- Track ring -->
|
||||
<circle cx="10" cy="10" r="8" fill="none" stroke="var(--ctp-surface1)" stroke-width="2"/>
|
||||
<!-- Progress arc -->
|
||||
<path
|
||||
d={arcPath}
|
||||
fill="none"
|
||||
stroke={arcColor}
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="ctx-pct" style="color: {arcColor}">{contextPct}%</span>
|
||||
</div>
|
||||
|
||||
<span class="footer-spacer"></span>
|
||||
<span class="model-label">{model}</span>
|
||||
<span class="footer-divider" aria-hidden="true"></span>
|
||||
|
||||
<!-- Send button — paper plane icon -->
|
||||
<!-- Send button -->
|
||||
<button
|
||||
class="send-btn"
|
||||
onclick={onSend}
|
||||
|
|
@ -96,6 +242,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click outside overlay to close popup -->
|
||||
{#if openPopup !== null}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="popup-backdrop"
|
||||
onclick={closePopup}
|
||||
onkeydown={e => e.key === 'Escape' && closePopup()}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.chat-input-outer {
|
||||
background: var(--ctp-crust);
|
||||
|
|
@ -104,7 +260,9 @@
|
|||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.chat-textarea {
|
||||
|
|
@ -121,17 +279,14 @@
|
|||
max-height: 12.5rem;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
transition: box-shadow 0.12s, border-color 0.12s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-textarea:focus {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ctp-peach) 12%, transparent);
|
||||
}
|
||||
|
||||
.chat-textarea::placeholder {
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.chat-textarea::placeholder { color: var(--ctp-subtext0); }
|
||||
.chat-textarea::-webkit-scrollbar { width: 0.25rem; }
|
||||
.chat-textarea::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
|
||||
|
|
@ -142,6 +297,12 @@
|
|||
gap: 0.125rem;
|
||||
padding: 0.3125rem;
|
||||
border-top: 0.5px solid var(--ctp-surface1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Popup anchor */
|
||||
.popup-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
|
|
@ -162,6 +323,76 @@
|
|||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.footer-btn.active {
|
||||
color: var(--ctp-blue);
|
||||
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Popup menu */
|
||||
.popup-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 0.5rem 1.25rem color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
min-width: 10rem;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.popup-item {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.35rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.08s, color 0.08s;
|
||||
}
|
||||
|
||||
.popup-item:hover { background: var(--ctp-surface1); }
|
||||
|
||||
.popup-slash {
|
||||
font-family: var(--term-font-family);
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.popup-slash:hover { color: var(--ctp-text); }
|
||||
|
||||
.disabled-item { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Popup click-outside backdrop */
|
||||
.popup-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 29;
|
||||
}
|
||||
|
||||
/* Radial context indicator */
|
||||
.context-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctx-pct {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
min-width: 2rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.model-label {
|
||||
font-size: 0.85em;
|
||||
color: var(--ctp-overlay1);
|
||||
|
|
@ -172,13 +403,6 @@
|
|||
max-width: 8rem;
|
||||
}
|
||||
|
||||
.context-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--ctp-overlay0);
|
||||
padding: 0.125rem;
|
||||
}
|
||||
|
||||
.footer-spacer { flex: 1; }
|
||||
|
||||
.footer-divider {
|
||||
|
|
@ -204,9 +428,7 @@
|
|||
transition: filter 0.12s;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.send-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
<script lang="ts">
|
||||
import AppearanceSettings from './settings/AppearanceSettings.svelte';
|
||||
import AgentSettings from './settings/AgentSettings.svelte';
|
||||
import SecuritySettings from './settings/SecuritySettings.svelte';
|
||||
import ProjectSettings from './settings/ProjectSettings.svelte';
|
||||
import OrchestrationSettings from './settings/OrchestrationSettings.svelte';
|
||||
import AdvancedSettings from './settings/AdvancedSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -6,40 +13,24 @@
|
|||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
// Local settings state (demo — no persistence in prototype)
|
||||
const THEMES = [
|
||||
{ id: 'mocha', label: 'Catppuccin Mocha' },
|
||||
{ id: 'macchiato', label: 'Catppuccin Macchiato' },
|
||||
{ id: 'frappe', label: 'Catppuccin Frappé' },
|
||||
{ id: 'latte', label: 'Catppuccin Latte' },
|
||||
];
|
||||
let themeId = $state('mocha');
|
||||
let themeDropdownOpen = $state(false);
|
||||
let selectedThemeLabel = $derived(THEMES.find(t => t.id === themeId)?.label ?? 'Catppuccin Mocha');
|
||||
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced';
|
||||
|
||||
function selectTheme(id: string) {
|
||||
themeId = id;
|
||||
themeDropdownOpen = false;
|
||||
}
|
||||
|
||||
let uiFontSize = $state(14);
|
||||
let termFontSize = $state(13);
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
let providers = $state<Provider[]>([
|
||||
{ id: 'claude', label: 'Claude (Anthropic)', enabled: true },
|
||||
{ id: 'codex', label: 'Codex (OpenAI)', enabled: false },
|
||||
{ id: 'ollama', label: 'Ollama (local)', enabled: false },
|
||||
]);
|
||||
const CATEGORIES: Category[] = [
|
||||
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
|
||||
{ id: 'agents', label: 'Agents', icon: '🤖' },
|
||||
{ id: 'security', label: 'Security', icon: '🔒' },
|
||||
{ id: 'projects', label: 'Projects', icon: '📁' },
|
||||
{ id: 'orchestration', label: 'Orchestration', icon: '⚙' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
|
||||
];
|
||||
|
||||
function toggleProvider(id: string) {
|
||||
providers = providers.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p);
|
||||
}
|
||||
let activeCategory = $state<CategoryId>('appearance');
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
|
|
@ -50,7 +41,6 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
|
|
@ -62,109 +52,50 @@
|
|||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<aside class="drawer-panel">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<aside class="drawer-panel" onclick={e => e.stopPropagation()} onkeydown={e => e.stopPropagation()}>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="drawer-header">
|
||||
<h2 class="drawer-title">Settings</h2>
|
||||
<button class="drawer-close" onclick={onClose} aria-label="Close settings">×</button>
|
||||
</header>
|
||||
|
||||
<!-- Body: sidebar + content -->
|
||||
<div class="drawer-body">
|
||||
<!-- Appearance -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-heading">Appearance</h3>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label" for="theme-dropdown-btn">Theme</label>
|
||||
<div class="theme-dropdown">
|
||||
<button
|
||||
id="theme-dropdown-btn"
|
||||
class="theme-dropdown-btn"
|
||||
onclick={() => themeDropdownOpen = !themeDropdownOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={themeDropdownOpen}
|
||||
>
|
||||
<span class="theme-dropdown-label">{selectedThemeLabel}</span>
|
||||
<svg class="theme-chevron" class:open={themeDropdownOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if themeDropdownOpen}
|
||||
<ul class="theme-dropdown-list" role="listbox" aria-label="Theme options">
|
||||
{#each THEMES as t}
|
||||
<li
|
||||
class="theme-option"
|
||||
class:selected={themeId === t.id}
|
||||
role="option"
|
||||
aria-selected={themeId === t.id}
|
||||
onclick={() => selectTheme(t.id)}
|
||||
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectTheme(t.id)}
|
||||
tabindex="0"
|
||||
>{t.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<span class="setting-label" id="ui-font-label">UI font size</span>
|
||||
<div class="font-stepper" role="group" aria-labelledby="ui-font-label">
|
||||
<button
|
||||
class="stepper-btn"
|
||||
onclick={() => uiFontSize = Math.max(10, uiFontSize - 1)}
|
||||
aria-label="Decrease UI font size"
|
||||
>−</button>
|
||||
<span class="stepper-value">{uiFontSize}px</span>
|
||||
<button
|
||||
class="stepper-btn"
|
||||
onclick={() => uiFontSize = Math.min(24, uiFontSize + 1)}
|
||||
aria-label="Increase UI font size"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<span class="setting-label" id="term-font-label">Terminal font size</span>
|
||||
<div class="font-stepper" role="group" aria-labelledby="term-font-label">
|
||||
<button
|
||||
class="stepper-btn"
|
||||
onclick={() => termFontSize = Math.max(8, termFontSize - 1)}
|
||||
aria-label="Decrease terminal font size"
|
||||
>−</button>
|
||||
<span class="stepper-value">{termFontSize}px</span>
|
||||
<button
|
||||
class="stepper-btn"
|
||||
onclick={() => termFontSize = Math.min(24, termFontSize + 1)}
|
||||
aria-label="Increase terminal font size"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Providers -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-heading">Providers</h3>
|
||||
{#each providers as prov (prov.id)}
|
||||
<div class="setting-row">
|
||||
<label class="setting-label" for="prov-{prov.id}">{prov.label}</label>
|
||||
<button
|
||||
id="prov-{prov.id}"
|
||||
class="toggle-btn"
|
||||
class:enabled={prov.enabled}
|
||||
onclick={() => toggleProvider(prov.id)}
|
||||
role="switch"
|
||||
aria-checked={prov.enabled}
|
||||
aria-label="{prov.label} {prov.enabled ? 'enabled' : 'disabled'}"
|
||||
>
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">{prov.enabled ? 'on' : 'off'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Category nav -->
|
||||
<nav class="cat-nav" aria-label="Settings categories">
|
||||
{#each CATEGORIES as cat}
|
||||
<button
|
||||
class="cat-btn"
|
||||
class:active={activeCategory === cat.id}
|
||||
onclick={() => activeCategory = cat.id}
|
||||
aria-current={activeCategory === cat.id ? 'page' : undefined}
|
||||
>
|
||||
<span class="cat-icon" aria-hidden="true">{cat.icon}</span>
|
||||
<span class="cat-label">{cat.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<!-- Category content -->
|
||||
<div class="cat-content">
|
||||
{#if activeCategory === 'appearance'}
|
||||
<AppearanceSettings />
|
||||
{:else if activeCategory === 'agents'}
|
||||
<AgentSettings />
|
||||
{:else if activeCategory === 'security'}
|
||||
<SecuritySettings />
|
||||
{:else if activeCategory === 'projects'}
|
||||
<ProjectSettings />
|
||||
{:else if activeCategory === 'orchestration'}
|
||||
<OrchestrationSettings />
|
||||
{:else if activeCategory === 'advanced'}
|
||||
<AdvancedSettings />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -180,8 +111,8 @@
|
|||
}
|
||||
|
||||
.drawer-panel {
|
||||
width: 18rem;
|
||||
max-width: 90vw;
|
||||
width: 30rem;
|
||||
max-width: 95vw;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
|
|
@ -195,6 +126,7 @@
|
|||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.drawer-header {
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
|
|
@ -232,199 +164,77 @@
|
|||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
/* Body: two-column layout */
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Category nav sidebar */
|
||||
.cat-nav {
|
||||
width: 8.5rem;
|
||||
flex-shrink: 0;
|
||||
background: var(--ctp-crust);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.375rem 0;
|
||||
gap: 0.0625rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.drawer-body::-webkit-scrollbar { width: 0.375rem; }
|
||||
.drawer-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.drawer-body::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
.cat-nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Sections */
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
.cat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
/* Theme dropdown */
|
||||
.theme-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-dropdown-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
color: var(--ctp-mauve);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
border-radius: 0;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.theme-dropdown-btn:hover { border-color: var(--ctp-surface2); }
|
||||
.cat-btn:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.theme-dropdown-label { flex: 1; }
|
||||
.cat-btn.active {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
border-left: 2px solid var(--ctp-mauve);
|
||||
padding-left: calc(0.75rem - 2px);
|
||||
}
|
||||
|
||||
.theme-chevron {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: var(--ctp-overlay1);
|
||||
transition: transform 0.15s;
|
||||
.cat-icon {
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-chevron.open { transform: rotate(180deg); }
|
||||
|
||||
.theme-dropdown-list {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.25rem);
|
||||
z-index: 10;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.375rem;
|
||||
min-width: 11rem;
|
||||
box-shadow: 0 0.5rem 1.25rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
.cat-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-subtext1);
|
||||
cursor: pointer;
|
||||
transition: background 0.08s, color 0.08s;
|
||||
outline: none;
|
||||
/* Content area */
|
||||
.cat-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.theme-option:hover,
|
||||
.theme-option:focus { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
|
||||
.theme-option.selected {
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 15%, transparent);
|
||||
color: var(--ctp-mauve);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Font stepper */
|
||||
.font-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.stepper-btn:hover { background: var(--ctp-surface1); }
|
||||
|
||||
.stepper-value {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-text);
|
||||
min-width: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
width: 2rem;
|
||||
height: 1.125rem;
|
||||
background: var(--ctp-surface1);
|
||||
border-radius: 0.5625rem;
|
||||
position: relative;
|
||||
transition: background 0.15s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled .toggle-track {
|
||||
background: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 0.1875rem;
|
||||
left: 0.1875rem;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--ctp-base);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled .toggle-thumb {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled .toggle-label {
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
.cat-content::-webkit-scrollbar { width: 0.375rem; }
|
||||
.cat-content::-webkit-scrollbar-track { background: transparent; }
|
||||
.cat-content::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
</style>
|
||||
|
|
|
|||
45
ui-electrobun/src/mainview/provider-capabilities.ts
Normal file
45
ui-electrobun/src/mainview/provider-capabilities.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export type ProviderId = 'claude' | 'codex' | 'ollama';
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
upload: boolean;
|
||||
context: boolean;
|
||||
web: boolean;
|
||||
slash: boolean;
|
||||
images: boolean;
|
||||
defaultModel: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const PROVIDER_CAPABILITIES: Record<ProviderId, ProviderCapabilities> = {
|
||||
claude: {
|
||||
upload: true,
|
||||
context: true,
|
||||
web: true,
|
||||
slash: true,
|
||||
images: true,
|
||||
defaultModel: 'claude-opus-4-5',
|
||||
label: 'Claude',
|
||||
},
|
||||
codex: {
|
||||
upload: true,
|
||||
context: true,
|
||||
web: false,
|
||||
slash: true,
|
||||
images: false,
|
||||
defaultModel: 'gpt-5.4',
|
||||
label: 'Codex',
|
||||
},
|
||||
ollama: {
|
||||
upload: false,
|
||||
context: true,
|
||||
web: false,
|
||||
slash: true,
|
||||
images: false,
|
||||
defaultModel: 'qwen3:8b',
|
||||
label: 'Ollama',
|
||||
},
|
||||
};
|
||||
|
||||
export function getCapabilities(provider: string): ProviderCapabilities {
|
||||
return PROVIDER_CAPABILITIES[provider as ProviderId] ?? PROVIDER_CAPABILITIES.claude;
|
||||
}
|
||||
167
ui-electrobun/src/mainview/settings/AdvancedSettings.svelte
Normal file
167
ui-electrobun/src/mainview/settings/AdvancedSettings.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script lang="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;
|
||||
}
|
||||
|
||||
let plugins = $state<Plugin[]>([
|
||||
{ id: 'quanta-plugin', name: 'Quanta Plugin', version: '1.2.0', enabled: true },
|
||||
{ id: 'session-wrap', name: 'Session Wrap', version: '0.4.1', enabled: false },
|
||||
]);
|
||||
|
||||
let logLevel = $state<LogLevel>('info');
|
||||
let otlpEndpoint = $state('');
|
||||
let relayUrls = $state('');
|
||||
let connTimeout = $state(30);
|
||||
|
||||
let appVersion = $state('3.0.0-dev');
|
||||
let updateChecking = $state(false);
|
||||
let updateResult = $state<string | null>(null);
|
||||
|
||||
function togglePlugin(id: string) {
|
||||
plugins = plugins.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p);
|
||||
}
|
||||
|
||||
function checkForUpdates() {
|
||||
updateChecking = true;
|
||||
updateResult = null;
|
||||
setTimeout(() => {
|
||||
updateChecking = false;
|
||||
updateResult = 'Already up to date (v3.0.0-dev)';
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const data = JSON.stringify({ logLevel, otlpEndpoint, relayUrls, connTimeout }, 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);
|
||||
}
|
||||
</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>
|
||||
{/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" />
|
||||
</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>
|
||||
</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} />
|
||||
<span class="unit">seconds</span>
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.75rem;">Plugins</h3>
|
||||
<div class="plugin-list">
|
||||
{#each plugins as plug}
|
||||
<div class="plugin-row">
|
||||
<div class="plug-info">
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
{#if plugins.length === 0}
|
||||
<p class="empty">No plugins found in config dir.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.75rem;">Updates</h3>
|
||||
<div class="update-row">
|
||||
<span class="version-label">v{appVersion}</span>
|
||||
<button class="action-btn" onclick={checkForUpdates} disabled={updateChecking}>
|
||||
{updateChecking ? 'Checking…' : 'Check for updates'}
|
||||
</button>
|
||||
</div>
|
||||
{#if updateResult}
|
||||
<p class="update-result">{updateResult}</p>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.sh { margin: 0.125rem 0; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
|
||||
.lbl { font-size: 0.75rem; color: var(--ctp-subtext0); }
|
||||
.field { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.row { flex-direction: row; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.text-in { padding: 0.3rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); }
|
||||
.text-in:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.prompt { padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8rem; font-family: var(--term-font-family, monospace); resize: vertical; line-height: 1.4; }
|
||||
.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:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.unit { font-size: 0.75rem; color: var(--ctp-overlay0); }
|
||||
|
||||
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
|
||||
.seg button { flex: 1; padding: 0.25rem 0.375rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.7rem; cursor: pointer; font-family: var(--ui-font-family); }
|
||||
.seg button:not(:last-child) { border-right: 1px solid var(--ctp-surface1); }
|
||||
.seg button:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
|
||||
.seg button.active { background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); color: var(--ctp-blue); font-weight: 600; }
|
||||
|
||||
.plugin-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.plugin-row { display: flex; align-items: center; justify-content: space-between; padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
.plug-info { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.plug-name { font-size: 0.8rem; color: var(--ctp-text); font-weight: 500; }
|
||||
.plug-ver { font-size: 0.7rem; color: var(--ctp-overlay0); }
|
||||
.empty { font-size: 0.8rem; color: var(--ctp-overlay0); margin: 0; font-style: italic; }
|
||||
|
||||
.toggle { position: relative; width: 2rem; height: 1.125rem; border: none; border-radius: 0.5625rem; background: var(--ctp-surface1); cursor: pointer; transition: background 0.2s; padding: 0; flex-shrink: 0; }
|
||||
.toggle.on { background: var(--ctp-blue); }
|
||||
.thumb { position: absolute; top: 0.125rem; left: 0.125rem; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--ctp-text); transition: transform 0.2s; display: block; }
|
||||
.toggle.on .thumb { transform: translateX(0.875rem); }
|
||||
|
||||
.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; }
|
||||
|
||||
.data-row { display: flex; gap: 0.5rem; }
|
||||
|
||||
.action-btn {
|
||||
padding: 0.275rem 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);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.action-btn:hover:not(:disabled) { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.action-btn.secondary { color: var(--ctp-overlay0); }
|
||||
</style>
|
||||
152
ui-electrobun/src/mainview/settings/AgentSettings.svelte
Normal file
152
ui-electrobun/src/mainview/settings/AgentSettings.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
|
||||
|
||||
type PermMode = 'bypassPermissions' | 'default' | 'plan';
|
||||
|
||||
const PROVIDERS: { id: ProviderId; label: string; desc: string }[] = [
|
||||
{ id: 'claude', label: 'Claude', desc: 'Anthropic — claude-opus/sonnet/haiku' },
|
||||
{ id: 'codex', label: 'Codex', desc: 'OpenAI — gpt-5.4' },
|
||||
{ id: 'ollama', label: 'Ollama', desc: 'Local — qwen3, llama3, etc.' },
|
||||
];
|
||||
|
||||
let defaultShell = $state('/bin/bash');
|
||||
let defaultCwd = $state('~');
|
||||
let permissionMode = $state<PermMode>('bypassPermissions');
|
||||
let systemPrompt = $state('');
|
||||
|
||||
interface ProviderState {
|
||||
enabled: boolean;
|
||||
model: string;
|
||||
}
|
||||
let providerState = $state<Record<string, ProviderState>>({
|
||||
claude: { enabled: true, model: 'claude-opus-4-5' },
|
||||
codex: { enabled: false, model: 'gpt-5.4' },
|
||||
ollama: { enabled: false, model: 'qwen3:8b' },
|
||||
});
|
||||
|
||||
let expandedProvider = $state<string | null>(null);
|
||||
|
||||
function toggleProvider(id: string) {
|
||||
providerState[id] = { ...providerState[id], enabled: !providerState[id].enabled };
|
||||
providerState = { ...providerState };
|
||||
}
|
||||
|
||||
function setModel(id: string, model: string) {
|
||||
providerState[id] = { ...providerState[id], model };
|
||||
providerState = { ...providerState };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="sh">Defaults</h3>
|
||||
|
||||
<div class="field">
|
||||
<label class="lbl" for="ag-shell">Shell</label>
|
||||
<input id="ag-shell" class="text-in" bind:value={defaultShell} placeholder="/bin/bash" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="lbl" for="ag-cwd">Working directory</label>
|
||||
<input id="ag-cwd" class="text-in" bind:value={defaultCwd} placeholder="~" />
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.75rem;">Providers</h3>
|
||||
<div class="prov-list">
|
||||
{#each PROVIDERS as prov}
|
||||
{@const state = providerState[prov.id]}
|
||||
<div class="prov-panel" class:disabled={!state.enabled}>
|
||||
<button class="prov-hdr" onclick={() => expandedProvider = expandedProvider === prov.id ? null : prov.id}>
|
||||
<span class="prov-name">{prov.label}</span>
|
||||
<span class="prov-desc">{prov.desc}</span>
|
||||
<span class="prov-chev">{expandedProvider === prov.id ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
{#if expandedProvider === prov.id}
|
||||
<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>
|
||||
</label>
|
||||
<div class="field">
|
||||
<label class="lbl" for="model-{prov.id}">Default model</label>
|
||||
<input
|
||||
id="model-{prov.id}"
|
||||
class="text-in"
|
||||
value={state.model}
|
||||
placeholder={PROVIDER_CAPABILITIES[prov.id].defaultModel}
|
||||
onchange={e => setModel(prov.id, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="caps">
|
||||
{#if PROVIDER_CAPABILITIES[prov.id].images}<span class="cap">Images</span>{/if}
|
||||
{#if PROVIDER_CAPABILITIES[prov.id].web}<span class="cap">Web</span>{/if}
|
||||
{#if PROVIDER_CAPABILITIES[prov.id].upload}<span class="cap">Upload</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.sh { margin: 0.125rem 0; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
|
||||
.field { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.lbl { font-size: 0.75rem; color: var(--ctp-subtext0); }
|
||||
|
||||
.text-in {
|
||||
padding: 0.3rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family);
|
||||
}
|
||||
.text-in:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
|
||||
.prompt {
|
||||
padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8rem;
|
||||
font-family: var(--term-font-family, monospace); resize: vertical; min-height: 3rem; line-height: 1.4;
|
||||
}
|
||||
.prompt:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.prompt::placeholder { color: var(--ctp-overlay0); }
|
||||
|
||||
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
|
||||
.seg button { flex: 1; padding: 0.25rem 0.5rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.75rem; cursor: pointer; font-family: var(--ui-font-family); }
|
||||
.seg button:not(:last-child) { border-right: 1px solid var(--ctp-surface1); }
|
||||
.seg button:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
|
||||
.seg button.active { background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); color: var(--ctp-blue); font-weight: 600; }
|
||||
|
||||
.prov-list { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.prov-panel { background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.3rem; overflow: hidden; transition: opacity 0.15s; }
|
||||
.prov-panel.disabled { opacity: 0.5; }
|
||||
.prov-hdr { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.45rem 0.625rem; background: transparent; border: none; color: var(--ctp-text); cursor: pointer; text-align: left; font-size: 0.8rem; font-family: var(--ui-font-family); }
|
||||
.prov-hdr:hover { background: var(--ctp-base); }
|
||||
.prov-name { font-weight: 600; white-space: nowrap; }
|
||||
.prov-desc { flex: 1; color: var(--ctp-overlay0); font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.prov-chev { color: var(--ctp-overlay0); font-size: 0.7rem; flex-shrink: 0; }
|
||||
.prov-body { padding: 0.5rem 0.625rem; border-top: 1px solid var(--ctp-surface1); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; cursor: pointer; }
|
||||
.toggle { position: relative; width: 2rem; height: 1.125rem; border: none; border-radius: 0.5625rem; background: var(--ctp-surface1); cursor: pointer; transition: background 0.2s; padding: 0; flex-shrink: 0; }
|
||||
.toggle.on { background: var(--ctp-blue); }
|
||||
.thumb { position: absolute; top: 0.125rem; left: 0.125rem; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--ctp-text); transition: transform 0.2s; }
|
||||
.toggle.on .thumb { transform: translateX(0.875rem); }
|
||||
|
||||
.caps { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.cap { padding: 0.125rem 0.5rem; background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); color: var(--ctp-blue); border-radius: 0.75rem; font-size: 0.65rem; font-weight: 500; }
|
||||
</style>
|
||||
199
ui-electrobun/src/mainview/settings/AppearanceSettings.svelte
Normal file
199
ui-electrobun/src/mainview/settings/AppearanceSettings.svelte
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<script lang="ts">
|
||||
const THEMES = [
|
||||
{ id: 'mocha', label: 'Catppuccin Mocha', group: 'Catppuccin' },
|
||||
{ id: 'macchiato', label: 'Catppuccin Macchiato', group: 'Catppuccin' },
|
||||
{ id: 'frappe', label: 'Catppuccin Frappé', group: 'Catppuccin' },
|
||||
{ id: 'latte', label: 'Catppuccin Latte', group: 'Catppuccin' },
|
||||
];
|
||||
|
||||
const UI_FONTS = [
|
||||
{ value: '', label: 'System Default' },
|
||||
{ value: 'Inter', label: 'Inter' },
|
||||
{ value: 'IBM Plex Sans', label: 'IBM Plex Sans' },
|
||||
{ value: 'Noto Sans', label: 'Noto Sans' },
|
||||
{ value: 'Roboto', label: 'Roboto' },
|
||||
{ value: 'Ubuntu', label: 'Ubuntu' },
|
||||
];
|
||||
|
||||
const TERM_FONTS = [
|
||||
{ value: '', label: 'Default (JetBrains Mono)' },
|
||||
{ value: 'JetBrains Mono', label: 'JetBrains Mono' },
|
||||
{ value: 'Fira Code', label: 'Fira Code' },
|
||||
{ value: 'Cascadia Code', label: 'Cascadia Code' },
|
||||
{ value: 'Source Code Pro', label: 'Source Code Pro' },
|
||||
{ value: 'IBM Plex Mono', label: 'IBM Plex Mono' },
|
||||
{ value: 'monospace', label: 'monospace' },
|
||||
];
|
||||
|
||||
let themeId = $state('mocha');
|
||||
let uiFont = $state('');
|
||||
let uiFontSize = $state(14);
|
||||
let termFont = $state('');
|
||||
let termFontSize = $state(13);
|
||||
let cursorStyle = $state('block');
|
||||
let cursorBlink = $state(true);
|
||||
let scrollback = $state(1000);
|
||||
|
||||
let themeOpen = $state(false);
|
||||
let uiFontOpen = $state(false);
|
||||
let termFontOpen = $state(false);
|
||||
|
||||
let themeLabel = $derived(THEMES.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)');
|
||||
|
||||
function closeAll() { themeOpen = false; uiFontOpen = false; termFontOpen = false; }
|
||||
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest('.dd-wrap')) closeAll();
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') closeAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="section" onclick={handleOutsideClick} onkeydown={handleKey}>
|
||||
<h3 class="sh">Theme</h3>
|
||||
<div class="field">
|
||||
<div class="dd-wrap">
|
||||
<button class="dd-btn" onclick={() => { themeOpen = !themeOpen; uiFontOpen = false; termFontOpen = false; }}>
|
||||
{themeLabel}
|
||||
<svg class="chev" class:open={themeOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#if themeOpen}
|
||||
<ul class="dd-list" role="listbox">
|
||||
{#each THEMES as t}
|
||||
<li class="dd-item" class:sel={themeId === t.id} role="option" aria-selected={themeId === t.id}
|
||||
tabindex="0"
|
||||
onclick={() => { themeId = t.id; themeOpen = false; }}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && (themeId = t.id, themeOpen = false)}
|
||||
>{t.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">UI Font</h3>
|
||||
<div class="field row">
|
||||
<div class="dd-wrap flex1">
|
||||
<button class="dd-btn" onclick={() => { uiFontOpen = !uiFontOpen; themeOpen = false; termFontOpen = false; }}>
|
||||
{uiFontLabel}
|
||||
<svg class="chev" class:open={uiFontOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#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={() => { uiFont = f.value; uiFontOpen = false; }}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && (uiFont = f.value, uiFontOpen = false)}
|
||||
>{f.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<button onclick={() => uiFontSize = Math.max(8, uiFontSize - 1)} aria-label="Decrease UI font size">−</button>
|
||||
<span>{uiFontSize}px</span>
|
||||
<button onclick={() => uiFontSize = Math.min(24, uiFontSize + 1)} aria-label="Increase UI font size">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">Terminal Font</h3>
|
||||
<div class="field row">
|
||||
<div class="dd-wrap flex1">
|
||||
<button class="dd-btn" onclick={() => { termFontOpen = !termFontOpen; themeOpen = false; uiFontOpen = false; }}>
|
||||
{termFontLabel}
|
||||
<svg class="chev" class:open={termFontOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#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={() => { termFont = f.value; termFontOpen = false; }}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && (termFont = f.value, termFontOpen = false)}
|
||||
>{f.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<button onclick={() => termFontSize = Math.max(8, termFontSize - 1)} aria-label="Decrease terminal font size">−</button>
|
||||
<span>{termFontSize}px</span>
|
||||
<button onclick={() => termFontSize = Math.min(24, termFontSize + 1)} aria-label="Increase terminal font size">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">Terminal Cursor</h3>
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="toggle-row">
|
||||
<span>Blink</span>
|
||||
<button class="toggle" class:on={cursorBlink} onclick={() => cursorBlink = !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" />
|
||||
<span class="hint">lines (100–100k)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.sh { margin: 0.375rem 0 0.125rem; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
|
||||
.field { position: relative; }
|
||||
.row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.flex1 { flex: 1; min-width: 0; }
|
||||
|
||||
.dd-wrap { position: relative; }
|
||||
.dd-btn {
|
||||
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 0.375rem;
|
||||
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
|
||||
padding: 0.3rem 0.5rem; color: var(--ctp-text); font-family: var(--ui-font-family);
|
||||
font-size: 0.8125rem; cursor: pointer; text-align: left;
|
||||
}
|
||||
.dd-btn:hover { border-color: var(--ctp-surface2); }
|
||||
.chev { width: 0.75rem; height: 0.75rem; color: var(--ctp-overlay1); transition: transform 0.15s; flex-shrink: 0; }
|
||||
.chev.open { transform: rotate(180deg); }
|
||||
.dd-list {
|
||||
position: absolute; top: calc(100% + 0.125rem); left: 0; right: 0; z-index: 50;
|
||||
list-style: none; margin: 0; padding: 0.25rem;
|
||||
background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.3rem;
|
||||
max-height: 12rem; overflow-y: auto;
|
||||
box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
}
|
||||
.dd-item {
|
||||
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; }
|
||||
|
||||
.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; }
|
||||
.stepper button:hover { background: var(--ctp-surface1); }
|
||||
.stepper span { font-size: 0.8125rem; color: var(--ctp-text); min-width: 2.5rem; text-align: center; }
|
||||
|
||||
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
|
||||
.seg button { flex: 1; padding: 0.25rem 0.5rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.75rem; cursor: pointer; }
|
||||
.seg button:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
|
||||
.seg button.active { background: var(--ctp-blue); color: var(--ctp-base); }
|
||||
|
||||
.toggle-row { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: var(--ctp-subtext0); cursor: pointer; }
|
||||
.toggle { padding: 0.1875rem 0.5rem; background: var(--ctp-surface0); border: none; border-radius: 0.2rem; color: var(--ctp-subtext0); cursor: pointer; font-size: 0.75rem; }
|
||||
.toggle.on { background: var(--ctp-green); color: var(--ctp-base); }
|
||||
|
||||
.num-in { width: 5rem; 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; }
|
||||
.num-in:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.hint { font-size: 0.6875rem; color: var(--ctp-overlay0); }
|
||||
</style>
|
||||
125
ui-electrobun/src/mainview/settings/OrchestrationSettings.svelte
Normal file
125
ui-electrobun/src/mainview/settings/OrchestrationSettings.svelte
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<script lang="ts">
|
||||
type WakeStrategy = 'persistent' | 'on-demand' | 'smart';
|
||||
type AnchorScale = 'small' | 'medium' | 'large' | 'full';
|
||||
|
||||
const WAKE_LABELS: Record<WakeStrategy, string> = {
|
||||
'persistent': 'Persistent',
|
||||
'on-demand': 'On-demand',
|
||||
'smart': 'Smart',
|
||||
};
|
||||
const WAKE_DESCS: Record<WakeStrategy, string> = {
|
||||
'persistent': 'Resume prompt whenever manager wakes',
|
||||
'on-demand': 'Fresh session on each wake',
|
||||
'smart': 'Threshold-gated on-demand',
|
||||
};
|
||||
|
||||
const NOTIF_TYPES = ['complete', 'error', 'crash', 'stall'] as const;
|
||||
const ANCHOR_SCALES: AnchorScale[] = ['small', 'medium', 'large', 'full'];
|
||||
|
||||
let wakeStrategy = $state<WakeStrategy>('persistent');
|
||||
let wakeThreshold = $state(50);
|
||||
let autoAnchor = $state(true);
|
||||
let anchorBudget = $state<AnchorScale>('medium');
|
||||
let stallThreshold = $state(15);
|
||||
let notifDesktop = $state(true);
|
||||
let notifTypes = $state<Set<string>>(new Set(['complete', 'error', 'crash']));
|
||||
|
||||
function toggleNotif(t: string) {
|
||||
const next = new Set(notifTypes);
|
||||
next.has(t) ? next.delete(t) : next.add(t);
|
||||
notifTypes = next;
|
||||
}
|
||||
</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>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="desc">{WAKE_DESCS[wakeStrategy]}</p>
|
||||
|
||||
{#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} />
|
||||
<span class="slider-val">{wakeThreshold}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.875rem;">Session Anchors</h3>
|
||||
<label class="toggle-row">
|
||||
<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>
|
||||
</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}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
<span class="slider-val">{stallThreshold} min</span>
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.875rem;">Notifications</h3>
|
||||
<label class="toggle-row">
|
||||
<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>
|
||||
</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}"
|
||||
/>
|
||||
{t}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.sh { margin: 0.125rem 0; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
|
||||
.lbl { font-size: 0.8rem; color: var(--ctp-subtext0); }
|
||||
.desc { font-size: 0.75rem; color: var(--ctp-overlay1); margin: 0; font-style: italic; }
|
||||
|
||||
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
|
||||
.seg button { flex: 1; padding: 0.25rem 0.5rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.75rem; cursor: pointer; font-family: var(--ui-font-family); }
|
||||
.seg button:not(:last-child) { border-right: 1px solid var(--ctp-surface1); }
|
||||
.seg button:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
|
||||
.seg button.active { background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); color: var(--ctp-blue); font-weight: 600; }
|
||||
|
||||
.slider-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.slider-row input[type="range"] { flex: 1; accent-color: var(--ctp-blue); }
|
||||
.slider-val { font-size: 0.8rem; color: var(--ctp-text); min-width: 3rem; text-align: right; }
|
||||
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; cursor: pointer; padding: 0.125rem 0; }
|
||||
.toggle { position: relative; width: 2rem; height: 1.125rem; border: none; border-radius: 0.5625rem; background: var(--ctp-surface1); cursor: pointer; transition: background 0.2s; padding: 0; flex-shrink: 0; }
|
||||
.toggle.on { background: var(--ctp-blue); }
|
||||
.thumb { position: absolute; top: 0.125rem; left: 0.125rem; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--ctp-text); transition: transform 0.2s; display: block; }
|
||||
.toggle.on .thumb { transform: translateX(0.875rem); }
|
||||
|
||||
.notif-types { display: flex; flex-wrap: wrap; gap: 0.375rem; }
|
||||
.notif-chip { display: flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; cursor: pointer; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); color: var(--ctp-subtext0); transition: all 0.12s; }
|
||||
.notif-chip input { display: none; }
|
||||
.notif-chip.active { background: color-mix(in srgb, var(--ctp-blue) 15%, var(--ctp-surface0)); border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
||||
.notif-chip:hover { border-color: var(--ctp-surface2); color: var(--ctp-subtext1); }
|
||||
</style>
|
||||
155
ui-electrobun/src/mainview/settings/ProjectSettings.svelte
Normal file
155
ui-electrobun/src/mainview/settings/ProjectSettings.svelte
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<script lang="ts">
|
||||
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
|
||||
|
||||
const ANCHOR_SCALES = ['small', 'medium', 'large', 'full'] as const;
|
||||
type AnchorScale = typeof ANCHOR_SCALES[number];
|
||||
|
||||
const PROVIDERS = Object.keys(PROVIDER_CAPABILITIES) as ProviderId[];
|
||||
|
||||
// Demo projects
|
||||
interface ProjectConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: ProviderId;
|
||||
model: string;
|
||||
useWorktrees: boolean;
|
||||
useSandbox: boolean;
|
||||
stallThreshold: number;
|
||||
anchorScale: AnchorScale;
|
||||
customContext: string;
|
||||
}
|
||||
|
||||
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: 'p2',
|
||||
name: 'quanta-discord-bot',
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
useWorktrees: false,
|
||||
useSandbox: false,
|
||||
stallThreshold: 15,
|
||||
anchorScale: 'medium',
|
||||
customContext: '',
|
||||
},
|
||||
]);
|
||||
|
||||
let selectedId = $state('p1');
|
||||
let proj = $derived(projects.find(p => p.id === selectedId)!);
|
||||
|
||||
function updateProj(patch: Partial<ProjectConfig>) {
|
||||
projects = projects.map(p => p.id === selectedId ? { ...p, ...patch } : p);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="section">
|
||||
<!-- Project selector -->
|
||||
<h3 class="sh">Project</h3>
|
||||
<div class="proj-tabs">
|
||||
{#each projects as p}
|
||||
<button class="proj-tab" class:active={selectedId === p.id} onclick={() => selectedId = p.id}>{p.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if proj}
|
||||
<h3 class="sh" style="margin-top: 0.75rem;">Provider</h3>
|
||||
<div class="seg">
|
||||
{#each PROVIDERS as prov}
|
||||
<button class:active={proj.provider === prov} onclick={() => updateProj({ provider: prov })}>{PROVIDER_CAPABILITIES[prov].label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.625rem;">Model</h3>
|
||||
<input
|
||||
class="text-in"
|
||||
value={proj.model}
|
||||
placeholder={PROVIDER_CAPABILITIES[proj.provider].defaultModel}
|
||||
onchange={e => updateProj({ model: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.625rem;">Options</h3>
|
||||
<div class="toggle-fields">
|
||||
<label class="toggle-row">
|
||||
<span class="lbl">Worktree isolation</span>
|
||||
<button class="toggle" class:on={proj.useWorktrees} role="switch" aria-checked={proj.useWorktrees}
|
||||
aria-label="Toggle worktree isolation"
|
||||
onclick={() => updateProj({ useWorktrees: !proj.useWorktrees })}><span class="thumb"></span></button>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<span class="lbl">Landlock sandbox</span>
|
||||
<button class="toggle" class:on={proj.useSandbox} role="switch" aria-checked={proj.useSandbox}
|
||||
aria-label="Toggle Landlock sandbox"
|
||||
onclick={() => updateProj({ useSandbox: !proj.useSandbox })}><span class="thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.625rem;">Stall threshold</h3>
|
||||
<div class="slider-row">
|
||||
<input type="range" min="5" max="60" step="5" value={proj.stallThreshold}
|
||||
oninput={e => updateProj({ stallThreshold: parseInt((e.target as HTMLInputElement).value, 10) })}
|
||||
/>
|
||||
<span class="slider-val">{proj.stallThreshold} min</span>
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.625rem;">Anchor budget</h3>
|
||||
<div class="seg">
|
||||
{#each ANCHOR_SCALES as s}
|
||||
<button class:active={proj.anchorScale === s} onclick={() => updateProj({ anchorScale: s })}>{s[0].toUpperCase() + s.slice(1)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.625rem;">Custom context</h3>
|
||||
<textarea
|
||||
class="prompt"
|
||||
rows="3"
|
||||
value={proj.customContext}
|
||||
placeholder="Custom system prompt context for this project..."
|
||||
onchange={e => updateProj({ customContext: (e.target as HTMLTextAreaElement).value })}
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.sh { margin: 0.125rem 0; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
|
||||
.lbl { font-size: 0.8rem; color: var(--ctp-subtext0); }
|
||||
|
||||
.proj-tabs { display: flex; gap: 0.25rem; flex-wrap: wrap; }
|
||||
.proj-tab { padding: 0.25rem 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-subtext0); font-size: 0.75rem; cursor: pointer; font-family: var(--ui-font-family); }
|
||||
.proj-tab:hover { color: var(--ctp-text); border-color: var(--ctp-surface2); }
|
||||
.proj-tab.active { border-color: var(--ctp-mauve); color: var(--ctp-mauve); background: color-mix(in srgb, var(--ctp-mauve) 10%, var(--ctp-surface0)); }
|
||||
|
||||
.text-in { padding: 0.3rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); }
|
||||
.text-in:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
|
||||
.prompt { padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8rem; font-family: var(--term-font-family, monospace); resize: vertical; line-height: 1.4; }
|
||||
.prompt:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.prompt::placeholder { color: var(--ctp-overlay0); }
|
||||
|
||||
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
|
||||
.seg button { flex: 1; padding: 0.25rem 0.375rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.75rem; cursor: pointer; font-family: var(--ui-font-family); }
|
||||
.seg button:not(:last-child) { border-right: 1px solid var(--ctp-surface1); }
|
||||
.seg button:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
|
||||
.seg button.active { background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); color: var(--ctp-blue); font-weight: 600; }
|
||||
|
||||
.toggle-fields { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; cursor: pointer; padding: 0.25rem 0; }
|
||||
.toggle { position: relative; width: 2rem; height: 1.125rem; border: none; border-radius: 0.5625rem; background: var(--ctp-surface1); cursor: pointer; transition: background 0.2s; padding: 0; flex-shrink: 0; }
|
||||
.toggle.on { background: var(--ctp-blue); }
|
||||
.thumb { position: absolute; top: 0.125rem; left: 0.125rem; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--ctp-text); transition: transform 0.2s; display: block; }
|
||||
.toggle.on .thumb { transform: translateX(0.875rem); }
|
||||
|
||||
.slider-row { display: flex; align-items: center; gap: 0.625rem; }
|
||||
.slider-row input[type="range"] { flex: 1; accent-color: var(--ctp-blue); }
|
||||
.slider-val { font-size: 0.8rem; color: var(--ctp-text); min-width: 3.5rem; text-align: right; }
|
||||
</style>
|
||||
190
ui-electrobun/src/mainview/settings/SecuritySettings.svelte
Normal file
190
ui-electrobun/src/mainview/settings/SecuritySettings.svelte
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<script lang="ts">
|
||||
const KNOWN_KEYS: Record<string, string> = {
|
||||
ANTHROPIC_API_KEY: 'Anthropic API Key',
|
||||
OPENAI_API_KEY: 'OpenAI API Key',
|
||||
GITHUB_TOKEN: 'GitHub Token',
|
||||
OLLAMA_API_KEY: 'Ollama API Key',
|
||||
};
|
||||
|
||||
// Demo state — no real keyring in prototype
|
||||
let keyringAvailable = $state(true);
|
||||
let storedKeys = $state<string[]>(['ANTHROPIC_API_KEY']);
|
||||
let revealedKey = $state<string | null>(null);
|
||||
|
||||
let newKey = $state('');
|
||||
let newValue = $state('');
|
||||
let keyDropOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
interface BranchPolicy {
|
||||
pattern: string;
|
||||
action: 'block' | 'warn';
|
||||
}
|
||||
let branchPolicies = $state<BranchPolicy[]>([
|
||||
{ pattern: 'main', action: 'block' },
|
||||
{ pattern: 'prod*', action: 'warn' },
|
||||
]);
|
||||
let newPattern = $state('');
|
||||
let newAction = $state<'block' | 'warn'>('warn');
|
||||
|
||||
let availableKeys = $derived(Object.keys(KNOWN_KEYS).filter(k => !storedKeys.includes(k)));
|
||||
let newKeyLabel = $derived(newKey ? (KNOWN_KEYS[newKey] ?? newKey) : 'Select key...');
|
||||
|
||||
function handleSaveSecret() {
|
||||
if (!newKey || !newValue) return;
|
||||
saving = true;
|
||||
setTimeout(() => {
|
||||
storedKeys = [...storedKeys, newKey];
|
||||
newKey = ''; newValue = '';
|
||||
saving = false;
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function deleteSecret(key: string) {
|
||||
storedKeys = storedKeys.filter(k => k !== key);
|
||||
if (revealedKey === key) revealedKey = null;
|
||||
}
|
||||
|
||||
function addBranchPolicy() {
|
||||
if (!newPattern.trim()) return;
|
||||
branchPolicies = [...branchPolicies, { pattern: newPattern.trim(), action: newAction }];
|
||||
newPattern = ''; newAction = 'warn';
|
||||
}
|
||||
|
||||
function removeBranchPolicy(idx: number) {
|
||||
branchPolicies = branchPolicies.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest('.dd-wrap')) keyDropOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="section" onclick={handleOutsideClick} onkeydown={e => e.key === 'Escape' && (keyDropOpen = false)}>
|
||||
|
||||
<h3 class="sh">Keyring Status</h3>
|
||||
<div class="keyring-status" class:ok={keyringAvailable} class:unavail={!keyringAvailable}>
|
||||
<span class="ks-dot"></span>
|
||||
<span>{keyringAvailable ? 'System keyring available' : 'System keyring unavailable — secrets stored in plain config'}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">Stored Secrets</h3>
|
||||
{#if storedKeys.length === 0}
|
||||
<p class="empty-hint">No secrets stored.</p>
|
||||
{:else}
|
||||
<div class="secret-list">
|
||||
{#each storedKeys as key}
|
||||
<div class="secret-row">
|
||||
<span class="secret-key">{KNOWN_KEYS[key] ?? key}</span>
|
||||
<span class="secret-val">{revealedKey === key ? '••••••• (revealed)' : '•••••••'}</span>
|
||||
<button class="icon-btn" onclick={() => revealedKey = revealedKey === key ? null : key} title="Toggle reveal">
|
||||
{revealedKey === key ? '🙈' : '👁'}
|
||||
</button>
|
||||
<button class="icon-btn danger" onclick={() => deleteSecret(key)} title="Delete">✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="add-secret">
|
||||
<div class="dd-wrap">
|
||||
<button class="dd-btn small" onclick={() => keyDropOpen = !keyDropOpen}>
|
||||
{newKeyLabel}
|
||||
<svg class="chev" class:open={keyDropOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#if keyDropOpen}
|
||||
<ul class="dd-list" role="listbox">
|
||||
{#each availableKeys as k}
|
||||
<li class="dd-item" role="option" aria-selected={newKey === k} tabindex="0"
|
||||
onclick={() => { newKey = k; keyDropOpen = false; }}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && (newKey = k, keyDropOpen = false)}
|
||||
>{KNOWN_KEYS[k]}</li>
|
||||
{/each}
|
||||
{#if availableKeys.length === 0}
|
||||
<li class="dd-item disabled-item">All known keys stored</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<input class="text-in flex1" type="password" bind:value={newValue} placeholder="Value" aria-label="Secret value" />
|
||||
<button class="save-btn" onclick={handleSaveSecret} disabled={!newKey || !newValue || saving}>
|
||||
{saving ? '…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.875rem;">Branch Policies</h3>
|
||||
<div class="policy-list">
|
||||
{#each branchPolicies as pol, i}
|
||||
<div class="policy-row">
|
||||
<code class="pol-pattern">{pol.pattern}</code>
|
||||
<span class="pol-action" class:block={pol.action === 'block'} class:warn={pol.action === 'warn'}>{pol.action}</span>
|
||||
<button class="icon-btn danger" onclick={() => removeBranchPolicy(i)} title="Remove">✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="add-policy">
|
||||
<input class="text-in flex1" bind:value={newPattern} placeholder="e.g. main, prod*" aria-label="Branch pattern" />
|
||||
<div class="seg">
|
||||
<button class:active={newAction === 'warn'} onclick={() => newAction = 'warn'}>Warn</button>
|
||||
<button class:active={newAction === 'block'} onclick={() => newAction = 'block'}>Block</button>
|
||||
</div>
|
||||
<button class="save-btn" onclick={addBranchPolicy} disabled={!newPattern.trim()}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.sh { margin: 0.375rem 0 0.125rem; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
|
||||
|
||||
.keyring-status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; padding: 0.375rem 0.5rem; border-radius: 0.25rem; background: var(--ctp-surface0); }
|
||||
.ks-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex-shrink: 0; background: var(--ctp-overlay0); }
|
||||
.keyring-status.ok .ks-dot { background: var(--ctp-green); }
|
||||
.keyring-status.unavail .ks-dot { background: var(--ctp-peach); }
|
||||
.keyring-status.ok { color: var(--ctp-subtext1); }
|
||||
.keyring-status.unavail { color: var(--ctp-peach); }
|
||||
|
||||
.empty-hint { font-size: 0.8rem; color: var(--ctp-overlay0); margin: 0; font-style: italic; }
|
||||
|
||||
.secret-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.secret-row { display: flex; align-items: center; gap: 0.375rem; padding: 0.3rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
.secret-key { font-size: 0.8rem; color: var(--ctp-text); font-weight: 500; flex: 1; }
|
||||
.secret-val { font-family: var(--term-font-family, monospace); font-size: 0.75rem; color: var(--ctp-overlay1); min-width: 7rem; }
|
||||
|
||||
.add-secret { display: flex; align-items: center; gap: 0.375rem; margin-top: 0.25rem; }
|
||||
.add-policy { display: flex; align-items: center; gap: 0.375rem; margin-top: 0.25rem; }
|
||||
.flex1 { flex: 1; min-width: 0; }
|
||||
|
||||
.dd-wrap { position: relative; flex-shrink: 0; }
|
||||
.dd-btn { display: flex; align-items: center; justify-content: space-between; gap: 0.25rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-subtext1); font-family: var(--ui-font-family); cursor: pointer; white-space: nowrap; }
|
||||
.dd-btn.small { padding: 0.275rem 0.5rem; font-size: 0.75rem; min-width: 8rem; }
|
||||
.chev { width: 0.625rem; height: 0.625rem; color: var(--ctp-overlay1); transition: transform 0.15s; }
|
||||
.chev.open { transform: rotate(180deg); }
|
||||
.dd-list { position: absolute; top: calc(100% + 0.125rem); left: 0; z-index: 50; list-style: none; margin: 0; padding: 0.2rem; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; min-width: 10rem; box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent); }
|
||||
.dd-item { padding: 0.3rem 0.5rem; border-radius: 0.2rem; font-size: 0.8rem; color: var(--ctp-subtext1); cursor: pointer; outline: none; }
|
||||
.dd-item:hover, .dd-item:focus { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.disabled-item { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.text-in { padding: 0.275rem 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); }
|
||||
.text-in:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
|
||||
.save-btn { padding: 0.275rem 0.625rem; 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; }
|
||||
.save-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.save-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.policy-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.policy-row { display: flex; align-items: center; gap: 0.375rem; padding: 0.3rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
.pol-pattern { font-family: var(--term-font-family, monospace); font-size: 0.8rem; color: var(--ctp-text); flex: 1; }
|
||||
.pol-action { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 0.1rem 0.375rem; border-radius: 0.2rem; }
|
||||
.pol-action.block { background: color-mix(in srgb, var(--ctp-red) 15%, transparent); color: var(--ctp-red); }
|
||||
.pol-action.warn { background: color-mix(in srgb, var(--ctp-yellow) 15%, transparent); color: var(--ctp-yellow); }
|
||||
|
||||
.icon-btn { background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 0.85rem; padding: 0.2rem; border-radius: 0.15rem; }
|
||||
.icon-btn:hover { color: var(--ctp-text); background: var(--ctp-surface1); }
|
||||
.icon-btn.danger:hover { color: var(--ctp-red); }
|
||||
|
||||
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); flex-shrink: 0; }
|
||||
.seg button { padding: 0.25rem 0.5rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.75rem; cursor: pointer; font-family: var(--ui-font-family); }
|
||||
.seg button:not(:last-child) { border-right: 1px solid var(--ctp-surface1); }
|
||||
.seg button.active { background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); color: var(--ctp-blue); font-weight: 600; }
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue