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:
Bohdan Triapitsyn
2026-08-26 14:52:31 +03:00
parent 76f977e580
commit 7977842e1e
44 changed files with 389 additions and 322 deletions
@@ -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',
+13 -31
View File
@@ -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'