From ad1cb4c20082b9e01231dc4a68980080f434925f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 21 Mar 2026 23:14:59 +0700 Subject: [PATCH] Add keyboard shortcut rebinding with string-based format and sectioned settings UI - Parse/format shortcut strings ("Cmd+Shift+O") bidirectionally via shortcut-string.ts - Convert all 60+ bindings from structured KeyCombo objects to declarative strings - Add keyboard shortcut overrides storage (AsyncStorage + React Query) - Wire overrides into the keyboard shortcut resolution pipeline - Add sectioned settings layout with sidebar on desktop, single scroll on mobile - Add keyboard shortcuts settings section with rebind/reset UI and key capture - Create useShortcutKeys hook so tooltip shortcut displays update on rebind - Replace hardcoded shortcut displays in agent-input-area, left-sidebar, command center --- .../app/src/components/agent-input-area.tsx | 4 +- packages/app/src/components/left-sidebar.tsx | 5 +- packages/app/src/hooks/use-command-center.ts | 29 +- .../hooks/use-keyboard-shortcut-overrides.ts | 77 + .../app/src/hooks/use-keyboard-shortcuts.ts | 9 +- packages/app/src/hooks/use-shortcut-keys.ts | 26 + .../app/src/keyboard/keyboard-shortcuts.ts | 210 ++- packages/app/src/keyboard/shortcut-string.ts | 179 ++ packages/app/src/screens/settings-screen.tsx | 1463 +++++++++++------ .../settings/keyboard-shortcuts-section.tsx | 217 +++ 10 files changed, 1607 insertions(+), 612 deletions(-) create mode 100644 packages/app/src/hooks/use-keyboard-shortcut-overrides.ts create mode 100644 packages/app/src/hooks/use-shortcut-keys.ts create mode 100644 packages/app/src/keyboard/shortcut-string.ts create mode 100644 packages/app/src/screens/settings/keyboard-shortcuts-section.tsx diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index a44f30bb3..c16f817e5 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -28,6 +28,7 @@ import { useVoiceOptional } from "@/contexts/voice-context"; import { useToast } from "@/contexts/toast-context"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { Autocomplete } from "@/components/ui/autocomplete"; import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete"; import { @@ -118,6 +119,7 @@ export function AgentInputArea({ const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); const toast = useToast(); const voice = useVoiceOptional(); + const voiceToggleKeys = useShortcutKeys("voice-toggle"); const isDictationReady = isConnected && (agentDirectoryStatus === "ready" || @@ -623,7 +625,7 @@ export function AgentInputArea({ Voice mode - + {voiceToggleKeys && } diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index a80448bc0..4eae2efaa 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -24,6 +24,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { MessagesSquare, Plus, Settings } from "lucide-react-native"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { router, usePathname } from "expo-router"; import { usePanelStore } from "@/stores/panel-store"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; @@ -353,6 +354,7 @@ function MobileSidebar({ closeToAgent, handleViewMoreNavigate, }: MobileSidebarProps) { + const newAgentKeys = useShortcutKeys("new-agent"); const { translateX, backdropOpacity, @@ -562,7 +564,7 @@ function MobileSidebar({ Add project - + {newAgentKeys && } @@ -626,6 +628,7 @@ function DesktopSidebar({ isOpen, handleViewMore, }: DesktopSidebarProps) { + const newAgentKeys = useShortcutKeys("new-agent"); const dragHandlers = useDesktopDragHandlers(); const trafficLightPadding = useTrafficLightPadding(); const hostStatusDotStyle = useMemo( diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index daabe6a35..ac8c3b432 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -13,6 +13,11 @@ import { } from "@/utils/command-center-focus-restore"; import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; +import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string"; +import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; +import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import { getIsDesktop } from "@/constants/layout"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { focusWithRetries } from "@/utils/web-focus"; @@ -48,7 +53,7 @@ type CommandCenterActionDefinition = { id: string; title: string; icon?: "plus" | "settings"; - shortcutKeys?: ShortcutKey[]; + actionId?: string; keywords: string[]; routeKind: "settings" | "none"; }; @@ -58,7 +63,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ id: "new-agent", title: "Open project", icon: "plus", - shortcutKeys: ["mod", "shift", "O"], + actionId: "new-agent", keywords: ["open", "project", "folder", "workspace", "repo"], routeKind: "none", }, @@ -99,9 +104,25 @@ export type CommandCenterItem = agent: AggregatedAgent; }; +function resolveActionShortcutKeys( + actionId: string | undefined, + overrides: Record, +): ShortcutKey[] | undefined { + if (!actionId) return undefined; + const isMac = getShortcutOs() === "mac"; + const isDesktop = getIsDesktop(); + const platform = { isMac, isDesktop }; + const bindingId = getBindingIdForAction(actionId, platform); + if (!bindingId) return undefined; + const override = overrides[bindingId]; + if (override) return comboStringToShortcutKeys(override); + return getDefaultKeysForAction(actionId, platform) ?? undefined; +} + export function useCommandCenter() { const pathname = usePathname(); const daemons = useHosts(); + const { overrides } = useKeyboardShortcutOverrides(); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); const inputRef = useRef(null); @@ -158,9 +179,9 @@ export function useCommandCenter() { title: action.title, icon: action.icon, route: action.routeKind === "settings" ? settingsRoute : undefined, - shortcutKeys: action.shortcutKeys, + shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides), })); - }, [open, query, settingsRoute]); + }, [open, query, settingsRoute, overrides]); const items = useMemo(() => { if (!open) { diff --git a/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts new file mode 100644 index 000000000..de337f8af --- /dev/null +++ b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts @@ -0,0 +1,77 @@ +import { useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides"; +const QUERY_KEY = ["keyboard-shortcut-overrides"]; + +const EMPTY_OVERRIDES: Record = {}; + +export interface UseKeyboardShortcutOverridesReturn { + overrides: Record; + isLoading: boolean; + setOverride: (bindingId: string, comboString: string) => Promise; + removeOverride: (bindingId: string) => Promise; + resetAll: () => Promise; + hasOverrides: boolean; +} + +export function useKeyboardShortcutOverrides(): UseKeyboardShortcutOverridesReturn { + const queryClient = useQueryClient(); + const { data, isPending } = useQuery({ + queryKey: QUERY_KEY, + queryFn: loadOverridesFromStorage, + staleTime: Infinity, + gcTime: Infinity, + }); + + const setOverride = useCallback( + async (bindingId: string, comboString: string) => { + const prev = + queryClient.getQueryData>(QUERY_KEY) ?? EMPTY_OVERRIDES; + const next = { ...prev, [bindingId]: comboString }; + queryClient.setQueryData>(QUERY_KEY, next); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + }, + [queryClient], + ); + + const removeOverride = useCallback( + async (bindingId: string) => { + const prev = + queryClient.getQueryData>(QUERY_KEY) ?? EMPTY_OVERRIDES; + const { [bindingId]: _, ...next } = prev; + queryClient.setQueryData>(QUERY_KEY, next); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + }, + [queryClient], + ); + + const resetAll = useCallback(async () => { + queryClient.setQueryData>(QUERY_KEY, EMPTY_OVERRIDES); + await AsyncStorage.removeItem(STORAGE_KEY); + }, [queryClient]); + + const overrides = data ?? EMPTY_OVERRIDES; + + return { + overrides, + isLoading: isPending, + setOverride, + removeOverride, + resetAll, + hasOverrides: Object.keys(overrides).length > 0, + }; +} + +async function loadOverridesFromStorage(): Promise> { + try { + const stored = await AsyncStorage.getItem(STORAGE_KEY); + if (stored) { + return JSON.parse(stored) as Record; + } + } catch (err) { + console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err); + } + return EMPTY_OVERRIDES; +} diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 28d0219b8..02c931d1c 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Platform } from "react-native"; import { usePathname } from "expo-router"; import { getIsDesktop } from "@/constants/layout"; @@ -17,10 +17,11 @@ import { } from "@/keyboard/actions"; import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; -import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts"; +import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts"; import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; export function useKeyboardShortcuts({ enabled, @@ -38,6 +39,8 @@ export function useKeyboardShortcuts({ const pathname = usePathname(); const hosts = useHosts(); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); + const { overrides } = useKeyboardShortcutOverrides(); + const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); const activeServerIdFromPath = parseServerIdFromPathname(pathname); const activeServerId = hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ?? @@ -285,6 +288,7 @@ export function useKeyboardShortcuts({ toggleFileExplorer, }), }, + bindings, }); if (!match) { return; @@ -332,6 +336,7 @@ export function useKeyboardShortcuts({ document.removeEventListener("visibilitychange", handleBlurOrHide); }; }, [ + bindings, enabled, isMobile, openProjectPickerAction, diff --git a/packages/app/src/hooks/use-shortcut-keys.ts b/packages/app/src/hooks/use-shortcut-keys.ts new file mode 100644 index 000000000..2cfa3044e --- /dev/null +++ b/packages/app/src/hooks/use-shortcut-keys.ts @@ -0,0 +1,26 @@ +import { useMemo } from "react"; +import type { ShortcutKey } from "@/utils/format-shortcut"; +import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string"; +import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; +import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import { getIsDesktop } from "@/constants/layout"; + +export function useShortcutKeys(actionId: string): ShortcutKey[] | null { + const { overrides } = useKeyboardShortcutOverrides(); + const isMac = getShortcutOs() === "mac"; + const isDesktop = getIsDesktop(); + + return useMemo(() => { + const platform = { isMac, isDesktop }; + const bindingId = getBindingIdForAction(actionId, platform); + if (!bindingId) return null; + + const override = overrides[bindingId]; + if (override) { + return comboStringToShortcutKeys(override); + } + + return getDefaultKeysForAction(actionId, platform); + }, [actionId, overrides, isMac, isDesktop]); +} diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 4bdd33e9f..8794966e7 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -5,6 +5,9 @@ import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind, } from "@/keyboard/actions"; +import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string"; + +export type { KeyCombo } from "@/keyboard/shortcut-string"; // --- Public types --- @@ -43,18 +46,6 @@ type KeyboardShortcutPlatformContext = { isDesktop: boolean; }; -interface KeyCombo { - /** Event.code to match. Use "Digit" for any digit 1-9. */ - code: string; - /** Optional event.key fallback (case-insensitive). */ - key?: string; - meta?: true; - ctrl?: true; - alt?: true; - shift?: true; - /** Set to false to block key repeat events. */ - repeat?: false; -} interface ShortcutWhen { /** true = mac only, false = non-mac only */ @@ -87,7 +78,8 @@ interface ShortcutHelp { interface ShortcutBinding { id: string; action: KeyboardActionId; - combo: KeyCombo; + combo: string; + repeat?: false; when?: ShortcutWhen; payload?: ShortcutPayloadDef; preventDefault?: boolean; @@ -95,6 +87,10 @@ interface ShortcutBinding { help?: ShortcutHelp; } +export interface ParsedShortcutBinding extends ShortcutBinding { + parsedCombo: KeyCombo; +} + // --- Constants --- const SHORTCUT_HELP_SECTION_TITLES: Record = { @@ -109,7 +105,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "agent-new-cmd-shift-o-mac", action: "agent.new", - combo: { code: "KeyO", key: "o", meta: true, shift: true }, + combo: "Cmd+Shift+O", when: { mac: true }, help: { id: "new-agent", @@ -121,7 +117,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "agent-new-ctrl-shift-o-non-mac", action: "agent.new", - combo: { code: "KeyO", key: "o", ctrl: true, shift: true }, + combo: "Ctrl+Shift+O", when: { mac: false, terminal: false }, help: { id: "new-agent", @@ -135,7 +131,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-tab-new-cmd-t-mac", action: "workspace.tab.new", - combo: { code: "KeyT", key: "t", meta: true }, + combo: "Cmd+T", when: { mac: true, commandCenter: false }, help: { id: "workspace-tab-new", @@ -147,7 +143,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-tab-new-ctrl-t-non-mac", action: "workspace.tab.new", - combo: { code: "KeyT", key: "t", ctrl: true }, + combo: "Ctrl+T", when: { mac: false, commandCenter: false, terminal: false }, help: { id: "workspace-tab-new", @@ -159,7 +155,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-tab-close-current-cmd-w-mac", action: "workspace.tab.close.current", - combo: { code: "KeyW", key: "w", meta: true }, + combo: "Cmd+W", when: { mac: true, desktop: true, commandCenter: false }, help: { id: "workspace-tab-close-current", @@ -171,7 +167,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-tab-close-current-ctrl-w-non-mac", action: "workspace.tab.close.current", - combo: { code: "KeyW", key: "w", ctrl: true }, + combo: "Ctrl+W", when: { mac: false, desktop: true, commandCenter: false, terminal: false }, help: { id: "workspace-tab-close-current", @@ -183,7 +179,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-tab-close-current-alt-shift-w-web", action: "workspace.tab.close.current", - combo: { code: "KeyW", key: "w", alt: true, shift: true }, + combo: "Alt+Shift+W", when: { desktop: false, commandCenter: false }, help: { id: "workspace-tab-close-current", @@ -197,7 +193,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-navigate-index-cmd-digit-mac", action: "workspace.navigate.index", - combo: { code: "Digit", meta: true }, + combo: "Cmd+Digit", when: { mac: true, desktop: true, commandCenter: false }, payload: { type: "index" }, help: { @@ -210,7 +206,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-navigate-index-ctrl-digit-non-mac", action: "workspace.navigate.index", - combo: { code: "Digit", ctrl: true }, + combo: "Ctrl+Digit", when: { mac: false, desktop: true, commandCenter: false, terminal: false }, payload: { type: "index" }, help: { @@ -223,7 +219,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-navigate-index-alt-digit-web", action: "workspace.navigate.index", - combo: { code: "Digit", alt: true }, + combo: "Alt+Digit", when: { desktop: false, commandCenter: false }, payload: { type: "index" }, help: { @@ -238,7 +234,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-tab-navigate-index-alt-digit-desktop", action: "workspace.tab.navigate.index", - combo: { code: "Digit", alt: true }, + combo: "Alt+Digit", when: { desktop: true, commandCenter: false }, payload: { type: "index" }, help: { @@ -251,7 +247,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-tab-navigate-index-alt-shift-digit-web", action: "workspace.tab.navigate.index", - combo: { code: "Digit", alt: true, shift: true }, + combo: "Alt+Shift+Digit", when: { desktop: false, commandCenter: false }, payload: { type: "index" }, help: { @@ -266,7 +262,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-navigate-relative-cmd-left-mac", action: "workspace.navigate.relative", - combo: { code: "BracketLeft", key: "[", meta: true }, + combo: "Cmd+[", when: { mac: true, desktop: true, commandCenter: false }, payload: { type: "delta", delta: -1 }, help: { @@ -279,7 +275,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-navigate-relative-ctrl-left-non-mac", action: "workspace.navigate.relative", - combo: { code: "BracketLeft", key: "[", ctrl: true }, + combo: "Ctrl+[", when: { mac: false, desktop: true, commandCenter: false, terminal: false }, payload: { type: "delta", delta: -1 }, help: { @@ -292,7 +288,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-navigate-relative-cmd-right-mac", action: "workspace.navigate.relative", - combo: { code: "BracketRight", key: "]", meta: true }, + combo: "Cmd+]", when: { mac: true, desktop: true, commandCenter: false }, payload: { type: "delta", delta: 1 }, help: { @@ -305,7 +301,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-navigate-relative-ctrl-right-non-mac", action: "workspace.navigate.relative", - combo: { code: "BracketRight", key: "]", ctrl: true }, + combo: "Ctrl+]", when: { mac: false, desktop: true, commandCenter: false, terminal: false }, payload: { type: "delta", delta: 1 }, help: { @@ -318,7 +314,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-navigate-relative-alt-left-web", action: "workspace.navigate.relative", - combo: { code: "BracketLeft", key: "[", alt: true }, + combo: "Alt+[", when: { desktop: false, commandCenter: false }, payload: { type: "delta", delta: -1 }, help: { @@ -331,7 +327,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-navigate-relative-alt-right-web", action: "workspace.navigate.relative", - combo: { code: "BracketRight", key: "]", alt: true }, + combo: "Alt+]", when: { desktop: false, commandCenter: false }, payload: { type: "delta", delta: 1 }, help: { @@ -346,7 +342,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-tab-navigate-relative-alt-shift-left", action: "workspace.tab.navigate.relative", - combo: { code: "BracketLeft", alt: true, shift: true }, + combo: "Alt+Shift+[", when: { commandCenter: false }, payload: { type: "delta", delta: -1 }, help: { @@ -359,7 +355,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-tab-navigate-relative-alt-shift-right", action: "workspace.tab.navigate.relative", - combo: { code: "BracketRight", alt: true, shift: true }, + combo: "Alt+Shift+]", when: { commandCenter: false }, payload: { type: "delta", delta: 1 }, help: { @@ -374,7 +370,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-split-right-cmd-backslash", action: "workspace.pane.split.right", - combo: { code: "Backslash", meta: true }, + combo: "Cmd+\\", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-split-right", @@ -386,7 +382,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-split-down-cmd-shift-backslash", action: "workspace.pane.split.down", - combo: { code: "Backslash", meta: true, shift: true }, + combo: "Cmd+Shift+\\", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-split-down", @@ -398,7 +394,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-focus-left-cmd-shift-left", action: "workspace.pane.focus.left", - combo: { code: "ArrowLeft", meta: true, shift: true }, + combo: "Cmd+Shift+ArrowLeft", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-focus-left", @@ -410,7 +406,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-focus-right-cmd-shift-right", action: "workspace.pane.focus.right", - combo: { code: "ArrowRight", meta: true, shift: true }, + combo: "Cmd+Shift+ArrowRight", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-focus-right", @@ -422,7 +418,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-focus-up-cmd-shift-up", action: "workspace.pane.focus.up", - combo: { code: "ArrowUp", meta: true, shift: true }, + combo: "Cmd+Shift+ArrowUp", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-focus-up", @@ -434,7 +430,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-focus-down-cmd-shift-down", action: "workspace.pane.focus.down", - combo: { code: "ArrowDown", meta: true, shift: true }, + combo: "Cmd+Shift+ArrowDown", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-focus-down", @@ -446,7 +442,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-move-tab-left-cmd-shift-alt-left", action: "workspace.pane.move-tab.left", - combo: { code: "ArrowLeft", meta: true, alt: true, shift: true }, + combo: "Cmd+Alt+Shift+ArrowLeft", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-move-tab-left", @@ -458,7 +454,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-move-tab-right-cmd-shift-alt-right", action: "workspace.pane.move-tab.right", - combo: { code: "ArrowRight", meta: true, alt: true, shift: true }, + combo: "Cmd+Alt+Shift+ArrowRight", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-move-tab-right", @@ -470,7 +466,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-move-tab-up-cmd-shift-alt-up", action: "workspace.pane.move-tab.up", - combo: { code: "ArrowUp", meta: true, alt: true, shift: true }, + combo: "Cmd+Alt+Shift+ArrowUp", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-move-tab-up", @@ -482,7 +478,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-move-tab-down-cmd-shift-alt-down", action: "workspace.pane.move-tab.down", - combo: { code: "ArrowDown", meta: true, alt: true, shift: true }, + combo: "Cmd+Alt+Shift+ArrowDown", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-move-tab-down", @@ -494,7 +490,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "workspace-pane-close-cmd-shift-w", action: "workspace.pane.close", - combo: { code: "KeyW", key: "w", meta: true, shift: true }, + combo: "Cmd+Shift+W", when: { mac: true, terminal: false, commandCenter: false }, help: { id: "workspace-pane-close", @@ -508,7 +504,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "command-center-toggle-cmd-k-mac", action: "command-center.toggle", - combo: { code: "KeyK", key: "k", meta: true }, + combo: "Cmd+K", when: { mac: true }, help: { id: "toggle-command-center", @@ -520,7 +516,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "command-center-toggle-ctrl-k-non-mac", action: "command-center.toggle", - combo: { code: "KeyK", key: "k", ctrl: true }, + combo: "Ctrl+K", when: { mac: false, terminal: false }, help: { id: "toggle-command-center", @@ -534,7 +530,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "shortcuts-dialog-toggle-question-mark", action: "shortcuts.dialog.toggle", - combo: { code: "Slash", key: "?", shift: true, repeat: false }, + combo: "Shift+?", + repeat: false, when: { focusScope: "other" }, help: { id: "show-shortcuts", @@ -549,7 +546,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "sidebar-toggle-left-mac-cmd-b", action: "sidebar.toggle.left", - combo: { code: "KeyB", key: "b", meta: true }, + combo: "Cmd+B", when: { mac: true }, help: { id: "toggle-left-sidebar", @@ -561,13 +558,13 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "sidebar-toggle-left-cmd-period-mac", action: "sidebar.toggle.left", - combo: { code: "Period", key: ".", meta: true }, + combo: "Cmd+.", when: { mac: true, commandCenter: false }, }, { id: "sidebar-toggle-left-ctrl-period-non-mac", action: "sidebar.toggle.left", - combo: { code: "Period", key: ".", ctrl: true }, + combo: "Ctrl+.", when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-left-sidebar", @@ -579,7 +576,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "sidebar-toggle-right-cmd-e-mac", action: "sidebar.toggle.right", - combo: { code: "KeyE", key: "e", meta: true }, + combo: "Cmd+E", when: { mac: true, hasSelectedAgent: true, commandCenter: false }, help: { id: "toggle-right-sidebar", @@ -591,7 +588,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "sidebar-toggle-right-ctrl-e-non-mac", action: "sidebar.toggle.right", - combo: { code: "KeyE", key: "e", ctrl: true }, + combo: "Ctrl+E", when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false }, help: { id: "toggle-right-sidebar", @@ -603,7 +600,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "sidebar-toggle-right-ctrl-backquote", action: "sidebar.toggle.right", - combo: { code: "Backquote", key: "`", ctrl: true }, + combo: "Ctrl+`", when: { hasSelectedAgent: true, commandCenter: false }, }, @@ -611,7 +608,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "message-input-voice-toggle-cmd-shift-d-mac", action: "message-input.action", - combo: { code: "KeyD", key: "d", meta: true, shift: true, repeat: false }, + combo: "Cmd+Shift+D", + repeat: false, when: { mac: true, commandCenter: false, terminal: false }, payload: { type: "message-input", kind: "voice-toggle" }, help: { @@ -624,7 +622,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "message-input-voice-toggle-ctrl-shift-d-non-mac", action: "message-input.action", - combo: { code: "KeyD", key: "d", ctrl: true, shift: true, repeat: false }, + combo: "Ctrl+Shift+D", + repeat: false, when: { mac: false, commandCenter: false, terminal: false }, payload: { type: "message-input", kind: "voice-toggle" }, help: { @@ -637,7 +636,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "message-input-dictation-toggle-cmd-d-mac", action: "message-input.action", - combo: { code: "KeyD", key: "d", meta: true }, + combo: "Cmd+D", when: { mac: true, commandCenter: false, terminal: false }, payload: { type: "message-input", kind: "dictation-toggle" }, help: { @@ -650,7 +649,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "message-input-dictation-toggle-ctrl-d-non-mac", action: "message-input.action", - combo: { code: "KeyD", key: "d", ctrl: true }, + combo: "Ctrl+D", when: { mac: false, commandCenter: false, terminal: false }, payload: { type: "message-input", kind: "dictation-toggle" }, help: { @@ -663,7 +662,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "message-input-dictation-cancel", action: "message-input.action", - combo: { code: "Escape" }, + combo: "Escape", when: { commandCenter: false, terminal: false }, payload: { type: "message-input", kind: "dictation-cancel" }, preventDefault: false, @@ -678,7 +677,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "message-input-voice-mute-toggle", action: "message-input.action", - combo: { code: "Space", key: " ", repeat: false }, + combo: "Space", + repeat: false, when: { commandCenter: false, focusScope: "other" }, payload: { type: "message-input", kind: "voice-mute-toggle" }, help: { @@ -690,6 +690,40 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, ]; +// --- Parse bindings at module load --- + +function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding { + const parsedCombo = parseShortcutString(binding.combo); + if (binding.repeat === false) { + parsedCombo.repeat = false; + } + return { ...binding, parsedCombo }; +} + +export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] = + SHORTCUT_BINDINGS.map(parseBinding); + +export function buildEffectiveBindings( + overrides: Record, +): ParsedShortcutBinding[] { + return DEFAULT_BINDINGS.map(function (binding) { + const override = overrides[binding.id]; + if (override === undefined) { + return binding; + } + let parsedCombo: KeyCombo; + try { + parsedCombo = parseShortcutString(override); + } catch { + return binding; + } + if (binding.repeat === false) { + parsedCombo.repeat = false; + } + return { ...binding, combo: override, parsedCombo }; + }); +} + // --- Matching engine --- function parseDigit(event: KeyboardEvent): number | null { @@ -709,9 +743,19 @@ function parseDigit(event: KeyboardEvent): number | null { return null; } -function matchesCombo(combo: KeyCombo, event: KeyboardEvent): boolean { - if (!!combo.meta !== event.metaKey) return false; - if (!!combo.ctrl !== event.ctrlKey) return false; +function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): boolean { + if (combo.mod) { + if (isMac) { + if (!event.metaKey) return false; + if (!!combo.ctrl !== event.ctrlKey) return false; + } else { + if (!event.ctrlKey) return false; + if (!!combo.meta !== event.metaKey) return false; + } + } else { + if (!!combo.meta !== event.metaKey) return false; + if (!!combo.ctrl !== event.ctrlKey) return false; + } if (!!combo.alt !== event.altKey) return false; if (!!combo.shift !== event.shiftKey) return false; if (combo.repeat === false && event.repeat) return false; @@ -767,10 +811,11 @@ function helpMatchesPlatform( export function resolveKeyboardShortcut(input: { event: KeyboardEvent; context: KeyboardShortcutContext; + bindings?: readonly ParsedShortcutBinding[]; }): KeyboardShortcutMatch | null { - const { event, context } = input; - for (const binding of SHORTCUT_BINDINGS) { - if (!matchesCombo(binding.combo, event)) { + const { event, context, bindings = DEFAULT_BINDINGS } = input; + for (const binding of bindings) { + if (!matchesCombo(binding.parsedCombo, event, context.isMac)) { continue; } if (!matchesWhen(binding.when, context)) { @@ -786,8 +831,41 @@ export function resolveKeyboardShortcut(input: { return null; } +export function getBindingIdForAction( + actionId: string, + platform: { isMac: boolean; isDesktop: boolean }, +): string | null { + for (const binding of DEFAULT_BINDINGS) { + if (binding.help?.id !== actionId) { + continue; + } + if (!helpMatchesPlatform(binding.when, platform)) { + continue; + } + return binding.id; + } + return null; +} + +export function getDefaultKeysForAction( + actionId: string, + platform: { isMac: boolean; isDesktop: boolean }, +): ShortcutKey[] | null { + for (const binding of DEFAULT_BINDINGS) { + if (binding.help?.id !== actionId) { + continue; + } + if (!helpMatchesPlatform(binding.when, platform)) { + continue; + } + return binding.help.keys; + } + return null; +} + export function buildKeyboardShortcutHelpSections( input: KeyboardShortcutPlatformContext, + bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS, ): KeyboardShortcutHelpSection[] { const seenRows = new Set(); const rowsBySection = new Map([ @@ -795,7 +873,7 @@ export function buildKeyboardShortcutHelpSections( ["agent-input", []], ]); - for (const binding of SHORTCUT_BINDINGS) { + for (const binding of bindings) { const help = binding.help; if (!help) { continue; diff --git a/packages/app/src/keyboard/shortcut-string.ts b/packages/app/src/keyboard/shortcut-string.ts new file mode 100644 index 000000000..c84ee8588 --- /dev/null +++ b/packages/app/src/keyboard/shortcut-string.ts @@ -0,0 +1,179 @@ +import type { ShortcutKey } from "@/utils/format-shortcut"; + +export interface KeyCombo { + code: string; + key?: string; + meta?: true; + ctrl?: true; + alt?: true; + shift?: true; + mod?: true; + repeat?: false; +} + +interface KeyMapping { + code: string; + key?: string; +} + +const KEY_MAP: Record = {}; + +for (let i = 0; i < 26; i++) { + const letter = String.fromCharCode(65 + i); + KEY_MAP[letter] = { code: `Key${letter}`, key: letter.toLowerCase() }; +} + +for (let i = 0; i <= 9; i++) { + KEY_MAP[String(i)] = { code: `Digit${i}`, key: String(i) }; +} + +KEY_MAP["Digit"] = { code: "Digit" }; +KEY_MAP["\\"] = { code: "Backslash" }; +KEY_MAP["["] = { code: "BracketLeft", key: "[" }; +KEY_MAP["]"] = { code: "BracketRight", key: "]" }; +KEY_MAP["."] = { code: "Period", key: "." }; +KEY_MAP["`"] = { code: "Backquote", key: "`" }; +KEY_MAP["/"] = { code: "Slash" }; +KEY_MAP["?"] = { code: "Slash", key: "?" }; +KEY_MAP["Space"] = { code: "Space", key: " " }; +KEY_MAP["Escape"] = { code: "Escape" }; +KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" }; +KEY_MAP["ArrowRight"] = { code: "ArrowRight" }; +KEY_MAP["ArrowUp"] = { code: "ArrowUp" }; +KEY_MAP["ArrowDown"] = { code: "ArrowDown" }; + +const CODE_TO_KEY: Record = {}; +for (const [humanKey, mapping] of Object.entries(KEY_MAP)) { + if (!CODE_TO_KEY[mapping.code]) { + CODE_TO_KEY[mapping.code] = humanKey; + } +} + +export function parseShortcutString(s: string): KeyCombo { + const parts = s.split("+"); + if (parts.length === 0 || parts.some((p) => p === "")) { + throw new Error(`Invalid shortcut string: "${s}"`); + } + + const combo: KeyCombo = { code: "" }; + let keyPart: string | null = null; + + for (const part of parts) { + switch (part) { + case "Cmd": + combo.meta = true; + break; + case "Ctrl": + combo.ctrl = true; + break; + case "Alt": + combo.alt = true; + break; + case "Shift": + combo.shift = true; + break; + case "Mod": + combo.mod = true; + break; + default: + if (keyPart !== null) { + throw new Error(`Invalid shortcut string: "${s}" - multiple key parts`); + } + keyPart = part; + break; + } + } + + if (keyPart === null) { + throw new Error(`Invalid shortcut string: "${s}" - no key part`); + } + + const mapping = KEY_MAP[keyPart]; + if (!mapping) { + throw new Error(`Unknown key: "${keyPart}"`); + } + + combo.code = mapping.code; + if (mapping.key !== undefined) { + combo.key = mapping.key; + } + + return combo; +} + +export function keyComboToString(combo: KeyCombo): string { + const parts: string[] = []; + + if (combo.mod) { + parts.push("Mod"); + } + if (combo.ctrl) parts.push("Ctrl"); + if (combo.alt) parts.push("Alt"); + if (combo.shift) parts.push("Shift"); + if (combo.meta) parts.push("Cmd"); + + const humanKey = CODE_TO_KEY[combo.code]; + if (humanKey) { + parts.push(humanKey); + } + + return parts.join("+"); +} + +const MODIFIER_CODES = new Set([ + "MetaLeft", + "MetaRight", + "ControlLeft", + "ControlRight", + "AltLeft", + "AltRight", + "ShiftLeft", + "ShiftRight", +]); + +export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] { + const parts = comboString.split("+"); + const keys: ShortcutKey[] = []; + for (const part of parts) { + switch (part) { + case "Cmd": + keys.push("mod"); + break; + case "Ctrl": + keys.push("ctrl"); + break; + case "Alt": + keys.push("alt"); + break; + case "Shift": + keys.push("shift"); + break; + default: + keys.push(part.toUpperCase()); + break; + } + } + return keys; +} + +export function keyboardEventToComboString(event: KeyboardEvent): string | null { + if (MODIFIER_CODES.has(event.code)) { + return null; + } + + const parts: string[] = []; + + if (event.ctrlKey) parts.push("Ctrl"); + if (event.altKey) parts.push("Alt"); + if (event.shiftKey) parts.push("Shift"); + if (event.metaKey) parts.push("Cmd"); + + const humanKey = CODE_TO_KEY[event.code]; + if (humanKey) { + parts.push(humanKey); + } else { + return null; + } + + return parts.join("+"); +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 87d6fa0b5..4e2a7bdb2 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1,12 +1,26 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import type { MutableRefObject } from "react"; +import type { MutableRefObject, ComponentType } from "react"; import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native"; import { router, useLocalSearchParams } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Buffer } from "buffer"; -import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native"; +import { + Sun, + Moon, + Monitor, + Globe, + Settings, + RotateCw, + Trash2, + Server, + Palette, + Keyboard, + Stethoscope, + Info, + Shield, +} from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; @@ -24,6 +38,7 @@ import { import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; +import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section"; import { NameHostModal } from "@/components/name-host-modal"; import { Button } from "@/components/ui/button"; import { SegmentedControl } from "@/components/ui/segmented-control"; @@ -45,6 +60,48 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +// --------------------------------------------------------------------------- +// Section definitions +// --------------------------------------------------------------------------- + +type SettingsSectionId = + | "hosts" + | "appearance" + | "keyboard-shortcuts" + | "diagnostics" + | "about" + | "permissions" + | "daemon"; + +interface SettingsSectionDef { + id: SettingsSectionId; + label: string; + icon: ComponentType<{ size: number; color: string }>; +} + +function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDef[] { + const sections: SettingsSectionDef[] = [ + { id: "hosts", label: "Hosts", icon: Server }, + { id: "appearance", label: "Appearance", icon: Palette }, + { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard }, + { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, + { id: "about", label: "About", icon: Info }, + ]; + + if (context.isDesktop) { + sections.push( + { id: "permissions", label: "Permissions", icon: Shield }, + { id: "daemon", label: "Daemon", icon: Settings }, + ); + } + + return sections; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + const delay = (ms: number) => new Promise((resolve) => { const timeout = setTimeout(() => { @@ -98,296 +155,6 @@ function formatActiveConnectionBadge(input: { }; } -const styles = StyleSheet.create((theme) => ({ - loadingContainer: { - flex: 1, - backgroundColor: theme.colors.surface0, - alignItems: "center", - justifyContent: "center", - }, - loadingText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.lg, - }, - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - scrollView: { - flex: 1, - }, - content: { - padding: theme.spacing[4], - paddingTop: theme.spacing[6], - width: "100%", - maxWidth: 720, - alignSelf: "center", - }, - label: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - marginBottom: theme.spacing[2], - }, - input: { - backgroundColor: theme.colors.surface0, - color: theme.colors.foreground, - padding: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - fontSize: theme.fontSize.base, - }, - // Host card styles - hostCard: { - marginBottom: theme.spacing[3], - overflow: "hidden", - }, - hostCardContent: { - padding: theme.spacing[4], - gap: theme.spacing[2], - }, - hostHeaderRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - }, - hostHeaderRight: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flexShrink: 0, - }, - hostLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - flex: 1, - flexShrink: 1, - }, - hostError: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.xs, - }, - // Status pill - statusPill: { - flexDirection: "row", - alignItems: "center", - gap: 6, - paddingHorizontal: theme.spacing[2], - paddingVertical: 4, - borderRadius: theme.borderRadius.full, - }, - statusPillMobile: { - alignItems: "center", - justifyContent: "center", - width: 14, - height: 14, - borderRadius: 7, - }, - statusDot: { - width: 6, - height: 6, - borderRadius: theme.borderRadius.full, - }, - statusText: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - }, - connectionPill: { - flexDirection: "row", - alignItems: "center", - gap: 6, - paddingHorizontal: theme.spacing[2], - paddingVertical: 4, - borderRadius: theme.borderRadius.full, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface3, - maxWidth: 170, - }, - connectionPillMobile: { - flexDirection: "row", - alignItems: "center", - borderRadius: theme.borderRadius.full, - }, - connectionText: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - flexShrink: 1, - }, - versionPill: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: theme.spacing[2], - paddingVertical: 4, - borderRadius: theme.borderRadius.full, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface3, - maxWidth: 200, - }, - hostSettingsButton: { - width: 28, - height: 28, - paddingVertical: 0, - paddingHorizontal: 0, - borderRadius: theme.borderRadius.md, - gap: 0, - marginLeft: theme.spacing[2], - alignItems: "center", - justifyContent: "center", - }, - advancedTrigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: "transparent", - }, - advancedTriggerText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, - disabled: { - opacity: theme.opacity[50], - }, - testResultText: { - fontSize: theme.fontSize.xs, - }, - // Add host button - addButton: { - borderStyle: "dashed", - }, - addButtonText: { - color: theme.colors.foregroundMuted, - }, - // Add/Edit form - formCard: { - padding: theme.spacing[4], - marginBottom: theme.spacing[3], - gap: theme.spacing[4], - }, - formTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, - formField: { - gap: theme.spacing[2], - }, - formActionsRow: { - flexDirection: "row", - justifyContent: "flex-end", - gap: theme.spacing[2], - }, - formButton: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[4], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - }, - formButtonPrimary: { - backgroundColor: theme.colors.palette.blue[500], - borderColor: theme.colors.palette.blue[500], - }, - formButtonText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - formButtonPrimaryText: { - color: theme.colors.palette.white, - }, - // Audio settings card - audioCard: { - overflow: "hidden", - }, - audioRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - audioRowBorder: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - audioRowContent: { - flex: 1, - marginRight: theme.spacing[3], - }, - audioRowTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - aboutValue: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - aboutHintText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - marginTop: 2, - }, - aboutErrorText: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.xs, - marginTop: theme.spacing[1], - }, - aboutUpdateActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - // Empty state - emptyCard: { - padding: theme.spacing[4], - marginBottom: theme.spacing[3], - }, - emptyText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - textAlign: "center", - }, - // Dev section - devCard: { - overflow: "hidden", - }, - devButton: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - devButtonBorder: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - devButtonContent: { - flex: 1, - }, - devButtonTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - devButtonDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: 2, - }, -})); - function formatDaemonVersionBadge(version: string | null): string | null { const daemonVersion = version?.trim(); if (!daemonVersion) { @@ -399,6 +166,457 @@ function formatDaemonVersionBadge(version: string | null): string | null { return `v${daemonVersion}`; } +// --------------------------------------------------------------------------- +// Section content components +// --------------------------------------------------------------------------- + +interface HostsSectionProps { + daemons: HostProfile[]; + settings: AppSettings; + routeServerId: string; + isDesktop: boolean; + theme: ReturnType["theme"]; + handleEditDaemon: (profile: HostProfile) => void; + setAddConnectionTargetServerId: (id: string | null) => void; + setPendingEditReopenServerId: (id: string | null) => void; + setIsAddHostMethodVisible: (visible: boolean) => void; + isAddHostMethodVisible: boolean; + isDirectHostVisible: boolean; + isPasteLinkVisible: boolean; + addConnectionTargetServerId: string | null; + closeAddConnectionFlow: () => void; + goBackToAddConnectionMethods: () => void; + setIsDirectHostVisible: (visible: boolean) => void; + setIsPasteLinkVisible: (visible: boolean) => void; + pendingNameHost: { serverId: string; hostname: string | null } | null; + setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void; + pendingNameHostname: string | null; + renameHost: (serverId: string, label: string) => Promise; + pendingRemoveHost: HostProfile | null; + setPendingRemoveHost: (host: HostProfile | null) => void; + isRemovingHost: boolean; + setIsRemovingHost: (removing: boolean) => void; + removeHost: (serverId: string) => Promise; + editingDaemonLive: HostProfile | null; + isSavingEdit: boolean; + handleCloseEditDaemon: () => void; + handleSaveEditDaemon: (label: string) => Promise; + handleRemoveConnection: (serverId: string, connectionId: string) => Promise; + handleRemoveDaemon: (profile: HostProfile) => void; + handleAddConnectionFromModal: () => void; + restartConfirmationMessage: string; + waitForCondition: ( + predicate: () => boolean, + timeoutMs: number, + intervalMs?: number, + ) => Promise; + isMountedRef: MutableRefObject; +} + +function HostsSection(props: HostsSectionProps) { + const { theme } = useUnistyles(); + + return ( + <> + + Hosts + + {props.daemons.length === 0 ? ( + + No hosts configured + + ) : ( + props.daemons.map((daemon) => { + return ( + + ); + }) + )} + + + + + { + props.setIsAddHostMethodVisible(false); + props.setIsDirectHostVisible(true); + }} + onPasteLink={() => { + props.setIsAddHostMethodVisible(false); + props.setIsPasteLinkVisible(true); + }} + onScanQr={() => { + const targetServerId = props.addConnectionTargetServerId; + const source = targetServerId ? "editHost" : "settings"; + const sourceServerId = props.routeServerId || targetServerId || undefined; + props.closeAddConnectionFlow(); + router.push({ + pathname: "/pair-scan", + params: targetServerId + ? { source, targetServerId, sourceServerId } + : { source, sourceServerId }, + }); + }} + /> + + { + if (isNewHost) { + props.setPendingNameHost({ serverId, hostname }); + } + }} + /> + + { + if (isNewHost) { + props.setPendingNameHost({ serverId, hostname }); + } + }} + /> + + {props.pendingNameHost ? ( + props.setPendingNameHost(null)} + onSave={(label) => { + void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => { + props.setPendingNameHost(null); + }); + }} + /> + ) : null} + + {props.pendingRemoveHost ? ( + { + if (props.isRemovingHost) return; + props.setPendingRemoveHost(null); + }} + testID="remove-host-confirm-modal" + > + + Remove {props.pendingRemoveHost.label}? This will delete its saved connections. + + + + + + + ) : null} + + void props.handleSaveEditDaemon(label)} + onRemoveConnection={props.handleRemoveConnection} + onRemoveHost={props.handleRemoveDaemon} + onAddConnection={props.handleAddConnectionFromModal} + restartConfirmationMessage={props.restartConfirmationMessage} + waitForCondition={props.waitForCondition} + isScreenMountedRef={props.isMountedRef} + /> + + ); +} + +interface AppearanceSectionProps { + settings: AppSettings; + handleThemeChange: (theme: AppSettings["theme"]) => void; +} + +function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) { + return ( + + Appearance + + + + Theme + + , + }, + { + value: "dark", + label: "Dark", + icon: ({ color, size }) => , + }, + { + value: "auto", + label: "System", + icon: ({ color, size }) => , + }, + ]} + /> + + + + ); +} + + +interface DiagnosticsSectionProps { + voiceAudioEngine: ReturnType; + isPlaybackTestRunning: boolean; + playbackTestResult: string | null; + handlePlaybackTest: () => Promise; +} + +function DiagnosticsSection({ + voiceAudioEngine, + isPlaybackTestRunning, + playbackTestResult, + handlePlaybackTest, +}: DiagnosticsSectionProps) { + return ( + + Diagnostics + + + + Test audio + {playbackTestResult ? ( + {playbackTestResult} + ) : null} + + + + + + ); +} + +interface AboutSectionProps { + appVersionText: string; + isDesktop: boolean; +} + +function AboutSection({ appVersionText, isDesktop }: AboutSectionProps) { + return ( + + About + + + + Version + + {appVersionText} + + {isDesktop ? : null} + + + ); +} + +// --------------------------------------------------------------------------- +// Section content switcher +// --------------------------------------------------------------------------- + +interface SettingsSectionContentProps { + sectionId: SettingsSectionId; + hostsProps: HostsSectionProps; + appearanceProps: AppearanceSectionProps; + diagnosticsProps: DiagnosticsSectionProps; + aboutProps: AboutSectionProps; + appVersion: string | null; + isLocalDaemon: boolean; + isDesktop: boolean; +} + +function SettingsSectionContent({ + sectionId, + hostsProps, + appearanceProps, + diagnosticsProps, + aboutProps, + appVersion, + isLocalDaemon, + isDesktop, +}: SettingsSectionContentProps) { + switch (sectionId) { + case "hosts": + return ; + case "appearance": + return ; + case "keyboard-shortcuts": + return ; + case "diagnostics": + return ; + case "about": + return ; + case "permissions": + return isDesktop ? : null; + case "daemon": + return isDesktop ? ( + + ) : null; + } +} + +// --------------------------------------------------------------------------- +// Layouts +// --------------------------------------------------------------------------- + +interface SettingsLayoutProps { + sections: SettingsSectionDef[]; + sectionContentProps: Omit; +} + +function SettingsMobileLayout({ sections, sectionContentProps }: SettingsLayoutProps) { + const insets = useSafeAreaInsets(); + + return ( + + + {sections.map((section) => ( + + ))} + + + ); +} + +function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayoutProps) { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const [selectedSectionId, setSelectedSectionId] = useState("hosts"); + + return ( + + + {sections.map((section) => { + const isSelected = section.id === selectedSectionId; + const IconComponent = section.icon; + return ( + setSelectedSectionId(section.id)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} + > + + + {section.label} + + + ); + })} + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Desktop app update row (unchanged) +// --------------------------------------------------------------------------- + function DesktopAppUpdateRow() { const { isDesktop, @@ -493,9 +711,12 @@ function DesktopAppUpdateRow() { ); } +// --------------------------------------------------------------------------- +// Main screen +// --------------------------------------------------------------------------- + export default function SettingsScreen() { const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); const voiceAudioEngine = useVoiceAudioEngineOptional(); const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>(); const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; @@ -685,9 +906,6 @@ export default function SettingsScreen() { [updateSettings], ); - const restartConfirmationMessage = - "This will restart the daemon. The app will reconnect automatically."; - const handlePlaybackTest = useCallback(async () => { if (!voiceAudioEngine || isPlaybackTestRunning) { return; @@ -717,6 +935,75 @@ export default function SettingsScreen() { } }, [isPlaybackTestRunning, voiceAudioEngine]); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const sections = getSettingsSections({ isDesktop }); + + const hostsProps: HostsSectionProps = { + daemons, + settings, + routeServerId, + isDesktop, + theme, + handleEditDaemon, + setAddConnectionTargetServerId, + setPendingEditReopenServerId, + setIsAddHostMethodVisible, + isAddHostMethodVisible, + isDirectHostVisible, + isPasteLinkVisible, + addConnectionTargetServerId, + closeAddConnectionFlow, + goBackToAddConnectionMethods, + setIsDirectHostVisible, + setIsPasteLinkVisible, + pendingNameHost, + setPendingNameHost, + pendingNameHostname, + renameHost, + pendingRemoveHost, + setPendingRemoveHost, + isRemovingHost, + setIsRemovingHost, + removeHost, + editingDaemonLive, + isSavingEdit, + handleCloseEditDaemon, + handleSaveEditDaemon, + handleRemoveConnection, + handleRemoveDaemon, + handleAddConnectionFromModal, + restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.", + waitForCondition, + isMountedRef, + }; + + const appearanceProps: AppearanceSectionProps = { + settings, + handleThemeChange, + }; + + const diagnosticsProps: DiagnosticsSectionProps = { + voiceAudioEngine, + isPlaybackTestRunning, + playbackTestResult, + handlePlaybackTest, + }; + + const aboutProps: AboutSectionProps = { + appVersionText, + isDesktop, + }; + + const sectionContentProps: Omit = { + hostsProps, + appearanceProps, + diagnosticsProps, + aboutProps, + appVersion, + isLocalDaemon, + isDesktop, + }; + if (isLoading) { return ( @@ -728,252 +1015,19 @@ export default function SettingsScreen() { return ( - - - - {/* Host Management */} - - Hosts - - {daemons.length === 0 ? ( - - No hosts configured - - ) : ( - daemons.map((daemon) => { - return ( - - ); - }) - )} - - - - - { - setIsAddHostMethodVisible(false); - setIsDirectHostVisible(true); - }} - onPasteLink={() => { - setIsAddHostMethodVisible(false); - setIsPasteLinkVisible(true); - }} - onScanQr={() => { - const targetServerId = addConnectionTargetServerId; - const source = targetServerId ? "editHost" : "settings"; - const sourceServerId = routeServerId || targetServerId || undefined; - closeAddConnectionFlow(); - router.push({ - pathname: "/pair-scan", - params: targetServerId - ? { source, targetServerId, sourceServerId } - : { source, sourceServerId }, - }); - }} - /> - - { - if (isNewHost) { - setPendingNameHost({ serverId, hostname }); - } - }} - /> - - { - if (isNewHost) { - setPendingNameHost({ serverId, hostname }); - } - }} - /> - - {pendingNameHost ? ( - setPendingNameHost(null)} - onSave={(label) => { - void renameHost(pendingNameHost.serverId, label).finally(() => { - setPendingNameHost(null); - }); - }} - /> - ) : null} - - {pendingRemoveHost ? ( - { - if (isRemovingHost) return; - setPendingRemoveHost(null); - }} - testID="remove-host-confirm-modal" - > - - Remove {pendingRemoveHost.label}? This will delete its saved connections. - - - - - - - ) : null} - - void handleSaveEditDaemon(label)} - onRemoveConnection={handleRemoveConnection} - onRemoveHost={handleRemoveDaemon} - onAddConnection={handleAddConnectionFromModal} - restartConfirmationMessage={restartConfirmationMessage} - waitForCondition={waitForCondition} - isScreenMountedRef={isMountedRef} - /> - - {/* Appearance */} - - Appearance - - - - Theme - - , - }, - { - value: "dark", - label: "Dark", - icon: ({ color, size }) => , - }, - { - value: "auto", - label: "System", - icon: ({ color, size }) => , - }, - ]} - /> - - - - - {isDesktop ? : null} - {isDesktop ? ( - - ) : null} - - - Diagnostics - - - - Test audio - {playbackTestResult ? ( - {playbackTestResult} - ) : null} - - - - - - - {/* About */} - - About - - - - Version - - {appVersionText} - - {isDesktop ? : null} - - - - + {isMobile ? ( + + ) : ( + + )} ); } +// --------------------------------------------------------------------------- +// HostDetailModal (unchanged) +// --------------------------------------------------------------------------- + interface HostDetailModalProps { visible: boolean; host: HostProfile | null; @@ -1354,6 +1408,10 @@ function HostDetailModal({ ); } +// --------------------------------------------------------------------------- +// ConnectionRow (unchanged) +// --------------------------------------------------------------------------- + function ConnectionRow({ connection, latencyMs, @@ -1408,6 +1466,10 @@ function ConnectionRow({ ); } +// --------------------------------------------------------------------------- +// DaemonCard (unchanged) +// --------------------------------------------------------------------------- + interface DaemonCardProps { daemon: HostProfile; onOpenSettings: (daemon: HostProfile) => void; @@ -1517,3 +1579,328 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) { ); } + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const styles = StyleSheet.create((theme) => ({ + loadingContainer: { + flex: 1, + backgroundColor: theme.colors.surface0, + alignItems: "center", + justifyContent: "center", + }, + loadingText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.lg, + }, + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + scrollView: { + flex: 1, + }, + content: { + padding: theme.spacing[4], + paddingTop: theme.spacing[6], + width: "100%", + maxWidth: 720, + alignSelf: "center", + }, + label: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + marginBottom: theme.spacing[2], + }, + input: { + backgroundColor: theme.colors.surface0, + color: theme.colors.foreground, + padding: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, + // Host card styles + hostCard: { + marginBottom: theme.spacing[3], + overflow: "hidden", + }, + hostCardContent: { + padding: theme.spacing[4], + gap: theme.spacing[2], + }, + hostHeaderRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + hostHeaderRight: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexShrink: 0, + }, + hostLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + flex: 1, + flexShrink: 1, + }, + hostError: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + }, + // Status pill + statusPill: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + borderRadius: theme.borderRadius.full, + }, + statusPillMobile: { + alignItems: "center", + justifyContent: "center", + width: 14, + height: 14, + borderRadius: 7, + }, + statusDot: { + width: 6, + height: 6, + borderRadius: theme.borderRadius.full, + }, + statusText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + connectionPill: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + maxWidth: 170, + }, + connectionPillMobile: { + flexDirection: "row", + alignItems: "center", + borderRadius: theme.borderRadius.full, + }, + connectionText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + versionPill: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + maxWidth: 200, + }, + hostSettingsButton: { + width: 28, + height: 28, + paddingVertical: 0, + paddingHorizontal: 0, + borderRadius: theme.borderRadius.md, + gap: 0, + marginLeft: theme.spacing[2], + alignItems: "center", + justifyContent: "center", + }, + advancedTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: "transparent", + }, + advancedTriggerText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + disabled: { + opacity: theme.opacity[50], + }, + testResultText: { + fontSize: theme.fontSize.xs, + }, + // Add host button + addButton: { + borderStyle: "dashed", + }, + addButtonText: { + color: theme.colors.foregroundMuted, + }, + // Add/Edit form + formCard: { + padding: theme.spacing[4], + marginBottom: theme.spacing[3], + gap: theme.spacing[4], + }, + formTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + }, + formField: { + gap: theme.spacing[2], + }, + formActionsRow: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, + formButton: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + }, + formButtonPrimary: { + backgroundColor: theme.colors.palette.blue[500], + borderColor: theme.colors.palette.blue[500], + }, + formButtonText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + formButtonPrimaryText: { + color: theme.colors.palette.white, + }, + // Audio settings card + audioCard: { + overflow: "hidden", + }, + audioRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + }, + audioRowBorder: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + audioRowContent: { + flex: 1, + marginRight: theme.spacing[3], + }, + audioRowTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + aboutValue: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + aboutHintText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + marginTop: 2, + }, + aboutErrorText: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + marginTop: theme.spacing[1], + }, + aboutUpdateActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + // Empty state + emptyCard: { + padding: theme.spacing[4], + marginBottom: theme.spacing[3], + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, + // Dev section + devCard: { + overflow: "hidden", + }, + devButton: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + }, + devButtonBorder: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + devButtonContent: { + flex: 1, + }, + devButtonTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + devButtonDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + marginTop: 2, + }, +})); + +const desktopStyles = StyleSheet.create((theme) => ({ + row: { + flex: 1, + flexDirection: "row", + }, + sidebar: { + width: 200, + borderRightWidth: 1, + borderRightColor: theme.colors.border, + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[2], + gap: theme.spacing[1], + }, + sidebarItem: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + }, + sidebarLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + fontWeight: theme.fontWeight.normal, + }, + contentPane: { + flex: 1, + }, +})); diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx new file mode 100644 index 000000000..7b1d91759 --- /dev/null +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -0,0 +1,217 @@ +import { useState, useEffect } from "react"; +import { View, Text, Platform } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { settingsStyles } from "@/styles/settings"; +import { Button } from "@/components/ui/button"; +import { Shortcut } from "@/components/ui/shortcut"; +import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { + buildKeyboardShortcutHelpSections, + getBindingIdForAction, + type KeyboardShortcutHelpRow, +} from "@/keyboard/keyboard-shortcuts"; +import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import { getIsDesktop } from "@/constants/layout"; + +function ShortcutRow({ + row, + bindingId, + overrideCombo, + isCapturing, + onRebind, + onCancel, + onReset, +}: { + row: KeyboardShortcutHelpRow; + bindingId: string | null; + overrideCombo: string | undefined; + isCapturing: boolean; + onRebind: () => void; + onCancel: () => void; + onReset: () => void; +}) { + const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys; + + return ( + + {row.label} + + {isCapturing ? ( + Press shortcut... + ) : ( + + )} + {bindingId !== null && ( + + )} + {overrideCombo !== undefined && !isCapturing && ( + + )} + + + ); +} + +export function KeyboardShortcutsSection() { + const [capturingBindingId, setCapturingBindingId] = useState(null); + const { overrides, hasOverrides, setOverride, removeOverride, resetAll } = + useKeyboardShortcutOverrides(); + + const isMac = getShortcutOs() === "mac"; + const isDesktop = getIsDesktop(); + const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop }); + + useEffect(() => { + if (Platform.OS !== "web") return; + if (capturingBindingId === null) return; + + const activeBindingId = capturingBindingId; + + function handleKeyDown(event: KeyboardEvent) { + const key = event.key ?? ""; + if (key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setCapturingBindingId(null); + return; + } + + if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") { + return; + } + + const comboString = keyboardEventToComboString(event); + if (comboString === null) return; + + event.preventDefault(); + event.stopPropagation(); + void setOverride(activeBindingId, comboString); + setCapturingBindingId(null); + } + + window.addEventListener("keydown", handleKeyDown, true); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [capturingBindingId, setOverride]); + + if (Platform.OS !== "web") { + return ( + + Keyboard Shortcuts + + + Keyboard shortcuts are only available on desktop. + + + + ); + } + + return ( + + + Keyboard Shortcuts + {hasOverrides && ( + + )} + + + {sections.map(function (section) { + return ( + + {section.title} + + {section.rows.map(function (row, index) { + const bindingId = getBindingIdForAction(row.id, { isMac, isDesktop }); + const overrideCombo = bindingId ? overrides[bindingId] : undefined; + + return ( + + setCapturingBindingId(bindingId)} + onCancel={() => setCapturingBindingId(null)} + onReset={() => { + if (bindingId) void removeOverride(bindingId); + }} + /> + {index < section.rows.length - 1 && } + + ); + })} + + + ); + })} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + sectionHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + subsectionTitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + marginBottom: theme.spacing[2], + marginTop: theme.spacing[4], + marginLeft: theme.spacing[1], + }, + row: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[3], + }, + rowCapturing: { + backgroundColor: theme.colors.surface2, + }, + rowLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + flexShrink: 1, + }, + rowActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + capturingText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + resetText: { + color: theme.colors.foregroundMuted, + }, + separator: { + height: 1, + backgroundColor: theme.colors.border, + }, + mobileCard: { + padding: theme.spacing[4], + }, + mobileText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, +}));