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:
@@ -24,18 +24,23 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
getVisibleContextRailSurfaces,
|
||||
sortContextSurfaces,
|
||||
type ContextSurfaceDescriptor,
|
||||
} from '@/lib/surfaces/registry';
|
||||
import {
|
||||
getEffectiveShortcutPrefix,
|
||||
isShortcutPrefixHeld,
|
||||
} from '@/lib/shortcuts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
const RAIL_TOOLTIP_DELAY_MS = 150;
|
||||
// Tablet width and up: below this the walkthrough cannot show a stop and its
|
||||
// code side by side, which is the whole point of the surface.
|
||||
const WALKTHROUGH_MIN_WIDTH = 768;
|
||||
// Hold the surface-switch modifier for this long before revealing the order
|
||||
// number badges on the rail icons.
|
||||
const RAIL_NUMBER_HOLD_DELAY_MS = 500;
|
||||
const EMPTY_TABS: never[] = [];
|
||||
|
||||
type RailItemProps = {
|
||||
@@ -44,6 +49,8 @@ type RailItemProps = {
|
||||
showActivityDot: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
orderNumber?: number | null;
|
||||
showOrderNumber?: boolean;
|
||||
onSelect: (surface: ContextSurfaceDescriptor) => void;
|
||||
};
|
||||
|
||||
@@ -53,6 +60,8 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
||||
showActivityDot,
|
||||
label,
|
||||
description,
|
||||
orderNumber,
|
||||
showOrderNumber,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
@@ -86,12 +95,20 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
||||
) : (
|
||||
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
|
||||
)}
|
||||
{showActivityDot ? (
|
||||
{showActivityDot && !showOrderNumber ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
||||
/>
|
||||
) : null}
|
||||
{showOrderNumber && orderNumber != null ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-0 top-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-surface-muted px-1 text-[0.625rem] font-medium leading-none text-muted-foreground"
|
||||
>
|
||||
{orderNumber === 10 ? '0' : orderNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
@@ -114,10 +131,86 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
|
||||
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const { screenWidth } = useDeviceInfo();
|
||||
const gitStatus = useGitStatus(directoryKey || null);
|
||||
|
||||
const surfaceSwitchPrefix = React.useMemo(
|
||||
() => getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides),
|
||||
[shortcutOverrides],
|
||||
);
|
||||
const [revealNumbers, setRevealNumbers] = React.useState(false);
|
||||
|
||||
// While the surface-switch modifier is held for RAIL_NUMBER_HOLD_DELAY_MS,
|
||||
// reveal the order number badges so users can see which digit maps to which
|
||||
// rail icon. Releasing (or losing focus) dismisses them, and pressing a
|
||||
// number key while the chord is armed consumes them for this hold — they
|
||||
// only come back on the next press-and-hold.
|
||||
React.useEffect(() => {
|
||||
const held = new Set<string>();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let consumedWhileHeld = false;
|
||||
|
||||
const isDigitKey = (key: string) => key.length === 1 && key >= '0' && key <= '9';
|
||||
|
||||
const update = () => {
|
||||
const armed = isShortcutPrefixHeld(surfaceSwitchPrefix, held);
|
||||
if (armed) {
|
||||
if (!consumedWhileHeld && timer === null) {
|
||||
timer = setTimeout(() => setRevealNumbers(true), RAIL_NUMBER_HOLD_DELAY_MS);
|
||||
}
|
||||
} else {
|
||||
consumedWhileHeld = false;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
setRevealNumbers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
held.add(e.key.toLowerCase());
|
||||
if (isDigitKey(e.key) && isShortcutPrefixHeld(surfaceSwitchPrefix, held)) {
|
||||
consumedWhileHeld = true;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
setRevealNumbers(false);
|
||||
return;
|
||||
}
|
||||
update();
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
held.delete(e.key.toLowerCase());
|
||||
update();
|
||||
};
|
||||
const onWindowBlur = () => {
|
||||
held.clear();
|
||||
consumedWhileHeld = false;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
setRevealNumbers(false);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('keyup', onKeyUp, true);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('keyup', onKeyUp, true);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [surfaceSwitchPrefix]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
|
||||
@@ -128,22 +221,13 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const activeMode = panelState?.isOpen ? activeTab?.mode ?? null : null;
|
||||
const changedFilesCount = gitStatus?.files.length ?? 0;
|
||||
|
||||
// Content-driven surfaces are hidden (not disabled) until content exists;
|
||||
// an existing tab keeps them visible even if the content source went away.
|
||||
const surfaces = React.useMemo(() => {
|
||||
return sortContextSurfaces(contextRailOrder).filter((surface) => {
|
||||
if (surface.id === 'plan' && !planModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
// The walkthrough needs room for a stop list beside real code, and its
|
||||
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
|
||||
if (surface.id === 'walkthrough' && (isVSCodeRuntime() || screenWidth < WALKTHROUGH_MIN_WIDTH)) {
|
||||
return false;
|
||||
}
|
||||
if (surface.availability === 'has-content') {
|
||||
return tabs.some((tab) => tab.mode === surface.mode);
|
||||
}
|
||||
return true;
|
||||
return getVisibleContextRailSurfaces({
|
||||
railOrder: contextRailOrder,
|
||||
planModeEnabled,
|
||||
isVSCode: isVSCodeRuntime(),
|
||||
screenWidth,
|
||||
tabs,
|
||||
});
|
||||
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
||||
|
||||
@@ -174,7 +258,7 @@ export const ContextPanelRail: React.FC = () => {
|
||||
>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={surfaces.map((surface) => surface.id)} strategy={verticalListSortingStrategy}>
|
||||
{surfaces.map((surface) => (
|
||||
{surfaces.map((surface, index) => (
|
||||
<ContextPanelRailItem
|
||||
key={surface.id}
|
||||
surface={surface}
|
||||
@@ -182,6 +266,8 @@ export const ContextPanelRail: React.FC = () => {
|
||||
showActivityDot={surface.id === 'git' && changedFilesCount > 0}
|
||||
label={t(surface.labelKey)}
|
||||
description={t(surface.descriptionKey)}
|
||||
orderNumber={index + 1}
|
||||
showOrderNumber={revealNumbers}
|
||||
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
formatShortcutForDisplay,
|
||||
getCustomizableShortcutActions,
|
||||
getEffectiveShortcutCombo,
|
||||
getEffectiveShortcutPrefix,
|
||||
isRiskyBrowserShortcut,
|
||||
keyToShortcutToken,
|
||||
normalizeCombo,
|
||||
@@ -49,6 +50,35 @@ const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): Sho
|
||||
return normalizeCombo(parts.join('+'));
|
||||
};
|
||||
|
||||
// Prefix capture for chord-style shortcuts (e.g. "switch context panel
|
||||
// surface"): a bare modifier press is accepted so the prefix can be just the
|
||||
// primary modifier (default) or a modifier + key chord like `mod+p`.
|
||||
const keyboardEventToPrefixCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
parts.push('mod');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
parts.push('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
parts.push('alt');
|
||||
}
|
||||
|
||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
}
|
||||
|
||||
const keyToken = keyToShortcutToken(event.key);
|
||||
if (!keyToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parts.push(keyToken);
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
};
|
||||
|
||||
export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
@@ -211,10 +241,19 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
|
||||
<div>
|
||||
{actions.map((action, index) => {
|
||||
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
const isSurfaceSwitch = action.id === 'switch_context_surface';
|
||||
const effective = isSurfaceSwitch
|
||||
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
|
||||
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
const draft = draftByAction[action.id];
|
||||
const displayCombo = draft ?? effective;
|
||||
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
|
||||
const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT;
|
||||
const displayValue = capturingActionId === action.id
|
||||
? t('settings.openchamber.keyboardShortcuts.field.pressKeys')
|
||||
: isSurfaceSwitch && !isUnassignedDisplay
|
||||
? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
|
||||
: formatShortcutForDisplay(displayCombo);
|
||||
|
||||
return (
|
||||
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
|
||||
@@ -224,7 +263,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
>
|
||||
<Input
|
||||
readOnly
|
||||
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)}
|
||||
value={displayValue}
|
||||
onFocus={() => {
|
||||
setCapturingActionId(action.id);
|
||||
setErrorText('');
|
||||
@@ -243,7 +282,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const combo = keyboardEventToCombo(event);
|
||||
const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event);
|
||||
if (!combo) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SidebarFooter({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label={t('sessions.sidebar.footer.actions.shortcuts')}>
|
||||
<Icon name="question" className="h-4.5 w-4.5" />
|
||||
<Icon name="command" className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.footer.actions.shortcuts')}</p></TooltipContent>
|
||||
|
||||
@@ -175,6 +175,11 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: "time",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + 1...0`],
|
||||
descriptionKey: "helpDialog.item.switchContextSurface",
|
||||
icon: "layout-right",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -186,11 +191,6 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: "palette",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + 1...9`],
|
||||
descriptionKey: "helpDialog.item.switchProject",
|
||||
icon: "layout-left",
|
||||
},
|
||||
{
|
||||
id: 'toggle_services_menu',
|
||||
descriptionKey: 'helpDialog.item.toggleServicesMenu',
|
||||
@@ -218,7 +218,7 @@ export const HelpDialog: React.FC = () => {
|
||||
<DialogContent className="max-w-2xl w-[min(42rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon name="settings-3" className="h-5 w-5" />
|
||||
<Icon name="command" className="h-5 w-5" />
|
||||
{t('helpDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1043,6 +1043,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Datei-Tab der rechten Seitenleiste öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster',
|
||||
|
||||
@@ -1558,7 +1558,7 @@ export const dict = {
|
||||
'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten',
|
||||
'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)',
|
||||
'helpDialog.item.switchProject': 'Projekt wechseln',
|
||||
'helpDialog.item.switchContextSurface': 'Kontextpanel-Oberfläche wechseln (Zahlentaste)',
|
||||
'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten',
|
||||
'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen',
|
||||
'helpDialog.item.openSettings': 'Einstellungen öffnen',
|
||||
|
||||
@@ -1108,6 +1108,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
|
||||
|
||||
@@ -1710,8 +1710,8 @@ export const dict = {
|
||||
'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock',
|
||||
'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel',
|
||||
'helpDialog.item.switchContextSurface': 'Switch Context Panel Surface (number key)',
|
||||
'helpDialog.item.cycleTheme': 'Cycle Theme (Light → Dark → System)',
|
||||
'helpDialog.item.switchProject': 'Switch Project',
|
||||
'helpDialog.item.toggleServicesMenu': 'Toggle Services Menu',
|
||||
'helpDialog.item.cycleServicesTab': 'Cycle Services Tab',
|
||||
'helpDialog.item.openSettings': 'Open Settings',
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos',
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat",
|
||||
|
||||
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal",
|
||||
"helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan",
|
||||
"helpDialog.item.cycleTheme": "Cambiar tema (Claro → Oscuro → Sistema)",
|
||||
"helpDialog.item.switchProject": "Cambiar proyecto",
|
||||
"helpDialog.item.switchContextSurface": "Cambiar superficie del panel de contexto (tecla numérica)",
|
||||
"helpDialog.item.toggleServicesMenu": "Mostrar u ocultar menú de servicios",
|
||||
"helpDialog.item.cycleServicesTab": "Cambiar pestaña de servicios",
|
||||
"helpDialog.item.openSettings": "Abrir configuración",
|
||||
|
||||
@@ -996,6 +996,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat',
|
||||
|
||||
@@ -1524,7 +1524,7 @@ export const dict = {
|
||||
'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan',
|
||||
'helpDialog.item.cycleTheme': 'Basculer le thème (clair → sombre → système)',
|
||||
'helpDialog.item.switchProject': 'Changer de projet',
|
||||
'helpDialog.item.switchContextSurface': 'Basculer la surface du panneau contextuel (touche numérique)',
|
||||
'helpDialog.item.toggleServicesMenu': 'Basculer le menu des services',
|
||||
'helpDialog.item.cycleServicesTab': 'Onglet Services de vélo',
|
||||
'helpDialog.item.openSettings': 'Ouvrir les paramètres',
|
||||
|
||||
@@ -1108,6 +1108,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
|
||||
|
||||
@@ -1707,7 +1707,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え',
|
||||
'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え',
|
||||
'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)',
|
||||
'helpDialog.item.switchProject': 'プロジェクトを切り替え',
|
||||
'helpDialog.item.switchContextSurface': 'コンテキストパネルのサーフェスを切り替え(数字キー)',
|
||||
'helpDialog.item.toggleServicesMenu': 'サービスの切り替え',
|
||||
'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え',
|
||||
'helpDialog.item.openSettings': '設定を開く',
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
|
||||
|
||||
@@ -1713,7 +1713,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기',
|
||||
'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환',
|
||||
'helpDialog.item.cycleTheme': '테마 순환(라이트 → 다크 → 시스템)',
|
||||
'helpDialog.item.switchProject': '프로젝트 전환',
|
||||
'helpDialog.item.switchContextSurface': '컨텍스트 패널 서피스 전환(숫자 키)',
|
||||
'helpDialog.item.toggleServicesMenu': '서비스 메뉴 전환',
|
||||
'helpDialog.item.cycleServicesTab': '서비스 탭 순환',
|
||||
'helpDialog.item.openSettings': '설정 열기',
|
||||
|
||||
@@ -820,6 +820,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Przełącz powierzchnię panelu kontekstu',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
|
||||
|
||||
@@ -2333,7 +2333,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git',
|
||||
'helpDialog.item.openSettings': 'Otwórz ustawienia',
|
||||
'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)',
|
||||
'helpDialog.item.switchProject': 'Przełącz projekt',
|
||||
'helpDialog.item.switchContextSurface': 'Przełącz powierzchnię panelu kontekstu (klawisz liczbowy)',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu',
|
||||
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
|
||||
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos',
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat",
|
||||
|
||||
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal",
|
||||
"helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano",
|
||||
"helpDialog.item.cycleTheme": "Alternar tema (Claro → Escuro → Sistema)",
|
||||
"helpDialog.item.switchProject": "Alternar projeto",
|
||||
"helpDialog.item.switchContextSurface": "Alternar superfície do painel de contexto (tecla numérica)",
|
||||
"helpDialog.item.toggleServicesMenu": "Mostrar ou ocultar menu de serviços",
|
||||
"helpDialog.item.cycleServicesTab": "Alternar aba de serviços",
|
||||
"helpDialog.item.openSettings": "Abrir configurações",
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів',
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
|
||||
|
||||
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал",
|
||||
"helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану",
|
||||
"helpDialog.item.cycleTheme": "Перемкнути тему (Світла → Темна → Системна)",
|
||||
"helpDialog.item.switchProject": "Перемкнути проєкт",
|
||||
"helpDialog.item.switchContextSurface": "Перемкнути поверхню панелі контексту (цифрова клавіша)",
|
||||
"helpDialog.item.toggleServicesMenu": "Перемкнути меню сервісів",
|
||||
"helpDialog.item.cycleServicesTab": "Перемкнути вкладку сервісів",
|
||||
"helpDialog.item.openSettings": "Відкрити налаштування",
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '打开文件界面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口',
|
||||
|
||||
@@ -1677,7 +1677,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.toggleTerminalExpanded': '切换终端展开状态',
|
||||
'helpDialog.item.togglePlanContextPanel': '切换计划上下文面板',
|
||||
'helpDialog.item.cycleTheme': '循环切换主题(浅色 → 深色 → 跟随系统)',
|
||||
'helpDialog.item.switchProject': '切换项目',
|
||||
'helpDialog.item.switchContextSurface': '切换上下文面板界面(数字键)',
|
||||
'helpDialog.item.toggleServicesMenu': '切换服务菜单',
|
||||
'helpDialog.item.cycleServicesTab': '循环服务标签',
|
||||
'helpDialog.item.openSettings': '打开设置',
|
||||
|
||||
@@ -982,6 +982,8 @@
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '開啟檔案介面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗',
|
||||
|
||||
@@ -1681,7 +1681,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.toggleTerminalExpanded': '切換終端機展開狀態',
|
||||
'helpDialog.item.togglePlanContextPanel': '切換計畫上下文面板',
|
||||
'helpDialog.item.cycleTheme': '循環切換主題(淺色 → 深色 → 跟隨系統)',
|
||||
'helpDialog.item.switchProject': '切換專案',
|
||||
'helpDialog.item.switchContextSurface': '切換上下文面板介面(數字鍵)',
|
||||
'helpDialog.item.toggleServicesMenu': '切換服務選單',
|
||||
'helpDialog.item.cycleServicesTab': '循環服務標籤',
|
||||
'helpDialog.item.openSettings': '開啟設定',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
eventMatchesShortcutPrefix,
|
||||
getEffectiveShortcutPrefix,
|
||||
isShortcutPrefixHeld,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
} from './shortcuts';
|
||||
|
||||
describe('getEffectiveShortcutPrefix', () => {
|
||||
test('falls back to the action default (bare mod) when unset', () => {
|
||||
expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod');
|
||||
});
|
||||
|
||||
test('honors modifier + key overrides', () => {
|
||||
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'mod+p' })).toBe('mod+p');
|
||||
});
|
||||
|
||||
test('honors modifier-only overrides', () => {
|
||||
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'shift' })).toBe('shift');
|
||||
});
|
||||
|
||||
test('returns UNASSIGNED for an explicit unassignment', () => {
|
||||
expect(
|
||||
getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: UNASSIGNED_SHORTCUT }),
|
||||
).toBe(UNASSIGNED_SHORTCUT);
|
||||
});
|
||||
|
||||
test('returns empty string for an unknown action', () => {
|
||||
expect(getEffectiveShortcutPrefix('does_not_exist', {})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isShortcutPrefixHeld', () => {
|
||||
test('false for an unassigned prefix', () => {
|
||||
expect(isShortcutPrefixHeld(UNASSIGNED_SHORTCUT, new Set(['control']))).toBe(false);
|
||||
});
|
||||
|
||||
test('requires the prefix primary key to be held', () => {
|
||||
expect(isShortcutPrefixHeld('mod+p', new Set(['control']))).toBe(false);
|
||||
expect(isShortcutPrefixHeld('mod+p', new Set(['control', 'p']))).toBe(true);
|
||||
});
|
||||
|
||||
test('requires every prefix modifier to be held', () => {
|
||||
expect(isShortcutPrefixHeld('mod+shift', new Set(['control']))).toBe(false);
|
||||
expect(isShortcutPrefixHeld('mod+shift', new Set(['control', 'shift']))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const keydown = (key: string, mods: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyboardEvent =>
|
||||
({
|
||||
key,
|
||||
metaKey: mods.meta ?? false,
|
||||
ctrlKey: mods.ctrl ?? false,
|
||||
shiftKey: mods.shift ?? false,
|
||||
altKey: mods.alt ?? false,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
describe('eventMatchesShortcutPrefix', () => {
|
||||
test('matches a bare mod prefix when the primary modifier is held', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod')).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a bare mod prefix without the primary modifier', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', {}), 'mod')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects when the event carries modifiers the prefix does not expect', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true, shift: true }), 'mod')).toBe(false);
|
||||
});
|
||||
|
||||
test('requires the prefix primary key to be held at match time', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control']))).toBe(false);
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control', 'p']))).toBe(true);
|
||||
});
|
||||
|
||||
test('false for an unassigned prefix', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), UNASSIGNED_SHORTCUT)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,17 @@ const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
|
||||
'ctrl': '⌃',
|
||||
};
|
||||
|
||||
// Physical `event.key` values (lowercased) that satisfy each modifier while a
|
||||
// chord is being held. `mod` maps to the platform primary key; on web macOS it
|
||||
// accepts either Meta or Ctrl, matching eventMatchesShortcut.
|
||||
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
|
||||
'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
|
||||
'shift': ['shift'],
|
||||
'alt': ['alt'],
|
||||
'option': ['alt'],
|
||||
'ctrl': ['control'],
|
||||
};
|
||||
|
||||
const KEY_LABEL_MAP: Record<string, string> = {
|
||||
'comma': ',',
|
||||
'period': '.',
|
||||
@@ -207,6 +218,13 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
description: 'Open right sidebar and select Files',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'switch_context_surface',
|
||||
defaultCombo: 'mod',
|
||||
label: 'Switch context panel surface',
|
||||
description: 'Hold the modifier and press a number to open or close the matching rail icon',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'new_chat',
|
||||
defaultCombo: 'mod+n',
|
||||
@@ -240,24 +258,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
label: 'Clear input',
|
||||
description: 'Clear the input field',
|
||||
},
|
||||
{
|
||||
id: 'open_diff_panel',
|
||||
defaultCombo: 'mod+2',
|
||||
label: 'Open diff panel',
|
||||
description: 'Switch to the diff panel',
|
||||
},
|
||||
{
|
||||
id: 'open_terminal_panel',
|
||||
defaultCombo: 'mod+3',
|
||||
label: 'Open terminal panel',
|
||||
description: 'Switch to the terminal panel',
|
||||
},
|
||||
{
|
||||
id: 'open_git_panel',
|
||||
defaultCombo: 'mod+4',
|
||||
label: 'Open git panel',
|
||||
description: 'Switch to the git panel',
|
||||
},
|
||||
{
|
||||
id: 'open_help',
|
||||
defaultCombo: 'mod+.',
|
||||
@@ -347,60 +347,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
label: 'Abort active run',
|
||||
description: 'Abort the currently running task (double press)',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_1',
|
||||
defaultCombo: 'mod+1',
|
||||
label: 'Switch to tab 1',
|
||||
description: 'Switch to the first tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_2',
|
||||
defaultCombo: 'mod+2',
|
||||
label: 'Switch to tab 2',
|
||||
description: 'Switch to the second tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_3',
|
||||
defaultCombo: 'mod+3',
|
||||
label: 'Switch to tab 3',
|
||||
description: 'Switch to the third tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_4',
|
||||
defaultCombo: 'mod+4',
|
||||
label: 'Switch to tab 4',
|
||||
description: 'Switch to the fourth tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_5',
|
||||
defaultCombo: 'mod+5',
|
||||
label: 'Switch to tab 5',
|
||||
description: 'Switch to the fifth tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_6',
|
||||
defaultCombo: 'mod+6',
|
||||
label: 'Switch to tab 6',
|
||||
description: 'Switch to the sixth tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_7',
|
||||
defaultCombo: 'mod+7',
|
||||
label: 'Switch to tab 7',
|
||||
description: 'Switch to the seventh tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_8',
|
||||
defaultCombo: 'mod+8',
|
||||
label: 'Switch to tab 8',
|
||||
description: 'Switch to the eighth tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_9',
|
||||
defaultCombo: 'mod+9',
|
||||
label: 'Switch to tab 9',
|
||||
description: 'Switch to the ninth tab or project',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
|
||||
@@ -610,3 +556,126 @@ export function eventMatchesShortcut(
|
||||
export function getModifierLabel(): string {
|
||||
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the configurable prefix for chord-style shortcuts such as
|
||||
* "switch context panel surface", where a trailing digit key completes the
|
||||
* combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the
|
||||
* bare `mod` primary key) are honored so the prefix can omit a primary key.
|
||||
* Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix.
|
||||
*/
|
||||
export function getEffectiveShortcutPrefix(
|
||||
actionId: string,
|
||||
overrides?: Record<string, ShortcutCombo>,
|
||||
): ShortcutCombo {
|
||||
const action = getShortcutAction(actionId);
|
||||
if (!action) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const override = overrides?.[actionId];
|
||||
if (typeof override === 'string' && override.trim() !== '') {
|
||||
const normalized = normalizeCombo(override);
|
||||
if (normalized === UNASSIGNED_SHORTCUT) {
|
||||
return UNASSIGNED_SHORTCUT;
|
||||
}
|
||||
if (normalized) {
|
||||
const parsed = parseShortcut(normalized);
|
||||
if (parsed.modifiers.size > 0 || parsed.key) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return action.defaultCombo;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the physical keys required to "arm" a prefix combo are currently
|
||||
* held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at
|
||||
* least one alias must be held.
|
||||
*/
|
||||
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
|
||||
if (isUnassignedShortcut(prefixCombo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(prefixCombo);
|
||||
|
||||
for (const modifier of parsed.modifiers) {
|
||||
const aliases = MODIFIER_KEY_ALIASES[modifier];
|
||||
if (!aliases.some((alias) => heldKeys.has(alias))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches an activating keydown (the caller checks the event's own key, e.g. a
|
||||
* digit) against a chord prefix: the event's modifier state must match the
|
||||
* prefix's modifiers, and when the prefix has a primary key that key must
|
||||
* currently be held.
|
||||
*/
|
||||
export function eventMatchesShortcutPrefix(
|
||||
event: KeyboardEvent | React.KeyboardEvent,
|
||||
prefixCombo: ShortcutCombo,
|
||||
heldKeys?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (isUnassignedShortcut(prefixCombo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(prefixCombo);
|
||||
|
||||
const expectedMod = parsed.modifiers.has('mod');
|
||||
const expectedShift = parsed.modifiers.has('shift');
|
||||
const expectedAlt = parsed.modifiers.has('alt');
|
||||
const expectedCtrl = parsed.modifiers.has('ctrl');
|
||||
const isDesktopMac = isMacOS() && isDesktopShell();
|
||||
const isMac = isMacOS();
|
||||
|
||||
const modMatches = isDesktopMac
|
||||
? event.metaKey
|
||||
: isMac
|
||||
? (event.metaKey || event.ctrlKey)
|
||||
: event.ctrlKey;
|
||||
|
||||
if (expectedMod && !modMatches) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!expectedMod && event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedShift !== event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedAlt !== event.altKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedCtrl) {
|
||||
if (!event.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
|
||||
if (event.ctrlKey && !ctrlUsedAsMod) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
|
||||
- Rail order is user-reorderable and persisted globally in
|
||||
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
|
||||
registry's default order and appends any missing surfaces.
|
||||
- `getVisibleContextRailSurfaces` is the single visibility filter shared by the
|
||||
rail and the global surface-switch shortcut (`switch_context_surface` in
|
||||
`lib/shortcuts.ts`): it drops the plan surface unless plan mode is enabled,
|
||||
drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, and hides
|
||||
`has-content` surfaces until a tab of their mode exists. Both consumers use
|
||||
it so the digit shown on a rail badge always maps to the same surface the
|
||||
shortcut opens.
|
||||
|
||||
## Adding a surface
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
CONTEXT_SURFACES,
|
||||
getVisibleContextRailSurfaces,
|
||||
WALKTHROUGH_MIN_WIDTH,
|
||||
} from './registry';
|
||||
|
||||
const baseOptions = {
|
||||
railOrder: [],
|
||||
planModeEnabled: true,
|
||||
isVSCode: false,
|
||||
screenWidth: 1200,
|
||||
tabs: [],
|
||||
} as const;
|
||||
|
||||
describe('getVisibleContextRailSurfaces', () => {
|
||||
test('hides the plan surface while plan mode is disabled', () => {
|
||||
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, planModeEnabled: false });
|
||||
expect(surfaces.some((surface) => surface.id === 'plan')).toBe(false);
|
||||
expect(surfaces.some((surface) => surface.id === 'context')).toBe(true);
|
||||
});
|
||||
|
||||
test('shows the plan surface while plan mode is enabled', () => {
|
||||
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, planModeEnabled: true });
|
||||
expect(surfaces.some((surface) => surface.id === 'plan')).toBe(true);
|
||||
});
|
||||
|
||||
test('hides the walkthrough on VS Code and below the min width', () => {
|
||||
expect(getVisibleContextRailSurfaces({ ...baseOptions, isVSCode: true }).some((s) => s.id === 'walkthrough')).toBe(false);
|
||||
expect(
|
||||
getVisibleContextRailSurfaces({ ...baseOptions, screenWidth: WALKTHROUGH_MIN_WIDTH - 1 }).some((s) => s.id === 'walkthrough'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
getVisibleContextRailSurfaces({ ...baseOptions, screenWidth: WALKTHROUGH_MIN_WIDTH }).some((s) => s.id === 'walkthrough'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('hides content-driven surfaces until a matching tab exists', () => {
|
||||
const preview = CONTEXT_SURFACES.find((surface) => surface.id === 'preview');
|
||||
if (!preview) {
|
||||
throw new Error('preview surface missing from registry');
|
||||
}
|
||||
expect(preview.availability).toBe('has-content');
|
||||
expect(getVisibleContextRailSurfaces(baseOptions).some((s) => s.id === 'preview')).toBe(false);
|
||||
expect(getVisibleContextRailSurfaces({ ...baseOptions, tabs: [{ mode: preview.mode }] }).some((s) => s.id === 'preview')).toBe(true);
|
||||
});
|
||||
|
||||
test('respects the persisted user rail order', () => {
|
||||
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, railOrder: ['git', 'context'] });
|
||||
expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']);
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,10 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
|
||||
const SURFACE_BY_ID = new Map(CONTEXT_SURFACES.map((surface) => [surface.id, surface]));
|
||||
const FRACTION_BY_MODE = new Map(CONTEXT_SURFACES.map((surface) => [surface.mode, surface.defaultWidthFraction]));
|
||||
|
||||
// Tablet width and up: below this the walkthrough cannot show a stop and its
|
||||
// code side by side, which is the whole point of the surface.
|
||||
export const WALKTHROUGH_MIN_WIDTH = 768;
|
||||
|
||||
export const getContextSurfaceWidthFraction = (mode: ContextPanelMode): number => {
|
||||
return FRACTION_BY_MODE.get(mode) ?? 1 / 2;
|
||||
};
|
||||
@@ -187,3 +191,36 @@ export const sortContextSurfaces = (railOrder: readonly string[]): ContextSurfac
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
type VisibleRailSurfacesOptions = {
|
||||
railOrder: readonly string[];
|
||||
planModeEnabled: boolean;
|
||||
isVSCode: boolean;
|
||||
screenWidth: number;
|
||||
tabs: readonly { mode: ContextPanelMode }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The context panel rail's visible, user-ordered surfaces. Shared by the rail
|
||||
* (for rendering and number badges) and the global surface-switch shortcut so
|
||||
* both agree on which surface each digit maps to.
|
||||
*
|
||||
* Content-driven surfaces are hidden (not disabled) until content exists; an
|
||||
* existing tab keeps them visible even if the content source went away.
|
||||
*/
|
||||
export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOptions): ContextSurfaceDescriptor[] => {
|
||||
return sortContextSurfaces(options.railOrder).filter((surface) => {
|
||||
if (surface.id === 'plan' && !options.planModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
// The walkthrough needs room for a stop list beside real code, and its
|
||||
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
|
||||
if (surface.id === 'walkthrough' && (options.isVSCode || options.screenWidth < WALKTHROUGH_MIN_WIDTH)) {
|
||||
return false;
|
||||
}
|
||||
if (surface.availability === 'has-content') {
|
||||
return options.tabs.some((tab) => tab.mode === surface.mode);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user