diff --git a/src/App.svelte b/src/App.svelte index ddedf35..dcc50a6 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -21,6 +21,7 @@ import { toastStore } from './lib/ui'; import { fadeQuick, overlayFade, overlayFlyUp } from './lib/transitions'; import DetailPanel from './lib/components/DetailPanel.svelte'; + import CommandPalette from './lib/components/CommandPalette.svelte'; import { isWebMode } from './lib/ts/transport'; // App state @@ -45,6 +46,7 @@ // Overlay state (views stay mounted underneath) let showEditor = $state(false); let showSettings = $state(false); + let showPalette = $state(false); let detailId = $state(null); let editorMemory = $state(null); let searchQuery = $state(null); @@ -147,6 +149,11 @@ } function handleKeydown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + showPalette = !showPalette; + return; + } if (e.ctrlKey && e.key === 'b') { e.preventDefault(); toggleSidebar(); @@ -287,6 +294,17 @@ {/if} +{#if showPalette} + (showSettings = true)} + onclose={() => (showPalette = false)} + /> +{/if} + {#if showEditor}
+ import { onMount, tick } from 'svelte'; + import { utekeServer } from '../ts/ipc'; + import type { UnifiedSearchResult, View } from '../ts/types'; + import { fadeQuick, overlayFade } from '../transitions'; + + interface PaletteItem { + key: string; + icon: string; + label: string; + hint: string; + kind: 'view' | 'action' | 'result'; + run: () => void; + } + + let { + onnavigate, + onnewmemory, + onopenmemory, + onopendocument, + onopensettings, + onclose, + }: { + onnavigate: (v: View) => void; + onnewmemory: () => void; + onopenmemory: (id: string) => void; + onopendocument: (slug: string) => void; + onopensettings: () => void; + onclose: () => void; + } = $props(); + + let query = $state(''); + let activeIndex = $state(0); + let inputEl = $state(null); + let listEl = $state(null); + let results = $state([]); + let searching = $state(false); + let searchSeq = 0; + + // Debounced unified search (memories + documents) once the query is long enough. + let debounce: ReturnType | null = null; + $effect(() => { + const q = query.trim(); + if (debounce) clearTimeout(debounce); + if (q.length < 2) { + results = []; + searching = false; + return; + } + searching = true; + const seq = ++searchSeq; + debounce = setTimeout(async () => { + try { + const hits = await utekeServer.recallUnified(q, { limit: 8 }); + if (seq === searchSeq) results = hits ?? []; + } catch { + if (seq === searchSeq) results = []; + } finally { + if (seq === searchSeq) searching = false; + } + }, 180); + }); + + // ── Static entries: views + actions ─────────────────────────────── + const viewItems: PaletteItem[] = [ + { key: 'v-dash', icon: '⌂', label: 'Go to Home', hint: 'Dashboard', kind: 'view', run: () => onnavigate('dashboard') }, + { key: 'v-mem', icon: '◉', label: 'Go to Memories', hint: 'List & hub', kind: 'view', run: () => onnavigate('memories') }, + { key: 'v-doc', icon: '▤', label: 'Go to Documents', hint: 'Docs', kind: 'view', run: () => onnavigate('documents') }, + { key: 'v-ns', icon: '◇', label: 'Go to Namespaces', hint: 'Workspaces', kind: 'view', run: () => onnavigate('namespaces') }, + { key: 'v-rooms', icon: '▣', label: 'Go to Rooms', hint: 'Agent rooms', kind: 'view', run: () => onnavigate('rooms') }, + { key: 'v-graph', icon: '⁂', label: 'Go to Graph', hint: 'Knowledge graph', kind: 'view', run: () => onnavigate('graph') }, + { key: 'v-life', icon: '♻', label: 'Go to Lifecycle', hint: 'Maintenance', kind: 'view', run: () => onnavigate('lifecycle') }, + { key: 'v-tools', icon: '⚒', label: 'Go to Tools', hint: 'Utilities', kind: 'view', run: () => onnavigate('tools') }, + ]; + + const actionItems: PaletteItem[] = [ + { key: 'a-new', icon: '+', label: 'New memory', hint: 'Ctrl+N', kind: 'action', run: () => onnewmemory() }, + { key: 'a-set', icon: '⚙', label: 'Open Settings', hint: 'Preferences', kind: 'action', run: () => onopensettings() }, + ]; + + type Row = + | { type: 'header'; label: string } + | { type: 'item'; item: PaletteItem }; + + const staticItems = $derived( + [...viewItems, ...actionItems].filter((i) => + query.trim().length < 2 || i.label.toLowerCase().includes(query.trim().toLowerCase()), + ), + ); + + const rows = $derived.by(() => { + const out: Row[] = []; + const q = query.trim().toLowerCase(); + const views = staticItems.filter((i) => i.kind === 'view'); + const actions = staticItems.filter((i) => i.kind === 'action'); + if (views.length) { + out.push({ type: 'header', label: 'Views' }); + for (const item of views) out.push({ type: 'item', item }); + } + if (results.length) { + out.push({ type: 'header', label: 'Search results' }); + for (const r of results) { + out.push({ + type: 'item', + item: { + key: r.result_type === 'document' ? `d-${r.doc_slug}` : `m-${r.memory_id}`, + icon: r.result_type === 'document' ? '▤' : '◉', + label: r.result_type === 'document' ? (r.doc_title ?? r.doc_slug ?? 'document') : (r.content.slice(0, 70)), + hint: r.result_type === 'document' ? 'Document' : 'Memory', + kind: 'result', + run: () => { + if (r.result_type === 'document' && r.doc_slug) onopendocument(r.doc_slug); + else if (r.memory_id) onopenmemory(r.memory_id); + }, + }, + }); + } + } + if (actions.length) { + out.push({ type: 'header', label: 'Actions' }); + for (const item of actions) out.push({ type: 'item', item }); + } + return out; + }); + + const selectable = $derived(rows.filter((r): r is Extract => r.type === 'item').map((r) => r.item)); + + // Reset highlight when the list changes. + $effect(() => { + rows; + activeIndex = Math.min(activeIndex, Math.max(0, selectable.length - 1)); + }); + + function runItem(item: PaletteItem) { + onclose(); + item.run(); + } + + function move(delta: number) { + const n = selectable.length; + if (!n) return; + activeIndex = (activeIndex + delta + n) % n; + tick().then(() => { + listEl?.querySelector('[data-active="1"]')?.scrollIntoView({ block: 'nearest' }); + }); + } + + function onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + onclose(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + move(1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + move(-1); + } else if (e.key === 'Enter') { + e.preventDefault(); + const item = selectable[activeIndex]; + if (item) runItem(item); + } + } + + onMount(() => { + inputEl?.focus(); + }); + + + + +