-
-
handleSearchChange(e.target.value)}
- placeholder="Search sessions..."
- className="w-full bg-surface-raised border border-border rounded-md text-[12px] text-text-secondary placeholder-text-faint pl-7 pr-7 py-1.5 outline-none focus:border-text-faint transition-colors"
- />
- {isSearching && (
-
+ {/* Search + New chat */}
+
+
+ {/* Search pill (always visible, hover-zone trigger) */}
+
+
+ {/* New chat pill (hidden under input when open) */}
+
+
+ {searchMounted && (
+ <>
+ handleSearchChange(e.target.value)}
+ onFocus={() => setSearchFocused(true)}
+ onBlur={() => setSearchFocused(false)}
+ onMouseEnter={() => setSearchHovered(true)}
+ onMouseLeave={() => setSearchHovered(false)}
+ placeholder="Search sessions..."
+ className={`absolute inset-0 w-full h-full bg-surface-raised border border-border rounded-md text-[12px] text-text-secondary placeholder-text-faint pl-7 pr-7 outline-none focus:border-text-faint transition-all duration-200 ease-out z-20 ${
+ searchVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
+ }`}
+ />
+ {isSearching && (
+
+ )}
+ >
)}
diff --git a/web/src/components/ShortcutsModal.tsx b/web/src/components/ShortcutsModal.tsx
new file mode 100644
index 0000000..ecd7b83
--- /dev/null
+++ b/web/src/components/ShortcutsModal.tsx
@@ -0,0 +1,122 @@
+import { useEffect } from 'react';
+import { X } from 'lucide-react';
+import { useUIStore } from '../stores/uiStore';
+import { formatCombo, type ShortcutCombo } from '../utils/keyboard';
+
+interface DisplayShortcut {
+ combo: ShortcutCombo;
+ description: string;
+}
+
+interface Section {
+ title: string;
+ items: DisplayShortcut[];
+}
+
+/**
+ * Static display of every keyboard binding. The runtime handlers live in
+ * App.tsx (global) and ChatPage.tsx (chat-scoped) — keep this list in sync
+ * with those when bindings change.
+ */
+const SECTIONS: Section[] = [
+ {
+ title: 'General',
+ items: [
+ { combo: { mod: true, shift: true, key: 'o' }, description: 'New chat' },
+ { combo: { mod: true, key: 'k' }, description: 'Focus session search' },
+ { combo: { mod: true, key: '/' }, description: 'Show keyboard shortcuts' },
+ { combo: { key: 'Escape' }, description: 'Close dialog · clear search · stop generation' },
+ ],
+ },
+ {
+ title: 'Chat',
+ items: [
+ { combo: { mod: true, shift: true, key: 's' }, description: 'Toggle session sidebar' },
+ { combo: { mod: true, shift: true, key: ';' }, description: 'Focus message input' },
+ { combo: { mod: true, shift: true, key: 'c' }, description: 'Copy last response' },
+ { combo: { mod: true, shift: true, key: 'Backspace' }, description: 'Delete current conversation' },
+ { combo: { mod: true, key: '\\' }, description: 'Toggle side panel' },
+ ],
+ },
+ {
+ title: 'Message input',
+ items: [
+ { combo: { key: 'Enter' }, description: 'Send message' },
+ { combo: { shift: true, key: 'Enter' }, description: 'New line' },
+ ],
+ },
+];
+
+export function ShortcutsModal() {
+ const open = useUIStore((s) => 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..ff7139d
--- /dev/null
+++ b/web/src/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,42 @@
+import { useEffect } from 'react';
+import { isSafeInInputCombo, 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.
+ *
+ * When focus is inside an editable element, shortcuts still fire if either:
+ * - the shortcut sets `allowInInput: true`, or
+ * - the combo is "safe in input" by default (Cmd/Ctrl combos and Escape) —
+ * pressing it can't be confused with typing.
+ *
+ * Set `allowInInput: false` explicitly to opt out of the safe-by-default
+ * behavior. 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) {
+ const allowed = sc.allowInInput ?? isSafeInInputCombo(sc.combo);
+ if (!allowed) 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/chatStore.ts b/web/src/stores/chatStore.ts
index ca904f5..36662bb 100644
--- a/web/src/stores/chatStore.ts
+++ b/web/src/stores/chatStore.ts
@@ -109,6 +109,8 @@ interface ChatState {
searchQuery: string;
searchResults: Session[] | null; // null = not searching
searchLoading: boolean;
+ /** Bumped whenever something wants the sidebar search input focused (e.g. Cmd+K). */
+ searchFocusNonce: number;
loadSessions: () => Promise