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
This commit is contained in:
Mohamed Boudra
2026-03-21 23:14:59 +07:00
parent 68ab7b81a1
commit ad1cb4c200
10 changed files with 1607 additions and 612 deletions
+25 -4
View File
@@ -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<string, string>,
): 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<TextInput>(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) {
@@ -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<string, string> = {};
export interface UseKeyboardShortcutOverridesReturn {
overrides: Record<string, string>;
isLoading: boolean;
setOverride: (bindingId: string, comboString: string) => Promise<void>;
removeOverride: (bindingId: string) => Promise<void>;
resetAll: () => Promise<void>;
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<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
const next = { ...prev, [bindingId]: comboString };
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
const removeOverride = useCallback(
async (bindingId: string) => {
const prev =
queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
const { [bindingId]: _, ...next } = prev;
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
const resetAll = useCallback(async () => {
queryClient.setQueryData<Record<string, string>>(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<Record<string, string>> {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as Record<string, string>;
}
} catch (err) {
console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err);
}
return EMPTY_OVERRIDES;
}
@@ -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,
@@ -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]);
}