BTerminal/v2/src/lib/components/Workspace/SettingsTab.svelte
Hibryda 0d9c473a06 feat(session-anchors): configurable budget scale + research-backed truncation fix
Remove 500-char assistant text truncation in anchor serializer — research
consensus (JetBrains NeurIPS 2025, SWE-agent, OpenDev ACC) is that agent
reasoning must never be truncated; only tool outputs get observation-masked.

Add AnchorBudgetScale type with 4 presets (Small=2K, Medium=6K, Large=12K,
Full=20K) and per-project range slider in SettingsTab. Remove Ollama-specific
warning toast — budget slider handles context limits generically.
2026-03-11 03:03:53 +01:00

1689 lines
50 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import {
getActiveProjectId,
getActiveGroup,
getActiveGroupId,
getAllGroups,
updateProject,
addProject,
removeProject,
addGroup,
removeGroup,
switchGroup,
} from '../../stores/workspace.svelte';
import { deriveIdentifier } from '../../types/groups';
import { getSetting, setSetting } from '../../adapters/settings-bridge';
import { getCurrentTheme, setTheme } from '../../stores/theme.svelte';
import { THEME_LIST, getPalette, type ThemeId } from '../../styles/themes';
import { listProfiles, type ClaudeProfile } from '../../adapters/claude-bridge';
import { invoke } from '@tauri-apps/api/core';
import { getProviders } from '../../providers/registry.svelte';
import type { ProviderId, ProviderSettings } from '../../providers/types';
import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors';
const PROJECT_ICONS = [
'📁', '🚀', '🤖', '🌐', '🔧', '🎮', '📱', '💻',
'🔬', '📊', '🎨', '🔒', '💬', '📦', '⚡', '🧪',
'🏗️', '📝', '🎯', '💡', '🔥', '🛠️', '🧩', '🗄️',
];
// Claude profiles for account selector
let profiles = $state<ClaudeProfile[]>([]);
// Provider settings (keyed by ProviderId)
let providerSettings = $state<Record<string, ProviderSettings>>({});
let expandedProvider = $state<string | null>(null);
let registeredProviders = $derived(getProviders());
let providerDropdownOpenFor = $state<string | null>(null);
let activeGroupId = $derived(getActiveGroupId());
let activeGroup = $derived(getActiveGroup());
let activeProjectId = $derived(getActiveProjectId());
let groups = $derived(getAllGroups());
let editingProject = $derived(
activeGroup?.projects.find(p => p.id === activeProjectId),
);
// Global settings
let defaultShell = $state('');
let defaultCwd = $state('');
let uiFont = $state('');
let uiFontSize = $state('');
let termFont = $state('');
let termFontSize = $state('');
let projectMaxAspect = $state('1.0');
let filesSaveOnBlur = $state(false);
let selectedTheme = $state<ThemeId>(getCurrentTheme());
// Dropdown open states
let themeDropdownOpen = $state(false);
let uiFontDropdownOpen = $state(false);
let termFontDropdownOpen = $state(false);
// Per-project icon picker & profile dropdown (keyed by project id)
let iconPickerOpenFor = $state<string | null>(null);
let profileDropdownOpenFor = $state<string | null>(null);
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: 'Source Sans 3', label: 'Source Sans 3' },
{ value: 'Ubuntu', label: 'Ubuntu' },
{ value: 'JetBrains Mono', label: 'JetBrains Mono' },
{ value: 'Fira Code', label: 'Fira Code' },
];
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: 'Hack', label: 'Hack' },
{ value: 'Inconsolata', label: 'Inconsolata' },
{ value: 'Ubuntu Mono', label: 'Ubuntu Mono' },
{ value: 'monospace', label: 'monospace' },
];
// Group themes by category
const themeGroups = $derived(() => {
const map = new Map<string, typeof THEME_LIST>();
for (const t of THEME_LIST) {
if (!map.has(t.group)) map.set(t.group, []);
map.get(t.group)!.push(t);
}
return [...map.entries()];
});
let selectedThemeLabel = $derived(
THEME_LIST.find(t => t.id === selectedTheme)?.label ?? selectedTheme,
);
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)',
);
onMount(async () => {
const [shell, cwd, font, size, tfont, tsize, aspect, saveOnBlur] = await Promise.all([
getSetting('default_shell'),
getSetting('default_cwd'),
getSetting('ui_font_family'),
getSetting('ui_font_size'),
getSetting('term_font_family'),
getSetting('term_font_size'),
getSetting('project_max_aspect'),
getSetting('files_save_on_blur'),
]);
defaultShell = shell ?? '';
defaultCwd = cwd ?? '';
uiFont = font ?? '';
uiFontSize = size ?? '';
termFont = tfont ?? '';
termFontSize = tsize ?? '';
projectMaxAspect = aspect ?? '1.0';
filesSaveOnBlur = saveOnBlur === 'true';
applyAspectRatio(projectMaxAspect);
selectedTheme = getCurrentTheme();
try {
profiles = await listProfiles();
} catch {
profiles = [];
}
// Load provider settings
try {
const raw = await getSetting('provider_settings');
if (raw) providerSettings = JSON.parse(raw);
} catch {
providerSettings = {};
}
});
function applyCssProp(prop: string, value: string) {
document.documentElement.style.setProperty(prop, value);
}
async function saveGlobalSetting(key: string, value: string) {
try {
await setSetting(key, value);
} catch (e) {
console.error(`Failed to save setting ${key}:`, e);
}
}
async function handleUiFontChange(family: string) {
uiFont = family;
uiFontDropdownOpen = false;
const val = family
? `'${family}', sans-serif`
: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace";
applyCssProp('--ui-font-family', val);
await saveGlobalSetting('ui_font_family', family);
}
async function handleUiFontSizeChange(size: string) {
const num = parseInt(size, 10);
if (isNaN(num) || num < 8 || num > 24) return;
uiFontSize = size;
applyCssProp('--ui-font-size', `${num}px`);
await saveGlobalSetting('ui_font_size', size);
}
async function handleTermFontChange(family: string) {
termFont = family;
termFontDropdownOpen = false;
const val = family
? `'${family}', monospace`
: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace";
applyCssProp('--term-font-family', val);
await saveGlobalSetting('term_font_family', family);
}
async function handleTermFontSizeChange(size: string) {
const num = parseInt(size, 10);
if (isNaN(num) || num < 8 || num > 24) return;
termFontSize = size;
applyCssProp('--term-font-size', `${num}px`);
await saveGlobalSetting('term_font_size', size);
}
function applyAspectRatio(value: string) {
const num = parseFloat(value);
if (isNaN(num) || num <= 0) return;
document.documentElement.style.setProperty('--project-max-aspect', value);
}
async function handleAspectChange(value: string) {
const num = parseFloat(value);
if (isNaN(num) || num < 0.3 || num > 3.0) return;
projectMaxAspect = value;
applyAspectRatio(value);
await saveGlobalSetting('project_max_aspect', value);
}
async function handleThemeChange(themeId: ThemeId) {
selectedTheme = themeId;
themeDropdownOpen = false;
await setTheme(themeId);
}
async function saveProviderSettings() {
await saveGlobalSetting('provider_settings', JSON.stringify(providerSettings));
}
function toggleProviderEnabled(providerId: string) {
const current = providerSettings[providerId] ?? { enabled: true, config: {} };
providerSettings[providerId] = { ...current, enabled: !current.enabled };
providerSettings = { ...providerSettings };
saveProviderSettings();
}
function setProviderModel(providerId: string, model: string) {
const current = providerSettings[providerId] ?? { enabled: true, config: {} };
providerSettings[providerId] = { ...current, defaultModel: model || undefined };
providerSettings = { ...providerSettings };
saveProviderSettings();
}
function isProviderEnabled(providerId: string): boolean {
return providerSettings[providerId]?.enabled ?? true;
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.custom-dropdown')) {
themeDropdownOpen = false;
uiFontDropdownOpen = false;
termFontDropdownOpen = false;
providerDropdownOpenFor = null;
}
if (!target.closest('.icon-field')) {
iconPickerOpenFor = null;
}
if (!target.closest('.profile-field')) {
profileDropdownOpenFor = null;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
themeDropdownOpen = false;
uiFontDropdownOpen = false;
termFontDropdownOpen = false;
iconPickerOpenFor = null;
profileDropdownOpenFor = null;
}
}
function getProfileLabel(profileName: string): string {
const p = profiles.find(pr => pr.name === profileName);
return p?.display_name || p?.name || profileName || 'default';
}
async function browseDirectory(): Promise<string | null> {
const selected = await invoke<string | null>('pick_directory');
return selected ?? null;
}
// New project form
let newName = $state('');
let newCwd = $state('');
function handleAddProject() {
if (!newName.trim() || !newCwd.trim() || !activeGroupId) return;
const id = crypto.randomUUID();
addProject(activeGroupId, {
id,
name: newName.trim(),
identifier: deriveIdentifier(newName.trim()),
description: '',
icon: '📁',
cwd: newCwd.trim(),
profile: 'default',
enabled: true,
});
newName = '';
newCwd = '';
}
// New group form
let newGroupName = $state('');
function handleAddGroup() {
if (!newGroupName.trim()) return;
const id = crypto.randomUUID();
addGroup({ id, name: newGroupName.trim(), projects: [] });
newGroupName = '';
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-tab" onclick={handleClickOutside} onkeydown={handleKeydown}>
<section class="settings-section">
<h2>Appearance</h2>
<div class="settings-list">
<div class="setting-field">
<span class="setting-label">Theme</span>
<div class="custom-dropdown">
<button
class="dropdown-trigger"
onclick={() => { themeDropdownOpen = !themeDropdownOpen; uiFontDropdownOpen = false; termFontDropdownOpen = false; }}
aria-haspopup="listbox"
aria-expanded={themeDropdownOpen}
>
<span
class="theme-swatch"
style="background: {getPalette(selectedTheme).base}; border-color: {getPalette(selectedTheme).surface1};"
></span>
<span class="dropdown-label">{selectedThemeLabel}</span>
<span class="dropdown-arrow">{themeDropdownOpen ? '\u25B4' : '\u25BE'}</span>
</button>
{#if themeDropdownOpen}
<div class="dropdown-menu" role="listbox">
{#each themeGroups() as [groupName, themes]}
<div class="dropdown-group-label">{groupName}</div>
{#each themes as t}
<button
class="dropdown-option"
class:active={t.id === selectedTheme}
role="option"
aria-selected={t.id === selectedTheme}
onclick={() => handleThemeChange(t.id)}
>
<span
class="theme-swatch"
style="background: {getPalette(t.id).base}; border-color: {getPalette(t.id).surface1};"
></span>
<span class="dropdown-option-label">{t.label}</span>
<span class="theme-colors">
<span class="color-dot" style="background: {getPalette(t.id).red};"></span>
<span class="color-dot" style="background: {getPalette(t.id).green};"></span>
<span class="color-dot" style="background: {getPalette(t.id).blue};"></span>
<span class="color-dot" style="background: {getPalette(t.id).yellow};"></span>
</span>
</button>
{/each}
{/each}
</div>
{/if}
</div>
</div>
<div class="setting-field">
<span class="setting-label">UI Font</span>
<div class="setting-row">
<div class="custom-dropdown dropdown-grow">
<button
class="dropdown-trigger"
onclick={() => { uiFontDropdownOpen = !uiFontDropdownOpen; themeDropdownOpen = false; termFontDropdownOpen = false; }}
aria-haspopup="listbox"
aria-expanded={uiFontDropdownOpen}
>
<span class="dropdown-label" style={uiFont ? `font-family: '${uiFont}', sans-serif` : ''}>{uiFontLabel}</span>
<span class="dropdown-arrow">{uiFontDropdownOpen ? '\u25B4' : '\u25BE'}</span>
</button>
{#if uiFontDropdownOpen}
<div class="dropdown-menu" role="listbox">
{#each UI_FONTS as f}
<button
class="dropdown-option"
class:active={f.value === uiFont}
role="option"
aria-selected={f.value === uiFont}
style={f.value ? `font-family: '${f.value}', sans-serif` : ''}
onclick={() => handleUiFontChange(f.value)}
>
<span class="dropdown-option-label">{f.label}</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="size-control">
<button
class="size-btn"
onclick={() => handleUiFontSizeChange(String((parseInt(uiFontSize, 10) || 13) - 1))}
disabled={(parseInt(uiFontSize, 10) || 13) <= 8}
>&minus;</button>
<input
type="number"
min="8"
max="24"
value={uiFontSize || '13'}
class="size-input"
onchange={e => handleUiFontSizeChange((e.target as HTMLInputElement).value)}
/>
<span class="size-unit">px</span>
<button
class="size-btn"
onclick={() => handleUiFontSizeChange(String((parseInt(uiFontSize, 10) || 13) + 1))}
disabled={(parseInt(uiFontSize, 10) || 13) >= 24}
>+</button>
</div>
</div>
</div>
<div class="setting-field">
<span class="setting-label">Terminal Font</span>
<div class="setting-row">
<div class="custom-dropdown dropdown-grow">
<button
class="dropdown-trigger"
onclick={() => { termFontDropdownOpen = !termFontDropdownOpen; themeDropdownOpen = false; uiFontDropdownOpen = false; }}
aria-haspopup="listbox"
aria-expanded={termFontDropdownOpen}
>
<span class="dropdown-label" style={termFont ? `font-family: '${termFont}', monospace` : ''}>{termFontLabel}</span>
<span class="dropdown-arrow">{termFontDropdownOpen ? '\u25B4' : '\u25BE'}</span>
</button>
{#if termFontDropdownOpen}
<div class="dropdown-menu" role="listbox">
{#each TERM_FONTS as f}
<button
class="dropdown-option"
class:active={f.value === termFont}
role="option"
aria-selected={f.value === termFont}
style={f.value ? `font-family: '${f.value}', monospace` : ''}
onclick={() => handleTermFontChange(f.value)}
>
<span class="dropdown-option-label">{f.label}</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="size-control">
<button
class="size-btn"
onclick={() => handleTermFontSizeChange(String((parseInt(termFontSize, 10) || 13) - 1))}
disabled={(parseInt(termFontSize, 10) || 13) <= 8}
>&minus;</button>
<input
type="number"
min="8"
max="24"
value={termFontSize || '13'}
class="size-input"
onchange={e => handleTermFontSizeChange((e.target as HTMLInputElement).value)}
/>
<span class="size-unit">px</span>
<button
class="size-btn"
onclick={() => handleTermFontSizeChange(String((parseInt(termFontSize, 10) || 13) + 1))}
disabled={(parseInt(termFontSize, 10) || 13) >= 24}
>+</button>
</div>
</div>
</div>
<div class="setting-field">
<span class="setting-label">Project max aspect ratio</span>
<div class="size-control">
<button
class="size-btn"
onclick={() => handleAspectChange((Math.max(0.3, parseFloat(projectMaxAspect) - 0.1)).toFixed(1))}
disabled={parseFloat(projectMaxAspect) <= 0.3}
>&minus;</button>
<input
type="number"
min="0.3"
max="3.0"
step="0.1"
value={projectMaxAspect}
class="size-input"
onchange={e => handleAspectChange((e.target as HTMLInputElement).value)}
/>
<span class="size-unit">w:h</span>
<button
class="size-btn"
onclick={() => handleAspectChange((Math.min(3.0, parseFloat(projectMaxAspect) + 0.1)).toFixed(1))}
disabled={parseFloat(projectMaxAspect) >= 3.0}
>+</button>
</div>
</div>
</div>
</section>
<section class="settings-section">
<h2>Defaults</h2>
<div class="settings-list">
<div class="setting-field">
<label for="default-shell" class="setting-label">Shell</label>
<input
id="default-shell"
value={defaultShell}
placeholder="/bin/bash"
onchange={e => { defaultShell = (e.target as HTMLInputElement).value; saveGlobalSetting('default_shell', defaultShell); }}
/>
</div>
<div class="setting-field">
<label for="default-cwd" class="setting-label">Working directory</label>
<div class="input-with-browse">
<input
id="default-cwd"
value={defaultCwd}
placeholder="~"
onchange={e => { defaultCwd = (e.target as HTMLInputElement).value; saveGlobalSetting('default_cwd', defaultCwd); }}
/>
<button class="browse-btn" title="Browse..." onclick={async () => { const d = await browseDirectory(); if (d) { defaultCwd = d; saveGlobalSetting('default_cwd', d); } }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M3 7v13h18V7H3zm0-2h7l2 2h9v1H3V5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
</div>
<h3 class="subsection-title">Editor</h3>
<div class="settings-grid">
<div class="setting-field">
<label class="setting-label toggle-label">
<span>Save on blur</span>
<button
class="toggle-switch"
class:on={filesSaveOnBlur}
role="switch"
aria-checked={filesSaveOnBlur}
onclick={() => { filesSaveOnBlur = !filesSaveOnBlur; saveGlobalSetting('files_save_on_blur', String(filesSaveOnBlur)); }}
>
<span class="toggle-thumb"></span>
</button>
</label>
<span class="setting-hint">Auto-save files when the editor loses focus</span>
</div>
</div>
</section>
<section class="settings-section">
<h2>Providers</h2>
<div class="provider-list">
{#each registeredProviders as provider}
<div class="provider-panel" class:disabled={!isProviderEnabled(provider.id)}>
<button
class="provider-header"
onclick={() => { expandedProvider = expandedProvider === provider.id ? null : provider.id; }}
>
<span class="provider-name">{provider.name}</span>
<span class="provider-desc">{provider.description}</span>
<span class="provider-chevron">{expandedProvider === provider.id ? '\u25B4' : '\u25BE'}</span>
</button>
{#if expandedProvider === provider.id}
<div class="provider-body">
<div class="setting-field">
<label class="setting-label toggle-label">
<span>Enabled</span>
<button
class="toggle-switch"
class:on={isProviderEnabled(provider.id)}
role="switch"
aria-checked={isProviderEnabled(provider.id)}
onclick={() => toggleProviderEnabled(provider.id)}
>
<span class="toggle-thumb"></span>
</button>
</label>
</div>
{#if provider.capabilities.hasModelSelection}
<div class="setting-field">
<span class="setting-label">Default model</span>
<input
value={providerSettings[provider.id]?.defaultModel ?? provider.defaultModel ?? ''}
placeholder={provider.defaultModel ?? 'default'}
onchange={e => setProviderModel(provider.id, (e.target as HTMLInputElement).value)}
/>
</div>
{/if}
<div class="provider-caps">
<span class="setting-label">Capabilities</span>
<div class="caps-grid">
{#if provider.capabilities.hasProfiles}<span class="cap-badge">Profiles</span>{/if}
{#if provider.capabilities.hasSkills}<span class="cap-badge">Skills</span>{/if}
{#if provider.capabilities.supportsSubagents}<span class="cap-badge">Subagents</span>{/if}
{#if provider.capabilities.supportsCost}<span class="cap-badge">Cost tracking</span>{/if}
{#if provider.capabilities.supportsResume}<span class="cap-badge">Resume</span>{/if}
{#if provider.capabilities.hasSandbox}<span class="cap-badge">Sandbox</span>{/if}
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
</section>
<section class="settings-section">
<h2>Groups</h2>
<div class="group-list">
{#each groups as group}
<div class="group-row" class:active={group.id === activeGroupId}>
<button class="group-name" onclick={async () => await switchGroup(group.id)}>
{group.name}
</button>
<span class="group-count">{group.projects.length} projects</span>
{#if groups.length > 1}
<button class="btn-danger" onclick={() => removeGroup(group.id)}>Remove</button>
{/if}
</div>
{/each}
</div>
<div class="add-form">
<input bind:value={newGroupName} placeholder="New group name" />
<button class="btn-primary" onclick={handleAddGroup} disabled={!newGroupName.trim()}>
Add Group
</button>
</div>
</section>
{#if activeGroup}
<section class="settings-section">
<h2>Projects in "{activeGroup.name}"</h2>
<div class="project-cards">
{#each activeGroup.projects as project}
<div class="project-card">
<div class="card-top-row">
<div class="icon-field">
<button
class="icon-trigger"
onclick={() => { iconPickerOpenFor = iconPickerOpenFor === project.id ? null : project.id; }}
title="Choose icon"
>{project.icon || '📁'}</button>
{#if iconPickerOpenFor === project.id}
<div class="icon-picker">
{#each PROJECT_ICONS as emoji}
<button
class="icon-option"
class:active={project.icon === emoji}
onclick={() => {
updateProject(activeGroupId, project.id, { icon: emoji });
iconPickerOpenFor = null;
}}
>{emoji}</button>
{/each}
</div>
{/if}
</div>
<div class="card-name-area">
<input
class="card-name-input"
value={project.name}
placeholder="Project name"
onchange={e => updateProject(activeGroupId, project.id, { name: (e.target as HTMLInputElement).value })}
/>
</div>
<label class="card-toggle" title={project.enabled ? 'Enabled' : 'Disabled'}>
<input
type="checkbox"
checked={project.enabled}
onchange={e => updateProject(activeGroupId, project.id, { enabled: (e.target as HTMLInputElement).checked })}
/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v13h18V7H3zm0-2h7l2 2h9v1H3V5z"/></svg>
Path
</span>
<div class="input-with-browse">
<input
class="cwd-input"
value={project.cwd}
placeholder="/path/to/project"
title={project.cwd}
onchange={e => updateProject(activeGroupId, project.id, { cwd: (e.target as HTMLInputElement).value })}
/>
<button class="browse-btn" title="Browse..." onclick={async () => { const d = await browseDirectory(); if (d) updateProject(activeGroupId, project.id, { cwd: d }); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M3 7v13h18V7H3zm0-2h7l2 2h9v1H3V5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
<div class="card-field profile-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Account
</span>
{#if profiles.length > 1}
<div class="custom-dropdown">
<button
class="dropdown-trigger profile-trigger"
onclick={() => { profileDropdownOpenFor = profileDropdownOpenFor === project.id ? null : project.id; }}
aria-haspopup="listbox"
aria-expanded={profileDropdownOpenFor === project.id}
>
<span class="profile-badge">{getProfileLabel(project.profile)}</span>
<span class="dropdown-arrow">{profileDropdownOpenFor === project.id ? '\u25B4' : '\u25BE'}</span>
</button>
{#if profileDropdownOpenFor === project.id}
<div class="dropdown-menu profile-menu" role="listbox">
{#each profiles as prof}
<button
class="dropdown-option"
class:active={project.profile === prof.name}
role="option"
aria-selected={project.profile === prof.name}
onclick={() => {
updateProject(activeGroupId, project.id, { profile: prof.name });
profileDropdownOpenFor = null;
}}
>
<span class="profile-option-name">{prof.display_name || prof.name}</span>
{#if prof.email}
<span class="profile-option-email">{prof.email}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{:else}
<span class="profile-single">{getProfileLabel(project.profile)}</span>
{/if}
</div>
{#if registeredProviders.length > 1}
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Provider
</span>
<div class="custom-dropdown">
<button
class="dropdown-trigger provider-trigger"
onclick={() => { providerDropdownOpenFor = providerDropdownOpenFor === project.id ? null : project.id; }}
aria-haspopup="listbox"
aria-expanded={providerDropdownOpenFor === project.id}
>
<span class="dropdown-label">{registeredProviders.find(p => p.id === (project.provider ?? 'claude'))?.name ?? 'Claude Code'}</span>
<span class="dropdown-arrow">{providerDropdownOpenFor === project.id ? '\u25B4' : '\u25BE'}</span>
</button>
{#if providerDropdownOpenFor === project.id}
<div class="dropdown-menu" role="listbox">
{#each registeredProviders.filter(p => isProviderEnabled(p.id)) as prov}
<button
class="dropdown-option"
class:active={(project.provider ?? 'claude') === prov.id}
role="option"
aria-selected={(project.provider ?? 'claude') === prov.id}
onclick={() => {
updateProject(activeGroupId, project.id, { provider: prov.id });
providerDropdownOpenFor = null;
}}
>
<span class="dropdown-option-label">{prov.name}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
Anchor Budget
</span>
<div class="scale-slider">
<input
type="range"
min="0"
max={ANCHOR_BUDGET_SCALES.length - 1}
step="1"
value={ANCHOR_BUDGET_SCALES.indexOf(project.anchorBudgetScale ?? 'medium')}
oninput={(e) => {
const idx = parseInt((e.target as HTMLInputElement).value);
updateProject(activeGroupId, project.id, { anchorBudgetScale: ANCHOR_BUDGET_SCALES[idx] });
}}
/>
<span class="scale-label">{ANCHOR_BUDGET_SCALE_LABELS[project.anchorBudgetScale ?? 'medium']}</span>
</div>
</div>
<div class="card-footer">
<button class="btn-remove" onclick={() => removeProject(activeGroupId, project.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
Remove
</button>
</div>
</div>
{/each}
</div>
{#if activeGroup.projects.length < 5}
<div class="add-project-form">
<div class="add-form-row">
<input class="add-name" bind:value={newName} placeholder="Project name" />
<div class="input-with-browse add-form-path">
<input bind:value={newCwd} placeholder="/path/to/project" />
<button class="browse-btn" title="Browse..." onclick={async () => { const d = await browseDirectory(); if (d) newCwd = d; }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M3 7v13h18V7H3zm0-2h7l2 2h9v1H3V5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<button class="btn-primary" onclick={handleAddProject} disabled={!newName.trim() || !newCwd.trim()}>
+ Add
</button>
</div>
</div>
{:else}
<p class="limit-notice">Maximum 5 projects per group reached.</p>
{/if}
</section>
{/if}
</div>
<style>
.settings-tab {
padding: 0.75rem 1rem;
overflow-y: auto;
height: 100%;
min-width: 22em;
}
h2 {
font-size: 0.8rem;
font-weight: 600;
color: var(--ctp-text);
margin: 0 0 0.625rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.settings-section {
margin-bottom: 1.25rem;
}
.settings-list {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.setting-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.setting-label {
font-size: 0.7rem;
color: var(--ctp-subtext0);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.setting-field > input,
.setting-field .input-with-browse input {
padding: 0.375rem 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8rem;
}
.setting-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
/* Reusable custom dropdown */
.custom-dropdown {
position: relative;
}
.dropdown-grow {
flex: 1;
min-width: 0;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.375rem 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8rem;
cursor: pointer;
text-align: left;
height: 100%;
}
.dropdown-trigger:hover {
border-color: var(--ctp-surface2);
}
.dropdown-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-arrow {
color: var(--ctp-overlay0);
font-size: 0.7rem;
flex-shrink: 0;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
min-width: 100%;
width: max-content;
max-height: 22.5rem;
overflow-y: auto;
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.4);
z-index: 100;
padding: 0.25rem 0;
}
.dropdown-group-label {
padding: 0.375rem 0.625rem 0.125rem;
font-size: 0.65rem;
font-weight: 700;
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dropdown-option {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.3125rem 0.625rem;
background: transparent;
border: none;
color: var(--ctp-subtext1);
font-size: 0.8rem;
cursor: pointer;
text-align: left;
white-space: nowrap;
}
.dropdown-option:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.dropdown-option.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
font-weight: 600;
}
.dropdown-option-label {
flex: 1;
}
/* Theme-specific dropdown extras */
.theme-swatch {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 0.1875rem;
border: 1px solid;
flex-shrink: 0;
}
.theme-colors {
display: flex;
gap: 0.1875rem;
flex-shrink: 0;
}
.color-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Size control (shared by UI and Terminal font) */
.size-control {
display: flex;
align-items: center;
gap: 0.125rem;
flex-shrink: 0;
}
.size-btn {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.9rem;
cursor: pointer;
}
.size-btn:hover {
background: var(--ctp-surface1);
}
.size-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.size-input {
width: 2.5rem;
padding: 0.25rem 0.125rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8rem;
text-align: center;
-moz-appearance: textfield;
}
.size-input::-webkit-inner-spin-button,
.size-input::-webkit-outer-spin-button {
-webkit-appearance: none;
}
.size-unit {
font-size: 0.7rem;
color: var(--ctp-overlay0);
margin-right: 0.125rem;
}
/* Groups */
.group-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.group-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--ctp-surface0);
border-radius: 0.25rem;
}
.group-row.active {
border-left: 3px solid var(--ctp-blue);
}
.group-name {
background: transparent;
border: none;
color: var(--ctp-text);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
flex: 1;
text-align: left;
}
.group-count {
color: var(--ctp-overlay0);
font-size: 0.75rem;
}
/* Project Cards */
.project-cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-card {
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
padding: 0.625rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
transition: border-color 0.15s;
}
.project-card:hover {
border-color: var(--ctp-surface2);
}
.card-top-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-name-area {
flex: 1;
min-width: 0;
}
.card-name-input {
width: 100%;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid transparent;
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.85rem;
font-weight: 600;
transition: border-color 0.15s, background 0.15s;
}
.card-name-input:hover {
background: var(--ctp-base);
border-color: var(--ctp-surface1);
}
.card-name-input:focus {
background: var(--ctp-base);
border-color: var(--ctp-blue);
outline: none;
}
/* Toggle switch */
.card-toggle {
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.card-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-track {
display: block;
width: 2rem;
height: 1.125rem;
background: var(--ctp-surface2);
border-radius: 0.5625rem;
transition: background 0.2s;
position: relative;
}
.card-toggle input:checked + .toggle-track {
background: var(--ctp-green);
}
.toggle-thumb {
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 0.875rem;
height: 0.875rem;
background: var(--ctp-text);
border-radius: 50%;
transition: transform 0.2s;
}
.card-toggle input:checked + .toggle-track .toggle-thumb {
transform: translateX(0.875rem);
}
/* Card fields */
.card-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.card-field-label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.65rem;
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card-field-label svg {
flex-shrink: 0;
opacity: 0.6;
}
.card-field .input-with-browse input {
padding: 0.3125rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.78rem;
font-family: var(--term-font-family, monospace);
}
/* Anchor budget scale slider */
.scale-slider {
display: flex;
align-items: center;
gap: 0.5rem;
}
.scale-slider input[type="range"] {
flex: 1;
height: 0.25rem;
appearance: none;
background: var(--ctp-surface1);
border-radius: 0.125rem;
outline: none;
cursor: pointer;
}
.scale-slider input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
background: var(--ctp-blue);
border: 2px solid var(--ctp-base);
cursor: pointer;
}
.scale-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
white-space: nowrap;
min-width: 5.5em;
}
/* CWD input: left-ellipsis */
.cwd-input {
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
/* Profile field */
.profile-trigger {
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
font-size: 0.78rem;
}
.profile-badge {
font-weight: 600;
color: var(--ctp-blue);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-single {
font-size: 0.78rem;
font-weight: 600;
color: var(--ctp-blue);
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
}
.profile-menu {
min-width: 12rem;
}
.profile-option-name {
font-weight: 500;
color: var(--ctp-text);
}
.profile-option-email {
font-size: 0.7rem;
color: var(--ctp-overlay0);
margin-left: auto;
}
/* Card footer */
.card-footer {
display: flex;
justify-content: flex-end;
padding-top: 0.25rem;
border-top: 1px solid var(--ctp-surface1);
margin-top: 0.125rem;
}
.btn-remove {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: transparent;
color: var(--ctp-overlay0);
border: none;
border-radius: 0.25rem;
font-size: 0.7rem;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.btn-remove:hover {
color: var(--ctp-red);
background: color-mix(in srgb, var(--ctp-red) 8%, transparent);
}
/* Icon field & picker */
.icon-field {
position: relative;
flex-shrink: 0;
}
.icon-trigger {
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
font-size: 1.1rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.icon-trigger:hover {
border-color: var(--ctp-overlay0);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ctp-blue) 15%, transparent);
}
.icon-picker {
display: grid;
position: absolute;
top: calc(100% + 0.375rem);
left: 50%;
transform: translateX(-50%);
z-index: 50;
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
padding: 0.5rem;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.35);
width: max-content;
}
.icon-option {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 0.25rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.1s, transform 0.1s;
}
.icon-option:hover {
background: var(--ctp-surface0);
transform: scale(1.15);
}
.icon-option.active {
background: var(--ctp-surface1);
border-color: var(--ctp-blue);
}
/* Add project form */
.add-project-form {
margin-top: 0.625rem;
padding: 0.5rem 0.625rem;
background: var(--ctp-mantle);
border: 1px dashed var(--ctp-surface1);
border-radius: 0.5rem;
}
.add-form-row {
display: flex;
gap: 0.5rem;
align-items: center;
min-width: 0;
}
.add-name {
width: 8rem;
flex-shrink: 0;
padding: 0.3125rem 0.625rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8rem;
}
.add-form {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.5rem;
min-width: 0;
}
.add-form input {
padding: 0.3125rem 0.625rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8rem;
flex: 1;
min-width: 0;
}
.btn-primary {
padding: 0.3125rem 0.875rem;
background: var(--ctp-blue);
color: var(--ctp-base);
border: none;
border-radius: 0.25rem;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-danger {
padding: 0.25rem 0.625rem;
background: transparent;
color: var(--ctp-red);
border: 1px solid var(--ctp-red);
border-radius: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
white-space: nowrap;
}
.limit-notice {
color: var(--ctp-overlay0);
font-size: 0.8rem;
font-style: italic;
margin-top: 0.5rem;
}
.input-with-browse {
display: flex;
gap: 0.25rem;
align-items: stretch;
}
.input-with-browse input {
flex: 1;
min-width: 0;
}
.add-form-path {
flex: 1;
min-width: 0;
}
.add-form-path input {
padding: 0.3125rem 0.625rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8rem;
}
.browse-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-subtext0);
cursor: pointer;
flex-shrink: 0;
}
.browse-btn:hover {
color: var(--ctp-text);
background: var(--ctp-surface1);
}
.subsection-title {
font-size: 0.725rem;
font-weight: 600;
color: var(--ctp-subtext1);
margin: 0.75rem 0 0.5rem;
}
.setting-hint {
font-size: 0.625rem;
color: var(--ctp-overlay0);
}
.toggle-label {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.toggle-switch {
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-switch.on {
background: var(--ctp-blue);
}
.toggle-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-switch.on .toggle-thumb {
transform: translateX(0.875rem);
}
/* Provider section */
.provider-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.provider-panel {
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
overflow: hidden;
transition: opacity 0.15s;
}
.provider-panel.disabled {
opacity: 0.5;
}
.provider-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
background: transparent;
border: none;
color: var(--ctp-text);
cursor: pointer;
text-align: left;
font-size: 0.8rem;
}
.provider-header:hover {
background: var(--ctp-base);
}
.provider-name {
font-weight: 600;
white-space: nowrap;
}
.provider-desc {
flex: 1;
color: var(--ctp-overlay0);
font-size: 0.7rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-chevron {
color: var(--ctp-overlay0);
font-size: 0.7rem;
flex-shrink: 0;
}
.provider-body {
padding: 0.5rem 0.625rem;
border-top: 1px solid var(--ctp-surface1);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.provider-body > .setting-field > input {
padding: 0.375rem 0.625rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8rem;
}
.provider-caps {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.caps-grid {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.cap-badge {
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;
white-space: nowrap;
}
.provider-trigger {
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
font-size: 0.78rem;
}
</style>