feat(ui): numbered context-panel surface switching with configurable prefix
- Add switch_context_surface shortcut (default Cmd/Ctrl + 1..9, 0 for the 10th surface) that opens/closes/switches context panel rail surfaces by their visible order, configurable and persisted in Settings -> Shortcuts. - Show order-number badges on rail icons while the modifier is held >500ms; dismiss on release, blur, or a number press until the next press-and-hold. - Remove the legacy mod+2/3/4 (diff/terminal/git) and switch_tab_1..9 bindings so numbered surface switching goes only through the new mechanism. - Replace the help-dialog 'Switch Project' row with the surface-switch row and update the shortcuts footer/header icons to the command icon.
This commit is contained in:
@@ -10,10 +10,19 @@ import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
||||
import {
|
||||
eventMatchesShortcut,
|
||||
eventMatchesShortcutPrefix,
|
||||
getEffectiveShortcutCombo,
|
||||
getEffectiveShortcutPrefix,
|
||||
normalizeCombo,
|
||||
} from '@/lib/shortcuts';
|
||||
import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry';
|
||||
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
import { addSelectionToChat } from '@/lib/addSelectionToChat';
|
||||
@@ -29,6 +38,7 @@ export const useKeyboardShortcuts = () => {
|
||||
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
const currentShortcutDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
|
||||
// The terminal lives in the context panel; these mirror the rail behavior.
|
||||
const toggleTerminalSurface = React.useCallback(() => {
|
||||
@@ -64,6 +74,9 @@ export const useKeyboardShortcuts = () => {
|
||||
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
||||
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const themeModeRef = React.useRef(themeMode);
|
||||
// Currently held physical keys (lowercased), used to match chord prefixes
|
||||
// whose primary key must be held while the activating key is pressed.
|
||||
const heldKeysRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
themeModeRef.current = themeMode;
|
||||
@@ -80,6 +93,7 @@ export const useKeyboardShortcuts = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
|
||||
const switchSurfacePrefix = getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides);
|
||||
const dropdownTargetSelector = [
|
||||
'[data-slot="dropdown-menu-content"]',
|
||||
'[data-slot="select-content"]',
|
||||
@@ -448,16 +462,6 @@ export const useKeyboardShortcuts = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('open_diff_panel'))) {
|
||||
const state = useUIStore.getState();
|
||||
if (state.isMobile || !currentDirectory) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'diff');
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('toggle_terminal'))) {
|
||||
const { isMobile } = useUIStore.getState();
|
||||
if (isMobile) {
|
||||
@@ -478,6 +482,39 @@ export const useKeyboardShortcuts = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Configured prefix + digit (default: Cmd/Ctrl + 1..9, with 0 for the
|
||||
// 10th surface): open/close the matching context panel rail surface. The
|
||||
// digit maps to the currently visible rail order, matching the number
|
||||
// badges shown while holding the modifier. `e.repeat` guard keeps
|
||||
// holding a digit from toggling.
|
||||
const switchSurfaceDigit = e.key.length === 1 && e.key >= '0' && e.key <= '9'
|
||||
? (e.key === '0' ? 10 : Number(e.key))
|
||||
: null;
|
||||
if (switchSurfaceDigit !== null
|
||||
&& !e.repeat
|
||||
&& eventMatchesShortcutPrefix(e, switchSurfacePrefix, heldKeysRef.current)) {
|
||||
const state = useUIStore.getState();
|
||||
if (state.isMobile || !effectiveDirectory) {
|
||||
return;
|
||||
}
|
||||
const directory = normalizeContextPanelDirectoryKey(effectiveDirectory);
|
||||
const panelState = state.contextPanelByDirectory[directory];
|
||||
const visibleSurfaces = getVisibleContextRailSurfaces({
|
||||
railOrder: state.contextRailOrder,
|
||||
planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled,
|
||||
isVSCode: isVSCodeRuntime(),
|
||||
screenWidth: window.innerWidth,
|
||||
tabs: panelState?.tabs ?? [],
|
||||
});
|
||||
const target = visibleSurfaces[switchSurfaceDigit - 1];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
state.openContextSurface(directory, target.mode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays)
|
||||
if (eventMatchesShortcut(e, combo('open_model_selector'))) {
|
||||
const {
|
||||
@@ -618,11 +655,30 @@ export const useKeyboardShortcuts = () => {
|
||||
|
||||
};
|
||||
|
||||
// Track held physical keys so chord prefixes (e.g. a configured
|
||||
// `mod+p`) can require their primary key to stay held. Capture phase runs
|
||||
// before handleKeyDown, so the set is current when chord matching runs.
|
||||
const handleKeyHoldDown = (e: KeyboardEvent) => {
|
||||
heldKeysRef.current.add(e.key.toLowerCase());
|
||||
};
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
heldKeysRef.current.delete(e.key.toLowerCase());
|
||||
};
|
||||
const handleWindowBlur = () => {
|
||||
heldKeysRef.current.clear();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyHoldDown, true);
|
||||
window.addEventListener('keyup', handleKeyUp, true);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
|
||||
window.addEventListener('keydown', handleEscapeKeyDownCapture, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyHoldDown, true);
|
||||
window.removeEventListener('keyup', handleKeyUp, true);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
|
||||
window.removeEventListener('keydown', handleEscapeKeyDownCapture, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
@@ -650,6 +706,7 @@ export const useKeyboardShortcuts = () => {
|
||||
resetAbortPriming,
|
||||
currentSessionId,
|
||||
currentDirectory,
|
||||
effectiveDirectory,
|
||||
activeProject?.id,
|
||||
activeProject?.path,
|
||||
shortcutOverrides,
|
||||
|
||||
Reference in New Issue
Block a user