Merge branch 'feat/chord-keyboard-shortcuts'

This commit is contained in:
Mohamed Boudra
2026-03-22 17:44:51 +07:00
19 changed files with 523 additions and 104 deletions
+36
View File
@@ -25,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.8", "@biomejs/biome": "^2.4.8",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0", "get-port-cli": "^3.0.0",
"knip": "^5.82.1", "knip": "^5.82.1",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
@@ -15184,6 +15185,31 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/connect": { "node_modules/connect": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
@@ -32547,6 +32573,16 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/trim-lines": { "node_modules/trim-lines": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+1
View File
@@ -59,6 +59,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.8", "@biomejs/biome": "^2.4.8",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0", "get-port-cli": "^3.0.0",
"knip": "^5.82.1", "knip": "^5.82.1",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
@@ -594,12 +594,14 @@ export function AgentInputArea({
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text> <Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? <Shortcut keys={dictationCancelKeys} style={styles.tooltipShortcut} /> : null} {dictationCancelKeys ? (
</View> <Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
</TooltipContent> ) : null}
</Tooltip> </View>
</TooltipContent>
</Tooltip>
) : null; ) : null;
const rightContent = ( const rightContent = (
@@ -626,7 +628,9 @@ export function AgentInputArea({
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text> <Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys && <Shortcut keys={voiceToggleKeys} style={styles.tooltipShortcut} />} {voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -178,7 +178,7 @@ export function CommandCenter() {
</View> </View>
</View> </View>
{action.shortcutKeys ? ( {action.shortcutKeys ? (
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} /> <Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
) : null} ) : null}
</View> </View>
</CommandCenterRow> </CommandCenterRow>
+2 -2
View File
@@ -565,7 +565,7 @@ function MobileSidebar({
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text> <Text style={styles.tooltipText}>Add project</Text>
{newAgentKeys && <Shortcut keys={newAgentKeys} />} {newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -745,7 +745,7 @@ function DesktopSidebar({
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text> <Text style={styles.tooltipText}>Add project</Text>
{newAgentKeys ? <Shortcut keys={newAgentKeys} /> : null} {newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -1013,7 +1013,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</Text> </Text>
{(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? ( {(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? (
<Shortcut <Shortcut
keys={(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys)!} chord={
(isRealtimeVoiceForCurrentAgent
? voiceMuteToggleKeys
: dictationToggleKeys)!
}
style={styles.tooltipShortcut} style={styles.tooltipShortcut}
/> />
) : null} ) : null}
@@ -1039,7 +1043,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Queue</Text> <Text style={styles.tooltipText}>Queue</Text>
{queueKeys ? <Shortcut keys={queueKeys} style={styles.tooltipShortcut} /> : null} {queueKeys ? (
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
) : null}
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -1062,7 +1068,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Send</Text> <Text style={styles.tooltipText}>Send</Text>
{sendKeys ? <Shortcut keys={sendKeys} style={styles.tooltipShortcut} /> : null} {sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -150,7 +150,7 @@ interface WorkspaceRowInnerProps {
onArchive?: () => void; onArchive?: () => void;
onCopyBranchName?: () => void; onCopyBranchName?: () => void;
onCopyPath?: () => void; onCopyPath?: () => void;
archiveShortcutKeys?: ShortcutKey[] | null; archiveShortcutKeys?: ShortcutKey[][] | null;
} }
function resolveStatusDotColor(input: { function resolveStatusDotColor(input: {
@@ -322,7 +322,7 @@ function NewWorktreeButton({
<View style={styles.projectActionTooltipRow}> <View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New worktree</Text> <Text style={styles.projectActionTooltipText}>New worktree</Text>
{showShortcutHint && newWorktreeKeys ? ( {showShortcutHint && newWorktreeKeys ? (
<Shortcut keys={newWorktreeKeys} style={styles.projectActionTooltipShortcut} /> <Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null} ) : null}
</View> </View>
</TooltipContent> </TooltipContent>
@@ -842,7 +842,7 @@ function WorkspaceRowInner({
<DropdownMenuItem <DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`} testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />} leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut keys={archiveShortcutKeys} /> : null} trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
status={archiveStatus} status={archiveStatus}
pendingLabel={archivePendingLabel} pendingLabel={archivePendingLabel}
onSelect={onArchive} onSelect={onArchive}
+34 -4
View File
@@ -6,22 +6,46 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
export function Shortcut({ export function Shortcut({
keys, keys,
chord,
style, style,
textStyle, textStyle,
}: { }: {
keys: ShortcutKey[]; keys?: ShortcutKey[];
chord?: ShortcutKey[][];
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>; textStyle?: StyleProp<TextStyle>;
}): ReactElement { }): ReactElement {
const displayChord = chord ?? (keys ? [keys] : []);
const shortcutOs = getShortcutOs();
const singleCombo = displayChord[0];
if (!singleCombo) {
return <View style={style} />;
}
if (displayChord.length === 1) {
return (
<View style={[styles.badge, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(singleCombo, shortcutOs)}</Text>
</View>
);
}
return ( return (
<View style={[styles.root, style]}> <View style={[styles.sequence, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text> {displayChord.map(function (combo, index) {
return (
<View key={`${combo.join("+")}-${index}`} style={styles.badge}>
<Text style={[styles.text, textStyle]}>{formatShortcut(combo, shortcutOs)}</Text>
</View>
);
})}
</View> </View>
); );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
root: { badge: {
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
paddingVertical: 2, paddingVertical: 2,
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
@@ -29,6 +53,12 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
}, },
sequence: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[1],
},
text: { text: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
+6 -5
View File
@@ -13,7 +13,7 @@ import {
} from "@/utils/command-center-focus-restore"; } from "@/utils/command-center-focus-restore";
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -91,7 +91,7 @@ export type CommandCenterActionItem = {
title: string; title: string;
icon?: "plus" | "settings"; icon?: "plus" | "settings";
route?: Href; route?: Href;
shortcutKeys?: ShortcutKey[]; shortcutKeys?: ShortcutKey[][];
}; };
export type CommandCenterItem = export type CommandCenterItem =
@@ -107,7 +107,7 @@ export type CommandCenterItem =
function resolveActionShortcutKeys( function resolveActionShortcutKeys(
actionId: string | undefined, actionId: string | undefined,
overrides: Record<string, string>, overrides: Record<string, string>,
): ShortcutKey[] | undefined { ): ShortcutKey[][] | undefined {
if (!actionId) return undefined; if (!actionId) return undefined;
const isMac = getShortcutOs() === "mac"; const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop(); const isDesktop = getIsDesktop();
@@ -115,8 +115,9 @@ function resolveActionShortcutKeys(
const bindingId = getBindingIdForAction(actionId, platform); const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined; if (!bindingId) return undefined;
const override = overrides[bindingId]; const override = overrides[bindingId];
if (override) return comboStringToShortcutKeys(override); if (override) return chordStringToShortcutKeys(override);
return getDefaultKeysForAction(actionId, platform) ?? undefined; const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : undefined;
} }
export function useCommandCenter() { export function useCommandCenter() {
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { usePathname } from "expo-router"; import { usePathname } from "expo-router";
import { getIsDesktop } from "@/constants/layout"; import { getIsDesktop } from "@/constants/layout";
@@ -17,7 +17,11 @@ import {
} from "@/keyboard/actions"; } from "@/keyboard/actions";
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing"; import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts"; import {
type ChordState,
resolveKeyboardShortcut,
buildEffectiveBindings,
} from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
@@ -41,6 +45,11 @@ export function useKeyboardShortcuts({
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
const chordStateRef = useRef<ChordState>({
candidateIndices: [],
step: 0,
timeoutId: null,
});
const activeServerIdFromPath = parseServerIdFromPathname(pathname); const activeServerIdFromPath = parseServerIdFromPathname(pathname);
const activeServerId = const activeServerId =
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ?? hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
@@ -271,6 +280,11 @@ export function useKeyboardShortcuts({
return; return;
} }
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;
}
const key = event.key ?? ""; const key = event.key ?? "";
if (key === "Alt" && !event.shiftKey) { if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true); useKeyboardShortcutsStore.getState().setAltDown(true);
@@ -285,12 +299,11 @@ export function useKeyboardShortcuts({
} }
} }
const store = useKeyboardShortcutsStore.getState();
const focusScope = resolveKeyboardFocusScope({ const focusScope = resolveKeyboardFocusScope({
target: event.target, target: event.target,
commandCenterOpen: store.commandCenterOpen, commandCenterOpen: store.commandCenterOpen,
}); });
const match = resolveKeyboardShortcut({ const result = resolveKeyboardShortcut({
event, event,
context: { context: {
isMac, isMac,
@@ -303,25 +316,41 @@ export function useKeyboardShortcuts({
toggleFileExplorer, toggleFileExplorer,
}), }),
}, },
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
},
bindings, bindings,
}); });
if (!match) {
chordStateRef.current = result.nextChordState;
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
if (!result.match) {
return; return;
} }
const handled = handleAction({ const handled = handleAction({
action: match.action, action: result.match.action,
payload: match.payload, payload: result.match.payload,
event, event,
}); });
if (!handled) { if (!handled) {
return; return;
} }
if (match.preventDefault) { if (result.match.preventDefault) {
event.preventDefault(); event.preventDefault();
} }
if (match.stopPropagation) { if (result.match.stopPropagation) {
event.stopPropagation(); event.stopPropagation();
} }
}; };
@@ -345,6 +374,14 @@ export function useKeyboardShortcuts({
window.addEventListener("blur", handleBlurOrHide); window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide); document.addEventListener("visibilitychange", handleBlurOrHide);
return () => { return () => {
if (chordStateRef.current.timeoutId !== null) {
clearTimeout(chordStateRef.current.timeoutId);
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true); window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide); window.removeEventListener("blur", handleBlurOrHide);
+5 -4
View File
@@ -1,12 +1,12 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout"; import { getIsDesktop } from "@/constants/layout";
export function useShortcutKeys(actionId: string): ShortcutKey[] | null { export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac"; const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop(); const isDesktop = getIsDesktop();
@@ -18,9 +18,10 @@ export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
const override = overrides[bindingId]; const override = overrides[bindingId];
if (override) { if (override) {
return comboStringToShortcutKeys(override); return chordStringToShortcutKeys(override);
} }
return getDefaultKeysForAction(actionId, platform); const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : null;
}, [actionId, overrides, isMac, isDesktop]); }, [actionId, overrides, isMac, isDesktop]);
} }
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
buildKeyboardShortcutHelpSections, buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
resolveKeyboardShortcut, resolveKeyboardShortcut,
type ChordState,
type KeyboardShortcutContext, type KeyboardShortcutContext,
type ParsedShortcutBinding,
} from "./keyboard-shortcuts"; } from "./keyboard-shortcuts";
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent { function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
@@ -31,6 +34,30 @@ function shortcutContext(
}; };
} }
function initialChordState(): ChordState {
return {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
function resolveShortcut(input: {
event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>;
chordState?: ChordState;
onChordReset?: () => void;
bindings?: readonly ParsedShortcutBinding[];
}) {
return resolveKeyboardShortcut({
event: keyboardEvent(input.event),
context: shortcutContext(input.context),
chordState: input.chordState ?? initialChordState(),
onChordReset: input.onChordReset ?? (() => undefined),
...(input.bindings ? { bindings: input.bindings } : {}),
});
}
function expectShortcutResolution(input: { function expectShortcutResolution(input: {
event: Partial<KeyboardEvent>; event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>; context?: Partial<KeyboardShortcutContext>;
@@ -39,29 +66,33 @@ function expectShortcutResolution(input: {
preventDefault?: boolean; preventDefault?: boolean;
stopPropagation?: boolean; stopPropagation?: boolean;
}) { }) {
const match = resolveKeyboardShortcut({ const result = resolveShortcut({
event: keyboardEvent(input.event), event: input.event,
context: shortcutContext(input.context), context: input.context,
}); });
expect(match?.action).toBe(input.action); expect(result.match?.action).toBe(input.action);
if ("payload" in input) { if ("payload" in input) {
expect(match?.payload).toEqual(input.payload); expect(result.match?.payload).toEqual(input.payload);
} }
expect(match?.preventDefault).toBe(input.preventDefault ?? true); expect(result.match?.preventDefault).toBe(input.preventDefault ?? true);
expect(match?.stopPropagation).toBe(input.stopPropagation ?? true); expect(result.match?.stopPropagation).toBe(input.stopPropagation ?? true);
expect(result.preventDefault).toBe(false);
expect(result.nextChordState).toEqual(initialChordState());
} }
function expectNoShortcutResolution(input: { function expectNoShortcutResolution(input: {
event: Partial<KeyboardEvent>; event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>; context?: Partial<KeyboardShortcutContext>;
}) { }) {
const match = resolveKeyboardShortcut({ const result = resolveShortcut({
event: keyboardEvent(input.event), event: input.event,
context: shortcutContext(input.context), context: input.context,
}); });
expect(match).toBeNull(); expect(result.match).toBeNull();
expect(result.preventDefault).toBe(false);
expect(result.nextChordState).toEqual(initialChordState());
} }
type MatchingShortcutCase = { type MatchingShortcutCase = {
@@ -338,6 +369,66 @@ describe("keyboard-shortcuts", () => {
it.each(nonMatchingCases)("$name", ({ event, context }) => { it.each(nonMatchingCases)("$name", ({ event, context }) => {
expectNoShortcutResolution({ event, context }); expectNoShortcutResolution({ event, context });
}); });
it("prefers advancing chord candidates over single-combo matches on the same prefix", () => {
const bindings = buildEffectiveBindings({
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
});
const chordBindingIndex = bindings.findIndex(
(binding) => binding.id === "workspace-terminal-new-ctrl-shift-t-non-mac",
);
expect(chordBindingIndex).toBeGreaterThan(-1);
const firstResult = resolveShortcut({
event: { key: "w", code: "KeyW", ctrlKey: true },
context: { isMac: false, isDesktop: true },
bindings,
});
expect(firstResult.match).toBeNull();
expect(firstResult.preventDefault).toBe(true);
expect(firstResult.nextChordState.step).toBe(1);
expect(firstResult.nextChordState.candidateIndices).toEqual([chordBindingIndex]);
const secondResult = resolveShortcut({
event: { key: "s", code: "KeyS" },
context: { isMac: false, isDesktop: true },
chordState: firstResult.nextChordState,
bindings,
});
expect(secondResult.match?.action).toBe("workspace.terminal.new");
expect(secondResult.match?.payload).toBeNull();
expect(secondResult.match?.preventDefault).toBe(true);
expect(secondResult.match?.stopPropagation).toBe(true);
expect(secondResult.preventDefault).toBe(false);
expect(secondResult.nextChordState).toEqual(initialChordState());
});
it("schedules a chord reset timeout for advancing candidates", () => {
vi.useFakeTimers();
const bindings = buildEffectiveBindings({
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
});
const onChordReset = vi.fn();
const result = resolveShortcut({
event: { key: "w", code: "KeyW", ctrlKey: true },
context: { isMac: false, isDesktop: true },
onChordReset,
bindings,
});
expect(result.match).toBeNull();
expect(result.preventDefault).toBe(true);
expect(result.nextChordState.timeoutId).not.toBeNull();
vi.advanceTimersByTime(1500);
expect(onChordReset).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
}); });
describe("keyboard-shortcut help sections", () => { describe("keyboard-shortcut help sections", () => {
+156 -20
View File
@@ -5,7 +5,7 @@ import type {
KeyboardShortcutPayload, KeyboardShortcutPayload,
MessageInputKeyboardActionKind, MessageInputKeyboardActionKind,
} from "@/keyboard/actions"; } from "@/keyboard/actions";
import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string"; import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
export type { KeyCombo } from "@/keyboard/shortcut-string"; export type { KeyCombo } from "@/keyboard/shortcut-string";
@@ -88,7 +88,13 @@ interface ShortcutBinding {
} }
export interface ParsedShortcutBinding extends ShortcutBinding { export interface ParsedShortcutBinding extends ShortcutBinding {
parsedCombo: KeyCombo; parsedChord: KeyCombo[];
}
export interface ChordState {
candidateIndices: number[];
step: number;
timeoutId: ReturnType<typeof setTimeout> | null;
} }
// --- Constants --- // --- Constants ---
@@ -817,11 +823,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
// --- Parse bindings at module load --- // --- Parse bindings at module load ---
function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding { function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
const parsedCombo = parseShortcutString(binding.combo); const parsedChord = parseChordString(binding.combo);
if (binding.repeat === false) { const lastCombo = parsedChord.at(-1);
parsedCombo.repeat = false; if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
} }
return { ...binding, parsedCombo }; return { ...binding, parsedChord };
} }
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] = export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
@@ -835,16 +842,17 @@ export function buildEffectiveBindings(
if (override === undefined) { if (override === undefined) {
return binding; return binding;
} }
let parsedCombo: KeyCombo; let parsedChord: KeyCombo[];
try { try {
parsedCombo = parseShortcutString(override); parsedChord = parseChordString(override);
} catch { } catch {
return binding; return binding;
} }
if (binding.repeat === false) { const lastCombo = parsedChord.at(-1);
parsedCombo.repeat = false; if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
} }
return { ...binding, combo: override, parsedCombo }; return { ...binding, combo: override, parsedChord };
}); });
} }
@@ -921,6 +929,27 @@ function resolvePayload(
} }
} }
const CHORD_TIMEOUT_MS = 1500;
function clearChordTimeout(timeoutId: ReturnType<typeof setTimeout> | null): void {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
}
function createChordTimeout(onChordReset: () => void): ReturnType<typeof setTimeout> {
return setTimeout(onChordReset, CHORD_TIMEOUT_MS);
}
function resetChordState(input: ChordState): ChordState {
clearChordTimeout(input.timeoutId);
return {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
function helpMatchesPlatform( function helpMatchesPlatform(
when: ShortcutWhen | undefined, when: ShortcutWhen | undefined,
context: KeyboardShortcutPlatformContext, context: KeyboardShortcutPlatformContext,
@@ -935,24 +964,131 @@ function helpMatchesPlatform(
export function resolveKeyboardShortcut(input: { export function resolveKeyboardShortcut(input: {
event: KeyboardEvent; event: KeyboardEvent;
context: KeyboardShortcutContext; context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;
bindings?: readonly ParsedShortcutBinding[]; bindings?: readonly ParsedShortcutBinding[];
}): KeyboardShortcutMatch | null { }): {
const { event, context, bindings = DEFAULT_BINDINGS } = input; match: KeyboardShortcutMatch | null;
for (const binding of bindings) { nextChordState: ChordState;
if (!matchesCombo(binding.parsedCombo, event, context.isMac)) { preventDefault: boolean;
};
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;
bindings?: readonly ParsedShortcutBinding[];
}): {
match: KeyboardShortcutMatch | null;
nextChordState: ChordState;
preventDefault: boolean;
} {
const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
if (chordState.step === 0) {
const advancingCandidateIndices: number[] = [];
let singleComboMatch: KeyboardShortcutMatch | null = null;
for (const [index, binding] of bindings.entries()) {
const firstCombo = binding.parsedChord[0];
if (!firstCombo) {
continue;
}
if (!matchesCombo(firstCombo, event, context.isMac)) {
continue;
}
if (!matchesWhen(binding.when, context)) {
continue;
}
if (binding.parsedChord.length > 1) {
advancingCandidateIndices.push(index);
continue;
}
if (!singleComboMatch) {
singleComboMatch = {
action: binding.action,
payload: resolvePayload(binding.payload, event),
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
}
}
if (advancingCandidateIndices.length > 0) {
return {
match: null,
nextChordState: {
candidateIndices: advancingCandidateIndices,
step: 1,
timeoutId: createChordTimeout(onChordReset),
},
preventDefault: true,
};
}
return {
match: singleComboMatch,
nextChordState: resetChordState(chordState),
preventDefault: false,
};
}
const matchingCandidateIndices: number[] = [];
let completedMatch: KeyboardShortcutMatch | null = null;
for (const index of chordState.candidateIndices) {
const binding = bindings[index];
if (!binding) {
continue;
}
const combo = binding.parsedChord[chordState.step];
if (!combo) {
continue;
}
if (!matchesCombo(combo, event, context.isMac)) {
continue; continue;
} }
if (!matchesWhen(binding.when, context)) { if (!matchesWhen(binding.when, context)) {
continue; continue;
} }
if (chordState.step + 1 === binding.parsedChord.length) {
completedMatch = {
action: binding.action,
payload: resolvePayload(binding.payload, event),
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
break;
}
matchingCandidateIndices.push(index);
}
if (completedMatch) {
return { return {
action: binding.action, match: completedMatch,
payload: resolvePayload(binding.payload, event), nextChordState: resetChordState(chordState),
preventDefault: binding.preventDefault ?? true, preventDefault: false,
stopPropagation: binding.stopPropagation ?? true,
}; };
} }
return null;
if (matchingCandidateIndices.length > 0) {
clearChordTimeout(chordState.timeoutId);
return {
match: null,
nextChordState: {
candidateIndices: matchingCandidateIndices,
step: chordState.step + 1,
timeoutId: createChordTimeout(onChordReset),
},
preventDefault: true,
};
}
return {
match: null,
nextChordState: resetChordState(chordState),
preventDefault: false,
};
} }
export function getBindingIdForAction( export function getBindingIdForAction(
@@ -103,6 +103,10 @@ export function parseShortcutString(s: string): KeyCombo {
return combo; return combo;
} }
export function parseChordString(s: string): KeyCombo[] {
return s.split(" ").map(parseShortcutString);
}
export function keyComboToString(combo: KeyCombo): string { export function keyComboToString(combo: KeyCombo): string {
const parts: string[] = []; const parts: string[] = [];
@@ -122,6 +126,10 @@ export function keyComboToString(combo: KeyCombo): string {
return parts.join("+"); return parts.join("+");
} }
export function chordToString(chord: KeyCombo[]): string {
return chord.map(keyComboToString).join(" ");
}
const MODIFIER_CODES = new Set([ const MODIFIER_CODES = new Set([
"MetaLeft", "MetaLeft",
"MetaRight", "MetaRight",
@@ -158,6 +166,10 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
return keys; return keys;
} }
export function chordStringToShortcutKeys(s: string): ShortcutKey[][] {
return s.split(" ").map(comboStringToShortcutKeys);
}
export function keyboardEventToComboString(event: KeyboardEvent): string | null { export function keyboardEventToComboString(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) { if (MODIFIER_CODES.has(event.code)) {
return null; return null;
@@ -10,16 +10,31 @@ import {
getBindingIdForAction, getBindingIdForAction,
type KeyboardShortcutHelpRow, type KeyboardShortcutHelpRow,
} from "@/keyboard/keyboard-shortcuts"; } from "@/keyboard/keyboard-shortcuts";
import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string"; import {
chordStringToShortcutKeys,
comboStringToShortcutKeys,
keyboardEventToComboString,
} from "@/keyboard/shortcut-string";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout"; import { getIsDesktop } from "@/constants/layout";
function ShortcutSequence({ chord }: { chord: string[] | null }) {
if (!chord || chord.length === 0) {
return <Text style={styles.capturingText}>Press shortcut...</Text>;
}
return <Shortcut chord={chord.map(comboStringToShortcutKeys)} />;
}
function ShortcutRow({ function ShortcutRow({
row, row,
bindingId, bindingId,
overrideCombo, overrideCombo,
isCapturing, isCapturing,
capturedCombos,
onRebind, onRebind,
onDone,
onCancel, onCancel,
onReset, onReset,
}: { }: {
@@ -27,29 +42,38 @@ function ShortcutRow({
bindingId: string | null; bindingId: string | null;
overrideCombo: string | undefined; overrideCombo: string | undefined;
isCapturing: boolean; isCapturing: boolean;
capturedCombos: string[];
onRebind: () => void; onRebind: () => void;
onDone: () => void;
onCancel: () => void; onCancel: () => void;
onReset: () => void; onReset: () => void;
}) { }) {
const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys; const displayChord = overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys];
return ( return (
<View style={[styles.row, isCapturing && styles.rowCapturing]}> <View style={[styles.row, isCapturing && styles.rowCapturing]}>
<Text style={styles.rowLabel}>{row.label}</Text> <Text style={styles.rowLabel}>{row.label}</Text>
<View style={styles.rowActions}> <View style={styles.rowActions}>
{isCapturing ? ( {isCapturing ? (
<Text style={styles.capturingText}>Press shortcut...</Text> <ShortcutSequence chord={capturedCombos} />
) : ( ) : (
<Shortcut keys={displayKeys} /> <Shortcut chord={displayChord} />
)} )}
{bindingId !== null && ( {bindingId !== null && (
<Button <>
variant="ghost" {isCapturing && capturedCombos.length > 0 ? (
size="sm" <Button variant="ghost" size="sm" onPress={onDone}>
onPress={isCapturing ? onCancel : onRebind} Done
> </Button>
{isCapturing ? "Cancel" : "Rebind"} ) : null}
</Button> <Button
variant="ghost"
size="sm"
onPress={isCapturing ? onCancel : onRebind}
>
{isCapturing ? "Cancel" : "Rebind"}
</Button>
</>
)} )}
{overrideCombo !== undefined && !isCapturing && ( {overrideCombo !== undefined && !isCapturing && (
<Button variant="ghost" size="sm" onPress={onReset}> <Button variant="ghost" size="sm" onPress={onReset}>
@@ -63,46 +87,68 @@ function ShortcutRow({
export function KeyboardShortcutsSection() { export function KeyboardShortcutsSection() {
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null); const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } = const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
useKeyboardShortcutOverrides(); useKeyboardShortcutOverrides();
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
const isMac = getShortcutOs() === "mac"; const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop(); const isDesktop = getIsDesktop();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop }); const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
function cancelCapture() {
setCapturedCombos([]);
setCapturingBindingId(null);
setCapturingShortcut(false);
}
function startCapture(bindingId: string) {
setCapturedCombos([]);
setCapturingBindingId(bindingId);
setCapturingShortcut(true);
}
function saveCapture() {
if (capturingBindingId === null || capturedCombos.length === 0) {
return;
}
void setOverride(capturingBindingId, capturedCombos.join(" "));
cancelCapture();
}
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (Platform.OS !== "web") return;
if (capturingBindingId === null) return; if (capturingBindingId === null) return;
const activeBindingId = capturingBindingId;
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
const key = event.key ?? ""; event.preventDefault();
if (key === "Escape") { event.stopPropagation();
event.preventDefault();
event.stopPropagation();
setCapturingBindingId(null);
return;
}
if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") { const key = event.key ?? "";
if (key === "Backspace") {
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
return; return;
} }
const comboString = keyboardEventToComboString(event); const comboString = keyboardEventToComboString(event);
if (comboString === null) return; if (comboString === null) {
return;
}
event.preventDefault(); setCapturedCombos((current) => [...current, comboString]);
event.stopPropagation();
void setOverride(activeBindingId, comboString);
setCapturingBindingId(null);
} }
window.addEventListener("keydown", handleKeyDown, true); window.addEventListener("keydown", handleKeyDown, true);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("keydown", handleKeyDown, true);
}; };
}, [capturingBindingId, setOverride]); }, [capturingBindingId]);
useEffect(() => {
return () => {
setCapturingShortcut(false);
};
}, [setCapturingShortcut]);
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
return ( return (
@@ -144,8 +190,14 @@ export function KeyboardShortcutsSection() {
bindingId={bindingId} bindingId={bindingId}
overrideCombo={overrideCombo} overrideCombo={overrideCombo}
isCapturing={capturingBindingId === bindingId} isCapturing={capturingBindingId === bindingId}
onRebind={() => setCapturingBindingId(bindingId)} capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
onCancel={() => setCapturingBindingId(null)} onRebind={() => {
if (bindingId) {
startCapture(bindingId);
}
}}
onDone={saveCapture}
onCancel={cancelCapture}
onReset={() => { onReset={() => {
if (bindingId) void removeOverride(bindingId); if (bindingId) void removeOverride(bindingId);
}} }}
@@ -457,7 +457,7 @@ export function WorkspaceDesktopTabsRow({
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text> <Text style={styles.newTabTooltipText}>New agent tab</Text>
{newAgentTabKeys ? ( {newAgentTabKeys ? (
<Shortcut keys={newAgentTabKeys} style={styles.newTabTooltipShortcut} /> <Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
) : null} ) : null}
</View> </View>
</TooltipContent> </TooltipContent>
@@ -478,7 +478,7 @@ export function WorkspaceDesktopTabsRow({
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text> <Text style={styles.newTabTooltipText}>New terminal tab</Text>
{newTerminalTabKeys ? ( {newTerminalTabKeys ? (
<Shortcut keys={newTerminalTabKeys} style={styles.newTabTooltipShortcut} /> <Shortcut chord={newTerminalTabKeys} style={styles.newTabTooltipShortcut} />
) : null} ) : null}
</View> </View>
</TooltipContent> </TooltipContent>
@@ -499,7 +499,7 @@ export function WorkspaceDesktopTabsRow({
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane right</Text> <Text style={styles.newTabTooltipText}>Split pane right</Text>
{splitRightKeys ? ( {splitRightKeys ? (
<Shortcut keys={splitRightKeys} style={styles.newTabTooltipShortcut} /> <Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null} ) : null}
</View> </View>
</TooltipContent> </TooltipContent>
@@ -520,7 +520,7 @@ export function WorkspaceDesktopTabsRow({
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane down</Text> <Text style={styles.newTabTooltipText}>Split pane down</Text>
{splitDownKeys ? ( {splitDownKeys ? (
<Shortcut keys={splitDownKeys} style={styles.newTabTooltipShortcut} /> <Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null} ) : null}
</View> </View>
</TooltipContent> </TooltipContent>
@@ -4,7 +4,9 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
beforeEach(() => { beforeEach(() => {
useKeyboardShortcutsStore.setState({ useKeyboardShortcutsStore.setState({
commandCenterOpen: false, commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false, shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false, altDown: false,
cmdOrCtrlDown: false, cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [], sidebarShortcutWorkspaceTargets: [],
@@ -18,4 +20,10 @@ describe("keyboard-shortcuts-store", () => {
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true); useKeyboardShortcutsStore.getState().setCommandCenterOpen(true);
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true); expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
}); });
it("toggles shortcut capture state", () => {
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(false);
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(true);
});
}); });
@@ -5,6 +5,7 @@ interface KeyboardShortcutsState {
commandCenterOpen: boolean; commandCenterOpen: boolean;
projectPickerOpen: boolean; projectPickerOpen: boolean;
shortcutsDialogOpen: boolean; shortcutsDialogOpen: boolean;
capturingShortcut: boolean;
altDown: boolean; altDown: boolean;
cmdOrCtrlDown: boolean; cmdOrCtrlDown: boolean;
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */ /** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
@@ -15,6 +16,7 @@ interface KeyboardShortcutsState {
setCommandCenterOpen: (open: boolean) => void; setCommandCenterOpen: (open: boolean) => void;
setProjectPickerOpen: (open: boolean) => void; setProjectPickerOpen: (open: boolean) => void;
setShortcutsDialogOpen: (open: boolean) => void; setShortcutsDialogOpen: (open: boolean) => void;
setCapturingShortcut: (capturing: boolean) => void;
setAltDown: (down: boolean) => void; setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void; setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void; setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
@@ -26,6 +28,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
commandCenterOpen: false, commandCenterOpen: false,
projectPickerOpen: false, projectPickerOpen: false,
shortcutsDialogOpen: false, shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false, altDown: false,
cmdOrCtrlDown: false, cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [], sidebarShortcutWorkspaceTargets: [],
@@ -34,6 +37,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }), setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }), setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
setAltDown: (down) => set({ altDown: down }), setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }), setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutWorkspaceTargets: (targets) => setSidebarShortcutWorkspaceTargets: (targets) =>
+1 -1
View File
@@ -5,7 +5,7 @@ export type ShortcutOs = "mac" | "non-mac";
const KEY_DISPLAY: Record<string, string> = { const KEY_DISPLAY: Record<string, string> = {
Backspace: "⌫", Backspace: "⌫",
Enter: "↩", Enter: "↩",
Esc: "⎋", Esc: "Esc",
Space: "␣", Space: "␣",
Left: "←", Left: "←",
Right: "→", Right: "→",