s.shortcutsModalOpen);
+ const close = useUIStore((s) => s.closeShortcutsModal);
+
+ // Local Esc handler — runs *before* the document-level shortcut listeners
+ // because modal mount captures it first when focus is inside.
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ close();
+ }
+ };
+ document.addEventListener('keydown', onKey, true); // capture phase = wins
+ return () => document.removeEventListener('keydown', onKey, true);
+ }, [open, close]);
+
+ if (!open) return null;
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
Keyboard shortcuts
+
+
+
+
+ {SECTIONS.map((section) => (
+
+
+ {section.title}
+
+
+ {section.items.map((item, idx) => (
+
+ {item.description}
+
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
+
+function Kbd({ combo }: { combo: ShortcutCombo }) {
+ return (
+
+ {formatCombo(combo)}
+
+ );
+}
diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts
new file mode 100644
index 0000000..e1764fb
--- /dev/null
+++ b/web/src/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,34 @@
+import { useEffect } from 'react';
+import { isTypingTarget, matchesCombo, type ShortcutDef } from '../utils/keyboard';
+
+/**
+ * Attach a `document`-level keydown listener that fires the first shortcut
+ * whose combo matches the event. Order matters: more specific shortcuts
+ * should come first.
+ *
+ * Skips when focus is inside an editable element unless the shortcut sets
+ * `allowInInput: true`. Skips when the shortcut's `when()` predicate is
+ * defined and returns false.
+ *
+ * Pass the same array reference across renders if you can — otherwise we
+ * re-register the listener on every render. In practice the callers below
+ * build a fresh array each render but the cost is one removeEventListener +
+ * addEventListener, which is negligible.
+ */
+export function useKeyboardShortcuts(shortcuts: ShortcutDef[]): void {
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ const typing = isTypingTarget(e.target);
+ for (const sc of shortcuts) {
+ if (typing && !sc.allowInInput) continue;
+ if (sc.when && !sc.when()) continue;
+ if (!matchesCombo(e, sc.combo)) continue;
+ e.preventDefault();
+ sc.action(e);
+ return;
+ }
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [shortcuts]);
+}
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx
index 43eff42..009bced 100644
--- a/web/src/pages/ChatPage.tsx
+++ b/web/src/pages/ChatPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useChatStore } from '../stores/chatStore';
import { SessionSidebar } from '../components/Chat/SessionSidebar';
@@ -10,6 +10,9 @@ import { SidePanel } from '../components/Chat/SidePanel';
import { BackgroundJobs } from '../components/Chat/BackgroundJobs';
import { Loader2, PanelLeftOpen, PanelLeftClose, Files, ExternalLink } from 'lucide-react';
import { api } from '../api/client';
+import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
+import type { ShortcutDef } from '../utils/keyboard';
+import type { ChatMessage, TextBlockData } from '../types/chat';
const STATUS_LABELS: Record
= {
thinking: 'Thinking...',
@@ -39,17 +42,60 @@ export function ChatPage() {
sendMessage, stopSession, toggleSidebar, openFilesPanel,
} = useChatStore();
- // Cmd/Ctrl + \ toggles side panel
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === '\\') {
- e.preventDefault();
- useChatStore.getState().togglePanel();
- }
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, []);
+ // Chat-scoped keyboard shortcuts. Global ones (new chat, search, modal,
+ // Esc cascade) live in in App.tsx.
+ const chatShortcuts = useMemo(() => [
+ {
+ id: 'chat-toggle-panel',
+ combo: { mod: true, key: '\\' },
+ description: 'Toggle side panel',
+ section: 'chat',
+ action: () => useChatStore.getState().togglePanel(),
+ },
+ {
+ id: 'chat-toggle-sidebar',
+ combo: { mod: true, shift: true, key: 's' },
+ description: 'Toggle session sidebar',
+ section: 'chat',
+ action: () => useChatStore.getState().toggleSidebar(),
+ },
+ {
+ id: 'chat-focus-input',
+ combo: { mod: true, shift: true, key: ';' },
+ description: 'Focus message input',
+ section: 'chat',
+ allowInInput: true,
+ action: () => {
+ const el = document.getElementById('nerve-chat-input');
+ if (el instanceof HTMLTextAreaElement) el.focus();
+ },
+ },
+ {
+ id: 'chat-copy-last',
+ combo: { mod: true, shift: true, key: 'c' },
+ description: 'Copy last response',
+ section: 'chat',
+ action: () => {
+ const text = getLastAssistantText(useChatStore.getState().messages);
+ if (text) void navigator.clipboard.writeText(text);
+ },
+ },
+ {
+ id: 'chat-delete-current',
+ combo: { mod: true, shift: true, key: 'Backspace' },
+ description: 'Delete current conversation',
+ section: 'chat',
+ action: () => {
+ const id = useChatStore.getState().activeSession;
+ if (!id) return;
+ if (window.confirm('Delete this conversation?')) {
+ void useChatStore.getState().deleteSession(id);
+ }
+ },
+ },
+ ], []);
+
+ useKeyboardShortcuts(chatShortcuts);
// Langfuse deep-link status — fetched once. Shows a small "external link"
// icon when observability is enabled so we can jump from a session to
@@ -192,3 +238,17 @@ export function ChatPage() {
);
}
+
+/** Walk messages backwards, return the joined text of the most recent assistant turn. */
+function getLastAssistantText(messages: ChatMessage[]): string | null {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i];
+ if (m.role !== 'assistant') continue;
+ const text = m.blocks
+ .filter((b): b is TextBlockData => b.type === 'text')
+ .map((b) => b.content)
+ .join('\n');
+ return text || null;
+ }
+ return null;
+}
diff --git a/web/src/stores/uiStore.ts b/web/src/stores/uiStore.ts
new file mode 100644
index 0000000..3f7cada
--- /dev/null
+++ b/web/src/stores/uiStore.ts
@@ -0,0 +1,19 @@
+import { create } from 'zustand';
+
+/**
+ * Misc UI state that doesn't belong to a single feature store.
+ * Right now: just the keyboard-shortcuts modal flag.
+ */
+interface UIState {
+ shortcutsModalOpen: boolean;
+ openShortcutsModal: () => void;
+ closeShortcutsModal: () => void;
+ toggleShortcutsModal: () => void;
+}
+
+export const useUIStore = create