feat: add keyboard-first UX and rewrite CommandPalette

Alt+1-5 project jump, Ctrl+H/L vi-nav, Ctrl+Shift+1-9 tab switch,
Ctrl+J terminal toggle, Ctrl+Shift+K focus agent. isEditing() guard.
CommandPalette: 18+ commands, 6 categories, fuzzy filter, arrow nav.
This commit is contained in:
Hibryda 2026-03-12 04:57:29 +01:00 committed by DexterFromLab
parent d31a2c3ed7
commit a9b7ed0dda
2 changed files with 525 additions and 60 deletions

View file

@ -11,7 +11,11 @@
import { OLLAMA_PROVIDER } from './lib/providers/ollama';
import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
import { MemoraAdapter } from './lib/adapters/memora-bridge';
import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte';
import {
loadWorkspace, getActiveTab, setActiveTab, setActiveProject,
getEnabledProjects, getAllWorkItems, getActiveProjectId,
triggerFocusFlash, emitProjectTabSwitch, emitTerminalToggle,
} from './lib/stores/workspace.svelte';
import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte';
import { invoke } from '@tauri-apps/api/core';
@ -22,6 +26,7 @@
import SettingsTab from './lib/components/Workspace/SettingsTab.svelte';
import CommsTab from './lib/components/Workspace/CommsTab.svelte';
import CommandPalette from './lib/components/Workspace/CommandPalette.svelte';
import SearchOverlay from './lib/components/Workspace/SearchOverlay.svelte';
// Shared
import StatusBar from './lib/components/StatusBar/StatusBar.svelte';
@ -35,6 +40,7 @@
let detachedConfig = getDetachedConfig();
let paletteOpen = $state(false);
let searchOpen = $state(false);
let drawerOpen = $state(false);
let loaded = $state(false);
@ -93,25 +99,104 @@
loadWorkspace().then(() => { loaded = true; });
}
/** Check if event target is an editable element (input, textarea, contenteditable) */
function isEditing(e: KeyboardEvent): boolean {
const t = e.target as HTMLElement;
if (!t) return false;
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
if (t.isContentEditable) return true;
// xterm.js canvases and textareas should be considered editing
if (t.closest('.xterm')) return true;
return false;
}
function handleKeydown(e: KeyboardEvent) {
// Ctrl+K — command palette
// Ctrl+K — command palette (always active)
if (e.ctrlKey && !e.shiftKey && e.key === 'k') {
e.preventDefault();
paletteOpen = !paletteOpen;
return;
}
// Ctrl+1..5 — focus project by index
if (e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '5') {
// Ctrl+Shift+F — global search overlay
if (e.ctrlKey && e.shiftKey && (e.key === 'F' || e.key === 'f')) {
e.preventDefault();
const projects = getEnabledProjects();
searchOpen = !searchOpen;
return;
}
// Alt+1..5 — quick-jump to project by index
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '5') {
e.preventDefault();
const projects = getAllWorkItems();
const idx = parseInt(e.key) - 1;
if (idx < projects.length) {
setActiveProject(projects[idx].id);
triggerFocusFlash(projects[idx].id);
}
return;
}
// Ctrl+Shift+1..9 — switch tab within focused project
if (e.ctrlKey && e.shiftKey && e.key >= '1' && e.key <= '9') {
// Allow Ctrl+Shift+K to pass through to its own handler
if (e.key === 'K') return;
e.preventDefault();
const projectId = getActiveProjectId();
if (projectId) {
const tabIdx = parseInt(e.key);
emitProjectTabSwitch(projectId, tabIdx);
}
return;
}
// Ctrl+Shift+K — focus agent pane (switch to Model tab)
if (e.ctrlKey && e.shiftKey && (e.key === 'K' || e.key === 'k')) {
e.preventDefault();
const projectId = getActiveProjectId();
if (projectId) {
emitProjectTabSwitch(projectId, 1); // Model tab
}
return;
}
// Vi-style navigation (skip when editing text)
if (e.ctrlKey && !e.shiftKey && !e.altKey && !isEditing(e)) {
const projects = getAllWorkItems();
const currentId = getActiveProjectId();
const currentIdx = projects.findIndex(p => p.id === currentId);
// Ctrl+H — focus previous project (left)
if (e.key === 'h') {
e.preventDefault();
if (currentIdx > 0) {
setActiveProject(projects[currentIdx - 1].id);
triggerFocusFlash(projects[currentIdx - 1].id);
}
return;
}
// Ctrl+L — focus next project (right)
if (e.key === 'l') {
e.preventDefault();
if (currentIdx >= 0 && currentIdx < projects.length - 1) {
setActiveProject(projects[currentIdx + 1].id);
triggerFocusFlash(projects[currentIdx + 1].id);
}
return;
}
// Ctrl+J — toggle terminal section in focused project
if (e.key === 'j') {
e.preventDefault();
if (currentId) {
emitTerminalToggle(currentId);
}
return;
}
}
// Ctrl+, — toggle settings panel
if (e.ctrlKey && e.key === ',') {
e.preventDefault();
@ -212,6 +297,7 @@
</div>
<CommandPalette open={paletteOpen} onclose={() => paletteOpen = false} />
<SearchOverlay open={searchOpen} onclose={() => searchOpen = false} />
{:else}
<div class="loading">Loading workspace...</div>
{/if}

View file

@ -1,5 +1,18 @@
<script lang="ts">
import { getAllGroups, switchGroup, getActiveGroupId } from '../../stores/workspace.svelte';
import {
getAllGroups,
switchGroup,
getActiveGroupId,
getAllWorkItems,
getActiveProjectId,
setActiveProject,
setActiveTab,
triggerFocusFlash,
emitProjectTabSwitch,
emitTerminalToggle,
addTerminalTab,
} from '../../stores/workspace.svelte';
import { getPluginCommands } from '../../stores/plugins.svelte';
interface Props {
open: boolean;
@ -10,32 +23,259 @@
let query = $state('');
let inputEl: HTMLInputElement | undefined = $state();
let selectedIndex = $state(0);
let showShortcuts = $state(false);
let groups = $derived(getAllGroups());
let filtered = $derived(
groups.filter(g =>
g.name.toLowerCase().includes(query.toLowerCase()),
),
);
let activeGroupId = $derived(getActiveGroupId());
// --- Command definitions ---
interface Command {
id: string;
label: string;
category: string;
shortcut?: string;
action: () => void;
}
let commands = $derived.by((): Command[] => {
const cmds: Command[] = [];
const groups = getAllGroups();
const projects = getAllWorkItems();
const activeGroupId = getActiveGroupId();
const activeProjectId = getActiveProjectId();
// Project focus commands
projects.forEach((p, i) => {
if (i < 5) {
cmds.push({
id: `focus-project-${i + 1}`,
label: `Focus Project ${i + 1}: ${p.name}`,
category: 'Navigation',
shortcut: `Alt+${i + 1}`,
action: () => {
setActiveProject(p.id);
triggerFocusFlash(p.id);
},
});
}
});
// Tab switching commands (for active project)
const tabNames: [string, number][] = [
['Model', 1], ['Docs', 2], ['Context', 3], ['Files', 4],
['SSH', 5], ['Memory', 6], ['Metrics', 7],
];
for (const [name, idx] of tabNames) {
cmds.push({
id: `tab-${name.toLowerCase()}`,
label: `Switch to ${name} Tab`,
category: 'Tabs',
shortcut: `Ctrl+Shift+${idx}`,
action: () => {
if (activeProjectId) {
emitProjectTabSwitch(activeProjectId, idx);
}
},
});
}
// Terminal toggle
cmds.push({
id: 'toggle-terminal',
label: 'Toggle Terminal Section',
category: 'Tabs',
shortcut: 'Ctrl+J',
action: () => {
if (activeProjectId) {
emitTerminalToggle(activeProjectId);
}
},
});
// New terminal tab
cmds.push({
id: 'new-terminal',
label: 'New Terminal Tab',
category: 'Terminal',
action: () => {
if (activeProjectId) {
addTerminalTab(activeProjectId, {
id: crypto.randomUUID(),
title: 'Terminal',
type: 'shell',
});
emitTerminalToggle(activeProjectId); // ensure terminal section is open
}
},
});
// Agent session commands
cmds.push({
id: 'focus-agent',
label: 'Focus Agent Pane',
category: 'Agent',
shortcut: 'Ctrl+Shift+K',
action: () => {
if (activeProjectId) {
emitProjectTabSwitch(activeProjectId, 1); // Model tab
}
},
});
// Group switching commands
for (const group of groups) {
cmds.push({
id: `group-${group.id}`,
label: `Switch Group: ${group.name}`,
category: 'Groups',
shortcut: group.id === activeGroupId ? '(active)' : undefined,
action: () => switchGroup(group.id),
});
}
// Settings toggle
cmds.push({
id: 'toggle-settings',
label: 'Toggle Settings',
category: 'UI',
shortcut: 'Ctrl+,',
action: () => {
setActiveTab('settings');
// Toggle is handled by App.svelte
},
});
// Vi navigation
cmds.push({
id: 'nav-prev-project',
label: 'Focus Previous Project',
category: 'Navigation',
shortcut: 'Ctrl+H',
action: () => {
const idx = projects.findIndex(p => p.id === activeProjectId);
if (idx > 0) {
setActiveProject(projects[idx - 1].id);
triggerFocusFlash(projects[idx - 1].id);
}
},
});
cmds.push({
id: 'nav-next-project',
label: 'Focus Next Project',
category: 'Navigation',
shortcut: 'Ctrl+L',
action: () => {
const idx = projects.findIndex(p => p.id === activeProjectId);
if (idx >= 0 && idx < projects.length - 1) {
setActiveProject(projects[idx + 1].id);
triggerFocusFlash(projects[idx + 1].id);
}
},
});
// Keyboard shortcuts help
cmds.push({
id: 'shortcuts-help',
label: 'Keyboard Shortcuts',
category: 'Help',
shortcut: '?',
action: () => { showShortcuts = true; },
});
// Plugin-registered commands
for (const pc of getPluginCommands()) {
cmds.push({
id: `plugin-${pc.pluginId}-${pc.label.toLowerCase().replace(/\s+/g, '-')}`,
label: pc.label,
category: 'Plugins',
action: pc.callback,
});
}
return cmds;
});
let filtered = $derived.by((): Command[] => {
if (!query.trim()) return commands;
const q = query.toLowerCase();
return commands.filter(c =>
c.label.toLowerCase().includes(q) ||
c.category.toLowerCase().includes(q)
);
});
// Grouped for display
let grouped = $derived.by((): [string, Command[]][] => {
const map = new Map<string, Command[]>();
for (const cmd of filtered) {
const list = map.get(cmd.category) ?? [];
list.push(cmd);
map.set(cmd.category, list);
}
return [...map.entries()];
});
$effect(() => {
if (open) {
query = '';
// Focus input after render
selectedIndex = 0;
showShortcuts = false;
requestAnimationFrame(() => inputEl?.focus());
}
});
function selectGroup(groupId: string) {
switchGroup(groupId);
// Reset selection when filter changes
$effect(() => {
void filtered;
selectedIndex = 0;
});
function executeCommand(cmd: Command) {
cmd.action();
onclose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onclose();
if (showShortcuts) {
showShortcuts = false;
e.stopPropagation();
} else {
onclose();
}
return;
}
if (showShortcuts) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filtered.length - 1);
scrollToSelected();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
scrollToSelected();
} else if (e.key === 'Enter' && filtered.length > 0) {
e.preventDefault();
executeCommand(filtered[selectedIndex]);
}
}
function scrollToSelected() {
requestAnimationFrame(() => {
const el = document.querySelector('.palette-item.selected');
el?.scrollIntoView({ block: 'nearest' });
});
}
// Track flat index across grouped display
function getFlatIndex(groupIdx: number, itemIdx: number): number {
let idx = 0;
for (let g = 0; g < groupIdx; g++) {
idx += grouped[g][1].length;
}
return idx + itemIdx;
}
</script>
@ -44,31 +284,77 @@
<div class="palette-backdrop" onclick={onclose} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette" data-testid="command-palette" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
<input
bind:this={inputEl}
bind:value={query}
class="palette-input"
data-testid="palette-input"
placeholder="Switch group..."
onkeydown={handleKeydown}
/>
<ul class="palette-results">
{#each filtered as group}
<li>
<button
class="palette-item"
class:active={group.id === activeGroupId}
onclick={() => selectGroup(group.id)}
>
<span class="group-name">{group.name}</span>
<span class="project-count">{group.projects.length} projects</span>
</button>
</li>
{/each}
{#if filtered.length === 0}
<li class="no-results">No groups match "{query}"</li>
{/if}
</ul>
{#if showShortcuts}
<div class="shortcuts-header">
<h3>Keyboard Shortcuts</h3>
<button class="shortcuts-close" onclick={() => showShortcuts = false}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="shortcuts-list">
<div class="shortcut-section">
<h4>Global</h4>
<div class="shortcut-row"><kbd>Ctrl+K</kbd><span>Command Palette</span></div>
<div class="shortcut-row"><kbd>Ctrl+,</kbd><span>Toggle Settings</span></div>
<div class="shortcut-row"><kbd>Ctrl+M</kbd><span>Toggle Messages</span></div>
<div class="shortcut-row"><kbd>Ctrl+B</kbd><span>Toggle Sidebar</span></div>
<div class="shortcut-row"><kbd>Escape</kbd><span>Close Panel / Palette</span></div>
</div>
<div class="shortcut-section">
<h4>Project Navigation</h4>
<div class="shortcut-row"><kbd>Alt+1</kbd> <kbd>Alt+5</kbd><span>Focus Project 15</span></div>
<div class="shortcut-row"><kbd>Ctrl+H</kbd><span>Previous Project</span></div>
<div class="shortcut-row"><kbd>Ctrl+L</kbd><span>Next Project</span></div>
<div class="shortcut-row"><kbd>Ctrl+J</kbd><span>Toggle Terminal</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+K</kbd><span>Focus Agent Pane</span></div>
</div>
<div class="shortcut-section">
<h4>Project Tabs</h4>
<div class="shortcut-row"><kbd>Ctrl+Shift+1</kbd><span>Model</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+2</kbd><span>Docs</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+3</kbd><span>Context</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+4</kbd><span>Files</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+5</kbd><span>SSH</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+6</kbd><span>Memory</span></div>
<div class="shortcut-row"><kbd>Ctrl+Shift+7</kbd><span>Metrics</span></div>
</div>
</div>
{:else}
<input
bind:this={inputEl}
bind:value={query}
class="palette-input"
data-testid="palette-input"
placeholder="Type a command..."
onkeydown={handleKeydown}
/>
<ul class="palette-results">
{#each grouped as [category, items], gi}
<li class="palette-category">{category}</li>
{#each items as cmd, ci}
{@const flatIdx = getFlatIndex(gi, ci)}
<li>
<button
class="palette-item"
class:selected={flatIdx === selectedIndex}
onclick={() => executeCommand(cmd)}
onmouseenter={() => selectedIndex = flatIdx}
>
<span class="cmd-label">{cmd.label}</span>
{#if cmd.shortcut}
<kbd class="cmd-shortcut">{cmd.shortcut}</kbd>
{/if}
</button>
</li>
{/each}
{/each}
{#if filtered.length === 0}
<li class="no-results">No commands match "{query}"</li>
{/if}
</ul>
{/if}
</div>
</div>
{/if}
@ -77,20 +363,20 @@
.palette-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
display: flex;
justify-content: center;
padding-top: 15vh;
padding-top: 12vh;
z-index: 1000;
}
.palette {
width: 28.75rem;
max-height: 22.5rem;
width: 32rem;
max-height: 28rem;
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
@ -103,8 +389,9 @@
border: none;
border-bottom: 1px solid var(--ctp-surface0);
color: var(--ctp-text);
font-size: 0.95rem;
font-size: 0.9rem;
outline: none;
font-family: inherit;
}
.palette-input::placeholder {
@ -118,37 +405,55 @@
overflow-y: auto;
}
.palette-category {
padding: 0.375rem 0.75rem 0.125rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-overlay0);
}
.palette-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
padding: 0.4rem 0.75rem;
background: transparent;
border: none;
color: var(--ctp-text);
font-size: 0.85rem;
font-size: 0.82rem;
cursor: pointer;
border-radius: 0.25rem;
transition: background 0.1s;
transition: background 0.08s;
font-family: inherit;
}
.palette-item:hover {
.palette-item:hover,
.palette-item.selected {
background: var(--ctp-surface0);
}
.palette-item.active {
background: var(--ctp-surface0);
border-left: 3px solid var(--ctp-blue);
.palette-item.selected {
outline: 1px solid var(--ctp-blue);
outline-offset: -1px;
}
.group-name {
font-weight: 600;
.cmd-label {
flex: 1;
text-align: left;
}
.project-count {
color: var(--ctp-overlay0);
font-size: 0.75rem;
.cmd-shortcut {
font-size: 0.68rem;
color: var(--ctp-overlay1);
background: var(--ctp-surface1);
padding: 0.1rem 0.375rem;
border-radius: 0.1875rem;
font-family: var(--font-mono, monospace);
white-space: nowrap;
margin-left: 0.5rem;
}
.no-results {
@ -157,4 +462,78 @@
font-size: 0.85rem;
text-align: center;
}
/* Shortcuts overlay */
.shortcuts-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.shortcuts-header h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--ctp-text);
}
.shortcuts-close {
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
background: transparent;
border: none;
border-radius: 0.25rem;
color: var(--ctp-subtext0);
cursor: pointer;
}
.shortcuts-close:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.shortcuts-list {
padding: 0.5rem 1rem;
overflow-y: auto;
}
.shortcut-section {
margin-bottom: 0.75rem;
}
.shortcut-section h4 {
margin: 0 0 0.25rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-overlay0);
}
.shortcut-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.8rem;
color: var(--ctp-subtext1);
}
.shortcut-row kbd {
font-size: 0.68rem;
color: var(--ctp-overlay1);
background: var(--ctp-surface1);
padding: 0.1rem 0.375rem;
border-radius: 0.1875rem;
font-family: var(--font-mono, monospace);
}
.shortcut-row span {
text-align: right;
}
</style>