mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 13:38:43 +02:00
feat: add rich keyboard shortcuts with cheat sheet overlay
Comprehensive keybindings for power-user navigation: - Ctrl+N: new session, Ctrl+W: archive session - Ctrl+Tab / Ctrl+Shift+Tab: cycle between sessions - Ctrl+,: open settings, Ctrl+/: shortcuts cheat sheet - Ctrl+.: quick-approve pending tool call - Ctrl+L: focus composer, Escape: cancel turn / close overlay Centralized shortcut registry in lib/keyboardShortcuts.ts drives both the cheat sheet panel and command palette shortcut badges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+149
-3
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { AppShell } from '@renderer/components/AppShell';
|
||||
import { ActivityPanel } from '@renderer/components/ActivityPanel';
|
||||
import { ChatPane } from '@renderer/components/ChatPane';
|
||||
import { CommandPalette } from '@renderer/components/CommandPalette';
|
||||
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
|
||||
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
|
||||
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
||||
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
||||
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
||||
@@ -114,6 +115,7 @@ export default function App() {
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
||||
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
|
||||
// Terminal state
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
@@ -266,18 +268,157 @@ export default function App() {
|
||||
if (hasPendingDiscoveries) setShowDiscoveryModal(true);
|
||||
}, [hasPendingDiscoveries]);
|
||||
|
||||
// Terminal: Ctrl+` toggle + Command palette: Ctrl+K / Cmd+K
|
||||
// Keep refs for values the keyboard handler reads — avoids re-registering on every render.
|
||||
const workspaceRef = useRef(workspace);
|
||||
workspaceRef.current = workspace;
|
||||
const showSettingsRef = useRef(showSettings);
|
||||
showSettingsRef.current = showSettings;
|
||||
const showShortcutsRef = useRef(showShortcuts);
|
||||
showShortcutsRef.current = showShortcuts;
|
||||
const commandPaletteOpenRef = useRef(commandPaletteOpen);
|
||||
commandPaletteOpenRef.current = commandPaletteOpen;
|
||||
const projectSettingsIdRef = useRef(projectSettingsId);
|
||||
projectSettingsIdRef.current = projectSettingsId;
|
||||
const newSessionProjectIdRef = useRef(newSessionProjectId);
|
||||
newSessionProjectIdRef.current = newSessionProjectId;
|
||||
|
||||
// ── Global keyboard shortcuts ──
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const ws = workspaceRef.current;
|
||||
|
||||
// Ignore keyboard shortcuts while typing in inputs (except our global combos)
|
||||
const target = e.target as HTMLElement;
|
||||
const isInput = target.tagName === 'INPUT'
|
||||
|| target.tagName === 'TEXTAREA'
|
||||
|| target.isContentEditable;
|
||||
|
||||
// ── Ctrl+` — Toggle terminal ──
|
||||
if (e.ctrlKey && e.key === '`') {
|
||||
e.preventDefault();
|
||||
setTerminalOpen((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
|
||||
// ── Ctrl/Cmd+K — Command palette ──
|
||||
if (mod && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setCommandPaletteOpen((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ctrl/Cmd+/ — Keyboard shortcuts cheat sheet ──
|
||||
if (mod && e.key === '/') {
|
||||
e.preventDefault();
|
||||
setShowShortcuts((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ctrl/Cmd+, — Open settings ──
|
||||
if (mod && e.key === ',') {
|
||||
e.preventDefault();
|
||||
setShowSettings(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Escape — Close overlays or cancel running turn ──
|
||||
if (e.key === 'Escape') {
|
||||
// Close overlays in priority order (command palette and shortcuts use their own capture listeners)
|
||||
if (projectSettingsIdRef.current) {
|
||||
e.preventDefault();
|
||||
setProjectSettingsId(undefined);
|
||||
return;
|
||||
}
|
||||
if (showSettingsRef.current) {
|
||||
e.preventDefault();
|
||||
setShowSettings(false);
|
||||
return;
|
||||
}
|
||||
if (newSessionProjectIdRef.current) {
|
||||
e.preventDefault();
|
||||
setNewSessionProjectId(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// If nothing is open, cancel a running turn on the selected session
|
||||
if (ws) {
|
||||
const session = ws.sessions.find((s) => s.id === ws.selectedSessionId);
|
||||
if (session?.status === 'running' && !isInput) {
|
||||
e.preventDefault();
|
||||
void api.cancelSessionTurn({ sessionId: session.id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip remaining shortcuts when focus is in an input field
|
||||
if (isInput) return;
|
||||
|
||||
// ── Ctrl/Cmd+N — New session ──
|
||||
if (mod && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
if (ws) {
|
||||
const defaultProjectId =
|
||||
ws.selectedProjectId ??
|
||||
ws.projects.find((p) => !isScratchpadProject(p))?.id;
|
||||
if (defaultProjectId) {
|
||||
setNewSessionProjectId(defaultProjectId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ctrl/Cmd+W — Archive / close current session ──
|
||||
if (mod && e.key === 'w') {
|
||||
e.preventDefault();
|
||||
if (ws?.selectedSessionId) {
|
||||
void api.setSessionArchived({ sessionId: ws.selectedSessionId, isArchived: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ctrl+Tab / Ctrl+Shift+Tab — Cycle sessions ──
|
||||
if (e.ctrlKey && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (ws) {
|
||||
const activeSessions = ws.sessions.filter((s) => !s.isArchived);
|
||||
if (activeSessions.length > 1) {
|
||||
const currentIdx = activeSessions.findIndex((s) => s.id === ws.selectedSessionId);
|
||||
const direction = e.shiftKey ? -1 : 1;
|
||||
const nextIdx = (currentIdx + direction + activeSessions.length) % activeSessions.length;
|
||||
void api.selectSession(activeSessions[nextIdx].id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ctrl/Cmd+. — Quick approve pending tool call ──
|
||||
if (mod && e.key === '.') {
|
||||
e.preventDefault();
|
||||
if (ws?.selectedSessionId) {
|
||||
const session = ws.sessions.find((s) => s.id === ws.selectedSessionId);
|
||||
if (session?.pendingApproval?.status === 'pending') {
|
||||
void api.resolveSessionApproval({
|
||||
sessionId: session.id,
|
||||
approvalId: session.pendingApproval.id,
|
||||
decision: 'approved',
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ctrl/Cmd+L — Focus the composer ──
|
||||
if (mod && e.key === 'l') {
|
||||
e.preventDefault();
|
||||
const editor = document.querySelector<HTMLElement>('.markdown-composer-editable');
|
||||
editor?.focus();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Track terminal running state via exit events
|
||||
@@ -645,8 +786,13 @@ export default function App() {
|
||||
}}
|
||||
onAddProject={() => void api.addProject()}
|
||||
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
||||
onShowShortcuts={() => setShowShortcuts(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showShortcuts && (
|
||||
<KeyboardShortcutsPanel onClose={() => setShowShortcuts(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Copy,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Keyboard,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
Moon,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||
import { isScratchpadProject } from '@shared/domain/project';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { shortcutKeys } from '@renderer/lib/keyboardShortcuts';
|
||||
|
||||
interface PaletteCommand {
|
||||
id: string;
|
||||
@@ -47,6 +49,7 @@ export interface CommandPaletteProps {
|
||||
onArchiveSession: (sessionId: string, isArchived: boolean) => void;
|
||||
onAddProject: () => void;
|
||||
onOpenAppDataFolder: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
}
|
||||
|
||||
/** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */
|
||||
@@ -86,6 +89,7 @@ export function CommandPalette({
|
||||
onArchiveSession,
|
||||
onAddProject,
|
||||
onOpenAppDataFolder,
|
||||
onShowShortcuts,
|
||||
}: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -151,6 +155,7 @@ export function CommandPalette({
|
||||
label: 'New Session',
|
||||
category: 'Actions',
|
||||
keywords: 'create start',
|
||||
shortcut: shortcutKeys('new-session'),
|
||||
icon: <Plus className={ICON} />,
|
||||
action: () => onNewSession(defaultProjectId),
|
||||
});
|
||||
@@ -187,7 +192,8 @@ export function CommandPalette({
|
||||
id: 'archive-session',
|
||||
label: 'Archive Session',
|
||||
category: 'Session',
|
||||
keywords: 'archive hide remove',
|
||||
keywords: 'archive hide remove close',
|
||||
shortcut: shortcutKeys('close-session'),
|
||||
icon: <Archive className={ICON} />,
|
||||
action: () => onArchiveSession(selectedSession.id, true),
|
||||
});
|
||||
@@ -221,6 +227,7 @@ export function CommandPalette({
|
||||
label: 'Open Settings',
|
||||
category: 'General',
|
||||
keywords: 'preferences config options',
|
||||
shortcut: shortcutKeys('settings'),
|
||||
icon: <Settings className={ICON} />,
|
||||
action: onOpenSettings,
|
||||
});
|
||||
@@ -241,7 +248,7 @@ export function CommandPalette({
|
||||
label: 'Toggle Terminal',
|
||||
category: 'General',
|
||||
keywords: 'terminal console shell command',
|
||||
shortcut: 'Ctrl+`',
|
||||
shortcut: shortcutKeys('toggle-terminal'),
|
||||
icon: <Terminal className={ICON} />,
|
||||
action: onToggleTerminal,
|
||||
});
|
||||
@@ -255,6 +262,16 @@ export function CommandPalette({
|
||||
action: onOpenAppDataFolder,
|
||||
});
|
||||
|
||||
cmds.push({
|
||||
id: 'keyboard-shortcuts',
|
||||
label: 'Keyboard Shortcuts',
|
||||
category: 'General',
|
||||
keywords: 'keys keybindings hotkeys help cheatsheet',
|
||||
shortcut: shortcutKeys('shortcut-help'),
|
||||
icon: <Keyboard className={ICON} />,
|
||||
action: onShowShortcuts,
|
||||
});
|
||||
|
||||
// ── Theme ──
|
||||
cmds.push({
|
||||
id: 'theme-dark',
|
||||
@@ -287,7 +304,7 @@ export function CommandPalette({
|
||||
onSelectSession, onSelectProject, onNewSession, onCreateScratchpad,
|
||||
onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme,
|
||||
onDuplicateSession, onPinSession, onArchiveSession, onAddProject,
|
||||
onOpenAppDataFolder,
|
||||
onOpenAppDataFolder, onShowShortcuts,
|
||||
]);
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
|
||||
import { shortcuts, type ShortcutDefinition } from '@renderer/lib/keyboardShortcuts';
|
||||
|
||||
interface KeyboardShortcutsPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const categoryOrder = ['Navigation', 'Sessions', 'Workspace', 'General'] as const;
|
||||
|
||||
function groupByCategory(defs: ShortcutDefinition[]): Map<string, ShortcutDefinition[]> {
|
||||
const groups = new Map<string, ShortcutDefinition[]>();
|
||||
for (const def of defs) {
|
||||
const list = groups.get(def.category) ?? [];
|
||||
list.push(def);
|
||||
groups.set(def.category, list);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsPanel({ onClose }: KeyboardShortcutsPanelProps) {
|
||||
// Escape to close — capture phase so it doesn't propagate
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
return () => document.removeEventListener('keydown', handleEscape, true);
|
||||
}, [onClose]);
|
||||
|
||||
const grouped = groupByCategory(shortcuts);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="palette-backdrop-enter fixed inset-0 z-[70] flex items-center justify-center bg-[#07080e]/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-title"
|
||||
>
|
||||
<div
|
||||
className="palette-enter w-full max-w-lg overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-5 py-3.5">
|
||||
<Keyboard className="size-4 text-[var(--color-text-accent)]" />
|
||||
<h2
|
||||
id="shortcuts-title"
|
||||
className="font-display text-[14px] font-semibold text-[var(--color-text-primary)]"
|
||||
>
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<span className="ml-auto text-[11px] text-[var(--color-text-muted)]">
|
||||
Press <Kbd>Esc</Kbd> to close
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body — two-column grid of categories */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-5 px-5 py-4">
|
||||
{categoryOrder.map((cat) => {
|
||||
const items = grouped.get(cat);
|
||||
if (!items?.length) return null;
|
||||
return (
|
||||
<div key={cat}>
|
||||
<h3 className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
{cat}
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 text-[12.5px]"
|
||||
>
|
||||
<span className="truncate text-[var(--color-text-secondary)]">
|
||||
{item.label}
|
||||
</span>
|
||||
<ShortcutBadge keys={item.keys} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-[var(--color-border)] px-5 py-2.5 text-[11px] text-[var(--color-text-muted)]">
|
||||
Tip: Use the command palette for even more actions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline keyboard key cap. */
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-0)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a compound shortcut like "Ctrl+Shift+Tab" as joined key caps. */
|
||||
function ShortcutBadge({ keys }: { keys: string }) {
|
||||
const parts = keys.split('+');
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
{parts.map((part, i) => (
|
||||
<Kbd key={i}>{part}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
|
||||
/** Platform-aware modifier key label. */
|
||||
export const MOD = isMac ? '⌘' : 'Ctrl';
|
||||
|
||||
export interface ShortcutDefinition {
|
||||
id: string;
|
||||
/** Human-readable label for the shortcut cheat sheet. */
|
||||
label: string;
|
||||
/** Display string for the keybinding. */
|
||||
keys: string;
|
||||
/** Grouping category. */
|
||||
category: 'Navigation' | 'Sessions' | 'Workspace' | 'General';
|
||||
}
|
||||
|
||||
/** Canonical shortcut registry used for the cheat sheet and command palette badges. */
|
||||
export const shortcuts: ShortcutDefinition[] = [
|
||||
// ── Navigation ──
|
||||
{ id: 'command-palette', label: 'Command palette', keys: `${MOD}+K`, category: 'Navigation' },
|
||||
{ id: 'settings', label: 'Open settings', keys: `${MOD}+,`, category: 'Navigation' },
|
||||
{ id: 'toggle-terminal', label: 'Toggle terminal', keys: 'Ctrl+`', category: 'Navigation' },
|
||||
{ id: 'shortcut-help', label: 'Keyboard shortcuts', keys: `${MOD}+/`, category: 'Navigation' },
|
||||
|
||||
// ── Sessions ──
|
||||
{ id: 'new-session', label: 'New session', keys: `${MOD}+N`, category: 'Sessions' },
|
||||
{ id: 'close-session', label: 'Close / archive', keys: `${MOD}+W`, category: 'Sessions' },
|
||||
{ id: 'next-session', label: 'Next session', keys: 'Ctrl+Tab', category: 'Sessions' },
|
||||
{ id: 'prev-session', label: 'Previous session', keys: 'Ctrl+Shift+Tab', category: 'Sessions' },
|
||||
|
||||
// ── Workspace ──
|
||||
{ id: 'quick-approve', label: 'Quick approve', keys: `${MOD}+.`, category: 'Workspace' },
|
||||
{ id: 'cancel-turn', label: 'Cancel running turn', keys: 'Escape', category: 'Workspace' },
|
||||
{ id: 'focus-composer', label: 'Focus composer', keys: `${MOD}+L`, category: 'Workspace' },
|
||||
|
||||
// ── General ──
|
||||
{ id: 'close-overlay', label: 'Close overlay', keys: 'Escape', category: 'General' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Look up the display-string for a shortcut by its id.
|
||||
* Returns `undefined` if not found.
|
||||
*/
|
||||
export function shortcutKeys(id: string): string | undefined {
|
||||
return shortcuts.find((s) => s.id === id)?.keys;
|
||||
}
|
||||
Reference in New Issue
Block a user