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,
+ },
+}));