feat(ui): redesign the default shortcut layout around a mod+k leader
Single chords stay for everyday actions; open/go actions move to two-step mod+k sequences; held mod+digit switches header session tabs and held mod+alt+digit switches context panel surfaces. Rare actions leave the shortcut schema for the command palette, every remaining action ships with a default binding, and stored overrides from the old layout reset once. Key matching now follows the physical key on non-Latin layouts and for Option-modified digits on macOS, including in the recording dialog.
This commit is contained in:
@@ -1471,17 +1471,6 @@ export const Header: React.FC = () => {
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
},
|
||||
// The desktop menu holds one destination now, so this shortcut opens it
|
||||
// rather than cycling. The binding is kept: it is user-configurable and
|
||||
// silently dropping it would break existing setups.
|
||||
cycle_services_tab: () => {
|
||||
if (servicesTabs.length === 0) return false;
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
},
|
||||
toggle_context_plan: () => {
|
||||
handleOpenContextPlan();
|
||||
},
|
||||
});
|
||||
|
||||
const desktopSidebarActions = (
|
||||
|
||||
@@ -53,17 +53,19 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
persist(nextOverrides);
|
||||
};
|
||||
const shortcutDisplay = (action: CustomizableShortcutAction): string => {
|
||||
const isSurfaceSwitch = action.id === 'switch_context_surface';
|
||||
const combo = isSurfaceSwitch
|
||||
const isPrefixStyle = 'prefixStyle' in action && action.prefixStyle;
|
||||
const combo = isPrefixStyle
|
||||
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
|
||||
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
const formatted = formatShortcutForDisplay(
|
||||
combo,
|
||||
t('settings.openchamber.keyboardShortcuts.unassigned'),
|
||||
);
|
||||
return isSurfaceSwitch && combo && combo !== UNASSIGNED_SHORTCUT
|
||||
? `${formatted}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
|
||||
: formatted;
|
||||
if (!isPrefixStyle || !combo || combo === UNASSIGNED_SHORTCUT) return formatted;
|
||||
const suffix = action.id === 'switch_session_tab'
|
||||
? t('settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix')
|
||||
: t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix');
|
||||
return `${formatted}${suffix}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -104,15 +106,17 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => resetOne(action.id)}
|
||||
>
|
||||
{t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
{action.id in shortcutOverrides ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => resetOne(action.id)}
|
||||
>
|
||||
{t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
) : null}
|
||||
</SettingsFieldRow>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { settleShortcutRecordingState, updateShortcutRecordingState } from './Sh
|
||||
const emptyState = { chords: [], livePreview: null, settled: false };
|
||||
|
||||
function keyEvent(key: string, modifiers: Partial<Record<'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey', boolean>> = {}) {
|
||||
return { key, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers };
|
||||
const code = /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : /^[0-9]$/.test(key) ? `Digit${key}` : key;
|
||||
return { key, code, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers };
|
||||
}
|
||||
|
||||
describe('ShortcutRecordingDialog recording state', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getShortcutBindingConflicts,
|
||||
isRiskyBrowserShortcut,
|
||||
keyToShortcutToken,
|
||||
resolveShortcutEventKey,
|
||||
normalizeCombo,
|
||||
type ShortcutActionId,
|
||||
type ShortcutBindingConflict,
|
||||
@@ -27,6 +28,7 @@ const SECOND_CHORD_TIMEOUT_MS = 3000;
|
||||
|
||||
interface RecordingKeyboardEvent {
|
||||
altKey: boolean;
|
||||
code: string;
|
||||
ctrlKey: boolean;
|
||||
isComposing: boolean;
|
||||
key: string;
|
||||
@@ -84,7 +86,7 @@ function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | nu
|
||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null;
|
||||
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
|
||||
|
||||
const key = keyToShortcutToken(event.key);
|
||||
const key = keyToShortcutToken(resolveShortcutEventKey(event));
|
||||
if (!key) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
@@ -200,7 +202,8 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) {
|
||||
const isPrefixStyleAction = Boolean(action && 'prefixStyle' in action && action.prefixStyle);
|
||||
if (phase === 'keyup' && isPrefixStyleAction && recording.chords.length === 0) {
|
||||
const modifierCombo = modifierKeyUpToCombo(event);
|
||||
if (modifierCombo) {
|
||||
setRecording({ chords: [modifierCombo], livePreview: null, settled: true });
|
||||
@@ -209,6 +212,7 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
||||
}
|
||||
const nextRecording = updateShortcutRecordingState(recording, {
|
||||
altKey: event.altKey,
|
||||
code: event.nativeEvent.code,
|
||||
ctrlKey: event.ctrlKey,
|
||||
isComposing: event.nativeEvent.isComposing,
|
||||
key: event.key,
|
||||
@@ -216,7 +220,7 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
||||
repeat: event.repeat,
|
||||
shiftKey: event.shiftKey,
|
||||
}, phase);
|
||||
setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1
|
||||
setRecording(isPrefixStyleAction && nextRecording.chords.length > 1
|
||||
? recording
|
||||
: nextRecording);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,21 @@ export const useSessionSearchEffects = ({
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [enabled, isSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
// The open_session_list shortcut lands here when the sidebar is visible:
|
||||
// the session list is already on screen, so the shortcut opens its search.
|
||||
React.useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handleOpenRequest = () => {
|
||||
setIsSessionSearchOpen(true);
|
||||
sessionSearchInputRef.current?.focus();
|
||||
sessionSearchInputRef.current?.select();
|
||||
};
|
||||
window.addEventListener('openchamber:sidebar-session-search', handleOpenRequest);
|
||||
return () => window.removeEventListener('openchamber:sidebar-session-search', handleOpenRequest);
|
||||
}, [enabled, setIsSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') {
|
||||
return;
|
||||
|
||||
@@ -37,7 +37,8 @@ import { toast } from '@/components/ui';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo, shortcutRegistry } from '@/lib/shortcuts';
|
||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
||||
|
||||
@@ -230,6 +231,25 @@ export const CommandPalette: React.FC = () => {
|
||||
if (currentDirectory) openContextOverview(currentDirectory);
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'cycle-theme',
|
||||
title: t('commandPalette.item.cycleTheme'),
|
||||
icon: <Icon name="palette" className="mr-2 h-4 w-4" />,
|
||||
shortcutId: 'cycle_theme',
|
||||
searchText: t('commandPalette.item.cycleTheme'),
|
||||
onSelect: run(() => {
|
||||
shortcutRegistry.invoke('cycle_theme');
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open-status',
|
||||
title: t('commandPalette.item.showOpenCodeStatus'),
|
||||
icon: <Icon name="pulse" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.showOpenCodeStatus'),
|
||||
onSelect: run(() => {
|
||||
void showOpenCodeStatus();
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open-settings',
|
||||
title: t('commandPalette.item.openSettings'),
|
||||
@@ -239,6 +259,15 @@ export const CommandPalette: React.FC = () => {
|
||||
onSelect: run(() => setSettingsDialogOpen(true)),
|
||||
},
|
||||
];
|
||||
list.push({
|
||||
id: 'toggle-memory-debug',
|
||||
title: t('commandPalette.item.toggleMemoryDebug'),
|
||||
icon: <Icon name="bug" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.toggleMemoryDebug'),
|
||||
onSelect: run(() => {
|
||||
window.dispatchEvent(new CustomEvent('openchamber:memory-debug-toggle'));
|
||||
}),
|
||||
});
|
||||
if (canUseElectronDesktopIPC()) {
|
||||
list.splice(1, 0, {
|
||||
id: 'new-mini-chat',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useUIStore } from "@/stores/useUIStore";
|
||||
import {
|
||||
getEffectiveShortcutCombo,
|
||||
getEffectiveShortcutPrefix,
|
||||
getShortcutAction,
|
||||
formatShortcutForDisplay,
|
||||
type ShortcutActionId,
|
||||
@@ -156,24 +157,6 @@ export const HelpDialog: React.FC = () => {
|
||||
{
|
||||
categoryKey: "helpDialog.section.panels",
|
||||
items: [
|
||||
{
|
||||
id: 'toggle_right_sidebar',
|
||||
descriptionKey: 'helpDialog.item.toggleRightSidebar',
|
||||
icon: "layout-right",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_right_sidebar_git',
|
||||
descriptionKey: 'helpDialog.item.openRightSidebarGitTab',
|
||||
icon: "git-branch",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_right_sidebar_files',
|
||||
descriptionKey: 'helpDialog.item.openRightSidebarFilesTab',
|
||||
icon: "layout-right",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'toggle_terminal',
|
||||
descriptionKey: 'helpDialog.item.toggleTerminalDock',
|
||||
@@ -187,14 +170,13 @@ export const HelpDialog: React.FC = () => {
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'toggle_context_plan',
|
||||
descriptionKey: 'helpDialog.item.togglePlanContextPanel',
|
||||
icon: "time",
|
||||
keys: '',
|
||||
keys: [`${formatShortcutForDisplay(getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides))} + 1...0`],
|
||||
descriptionKey: "helpDialog.item.switchContextSurface",
|
||||
icon: "layout-right",
|
||||
},
|
||||
{
|
||||
keys: [`${formatShortcutForDisplay('mod')} + 1...0`],
|
||||
descriptionKey: "helpDialog.item.switchContextSurface",
|
||||
keys: [`${formatShortcutForDisplay(getEffectiveShortcutPrefix('switch_session_tab', shortcutOverrides))} + 1...9`],
|
||||
descriptionKey: "helpDialog.item.switchSessionTab",
|
||||
icon: "layout-right",
|
||||
},
|
||||
],
|
||||
@@ -214,12 +196,6 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: "stack",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'cycle_services_tab',
|
||||
descriptionKey: 'helpDialog.item.cycleServicesTab',
|
||||
icon: "stack",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_settings',
|
||||
descriptionKey: "helpDialog.item.openSettings",
|
||||
@@ -258,6 +234,12 @@ export const HelpDialog: React.FC = () => {
|
||||
const descriptionKey = shortcut.descriptionKey
|
||||
?? (action?.customizable ? action.settingsLabelKey : undefined);
|
||||
if (!descriptionKey) return null;
|
||||
// This dialog lists what the keyboard can do right now;
|
||||
// an action without a binding belongs to the command
|
||||
// palette and Settings, not here.
|
||||
if (shortcut.id && !getEffectiveShortcutCombo(shortcut.id, shortcutOverrides)) {
|
||||
return null;
|
||||
}
|
||||
const displayKeys = shortcut.id
|
||||
? renderShortcut(
|
||||
shortcut.id,
|
||||
@@ -320,7 +302,7 @@ export const HelpDialog: React.FC = () => {
|
||||
• {t('helpDialog.proTips.recentSessions')}
|
||||
</li>
|
||||
<li>
|
||||
• {t('helpDialog.proTips.themeCycling')}
|
||||
• {t('helpDialog.proTips.leaderSequences')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,18 @@ import type React from 'react';
|
||||
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
|
||||
function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
|
||||
function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
|
||||
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
|
||||
if (event.key.toLowerCase() === 'n') return 'ArrowDown';
|
||||
if (event.key.toLowerCase() === 'p') return 'ArrowUp';
|
||||
// `code` covers non-Latin layouts, where `key` is the layout's own letter.
|
||||
if (event.key.toLowerCase() === 'n' || event.code === 'KeyN') return 'ArrowDown';
|
||||
if (event.key.toLowerCase() === 'p' || event.code === 'KeyP') return 'ArrowUp';
|
||||
return null;
|
||||
}
|
||||
|
||||
type DropdownNavigationEvent = Pick<
|
||||
React.KeyboardEvent<HTMLElement>,
|
||||
| 'altKey'
|
||||
| 'code'
|
||||
| 'ctrlKey'
|
||||
| 'defaultPrevented'
|
||||
| 'isPropagationStopped'
|
||||
|
||||
Reference in New Issue
Block a user