diff --git a/packages/app/e2e/sidebar-toggle-tooltip.spec.ts b/packages/app/e2e/sidebar-toggle-tooltip.spec.ts index 952afaeb1..de012feed 100644 --- a/packages/app/e2e/sidebar-toggle-tooltip.spec.ts +++ b/packages/app/e2e/sidebar-toggle-tooltip.spec.ts @@ -14,7 +14,7 @@ test('sidebar toggle shows tooltip on the right', async ({ page }) => { const tooltip = page.getByTestId('menu-button-tooltip'); await expect(tooltip).toBeVisible(); await expect(tooltip).toContainText('Toggle sidebar'); - await expect(tooltip).toContainText(/⌘B|Ctrl\+B/); + await expect(tooltip).toContainText(/⌘B|Ctrl\+\./); await page.waitForTimeout(250); await expect(tooltip).toBeVisible(); diff --git a/packages/app/e2e/terminal-pane.spec.ts b/packages/app/e2e/terminal-pane.spec.ts index ad20cff1b..74c256f8e 100644 --- a/packages/app/e2e/terminal-pane.spec.ts +++ b/packages/app/e2e/terminal-pane.spec.ts @@ -95,6 +95,24 @@ async function openTerminalsPanel(page: Page): Promise { }); } +async function getDesktopAgentSidebarOpen(page: Page): Promise { + return await page.evaluate(() => { + const raw = localStorage.getItem("panel-state"); + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw) as { + state?: { desktop?: { agentListOpen?: boolean } }; + }; + const value = parsed?.state?.desktop?.agentListOpen; + return typeof value === "boolean" ? value : null; + } catch { + return null; + } + }); +} + async function selectNewestTerminalTab(page: Page): Promise { const tabs = page.locator('[data-testid^="terminal-tab-"]'); @@ -256,6 +274,75 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => { } }); +test("terminal captures escape and ctrl+c key input", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-terminal-keys-"); + + try { + await openNewAgentDraft(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, "Terminal key combo capture"); + + await openTerminalsPanel(page); + + const surface = page.getByTestId("terminal-surface").first(); + await expect(surface).toBeVisible({ timeout: 30000 }); + await surface.click({ force: true }); + + await page.keyboard.type("cat -v", { delay: 1 }); + await page.keyboard.press("Enter"); + await expect(surface).toContainText("cat -v", { timeout: 30000 }); + + await page.keyboard.press("Escape"); + await expect(surface).toContainText("^[", { timeout: 30000 }); + + await page.keyboard.press("Control+C"); + await expect(surface).toContainText("^C", { timeout: 30000 }); + + await page.keyboard.press("Control+B"); + await expect(surface).toContainText("^B", { timeout: 30000 }); + + const marker = `terminal-key-capture-${Date.now()}`; + await page.keyboard.type(`echo ${marker}`, { delay: 1 }); + await page.keyboard.press("Enter"); + await expect(surface).toContainText(marker, { timeout: 30000 }); + } finally { + await repo.cleanup(); + } +}); + +test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-terminal-cmd-b-"); + + try { + await openNewAgentDraft(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, "Terminal Cmd+B"); + + await openTerminalsPanel(page); + const surface = page.getByTestId("terminal-surface").first(); + await expect(surface).toBeVisible({ timeout: 30000 }); + await surface.click({ force: true }); + + await expect + .poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 }) + .toBe(true); + + await page.keyboard.press("Meta+B"); + await expect + .poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 }) + .toBe(false); + + await page.keyboard.press("Meta+B"); + await expect + .poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 }) + .toBe(true); + } finally { + await repo.cleanup(); + } +}); + async function getTerminalRows(page: Page): Promise { return await page.evaluate(() => { const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal; diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 5b5e45579..75bcc058f 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -36,7 +36,7 @@ import { import { getIsTauri, getIsTauriMac } from "@/constants/layout"; import { useTrafficLightPadding } from "@/utils/tauri-window"; import { CommandCenter } from "@/components/command-center"; -import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { queryClient } from "@/query/query-client"; import { WEB_NOTIFICATION_CLICK_EVENT, @@ -147,7 +147,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { const openGestureEnabled = chromeEnabled && isMobile && mobileView === "agent"; - useGlobalKeyboardNav({ + useKeyboardShortcuts({ enabled: chromeEnabled, isMobile, toggleAgentList, diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 8fe701bb0..f405b31cb 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -28,7 +28,7 @@ import { Theme } from "@/styles/theme"; import { CommandAutocomplete } from "./command-autocomplete"; import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; -import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { focusWithRetries } from "@/utils/web-focus"; import { useVoiceOptional } from "@/contexts/voice-context"; import { useToast } from "@/contexts/toast-context"; @@ -74,8 +74,12 @@ export function AgentInputArea({ const insets = useSafeAreaInsets(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const isScreenFocused = useIsFocused(); - const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest); - const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest); + const messageInputActionRequest = useKeyboardShortcutsStore( + (s) => s.messageInputActionRequest + ); + const clearMessageInputActionRequest = useKeyboardShortcutsStore( + (s) => s.clearMessageInputActionRequest + ); const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null @@ -108,7 +112,7 @@ export function AgentInputArea({ const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [sendError, setSendError] = useState(null); const [commandSelectedIndex, setCommandSelectedIndex] = useState(0); - const lastHandledFocusRequestIdRef = useRef(null); + const lastHandledMessageInputActionRequestIdRef = useRef(null); // Command autocomplete logic const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); @@ -400,21 +404,36 @@ export function AgentInputArea({ saveDraftInput(agentId, { text: userInput, images: selectedImages }); }, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]); - // When switching agents from the command center, auto-focus the input on web. + // Keyboard-dispatched message-input actions are routed through store requests. useEffect(() => { - if (Platform.OS !== "web") return; if (!isScreenFocused) return; - if (!focusChatInputRequest) return; + if (!messageInputActionRequest) return; const currentKey = `${serverId}:${agentId}`; - if (focusChatInputRequest.agentKey !== currentKey) { + if (messageInputActionRequest.agentKey !== currentKey) { return; } - if (lastHandledFocusRequestIdRef.current === focusChatInputRequest.id) { + if ( + lastHandledMessageInputActionRequestIdRef.current === + messageInputActionRequest.id + ) { + return; + } + lastHandledMessageInputActionRequestIdRef.current = + messageInputActionRequest.id; + + if (messageInputActionRequest.kind !== "focus") { + messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind); + clearMessageInputActionRequest(messageInputActionRequest.id); + return; + } + + if (Platform.OS !== "web") { + messageInputRef.current?.focus(); + clearMessageInputActionRequest(messageInputActionRequest.id); return; } - lastHandledFocusRequestIdRef.current = focusChatInputRequest.id; return focusWithRetries({ focus: () => messageInputRef.current?.focus(), @@ -424,14 +443,16 @@ export function AgentInputArea({ typeof document !== "undefined" ? document.activeElement : null; return Boolean(el) && active === el; }, - onSuccess: () => clearFocusChatInputRequest(), - onTimeout: () => clearFocusChatInputRequest(), + onSuccess: () => + clearMessageInputActionRequest(messageInputActionRequest.id), + onTimeout: () => + clearMessageInputActionRequest(messageInputActionRequest.id), }); }, [ agentId, - clearFocusChatInputRequest, - focusChatInputRequest, + clearMessageInputActionRequest, isScreenFocused, + messageInputActionRequest, serverId, ]); diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index dd0a91b3e..3d56b4af0 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -48,9 +48,13 @@ export function CommandCenter() { - + state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); + const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]; const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const MenuIcon = isMobile ? Menu : PanelLeft; @@ -32,7 +34,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) { void; blur: () => void; + runKeyboardAction: (action: MessageInputKeyboardActionKind) => void; /** * Web-only: return the underlying DOM element for focus assertions/retries. * May return null if not mounted or on native. @@ -138,8 +139,6 @@ export const MessageInput = forwardRef( const { theme } = useUnistyles(); const toast = useToast(); const voice = useVoiceOptional(); - const toggleAgentList = usePanelStore((state) => state.toggleAgentList); - const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const textInputRef = useRef< TextInput | (TextInput & { getNativeRef?: () => unknown }) | null @@ -153,6 +152,40 @@ export const MessageInput = forwardRef( blur: () => { textInputRef.current?.blur?.(); }, + runKeyboardAction: (action) => { + if (action === "focus") { + textInputRef.current?.focus(); + return; + } + + if (action === "voice-toggle") { + handleToggleRealtimeVoiceShortcut(); + return; + } + + if (action === "voice-mute-toggle") { + if (isRealtimeVoiceForCurrentAgent) { + voice?.toggleMute(); + } + return; + } + + if (action === "dictation-cancel") { + if (isDictatingRef.current) { + cancelDictation(); + } + return; + } + + if (action === "dictation-toggle") { + if (isDictatingRef.current) { + sendAfterTranscriptRef.current = true; + confirmDictation(); + } else { + void startDictationIfAvailable(); + } + } + }, getNativeElement: () => { if (!IS_WEB) return null; const current = textInputRef.current as @@ -295,121 +328,6 @@ export const MessageInput = forwardRef( await startDictation(); }, [dictationUnavailableMessage, startDictation, toast]); - // Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation - useEffect(() => { - if (!IS_WEB) return; - const toggleRealtimeVoice = () => { - if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) { - return; - } - if (voice.isVoiceSwitching) { - return; - } - if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) { - const tasks: Promise[] = []; - if (isAgentRunning && client) { - tasks.push(client.cancelAgent(voiceAgentId)); - } - tasks.push(voice.stopVoice()); - void Promise.allSettled(tasks).then((results) => { - results.forEach((result) => { - if (result.status === "rejected") { - console.error( - "[MessageInput] Failed to stop realtime voice", - result.reason - ); - } - }); - }); - return; - } - void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => { - console.error("[MessageInput] Failed to start realtime voice", error); - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : null; - if (message && message.trim().length > 0) { - toast.error(message); - } - }); - }; - - const resolveNativeInput = (): unknown => { - const current = textInputRef.current as any; - if (!current) return null; - if (typeof current.getNativeRef === "function") { - return current.getNativeRef(); - } - return current; - }; - function handleKeyDown(event: KeyboardEvent) { - const nativeInput = resolveNativeInput(); - const isFromInput = Boolean(nativeInput && event.target === nativeInput); - if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) { - return; - } - const isMod = event.metaKey || event.ctrlKey; - const isKeyD = event.code === "KeyD" || event.key.toLowerCase() === "d"; - - if (isMod && event.shiftKey && isKeyD && !event.repeat) { - event.preventDefault(); - toggleRealtimeVoice(); - return; - } - - if ( - isRealtimeVoiceForCurrentAgent && - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - (event.code === "Space" || event.key === " ") && - !event.repeat - ) { - event.preventDefault(); - voice?.toggleMute(); - return; - } - - const dictating = isDictatingRef.current; - // Cmd+D: start dictation or submit if already dictating - if (isMod && isKeyD) { - event.preventDefault(); - if (dictating) { - sendAfterTranscriptRef.current = true; - confirmDictation(); - } else { - void startDictationIfAvailable(); - } - return; - } - // Escape: cancel dictation - if (event.key === "Escape" && dictating) { - event.preventDefault(); - cancelDictation(); - } - } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [ - cancelDictation, - client, - confirmDictation, - disabled, - isAgentRunning, - isConnected, - isRealtimeVoiceForCurrentAgent, - isScreenFocused, - startDictationIfAvailable, - toast, - voiceAgentId, - voiceServerId, - voice, - ]); - // Animate overlay useEffect(() => { overlayTransition.value = withTiming(showOverlay ? 1 : 0, { @@ -636,60 +554,6 @@ export const MessageInput = forwardRef( const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; - const key = event.nativeEvent.key.toLowerCase(); - - // Cmd+B or Ctrl+B: toggle sidebar - if ((metaKey || ctrlKey) && key === "b") { - event.preventDefault(); - toggleAgentList(); - return; - } - - // Cmd+E or Ctrl+E: toggle explorer sidebar - if ((metaKey || ctrlKey) && key === "e") { - event.preventDefault(); - toggleFileExplorer(); - return; - } - - // Cmd+Shift+D or Ctrl+Shift+D: toggle realtime voice mode - if ((metaKey || ctrlKey) && shiftKey && key === "d") { - event.preventDefault(); - handleToggleRealtimeVoiceShortcut(); - return; - } - - // Cmd+D or Ctrl+D: start dictation or submit if already dictating - if ((metaKey || ctrlKey) && key === "d") { - event.preventDefault(); - if (isDictating) { - sendAfterTranscriptRef.current = true; - confirmDictation(); - } else { - void startDictationIfAvailable(); - } - return; - } - - // Escape: cancel dictation - if (event.nativeEvent.key === "Escape" && isDictating) { - event.preventDefault(); - cancelDictation(); - return; - } - - if ( - isRealtimeVoiceForCurrentAgent && - !metaKey && - !ctrlKey && - !shiftKey && - event.nativeEvent.key === " " - ) { - event.preventDefault(); - voice?.toggleMute(); - return; - } - if (event.nativeEvent.key !== "Enter") return; // Shift+Enter: add newline (default behavior, don't intercept) @@ -714,7 +578,7 @@ export const MessageInput = forwardRef( const shouldShowSendButton = hasSendableContent || isSubmitLoading; return ( - + {/* Regular input */} {/* Image preview pills */} diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index c4996a079..f73bbb8d5 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -45,7 +45,7 @@ import { type SidebarSectionData, } from "@/hooks/use-sidebar-agents-grouped"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; -import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { getIsTauri } from "@/constants/layout"; import { AgentStatusDot } from "@/components/agent-status-dot"; @@ -370,9 +370,11 @@ export function SidebarAgentList({ const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys); const toggleProjectCollapsed = useSidebarCollapsedSectionsStore((s) => s.toggleProjectCollapsed); - const altDown = useKeyboardNavStore((s) => s.altDown); - const cmdOrCtrlDown = useKeyboardNavStore((s) => s.cmdOrCtrlDown); - const sidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.sidebarShortcutAgentKeys); + const altDown = useKeyboardShortcutsStore((s) => s.altDown); + const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown); + const sidebarShortcutAgentKeys = useKeyboardShortcutsStore( + (s) => s.sidebarShortcutAgentKeys + ); const isTauri = getIsTauri(); const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown); const shortcutIndexByAgentKey = useMemo(() => { diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 0bce11f1e..183a2776b 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -18,7 +18,7 @@ import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; -import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts"; import { Combobox } from "@/components/ui/combobox"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; @@ -121,7 +121,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { }, [isRevalidating, isManualRefresh]); const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys); - const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys); + const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore( + (s) => s.setSidebarShortcutAgentKeys + ); const sidebarShortcutAgentKeys = useMemo(() => { return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9); }, [collapsedProjectKeys, sections]); diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 14470b883..93d70974b 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TextInput } from "react-native"; import { router, usePathname } from "expo-router"; -import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { @@ -126,9 +126,11 @@ export type CommandCenterItem = export function useCommandCenter() { const pathname = usePathname(); const { agents } = useAggregatedAgents(); - const open = useKeyboardNavStore((s) => s.commandCenterOpen); - const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen); - const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput); + const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); + const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); + const requestMessageInputAction = useKeyboardShortcutsStore( + (s) => s.requestMessageInputAction + ); const inputRef = useRef(null); const didNavigateRef = useRef(false); const prevOpenRef = useRef(open); @@ -220,13 +222,16 @@ export function useCommandCenter() { const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const navigate = shouldReplace ? router.replace : router.push; - requestFocusChatInput(agentKey(agent)); + requestMessageInputAction({ + agentKey: agentKey(agent), + kind: "focus", + }); // Don't restore focus back to the prior element after we navigate. clearCommandCenterFocusRestoreElement(); setOpen(false); navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any); }, - [pathname, requestFocusChatInput, setOpen] + [pathname, requestMessageInputAction, setOpen] ); const handleSelectAction = useCallback((action: CommandCenterActionItem) => { @@ -267,7 +272,10 @@ export function useCommandCenter() { isFocused, onTimeout: () => { if (agentKeyFromPathname) { - requestFocusChatInput(agentKeyFromPathname); + requestMessageInputAction({ + agentKey: agentKeyFromPathname, + kind: "focus", + }); } }, }); @@ -283,7 +291,7 @@ export function useCommandCenter() { inputRef.current?.focus(); }, 0); return () => clearTimeout(id); - }, [agentKeyFromPathname, open, requestFocusChatInput]); + }, [agentKeyFromPathname, open, requestMessageInputAction]); useEffect(() => { if (!open) return; diff --git a/packages/app/src/hooks/use-global-keyboard-nav.ts b/packages/app/src/hooks/use-global-keyboard-nav.ts deleted file mode 100644 index fc0e7d478..000000000 --- a/packages/app/src/hooks/use-global-keyboard-nav.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { useEffect } from "react"; -import { Platform } from "react-native"; -import { usePathname, useRouter } from "expo-router"; -import { getIsTauri } from "@/constants/layout"; -import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; -import { useSessionStore } from "@/stores/session-store"; -import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; -import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; -import { - checkoutStatusQueryKey, - type CheckoutStatusPayload, -} from "@/hooks/use-checkout-status-query"; -import { queryClient } from "@/query/query-client"; -import { - buildNewAgentRoute, - resolveNewAgentWorkingDir, -} from "@/utils/new-agent-routing"; -import { - buildHostAgentDetailRoute, - parseHostAgentRouteFromPathname, - parseServerIdFromPathname, -} from "@/utils/host-routes"; - -export function useGlobalKeyboardNav({ - enabled, - isMobile, - toggleAgentList, - selectedAgentId, - toggleFileExplorer, -}: { - enabled: boolean; - isMobile: boolean; - toggleAgentList: () => void; - selectedAgentId?: string; - toggleFileExplorer?: () => void; -}) { - const router = useRouter(); - const pathname = usePathname(); - const resetModifiers = useKeyboardNavStore((s) => s.resetModifiers); - - useEffect(() => { - if (!enabled) return; - if (Platform.OS !== "web") return; - if (isMobile) return; - - const isTauri = getIsTauri(); - const shouldHandle = () => { - if (typeof document === "undefined") return false; - if (document.visibilityState !== "visible") return false; - return true; - }; - - const isEditableTarget = (event: KeyboardEvent): boolean => { - const target = event.target; - if (!(target instanceof Element)) return false; - - if ((target as HTMLElement).isContentEditable) return true; - const tag = target.tagName.toLowerCase(); - if (tag === "input" || tag === "textarea") return true; - - return false; - }; - - const parseShortcutDigit = (event: KeyboardEvent): number | null => { - const code = event.code ?? ""; - if (code.startsWith("Digit")) { - const n = Number(code.slice("Digit".length)); - return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null; - } - if (code.startsWith("Numpad")) { - const n = Number(code.slice("Numpad".length)); - return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null; - } - const key = event.key ?? ""; - if (key >= "1" && key <= "9") { - return Number(key); - } - return null; - }; - - const navigateToSidebarShortcut = (digit: number) => { - const state = useKeyboardNavStore.getState(); - const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null; - if (!targetKey) { - return; - } - - const parsed = parseSidebarAgentKey(targetKey); - if (!parsed) { - return; - } - const { serverId, agentId } = parsed; - - const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); - const navigate = shouldReplace ? router.replace : router.push; - navigate(buildHostAgentDetailRoute(serverId, agentId) as any); - }; - - const navigateToNewAgent = () => { - let targetServerId = parseServerIdFromPathname(pathname); - let targetWorkingDir: string | null = null; - if (selectedAgentId) { - const separatorIndex = selectedAgentId.indexOf(":"); - if (separatorIndex > 0) { - const serverId = selectedAgentId.slice(0, separatorIndex); - const agentId = selectedAgentId.slice(separatorIndex + 1); - targetServerId = serverId; - const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId); - const cwd = agent?.cwd?.trim(); - if (cwd) { - const checkout = - queryClient.getQueryData( - checkoutStatusQueryKey(serverId, cwd) - ) ?? null; - targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout); - } - } - } - - if (!targetServerId) { - const sessionServerIds = Object.keys(useSessionStore.getState().sessions); - targetServerId = sessionServerIds[0] ?? null; - } - - if (!targetServerId) { - return; - } - - router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (!shouldHandle()) { - return; - } - - const key = event.key ?? ""; - const lowerKey = key.toLowerCase(); - - if (key === "Alt" && !event.shiftKey) { - useKeyboardNavStore.getState().setAltDown(true); - } - if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) { - useKeyboardNavStore.getState().setCmdOrCtrlDown(true); - } - // If shift is pressed while a modifier is held, hide the badges - if (key === "Shift") { - const state = useKeyboardNavStore.getState(); - if (state.altDown || state.cmdOrCtrlDown) { - state.resetModifiers(); - } - } - - const isMod = event.metaKey || event.ctrlKey; - const isKeyN = event.code === "KeyN" || lowerKey === "n"; - - // Cmd/Ctrl+Alt+N: new agent (web + Tauri) - // Note: intentionally works even when focus is inside an input/textarea. - if (isMod && event.altKey && !event.shiftKey && isKeyN) { - event.preventDefault(); - navigateToNewAgent(); - return; - } - - // Cmd/Ctrl+N: new agent (Tauri only) - // Note: intentionally works even when focus is inside an input/textarea. - if (isTauri && isMod && !event.altKey && !event.shiftKey && isKeyN) { - event.preventDefault(); - navigateToNewAgent(); - return; - } - - // Cmd+B: toggle sidebar - if ( - isMod && - (event.code === "KeyB" || lowerKey === "b") - ) { - // The MessageInput already handles Cmd+B inside editable fields. If we also - // handle it globally, it can double-toggle and look like it "doesn't work". - if (isEditableTarget(event)) { - return; - } - event.preventDefault(); - toggleAgentList(); - return; - } - - // Cmd+.: toggle sidebar (VS Code quick-fix muscle memory) - // Note: intentionally works even when focus is inside an input/textarea. - if ( - isMod && - (event.code === "Period" || key === ".") - ) { - // Ignore while command center is open. - if (useKeyboardNavStore.getState().commandCenterOpen) { - return; - } - event.preventDefault(); - toggleAgentList(); - return; - } - - // Cmd+E: toggle explorer sidebar (only when an agent is selected) - if ( - selectedAgentId && - toggleFileExplorer && - isMod && - (event.code === "KeyE" || lowerKey === "e") - ) { - // Same double-toggle issue as Cmd+B when focus is inside a text input. - if (isEditableTarget(event)) { - return; - } - event.preventDefault(); - toggleFileExplorer(); - return; - } - - // Ctrl+`: toggle explorer sidebar (VS Code muscle memory) - // Note: intentionally works even when focus is inside an input/textarea. - if ( - selectedAgentId && - toggleFileExplorer && - event.ctrlKey && - !event.metaKey && - (event.code === "Backquote" || key === "`") - ) { - // Ignore while command center is open. - if (useKeyboardNavStore.getState().commandCenterOpen) { - return; - } - event.preventDefault(); - toggleFileExplorer(); - return; - } - - // Cmd+K: command center - if (isMod && lowerKey === "k") { - event.preventDefault(); - const s = useKeyboardNavStore.getState(); - if (!s.commandCenterOpen) { - const target = - event.target instanceof Element ? (event.target as Element) : null; - const targetEl = - target?.closest?.("textarea, input, [contenteditable='true']") ?? - (target instanceof HTMLElement ? target : null); - const active = document.activeElement; - const activeEl = active instanceof HTMLElement ? active : null; - setCommandCenterFocusRestoreElement( - (targetEl as HTMLElement | null) ?? activeEl ?? null - ); - } - s.setCommandCenterOpen(!s.commandCenterOpen); - return; - } - - // Number switching: ignore while command center is open. - if (useKeyboardNavStore.getState().commandCenterOpen) { - return; - } - - const digit = parseShortcutDigit(event); - if (!digit) { - return; - } - - // Alt/Option+number: always (web + Tauri) - if (event.altKey) { - event.preventDefault(); - navigateToSidebarShortcut(digit); - return; - } - - // Cmd/Ctrl+number: Tauri only (avoid browser tab switching) - if (isTauri && isMod) { - event.preventDefault(); - navigateToSidebarShortcut(digit); - } - }; - - const handleKeyUp = (event: KeyboardEvent) => { - const key = event.key ?? ""; - if (key === "Alt") { - useKeyboardNavStore.getState().setAltDown(false); - } - if (isTauri && (key === "Meta" || key === "Control")) { - useKeyboardNavStore.getState().setCmdOrCtrlDown(false); - } - }; - - const handleBlurOrHide = () => { - resetModifiers(); - }; - - // react-native-web can stop propagation on key events, so listen in capture phase. - window.addEventListener("keydown", handleKeyDown, true); - window.addEventListener("keyup", handleKeyUp, true); - window.addEventListener("blur", handleBlurOrHide); - document.addEventListener("visibilitychange", handleBlurOrHide); - return () => { - window.removeEventListener("keydown", handleKeyDown, true); - window.removeEventListener("keyup", handleKeyUp, true); - window.removeEventListener("blur", handleBlurOrHide); - document.removeEventListener("visibilitychange", handleBlurOrHide); - }; - }, [ - enabled, - isMobile, - pathname, - resetModifiers, - router, - selectedAgentId, - toggleAgentList, - toggleFileExplorer, - ]); -} diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts new file mode 100644 index 000000000..1fb6ee500 --- /dev/null +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -0,0 +1,281 @@ +import { useEffect } from "react"; +import { Platform } from "react-native"; +import { usePathname, useRouter } from "expo-router"; +import { getIsTauri } from "@/constants/layout"; +import { useSessionStore } from "@/stores/session-store"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; +import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; +import { + checkoutStatusQueryKey, + type CheckoutStatusPayload, +} from "@/hooks/use-checkout-status-query"; +import { queryClient } from "@/query/query-client"; +import { + buildNewAgentRoute, + resolveNewAgentWorkingDir, +} from "@/utils/new-agent-routing"; +import { + buildHostAgentDetailRoute, + parseHostAgentRouteFromPathname, + parseServerIdFromPathname, +} from "@/utils/host-routes"; +import { + type MessageInputKeyboardActionKind, + type KeyboardShortcutPayload, +} from "@/keyboard/actions"; +import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts"; +import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; +import { getShortcutOs } from "@/utils/shortcut-platform"; + +function resolveSelectedOrRouteAgentKey(input: { + selectedAgentId?: string; + pathname: string; +}): string | null { + if (input.selectedAgentId) { + return input.selectedAgentId; + } + const route = parseHostAgentRouteFromPathname(input.pathname); + if (!route) { + return null; + } + return `${route.serverId}:${route.agentId}`; +} + +export function useKeyboardShortcuts({ + enabled, + isMobile, + toggleAgentList, + selectedAgentId, + toggleFileExplorer, +}: { + enabled: boolean; + isMobile: boolean; + toggleAgentList: () => void; + selectedAgentId?: string; + toggleFileExplorer?: () => void; +}) { + const router = useRouter(); + const pathname = usePathname(); + const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); + + useEffect(() => { + if (!enabled) return; + if (Platform.OS !== "web") return; + if (isMobile) return; + + const isTauri = getIsTauri(); + const isMac = getShortcutOs() === "mac"; + + const shouldHandle = () => { + if (typeof document === "undefined") return false; + if (document.visibilityState !== "visible") return false; + return true; + }; + + const navigateToSidebarShortcut = (digit: number): boolean => { + const state = useKeyboardShortcutsStore.getState(); + const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null; + if (!targetKey) { + return false; + } + + const parsed = parseSidebarAgentKey(targetKey); + if (!parsed) { + return false; + } + const { serverId, agentId } = parsed; + + const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); + const navigate = shouldReplace ? router.replace : router.push; + navigate(buildHostAgentDetailRoute(serverId, agentId) as any); + return true; + }; + + const navigateToNewAgent = (): boolean => { + let targetServerId = parseServerIdFromPathname(pathname); + let targetWorkingDir: string | null = null; + if (selectedAgentId) { + const separatorIndex = selectedAgentId.indexOf(":"); + if (separatorIndex > 0) { + const serverId = selectedAgentId.slice(0, separatorIndex); + const agentId = selectedAgentId.slice(separatorIndex + 1); + targetServerId = serverId; + const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId); + const cwd = agent?.cwd?.trim(); + if (cwd) { + const checkout = + queryClient.getQueryData( + checkoutStatusQueryKey(serverId, cwd) + ) ?? null; + targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout); + } + } + } + + if (!targetServerId) { + const sessionServerIds = Object.keys(useSessionStore.getState().sessions); + targetServerId = sessionServerIds[0] ?? null; + } + + if (!targetServerId) { + return false; + } + + router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any); + return true; + }; + + const requestMessageInputAction = ( + kind: MessageInputKeyboardActionKind + ): boolean => { + const agentKey = resolveSelectedOrRouteAgentKey({ selectedAgentId, pathname }); + if (!agentKey) { + return false; + } + useKeyboardShortcutsStore.getState().requestMessageInputAction({ + agentKey, + kind, + }); + return true; + }; + + const handleAction = (input: { + action: string; + payload: KeyboardShortcutPayload; + event: KeyboardEvent; + }): boolean => { + switch (input.action) { + case "agent.new": + return navigateToNewAgent(); + case "sidebar.toggle.left": + toggleAgentList(); + return true; + case "sidebar.toggle.right": + if (!selectedAgentId || !toggleFileExplorer) { + return false; + } + toggleFileExplorer(); + return true; + case "sidebar.navigate.shortcut": + if (!input.payload || typeof input.payload !== "object" || !("digit" in input.payload)) { + return false; + } + return navigateToSidebarShortcut(input.payload.digit); + case "command-center.toggle": { + const store = useKeyboardShortcutsStore.getState(); + if (!store.commandCenterOpen) { + const target = + input.event.target instanceof Element ? (input.event.target as Element) : null; + const targetEl = + target?.closest?.("textarea, input, [contenteditable='true']") ?? + (target instanceof HTMLElement ? target : null); + const active = document.activeElement; + const activeEl = active instanceof HTMLElement ? active : null; + setCommandCenterFocusRestoreElement( + (targetEl as HTMLElement | null) ?? activeEl ?? null + ); + } + store.setCommandCenterOpen(!store.commandCenterOpen); + return true; + } + case "message-input.action": + if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) { + return false; + } + return requestMessageInputAction(input.payload.kind); + default: + return false; + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (!shouldHandle()) { + return; + } + + const key = event.key ?? ""; + if (key === "Alt" && !event.shiftKey) { + useKeyboardShortcutsStore.getState().setAltDown(true); + } + if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) { + useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true); + } + if (key === "Shift") { + const state = useKeyboardShortcutsStore.getState(); + if (state.altDown || state.cmdOrCtrlDown) { + state.resetModifiers(); + } + } + + const store = useKeyboardShortcutsStore.getState(); + const focusScope = resolveKeyboardFocusScope({ + target: event.target, + commandCenterOpen: store.commandCenterOpen, + }); + const match = resolveKeyboardShortcut({ + event, + context: { + isMac, + isTauri, + focusScope, + commandCenterOpen: store.commandCenterOpen, + hasSelectedAgent: Boolean(selectedAgentId && toggleFileExplorer), + }, + }); + if (!match) { + return; + } + + const handled = handleAction({ + action: match.action, + payload: match.payload, + event, + }); + if (!handled) { + return; + } + + if (match.preventDefault) { + event.preventDefault(); + } + if (match.stopPropagation) { + event.stopPropagation(); + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + const key = event.key ?? ""; + if (key === "Alt") { + useKeyboardShortcutsStore.getState().setAltDown(false); + } + if (isTauri && (key === "Meta" || key === "Control")) { + useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false); + } + }; + + const handleBlurOrHide = () => { + resetModifiers(); + }; + + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("keyup", handleKeyUp, true); + window.addEventListener("blur", handleBlurOrHide); + document.addEventListener("visibilitychange", handleBlurOrHide); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("keyup", handleKeyUp, true); + window.removeEventListener("blur", handleBlurOrHide); + document.removeEventListener("visibilitychange", handleBlurOrHide); + }; + }, [ + enabled, + isMobile, + pathname, + resetModifiers, + router, + selectedAgentId, + toggleAgentList, + toggleFileExplorer, + ]); +} diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts new file mode 100644 index 000000000..03e3dfe8e --- /dev/null +++ b/packages/app/src/keyboard/actions.ts @@ -0,0 +1,26 @@ +export type KeyboardFocusScope = + | "terminal" + | "message-input" + | "command-center" + | "editable" + | "other"; + +export type MessageInputKeyboardActionKind = + | "focus" + | "dictation-toggle" + | "dictation-cancel" + | "voice-toggle" + | "voice-mute-toggle"; + +export type KeyboardActionId = + | "agent.new" + | "sidebar.toggle.left" + | "sidebar.toggle.right" + | "sidebar.navigate.shortcut" + | "command-center.toggle" + | "message-input.action"; + +export type KeyboardShortcutPayload = + | { digit: number } + | { kind: MessageInputKeyboardActionKind } + | null; diff --git a/packages/app/src/keyboard/focus-scope.ts b/packages/app/src/keyboard/focus-scope.ts new file mode 100644 index 000000000..b6d36d960 --- /dev/null +++ b/packages/app/src/keyboard/focus-scope.ts @@ -0,0 +1,42 @@ +import type { KeyboardFocusScope } from "@/keyboard/actions"; + +export function resolveKeyboardFocusScope(input: { + target: EventTarget | null; + commandCenterOpen: boolean; +}): KeyboardFocusScope { + const { target, commandCenterOpen } = input; + if (!(target instanceof Element)) { + return commandCenterOpen ? "command-center" : "other"; + } + + if ( + target.closest("[data-testid='terminal-surface']") || + target.closest(".xterm") + ) { + return "terminal"; + } + + if ( + commandCenterOpen && + (target.closest("[data-testid='command-center-panel']") || + target.closest("[data-testid='command-center-input']")) + ) { + return "command-center"; + } + + if (target.closest("[data-testid='message-input-root']")) { + return "message-input"; + } + + const editable = target as HTMLElement; + if (editable.isContentEditable) { + return commandCenterOpen ? "command-center" : "editable"; + } + + const tag = target.tagName.toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") { + return commandCenterOpen ? "command-center" : "editable"; + } + + return commandCenterOpen ? "command-center" : "other"; +} diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts new file mode 100644 index 000000000..aabc6f66c --- /dev/null +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + resolveKeyboardShortcut, + type KeyboardShortcutContext, +} from "./keyboard-shortcuts"; + +function keyboardEvent(overrides: Partial): KeyboardEvent { + return { + key: "", + code: "", + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + repeat: false, + ...overrides, + } as KeyboardEvent; +} + +function shortcutContext( + overrides: Partial = {} +): KeyboardShortcutContext { + return { + isMac: false, + isTauri: false, + focusScope: "other", + commandCenterOpen: false, + hasSelectedAgent: true, + ...overrides, + }; +} + +describe("keyboard-shortcuts", () => { + it("matches Cmd+B sidebar toggle on macOS", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "b", + code: "KeyB", + metaKey: true, + }), + context: shortcutContext({ isMac: true }), + }); + + expect(match?.action).toBe("sidebar.toggle.left"); + }); + + it("does not bind Ctrl+B on non-mac", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "b", + code: "KeyB", + ctrlKey: true, + }), + context: shortcutContext({ isMac: false }), + }); + + expect(match).toBeNull(); + }); + + it("keeps Mod+. as sidebar toggle fallback", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: ".", + code: "Period", + ctrlKey: true, + }), + context: shortcutContext({ isMac: false }), + }); + + expect(match?.action).toBe("sidebar.toggle.left"); + }); + + it("routes Mod+D to message-input action outside terminal", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "d", + code: "KeyD", + metaKey: true, + }), + context: shortcutContext({ isMac: true, focusScope: "message-input" }), + }); + + expect(match?.action).toBe("message-input.action"); + expect(match?.payload).toEqual({ kind: "dictation-toggle" }); + }); + + it("does not route message-input actions when terminal is focused", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "d", + code: "KeyD", + metaKey: true, + }), + context: shortcutContext({ isMac: true, focusScope: "terminal" }), + }); + + expect(match).toBeNull(); + }); + + it("parses Alt+digit sidebar shortcut payload", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "2", + code: "Digit2", + altKey: true, + }), + context: shortcutContext(), + }); + + expect(match?.action).toBe("sidebar.navigate.shortcut"); + expect(match?.payload).toEqual({ digit: 2 }); + }); +}); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts new file mode 100644 index 000000000..e80cc233c --- /dev/null +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -0,0 +1,227 @@ +import type { + KeyboardActionId, + KeyboardFocusScope, + KeyboardShortcutPayload, + MessageInputKeyboardActionKind, +} from "@/keyboard/actions"; + +export type KeyboardShortcutContext = { + isMac: boolean; + isTauri: boolean; + focusScope: KeyboardFocusScope; + commandCenterOpen: boolean; + hasSelectedAgent: boolean; +}; + +export type KeyboardShortcutMatch = { + action: KeyboardActionId; + payload: KeyboardShortcutPayload; + preventDefault: boolean; + stopPropagation: boolean; +}; + +type KeyboardShortcutBinding = { + id: string; + action: KeyboardActionId; + matches: (event: KeyboardEvent) => boolean; + when: (context: KeyboardShortcutContext) => boolean; + payload?: (event: KeyboardEvent) => KeyboardShortcutPayload; + preventDefault?: boolean; + stopPropagation?: boolean; +}; + +function isMod(event: KeyboardEvent): boolean { + return event.metaKey || event.ctrlKey; +} + +function parseDigit(event: KeyboardEvent): number | null { + const code = event.code ?? ""; + if (code.startsWith("Digit")) { + const value = Number(code.slice("Digit".length)); + return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null; + } + if (code.startsWith("Numpad")) { + const value = Number(code.slice("Numpad".length)); + return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null; + } + const key = event.key ?? ""; + if (key >= "1" && key <= "9") { + return Number(key); + } + return null; +} + +function hasDigit(event: KeyboardEvent): boolean { + return parseDigit(event) !== null; +} + +function withMessageInputAction( + kind: MessageInputKeyboardActionKind +): (event: KeyboardEvent) => KeyboardShortcutPayload { + return () => ({ kind }); +} + +const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ + { + id: "agent-new-mod-alt-n", + action: "agent.new", + matches: (event) => + isMod(event) && + event.altKey && + !event.shiftKey && + (event.code === "KeyN" || event.key.toLowerCase() === "n"), + when: () => true, + }, + { + id: "agent-new-tauri-mod-n", + action: "agent.new", + matches: (event) => + isMod(event) && + !event.altKey && + !event.shiftKey && + (event.code === "KeyN" || event.key.toLowerCase() === "n"), + when: (context) => context.isTauri, + }, + { + id: "command-center-toggle", + action: "command-center.toggle", + matches: (event) => + isMod(event) && + !event.altKey && + !event.shiftKey && + (event.code === "KeyK" || event.key.toLowerCase() === "k"), + when: () => true, + }, + { + id: "sidebar-toggle-left-mac-cmd-b", + action: "sidebar.toggle.left", + matches: (event) => + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + (event.code === "KeyB" || event.key.toLowerCase() === "b"), + when: (context) => context.isMac, + }, + { + id: "sidebar-toggle-left-mod-period", + action: "sidebar.toggle.left", + matches: (event) => + isMod(event) && + !event.altKey && + !event.shiftKey && + (event.code === "Period" || event.key === "."), + when: (context) => !context.commandCenterOpen, + }, + { + id: "sidebar-toggle-right-mod-e", + action: "sidebar.toggle.right", + matches: (event) => + isMod(event) && + !event.altKey && + !event.shiftKey && + (event.code === "KeyE" || event.key.toLowerCase() === "e"), + when: (context) => context.hasSelectedAgent && !context.commandCenterOpen, + }, + { + id: "sidebar-toggle-right-ctrl-backquote", + action: "sidebar.toggle.right", + matches: (event) => + event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + (event.code === "Backquote" || event.key === "`"), + when: (context) => context.hasSelectedAgent && !context.commandCenterOpen, + }, + { + id: "message-input-voice-toggle", + action: "message-input.action", + matches: (event) => + isMod(event) && + event.shiftKey && + !event.altKey && + (event.code === "KeyD" || event.key.toLowerCase() === "d") && + !event.repeat, + payload: withMessageInputAction("voice-toggle"), + when: (context) => + !context.commandCenterOpen && context.focusScope !== "terminal", + }, + { + id: "message-input-dictation-toggle", + action: "message-input.action", + matches: (event) => + isMod(event) && + !event.shiftKey && + !event.altKey && + (event.code === "KeyD" || event.key.toLowerCase() === "d"), + payload: withMessageInputAction("dictation-toggle"), + when: (context) => + !context.commandCenterOpen && context.focusScope !== "terminal", + }, + { + id: "message-input-dictation-cancel", + action: "message-input.action", + matches: (event) => event.key === "Escape", + payload: withMessageInputAction("dictation-cancel"), + when: (context) => + !context.commandCenterOpen && context.focusScope !== "terminal", + }, + { + id: "message-input-voice-mute-toggle", + action: "message-input.action", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + (event.code === "Space" || event.key === " ") && + !event.repeat, + payload: withMessageInputAction("voice-mute-toggle"), + when: (context) => + !context.commandCenterOpen && context.focusScope !== "terminal", + }, + { + id: "sidebar-shortcut-alt-digit", + action: "sidebar.navigate.shortcut", + matches: (event) => event.altKey && hasDigit(event), + payload: (event) => { + const digit = parseDigit(event); + return digit ? { digit } : null; + }, + when: (context) => !context.commandCenterOpen, + }, + { + id: "sidebar-shortcut-tauri-mod-digit", + action: "sidebar.navigate.shortcut", + matches: (event) => isMod(event) && hasDigit(event), + payload: (event) => { + const digit = parseDigit(event); + return digit ? { digit } : null; + }, + when: (context) => context.isTauri && !context.commandCenterOpen, + }, +]; + +export function resolveKeyboardShortcut(input: { + event: KeyboardEvent; + context: KeyboardShortcutContext; +}): KeyboardShortcutMatch | null { + const { event, context } = input; + for (const binding of SHORTCUT_BINDINGS) { + if (!binding.matches(event)) { + continue; + } + if (!binding.when(context)) { + continue; + } + const payload = binding.payload?.(event) ?? null; + return { + action: binding.action, + payload, + preventDefault: binding.preventDefault ?? true, + stopPropagation: binding.stopPropagation ?? true, + }; + } + return null; +} diff --git a/packages/app/src/stores/keyboard-nav-store.ts b/packages/app/src/stores/keyboard-nav-store.ts deleted file mode 100644 index 30c35c5f9..000000000 --- a/packages/app/src/stores/keyboard-nav-store.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { create } from "zustand"; - -type FocusChatInputRequest = { - id: number; - agentKey: string; -}; - -interface KeyboardNavState { - commandCenterOpen: boolean; - altDown: boolean; - cmdOrCtrlDown: boolean; - /** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */ - sidebarShortcutAgentKeys: string[]; - - /** Web-only request to focus the MessageInput for the selected agent. */ - focusChatInputRequest: FocusChatInputRequest | null; - requestFocusChatInput: (agentKey: string) => void; - clearFocusChatInputRequest: () => void; - - setCommandCenterOpen: (open: boolean) => void; - setAltDown: (down: boolean) => void; - setCmdOrCtrlDown: (down: boolean) => void; - setSidebarShortcutAgentKeys: (keys: string[]) => void; - resetModifiers: () => void; -} - -export const useKeyboardNavStore = create((set, get) => ({ - commandCenterOpen: false, - altDown: false, - cmdOrCtrlDown: false, - sidebarShortcutAgentKeys: [], - - focusChatInputRequest: null, - requestFocusChatInput: (agentKey) => { - const prev = get().focusChatInputRequest; - const id = (prev?.id ?? 0) + 1; - set({ focusChatInputRequest: { id, agentKey } }); - }, - clearFocusChatInputRequest: () => set({ focusChatInputRequest: null }), - - setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), - setAltDown: (down) => set({ altDown: down }), - setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }), - setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }), - resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }), -})); - diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts new file mode 100644 index 000000000..1452a0b06 --- /dev/null +++ b/packages/app/src/stores/keyboard-shortcuts-store.ts @@ -0,0 +1,58 @@ +import { create } from "zustand"; +import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; + +export type MessageInputActionRequest = { + id: number; + agentKey: string; + kind: MessageInputKeyboardActionKind; +}; + +interface KeyboardShortcutsState { + commandCenterOpen: boolean; + altDown: boolean; + cmdOrCtrlDown: boolean; + /** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */ + sidebarShortcutAgentKeys: string[]; + messageInputActionRequest: MessageInputActionRequest | null; + + setCommandCenterOpen: (open: boolean) => void; + setAltDown: (down: boolean) => void; + setCmdOrCtrlDown: (down: boolean) => void; + setSidebarShortcutAgentKeys: (keys: string[]) => void; + resetModifiers: () => void; + + requestMessageInputAction: (input: { + agentKey: string; + kind: MessageInputKeyboardActionKind; + }) => void; + clearMessageInputActionRequest: (id: number) => void; +} + +export const useKeyboardShortcutsStore = create( + (set, get) => ({ + commandCenterOpen: false, + altDown: false, + cmdOrCtrlDown: false, + sidebarShortcutAgentKeys: [], + messageInputActionRequest: null, + + setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), + setAltDown: (down) => set({ altDown: down }), + setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }), + setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }), + resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }), + + requestMessageInputAction: ({ agentKey, kind }) => { + const previous = get().messageInputActionRequest; + const id = (previous?.id ?? 0) + 1; + set({ messageInputActionRequest: { id, agentKey, kind } }); + }, + clearMessageInputActionRequest: (id) => { + const current = get().messageInputActionRequest; + if (!current || current.id !== id) { + return; + } + set({ messageInputActionRequest: null }); + }, + }) +);