feat(electrobun): groups, cloning, shortcuts, custom window — all 5 features
Groups Sidebar: - SQLite groups table (4 seeded: Development, Testing, DevOps, Research) - Left icon rail with emoji group icons, Ctrl+1-4 switching - Active group highlighted, projects filtered by group Project Cloning: - Clone button on project cards (fork icon) - git worktree add via Bun.spawn (array form, no shell strings) - 3-clone limit, branch name validation, pending-status pattern - Clone cards: WT badge + branch name + accent top border - Chain link SVG icons between linked clones in grid Keyboard Shortcuts: - keybinding-store.svelte.ts: 16 defaults across 4 categories - Two-scope: document capture + terminal focus guard - KeyboardSettings.svelte: search, click-to-capture, conflict detection - Per-binding reset + Reset All Custom Window: - titleBarStyle: "hidden" — no native title bar - Vertical "AGOR" text in left sidebar (writing-mode: vertical-rl) - Floating window controls badge (minimize/maximize/close) - Draggable region via -webkit-app-region: drag - Window frame persisted to SQLite (debounced 500ms) Window is resizable by default (Electrobun BrowserWindow).
This commit is contained in:
parent
5032021915
commit
a020f59cb4
14 changed files with 1741 additions and 189 deletions
|
|
@ -6,6 +6,7 @@
|
|||
import ToastContainer from './ToastContainer.svelte';
|
||||
import { themeStore } from './theme-store.svelte.ts';
|
||||
import { fontStore } from './font-store.svelte.ts';
|
||||
import { keybindingStore } from './keybinding-store.svelte.ts';
|
||||
import { appRpc } from './main.ts';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
|
@ -32,10 +33,22 @@
|
|||
model?: string;
|
||||
contextPct?: number;
|
||||
burnRate?: number;
|
||||
groupId?: string;
|
||||
cloneOf?: string;
|
||||
worktreeBranch?: string;
|
||||
mainRepoPath?: string;
|
||||
cloneIndex?: number;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
// ── Demo data ──────────────────────────────────────────────────
|
||||
const PROJECTS: Project[] = [
|
||||
let PROJECTS = $state<Project[]>([
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'agent-orchestrator',
|
||||
|
|
@ -49,14 +62,14 @@
|
|||
model: 'claude-opus-4-5',
|
||||
contextPct: 78,
|
||||
burnRate: 0.12,
|
||||
groupId: 'dev',
|
||||
mainRepoPath: '~/code/ai/agent-orchestrator',
|
||||
messages: [
|
||||
{ id: 1, role: 'user', content: 'Add a wake scheduler for Manager agents that wakes them when review queue depth > 3.' },
|
||||
{ id: 2, role: 'assistant', content: 'Reading existing wake-scheduler.svelte.ts to understand the 3-strategy pattern...' },
|
||||
{ id: 3, role: 'tool-call', content: 'Read("src/lib/stores/wake-scheduler.svelte.ts")' },
|
||||
{ id: 4, role: 'tool-result', content: '// 312 lines\nexport type WakeStrategy = "persistent" | "on-demand" | "smart";\n...' },
|
||||
{ id: 5, role: 'assistant', content: 'Found the WakeSignal enum. I\'ll add ReviewBacklog(0.6) and hook into bttask polling. Writing wake-scorer.ts update now.' },
|
||||
{ id: 6, role: 'tool-call', content: 'Edit("src/lib/utils/wake-scorer.ts", old="// signals", new="// signals\n ReviewBacklog = 0.6,")' },
|
||||
{ id: 7, role: 'assistant', content: 'Done. ReviewBacklog signal added at priority 0.6 (between TaskQueuePressure 0.7 and PeriodicFloor 0.1). Run `npm test` to verify 24 scorer tests still pass.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -70,38 +83,143 @@
|
|||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
contextPct: 32,
|
||||
groupId: 'dev',
|
||||
messages: [
|
||||
{ id: 1, role: 'user', content: 'Why is the QRAG MCP server returning 504s on large semantic search queries?' },
|
||||
{ id: 2, role: 'assistant', content: 'Checking the MCP HTTP handler timeout config and Qdrant query path...' },
|
||||
{ id: 3, role: 'tool-call', content: 'Read("src/mcp/server.ts", offset=120, limit=40)' },
|
||||
{ id: 4, role: 'tool-result', content: 'const QUERY_TIMEOUT_MS = 5000; // default\n...' },
|
||||
{ id: 5, role: 'assistant', content: 'Found it. The MCP handler has a hardcoded 5s timeout but Qdrant HNSW search on 500k vectors takes 6–9s at ef=256. Two options:\n1. Raise timeout to 15s (quick fix)\n2. Lower ef to 128 for faster approximate search (~3s, slight recall drop)\n\nRecommend option 2 for latency-sensitive bot responses.' },
|
||||
{ id: 6, role: 'user', content: 'Go with option 2.' },
|
||||
{ id: 7, role: 'assistant', content: 'Updated `ef_searching: 128` in qdrant-config.ts and raised timeout to 8s as safety margin. Restarted service on port 9320.' },
|
||||
{ id: 5, role: 'assistant', content: 'Found it. Raised ef_searching to 128 and timeout to 8s as safety margin.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
// ── Init theme + fonts on mount ────────────────────────────────
|
||||
// ── Groups state ───────────────────────────────────────────────
|
||||
let groups = $state<Group[]>([
|
||||
{ id: 'dev', name: 'Development', icon: '🔧', position: 0 },
|
||||
{ id: 'test', name: 'Testing', icon: '🧪', position: 1 },
|
||||
{ id: 'ops', name: 'DevOps', icon: '🚀', position: 2 },
|
||||
{ id: 'research', name: 'Research', icon: '🔬', position: 3 },
|
||||
]);
|
||||
let activeGroupId = $state('dev');
|
||||
|
||||
// ── Filtered projects for active group ────────────────────────
|
||||
let activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]);
|
||||
let filteredProjects = $derived(
|
||||
PROJECTS.filter(p => (p.groupId ?? 'dev') === activeGroupId)
|
||||
);
|
||||
|
||||
// Group projects into: top-level cards + clone groups
|
||||
// A "clone group" is a parent + its clones rendered side by side
|
||||
interface ProjectRow {
|
||||
type: 'standalone';
|
||||
project: Project;
|
||||
}
|
||||
interface CloneGroupRow {
|
||||
type: 'clone-group';
|
||||
parent: Project;
|
||||
clones: Project[];
|
||||
}
|
||||
type GridRow = ProjectRow | CloneGroupRow;
|
||||
|
||||
let gridRows = $derived((): GridRow[] => {
|
||||
const standalone: GridRow[] = [];
|
||||
const cloneParentIds = new Set(
|
||||
filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!)
|
||||
);
|
||||
|
||||
for (const p of filteredProjects) {
|
||||
if (p.cloneOf) continue; // Skip clones — they go into clone groups
|
||||
if (cloneParentIds.has(p.id)) {
|
||||
// This parent has clones
|
||||
const clones = filteredProjects
|
||||
.filter(c => c.cloneOf === p.id)
|
||||
.sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0));
|
||||
standalone.push({ type: 'clone-group', parent: p, clones });
|
||||
} else {
|
||||
standalone.push({ type: 'standalone', project: p });
|
||||
}
|
||||
}
|
||||
return standalone;
|
||||
});
|
||||
|
||||
// ── Clone count helpers ────────────────────────────────────────
|
||||
function cloneCountForProject(projectId: string): number {
|
||||
return PROJECTS.filter(p => p.cloneOf === projectId).length;
|
||||
}
|
||||
|
||||
function handleClone(projectId: string) {
|
||||
// In a real app: open the clone dialog and call RPC.
|
||||
// Here we demonstrate the flow by calling the RPC directly with a demo branch.
|
||||
const source = PROJECTS.find(p => p.id === projectId);
|
||||
if (!source) return;
|
||||
const branchName = `feature/clone-${Date.now()}`;
|
||||
appRpc.request["project.clone"]({ projectId, branchName }).then((result) => {
|
||||
if (result.ok && result.project) {
|
||||
const cloneConfig = JSON.parse(result.project.config) as Project;
|
||||
PROJECTS = [...PROJECTS, {
|
||||
...cloneConfig,
|
||||
status: 'idle',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
messages: [],
|
||||
}];
|
||||
} else {
|
||||
console.error('[clone]', result.error);
|
||||
}
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
// ── Init on mount ──────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
themeStore.initTheme(appRpc).catch(console.error);
|
||||
fontStore.initFonts(appRpc).catch(console.error);
|
||||
keybindingStore.init(appRpc).catch(console.error);
|
||||
|
||||
// Load groups from DB
|
||||
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }) => {
|
||||
if (dbGroups.length > 0) groups = dbGroups;
|
||||
}).catch(console.error);
|
||||
|
||||
// Restore active group from settings
|
||||
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }) => {
|
||||
if (value && groups.some(g => g.id === value)) activeGroupId = value;
|
||||
}).catch(console.error);
|
||||
|
||||
// Register keybinding command handlers
|
||||
keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; });
|
||||
keybindingStore.on('settings', () => { settingsOpen = !settingsOpen; });
|
||||
keybindingStore.on('group1', () => setActiveGroup(groups[0]?.id));
|
||||
keybindingStore.on('group2', () => setActiveGroup(groups[1]?.id));
|
||||
keybindingStore.on('group3', () => setActiveGroup(groups[2]?.id));
|
||||
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
|
||||
keybindingStore.on('minimize', () => appRpc.request["window.minimize"]({}).catch(console.error));
|
||||
|
||||
// Install listener (returns cleanup fn, but onMount handles lifecycle)
|
||||
const cleanup = keybindingStore.installListener();
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
// ── Reactive state ─────────────────────────────────────────────
|
||||
let settingsOpen = $state(false);
|
||||
let paletteOpen = $state(false);
|
||||
let notifCount = $state(2); // demo unread
|
||||
let notifCount = $state(2);
|
||||
let sessionStart = $state(Date.now());
|
||||
|
||||
// Blink state — JS timer, no CSS animation (0% CPU overhead)
|
||||
function setActiveGroup(id: string | undefined) {
|
||||
if (!id) return;
|
||||
activeGroupId = id;
|
||||
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error);
|
||||
}
|
||||
|
||||
// Blink state
|
||||
let blinkVisible = $state(true);
|
||||
$effect(() => {
|
||||
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Session duration string (updates every 10s)
|
||||
// Session duration
|
||||
let sessionDuration = $state('0m');
|
||||
$effect(() => {
|
||||
function update() {
|
||||
|
|
@ -113,21 +231,19 @@
|
|||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// ── Global keyboard shortcuts ──────────────────────────────────
|
||||
$effect(() => {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
paletteOpen = !paletteOpen;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === ',') {
|
||||
e.preventDefault();
|
||||
settingsOpen = !settingsOpen;
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
// Window frame persistence (debounced 500ms)
|
||||
let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function saveWindowFrame() {
|
||||
if (frameSaveTimer) clearTimeout(frameSaveTimer);
|
||||
frameSaveTimer = setTimeout(() => {
|
||||
appRpc.request["window.getFrame"]({}).then((frame) => {
|
||||
appRpc.request["settings.set"]({ key: 'win_x', value: String(frame.x) }).catch(console.error);
|
||||
appRpc.request["settings.set"]({ key: 'win_y', value: String(frame.y) }).catch(console.error);
|
||||
appRpc.request["settings.set"]({ key: 'win_width', value: String(frame.width) }).catch(console.error);
|
||||
appRpc.request["settings.set"]({ key: 'win_height', value: String(frame.height) }).catch(console.error);
|
||||
}).catch(console.error);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// ── Status bar aggregates ──────────────────────────────────────
|
||||
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
|
||||
|
|
@ -135,7 +251,6 @@
|
|||
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
|
||||
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
|
||||
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
|
||||
// Attention queue: projects with high context or stalled
|
||||
let attentionItems = $derived(
|
||||
PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75)
|
||||
);
|
||||
|
|
@ -147,16 +262,54 @@
|
|||
function fmtCost(n: number): string {
|
||||
return `$${n.toFixed(3)}`;
|
||||
}
|
||||
|
||||
// ── Window control helpers ─────────────────────────────────────
|
||||
function windowMinimize() {
|
||||
appRpc.request["window.minimize"]({}).catch(console.error);
|
||||
}
|
||||
|
||||
function windowMaximize() {
|
||||
appRpc.request["window.maximize"]({}).catch(console.error);
|
||||
}
|
||||
|
||||
function windowClose() {
|
||||
appRpc.request["window.close"]({}).catch(console.error);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
||||
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
||||
<ToastContainer />
|
||||
|
||||
<div class="app-shell">
|
||||
<div
|
||||
class="app-shell"
|
||||
role="presentation"
|
||||
onresize={saveWindowFrame}
|
||||
>
|
||||
<!-- Sidebar icon rail -->
|
||||
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
|
||||
<!-- AGOR vertical title -->
|
||||
<div class="agor-title" aria-hidden="true">AGOR</div>
|
||||
|
||||
<!-- Group icons -->
|
||||
<div class="sidebar-groups" role="list" aria-label="Project groups">
|
||||
{#each groups as group, i}
|
||||
<button
|
||||
class="sidebar-icon group-icon"
|
||||
class:active={activeGroupId === group.id}
|
||||
onclick={() => setActiveGroup(group.id)}
|
||||
aria-label="{group.name} (Ctrl+{i + 1})"
|
||||
title="{group.name} (Ctrl+{i + 1})"
|
||||
role="listitem"
|
||||
>
|
||||
<span class="group-emoji" aria-hidden="true">{group.icon}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-spacer"></div>
|
||||
|
||||
<!-- Settings gear -->
|
||||
<button
|
||||
class="sidebar-icon"
|
||||
class:active={settingsOpen}
|
||||
|
|
@ -173,32 +326,121 @@
|
|||
|
||||
<!-- Main workspace -->
|
||||
<main class="workspace">
|
||||
<div class="project-grid">
|
||||
{#each PROJECTS as project (project.id)}
|
||||
<ProjectCard
|
||||
id={project.id}
|
||||
name={project.name}
|
||||
cwd={project.cwd}
|
||||
accent={project.accent}
|
||||
status={project.status}
|
||||
costUsd={project.costUsd}
|
||||
tokens={project.tokens}
|
||||
messages={project.messages}
|
||||
provider={project.provider}
|
||||
profile={project.profile}
|
||||
model={project.model}
|
||||
contextPct={project.contextPct}
|
||||
burnRate={project.burnRate}
|
||||
{blinkVisible}
|
||||
/>
|
||||
<!-- Draggable title bar area with window controls -->
|
||||
<div class="title-bar" aria-label="Window title bar">
|
||||
<div class="title-bar-drag" aria-hidden="true">
|
||||
<span class="title-bar-text">{activeGroup?.name ?? 'Agent Orchestrator'}</span>
|
||||
</div>
|
||||
<div class="window-controls" role="toolbar" aria-label="Window controls">
|
||||
<button
|
||||
class="wc-btn"
|
||||
onclick={windowMinimize}
|
||||
aria-label="Minimize window"
|
||||
title="Minimize"
|
||||
>─</button>
|
||||
<button
|
||||
class="wc-btn"
|
||||
onclick={windowMaximize}
|
||||
aria-label="Maximize window"
|
||||
title="Maximize"
|
||||
>□</button>
|
||||
<button
|
||||
class="wc-btn close-btn"
|
||||
onclick={windowClose}
|
||||
aria-label="Close window"
|
||||
title="Close"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project grid -->
|
||||
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
|
||||
{#each gridRows() as row (row.type === 'standalone' ? row.project.id : `cg-${row.parent.id}`)}
|
||||
{#if row.type === 'standalone'}
|
||||
<div role="listitem">
|
||||
<ProjectCard
|
||||
id={row.project.id}
|
||||
name={row.project.name}
|
||||
cwd={row.project.cwd}
|
||||
accent={row.project.accent}
|
||||
status={row.project.status}
|
||||
costUsd={row.project.costUsd}
|
||||
tokens={row.project.tokens}
|
||||
messages={row.project.messages}
|
||||
provider={row.project.provider}
|
||||
profile={row.project.profile}
|
||||
model={row.project.model}
|
||||
contextPct={row.project.contextPct}
|
||||
burnRate={row.project.burnRate}
|
||||
{blinkVisible}
|
||||
clonesAtMax={cloneCountForProject(row.project.id) >= 3}
|
||||
onClone={handleClone}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Clone group: parent + clones in a flex row spanning full width -->
|
||||
<div class="clone-group-row" role="listitem" aria-label="Project group: {row.parent.name}">
|
||||
<ProjectCard
|
||||
id={row.parent.id}
|
||||
name={row.parent.name}
|
||||
cwd={row.parent.cwd}
|
||||
accent={row.parent.accent}
|
||||
status={row.parent.status}
|
||||
costUsd={row.parent.costUsd}
|
||||
tokens={row.parent.tokens}
|
||||
messages={row.parent.messages}
|
||||
provider={row.parent.provider}
|
||||
profile={row.parent.profile}
|
||||
model={row.parent.model}
|
||||
contextPct={row.parent.contextPct}
|
||||
burnRate={row.parent.burnRate}
|
||||
{blinkVisible}
|
||||
clonesAtMax={row.clones.length >= 3}
|
||||
onClone={handleClone}
|
||||
/>
|
||||
{#each row.clones as clone (clone.id)}
|
||||
<!-- Chain link icon between cards -->
|
||||
<div class="chain-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</div>
|
||||
<ProjectCard
|
||||
id={clone.id}
|
||||
name={clone.name}
|
||||
cwd={clone.cwd}
|
||||
accent={clone.accent ?? row.parent.accent}
|
||||
status={clone.status}
|
||||
costUsd={clone.costUsd}
|
||||
tokens={clone.tokens}
|
||||
messages={clone.messages}
|
||||
provider={clone.provider}
|
||||
profile={clone.profile}
|
||||
model={clone.model}
|
||||
contextPct={clone.contextPct}
|
||||
burnRate={clone.burnRate}
|
||||
{blinkVisible}
|
||||
worktreeBranch={clone.worktreeBranch}
|
||||
cloneOf={clone.cloneOf}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if filteredProjects.length === 0}
|
||||
<div class="empty-group" role="listitem">
|
||||
<span class="empty-group-icon" aria-hidden="true">{activeGroup?.icon ?? '📁'}</span>
|
||||
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
|
||||
<!-- Agent states -->
|
||||
{#if runningCount > 0}
|
||||
<span class="status-segment">
|
||||
<span class="status-dot-sm green" aria-hidden="true"></span>
|
||||
|
|
@ -221,7 +463,6 @@
|
|||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Attention queue -->
|
||||
{#if attentionItems.length > 0}
|
||||
<span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p=>p.name).join(', ')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon">
|
||||
|
|
@ -234,13 +475,17 @@
|
|||
|
||||
<span class="status-bar-spacer"></span>
|
||||
|
||||
<!-- Session duration -->
|
||||
<!-- Group indicator -->
|
||||
<span class="status-segment" title="Active group">
|
||||
<span aria-hidden="true">{activeGroup?.icon}</span>
|
||||
<span class="status-value">{activeGroup?.name}</span>
|
||||
</span>
|
||||
|
||||
<span class="status-segment" title="Session duration">
|
||||
<span>session</span>
|
||||
<span class="status-value">{sessionDuration}</span>
|
||||
</span>
|
||||
|
||||
<!-- Tokens + cost -->
|
||||
<span class="status-segment" title="Total tokens used">
|
||||
<span>tokens</span>
|
||||
<span class="status-value">{fmtTokens(totalTokens)}</span>
|
||||
|
|
@ -250,7 +495,6 @@
|
|||
<span class="status-value">{fmtCost(totalCost)}</span>
|
||||
</span>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<button
|
||||
class="notif-btn"
|
||||
onclick={() => notifCount = 0}
|
||||
|
|
@ -265,7 +509,6 @@
|
|||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Palette hint -->
|
||||
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
|
||||
</footer>
|
||||
|
||||
|
|
@ -280,7 +523,7 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar icon rail */
|
||||
/* ── Sidebar ──────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
|
|
@ -289,8 +532,34 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0 0.5rem;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* Vertical AGOR title */
|
||||
.agor-title {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: var(--ui-font-family);
|
||||
font-weight: 800;
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--ctp-overlay1);
|
||||
padding: 0.625rem 0;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Group icons section */
|
||||
.sidebar-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.sidebar-spacer { flex: 1; }
|
||||
|
|
@ -308,13 +577,31 @@
|
|||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-mauve); }
|
||||
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
.sidebar-icon svg { width: 1rem; height: 1rem; }
|
||||
|
||||
/* Workspace */
|
||||
/* Active group: accent border-left indicator */
|
||||
.group-icon.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -0.375rem;
|
||||
top: 25%;
|
||||
bottom: 25%;
|
||||
width: 2px;
|
||||
background: var(--ctp-mauve);
|
||||
border-radius: 0 1px 1px 0;
|
||||
}
|
||||
|
||||
.group-emoji {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Workspace ────────────────────────────────────────────── */
|
||||
.workspace {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
@ -323,6 +610,67 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Title bar (custom chrome) ────────────────────────────── */
|
||||
.title-bar {
|
||||
height: 2rem;
|
||||
background: var(--ctp-crust);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
/* Make entire title bar draggable; window controls override this */
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.title-bar-drag {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.title-bar-text {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-overlay1);
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
-webkit-app-region: no-drag;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wc-btn {
|
||||
width: 2.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
padding: 0;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
|
||||
.wc-btn:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--ctp-red);
|
||||
color: var(--ctp-base);
|
||||
}
|
||||
|
||||
/* ── Project grid ─────────────────────────────────────────── */
|
||||
.project-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
|
@ -331,9 +679,61 @@
|
|||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--ctp-crust);
|
||||
overflow-y: auto;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.project-grid::-webkit-scrollbar { width: 0.375rem; }
|
||||
.project-grid::-webkit-scrollbar-track { background: transparent; }
|
||||
.project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
|
||||
/* Clone group: spans both grid columns, flex row */
|
||||
.clone-group-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Each ProjectCard inside a clone group gets flex: 1 */
|
||||
.clone-group-row :global(.project-card) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Chain icon between linked cards */
|
||||
.chain-icon {
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.chain-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* Empty group placeholder */
|
||||
.empty-group {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 3rem 0;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.empty-group-icon { font-size: 2rem; }
|
||||
.empty-group-text { font-size: 0.875rem; font-style: italic; }
|
||||
|
||||
/* ── Status bar ───────────────────────────────────────────── */
|
||||
.status-bar {
|
||||
height: var(--status-bar-height);
|
||||
background: var(--ctp-crust);
|
||||
|
|
@ -368,11 +768,9 @@
|
|||
.status-value { color: var(--ctp-text); font-weight: 500; }
|
||||
.status-bar-spacer { flex: 1; }
|
||||
|
||||
/* Attention badge */
|
||||
.attn-badge { color: var(--ctp-yellow); }
|
||||
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
|
||||
|
||||
/* Notification bell */
|
||||
.notif-btn {
|
||||
position: relative;
|
||||
width: 1.5rem;
|
||||
|
|
@ -411,7 +809,6 @@
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Palette shortcut hint */
|
||||
.palette-hint {
|
||||
padding: 0.1rem 0.3rem;
|
||||
background: var(--ctp-surface0);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@
|
|||
contextPct?: number;
|
||||
burnRate?: number;
|
||||
blinkVisible?: boolean;
|
||||
/** Worktree branch name — set when this is a clone card. */
|
||||
worktreeBranch?: string;
|
||||
/** ID of parent project — set when this is a clone card. */
|
||||
cloneOf?: string;
|
||||
/** Max clones reached for this project. */
|
||||
clonesAtMax?: boolean;
|
||||
/** Callback when user requests cloning. */
|
||||
onClone?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -46,8 +54,34 @@
|
|||
contextPct = 0,
|
||||
burnRate = 0,
|
||||
blinkVisible = true,
|
||||
worktreeBranch,
|
||||
cloneOf,
|
||||
clonesAtMax = false,
|
||||
onClone,
|
||||
}: Props = $props();
|
||||
|
||||
// ── Clone dialog state ──────────────────────────────────────────
|
||||
let showCloneDialog = $state(false);
|
||||
let cloneBranchName = $state('');
|
||||
let cloneError = $state('');
|
||||
|
||||
const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
|
||||
|
||||
function openCloneDialog() {
|
||||
cloneBranchName = '';
|
||||
cloneError = '';
|
||||
showCloneDialog = true;
|
||||
}
|
||||
|
||||
function submitClone() {
|
||||
if (!BRANCH_RE.test(cloneBranchName)) {
|
||||
cloneError = 'Use only letters, numbers, /, _, -, .';
|
||||
return;
|
||||
}
|
||||
onClone?.(id);
|
||||
showCloneDialog = false;
|
||||
}
|
||||
|
||||
let activeTab = $state<ProjectTab>('model');
|
||||
// svelte-ignore state_referenced_locally
|
||||
const seedMessages = initialMessages.slice();
|
||||
|
|
@ -77,8 +111,9 @@
|
|||
|
||||
<article
|
||||
class="project-card"
|
||||
class:is-clone={!!cloneOf}
|
||||
style="--accent: {accent}"
|
||||
aria-label="Project: {name}"
|
||||
aria-label="Project: {name}{cloneOf ? ' (worktree clone)' : ''}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="project-header">
|
||||
|
|
@ -94,6 +129,12 @@
|
|||
<span class="project-name" title={name}>{name}</span>
|
||||
<span class="project-cwd" title={cwd}>{cwd}</span>
|
||||
|
||||
{#if worktreeBranch}
|
||||
<span class="wt-badge" title="Worktree branch: {worktreeBranch}">
|
||||
WT · {worktreeBranch}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="provider-badge" title="Provider: {provider}">{provider}</span>
|
||||
|
||||
{#if profile}
|
||||
|
|
@ -112,8 +153,56 @@
|
|||
{#if burnRate > 0}
|
||||
<span class="burn-badge" title="Burn rate">${burnRate.toFixed(2)}/hr</span>
|
||||
{/if}
|
||||
|
||||
<!-- Clone button (only on non-clone cards) -->
|
||||
{#if !cloneOf && onClone}
|
||||
<button
|
||||
class="clone-btn"
|
||||
onclick={openCloneDialog}
|
||||
disabled={clonesAtMax}
|
||||
title={clonesAtMax ? 'Maximum 3 clones reached' : 'Clone into git worktree'}
|
||||
aria-label="Clone project into worktree"
|
||||
>
|
||||
<!-- Fork / branch SVG icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="6" y1="3" x2="6" y2="15"/>
|
||||
<circle cx="18" cy="6" r="3"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<path d="M18 9a9 9 0 0 1-9 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Clone dialog (inline, shown above tab bar) -->
|
||||
{#if showCloneDialog}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="clone-dialog"
|
||||
role="dialog"
|
||||
aria-label="Create worktree clone"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') showCloneDialog = false; }}
|
||||
>
|
||||
<span class="clone-dialog-label">Branch name</span>
|
||||
<input
|
||||
class="clone-dialog-input"
|
||||
type="text"
|
||||
placeholder="feature/my-branch"
|
||||
bind:value={cloneBranchName}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') submitClone(); }}
|
||||
autofocus
|
||||
aria-label="New branch name for worktree"
|
||||
/>
|
||||
{#if cloneError}
|
||||
<span class="clone-dialog-error">{cloneError}</span>
|
||||
{/if}
|
||||
<div class="clone-dialog-actions">
|
||||
<button class="clone-dialog-cancel" onclick={() => showCloneDialog = false}>Cancel</button>
|
||||
<button class="clone-dialog-submit" onclick={submitClone}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Project tab bar -->
|
||||
<div class="tab-bar" role="tablist" aria-label="{name} tabs">
|
||||
{#each ALL_TABS as tab}
|
||||
|
|
@ -276,6 +365,15 @@
|
|||
border-radius: 0.5rem 0 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Worktree clone: accent top border instead of left stripe */
|
||||
.project-card.is-clone {
|
||||
border-top: 2px solid var(--accent, var(--ctp-mauve));
|
||||
}
|
||||
|
||||
.project-card.is-clone::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.project-header {
|
||||
height: 2.5rem;
|
||||
|
|
@ -367,6 +465,131 @@
|
|||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
/* Worktree badge */
|
||||
.wt-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.1rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 20%, transparent);
|
||||
color: var(--accent, var(--ctp-mauve));
|
||||
border: 1px solid color-mix(in srgb, var(--accent, var(--ctp-mauve)) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Clone button */
|
||||
.clone-btn {
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay1);
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.clone-btn:hover:not(:disabled) {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.clone-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.clone-btn svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
/* Inline clone dialog */
|
||||
.clone-dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clone-dialog-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clone-dialog-input {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
height: 1.625rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.375rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clone-dialog-input:focus { border-color: var(--ctp-mauve); }
|
||||
|
||||
.clone-dialog-error {
|
||||
width: 100%;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.clone-dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clone-dialog-cancel,
|
||||
.clone-dialog-submit {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--ui-font-family);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.clone-dialog-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.clone-dialog-cancel:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.clone-dialog-submit {
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
|
||||
border: 1px solid var(--ctp-mauve);
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.clone-dialog-submit:hover {
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.tab-bar {
|
||||
height: 2rem;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import OrchestrationSettings from './settings/OrchestrationSettings.svelte';
|
||||
import AdvancedSettings from './settings/AdvancedSettings.svelte';
|
||||
import MarketplaceTab from './settings/MarketplaceTab.svelte';
|
||||
import KeyboardSettings from './settings/KeyboardSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -14,7 +15,7 @@
|
|||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced' | 'marketplace';
|
||||
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced' | 'marketplace' | 'keyboard';
|
||||
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
{ id: 'security', label: 'Security', icon: '🔒' },
|
||||
{ id: 'projects', label: 'Projects', icon: '📁' },
|
||||
{ id: 'orchestration', label: 'Orchestration', icon: '⚙' },
|
||||
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
|
||||
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
|
||||
];
|
||||
|
|
@ -94,6 +96,8 @@
|
|||
<OrchestrationSettings />
|
||||
{:else if activeCategory === 'advanced'}
|
||||
<AdvancedSettings />
|
||||
{:else if activeCategory === 'keyboard'}
|
||||
<KeyboardSettings />
|
||||
{:else if activeCategory === 'marketplace'}
|
||||
<MarketplaceTab />
|
||||
{/if}
|
||||
|
|
|
|||
168
ui-electrobun/src/mainview/keybinding-store.svelte.ts
Normal file
168
ui-electrobun/src/mainview/keybinding-store.svelte.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Svelte 5 rune-based keybinding store.
|
||||
* Manages global keyboard shortcuts with user-customizable chords.
|
||||
* Persists overrides via settings RPC (only non-default bindings saved).
|
||||
*
|
||||
* Usage:
|
||||
* import { keybindingStore } from './keybinding-store.svelte.ts';
|
||||
* await keybindingStore.init(rpc);
|
||||
*
|
||||
* // Register a command handler
|
||||
* keybindingStore.on('palette', () => paletteOpen = true);
|
||||
*
|
||||
* // Install the global keydown listener (call once)
|
||||
* keybindingStore.installListener();
|
||||
*/
|
||||
|
||||
// ── Minimal RPC interface ────────────────────────────────────────────────────
|
||||
|
||||
interface SettingsRpc {
|
||||
request: {
|
||||
"keybindings.getAll"(p: Record<string, never>): Promise<{ keybindings: Record<string, string> }>;
|
||||
"keybindings.set"(p: { id: string; chord: string }): Promise<{ ok: boolean }>;
|
||||
"keybindings.reset"(p: { id: string }): Promise<{ ok: boolean }>;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Keybinding {
|
||||
id: string;
|
||||
label: string;
|
||||
category: "Global" | "Navigation" | "Terminal" | "Settings";
|
||||
chord: string;
|
||||
defaultChord: string;
|
||||
}
|
||||
|
||||
// ── Default bindings ─────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULTS: Keybinding[] = [
|
||||
{ id: "palette", label: "Command Palette", category: "Global", chord: "Ctrl+K", defaultChord: "Ctrl+K" },
|
||||
{ id: "settings", label: "Open Settings", category: "Global", chord: "Ctrl+,", defaultChord: "Ctrl+," },
|
||||
{ id: "group1", label: "Switch to Group 1", category: "Navigation", chord: "Ctrl+1", defaultChord: "Ctrl+1" },
|
||||
{ id: "group2", label: "Switch to Group 2", category: "Navigation", chord: "Ctrl+2", defaultChord: "Ctrl+2" },
|
||||
{ id: "group3", label: "Switch to Group 3", category: "Navigation", chord: "Ctrl+3", defaultChord: "Ctrl+3" },
|
||||
{ id: "group4", label: "Switch to Group 4", category: "Navigation", chord: "Ctrl+4", defaultChord: "Ctrl+4" },
|
||||
{ id: "newTerminal", label: "New Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+T", defaultChord: "Ctrl+Shift+T" },
|
||||
{ id: "closeTab", label: "Close Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+W", defaultChord: "Ctrl+Shift+W" },
|
||||
{ id: "nextTab", label: "Next Terminal Tab", category: "Terminal", chord: "Ctrl+]", defaultChord: "Ctrl+]" },
|
||||
{ id: "prevTab", label: "Previous Terminal Tab", category: "Terminal", chord: "Ctrl+[", defaultChord: "Ctrl+[" },
|
||||
{ id: "search", label: "Global Search", category: "Global", chord: "Ctrl+Shift+F", defaultChord: "Ctrl+Shift+F" },
|
||||
{ id: "notifications",label: "Notification Center", category: "Global", chord: "Ctrl+Shift+N", defaultChord: "Ctrl+Shift+N" },
|
||||
{ id: "minimize", label: "Minimize Window", category: "Global", chord: "Ctrl+M", defaultChord: "Ctrl+M" },
|
||||
{ id: "toggleFiles", label: "Toggle Files Tab", category: "Navigation", chord: "Ctrl+Shift+E", defaultChord: "Ctrl+Shift+E" },
|
||||
{ id: "toggleMemory", label: "Toggle Memory Tab", category: "Navigation", chord: "Ctrl+Shift+M", defaultChord: "Ctrl+Shift+M" },
|
||||
{ id: "reload", label: "Reload App", category: "Settings", chord: "Ctrl+R", defaultChord: "Ctrl+R" },
|
||||
];
|
||||
|
||||
// ── Chord serialisation helpers ───────────────────────────────────────────────
|
||||
|
||||
export function chordFromEvent(e: KeyboardEvent): string {
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
if (e.altKey) parts.push("Alt");
|
||||
const key = e.key === " " ? "Space" : e.key;
|
||||
// Exclude pure modifier keys
|
||||
if (!["Control", "Shift", "Alt", "Meta"].includes(key)) {
|
||||
parts.push(key.length === 1 ? key.toUpperCase() : key);
|
||||
}
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
function matchesChord(e: KeyboardEvent, chord: string): boolean {
|
||||
return chordFromEvent(e) === chord;
|
||||
}
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function createKeybindingStore() {
|
||||
let bindings = $state<Keybinding[]>(DEFAULTS.map((b) => ({ ...b })));
|
||||
let rpc: SettingsRpc | null = null;
|
||||
const handlers = new Map<string, () => void>();
|
||||
let listenerInstalled = false;
|
||||
|
||||
/** Load persisted overrides and merge with defaults. */
|
||||
async function init(rpcInstance: SettingsRpc): Promise<void> {
|
||||
rpc = rpcInstance;
|
||||
try {
|
||||
const { keybindings: overrides } = await rpc.request["keybindings.getAll"]({});
|
||||
bindings = DEFAULTS.map((b) => ({
|
||||
...b,
|
||||
chord: overrides[b.id] ?? b.defaultChord,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("[keybinding-store] Failed to load keybindings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Set a custom chord for a binding id. Persists to SQLite. */
|
||||
function setChord(id: string, chord: string): void {
|
||||
bindings = bindings.map((b) => b.id === id ? { ...b, chord } : b);
|
||||
rpc?.request["keybindings.set"]({ id, chord }).catch(console.error);
|
||||
}
|
||||
|
||||
/** Reset a binding to its default chord. Removes SQLite override. */
|
||||
function resetChord(id: string): void {
|
||||
const def = DEFAULTS.find((b) => b.id === id);
|
||||
if (!def) return;
|
||||
bindings = bindings.map((b) => b.id === id ? { ...b, chord: def.defaultChord } : b);
|
||||
rpc?.request["keybindings.reset"]({ id }).catch(console.error);
|
||||
}
|
||||
|
||||
/** Reset all bindings to defaults. */
|
||||
function resetAll(): void {
|
||||
for (const b of bindings) {
|
||||
if (b.chord !== b.defaultChord) resetChord(b.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a handler for a binding id. */
|
||||
function on(id: string, handler: () => void): void {
|
||||
handlers.set(id, handler);
|
||||
}
|
||||
|
||||
/** Install a global capture-phase keydown listener. Idempotent. */
|
||||
function installListener(): () => void {
|
||||
if (listenerInstalled) return () => {};
|
||||
listenerInstalled = true;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
// Never intercept keys when focus is inside a terminal canvas
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest(".terminal-container, .xterm")) return;
|
||||
|
||||
const chord = chordFromEvent(e);
|
||||
if (!chord) return;
|
||||
|
||||
for (const b of bindings) {
|
||||
if (b.chord === chord) {
|
||||
const handler = handlers.get(b.id);
|
||||
if (handler) {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeydown, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeydown, { capture: true });
|
||||
listenerInstalled = false;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get bindings() { return bindings; },
|
||||
init,
|
||||
setChord,
|
||||
resetChord,
|
||||
resetAll,
|
||||
on,
|
||||
installListener,
|
||||
};
|
||||
}
|
||||
|
||||
export const keybindingStore = createKeybindingStore();
|
||||
315
ui-electrobun/src/mainview/settings/KeyboardSettings.svelte
Normal file
315
ui-electrobun/src/mainview/settings/KeyboardSettings.svelte
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<script lang="ts">
|
||||
import { keybindingStore, chordFromEvent, type Keybinding } from '../keybinding-store.svelte.ts';
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────
|
||||
let searchQuery = $state('');
|
||||
let capturingId = $state<string | null>(null);
|
||||
let conflictWarning = $state<string | null>(null);
|
||||
|
||||
// ── Derived filtered list ──────────────────────────────────────
|
||||
let filtered = $derived(
|
||||
searchQuery.trim()
|
||||
? keybindingStore.bindings.filter(
|
||||
(b) =>
|
||||
b.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
b.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
b.chord.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: keybindingStore.bindings
|
||||
);
|
||||
|
||||
// ── Category groups ────────────────────────────────────────────
|
||||
let grouped = $derived(
|
||||
filtered.reduce<Record<string, Keybinding[]>>((acc, b) => {
|
||||
(acc[b.category] ??= []).push(b);
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const CATEGORY_ORDER = ['Global', 'Navigation', 'Terminal', 'Settings'];
|
||||
|
||||
// ── Capture mode ──────────────────────────────────────────────
|
||||
function startCapture(id: string) {
|
||||
capturingId = id;
|
||||
conflictWarning = null;
|
||||
}
|
||||
|
||||
function handleCaptureKeydown(e: KeyboardEvent, id: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const chord = chordFromEvent(e);
|
||||
if (!chord || chord === 'Escape') {
|
||||
capturingId = null;
|
||||
conflictWarning = null;
|
||||
return;
|
||||
}
|
||||
// Skip bare modifier key presses (no actual key yet)
|
||||
if (!chord.match(/[A-Z0-9,.\[\]\\/'`\-=; ]|F\d+|Enter|Tab|Space|Backspace|Delete|Arrow/)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Conflict check
|
||||
const conflict = keybindingStore.bindings.find(
|
||||
(b) => b.id !== id && b.chord === chord
|
||||
);
|
||||
|
||||
if (conflict) {
|
||||
conflictWarning = `Conflicts with "${conflict.label}"`;
|
||||
} else {
|
||||
conflictWarning = null;
|
||||
}
|
||||
|
||||
keybindingStore.setChord(id, chord);
|
||||
capturingId = null;
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
keybindingStore.resetAll();
|
||||
conflictWarning = null;
|
||||
}
|
||||
|
||||
function isModified(b: Keybinding): boolean {
|
||||
return b.chord !== b.defaultChord;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kb-settings">
|
||||
<!-- Toolbar -->
|
||||
<div class="kb-toolbar">
|
||||
<input
|
||||
class="kb-search"
|
||||
type="search"
|
||||
placeholder="Search shortcuts…"
|
||||
bind:value={searchQuery}
|
||||
aria-label="Search keyboard shortcuts"
|
||||
/>
|
||||
<button class="kb-reset-all" onclick={resetAll} title="Reset all shortcuts to defaults">
|
||||
Reset All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if conflictWarning}
|
||||
<div class="kb-conflict-banner" role="alert">
|
||||
Warning: {conflictWarning}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Table by category -->
|
||||
{#each CATEGORY_ORDER as category}
|
||||
{#if grouped[category]?.length}
|
||||
<div class="kb-category">
|
||||
<div class="kb-category-header">{category}</div>
|
||||
<div class="kb-table">
|
||||
{#each grouped[category] as binding (binding.id)}
|
||||
<div class="kb-row" class:modified={isModified(binding)}>
|
||||
<span class="kb-label">{binding.label}</span>
|
||||
|
||||
<!-- Chord cell: click to capture -->
|
||||
{#if capturingId === binding.id}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="kb-chord capturing"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Press new key combination for {binding.label}"
|
||||
autofocus
|
||||
onkeydown={(e) => handleCaptureKeydown(e, binding.id)}
|
||||
onblur={() => capturingId = null}
|
||||
>
|
||||
Press keys…
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="kb-chord"
|
||||
onclick={() => startCapture(binding.id)}
|
||||
title="Click to rebind"
|
||||
aria-label="Current shortcut for {binding.label}: {binding.chord}. Click to change."
|
||||
>
|
||||
{binding.chord}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Reset button: only shown when modified -->
|
||||
{#if isModified(binding)}
|
||||
<button
|
||||
class="kb-reset"
|
||||
onclick={() => keybindingStore.resetChord(binding.id)}
|
||||
title="Reset to {binding.defaultChord}"
|
||||
aria-label="Reset {binding.label} to default"
|
||||
>
|
||||
↩ {binding.defaultChord}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="kb-reset-placeholder"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="kb-empty">No shortcuts match "{searchQuery}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kb-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kb-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kb-search {
|
||||
flex: 1;
|
||||
height: 1.75rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.8125rem;
|
||||
padding: 0 0.5rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kb-search:focus { border-color: var(--ctp-mauve); }
|
||||
|
||||
.kb-reset-all {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.kb-reset-all:hover {
|
||||
border-color: var(--ctp-red);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.kb-conflict-banner {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
|
||||
border: 1px solid var(--ctp-yellow);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-yellow);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.kb-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.kb-category-header {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ctp-overlay0);
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.kb-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.kb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.kb-row:hover { background: var(--ctp-surface0); }
|
||||
.kb-row.modified { background: color-mix(in srgb, var(--ctp-mauve) 6%, transparent); }
|
||||
|
||||
.kb-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kb-chord {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kb-chord:hover:not(.capturing) {
|
||||
border-color: var(--ctp-mauve);
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.kb-chord.capturing {
|
||||
border-color: var(--ctp-mauve);
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 15%, var(--ctp-surface0));
|
||||
color: var(--ctp-mauve);
|
||||
animation: pulse-capture 0.8s ease-in-out infinite;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-capture {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
.kb-reset {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.kb-reset:hover {
|
||||
border-color: var(--ctp-surface1);
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.kb-reset-placeholder {
|
||||
width: 5rem; /* Reserve space so layout stays stable */
|
||||
}
|
||||
|
||||
.kb-empty {
|
||||
text-align: center;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue