fix(ui): refine contextual shortcut interactions
This commit is contained in:
@@ -12,6 +12,7 @@ import React from 'react';
|
|||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
|
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -107,6 +108,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
|||||||
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
|
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
|
||||||
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
|
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||||
const worktreeTriggerRef = 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', () => {
|
useKeybind('open_draft_project_picker', () => {
|
||||||
projectTriggerRef.current?.focus();
|
projectTriggerRef.current?.focus();
|
||||||
@@ -139,6 +146,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
ref={projectTriggerRef}
|
ref={projectTriggerRef}
|
||||||
|
onKeyDown={handlePickerKeyDown}
|
||||||
size="sm"
|
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"
|
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} />}
|
{<ProjectLabel project={selectedProject} theme={theme} />}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent fitContent>
|
<SelectContent fitContent onKeyDown={handlePickerKeyDown}>
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||||
{<ProjectLabel project={project} theme={theme} />}
|
{<ProjectLabel project={project} theme={theme} />}
|
||||||
@@ -165,6 +173,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
ref={worktreeTriggerRef}
|
ref={worktreeTriggerRef}
|
||||||
|
onKeyDown={handlePickerKeyDown}
|
||||||
size="sm"
|
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"
|
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')}
|
{selectedBranchLabel ?? t('chat.chatInput.branch')}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="w-max min-w-48">
|
<SelectContent className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
|
||||||
{projectRootBranchOption ? (
|
{projectRootBranchOption ? (
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
|
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
|
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
|
||||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||||
|
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
|
||||||
|
|
||||||
interface TextSelectionMenuProps {
|
interface TextSelectionMenuProps {
|
||||||
containerRef: React.RefObject<HTMLElement | null>;
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
@@ -60,6 +61,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const openRafRef = React.useRef<number | null>(null);
|
const openRafRef = React.useRef<number | null>(null);
|
||||||
const mouseUpTimeoutRef = React.useRef<number | null>(null);
|
const mouseUpTimeoutRef = React.useRef<number | null>(null);
|
||||||
const isMenuVisibleRef = React.useRef(false);
|
const isMenuVisibleRef = React.useRef(false);
|
||||||
|
const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
|
||||||
const createSession = useSessionUIStore((state) => state.createSession);
|
const createSession = useSessionUIStore((state) => state.createSession);
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||||
@@ -75,6 +77,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
activeAddToChatCleanupRef.current?.();
|
||||||
|
activeAddToChatCleanupRef.current = null;
|
||||||
if (openRafRef.current !== null) {
|
if (openRafRef.current !== null) {
|
||||||
window.cancelAnimationFrame(openRafRef.current);
|
window.cancelAnimationFrame(openRafRef.current);
|
||||||
openRafRef.current = null;
|
openRafRef.current = null;
|
||||||
@@ -88,6 +92,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
const hideMenu = React.useCallback(() => {
|
const hideMenu = React.useCallback(() => {
|
||||||
pendingSelectionRef.current = null;
|
pendingSelectionRef.current = null;
|
||||||
|
activeAddToChatCleanupRef.current?.();
|
||||||
|
activeAddToChatCleanupRef.current = null;
|
||||||
|
|
||||||
if (!isMenuVisibleRef.current) {
|
if (!isMenuVisibleRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -123,12 +129,30 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
return Math.min(Math.max(anchorX, minX), maxX);
|
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(() => {
|
const showMenu = React.useCallback(() => {
|
||||||
if (!pendingSelectionRef.current) return;
|
if (!pendingSelectionRef.current) return;
|
||||||
|
|
||||||
const { plainText, markdownText, rect } = pendingSelectionRef.current;
|
const { plainText, markdownText, rect } = pendingSelectionRef.current;
|
||||||
const shouldAnimateIn = !position.show;
|
const shouldAnimateIn = !position.show;
|
||||||
|
|
||||||
|
activeAddToChatCleanupRef.current?.();
|
||||||
|
activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({
|
||||||
|
addToChat: () => addMarkdownToChat(markdownText),
|
||||||
|
dismiss: hideMenu,
|
||||||
|
});
|
||||||
|
|
||||||
// Position menu above the selection
|
// Position menu above the selection
|
||||||
const menuX = isMobile
|
const menuX = isMobile
|
||||||
? rect.left + rect.width / 2
|
? rect.left + rect.width / 2
|
||||||
@@ -154,7 +178,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
openRafRef.current = null;
|
openRafRef.current = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [getDesktopClampedX, isMobile, position.show]);
|
}, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (!position.show || isMobile || !menuRef.current) {
|
if (!position.show || isMobile || !menuRef.current) {
|
||||||
@@ -302,18 +326,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
const handleAddToChat = React.useCallback(() => {
|
const handleAddToChat = React.useCallback(() => {
|
||||||
if (!selectedTextMarkdown) return;
|
if (!selectedTextMarkdown) return;
|
||||||
|
addMarkdownToChat(selectedTextMarkdown);
|
||||||
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
|
}, [addMarkdownToChat, selectedTextMarkdown]);
|
||||||
setPendingInputText(markdownBlock, 'append');
|
|
||||||
|
|
||||||
hideMenu();
|
|
||||||
|
|
||||||
// Clear selection
|
|
||||||
window.getSelection()?.removeAllRanges();
|
|
||||||
queueMicrotask(() => {
|
|
||||||
focusChatInput();
|
|
||||||
});
|
|
||||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
|
||||||
|
|
||||||
const handleCreateNewSession = React.useCallback(async () => {
|
const handleCreateNewSession = React.useCallback(async () => {
|
||||||
if (!selectedText) return;
|
if (!selectedText) return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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>> = {}) {
|
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 };
|
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();
|
expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('records up to two chords', () => {
|
test('waits after the first chord and settles when a second chord is recorded', () => {
|
||||||
const first = updateShortcutRecordingState(emptyState, keyEvent('k', { ctrlKey: true }), 'keydown');
|
const first = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
|
||||||
const second = updateShortcutRecordingState(first, keyEvent('p', { ctrlKey: true }), 'keydown');
|
const second = updateShortcutRecordingState(first, keyEvent('p'), 'keydown');
|
||||||
const third = updateShortcutRecordingState(second, keyEvent('x', { ctrlKey: true }), 'keydown');
|
const third = updateShortcutRecordingState(second, keyEvent('x'), 'keydown');
|
||||||
expect(first.chords).toEqual(['mod+k']);
|
expect(first.chords).toEqual(['mod+s']);
|
||||||
expect(second.chords).toEqual(['mod+k', 'mod+p']);
|
expect(first.settled).toBe(false);
|
||||||
expect(third.chords).toEqual(['mod+k', 'mod+p']);
|
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', () => {
|
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', () => {
|
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('Enter'), 'keydown').chords).toEqual(['enter']);
|
||||||
expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']);
|
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').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';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
||||||
|
const MAX_SHORTCUT_KEY_COUNT = 3;
|
||||||
|
const SECOND_CHORD_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
interface RecordingKeyboardEvent {
|
interface RecordingKeyboardEvent {
|
||||||
altKey: boolean;
|
altKey: boolean;
|
||||||
@@ -36,6 +38,7 @@ interface RecordingKeyboardEvent {
|
|||||||
interface ShortcutRecordingState {
|
interface ShortcutRecordingState {
|
||||||
chords: ShortcutCombo[];
|
chords: ShortcutCombo[];
|
||||||
livePreview: ShortcutCombo | null;
|
livePreview: ShortcutCombo | null;
|
||||||
|
settled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShortcutRecordingDialogProps {
|
interface ShortcutRecordingDialogProps {
|
||||||
@@ -49,6 +52,19 @@ interface ShortcutRecordingDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
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(
|
function isCustomizableConflict(
|
||||||
conflict: ShortcutBindingConflict,
|
conflict: ShortcutBindingConflict,
|
||||||
): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } {
|
): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } {
|
||||||
@@ -56,6 +72,7 @@ function isCustomizableConflict(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
||||||
|
if (getPhysicalKeyCount(event) > MAX_SHORTCUT_KEY_COUNT) return null;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (event.metaKey || event.ctrlKey) parts.push('mod');
|
if (event.metaKey || event.ctrlKey) parts.push('mod');
|
||||||
if (event.shiftKey) parts.push('shift');
|
if (event.shiftKey) parts.push('shift');
|
||||||
@@ -65,6 +82,7 @@ function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null
|
|||||||
|
|
||||||
function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
||||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) return 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);
|
const key = keyToShortcutToken(event.key);
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
@@ -80,6 +98,7 @@ function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | nu
|
|||||||
function modifierKeyUpToCombo(event: React.KeyboardEvent<HTMLDivElement>): ShortcutCombo | null {
|
function modifierKeyUpToCombo(event: React.KeyboardEvent<HTMLDivElement>): ShortcutCombo | null {
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
if (!MODIFIER_KEYS.has(key)) return null;
|
if (!MODIFIER_KEYS.has(key)) return null;
|
||||||
|
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (event.metaKey || event.ctrlKey || key === 'meta' || key === 'control') parts.push('mod');
|
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;
|
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
|
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
|
||||||
export function updateShortcutRecordingState(
|
export function updateShortcutRecordingState(
|
||||||
state: ShortcutRecordingState,
|
state: ShortcutRecordingState,
|
||||||
@@ -100,14 +124,19 @@ export function updateShortcutRecordingState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'Backspace') {
|
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);
|
const chord = keyboardEventToCombo(event);
|
||||||
if (chord) {
|
if (chord) {
|
||||||
|
if (state.settled) {
|
||||||
|
return { chords: [chord], livePreview: null, settled: false };
|
||||||
|
}
|
||||||
|
const chords = state.chords.length < 2 ? [...state.chords, chord] : state.chords;
|
||||||
return {
|
return {
|
||||||
chords: state.chords.length < 2 ? [...state.chords, chord] : state.chords,
|
chords,
|
||||||
livePreview: null,
|
livePreview: null,
|
||||||
|
settled: chords.length === 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,27 +151,47 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
|
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);
|
const recordingRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
setRecording({ chords: [], livePreview: null });
|
setRecording({ chords: [], livePreview: null, settled: false });
|
||||||
recordingRef.current?.focus();
|
recordingRef.current?.focus();
|
||||||
}, [action]);
|
}, [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 combo = normalizeCombo(recording.chords.join(' '));
|
||||||
const conflicts = React.useMemo(
|
const conflicts = React.useMemo(
|
||||||
() => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [],
|
() => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [],
|
||||||
[action, 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 customizableConflicts = conflicts.filter(isCustomizableConflict);
|
||||||
const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix');
|
const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix');
|
||||||
const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact');
|
const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact');
|
||||||
|
const contextualPrefixConflict = conflicts.find((conflict) => conflict.kind === 'contextual-prefix');
|
||||||
|
|
||||||
const close = () => onOpenChange(false);
|
const close = () => onOpenChange(false);
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
|
if (!recording.settled) setRecording(settleShortcutRecordingState);
|
||||||
if (!action || !combo || protectedConflict || prefixConflict) return;
|
if (!action || !combo || protectedConflict || prefixConflict) return;
|
||||||
onSave(action.id, combo, exactConflict?.action.id);
|
onSave(action.id, combo, exactConflict?.action.id);
|
||||||
close();
|
close();
|
||||||
@@ -150,10 +199,11 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
|||||||
const handleRecordingEvent = (event: React.KeyboardEvent<HTMLDivElement>, phase: 'keydown' | 'keyup') => {
|
const handleRecordingEvent = (event: React.KeyboardEvent<HTMLDivElement>, phase: 'keydown' | 'keyup') => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) {
|
if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) {
|
||||||
const modifierCombo = modifierKeyUpToCombo(event);
|
const modifierCombo = modifierKeyUpToCombo(event);
|
||||||
if (modifierCombo) {
|
if (modifierCombo) {
|
||||||
setRecording({ chords: [modifierCombo], livePreview: null });
|
setRecording({ chords: [modifierCombo], livePreview: null, settled: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +217,7 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
|||||||
shiftKey: event.shiftKey,
|
shiftKey: event.shiftKey,
|
||||||
}, phase);
|
}, phase);
|
||||||
setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1
|
setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1
|
||||||
? { ...nextRecording, chords: nextRecording.chords.slice(0, 1) }
|
? recording
|
||||||
: nextRecording);
|
: nextRecording);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,21 +265,28 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{protectedConflict ? (
|
{recording.settled && protectedConflict ? (
|
||||||
<p className="typography-meta text-[var(--status-error)]">
|
<p className="typography-meta text-[var(--status-error)]">
|
||||||
{t('settings.openchamber.keyboardShortcuts.error.internalConflict')}
|
{t('settings.openchamber.keyboardShortcuts.error.internalConflict')}
|
||||||
</p>
|
</p>
|
||||||
) : prefixConflict ? (
|
) : recording.settled && prefixConflict ? (
|
||||||
<p className="typography-meta text-[var(--status-error)]">
|
<p className="typography-meta text-[var(--status-error)]">
|
||||||
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
|
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{exactConflict && !protectedConflict && !prefixConflict ? (
|
{recording.settled && exactConflict && !protectedConflict && !prefixConflict ? (
|
||||||
<p className="typography-meta text-[var(--status-warning)]">
|
<p className="typography-meta text-[var(--status-warning)]">
|
||||||
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
|
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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)]">
|
<p className="typography-meta text-[var(--status-warning)]">
|
||||||
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
|
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
|
||||||
</p>
|
</p>
|
||||||
@@ -242,7 +299,7 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!combo || Boolean(protectedConflict) || Boolean(prefixConflict)}
|
disabled={!combo || (recording.settled && (Boolean(protectedConflict) || Boolean(prefixConflict)))}
|
||||||
onClick={confirm}
|
onClick={confirm}
|
||||||
>
|
>
|
||||||
{t('settings.openchamber.keyboardShortcuts.actions.confirm')}
|
{t('settings.openchamber.keyboardShortcuts.actions.confirm')}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { expect, test } from 'bun:test';
|
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 {
|
return {
|
||||||
key,
|
key,
|
||||||
ctrlKey: false,
|
ctrlKey: false,
|
||||||
@@ -9,7 +16,36 @@ function keyEvent(key: string, modifiers: Partial<Pick<KeyboardEvent, 'ctrlKey'
|
|||||||
altKey: false,
|
altKey: false,
|
||||||
shiftKey: false,
|
shiftKey: false,
|
||||||
...modifiers,
|
...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', () => {
|
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, altKey: true }))).toBe(null);
|
||||||
expect(getDropdownNavigationKey(keyEvent('p', { ctrlKey: true, metaKey: 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 { cn } from "@/lib/utils"
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { shortcutRegistry } from "@/lib/shortcuts";
|
import { shortcutRegistry } from "@/lib/shortcuts";
|
||||||
import { isIMECompositionEvent } from "@/lib/ime";
|
import { handleDropdownNavigationKey } from "./dropdown-navigation";
|
||||||
import { getDropdownNavigationKey } from "./dropdown-navigation";
|
|
||||||
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles";
|
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles";
|
||||||
|
|
||||||
type AsChildProps = { asChild?: boolean };
|
type AsChildProps = { asChild?: boolean };
|
||||||
@@ -142,17 +141,13 @@ function DropdownMenuContent({
|
|||||||
|
|
||||||
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseMenu.Popup>['onKeyDown']> = (event) => {
|
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseMenu.Popup>['onKeyDown']> = (event) => {
|
||||||
onKeyDown?.(event);
|
onKeyDown?.(event);
|
||||||
if (event.defaultPrevented || event.isPropagationStopped() || isIMECompositionEvent(event)) return;
|
handleDropdownNavigationKey(event, (navigationKey) => {
|
||||||
const navigationKey = getDropdownNavigationKey(event);
|
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
if (!navigationKey) return;
|
key: navigationKey,
|
||||||
|
bubbles: true,
|
||||||
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
|
cancelable: true,
|
||||||
key: navigationKey,
|
}));
|
||||||
bubbles: true,
|
});
|
||||||
cancelable: true,
|
|
||||||
}));
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 {
|
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.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
|
||||||
if (event.key.toLowerCase() === 'n') return 'ArrowDown';
|
if (event.key.toLowerCase() === 'n') return 'ArrowDown';
|
||||||
if (event.key.toLowerCase() === 'p') return 'ArrowUp';
|
if (event.key.toLowerCase() === 'p') return 'ArrowUp';
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger"
|
|||||||
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
|
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { shortcutRegistry } from "@/lib/shortcuts";
|
import { shortcutRegistry } from "@/lib/shortcuts";
|
||||||
import { isIMECompositionEvent } from "@/lib/ime";
|
import { handleDropdownNavigationKey } from "./dropdown-navigation";
|
||||||
import { getDropdownNavigationKey } from "./dropdown-navigation";
|
|
||||||
|
|
||||||
type AsChildProps = { asChild?: boolean };
|
type AsChildProps = { asChild?: boolean };
|
||||||
type AsChildRenderProps = {
|
type AsChildRenderProps = {
|
||||||
@@ -210,17 +209,13 @@ function SelectContent({
|
|||||||
|
|
||||||
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseSelect.Popup>['onKeyDown']> = (event) => {
|
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseSelect.Popup>['onKeyDown']> = (event) => {
|
||||||
onKeyDown?.(event);
|
onKeyDown?.(event);
|
||||||
if (event.defaultPrevented || event.isPropagationStopped() || isIMECompositionEvent(event)) return;
|
handleDropdownNavigationKey(event, (navigationKey) => {
|
||||||
const navigationKey = getDropdownNavigationKey(event);
|
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
if (!navigationKey) return;
|
key: navigationKey,
|
||||||
|
bubbles: true,
|
||||||
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
|
cancelable: true,
|
||||||
key: navigationKey,
|
}));
|
||||||
bubbles: true,
|
});
|
||||||
cancelable: true,
|
|
||||||
}));
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from 'bun:test';
|
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', () => {
|
test('does not treat an unrelated visible listbox as an open dropdown', () => {
|
||||||
const promptNavigator = {} as Element;
|
const promptNavigator = {} as Element;
|
||||||
@@ -28,3 +28,10 @@ test('detects an open select popup', () => {
|
|||||||
|
|
||||||
expect(hasOpenDropdown(root)).toBe(true);
|
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 {
|
export function hasOpenDropdown(root: ParentNode = document): boolean {
|
||||||
return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR));
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
shortcutRegistry,
|
shortcutRegistry,
|
||||||
type ShortcutActionId,
|
type ShortcutActionId,
|
||||||
} from '@/lib/shortcuts';
|
} from '@/lib/shortcuts';
|
||||||
|
import { ShortcutRegistry } from '@/lib/shortcuts/registry';
|
||||||
import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry';
|
import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry';
|
||||||
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
|
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
@@ -29,8 +30,14 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
|||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
|
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
|
||||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||||
import { addSelectionToChat } from '@/lib/addSelectionToChat';
|
import {
|
||||||
import { hasOpenDropdown } from './keyboard-shortcut-dom';
|
dismissActiveSelectionToolbar,
|
||||||
|
getActiveSelectionToolbarVersion,
|
||||||
|
hasActiveSelectionToolbar,
|
||||||
|
invokeActiveSelectionAddToChat,
|
||||||
|
} from '@/lib/addSelectionToChat';
|
||||||
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
|
import { hasOpenDropdown, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
|
||||||
|
|
||||||
const dropdownTargetSelector = [
|
const dropdownTargetSelector = [
|
||||||
'[data-slot="dropdown-menu-content"]', '[data-slot="select-content"]', '[role="combobox"]',
|
'[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 abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const themeModeRef = React.useRef(themeMode);
|
const themeModeRef = React.useRef(themeMode);
|
||||||
const dispatcherRef = React.useRef<ShortcutDispatcher | null>(null);
|
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());
|
const heldKeysRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
if (!dispatcherRef.current) {
|
if (!dispatcherRef.current) {
|
||||||
@@ -64,6 +73,18 @@ export const useKeyboardShortcuts = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const dispatcher = dispatcherRef.current;
|
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]);
|
React.useEffect(() => { themeModeRef.current = themeMode; }, [themeMode]);
|
||||||
|
|
||||||
@@ -173,9 +194,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
const state = useUIStore.getState();
|
const state = useUIStore.getState();
|
||||||
state.setSettingsDialogOpen(!state.isSettingsDialogOpen);
|
state.setSettingsDialogOpen(!state.isSettingsDialogOpen);
|
||||||
},
|
},
|
||||||
add_selection_to_chat: () => {
|
add_selection_to_chat: invokeActiveSelectionAddToChat,
|
||||||
addSelectionToChat();
|
|
||||||
},
|
|
||||||
toggle_sidebar: () => {
|
toggle_sidebar: () => {
|
||||||
const state = useUIStore.getState();
|
const state = useUIStore.getState();
|
||||||
if (state.isMobile) state.setSessionSwitcherOpen(!state.isSessionSwitcherOpen);
|
if (state.isMobile) state.setSessionSwitcherOpen(!state.isSessionSwitcherOpen);
|
||||||
@@ -332,6 +351,34 @@ export const useKeyboardShortcuts = () => {
|
|||||||
event.stopPropagation();
|
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) => {
|
const handleEscapeKeyDownCapture = (event: KeyboardEvent) => {
|
||||||
if (event.key !== 'Escape') return;
|
if (event.key !== 'Escape') return;
|
||||||
if (dispatcher.handleEscape()) {
|
if (dispatcher.handleEscape()) {
|
||||||
@@ -343,11 +390,16 @@ export const useKeyboardShortcuts = () => {
|
|||||||
const state = useUIStore.getState();
|
const state = useUIStore.getState();
|
||||||
const isDropdownTarget = target instanceof Element
|
const isDropdownTarget = target instanceof Element
|
||||||
&& target.closest(dropdownTargetSelector);
|
&& target.closest(dropdownTargetSelector);
|
||||||
|
const dropdownOpen = Boolean(isDropdownTarget || hasOpenDropdown());
|
||||||
|
if (shouldStopDropdownImeEscape(event, dropdownOpen)) {
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
resetAbortPriming();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
target?.closest('[role="dialog"]')
|
target?.closest('[role="dialog"]')
|
||||||
|| isTerminalEventTarget(target)
|
|| isTerminalEventTarget(target)
|
||||||
|| isDropdownTarget
|
|| dropdownOpen
|
||||||
|| hasOpenDropdown()
|
|
||||||
) {
|
) {
|
||||||
resetAbortPriming();
|
resetAbortPriming();
|
||||||
return;
|
return;
|
||||||
@@ -410,6 +462,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (dispatcher.consumeCapturedPrefixEvent(event)) return;
|
if (dispatcher.consumeCapturedPrefixEvent(event)) return;
|
||||||
if (event.key === 'Escape' || isTerminalEventTarget(event.target)) return;
|
if (event.key === 'Escape' || isTerminalEventTarget(event.target)) return;
|
||||||
|
if (shortcutRegistry.isSuspended() || hasActiveSelectionToolbar()) return;
|
||||||
const combo = getEffectiveShortcutCombo('cycle_agent', useUIStore.getState().shortcutOverrides);
|
const combo = getEffectiveShortcutCombo('cycle_agent', useUIStore.getState().shortcutOverrides);
|
||||||
const backward = combo && !combo.includes('shift') ? normalizeCombo(`shift+${combo}`) : '';
|
const backward = combo && !combo.includes('shift') ? normalizeCombo(`shift+${combo}`) : '';
|
||||||
if (backward && eventMatchesShortcut(event, backward)) {
|
if (backward && eventMatchesShortcut(event, backward)) {
|
||||||
@@ -460,8 +513,10 @@ export const useKeyboardShortcuts = () => {
|
|||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
heldKeysRef.current.clear();
|
heldKeysRef.current.clear();
|
||||||
dispatcher.handleBlur();
|
dispatcher.handleBlur();
|
||||||
|
selectionToolbarDispatcher.handleBlur();
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyHoldDown, true);
|
window.addEventListener('keydown', handleKeyHoldDown, true);
|
||||||
|
window.addEventListener('keydown', handleSelectionToolbarKeyDownCapture, true);
|
||||||
window.addEventListener('keyup', handleKeyUp, true);
|
window.addEventListener('keyup', handleKeyUp, true);
|
||||||
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
|
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
|
||||||
window.addEventListener('keydown', handleEscapeKeyDownCapture, true);
|
window.addEventListener('keydown', handleEscapeKeyDownCapture, true);
|
||||||
@@ -470,6 +525,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
window.addEventListener('blur', handleBlur);
|
window.addEventListener('blur', handleBlur);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyHoldDown, true);
|
window.removeEventListener('keydown', handleKeyHoldDown, true);
|
||||||
|
window.removeEventListener('keydown', handleSelectionToolbarKeyDownCapture, true);
|
||||||
window.removeEventListener('keyup', handleKeyUp, true);
|
window.removeEventListener('keyup', handleKeyUp, true);
|
||||||
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
|
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
|
||||||
window.removeEventListener('keydown', handleEscapeKeyDownCapture, true);
|
window.removeEventListener('keydown', handleEscapeKeyDownCapture, true);
|
||||||
@@ -477,7 +533,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
window.removeEventListener('blur', handleBlur);
|
window.removeEventListener('blur', handleBlur);
|
||||||
};
|
};
|
||||||
}, [armAbortPrompt, currentSessionId, dispatcher, effectiveDirectory, resetAbortPriming, sessionPhase]);
|
}, [armAbortPrompt, currentSessionId, dispatcher, effectiveDirectory, resetAbortPriming, selectionToolbarDispatcher, sessionPhase]);
|
||||||
|
|
||||||
React.useEffect(() => () => resetAbortPriming(), [resetAbortPriming]);
|
React.useEffect(() => () => resetAbortPriming(), [resetAbortPriming]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 originalDocument = globalThis.document;
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -296,3 +305,109 @@ describe('addSelectionToChat', () => {
|
|||||||
expect(focusChatInputCalls.length).toBe(1);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,9 +8,66 @@ import {
|
|||||||
} from '@/components/chat/message/selectionMarkdown';
|
} from '@/components/chat/message/selectionMarkdown';
|
||||||
import { useInputStore } from '@/sync/input-store';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { shortcutRegistry } from '@/lib/shortcuts';
|
||||||
|
|
||||||
const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]';
|
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 => {
|
const isInsideChatComposer = (node: Node | null): boolean => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1032,6 +1032,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Tasten drücken...',
|
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Tasten drücken...',
|
||||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Verwende zuerst eine Tastenkombination.',
|
'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.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_go_to_line.label': 'Gehe zu Zeile (Datei-Editor)',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Befehlspalette öffnen',
|
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Befehlspalette öffnen',
|
||||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Eingabe fokussieren',
|
'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.edit': 'Bearbeiten',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Bestätigen',
|
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Bestätigen',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} bearbeiten',
|
'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.firstChord': 'Erste Kombination',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Zweite Kombination',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Zweite Kombination',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Tasten drücken…',
|
'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.field.pressKeys': 'Press keys...',
|
||||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capture a shortcut first.',
|
'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.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_go_to_line.label': 'Go to line (files editor)',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Open command palette',
|
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Open command palette',
|
||||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Focus input',
|
'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.edit': 'Edit',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirm',
|
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirm',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}',
|
'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.firstChord': 'First combination',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…',
|
'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.field.pressKeys": "Pulsa las teclas...",
|
||||||
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura un atajo primero.",
|
"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.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_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.open_command_palette.label": "Abrir paleta de comandos",
|
||||||
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Enfocar entrada",
|
"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.edit": "Editar",
|
||||||
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
|
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
"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.firstChord": "Primera combinación",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación",
|
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…",
|
"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.field.pressKeys': 'Appuyez sur les touches...',
|
||||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capturez d\'abord un raccourci.',
|
'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 l’enregistrer.',
|
'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 l’enregistrer.',
|
||||||
|
'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_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.open_command_palette.label': 'Ouvrir la palette de commandes',
|
||||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Entrée de mise au point',
|
'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.edit': 'Modifier',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirmer',
|
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirmer',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}',
|
'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.firstChord': 'Première combinaison',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…',
|
||||||
|
|||||||
@@ -1097,6 +1097,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'キーを押してください...',
|
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'キーを押してください...',
|
||||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': '最初にショートカットを設定してください。',
|
'settings.openchamber.keyboardShortcuts.error.captureFirst': '最初にショートカットを設定してください。',
|
||||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性がありますが、そのまま保存できます。',
|
'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_go_to_line.label': '指定行に移動(ファイルエディター)',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'コマンドパレットを開く',
|
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'コマンドパレットを開く',
|
||||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '入力をフォーカス',
|
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '入力をフォーカス',
|
||||||
@@ -1133,7 +1134,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': '編集',
|
'settings.openchamber.keyboardShortcuts.actions.edit': '編集',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
|
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集',
|
'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.firstChord': '最初の組み合わせ',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…',
|
||||||
|
|||||||
@@ -1064,6 +1064,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': '키를 누르세요...',
|
'settings.openchamber.keyboardShortcuts.field.pressKeys': '키를 누르세요...',
|
||||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': '먼저 단축키를 입력하세요.',
|
'settings.openchamber.keyboardShortcuts.error.captureFirst': '먼저 단축키를 입력하세요.',
|
||||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있지만 그래도 저장할 수 있습니다.',
|
'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_go_to_line.label': '줄로 이동(파일 편집기)',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '명령 팔레트 열기',
|
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '명령 팔레트 열기',
|
||||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '입력에 포커스',
|
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '입력에 포커스',
|
||||||
@@ -1100,7 +1101,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': '편집',
|
'settings.openchamber.keyboardShortcuts.actions.edit': '편집',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '확인',
|
'settings.openchamber.keyboardShortcuts.actions.confirm': '확인',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집',
|
'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.firstChord': '첫 번째 조합',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…',
|
||||||
|
|||||||
@@ -839,6 +839,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.title': 'Skróty klawiszowe',
|
'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.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.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.browse': 'Przeglądaj',
|
||||||
'settings.openchamber.opencodeCli.actions.browseAria': 'Przeglądaj ścieżkę do pliku binarnego OpenCode',
|
'settings.openchamber.opencodeCli.actions.browseAria': 'Przeglądaj ścieżkę do pliku binarnego OpenCode',
|
||||||
'settings.openchamber.opencodeCli.actions.restartingOpenCode': 'Restartowanie 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.edit': 'Edytuj',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Potwierdź',
|
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Potwierdź',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}',
|
'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.firstChord': 'Pierwsza kombinacja',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…',
|
'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.field.pressKeys": "Pressione as teclas...",
|
||||||
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura um atalho primeiro.",
|
"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.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_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.open_command_palette.label": "Abrir paleta de comandos",
|
||||||
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Focar entrada",
|
"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.edit": "Editar",
|
||||||
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
|
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
"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.firstChord": "Primeira combinação",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação",
|
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…",
|
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…",
|
||||||
|
|||||||
@@ -1064,6 +1064,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Натисніть клавіші...",
|
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Натисніть клавіші...",
|
||||||
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Спочатку запишіть комбінацію клавіш.",
|
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Спочатку запишіть комбінацію клавіш.",
|
||||||
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Ви все одно можете її зберегти.",
|
"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_go_to_line.label": "Перейти до рядка (редактор файлів)",
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Відкрити палітру команд",
|
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Відкрити палітру команд",
|
||||||
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Фокус на полі вводу",
|
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Фокус на полі вводу",
|
||||||
@@ -1100,7 +1101,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати",
|
"settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати",
|
||||||
"settings.openchamber.keyboardShortcuts.actions.confirm": "Підтвердити",
|
"settings.openchamber.keyboardShortcuts.actions.confirm": "Підтвердити",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}",
|
"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.firstChord": "Перша комбінація",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація",
|
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…",
|
"settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…",
|
||||||
|
|||||||
@@ -1064,6 +1064,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按键...',
|
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按键...',
|
||||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': '请先录入一个快捷键。',
|
'settings.openchamber.keyboardShortcuts.error.captureFirst': '请先录入一个快捷键。',
|
||||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍可保存。',
|
'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_go_to_line.label': '跳转到行(文件编辑器)',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '打开命令面板',
|
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '打开命令面板',
|
||||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦输入框',
|
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦输入框',
|
||||||
@@ -1100,7 +1101,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': '编辑',
|
'settings.openchamber.keyboardShortcuts.actions.edit': '编辑',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '确认',
|
'settings.openchamber.keyboardShortcuts.actions.confirm': '确认',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}',
|
'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.firstChord': '第一个组合',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…',
|
||||||
|
|||||||
@@ -971,6 +971,7 @@
|
|||||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按鍵...',
|
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按鍵...',
|
||||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': '請先錄入一個快速鍵。',
|
'settings.openchamber.keyboardShortcuts.error.captureFirst': '請先錄入一個快速鍵。',
|
||||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍可儲存。',
|
'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_go_to_line.label': '跳轉到行(檔案編輯器)',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '開啟命令面板',
|
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '開啟命令面板',
|
||||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦輸入方塊',
|
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦輸入方塊',
|
||||||
@@ -1007,7 +1008,7 @@
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': '編輯',
|
'settings.openchamber.keyboardShortcuts.actions.edit': '編輯',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
|
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}',
|
'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.firstChord': '第一個組合',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…',
|
'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 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
|
# 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'applic
|
|||||||
type ShortcutConfig = {
|
type ShortcutConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
defaultBinding: ShortcutCombo;
|
defaultBinding: ShortcutCombo;
|
||||||
|
allowsSequenceFallback?: true;
|
||||||
} & (
|
} & (
|
||||||
| { customizable: false }
|
| { customizable: false }
|
||||||
| {
|
| {
|
||||||
@@ -18,6 +19,7 @@ const SHORTCUT_GROUPS = {
|
|||||||
{
|
{
|
||||||
id: 'add_selection_to_chat',
|
id: 'add_selection_to_chat',
|
||||||
defaultBinding: 'mod+l',
|
defaultBinding: 'mod+l',
|
||||||
|
allowsSequenceFallback: true,
|
||||||
customizable: true,
|
customizable: true,
|
||||||
settingsLabelKey:
|
settingsLabelKey:
|
||||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label',
|
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label',
|
||||||
@@ -190,7 +192,7 @@ const SHORTCUT_GROUPS = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
navigation: [
|
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: 'find_in_file', defaultBinding: 'mod+f', customizable: false },
|
||||||
{
|
{
|
||||||
id: 'open_go_to_line',
|
id: 'open_go_to_line',
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ describe('ShortcutDispatcher', () => {
|
|||||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h', now: () => now });
|
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h', now: () => now });
|
||||||
|
|
||||||
expect(dispatcher.dispatch(key('g'))).toBe(true);
|
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('h'))).toBe(false);
|
||||||
expect(dispatcher.dispatch(key('g', { repeat: true }))).toBe(false);
|
expect(dispatcher.dispatch(key('g', { repeat: true }))).toBe(false);
|
||||||
expect(dispatcher.dispatch(key('g', { isComposing: true }))).toBe(false);
|
expect(dispatcher.dispatch(key('g', { isComposing: true }))).toBe(false);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { type ShortcutHandler, ShortcutRegistry } from './registry';
|
|||||||
import type { ShortcutActionId } from './schema';
|
import type { ShortcutActionId } from './schema';
|
||||||
import { isIMECompositionEvent } from '../ime';
|
import { isIMECompositionEvent } from '../ime';
|
||||||
|
|
||||||
const SEQUENCE_TIMEOUT_MS = 1500;
|
const SEQUENCE_TIMEOUT_MS = 3000;
|
||||||
const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']);
|
const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']);
|
||||||
|
|
||||||
export interface ShortcutDispatcherOptions {
|
export interface ShortcutDispatcherOptions {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ test('suspends all handlers until every idempotent cleanup completes', () => {
|
|||||||
const resumeFirst = registry.suspend();
|
const resumeFirst = registry.suspend();
|
||||||
const resumeSecond = registry.suspend();
|
const resumeSecond = registry.suspend();
|
||||||
expect(registry.get('open_settings')).toBe(undefined);
|
expect(registry.get('open_settings')).toBe(undefined);
|
||||||
|
expect(registry.isSuspended()).toBe(true);
|
||||||
|
|
||||||
resumeFirst();
|
resumeFirst();
|
||||||
resumeFirst();
|
resumeFirst();
|
||||||
@@ -41,4 +42,5 @@ test('suspends all handlers until every idempotent cleanup completes', () => {
|
|||||||
resumeSecond();
|
resumeSecond();
|
||||||
resumeSecond();
|
resumeSecond();
|
||||||
expect(registry.get('open_settings')).toBe(handler);
|
expect(registry.get('open_settings')).toBe(handler);
|
||||||
|
expect(registry.isSuspended()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export class ShortcutRegistry {
|
|||||||
return this.suspensionVersion;
|
return this.suspensionVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSuspended(): boolean {
|
||||||
|
return this.suspensionCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
actionIds(): IterableIterator<ShortcutActionId> {
|
actionIds(): IterableIterator<ShortcutActionId> {
|
||||||
return this.handlers.keys();
|
return this.handlers.keys();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,12 +73,21 @@ describe('shortcut schema', () => {
|
|||||||
.find((conflict) => conflict.action.id === 'find_in_file');
|
.find((conflict) => conflict.action.id === 'find_in_file');
|
||||||
const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x')
|
const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x')
|
||||||
.find((conflict) => conflict.action.id === 'save_file');
|
.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?.kind).toBe('exact');
|
||||||
expect(customizableConflict?.action.customizable).toBe(true);
|
expect(customizableConflict?.action.customizable).toBe(true);
|
||||||
expect(internalConflict?.kind).toBe('exact');
|
expect(internalConflict?.kind).toBe('exact');
|
||||||
expect(internalConflict?.action.customizable).toBe(false);
|
expect(internalConflict?.action.customizable).toBe(false);
|
||||||
expect(internalPrefixConflict?.kind).toBe('prefix');
|
expect(internalPrefixConflict?.kind).toBe('contextual-prefix');
|
||||||
expect(internalPrefixConflict?.action.customizable).toBe(false);
|
expect(internalPrefixConflict?.action.customizable).toBe(false);
|
||||||
|
expect(contextualPrefixConflict?.kind).toBe('contextual-prefix');
|
||||||
|
expect(contextualLeaderConflict?.kind).toBe('contextual-prefix');
|
||||||
|
expect(blockingPrefixConflict?.kind).toBe('prefix');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,11 +15,29 @@ export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number];
|
|||||||
export type ShortcutActionId = ShortcutAction['id'];
|
export type ShortcutActionId = ShortcutAction['id'];
|
||||||
export type ShortcutCategory = ShortcutAction['category'];
|
export type ShortcutCategory = ShortcutAction['category'];
|
||||||
export type CustomizableShortcutAction = Extract<ShortcutAction, { customizable: true }>;
|
export type CustomizableShortcutAction = Extract<ShortcutAction, { customizable: true }>;
|
||||||
|
export type ShortcutBindingConflictKind = ShortcutConflict | 'contextual-prefix';
|
||||||
export type ShortcutBindingConflict = {
|
export type ShortcutBindingConflict = {
|
||||||
action: ShortcutAction;
|
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 {
|
export function getShortcutAction(id: string): ShortcutAction | undefined {
|
||||||
return SHORTCUT_SCHEMA.find((action) => action.id === id);
|
return SHORTCUT_SCHEMA.find((action) => action.id === id);
|
||||||
}
|
}
|
||||||
@@ -73,13 +91,21 @@ export function getShortcutBindingConflicts(
|
|||||||
overrides?: Record<string, ShortcutCombo>,
|
overrides?: Record<string, ShortcutCombo>,
|
||||||
): ShortcutBindingConflict[] {
|
): ShortcutBindingConflict[] {
|
||||||
const conflicts: ShortcutBindingConflict[] = [];
|
const conflicts: ShortcutBindingConflict[] = [];
|
||||||
|
const action = getShortcutAction(actionId);
|
||||||
|
if (!action) return conflicts;
|
||||||
for (const candidate of SHORTCUT_SCHEMA) {
|
for (const candidate of SHORTCUT_SCHEMA) {
|
||||||
if (candidate.id === actionId) continue;
|
if (candidate.id === actionId) continue;
|
||||||
const candidateCombo = candidate.id === 'switch_context_surface'
|
const candidateCombo = candidate.id === 'switch_context_surface'
|
||||||
? getEffectiveShortcutPrefix(candidate.id, overrides)
|
? getEffectiveShortcutPrefix(candidate.id, overrides)
|
||||||
: getEffectiveShortcutCombo(candidate.id, overrides);
|
: getEffectiveShortcutCombo(candidate.id, overrides);
|
||||||
const kind = getShortcutConflict(combo, candidateCombo);
|
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;
|
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.
|
||||||
Reference in New Issue
Block a user