From 9da5b26db0b6cc734598ff4803a8a6272a9eca53 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 14 Feb 2026 20:18:28 +0700 Subject: [PATCH] feat(app): add global shortcut help dialog --- .../app/e2e/keyboard-shortcuts-dialog.spec.ts | 17 ++ packages/app/src/app/_layout.tsx | 2 + .../components/keyboard-shortcuts-dialog.tsx | 180 ++++++++++++++++++ .../app/src/hooks/use-keyboard-shortcuts.ts | 5 + packages/app/src/keyboard/actions.ts | 1 + .../src/keyboard/keyboard-shortcuts.test.ts | 51 +++++ .../app/src/keyboard/keyboard-shortcuts.ts | 19 +- .../src/stores/keyboard-shortcuts-store.ts | 4 + 8 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/keyboard-shortcuts-dialog.spec.ts create mode 100644 packages/app/src/components/keyboard-shortcuts-dialog.tsx diff --git a/packages/app/e2e/keyboard-shortcuts-dialog.spec.ts b/packages/app/e2e/keyboard-shortcuts-dialog.spec.ts new file mode 100644 index 000000000..d09b9f5f3 --- /dev/null +++ b/packages/app/e2e/keyboard-shortcuts-dialog.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from "./fixtures"; +import { gotoHome } from "./helpers/app"; + +test("question mark opens keyboard shortcuts dialog", async ({ page }) => { + await gotoHome(page); + await page.getByTestId("menu-button").first().focus(); + + await page.keyboard.press("Shift+/"); + + const dialog = page.getByTestId("keyboard-shortcuts-dialog"); + const content = page.getByTestId("keyboard-shortcuts-dialog-content"); + + await expect(dialog).toBeVisible({ timeout: 10000 }); + await expect(content).toBeVisible({ timeout: 10000 }); + await expect(content).toContainText("Show keyboard shortcuts"); + await expect(content).toContainText("Toggle left sidebar"); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 75bcc058f..dac040cba 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -36,6 +36,7 @@ import { import { getIsTauri, getIsTauriMac } from "@/constants/layout"; import { useTrafficLightPadding } from "@/utils/tauri-window"; import { CommandCenter } from "@/components/command-center"; +import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { queryClient } from "@/query/query-client"; import { @@ -255,6 +256,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { {isMobile && chromeEnabled && } + ); diff --git a/packages/app/src/components/keyboard-shortcuts-dialog.tsx b/packages/app/src/components/keyboard-shortcuts-dialog.tsx new file mode 100644 index 000000000..f68065323 --- /dev/null +++ b/packages/app/src/components/keyboard-shortcuts-dialog.tsx @@ -0,0 +1,180 @@ +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { getIsTauri } from "@/constants/layout"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { Shortcut } from "@/components/ui/shortcut"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import type { ShortcutKey } from "@/utils/format-shortcut"; +import { getShortcutOs } from "@/utils/shortcut-platform"; + +type ShortcutHelpRow = { + id: string; + label: string; + keys: ShortcutKey[]; + note?: string; +}; + +type ShortcutHelpSection = { + title: string; + rows: ShortcutHelpRow[]; +}; + +function buildShortcutHelpSections(input: { + isMac: boolean; + isTauri: boolean; +}): ShortcutHelpSection[] { + const leftSidebarKeys: ShortcutKey[] = input.isMac ? ["mod", "B"] : ["mod", "."]; + const newAgentKeys: ShortcutKey[] = input.isTauri + ? ["mod", "N"] + : ["mod", "alt", "N"]; + const quickAgentKeys: ShortcutKey[] = input.isTauri + ? ["mod", "1-9"] + : ["alt", "1-9"]; + + return [ + { + title: "Global", + rows: [ + { + id: "show-shortcuts", + label: "Show keyboard shortcuts", + keys: ["?"], + note: "Available when focus is not in a text field or terminal.", + }, + { + id: "toggle-left-sidebar", + label: "Toggle left sidebar", + keys: leftSidebarKeys, + }, + { + id: "toggle-right-sidebar", + label: "Toggle right sidebar", + keys: ["mod", "E"], + }, + { + id: "toggle-command-center", + label: "Toggle command center", + keys: ["mod", "K"], + }, + { + id: "new-agent", + label: "Create new agent", + keys: newAgentKeys, + }, + { + id: "quick-open-agent", + label: "Open sidebar agent shortcut", + keys: quickAgentKeys, + }, + ], + }, + { + title: "Agent Input", + rows: [ + { + id: "dictation-toggle", + label: "Start/stop dictation", + keys: ["mod", "D"], + }, + { + id: "voice-toggle", + label: "Toggle voice mode", + keys: ["mod", "shift", "D"], + }, + { + id: "voice-mute-toggle", + label: "Mute/unmute voice mode", + keys: ["Space"], + }, + ], + }, + ]; +} + +export function KeyboardShortcutsDialog() { + const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen); + const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen); + + const isMac = getShortcutOs() === "mac"; + const isTauri = getIsTauri(); + const sections = useMemo( + () => buildShortcutHelpSections({ isMac, isTauri }), + [isMac, isTauri] + ); + + return ( + setOpen(false)} + testID="keyboard-shortcuts-dialog" + snapPoints={["70%", "92%"]} + > + + {sections.map((section) => ( + + {section.title} + + {section.rows.map((row) => ( + + + {row.label} + {row.note ? {row.note} : null} + + + + ))} + + + ))} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + content: { + gap: theme.spacing[4], + }, + section: { + gap: theme.spacing[2], + }, + sectionTitle: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + rows: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + }, + row: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.surface2, + }, + rowText: { + flex: 1, + minWidth: 0, + }, + rowLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + rowNote: { + marginTop: 2, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + rowShortcut: { + alignSelf: "flex-start", + }, +})); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 1fb6ee500..50efb0a29 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -179,6 +179,11 @@ export function useKeyboardShortcuts({ store.setCommandCenterOpen(!store.commandCenterOpen); return true; } + case "shortcuts.dialog.toggle": { + const store = useKeyboardShortcutsStore.getState(); + store.setShortcutsDialogOpen(!store.shortcutsDialogOpen); + return true; + } case "message-input.action": if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) { return false; diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 03e3dfe8e..a626d5c60 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -18,6 +18,7 @@ export type KeyboardActionId = | "sidebar.toggle.right" | "sidebar.navigate.shortcut" | "command-center.toggle" + | "shortcuts.dialog.toggle" | "message-input.action"; export type KeyboardShortcutPayload = diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index aabc6f66c..fe14d5d47 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -31,6 +31,32 @@ function shortcutContext( } describe("keyboard-shortcuts", () => { + it("matches question-mark shortcut to toggle the shortcuts dialog", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "?", + code: "Slash", + shiftKey: true, + }), + context: shortcutContext({ focusScope: "other" }), + }); + + expect(match?.action).toBe("shortcuts.dialog.toggle"); + }); + + it("does not match question-mark shortcut inside editable scopes", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "?", + code: "Slash", + shiftKey: true, + }), + context: shortcutContext({ focusScope: "message-input" }), + }); + + expect(match).toBeNull(); + }); + it("matches Cmd+B sidebar toggle on macOS", () => { const match = resolveKeyboardShortcut({ event: keyboardEvent({ @@ -97,6 +123,31 @@ describe("keyboard-shortcuts", () => { expect(match).toBeNull(); }); + it("keeps space typing available in message input", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: " ", + code: "Space", + }), + context: shortcutContext({ focusScope: "message-input" }), + }); + + expect(match).toBeNull(); + }); + + it("routes space to voice mute toggle outside editable scopes", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: " ", + code: "Space", + }), + context: shortcutContext({ focusScope: "other" }), + }); + + expect(match?.action).toBe("message-input.action"); + expect(match?.payload).toEqual({ kind: "voice-mute-toggle" }); + }); + it("parses Alt+digit sidebar shortcut payload", () => { const match = resolveKeyboardShortcut({ event: keyboardEvent({ diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index e80cc233c..2e63e657a 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -55,6 +55,17 @@ function hasDigit(event: KeyboardEvent): boolean { return parseDigit(event) !== null; } +function isQuestionMarkShortcut(event: KeyboardEvent): boolean { + return ( + !event.metaKey && + !event.ctrlKey && + !event.altKey && + event.shiftKey && + !event.repeat && + (event.key === "?" || event.code === "Slash") + ); +} + function withMessageInputAction( kind: MessageInputKeyboardActionKind ): (event: KeyboardEvent) => KeyboardShortcutPayload { @@ -92,6 +103,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ (event.code === "KeyK" || event.key.toLowerCase() === "k"), when: () => true, }, + { + id: "shortcuts-dialog-toggle-question-mark", + action: "shortcuts.dialog.toggle", + matches: isQuestionMarkShortcut, + when: (context) => context.focusScope === "other", + }, { id: "sidebar-toggle-left-mac-cmd-b", action: "sidebar.toggle.left", @@ -179,7 +196,7 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ !event.repeat, payload: withMessageInputAction("voice-mute-toggle"), when: (context) => - !context.commandCenterOpen && context.focusScope !== "terminal", + !context.commandCenterOpen && context.focusScope === "other", }, { id: "sidebar-shortcut-alt-digit", diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts index 1452a0b06..dd0609438 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.ts @@ -9,6 +9,7 @@ export type MessageInputActionRequest = { interface KeyboardShortcutsState { commandCenterOpen: boolean; + shortcutsDialogOpen: boolean; altDown: boolean; cmdOrCtrlDown: boolean; /** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */ @@ -16,6 +17,7 @@ interface KeyboardShortcutsState { messageInputActionRequest: MessageInputActionRequest | null; setCommandCenterOpen: (open: boolean) => void; + setShortcutsDialogOpen: (open: boolean) => void; setAltDown: (down: boolean) => void; setCmdOrCtrlDown: (down: boolean) => void; setSidebarShortcutAgentKeys: (keys: string[]) => void; @@ -31,12 +33,14 @@ interface KeyboardShortcutsState { export const useKeyboardShortcutsStore = create( (set, get) => ({ commandCenterOpen: false, + shortcutsDialogOpen: false, altDown: false, cmdOrCtrlDown: false, sidebarShortcutAgentKeys: [], messageInputActionRequest: null, setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), + setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }), setAltDown: (down) => set({ altDown: down }), setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }), setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),