fix(ui): refine contextual shortcut interactions

This commit is contained in:
ChangeHow
2026-08-06 14:18:15 +08:00
parent 45a792d657
commit 23253b9481
33 changed files with 828 additions and 97 deletions
@@ -12,6 +12,7 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import {
Select,
SelectContent,
@@ -107,6 +108,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (openPicker === null || !shouldDismissDropdown(event)) return;
event.preventDefault();
event.stopPropagation();
setOpenPicker(null);
};
useKeybind('open_draft_project_picker', () => {
projectTriggerRef.current?.focus();
@@ -139,6 +146,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
>
<SelectTrigger
ref={projectTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
@@ -146,7 +154,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{<ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
<SelectContent fitContent onKeyDown={handlePickerKeyDown}>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
{<ProjectLabel project={project} theme={theme} />}
@@ -165,6 +173,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
@@ -172,7 +181,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-max min-w-48">
<SelectContent className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
@@ -17,6 +17,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
@@ -60,6 +61,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const openRafRef = React.useRef<number | null>(null);
const mouseUpTimeoutRef = React.useRef<number | null>(null);
const isMenuVisibleRef = React.useRef(false);
const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
const createSession = useSessionUIStore((state) => state.createSession);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
@@ -75,6 +77,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
React.useEffect(() => {
return () => {
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = null;
if (openRafRef.current !== null) {
window.cancelAnimationFrame(openRafRef.current);
openRafRef.current = null;
@@ -88,6 +92,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const hideMenu = React.useCallback(() => {
pendingSelectionRef.current = null;
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = null;
if (!isMenuVisibleRef.current) {
return;
@@ -123,12 +129,30 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
return Math.min(Math.max(anchorX, minX), maxX);
}, []);
const addMarkdownToChat = React.useCallback((markdownText: string) => {
const markdownBlock = wrapMarkdownSelectionForChat(markdownText);
setPendingInputText(markdownBlock, 'append');
hideMenu();
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
focusChatInput();
});
}, [hideMenu, setPendingInputText]);
const showMenu = React.useCallback(() => {
if (!pendingSelectionRef.current) return;
const { plainText, markdownText, rect } = pendingSelectionRef.current;
const shouldAnimateIn = !position.show;
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({
addToChat: () => addMarkdownToChat(markdownText),
dismiss: hideMenu,
});
// Position menu above the selection
const menuX = isMobile
? rect.left + rect.width / 2
@@ -154,7 +178,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
openRafRef.current = null;
});
}
}, [getDesktopClampedX, isMobile, position.show]);
}, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]);
React.useLayoutEffect(() => {
if (!position.show || isMobile || !menuRef.current) {
@@ -302,18 +326,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleAddToChat = React.useCallback(() => {
if (!selectedTextMarkdown) return;
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
setPendingInputText(markdownBlock, 'append');
hideMenu();
// Clear selection
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
focusChatInput();
});
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
addMarkdownToChat(selectedTextMarkdown);
}, [addMarkdownToChat, selectedTextMarkdown]);
const handleCreateNewSession = React.useCallback(async () => {
if (!selectedText) return;
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test';
import { updateShortcutRecordingState } from './ShortcutRecordingDialog';
import { settleShortcutRecordingState, updateShortcutRecordingState } from './ShortcutRecordingDialog';
const emptyState = { chords: [], livePreview: null };
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 };
@@ -14,13 +14,38 @@ describe('ShortcutRecordingDialog recording state', () => {
expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull();
});
test('records up to two chords', () => {
const first = updateShortcutRecordingState(emptyState, keyEvent('k', { ctrlKey: true }), 'keydown');
const second = updateShortcutRecordingState(first, keyEvent('p', { ctrlKey: true }), 'keydown');
const third = updateShortcutRecordingState(second, keyEvent('x', { ctrlKey: true }), 'keydown');
expect(first.chords).toEqual(['mod+k']);
expect(second.chords).toEqual(['mod+k', 'mod+p']);
expect(third.chords).toEqual(['mod+k', 'mod+p']);
test('waits after the first chord and settles when a second chord is recorded', () => {
const first = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
const second = updateShortcutRecordingState(first, keyEvent('p'), 'keydown');
const third = updateShortcutRecordingState(second, keyEvent('x'), 'keydown');
expect(first.chords).toEqual(['mod+s']);
expect(first.settled).toBe(false);
expect(second.chords).toEqual(['mod+s', 'p']);
expect(second.settled).toBe(true);
expect(third.chords).toEqual(['x']);
expect(third.settled).toBe(false);
});
test('settles a single chord for timeout and Confirm validation', () => {
const waiting = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
expect(settleShortcutRecordingState(waiting)).toEqual({ chords: ['mod+s'], livePreview: null, settled: true });
});
test('records at most three simultaneous keys', () => {
const previous = { chords: ['mod+k'], livePreview: null, settled: false };
const threeKeys = updateShortcutRecordingState(
previous,
keyEvent('s', { ctrlKey: true, shiftKey: true }),
'keydown',
);
const fourKeys = updateShortcutRecordingState(
previous,
keyEvent('s', { ctrlKey: true, metaKey: true, shiftKey: true }),
'keydown',
);
expect(threeKeys.chords).toEqual(['mod+k', 'mod+shift+s']);
expect(fourKeys.chords).toEqual(['mod+k']);
});
test('ignores repeat and IME events', () => {
@@ -29,9 +54,11 @@ describe('ShortcutRecordingDialog recording state', () => {
});
test('records Enter and Escape while Backspace removes the final chord', () => {
const state = { chords: ['mod+k', 'mod+p'], livePreview: null };
const state = { chords: ['mod+k', 'mod+p'], livePreview: null, settled: true };
expect(updateShortcutRecordingState(emptyState, keyEvent('Enter'), 'keydown').chords).toEqual(['enter']);
expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']);
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').chords).toEqual(['mod+k']);
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').settled).toBe(false);
expect(updateShortcutRecordingState({ chords: ['mod+k'], livePreview: null, settled: false }, keyEvent('Backspace'), 'keydown')).toEqual(emptyState);
});
});
@@ -22,6 +22,8 @@ import {
import { useI18n } from '@/lib/i18n';
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
const MAX_SHORTCUT_KEY_COUNT = 3;
const SECOND_CHORD_TIMEOUT_MS = 3000;
interface RecordingKeyboardEvent {
altKey: boolean;
@@ -36,6 +38,7 @@ interface RecordingKeyboardEvent {
interface ShortcutRecordingState {
chords: ShortcutCombo[];
livePreview: ShortcutCombo | null;
settled: boolean;
}
interface ShortcutRecordingDialogProps {
@@ -49,6 +52,19 @@ interface ShortcutRecordingDialogProps {
onOpenChange: (open: boolean) => void;
}
function getPhysicalKeyCount(
event: Pick<RecordingKeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
includeEventKey = false,
): number {
const keys = new Set<string>();
if (event.altKey) keys.add('alt');
if (event.ctrlKey) keys.add('control');
if (event.metaKey) keys.add('meta');
if (event.shiftKey) keys.add('shift');
if (includeEventKey) keys.add(event.key.toLowerCase());
return keys.size;
}
function isCustomizableConflict(
conflict: ShortcutBindingConflict,
): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } {
@@ -56,6 +72,7 @@ function isCustomizableConflict(
}
function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null {
if (getPhysicalKeyCount(event) > MAX_SHORTCUT_KEY_COUNT) return null;
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) parts.push('mod');
if (event.shiftKey) parts.push('shift');
@@ -65,6 +82,7 @@ function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null
function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null {
if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null;
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
const key = keyToShortcutToken(event.key);
if (!key) return null;
@@ -80,6 +98,7 @@ function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | nu
function modifierKeyUpToCombo(event: React.KeyboardEvent<HTMLDivElement>): ShortcutCombo | null {
const key = event.key.toLowerCase();
if (!MODIFIER_KEYS.has(key)) return null;
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
const parts: string[] = [];
if (event.metaKey || event.ctrlKey || key === 'meta' || key === 'control') parts.push('mod');
@@ -88,6 +107,11 @@ function modifierKeyUpToCombo(event: React.KeyboardEvent<HTMLDivElement>): Short
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
}
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
export function settleShortcutRecordingState(state: ShortcutRecordingState): ShortcutRecordingState {
return state.chords.length > 0 ? { ...state, livePreview: null, settled: true } : state;
}
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
export function updateShortcutRecordingState(
state: ShortcutRecordingState,
@@ -100,14 +124,19 @@ export function updateShortcutRecordingState(
}
if (event.key === 'Backspace') {
return { chords: state.chords.slice(0, -1), livePreview: null };
return { chords: state.chords.slice(0, -1), livePreview: null, settled: false };
}
const chord = keyboardEventToCombo(event);
if (chord) {
if (state.settled) {
return { chords: [chord], livePreview: null, settled: false };
}
const chords = state.chords.length < 2 ? [...state.chords, chord] : state.chords;
return {
chords: state.chords.length < 2 ? [...state.chords, chord] : state.chords,
chords,
livePreview: null,
settled: chords.length === 2,
};
}
@@ -122,27 +151,47 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
}) => {
const { t } = useI18n();
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
const [recording, setRecording] = React.useState<ShortcutRecordingState>({ chords: [], livePreview: null });
const conflictActionLabel = (conflict: ShortcutBindingConflict) => (
conflict.action.customizable
? actionLabel(conflict.action)
: formatShortcutForDisplay(conflict.action.defaultBinding)
);
const [recording, setRecording] = React.useState<ShortcutRecordingState>({ chords: [], livePreview: null, settled: false });
const recordingRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!action) return;
setRecording({ chords: [], livePreview: null });
setRecording({ chords: [], livePreview: null, settled: false });
recordingRef.current?.focus();
}, [action]);
const waitingForSecondChord = recording.chords.length === 1 && !recording.settled;
React.useEffect(() => {
if (!waitingForSecondChord) return;
const timeout = window.setTimeout(
() => setRecording(settleShortcutRecordingState),
SECOND_CHORD_TIMEOUT_MS,
);
return () => window.clearTimeout(timeout);
}, [waitingForSecondChord]);
const combo = normalizeCombo(recording.chords.join(' '));
const conflicts = React.useMemo(
() => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [],
[action, combo, overrides],
);
const protectedConflict = conflicts.find((conflict) => !conflict.action.customizable);
const protectedConflict = conflicts.find((conflict) => (
!conflict.action.customizable && conflict.kind !== 'contextual-prefix'
));
const customizableConflicts = conflicts.filter(isCustomizableConflict);
const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix');
const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact');
const contextualPrefixConflict = conflicts.find((conflict) => conflict.kind === 'contextual-prefix');
const close = () => onOpenChange(false);
const confirm = () => {
if (!recording.settled) setRecording(settleShortcutRecordingState);
if (!action || !combo || protectedConflict || prefixConflict) return;
onSave(action.id, combo, exactConflict?.action.id);
close();
@@ -150,10 +199,11 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
const handleRecordingEvent = (event: React.KeyboardEvent<HTMLDivElement>, phase: 'keydown' | 'keyup') => {
event.preventDefault();
event.stopPropagation();
if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) {
const modifierCombo = modifierKeyUpToCombo(event);
if (modifierCombo) {
setRecording({ chords: [modifierCombo], livePreview: null });
setRecording({ chords: [modifierCombo], livePreview: null, settled: true });
return;
}
}
@@ -167,7 +217,7 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
shiftKey: event.shiftKey,
}, phase);
setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1
? { ...nextRecording, chords: nextRecording.chords.slice(0, 1) }
? recording
: nextRecording);
};
@@ -215,21 +265,28 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
</div>
</div>
{protectedConflict ? (
{recording.settled && protectedConflict ? (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.openchamber.keyboardShortcuts.error.internalConflict')}
</p>
) : prefixConflict ? (
) : recording.settled && prefixConflict ? (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
</p>
) : null}
{exactConflict && !protectedConflict && !prefixConflict ? (
{recording.settled && exactConflict && !protectedConflict && !prefixConflict ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
</p>
) : null}
{combo && isRiskyBrowserShortcut(combo) ? (
{recording.settled && contextualPrefixConflict && !protectedConflict && !prefixConflict ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.warning.contextualPrefix', {
action: conflictActionLabel(contextualPrefixConflict),
})}
</p>
) : null}
{recording.settled && combo && isRiskyBrowserShortcut(combo) ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
</p>
@@ -242,7 +299,7 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
<Button
type="button"
size="sm"
disabled={!combo || Boolean(protectedConflict) || Boolean(prefixConflict)}
disabled={!combo || (recording.settled && (Boolean(protectedConflict) || Boolean(prefixConflict)))}
onClick={confirm}
>
{t('settings.openchamber.keyboardShortcuts.actions.confirm')}
@@ -1,7 +1,14 @@
import { expect, test } from 'bun:test';
import { getDropdownNavigationKey } from './dropdown-navigation';
import {
getDropdownNavigationKey,
handleDropdownNavigationKey,
shouldDismissDropdown,
} from './dropdown-navigation';
function keyEvent(key: string, modifiers: Partial<Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>> = {}) {
type KeyEvent = Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>;
type CompositionState = Partial<Pick<KeyboardEvent, 'isComposing' | 'keyCode'>>;
function keyEvent(key: string, modifiers: Partial<Omit<KeyEvent, 'key'>> = {}): KeyEvent {
return {
key,
ctrlKey: false,
@@ -9,7 +16,36 @@ function keyEvent(key: string, modifiers: Partial<Pick<KeyboardEvent, 'ctrlKey'
altKey: false,
shiftKey: false,
...modifiers,
} as KeyboardEvent;
};
}
function dropdownEvent(key: string, compositionState: CompositionState = {}) {
const calls = {
navigation: [] as Array<'ArrowDown' | 'ArrowUp'>,
preventDefault: 0,
stopPropagation: 0,
};
const event = {
...keyEvent(key, { ctrlKey: true }),
defaultPrevented: false,
isPropagationStopped: () => false,
nativeEvent: compositionState,
preventDefault: () => { calls.preventDefault += 1; },
stopPropagation: () => { calls.stopPropagation += 1; },
} as unknown as Parameters<typeof handleDropdownNavigationKey>[0];
return { calls, event };
}
function reactKeyEvent(key: string, compositionState: CompositionState = {}) {
return {
key,
nativeEvent: {
isComposing: false,
keyCode: 0,
...compositionState,
},
} as unknown as Parameters<typeof shouldDismissDropdown>[0];
}
test('maps only exact Ctrl+N and Ctrl+P to menu navigation keys', () => {
@@ -21,3 +57,43 @@ test('maps only exact Ctrl+N and Ctrl+P to menu navigation keys', () => {
expect(getDropdownNavigationKey(keyEvent('p', { ctrlKey: true, altKey: true }))).toBe(null);
expect(getDropdownNavigationKey(keyEvent('p', { ctrlKey: true, metaKey: true }))).toBe(null);
});
test('handles exact Ctrl+N and Ctrl+P during IME composition', () => {
const navigationCases = [
['n', 'ArrowDown'],
['p', 'ArrowUp'],
] as const;
for (const [key, navigationKey] of navigationCases) {
for (const compositionState of [{ isComposing: true }, { keyCode: 229 }]) {
const { calls, event } = dropdownEvent(key, compositionState);
expect(handleDropdownNavigationKey(event, (nextKey) => calls.navigation.push(nextKey))).toBe(true);
expect(calls).toEqual({
navigation: [navigationKey],
preventDefault: 1,
stopPropagation: 1,
});
}
}
});
test('leaves other IME input untouched', () => {
for (const compositionState of [{ isComposing: true }, { keyCode: 229 }]) {
const { calls, event } = dropdownEvent('x', compositionState);
expect(handleDropdownNavigationKey(event, (key) => calls.navigation.push(key))).toBe(false);
expect(calls).toEqual({
navigation: [],
preventDefault: 0,
stopPropagation: 0,
});
}
});
test('dismisses on Escape only outside IME composition', () => {
expect(shouldDismissDropdown(reactKeyEvent('Escape'))).toBe(true);
expect(shouldDismissDropdown(reactKeyEvent('Escape', { isComposing: true }))).toBe(false);
expect(shouldDismissDropdown(reactKeyEvent('Escape', { keyCode: 229 }))).toBe(false);
expect(shouldDismissDropdown(reactKeyEvent('Enter'))).toBe(false);
});
@@ -4,8 +4,7 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { Icon } from "@/components/icon/Icon";
import { shortcutRegistry } from "@/lib/shortcuts";
import { isIMECompositionEvent } from "@/lib/ime";
import { getDropdownNavigationKey } from "./dropdown-navigation";
import { handleDropdownNavigationKey } from "./dropdown-navigation";
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles";
type AsChildProps = { asChild?: boolean };
@@ -142,17 +141,13 @@ function DropdownMenuContent({
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseMenu.Popup>['onKeyDown']> = (event) => {
onKeyDown?.(event);
if (event.defaultPrevented || event.isPropagationStopped() || isIMECompositionEvent(event)) return;
const navigationKey = getDropdownNavigationKey(event);
if (!navigationKey) return;
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
event.preventDefault();
event.stopPropagation();
handleDropdownNavigationKey(event, (navigationKey) => {
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
});
};
return (
@@ -1,6 +1,45 @@
import type React from 'react';
import { isIMECompositionEvent } from '@/lib/ime';
export function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | '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';
return null;
}
type DropdownNavigationEvent = Pick<
React.KeyboardEvent<HTMLElement>,
| 'altKey'
| 'ctrlKey'
| 'defaultPrevented'
| 'isPropagationStopped'
| 'key'
| 'metaKey'
| 'preventDefault'
| 'shiftKey'
| 'stopPropagation'
>;
export function handleDropdownNavigationKey(
event: DropdownNavigationEvent,
navigate: (key: 'ArrowDown' | 'ArrowUp') => void,
): boolean {
if (event.defaultPrevented || event.isPropagationStopped()) return false;
const navigationKey = getDropdownNavigationKey(event);
if (!navigationKey) return false;
// Do not add an IME guard: exact Ctrl+N/P remain intentional commands, while
// every other composing key falls through without being handled.
navigate(navigationKey);
event.preventDefault();
event.stopPropagation();
return true;
}
export function shouldDismissDropdown(
event: KeyboardEvent | React.KeyboardEvent,
): boolean {
return event.key === 'Escape' && !isIMECompositionEvent(event);
}
+8 -13
View File
@@ -9,8 +9,7 @@ import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger"
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
import { Icon } from "@/components/icon/Icon";
import { shortcutRegistry } from "@/lib/shortcuts";
import { isIMECompositionEvent } from "@/lib/ime";
import { getDropdownNavigationKey } from "./dropdown-navigation";
import { handleDropdownNavigationKey } from "./dropdown-navigation";
type AsChildProps = { asChild?: boolean };
type AsChildRenderProps = {
@@ -210,17 +209,13 @@ function SelectContent({
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseSelect.Popup>['onKeyDown']> = (event) => {
onKeyDown?.(event);
if (event.defaultPrevented || event.isPropagationStopped() || isIMECompositionEvent(event)) return;
const navigationKey = getDropdownNavigationKey(event);
if (!navigationKey) return;
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
event.preventDefault();
event.stopPropagation();
handleDropdownNavigationKey(event, (navigationKey) => {
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
});
};
return (
@@ -1,6 +1,6 @@
import { expect, test } from 'bun:test';
import { hasOpenDropdown } from './keyboard-shortcut-dom';
import { hasOpenDropdown, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
test('does not treat an unrelated visible listbox as an open dropdown', () => {
const promptNavigator = {} as Element;
@@ -28,3 +28,10 @@ test('detects an open select popup', () => {
expect(hasOpenDropdown(root)).toBe(true);
});
test('stops IME Escape before an open dropdown dismiss listener', () => {
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, true)).toBe(true);
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 229 }, true)).toBe(true);
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 27 }, true)).toBe(false);
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, false)).toBe(false);
});
@@ -6,3 +6,12 @@ const OPEN_DROPDOWN_SELECTOR = [
export function hasOpenDropdown(root: ParentNode = document): boolean {
return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR));
}
export function shouldStopDropdownImeEscape(
event: Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>,
dropdownOpen: boolean,
): boolean {
return dropdownOpen
&& event.key === 'Escape'
&& (event.isComposing || event.keyCode === 229);
}
+64 -8
View File
@@ -21,6 +21,7 @@ import {
shortcutRegistry,
type ShortcutActionId,
} from '@/lib/shortcuts';
import { ShortcutRegistry } from '@/lib/shortcuts/registry';
import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry';
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -29,8 +30,14 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { addSelectionToChat } from '@/lib/addSelectionToChat';
import { hasOpenDropdown } from './keyboard-shortcut-dom';
import {
dismissActiveSelectionToolbar,
getActiveSelectionToolbarVersion,
hasActiveSelectionToolbar,
invokeActiveSelectionAddToChat,
} from '@/lib/addSelectionToChat';
import { isIMECompositionEvent } from '@/lib/ime';
import { hasOpenDropdown, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
const dropdownTargetSelector = [
'[data-slot="dropdown-menu-content"]', '[data-slot="select-content"]', '[role="combobox"]',
@@ -52,6 +59,8 @@ export const useKeyboardShortcuts = () => {
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const themeModeRef = React.useRef(themeMode);
const dispatcherRef = React.useRef<ShortcutDispatcher | null>(null);
const selectionToolbarDispatcherRef = React.useRef<ShortcutDispatcher | null>(null);
const selectionToolbarVersionRef = React.useRef(-1);
const heldKeysRef = React.useRef<Set<string>>(new Set());
if (!dispatcherRef.current) {
@@ -64,6 +73,18 @@ export const useKeyboardShortcuts = () => {
});
}
const dispatcher = dispatcherRef.current;
if (!selectionToolbarDispatcherRef.current) {
const registry = new ShortcutRegistry();
registry.register('add_selection_to_chat', invokeActiveSelectionAddToChat);
selectionToolbarDispatcherRef.current = new ShortcutDispatcher({
registry,
getBinding: () => getEffectiveShortcutCombo(
'add_selection_to_chat',
useUIStore.getState().shortcutOverrides,
),
});
}
const selectionToolbarDispatcher = selectionToolbarDispatcherRef.current;
React.useEffect(() => { themeModeRef.current = themeMode; }, [themeMode]);
@@ -173,9 +194,7 @@ export const useKeyboardShortcuts = () => {
const state = useUIStore.getState();
state.setSettingsDialogOpen(!state.isSettingsDialogOpen);
},
add_selection_to_chat: () => {
addSelectionToChat();
},
add_selection_to_chat: invokeActiveSelectionAddToChat,
toggle_sidebar: () => {
const state = useUIStore.getState();
if (state.isMobile) state.setSessionSwitcherOpen(!state.isSessionSwitcherOpen);
@@ -332,6 +351,34 @@ export const useKeyboardShortcuts = () => {
event.stopPropagation();
}
};
const handleSelectionToolbarKeyDownCapture = (event: KeyboardEvent) => {
const version = getActiveSelectionToolbarVersion();
if (selectionToolbarVersionRef.current !== version) {
selectionToolbarVersionRef.current = version;
selectionToolbarDispatcher.clear();
}
if (!hasActiveSelectionToolbar()) return;
if (isIMECompositionEvent(event)) {
selectionToolbarDispatcher.clear();
if (event.key === 'Escape') {
event.stopImmediatePropagation();
}
return;
}
if (event.key === 'Escape') {
selectionToolbarDispatcher.clear();
if (dismissActiveSelectionToolbar()) {
event.preventDefault();
event.stopImmediatePropagation();
resetAbortPriming();
}
return;
}
if (selectionToolbarDispatcher.dispatch(event)) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
const handleEscapeKeyDownCapture = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (dispatcher.handleEscape()) {
@@ -343,11 +390,16 @@ export const useKeyboardShortcuts = () => {
const state = useUIStore.getState();
const isDropdownTarget = target instanceof Element
&& target.closest(dropdownTargetSelector);
const dropdownOpen = Boolean(isDropdownTarget || hasOpenDropdown());
if (shouldStopDropdownImeEscape(event, dropdownOpen)) {
event.stopImmediatePropagation();
resetAbortPriming();
return;
}
if (
target?.closest('[role="dialog"]')
|| isTerminalEventTarget(target)
|| isDropdownTarget
|| hasOpenDropdown()
|| dropdownOpen
) {
resetAbortPriming();
return;
@@ -410,6 +462,7 @@ export const useKeyboardShortcuts = () => {
const handleKeyDown = (event: KeyboardEvent) => {
if (dispatcher.consumeCapturedPrefixEvent(event)) return;
if (event.key === 'Escape' || isTerminalEventTarget(event.target)) return;
if (shortcutRegistry.isSuspended() || hasActiveSelectionToolbar()) return;
const combo = getEffectiveShortcutCombo('cycle_agent', useUIStore.getState().shortcutOverrides);
const backward = combo && !combo.includes('shift') ? normalizeCombo(`shift+${combo}`) : '';
if (backward && eventMatchesShortcut(event, backward)) {
@@ -460,8 +513,10 @@ export const useKeyboardShortcuts = () => {
const handleBlur = () => {
heldKeysRef.current.clear();
dispatcher.handleBlur();
selectionToolbarDispatcher.handleBlur();
};
window.addEventListener('keydown', handleKeyHoldDown, true);
window.addEventListener('keydown', handleSelectionToolbarKeyDownCapture, true);
window.addEventListener('keyup', handleKeyUp, true);
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
window.addEventListener('keydown', handleEscapeKeyDownCapture, true);
@@ -470,6 +525,7 @@ export const useKeyboardShortcuts = () => {
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyHoldDown, true);
window.removeEventListener('keydown', handleSelectionToolbarKeyDownCapture, true);
window.removeEventListener('keyup', handleKeyUp, true);
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
window.removeEventListener('keydown', handleEscapeKeyDownCapture, true);
@@ -477,7 +533,7 @@ export const useKeyboardShortcuts = () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleBlur);
};
}, [armAbortPrompt, currentSessionId, dispatcher, effectiveDirectory, resetAbortPriming, sessionPhase]);
}, [armAbortPrompt, currentSessionId, dispatcher, effectiveDirectory, resetAbortPriming, selectionToolbarDispatcher, sessionPhase]);
React.useEffect(() => () => resetAbortPriming(), [resetAbortPriming]);
};
+116 -1
View File
@@ -51,7 +51,16 @@ mock.module('@/stores/useUIStore', () => ({
},
}));
const { addSelectionToChat, captureSelectionMarkdownForChat } = await import('./addSelectionToChat');
const {
addSelectionToChat,
captureSelectionMarkdownForChat,
dismissActiveSelectionToolbar,
getActiveSelectionToolbarVersion,
hasActiveSelectionToolbar,
invokeActiveSelectionAddToChat,
registerActiveSelectionToolbar,
} = await import('./addSelectionToChat');
const { shortcutRegistry } = await import('./shortcuts');
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
@@ -296,3 +305,109 @@ describe('addSelectionToChat', () => {
expect(focusChatInputCalls.length).toBe(1);
});
});
describe('active selection toolbar shortcut', () => {
beforeEach(() => {
clearCalls();
});
test('does not use the generic selection fallback without a visible toolbar', () => {
const textarea = {
tagName: 'TEXTAREA',
value: 'selected',
selectionStart: 0,
selectionEnd: 8,
closest: () => null,
} as unknown as HTMLTextAreaElement;
installSelectionEnvironment({ activeElement: textarea });
expect(invokeActiveSelectionAddToChat()).toBe(false);
expect(textarea.selectionStart).toBe(0);
expect(textarea.selectionEnd).toBe(8);
expect(pendingInputCalls).toEqual([]);
expect(activeMainTabCalls).toEqual([]);
});
test('becomes inactive when the selection toolbar hides', () => {
const calls: number[] = [];
const cleanup = registerActiveSelectionToolbar({
addToChat: () => calls.push(1),
dismiss: () => undefined,
});
expect(hasActiveSelectionToolbar()).toBe(true);
expect(shortcutRegistry.isSuspended()).toBe(true);
cleanup();
expect(hasActiveSelectionToolbar()).toBe(false);
expect(shortcutRegistry.isSuspended()).toBe(false);
expect(invokeActiveSelectionAddToChat()).toBe(false);
expect(calls).toEqual([]);
});
test('invokes the active toolbar action once', () => {
const calls: number[] = [];
const cleanup = registerActiveSelectionToolbar({
addToChat: () => calls.push(1),
dismiss: () => undefined,
});
expect(invokeActiveSelectionAddToChat()).toBe(true);
expect(shortcutRegistry.isSuspended()).toBe(false);
expect(invokeActiveSelectionAddToChat()).toBe(false);
expect(calls).toEqual([1]);
cleanup();
});
test('keeps the newest visible toolbar active', () => {
const calls: string[] = [];
const cleanupFirst = registerActiveSelectionToolbar({
addToChat: () => calls.push('first'),
dismiss: () => undefined,
});
const cleanupSecond = registerActiveSelectionToolbar({
addToChat: () => calls.push('second'),
dismiss: () => undefined,
});
cleanupFirst();
expect(shortcutRegistry.isSuspended()).toBe(true);
expect(invokeActiveSelectionAddToChat()).toBe(true);
expect(shortcutRegistry.isSuspended()).toBe(false);
expect(calls).toEqual(['second']);
cleanupSecond();
});
test('restores the previous visible toolbar when the newest one unmounts', () => {
const calls: string[] = [];
const cleanupFirst = registerActiveSelectionToolbar({
addToChat: () => calls.push('first'),
dismiss: () => undefined,
});
const cleanupSecond = registerActiveSelectionToolbar({
addToChat: () => calls.push('second'),
dismiss: () => undefined,
});
cleanupSecond();
expect(shortcutRegistry.isSuspended()).toBe(true);
expect(invokeActiveSelectionAddToChat()).toBe(true);
expect(shortcutRegistry.isSuspended()).toBe(false);
expect(calls).toEqual(['first']);
cleanupFirst();
});
test('dismisses the active toolbar and advances its ownership version', () => {
const calls: string[] = [];
const before = getActiveSelectionToolbarVersion();
const cleanup = registerActiveSelectionToolbar({
addToChat: () => calls.push('add'),
dismiss: () => calls.push('dismiss'),
});
expect(getActiveSelectionToolbarVersion()).toBeGreaterThan(before);
expect(dismissActiveSelectionToolbar()).toBe(true);
expect(calls).toEqual(['dismiss']);
expect(hasActiveSelectionToolbar()).toBe(false);
cleanup();
});
});
+57
View File
@@ -8,9 +8,66 @@ import {
} from '@/components/chat/message/selectionMarkdown';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { shortcutRegistry } from '@/lib/shortcuts';
const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]';
interface ActiveSelectionToolbarActions {
addToChat: () => void;
dismiss: () => void;
}
interface ActiveSelectionToolbarRegistration extends ActiveSelectionToolbarActions {
resumeGlobalShortcuts: () => void;
}
const activeSelectionToolbarRegistrations: ActiveSelectionToolbarRegistration[] = [];
let activeSelectionToolbarVersion = 0;
const releaseSelectionToolbar = (registration: ActiveSelectionToolbarRegistration): void => {
const index = activeSelectionToolbarRegistrations.indexOf(registration);
if (index === -1) return;
activeSelectionToolbarRegistrations.splice(index, 1);
registration.resumeGlobalShortcuts();
activeSelectionToolbarVersion += 1;
};
export const registerActiveSelectionToolbar = (
actions: ActiveSelectionToolbarActions,
): (() => void) => {
const registration: ActiveSelectionToolbarRegistration = {
...actions,
resumeGlobalShortcuts: shortcutRegistry.suspend(),
};
activeSelectionToolbarRegistrations.push(registration);
activeSelectionToolbarVersion += 1;
return () => releaseSelectionToolbar(registration);
};
export const hasActiveSelectionToolbar = (): boolean => activeSelectionToolbarRegistrations.length > 0;
export const getActiveSelectionToolbarVersion = (): number => activeSelectionToolbarVersion;
export const invokeActiveSelectionAddToChat = (): boolean => {
const registration = activeSelectionToolbarRegistrations.at(-1);
if (!registration) return false;
releaseSelectionToolbar(registration);
registration.addToChat();
return true;
};
export const dismissActiveSelectionToolbar = (): boolean => {
const registration = activeSelectionToolbarRegistrations.at(-1);
if (!registration) return false;
releaseSelectionToolbar(registration);
registration.dismiss();
return true;
};
const isInsideChatComposer = (node: Node | null): boolean => {
if (!node) {
return false;
@@ -1032,6 +1032,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Tasten drücken...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Verwende zuerst eine Tastenkombination.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Diese Tastenkombination kann mit Standard-Tastenkombinationen des Browsers kollidieren. Sie wird dennoch gespeichert.',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Diese Sequenz teilt ein kontextabhängiges Präfix mit {action}. Wenn dessen Kontext aktiv ist, hat diese Aktion Vorrang.',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Gehe zu Zeile (Datei-Editor)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Befehlspalette öffnen',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Eingabe fokussieren',
@@ -1068,7 +1069,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.actions.edit': 'Bearbeiten',
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Bestätigen',
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} bearbeiten',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Drücken Sie bis zu zwei Tastenkombinationen. Wählen Sie Bestätigen zum Anwenden oder Abbrechen zum Verwerfen. Mit der Rücktaste entfernen Sie die letzte.',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Drücken Sie bis zu zwei Tastenkombinationen mit jeweils höchstens drei Tasten. Warten Sie nach der ersten bis zu 3 Sekunden auf eine zweite Kombination. Wählen Sie Bestätigen zum Anwenden oder Abbrechen zum Verwerfen. Mit der Rücktaste entfernen Sie die letzte.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Erste Kombination',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Zweite Kombination',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Tasten drücken…',
@@ -1097,6 +1097,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Press keys...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capture a shortcut first.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'This shortcut can conflict with browser defaults. You can still save it.',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'This sequence shares a contextual prefix with {action}. That action takes priority while its context is active.',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Go to line (files editor)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Open command palette',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Focus input',
@@ -1133,7 +1134,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edit',
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirm',
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations. Use Confirm to apply or Cancel to discard. Backspace removes the last one.',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations, with at most three keys each. After the first, wait up to 3 seconds for a second combination. Use Confirm to apply or Cancel to discard. Backspace removes the last one.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'First combination',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…',
@@ -1064,6 +1064,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Pulsa las teclas...",
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura un atajo primero.",
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atajo puede entrar en conflicto con los predeterminados del navegador. Aun así, puedes guardarlo.",
"settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Esta secuencia comparte un prefijo contextual con {action}. Cuando su contexto está activo, esa acción tiene prioridad.",
"settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Ir a línea (editor de archivos)",
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos",
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Enfocar entrada",
@@ -1100,7 +1101,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas. Use Confirmar para aplicar o Cancelar para descartar. Retroceso elimina la última.",
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas, con un máximo de tres teclas cada una. Tras la primera, espere hasta 3 segundos por una segunda combinación. Use Confirmar para aplicar o Cancelar para descartar. Retroceso elimina la última.",
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primera combinación",
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación",
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…",
@@ -985,6 +985,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Appuyez sur les touches...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capturez d\'abord un raccourci.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ce raccourci peut entrer en conflit avec les paramètres par défaut du navigateur. Vous pouvez tout de même lenregistrer.',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Cette séquence partage un préfixe contextuel avec {action}. Lorsque son contexte est actif, cette action est prioritaire.',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Aller à la ligne (éditeur de fichiers)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Ouvrir la palette de commandes',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Entrée de mise au point',
@@ -1021,7 +1022,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.actions.edit': 'Modifier',
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirmer',
'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum. Utilisez Confirmer pour appliquer ou Annuler pour abandonner. Retour arrière supprime la dernière.',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum, avec trois touches au plus chacune. Après la première, attendez jusqu’à 3 secondes une seconde combinaison. Utilisez Confirmer pour appliquer ou Annuler pour abandonner. Retour arrière supprime la dernière.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Première combinaison',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…',
@@ -1097,6 +1097,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'キーを押してください...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': '最初にショートカットを設定してください。',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性がありますが、そのまま保存できます。',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'このシーケンスは {action} とコンテキスト依存のプレフィックスを共有しています。そのコンテキストが有効な間は、この操作が優先されます。',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '指定行に移動(ファイルエディター)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'コマンドパレットを開く',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '入力をフォーカス',
@@ -1133,7 +1134,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.actions.edit': '編集',
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力できます。適用するには確認、破棄するにはキャンセルを選択してください。Backspace で最後の組み合わせを削除します。',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力でき、各組み合わせは最大3キーです。最初の組み合わせの後、2つ目の組み合わせを最大3秒待ちます。適用するには確認、破棄するにはキャンセルを選択してください。Backspace で最後の組み合わせを削除します。',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '最初の組み合わせ',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…',
@@ -1064,6 +1064,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.field.pressKeys': '키를 누르세요...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': '먼저 단축키를 입력하세요.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있지만 그래도 저장할 수 있습니다.',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '이 시퀀스는 {action}과 컨텍스트 접두사를 공유합니다. 해당 컨텍스트가 활성화된 동안에는 그 동작이 우선합니다.',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '줄로 이동(파일 편집기)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '명령 팔레트 열기',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '입력에 포커스',
@@ -1100,7 +1101,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.actions.edit': '편집',
'settings.openchamber.keyboardShortcuts.actions.confirm': '확인',
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집',
'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. 적용하려면 확인을, 취소하려면 취소를 선택하세요. Backspace로 마지막 조합을 삭제합니다.',
'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. 각 조합에는 최대 세 개의 키를 사용할 수 있습니다. 첫 번째 조합 뒤에는 두 번째 조합을 위해 최대 3초 동안 기다립니다. 적용하려면 확인을, 취소하려면 취소를 선택하세요. Backspace로 마지막 조합을 삭제합니다.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '첫 번째 조합',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합',
'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…',
@@ -839,6 +839,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.title': 'Skróty klawiszowe',
'settings.openchamber.keyboardShortcuts.tooltip': 'Przechwyć nową kombinację klawiszy, zapisz ją, a przypisania zostaną natychmiast zaktualizowane.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ten skrót może kolidować z domyślnymi skrótami przeglądarki. Nadal możesz go zapisać.',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Ta sekwencja współdzieli prefiks kontekstowy z działaniem {action}. Gdy jego kontekst jest aktywny, to działanie ma pierwszeństwo.',
'settings.openchamber.opencodeCli.actions.browse': 'Przeglądaj',
'settings.openchamber.opencodeCli.actions.browseAria': 'Przeglądaj ścieżkę do pliku binarnego OpenCode',
'settings.openchamber.opencodeCli.actions.restartingOpenCode': 'Restartowanie OpenCode...',
@@ -1368,7 +1369,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edytuj',
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Potwierdź',
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy. Wybierz Potwierdź, aby zastosować, lub Anuluj, aby odrzucić. Backspace usuwa ostatnią.',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy, po najwyżej trzy klawisze każda. Po pierwszej odczekaj do 3 sekund na drugą kombinację. Wybierz Potwierdź, aby zastosować, lub Anuluj, aby odrzucić. Backspace usuwa ostatnią.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Pierwsza kombinacja',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…',
@@ -1064,6 +1064,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Pressione as teclas...",
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura um atalho primeiro.",
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atalho pode entrar em conflito com os padrões do navegador. Ainda assim, você pode salvá-lo.",
"settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Esta sequência compartilha um prefixo contextual com {action}. Quando esse contexto está ativo, essa ação tem prioridade.",
"settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Ir para linha (editor de arquivos)",
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos",
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Focar entrada",
@@ -1100,7 +1101,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas. Use Confirmar para aplicar ou Cancelar para descartar. Backspace remove a última.",
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas, com no máximo três teclas em cada uma. Após a primeira, aguarde até 3 segundos por uma segunda combinação. Use Confirmar para aplicar ou Cancelar para descartar. Backspace remove a última.",
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primeira combinação",
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação",
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…",
@@ -1064,6 +1064,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Натисніть клавіші...",
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Спочатку запишіть комбінацію клавіш.",
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Ви все одно можете її зберегти.",
"settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Ця послідовність має спільний контекстний префікс із дією {action}. Коли її контекст активний, ця дія має пріоритет.",
"settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Перейти до рядка (редактор файлів)",
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Відкрити палітру команд",
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Фокус на полі вводу",
@@ -1100,7 +1101,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати",
"settings.openchamber.keyboardShortcuts.actions.confirm": "Підтвердити",
"settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}",
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш. Виберіть Підтвердити, щоб застосувати, або Скасувати, щоб відхилити. Backspace видаляє останню.",
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш, не більше трьох клавіш у кожній. Після першої зачекайте до 3 секунд на другу комбінацію. Виберіть Підтвердити, щоб застосувати, або Скасувати, щоб відхилити. Backspace видаляє останню.",
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Перша комбінація",
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація",
"settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…",
@@ -1064,6 +1064,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按键...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': '请先录入一个快捷键。',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍可保存。',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列与“{action}”共享上下文前缀。对应上下文生效时,该操作会优先执行。',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳转到行(文件编辑器)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '打开命令面板',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦输入框',
@@ -1100,7 +1101,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.actions.edit': '编辑',
'settings.openchamber.keyboardShortcuts.actions.confirm': '确认',
'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下个按键组合。点击确认应用,或点击取消放弃;按 Backspace 删除最后一个组合。',
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多输入两个按键组合,每个组合最多同时按下个按键。输入第一个组合后,最多等待 3 秒以输入第二个组合。点击确认应用,或点击取消放弃;按 Backspace 删除最后一个组合。',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一个组合',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合',
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…',
@@ -971,6 +971,7 @@
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按鍵...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': '請先錄入一個快速鍵。',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍可儲存。',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列與「{action}」共用情境前綴。對應情境生效時,該操作會優先執行。',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳轉到行(檔案編輯器)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '開啟命令面板',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦輸入方塊',
@@ -1007,7 +1008,7 @@
'settings.openchamber.keyboardShortcuts.actions.edit': '編輯',
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下個按鍵組合。點擊確認套用,或點擊取消放棄;按 Backspace 刪除最後一個組合。',
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多輸入兩個按鍵組合,每個組合最多同時按下個按鍵。輸入第一個組合後,最多等待 3 秒以輸入第二個組合。點擊確認套用,或點擊取消放棄;按 Backspace 刪除最後一個組合。',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一個組合',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合',
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…',
@@ -31,15 +31,17 @@ Contextual internal commands may deliberately share a sequence leader. The singl
The internal `switch_tab_*` bindings remain available to mobile handlers. Desktop numeric context-surface switching is resolved by the configurable `switch_context_surface` prefix before normal dispatcher matching and falls through on mobile.
The settings recorder also stops at two chords and checks the complete schema, not only customizable actions. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced.
The settings recorder captures up to two chords with at most three simultaneous physical keys per chord and checks the complete schema, not only customizable actions. After the first chord it waits up to 3000ms for a second; conflict and browser-risk feedback appears only when the second chord, timeout, or Confirm settles the recording. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts unless the single-chord action explicitly allows sequence fallback. Those contextual prefixes remain saveable with a warning because their handler yields outside its owning context. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced.
`add_selection_to_chat` is contextual. A visible text-selection toolbar publishes its Add to chat and dismiss actions, suspends the shared application registry, and clears both synchronously when hidden or unmounted. The main application route also gates directly on active toolbar ownership before global dispatch, so unrelated shortcuts cannot escape the scoped interaction even if runtime bundling isolates registry state. The newest visible toolbar owns a dedicated scoped dispatcher; it ignores IME composition, stops IME Escape before the global Escape route without preventing its native default, handles non-IME Escape and the configured Add to chat binding (including a two-chord binding), and lets native input continue for unrelated keys. The application handler returns `false` when no toolbar action is active, so an unselected or stale DOM range can instead become a sequence leader. Opening, closing, or replacing a toolbar invalidates any pending scoped or global prefix.
# Dispatching
`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 1500ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed. When a sequence prefix is active, only its second key is dispatched during window capture so local input handlers cannot block it; an exact second key remains eligible during IME composition and is prevented when handled, while an IME mismatch clears the prefix and retains normal composition input. Normal application shortcuts remain window-bubble listeners.
`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 3000ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed. When a sequence prefix is active, only its second key is dispatched during window capture so local input handlers cannot block it; an exact second key remains eligible during IME composition and is prevented when handled, while an IME mismatch clears the prefix and retains normal composition input. Normal application shortcuts remain window-bubble listeners.
`shortcutRegistry.suspend()` disables all application handlers and returns an idempotent cleanup. Suspensions nest; handlers resume only after the final cleanup. Starting or ending a suspension invalidates every pending dispatcher prefix, so stale second keys and Escape cannot consume it.
`shortcutRegistry.suspend()` disables all application handlers and returns an idempotent cleanup. Suspensions nest; handlers resume only after the final cleanup. Starting or ending a suspension invalidates every pending global dispatcher prefix, so stale second keys and Escape cannot consume it. Interaction surfaces that need shortcuts while suspended must own a dedicated scoped dispatcher and process it before the global route.
Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGlobalShortcuts`; they suspend while open for both controlled and uncontrolled popups and resume on close or unmount.
Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGlobalShortcuts`; they suspend while open for both controlled and uncontrolled popups and resume on close or unmount. Exact `Ctrl+N` and `Ctrl+P` chords are translated to menu navigation even when the native event reports IME composition; no other composing key is intercepted. Window capture stops an IME Escape before Base UI's document-level dismiss listener without preventing the native IME action. Controlled draft project and worktree pickers close on non-IME Escape from either the trigger or portaled popup.
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
+3 -1
View File
@@ -5,6 +5,7 @@ type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'applic
type ShortcutConfig = {
id: string;
defaultBinding: ShortcutCombo;
allowsSequenceFallback?: true;
} & (
| { customizable: false }
| {
@@ -18,6 +19,7 @@ const SHORTCUT_GROUPS = {
{
id: 'add_selection_to_chat',
defaultBinding: 'mod+l',
allowsSequenceFallback: true,
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label',
@@ -190,7 +192,7 @@ const SHORTCUT_GROUPS = {
},
],
navigation: [
{ id: 'save_file', defaultBinding: 'mod+s', customizable: false },
{ id: 'save_file', defaultBinding: 'mod+s', customizable: false, allowsSequenceFallback: true },
{ id: 'find_in_file', defaultBinding: 'mod+f', customizable: false },
{
id: 'open_go_to_line',
@@ -65,7 +65,9 @@ describe('ShortcutDispatcher', () => {
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h', now: () => now });
expect(dispatcher.dispatch(key('g'))).toBe(true);
now = 1500;
now = 2999;
expect(dispatcher.hasActivePrefix()).toBe(true);
now = 3000;
expect(dispatcher.dispatch(key('h'))).toBe(false);
expect(dispatcher.dispatch(key('g', { repeat: true }))).toBe(false);
expect(dispatcher.dispatch(key('g', { isComposing: true }))).toBe(false);
+1 -1
View File
@@ -9,7 +9,7 @@ import { type ShortcutHandler, ShortcutRegistry } from './registry';
import type { ShortcutActionId } from './schema';
import { isIMECompositionEvent } from '../ime';
const SEQUENCE_TIMEOUT_MS = 1500;
const SEQUENCE_TIMEOUT_MS = 3000;
const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']);
export interface ShortcutDispatcherOptions {
@@ -34,6 +34,7 @@ test('suspends all handlers until every idempotent cleanup completes', () => {
const resumeFirst = registry.suspend();
const resumeSecond = registry.suspend();
expect(registry.get('open_settings')).toBe(undefined);
expect(registry.isSuspended()).toBe(true);
resumeFirst();
resumeFirst();
@@ -41,4 +42,5 @@ test('suspends all handlers until every idempotent cleanup completes', () => {
resumeSecond();
resumeSecond();
expect(registry.get('open_settings')).toBe(handler);
expect(registry.isSuspended()).toBe(false);
});
@@ -53,6 +53,10 @@ export class ShortcutRegistry {
return this.suspensionVersion;
}
isSuspended(): boolean {
return this.suspensionCount > 0;
}
actionIds(): IterableIterator<ShortcutActionId> {
return this.handlers.keys();
}
+10 -1
View File
@@ -73,12 +73,21 @@ describe('shortcut schema', () => {
.find((conflict) => conflict.action.id === 'find_in_file');
const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x')
.find((conflict) => conflict.action.id === 'save_file');
const contextualPrefixConflict = getShortcutBindingConflicts('focus_input', 'mod+l l')
.find((conflict) => conflict.action.id === 'add_selection_to_chat');
const contextualLeaderConflict = getShortcutBindingConflicts('add_selection_to_chat', 'mod+s')
.find((conflict) => conflict.action.id === 'open_draft_project_picker');
const blockingPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+p x')
.find((conflict) => conflict.action.id === 'open_command_palette');
expect(customizableConflict?.kind).toBe('exact');
expect(customizableConflict?.action.customizable).toBe(true);
expect(internalConflict?.kind).toBe('exact');
expect(internalConflict?.action.customizable).toBe(false);
expect(internalPrefixConflict?.kind).toBe('prefix');
expect(internalPrefixConflict?.kind).toBe('contextual-prefix');
expect(internalPrefixConflict?.action.customizable).toBe(false);
expect(contextualPrefixConflict?.kind).toBe('contextual-prefix');
expect(contextualLeaderConflict?.kind).toBe('contextual-prefix');
expect(blockingPrefixConflict?.kind).toBe('prefix');
});
});
+28 -2
View File
@@ -15,11 +15,29 @@ export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number];
export type ShortcutActionId = ShortcutAction['id'];
export type ShortcutCategory = ShortcutAction['category'];
export type CustomizableShortcutAction = Extract<ShortcutAction, { customizable: true }>;
export type ShortcutBindingConflictKind = ShortcutConflict | 'contextual-prefix';
export type ShortcutBindingConflict = {
action: ShortcutAction;
kind: ShortcutConflict;
kind: ShortcutBindingConflictKind;
};
function allowsContextualPrefix(
action: ShortcutAction,
combo: ShortcutCombo,
candidate: ShortcutAction,
candidateCombo: ShortcutCombo,
): boolean {
const chordCount = parseShortcut(combo)?.chords.length;
const candidateChordCount = parseShortcut(candidateCombo)?.chords.length;
if (chordCount === 1 && candidateChordCount === 2) {
return 'allowsSequenceFallback' in action && action.allowsSequenceFallback;
}
if (chordCount === 2 && candidateChordCount === 1) {
return 'allowsSequenceFallback' in candidate && candidate.allowsSequenceFallback;
}
return false;
}
export function getShortcutAction(id: string): ShortcutAction | undefined {
return SHORTCUT_SCHEMA.find((action) => action.id === id);
}
@@ -73,13 +91,21 @@ export function getShortcutBindingConflicts(
overrides?: Record<string, ShortcutCombo>,
): ShortcutBindingConflict[] {
const conflicts: ShortcutBindingConflict[] = [];
const action = getShortcutAction(actionId);
if (!action) return conflicts;
for (const candidate of SHORTCUT_SCHEMA) {
if (candidate.id === actionId) continue;
const candidateCombo = candidate.id === 'switch_context_surface'
? getEffectiveShortcutPrefix(candidate.id, overrides)
: getEffectiveShortcutCombo(candidate.id, overrides);
const kind = getShortcutConflict(combo, candidateCombo);
if (kind) conflicts.push({ action: candidate, kind });
if (!kind) continue;
conflicts.push({
action: candidate,
kind: kind === 'prefix' && allowsContextualPrefix(action, combo, candidate, candidateCombo)
? 'contextual-prefix'
: kind,
});
}
return conflicts;
}
@@ -0,0 +1,217 @@
version: 1
kind: manual-agent-browser-checklist
metadata:
id: shortcut-registry-ego-lite
title: Shortcut registry and prefix sequence regression
owner: packages/ui
runner: ego-lite
interface: ego-browser
dependencies: []
documentation: packages/ui/src/lib/shortcuts/DOCUMENTATION.md
target:
default_url: http://127.0.0.1:9601
viewport:
width: 1800
height: 1050
evidence_directory: ~/Desktop/openchamber-pr-2532-evidence
evidence_prefix: openchamber-pr-2532
limitations:
- IME checks use synthetic KeyboardEvent.isComposing and keyCode 229 signals. Repeat them with a real system IME before claiming native IME coverage.
- The Windows profile overrides Chromium user-agent data. It validates browser platform detection and rendered shortcut labels, not native Windows keyboard events or desktop packaging.
- The macOS profile validates the web runtime. Electron, VS Code, hosted mobile, and Capacitor mobile require separate runtime checks.
platform_profiles:
macos_native:
description: Use the host browser user agent without overrides.
expected_primary_modifier: Command
expected_labels:
- Command symbol U+2318
- Option symbol U+2325
setup:
- Open the target URL in a fresh ego-lite tab.
- Confirm navigator.userAgent contains Macintosh or Mac OS X.
- Open Settings, then Shortcuts.
windows_ua_mock:
description: Override Chromium identity before reloading the application.
cdp_command:
method: Network.setUserAgentOverride
params:
userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
platform: Win32
userAgentMetadata:
brands:
- brand: Chromium
version: "138"
- brand: Not=A?Brand
version: "24"
fullVersionList:
- brand: Chromium
version: 138.0.0.0
- brand: Not=A?Brand
version: 24.0.0.0
fullVersion: 138.0.0.0
platform: Windows
platformVersion: 10.0.0
architecture: x86
model: ""
mobile: false
bitness: "64"
wow64: false
setup:
- Open the target URL in a separate ego-lite tab.
- Apply the CDP command above.
- Reload before evaluating navigator or rendered shortcut labels.
- Confirm navigator.userAgent contains Windows NT and navigator.platform is Win32.
- Open Settings, then Shortcuts.
checks:
- id: macos-shortcut-labels
priority: critical
profile: macos_native
steps:
- Inspect Session Controls, Panels and Tools, Navigation, and Application.
- Capture the visible Shortcuts settings pane.
assertions:
- Open draft project picker renders as Command + S, P using the macOS Command symbol.
- Open draft worktree picker renders as Command + S, G using the macOS Command symbol.
- Open recent sessions renders as Command + S, L using the macOS Command symbol.
- New Mini Chat window renders both macOS Command and Option symbols.
- No Windows key glyph is used for Mod or Alt.
evidence:
type: screenshot
filename: openchamber-pr-2532-macos-shortcuts.png
last_result:
status: passed
- id: windows-ua-shortcut-labels
priority: critical
profile: windows_ua_mock
steps:
- Inspect the same shortcut rows used by macos-shortcut-labels.
- Capture the visible Shortcuts settings pane.
assertions:
- Open draft project picker renders as Ctrl + S, P.
- Open draft worktree picker renders as Ctrl + S, G.
- Open recent sessions renders as Ctrl + S, L.
- New Mini Chat window renders as Ctrl + Alt + N.
- No macOS modifier symbols or Windows key glyph are rendered.
evidence:
type: screenshot
filename: openchamber-pr-2532-windows-ua-shortcuts.png
last_result:
status: passed
- id: recorder-contextual-prefix
priority: critical
profile: macos_native
steps:
- Edit Focus input.
- Record Mod + L as the first chord.
- Verify no conflict message is shown before the 3000 ms settling timeout.
- Record L as the second chord before timeout.
- Capture the settled recorder without saving the override.
assertions:
- The recorder shows two chords and no more than three physical keys per chord.
- A contextual prefix warning names Add selection to chat.
- Confirm remains enabled because the contextual owner yields outside its context.
- The browser-risk warning is visible for the Mod + L leader.
evidence:
type: screenshot
filename: openchamber-pr-2532-recorder-contextual-prefix.png
cleanup:
- Select Cancel so the test does not persist a shortcut override.
last_result:
status: passed
- id: recorder-single-chord-timeout
priority: high
profile: macos_native
steps:
- Edit Focus input.
- Record Mod + L as the first chord.
- Observe the recorder before 3000 ms.
- Wait at least 3000 ms without pressing a second chord.
assertions:
- No conflict or browser-risk message is visible before settlement.
- Exact-conflict and browser-risk feedback appears after settlement.
cleanup:
- Select Cancel so the test does not persist a shortcut override.
last_result:
status: passed
- id: selection-toolbar-scope
priority: critical
profile: macos_native
preconditions:
- Open a rendered assistant response containing selectable Markdown text.
steps:
- Select text to open the selection toolbar.
- Trigger an unrelated application shortcut and verify it does not run.
- Dispatch composing Mod + L and verify Add to chat does not run.
- Dispatch composing Escape and verify the toolbar remains open without reaching later global capture listeners.
- Dispatch non-composing Mod + L and verify the selected Markdown reaches the composer once.
- Reopen the toolbar and press Escape.
assertions:
- The visible toolbar suspends the global shortcut registry.
- IME composition is not consumed by the toolbar shortcut scope.
- IME Escape keeps its native default while bypassing global Escape handling.
- Add to chat runs once for the active toolbar only.
- Escape dismisses the toolbar and restores global shortcuts.
evidence:
type: recording
filename: openchamber-pr-2532-shortcut-regression-final.mov
last_result:
status: passed
- id: draft-picker-sequences
priority: critical
profile: macos_native
preconditions:
- Open a draft session with project and worktree selectors mounted.
steps:
- Trigger Mod + S, P and verify the project picker opens.
- Press Escape once and verify it closes.
- Trigger Mod + S, G and verify the worktree picker opens.
- Press Escape once and verify it closes.
assertions:
- A contextual Mod + S owner yields when its context is inactive.
- Each sequence opens only its target picker.
- One non-IME Escape closes either controlled picker.
evidence:
type: recording
filename: openchamber-pr-2532-shortcut-regression-final.mov
last_result:
status: passed
- id: dropdown-ime-navigation
priority: critical
profile: macos_native
steps:
- Open the project picker.
- Dispatch Ctrl + N with isComposing true and keyCode 229.
- Dispatch Ctrl + P with isComposing true and keyCode 229.
- Dispatch Escape with isComposing true and keyCode 229.
- Dispatch one non-IME Escape.
assertions:
- Ctrl + N moves active selection forward during composition.
- Ctrl + P moves active selection backward during composition.
- IME Escape remains available to the native input method and does not close the picker.
- Non-IME Escape closes the picker once.
evidence:
type: recording
filename: openchamber-pr-2532-shortcut-regression-final.mov
last_result:
status: passed
last_run:
date: 2026-08-06
application_url: http://127.0.0.1:9601
source: working-tree
browser: ego-lite through ego-browser
overall_status: passed-with-documented-limitations
notes:
- The browser checks passed against the working tree before commit; repository validation is recorded separately in the pull request.
- Keep screenshots and recordings outside the repository and attach them to the pull request.