diff --git a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx index 1956615c..bd36e40d 100644 --- a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx @@ -1,12 +1,7 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - SettingsSection, - SettingsFieldRow, -} from '@/components/sections/shared/SettingsSection'; +import { SettingsFieldRow, SettingsSection } from '@/components/sections/shared/SettingsSection'; import { useUIStore } from '@/stores/useUIStore'; -import { cn } from '@/lib/utils'; import { updateDesktopSettings } from '@/lib/persistence'; import { isVSCodeRuntime } from '@/lib/desktop'; import { @@ -14,314 +9,124 @@ import { getCustomizableShortcutActions, getEffectiveShortcutCombo, getEffectiveShortcutPrefix, - isRiskyBrowserShortcut, - keyToShortcutToken, - normalizeCombo, UNASSIGNED_SHORTCUT, + type ShortcutActionId, + type ShortcutCategory, type ShortcutCombo, + type CustomizableShortcutAction, } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; +import { ShortcutRecordingDialog } from './ShortcutRecordingDialog'; -const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); - -const keyboardEventToCombo = (event: React.KeyboardEvent): ShortcutCombo | null => { - if (MODIFIER_KEYS.has(event.key.toLowerCase())) { - return null; - } - - const parts: string[] = []; - - if (event.metaKey || event.ctrlKey) { - parts.push('mod'); - } - if (event.shiftKey) { - parts.push('shift'); - } - if (event.altKey) { - parts.push('alt'); - } - - const keyToken = keyToShortcutToken(event.key); - if (!keyToken) { - return null; - } - - parts.push(keyToken); - return normalizeCombo(parts.join('+')); -}; - -// Prefix capture for chord-style shortcuts (e.g. "switch context panel -// surface"): a bare modifier press is accepted so the prefix can be just the -// primary modifier (default) or a modifier + key chord like `mod+p`. -const keyboardEventToPrefixCombo = (event: React.KeyboardEvent): ShortcutCombo | null => { - const parts: string[] = []; - - if (event.metaKey || event.ctrlKey) { - parts.push('mod'); - } - if (event.shiftKey) { - parts.push('shift'); - } - if (event.altKey) { - parts.push('alt'); - } - - if (MODIFIER_KEYS.has(event.key.toLowerCase())) { - return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; - } - - const keyToken = keyToShortcutToken(event.key); - if (!keyToken) { - return null; - } - - parts.push(keyToken); - return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; -}; +const CATEGORIES: ShortcutCategory[] = ['session', 'models', 'panels', 'navigation', 'application']; export const KeyboardShortcutsSettings: React.FC = () => { const { t } = useI18n(); - const tUnsafe = React.useCallback((key: string) => t(key as Parameters[0]), [t]); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const setShortcutOverride = useUIStore((state) => state.setShortcutOverride); const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride); const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides); + const [editingAction, setEditingAction] = React.useState(null); const actions = React.useMemo(() => { const all = getCustomizableShortcutActions(); - if (!isVSCodeRuntime()) { - return all; - } - return all.filter((action) => action.id !== 'toggle_prompt_navigator'); + return isVSCodeRuntime() ? all.filter((action) => action.id !== 'toggle_prompt_navigator') : all; }, []); - const actionLabel = React.useCallback((id: string, fallbackLabel: string): string => { - const key = `settings.openchamber.keyboardShortcuts.action.${id}.label`; - const translated = tUnsafe(key); - return translated === key ? fallbackLabel : translated; - }, [tUnsafe]); - - const [capturingActionId, setCapturingActionId] = React.useState(null); - const [draftByAction, setDraftByAction] = React.useState>({}); - const [errorText, setErrorText] = React.useState(''); - const [warningText, setWarningText] = React.useState(''); - const [pendingOverwrite, setPendingOverwrite] = React.useState<{ - actionId: string; - combo: ShortcutCombo; - conflictActionId: string; - } | null>(null); - - const persistShortcutOverrides = React.useCallback((nextOverrides: Record) => { + const persist = (nextOverrides: Record) => { void updateDesktopSettings({ shortcutOverrides: nextOverrides }); - }, []); - - const findConflict = React.useCallback((actionId: string, combo: ShortcutCombo): string | null => { - const normalized = normalizeCombo(combo); - for (const action of actions) { - if (action.id === actionId) { - continue; - } - const existing = getEffectiveShortcutCombo(action.id, shortcutOverrides); - if (normalizeCombo(existing) === normalized) { - return action.id; - } - } - return null; - }, [actions, shortcutOverrides]); - - const saveCombo = React.useCallback((actionId: string, combo: ShortcutCombo) => { - const normalized = normalizeCombo(combo); - const conflictActionId = findConflict(actionId, normalized); - if (conflictActionId) { - setPendingOverwrite({ actionId, combo: normalized, conflictActionId }); - setErrorText(''); - return; - } - - const nextOverrides = { ...shortcutOverrides, [actionId]: normalized }; - setShortcutOverride(actionId, normalized); - persistShortcutOverrides(nextOverrides); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(isRiskyBrowserShortcut(normalized) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : ''); - setDraftByAction((current) => { - const rest = { ...current }; - delete rest[actionId]; - return rest; - }); - }, [findConflict, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]); - - const confirmOverwrite = React.useCallback(() => { - if (!pendingOverwrite) { - return; - } - - const nextOverrides = { - ...shortcutOverrides, - [pendingOverwrite.conflictActionId]: UNASSIGNED_SHORTCUT, - [pendingOverwrite.actionId]: pendingOverwrite.combo, - }; - setShortcutOverride(pendingOverwrite.conflictActionId, UNASSIGNED_SHORTCUT); - setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo); - persistShortcutOverrides(nextOverrides); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : ''); - setDraftByAction((current) => { - const rest = { ...current }; - delete rest[pendingOverwrite.actionId]; - return rest; - }); - }, [pendingOverwrite, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]); - - const resetOne = React.useCallback((actionId: string) => { + }; + const save = ( + actionId: ShortcutActionId, + combo: ShortcutCombo, + replaceActionId?: ShortcutActionId, + ) => { + const nextOverrides = { ...shortcutOverrides, [actionId]: combo }; + if (replaceActionId) nextOverrides[replaceActionId] = UNASSIGNED_SHORTCUT; + setShortcutOverride(actionId, combo); + if (replaceActionId) setShortcutOverride(replaceActionId, UNASSIGNED_SHORTCUT); + persist(nextOverrides); + }; + const resetOne = (actionId: ShortcutActionId) => { const nextOverrides = { ...shortcutOverrides }; delete nextOverrides[actionId]; clearShortcutOverride(actionId); - persistShortcutOverrides(nextOverrides); - setDraftByAction((current) => { - const rest = { ...current }; - delete rest[actionId]; - return rest; - }); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(''); - }, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]); + persist(nextOverrides); + }; + const shortcutDisplay = (action: CustomizableShortcutAction): string => { + const isSurfaceSwitch = action.id === 'switch_context_surface'; + const combo = isSurfaceSwitch + ? getEffectiveShortcutPrefix(action.id, shortcutOverrides) + : getEffectiveShortcutCombo(action.id, shortcutOverrides); + const formatted = formatShortcutForDisplay( + combo, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ); + return isSurfaceSwitch && combo && combo !== UNASSIGNED_SHORTCUT + ? `${formatted}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}` + : formatted; + }; return ( - { - resetAllShortcutOverrides(); - persistShortcutOverrides({}); - setDraftByAction({}); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(''); - }} - > - {t('settings.openchamber.keyboardShortcuts.actions.resetAll')} - - )} - > - {(errorText || warningText || pendingOverwrite) && ( -
- {pendingOverwrite && ( -
- - {t('settings.openchamber.keyboardShortcuts.overwritePrompt')} - -
- - -
+ <> + {CATEGORIES.map((category, categoryIndex) => { + const categoryActions = actions.filter((action) => action.category === category); + if (categoryActions.length === 0) return null; + return ( + { + resetAllShortcutOverrides(); + persist({}); + }}> + {t('settings.openchamber.keyboardShortcuts.actions.resetAll')} + + ) : undefined} + > +
+ {categoryActions.map((action) => ( + + + {shortcutDisplay(action)} + + + + + ))}
- )} - {errorText && ( -
- {errorText} -
- )} - {warningText && ( -
- {warningText} -
- )} -
- )} - -
- {actions.map((action, index) => { - const isSurfaceSwitch = action.id === 'switch_context_surface'; - const effective = isSurfaceSwitch - ? getEffectiveShortcutPrefix(action.id, shortcutOverrides) - : getEffectiveShortcutCombo(action.id, shortcutOverrides); - const draft = draftByAction[action.id]; - const displayCombo = draft ?? effective; - const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective); - const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT; - const displayValue = capturingActionId === action.id - ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') - : isSurfaceSwitch && !isUnassignedDisplay - ? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}` - : formatShortcutForDisplay(displayCombo); - - return ( -
0 && "border-t border-border/40")}> - - { - setCapturingActionId(action.id); - setErrorText(''); - }} - onBlur={() => { - if (capturingActionId === action.id) { - setCapturingActionId(null); - } - }} - onKeyDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - - if (event.key === 'Escape') { - setCapturingActionId(null); - return; - } - - const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event); - if (!combo) { - return; - } - - setDraftByAction((current) => ({ - ...current, - [action.id]: combo, - })); - setCapturingActionId(null); - setPendingOverwrite(null); - setErrorText(''); - }} - className="h-7 w-40 min-w-0 typography-ui-label text-center" - /> - - - -
- ); - })} -
- + + ); + })} + { + if (!open) setEditingAction(null); + }} + /> + ); }; diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts new file mode 100644 index 00000000..256383a1 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test'; +import { settleShortcutRecordingState, updateShortcutRecordingState } from './ShortcutRecordingDialog'; + +const emptyState = { chords: [], livePreview: null, settled: false }; + +function keyEvent(key: string, modifiers: Partial> = {}) { + return { key, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers }; +} + +describe('ShortcutRecordingDialog recording state', () => { + test('previews modifiers and clears the preview when they are released', () => { + const pressed = updateShortcutRecordingState(emptyState, keyEvent('Control', { ctrlKey: true, shiftKey: true }), 'keydown'); + expect(pressed.livePreview).toBe('mod+shift'); + expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull(); + }); + + test('waits after the first chord and settles when a second chord is recorded', () => { + const first = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown'); + const second = updateShortcutRecordingState(first, keyEvent('p'), 'keydown'); + const third = updateShortcutRecordingState(second, keyEvent('x'), 'keydown'); + expect(first.chords).toEqual(['mod+s']); + expect(first.settled).toBe(false); + expect(second.chords).toEqual(['mod+s', 'p']); + expect(second.settled).toBe(true); + expect(third.chords).toEqual(['x']); + expect(third.settled).toBe(false); + }); + + test('settles a single chord for timeout and Confirm validation', () => { + const waiting = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown'); + expect(settleShortcutRecordingState(waiting)).toEqual({ chords: ['mod+s'], livePreview: null, settled: true }); + }); + + test('records at most three simultaneous keys', () => { + const previous = { chords: ['mod+k'], livePreview: null, settled: false }; + const threeKeys = updateShortcutRecordingState( + previous, + keyEvent('s', { ctrlKey: true, shiftKey: true }), + 'keydown', + ); + const fourKeys = updateShortcutRecordingState( + previous, + keyEvent('s', { ctrlKey: true, metaKey: true, shiftKey: true }), + 'keydown', + ); + + expect(threeKeys.chords).toEqual(['mod+k', 'mod+shift+s']); + expect(fourKeys.chords).toEqual(['mod+k']); + }); + + test('ignores repeat and IME events', () => { + expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown')).toEqual(emptyState); + expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown')).toEqual(emptyState); + }); + + test('records Enter and Escape while Backspace removes the final chord', () => { + const state = { chords: ['mod+k', 'mod+p'], livePreview: null, settled: true }; + expect(updateShortcutRecordingState(emptyState, keyEvent('Enter'), 'keydown').chords).toEqual(['enter']); + expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']); + expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').chords).toEqual(['mod+k']); + expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').settled).toBe(false); + expect(updateShortcutRecordingState({ chords: ['mod+k'], livePreview: null, settled: false }, keyEvent('Backspace'), 'keydown')).toEqual(emptyState); + }); +}); diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx new file mode 100644 index 00000000..a04fb8a1 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx @@ -0,0 +1,311 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + formatShortcutForDisplay, + getShortcutBindingConflicts, + isRiskyBrowserShortcut, + keyToShortcutToken, + normalizeCombo, + type ShortcutActionId, + type ShortcutBindingConflict, + type ShortcutCombo, + type CustomizableShortcutAction, +} from '@/lib/shortcuts'; +import { useI18n } from '@/lib/i18n'; + +const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); +const MAX_SHORTCUT_KEY_COUNT = 3; +const SECOND_CHORD_TIMEOUT_MS = 3000; + +interface RecordingKeyboardEvent { + altKey: boolean; + ctrlKey: boolean; + isComposing: boolean; + key: string; + metaKey: boolean; + repeat: boolean; + shiftKey: boolean; +} + +interface ShortcutRecordingState { + chords: ShortcutCombo[]; + livePreview: ShortcutCombo | null; + settled: boolean; +} + +interface ShortcutRecordingDialogProps { + action: CustomizableShortcutAction | null; + overrides: Record; + onSave: ( + actionId: ShortcutActionId, + combo: ShortcutCombo, + replaceActionId?: ShortcutActionId, + ) => void; + onOpenChange: (open: boolean) => void; +} + +function getPhysicalKeyCount( + event: Pick, + includeEventKey = false, +): number { + const keys = new Set(); + if (event.altKey) keys.add('alt'); + if (event.ctrlKey) keys.add('control'); + if (event.metaKey) keys.add('meta'); + if (event.shiftKey) keys.add('shift'); + if (includeEventKey) keys.add(event.key.toLowerCase()); + return keys.size; +} + +function isCustomizableConflict( + conflict: ShortcutBindingConflict, +): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } { + return conflict.action.customizable; +} + +function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null { + if (getPhysicalKeyCount(event) > MAX_SHORTCUT_KEY_COUNT) return null; + const parts: string[] = []; + if (event.metaKey || event.ctrlKey) parts.push('mod'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; +} + +function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null { + if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null; + if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null; + + const key = keyToShortcutToken(event.key); + if (!key) return null; + + const parts: string[] = []; + if (event.metaKey || event.ctrlKey) parts.push('mod'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + parts.push(key); + return normalizeCombo(parts.join('+')); +} + +function modifierKeyUpToCombo(event: React.KeyboardEvent): ShortcutCombo | null { + const key = event.key.toLowerCase(); + if (!MODIFIER_KEYS.has(key)) return null; + if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null; + + const parts: string[] = []; + if (event.metaKey || event.ctrlKey || key === 'meta' || key === 'control') parts.push('mod'); + if (event.shiftKey || key === 'shift') parts.push('shift'); + if (event.altKey || key === 'alt') parts.push('alt'); + return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; +} + +// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition +export function settleShortcutRecordingState(state: ShortcutRecordingState): ShortcutRecordingState { + return state.chords.length > 0 ? { ...state, livePreview: null, settled: true } : state; +} + +// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition +export function updateShortcutRecordingState( + state: ShortcutRecordingState, + event: RecordingKeyboardEvent, + phase: 'keydown' | 'keyup', +): ShortcutRecordingState { + if (event.repeat || event.isComposing) return state; + if (phase === 'keyup') { + return { ...state, livePreview: getModifierPreview(event) }; + } + + if (event.key === 'Backspace') { + return { chords: state.chords.slice(0, -1), livePreview: null, settled: false }; + } + + const chord = keyboardEventToCombo(event); + if (chord) { + if (state.settled) { + return { chords: [chord], livePreview: null, settled: false }; + } + const chords = state.chords.length < 2 ? [...state.chords, chord] : state.chords; + return { + chords, + livePreview: null, + settled: chords.length === 2, + }; + } + + return { ...state, livePreview: getModifierPreview(event) }; +} + +export const ShortcutRecordingDialog: React.FC = ({ + action, + overrides, + onSave, + onOpenChange, +}) => { + const { t } = useI18n(); + const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey); + const conflictActionLabel = (conflict: ShortcutBindingConflict) => ( + conflict.action.customizable + ? actionLabel(conflict.action) + : formatShortcutForDisplay(conflict.action.defaultBinding) + ); + const [recording, setRecording] = React.useState({ chords: [], livePreview: null, settled: false }); + const recordingRef = React.useRef(null); + + React.useEffect(() => { + if (!action) return; + setRecording({ chords: [], livePreview: null, settled: false }); + recordingRef.current?.focus(); + }, [action]); + + const waitingForSecondChord = recording.chords.length === 1 && !recording.settled; + + React.useEffect(() => { + if (!waitingForSecondChord) return; + const timeout = window.setTimeout( + () => setRecording(settleShortcutRecordingState), + SECOND_CHORD_TIMEOUT_MS, + ); + return () => window.clearTimeout(timeout); + }, [waitingForSecondChord]); + + const combo = normalizeCombo(recording.chords.join(' ')); + const conflicts = React.useMemo( + () => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [], + [action, combo, overrides], + ); + const protectedConflict = conflicts.find((conflict) => ( + !conflict.action.customizable && conflict.kind !== 'contextual-prefix' + )); + const customizableConflicts = conflicts.filter(isCustomizableConflict); + const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix'); + const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact'); + const contextualPrefixConflict = conflicts.find((conflict) => conflict.kind === 'contextual-prefix'); + + const close = () => onOpenChange(false); + const confirm = () => { + if (!recording.settled) setRecording(settleShortcutRecordingState); + if (!action || !combo || protectedConflict || prefixConflict) return; + onSave(action.id, combo, exactConflict?.action.id); + close(); + }; + const handleRecordingEvent = (event: React.KeyboardEvent, phase: 'keydown' | 'keyup') => { + event.preventDefault(); + event.stopPropagation(); + + if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) { + const modifierCombo = modifierKeyUpToCombo(event); + if (modifierCombo) { + setRecording({ chords: [modifierCombo], livePreview: null, settled: true }); + return; + } + } + const nextRecording = updateShortcutRecordingState(recording, { + altKey: event.altKey, + ctrlKey: event.ctrlKey, + isComposing: event.nativeEvent.isComposing, + key: event.key, + metaKey: event.metaKey, + repeat: event.repeat, + shiftKey: event.shiftKey, + }, phase); + setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1 + ? recording + : nextRecording); + }; + + return ( + { + if (!open) { + eventDetails.cancel(); + } + }} + > + + + + {action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''} + + {t('settings.openchamber.keyboardShortcuts.dialog.instructions')} + + +
handleRecordingEvent(event, 'keydown')} + onKeyUp={(event) => handleRecordingEvent(event, 'keyup')} + onBlur={() => setRecording((current) => ({ ...current, livePreview: null }))} + > +
+ {recording.chords.map((chord, index) => ( + + {formatShortcutForDisplay(chord)} + + ))} + {recording.livePreview ? ( + + {formatShortcutForDisplay(recording.livePreview)} + + ) : null} + {recording.chords.length === 0 && !recording.livePreview ? ( + + {t('settings.openchamber.keyboardShortcuts.dialog.recording')} + + ) : null} +
+
+ + {recording.settled && protectedConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.error.internalConflict')} +

+ ) : recording.settled && prefixConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })} +

+ ) : null} + {recording.settled && exactConflict && !protectedConflict && !prefixConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })} +

+ ) : null} + {recording.settled && contextualPrefixConflict && !protectedConflict && !prefixConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.warning.contextualPrefix', { + action: conflictActionLabel(contextualPrefixConflict), + })} +

+ ) : null} + {recording.settled && combo && isRiskyBrowserShortcut(combo) ? ( +

+ {t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')} +

+ ) : null} + + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 6ed250ea..6080ac29 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -11,17 +11,17 @@ import { useUIStore } from "@/stores/useUIStore"; import { getEffectiveShortcutCombo, getShortcutAction, - getModifierLabel, formatShortcutForDisplay, + type ShortcutActionId, } from "@/lib/shortcuts"; import { useI18n, type I18nKey } from "@/lib/i18n"; import { isVSCodeRuntime } from "@/lib/desktop"; import type { IconName } from "@/components/icon/icons"; type ShortcutItem = { - id?: string; + id?: ShortcutActionId; keys: string | string[]; - descriptionKey: I18nKey; + descriptionKey?: I18nKey; icon: IconName | null; }; @@ -30,9 +30,12 @@ type ShortcutSection = { items: ShortcutItem[]; }; -const renderShortcut = (id: string, fallbackCombo: string, overrides: Record) => { - const action = getShortcutAction(id); - return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides)) : fallbackCombo; +const renderShortcut = ( + id: ShortcutActionId, + overrides: Record, + unassignedLabel: string, +) => { + return formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel); }; export const HelpDialog: React.FC = () => { @@ -40,7 +43,6 @@ export const HelpDialog: React.FC = () => { const isHelpDialogOpen = useUIStore((state) => state.isHelpDialogOpen); const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const mod = getModifierLabel(); const isVSCode = isVSCodeRuntime(); const shortcuts: ShortcutSection[] = [ @@ -100,7 +102,7 @@ export const HelpDialog: React.FC = () => { keys: '', }, { - keys: [`Shift + Alt + ${mod} + N`], + keys: [formatShortcutForDisplay('mod+shift+alt+n')], descriptionKey: "helpDialog.item.newWindow", icon: "window", }, @@ -121,6 +123,21 @@ export const HelpDialog: React.FC = () => { icon: "git-branch", keys: '', }, + { + id: 'open_draft_project_picker', + icon: 'folder', + keys: '', + }, + { + id: 'open_draft_worktree_picker', + icon: 'git-branch', + keys: '', + }, + { + id: 'open_session_list', + icon: 'list-unordered', + keys: '', + }, { id: 'focus_input', descriptionKey: "helpDialog.item.focusChatInput", icon: "text", keys: '' }, { id: 'toggle_prompt_navigator', @@ -176,7 +193,7 @@ export const HelpDialog: React.FC = () => { keys: '', }, { - keys: [`${mod} + 1...0`], + keys: [`${formatShortcutForDisplay('mod')} + 1...0`], descriptionKey: "helpDialog.item.switchContextSurface", icon: "layout-right", }, @@ -214,7 +231,7 @@ export const HelpDialog: React.FC = () => { ]; return ( - + @@ -237,40 +254,48 @@ export const HelpDialog: React.FC = () => { {section.items .filter((shortcut) => !(isVSCode && shortcut.id === 'toggle_prompt_navigator')) .map((shortcut) => { - const displayKeys = shortcut.id - ? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides) - : (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / ")); + const action = shortcut.id ? getShortcutAction(shortcut.id) : undefined; + const descriptionKey = shortcut.descriptionKey + ?? (action?.customizable ? action.settingsLabelKey : undefined); + if (!descriptionKey) return null; + const displayKeys = shortcut.id + ? renderShortcut( + shortcut.id, + shortcutOverrides, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ) + : (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / ")); - return ( -
-
- {shortcut.icon && ( - - )} - - {t(shortcut.descriptionKey)} - + return ( +
+
+ {shortcut.icon && ( + + )} + + {t(descriptionKey)} + +
+
+ {(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => ( + + {i > 0 && ( + + {t('helpDialog.keyCombiner.or')} + + )} + + {keyCombo} + + + ))} +
-
- {(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => ( - - {i > 0 && ( - - {t('helpDialog.keyCombiner.or')} - - )} - - {keyCombo} - - - ))} -
-
- ); - })} + ); + })}
))} @@ -284,7 +309,11 @@ export const HelpDialog: React.FC = () => {
  • • {t('helpDialog.proTips.commandPalette', { - shortcut: renderShortcut('open_command_palette', `${mod} P`, shortcutOverrides), + shortcut: renderShortcut( + 'open_command_palette', + shortcutOverrides, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ), })}
  • diff --git a/packages/ui/src/components/ui/dropdown-navigation.ts b/packages/ui/src/components/ui/dropdown-navigation.ts new file mode 100644 index 00000000..bba31b73 --- /dev/null +++ b/packages/ui/src/components/ui/dropdown-navigation.ts @@ -0,0 +1,45 @@ +import type React from 'react'; + +import { isIMECompositionEvent } from '@/lib/ime'; + +export function getDropdownNavigationKey(event: Pick): 'ArrowDown' | 'ArrowUp' | null { + if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null; + if (event.key.toLowerCase() === 'n') return 'ArrowDown'; + if (event.key.toLowerCase() === 'p') return 'ArrowUp'; + return null; +} + +type DropdownNavigationEvent = Pick< + React.KeyboardEvent, + | '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); +} diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts index 608bb203..f475c3ae 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test'; -import { hasOpenDropdown } from './keyboard-shortcut-dom'; +import { hasOpenDropdown, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom'; test('does not treat an unrelated visible listbox as an open dropdown', () => { const promptNavigator = {} as Element; @@ -28,3 +28,10 @@ test('detects an open select popup', () => { expect(hasOpenDropdown(root)).toBe(true); }); + +test('stops IME Escape before an open dropdown dismiss listener', () => { + expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, true)).toBe(true); + expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 229 }, true)).toBe(true); + expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 27 }, true)).toBe(false); + expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, false)).toBe(false); +}); diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.ts index 413b6be2..97400b3f 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.ts @@ -6,3 +6,12 @@ const OPEN_DROPDOWN_SELECTOR = [ export function hasOpenDropdown(root: ParentNode = document): boolean { return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR)); } + +export function shouldStopDropdownImeEscape( + event: Pick, + dropdownOpen: boolean, +): boolean { + return dropdownOpen + && event.key === 'Escape' + && (event.isComposing || event.keyCode === 229); +} diff --git a/packages/ui/src/hooks/useKeybind.test.ts b/packages/ui/src/hooks/useKeybind.test.ts new file mode 100644 index 00000000..498ab7bb --- /dev/null +++ b/packages/ui/src/hooks/useKeybind.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test'; +import type { ShortcutHandler } from '@/lib/shortcuts'; +import type { ShortcutBindings } from './useKeybind'; + +const handler: ShortcutHandler = () => {}; +const validBindings = { + open_session_list: handler, +}; +const mixedBindingsWithTypo = { + open_session_list: handler, + open_session_lsit: handler, +}; + +const acceptedBindings: ShortcutBindings = validBindings; +// @ts-expect-error A misspelled key must fail even when the object also contains a valid ID. +const rejectedBindings: ShortcutBindings = mixedBindingsWithTypo; +void rejectedBindings; + +test('accepts bindings whose IDs are declared in the shortcut schema', () => { + expect(Object.keys(acceptedBindings)).toEqual(['open_session_list']); +}); diff --git a/packages/ui/src/hooks/useKeybind.ts b/packages/ui/src/hooks/useKeybind.ts new file mode 100644 index 00000000..8076a26c --- /dev/null +++ b/packages/ui/src/hooks/useKeybind.ts @@ -0,0 +1,30 @@ +import React from 'react'; +import { shortcutRegistry, type ShortcutActionId, type ShortcutHandler } from '@/lib/shortcuts'; + +export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler): void { + const handlerRef = React.useRef(handler); + handlerRef.current = handler; + + React.useEffect(() => shortcutRegistry.register(actionId, (event) => handlerRef.current(event)), [actionId]); +} + +export type ShortcutBindings< + Bindings extends Partial>, +> = Bindings & Record, never>; + +export function useKeybinds< + const Bindings extends Partial>, +>(bindings: ShortcutBindings): void { + const handlersRef = React.useRef(bindings); + handlersRef.current = bindings; + const actionIdsKey = Object.keys(bindings).sort().join('\0'); + + React.useEffect(() => { + const actionIds = (actionIdsKey ? actionIdsKey.split('\0') : []) as ShortcutActionId[]; + const unregister = actionIds.map((actionId) => shortcutRegistry.register(actionId, (event) => { + const handler = handlersRef.current[actionId]; + return handler ? handler(event) : false; + })); + return () => unregister.forEach((remove) => remove()); + }, [actionIdsKey]); +} diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 1f1a2f48..c52e96e8 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1086,6 +1086,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Sitzungs-Tab schließen', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Tastenkürzel öffnen', @@ -1100,6 +1101,27 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Eingabe erweitern', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Konversations-Zeitleiste öffnen', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Prompt-Navigator umschalten', + '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.category.session': 'Sitzungssteuerung', + 'settings.openchamber.keyboardShortcuts.category.models': 'Modelle und Agenten', + 'settings.openchamber.keyboardShortcuts.category.panels': 'Panels und Werkzeuge', + 'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation', + 'settings.openchamber.keyboardShortcuts.category.application': 'Anwendung', + 'settings.openchamber.keyboardShortcuts.actions.edit': 'Bearbeiten', + 'settings.openchamber.keyboardShortcuts.actions.confirm': 'Bestätigen', + 'settings.openchamber.keyboardShortcuts.dialog.title': '{action} bearbeiten', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Drücken Sie bis zu zwei Tastenkombinationen mit jeweils höchstens drei Tasten. Warten Sie nach der ersten bis zu 3 Sekunden auf eine zweite Kombination. Wählen Sie Bestätigen zum Anwenden oder Abbrechen zum Verwerfen. Mit der Rücktaste entfernen Sie die letzte.', + 'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Erste Kombination', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Zweite Kombination', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Tasten drücken…', + 'settings.openchamber.keyboardShortcuts.unassigned': 'Nicht zugewiesen', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Dies kollidiert mit der von {action} verwendeten Sequenz. Wählen Sie eine andere Kombination.', + 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Diese Kombination wird bereits von {action} verwendet.', + 'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Diese Kombination kollidiert mit einem integrierten Tastenkürzel, das nicht ersetzt werden kann.', + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Projektauswahl für Entwurf öffnen', + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Worktree-Auswahl für Entwurf öffnen', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Letzte Sitzungen öffnen', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Spracheingabe', 'settings.projects.sidebar.total': 'Gesamt {count}', 'settings.projects.sidebar.actions.addProject': 'Projekt hinzufügen', 'settings.projects.page.empty.noProjects': 'Keine Projekte verfügbar.', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 44b048d4..15735541 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1498,7 +1498,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.planImported': 'Plan importiert', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Fehler beim Lesen der Plan-Datei', 'inlineComment.range.lines': 'Zeilen {start}-{end}', - 'inlineComment.input.placeholder': 'Kommentar hinzufügen... (Cmd+Enter zum Speichern)', + 'inlineComment.input.placeholder': 'Kommentar hinzufügen... ({shortcut} zum Speichern)', 'inlineComment.input.placeholderShort': 'Kommentar hinzufügen...', 'inlineComment.actions.cancel': 'Abbrechen', 'inlineComment.actions.save': 'Speichern', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index b8718cda..f48bdd83 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1133,7 +1133,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'This combo is already used by another shortcut. Overwrite and clear that other mapping?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Press keys...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capture a shortcut first.', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'This shortcut can conflict with browser defaults. It is still saved.', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'This shortcut can conflict with browser defaults. You can still save it.', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Go to line (files editor)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Open command palette', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Focus input', @@ -1148,6 +1148,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Close session tab', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Open keyboard shortcuts', @@ -1162,6 +1163,27 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Expand input', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Open conversation timeline', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Toggle prompt navigator', + '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.category.session': 'Session Controls', + 'settings.openchamber.keyboardShortcuts.category.models': 'Models & Agents', + 'settings.openchamber.keyboardShortcuts.category.panels': 'Panels & Tools', + 'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation', + 'settings.openchamber.keyboardShortcuts.category.application': 'Application', + 'settings.openchamber.keyboardShortcuts.actions.edit': 'Edit', + 'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirm', + 'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations, with at most three keys each. After the first, wait up to 3 seconds for a second combination. Use Confirm to apply or Cancel to discard. Backspace removes the last one.', + 'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'First combination', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…', + 'settings.openchamber.keyboardShortcuts.unassigned': 'Unassigned', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'This conflicts with the sequence used by {action}. Choose a different combination.', + 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'This combination is already used by {action}.', + 'settings.openchamber.keyboardShortcuts.error.internalConflict': 'This combination conflicts with a built-in shortcut, which cannot be replaced.', + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Open draft project picker', + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Open draft worktree picker', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Open recent sessions', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Voice input', 'settings.projects.sidebar.total': 'Total {count}', 'settings.projects.sidebar.actions.addProject': 'Add project', 'settings.projects.page.empty.noProjects': 'No projects available.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a4840295..c33afc63 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1668,7 +1668,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.planImported': 'Plan imported', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Failed to read plan file', 'inlineComment.range.lines': 'Lines {start}-{end}', - 'inlineComment.input.placeholder': 'Add a comment... (Cmd+Enter to save)', + 'inlineComment.input.placeholder': 'Add a comment... ({shortcut} to save)', 'inlineComment.input.placeholderShort': 'Add a comment...', 'inlineComment.actions.cancel': 'Cancel', 'inlineComment.actions.save': 'Save', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b05c568c..67ea6135 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1101,7 +1101,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.overwritePrompt": "Esta combinación ya está usada por otro atajo. ¿Sobrescribir y limpiar esa otra asignación?", "settings.openchamber.keyboardShortcuts.field.pressKeys": "Pulsa las teclas...", "settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura un atajo primero.", - "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atajo puede entrar en conflicto con los predeterminados del navegador. Todavía se guarda.", + "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atajo puede entrar en conflicto con los predeterminados del navegador. Aun así, puedes guardarlo.", "settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Ir a línea (editor de archivos)", "settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos", "settings.openchamber.keyboardShortcuts.action.focus_input.label": "Enfocar entrada", @@ -1116,6 +1116,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión", + "settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Cerrar pestaña de sesión", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atajos de teclado", @@ -1130,6 +1131,27 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada", "settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir línea de tiempo de conversación", "settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Mostrar u ocultar navegador de prompts", + "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.category.session": "Controles de sesión", + "settings.openchamber.keyboardShortcuts.category.models": "Modelos y agentes", + "settings.openchamber.keyboardShortcuts.category.panels": "Paneles y herramientas", + "settings.openchamber.keyboardShortcuts.category.navigation": "Navegación", + "settings.openchamber.keyboardShortcuts.category.application": "Aplicación", + "settings.openchamber.keyboardShortcuts.actions.edit": "Editar", + "settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar", + "settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}", + "settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas, con un máximo de tres teclas cada una. Tras la primera, espere hasta 3 segundos por una segunda combinación. Use Confirmar para aplicar o Cancelar para descartar. Retroceso elimina la última.", + "settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primera combinación", + "settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación", + "settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…", + "settings.openchamber.keyboardShortcuts.unassigned": "Sin asignar", + "settings.openchamber.keyboardShortcuts.error.prefixConflict": "Esto entra en conflicto con la secuencia usada por {action}. Elija otra combinación.", + "settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinación ya la usa {action}.", + "settings.openchamber.keyboardShortcuts.error.internalConflict": "Esta combinación entra en conflicto con un atajo integrado, que no se puede reemplazar.", + "settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir selector de proyecto de borrador", + "settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir selector de árbol de trabajo de borrador", + "settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sesiones recientes", + "settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada de voz", "settings.projects.sidebar.total": "Total {count}", "settings.projects.sidebar.actions.addProject": "Añadir proyecto", "settings.projects.page.empty.noProjects": "No hay proyectos disponibles.", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 1f6b829e..7999cdb7 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1646,7 +1646,7 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.toast.planImported": "Plan importado", "rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "No se pudo leer el archivo del plan", "inlineComment.range.lines": "Líneas {start}-{end}", - "inlineComment.input.placeholder": "Añadir un comentario... (Cmd+Enter para guardar)", + "inlineComment.input.placeholder": "Añadir un comentario... ({shortcut} para guardar)", "inlineComment.input.placeholderShort": "Añadir un comentario...", "inlineComment.actions.cancel": "Cancelar", "inlineComment.actions.save": "Guardar", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 345e0c08..80d982e6 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1019,7 +1019,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'Ce combo est déjà utilisé par un autre raccourci. Écraser et effacer cet autre mappage ?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Appuyez sur les touches...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capturez d\'abord un raccourci.', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ce raccourci peut entrer en conflit avec les paramètres par défaut du navigateur. Il est toujours sauvegardé.', + '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.action.open_go_to_line.label': 'Aller à la ligne (éditeur de fichiers)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Ouvrir la palette de commandes', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Entrée de mise au point', @@ -1034,6 +1034,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Fermer l’onglet de session', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Ouvrir les raccourcis clavier', @@ -1048,6 +1049,27 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Développer l\'entrée', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Chronologie de la conversation ouverte', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Afficher ou masquer le navigateur de prompts', + '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.category.session': 'Commandes de session', + 'settings.openchamber.keyboardShortcuts.category.models': 'Modèles et agents', + 'settings.openchamber.keyboardShortcuts.category.panels': 'Panneaux et outils', + 'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation', + 'settings.openchamber.keyboardShortcuts.category.application': 'Application', + 'settings.openchamber.keyboardShortcuts.actions.edit': 'Modifier', + 'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirmer', + 'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum, avec trois touches au plus chacune. Après la première, attendez jusqu’à 3 secondes une seconde combinaison. Utilisez Confirmer pour appliquer ou Annuler pour abandonner. Retour arrière supprime la dernière.', + 'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Première combinaison', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…', + 'settings.openchamber.keyboardShortcuts.unassigned': 'Non attribué', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Cela entre en conflit avec la séquence utilisée par {action}. Choisissez une autre combinaison.', + 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Cette combinaison est déjà utilisée par {action}.', + 'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Cette combinaison entre en conflit avec un raccourci intégré qui ne peut pas être remplacé.', + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Ouvrir le sélecteur de projet de brouillon', + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Ouvrir le sélecteur de worktree de brouillon', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Ouvrir les sessions récentes', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Saisie vocale', 'settings.projects.sidebar.total': 'Total {count}', 'settings.projects.sidebar.actions.addProject': 'Ajouter un projet', 'settings.projects.page.empty.noProjects': 'Aucun projet disponible.', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 1b8ccd3a..f16dee7b 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1433,7 +1433,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.planImported': 'Forfait importé', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Échec de la lecture du fichier de plan', 'inlineComment.range.lines': 'Lignes {start}-{end}', - 'inlineComment.input.placeholder': 'Ajouter un commentaire... (Cmd+Entrée pour enregistrer)', + 'inlineComment.input.placeholder': 'Ajouter un commentaire... ({shortcut} pour enregistrer)', 'inlineComment.actions.cancel': 'Annuler', 'inlineComment.actions.save': 'Sauvegarder', 'inlineComment.actions.comment': 'Commentaire', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 8d6f1c1b..98d45ab8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1134,7 +1134,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'このキーコンボは別のショートカットで既に使用されています。上書きしてそのマッピングをクリアしますか?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': 'キーを押してください...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': '最初にショートカットを設定してください。', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性があります。それでも保存されます。', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性がありますが、そのまま保存できます。', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '指定行に移動(ファイルエディター)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'コマンドパレットを開く', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': '入力をフォーカス', @@ -1149,6 +1149,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'セッションタブを閉じる', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'キーボードショートカットを開く', @@ -1163,6 +1164,27 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'モデルセレクターを開く', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '会話タイムラインを開く', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'プロンプトナビゲーターの表示切替', + 'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'このシーケンスは {action} とコンテキスト依存のプレフィックスを共有しています。そのコンテキストが有効な間は、この操作が優先されます。', + 'settings.openchamber.keyboardShortcuts.category.session': 'セッション操作', + 'settings.openchamber.keyboardShortcuts.category.models': 'モデルとエージェント', + 'settings.openchamber.keyboardShortcuts.category.panels': 'パネルとツール', + 'settings.openchamber.keyboardShortcuts.category.navigation': 'ナビゲーション', + 'settings.openchamber.keyboardShortcuts.category.application': 'アプリケーション', + 'settings.openchamber.keyboardShortcuts.actions.edit': '編集', + 'settings.openchamber.keyboardShortcuts.actions.confirm': '確認', + 'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力でき、各組み合わせは最大3キーです。最初の組み合わせの後、2つ目の組み合わせを最大3秒待ちます。適用するには確認、破棄するにはキャンセルを選択してください。Backspace で最後の組み合わせを削除します。', + 'settings.openchamber.keyboardShortcuts.dialog.firstChord': '最初の組み合わせ', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…', + 'settings.openchamber.keyboardShortcuts.unassigned': '未割り当て', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action} のシーケンスと競合しています。別の組み合わせを選択してください。', + 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'この組み合わせは {action} で使用されています。', + 'settings.openchamber.keyboardShortcuts.error.internalConflict': 'この組み合わせは組み込みショートカットと競合しています。組み込みショートカットは置き換えられません。', + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '下書きプロジェクト選択を開く', + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '下書きワークツリー選択を開く', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '最近のセッションを開く', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '音声入力', 'settings.projects.sidebar.total': '合計 {count}', 'settings.projects.sidebar.actions.addProject': 'プロジェクトを追加', 'settings.projects.page.empty.noProjects': '利用可能なプロジェクトがありません。', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index fe7f1868..3b511318 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1664,7 +1664,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.planImported': '計画をインポートしました', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '計画ファイルの読み込みに失敗しました', 'inlineComment.range.lines': '{start}行目~{end}行目', - 'inlineComment.input.placeholder': 'コメントを追加...(Cmd+Enterで保存)', + 'inlineComment.input.placeholder': 'コメントを追加...({shortcut}で保存)', 'inlineComment.input.placeholderShort': 'コメントを追加...', 'inlineComment.actions.cancel': 'キャンセル', 'inlineComment.actions.save': '保存', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 07a8e7d0..70314bdb 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1101,7 +1101,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': '이 조합은 이미 다른 단축키에서 사용 중입니다. 덮어쓰고 기존 매핑을 지울까요?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': '키를 누르세요...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': '먼저 단축키를 입력하세요.', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있습니다. 그래도 저장됩니다.', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있지만 그래도 저장할 수 있습니다.', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '줄로 이동(파일 편집기)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '명령 팔레트 열기', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': '입력에 포커스', @@ -1116,6 +1116,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '세션 탭 닫기', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '키보드 단축키 열기', @@ -1130,6 +1131,27 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '입력 확장', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '대화 타임라인 열기', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '프롬프트 탐색기 표시/숨기기', + 'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '이 시퀀스는 {action}과 컨텍스트 접두사를 공유합니다. 해당 컨텍스트가 활성화된 동안에는 그 동작이 우선합니다.', + 'settings.openchamber.keyboardShortcuts.category.session': '세션 제어', + 'settings.openchamber.keyboardShortcuts.category.models': '모델 및 에이전트', + 'settings.openchamber.keyboardShortcuts.category.panels': '패널 및 도구', + 'settings.openchamber.keyboardShortcuts.category.navigation': '탐색', + 'settings.openchamber.keyboardShortcuts.category.application': '애플리케이션', + 'settings.openchamber.keyboardShortcuts.actions.edit': '편집', + 'settings.openchamber.keyboardShortcuts.actions.confirm': '확인', + 'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. 각 조합에는 최대 세 개의 키를 사용할 수 있습니다. 첫 번째 조합 뒤에는 두 번째 조합을 위해 최대 3초 동안 기다립니다. 적용하려면 확인을, 취소하려면 취소를 선택하세요. Backspace로 마지막 조합을 삭제합니다.', + 'settings.openchamber.keyboardShortcuts.dialog.firstChord': '첫 번째 조합', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합', + 'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…', + 'settings.openchamber.keyboardShortcuts.unassigned': '할당되지 않음', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action}에서 사용하는 시퀀스와 충돌합니다. 다른 조합을 선택하세요.', + 'settings.openchamber.keyboardShortcuts.error.exactConflict': '이 조합은 이미 {action}에서 사용합니다.', + 'settings.openchamber.keyboardShortcuts.error.internalConflict': '이 조합은 바꿀 수 없는 기본 제공 단축키와 충돌합니다.', + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '초안 프로젝트 선택기 열기', + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '초안 워크트리 선택기 열기', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '최근 세션 열기', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '음성 입력', 'settings.projects.sidebar.total': '총 {count}개', 'settings.projects.sidebar.actions.addProject': '프로젝트 추가', 'settings.projects.page.empty.noProjects': '사용 가능한 프로젝트가 없습니다.', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index a2b73918..4fef8b87 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1670,7 +1670,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.planImported': '플랜 가져옴', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '플랜 파일 읽기 실패', 'inlineComment.range.lines': '줄 {start}-{end}', - 'inlineComment.input.placeholder': '댓글 추가… (Cmd+Enter로 저장)', + 'inlineComment.input.placeholder': '댓글 추가… ({shortcut}로 저장)', 'inlineComment.input.placeholderShort': '댓글 추가…', 'inlineComment.actions.cancel': '취소', 'inlineComment.actions.save': '저장', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 1ce9c512..5a9fccc0 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -825,6 +825,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Przełącz nawigator promptów', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Skup pole wprowadzania', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nowa sesja', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Zamknij kartę sesji', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nowy szkic obszaru roboczego', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nowe okno Mini Chat', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Otwórz paletę poleceń', @@ -849,7 +850,29 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'Ta kombinacja jest już używana przez inny skrót. Nadpisać i wyczyścić to inne przypisanie?', 'settings.openchamber.keyboardShortcuts.title': 'Skróty klawiszowe', 'settings.openchamber.keyboardShortcuts.tooltip': 'Przechwyć nową kombinację klawiszy, zapisz ją, a przypisania zostaną natychmiast zaktualizowane.', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ten skrót może kolidować z domyślnymi skrótami przeglądarki. Został jednak zapisany.', + '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.keyboardShortcuts.category.session': 'Sterowanie sesją', + 'settings.openchamber.keyboardShortcuts.category.models': 'Modele i agenci', + 'settings.openchamber.keyboardShortcuts.category.panels': 'Panele i narzędzia', + 'settings.openchamber.keyboardShortcuts.category.navigation': 'Nawigacja', + 'settings.openchamber.keyboardShortcuts.category.application': 'Aplikacja', + 'settings.openchamber.keyboardShortcuts.actions.edit': 'Edytuj', + 'settings.openchamber.keyboardShortcuts.actions.confirm': 'Potwierdź', + 'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy, po najwyżej trzy klawisze każda. Po pierwszej odczekaj do 3 sekund na drugą kombinację. Wybierz Potwierdź, aby zastosować, lub Anuluj, aby odrzucić. Backspace usuwa ostatnią.', + 'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Pierwsza kombinacja', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…', + 'settings.openchamber.keyboardShortcuts.unassigned': 'Nieprzypisany', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'To koliduje z sekwencją używaną przez {action}. Wybierz inną kombinację.', + 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Ta kombinacja jest już używana przez {action}.', + 'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Ta kombinacja koliduje z wbudowanym skrótem, którego nie można zastąpić.', + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Otwórz wybór projektu szkicu', + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Otwórz wybór worktree szkicu', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Otwórz ostatnie sesje', + 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Otwórz oś czasu rozmowy', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Wprowadzanie głosowe', 'settings.openchamber.opencodeCli.actions.browse': 'Przeglądaj', 'settings.openchamber.opencodeCli.actions.browseAria': 'Przeglądaj ścieżkę do pliku binarnego OpenCode', 'settings.openchamber.opencodeCli.actions.restartingOpenCode': 'Restartowanie OpenCode...', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 2f3ef7b6..476aff49 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2490,7 +2490,7 @@ export const dict: Record = { 'inlineComment.actions.save': 'Zapisz', 'inlineComment.actions.showLess': 'Show less', 'inlineComment.actions.showMore': 'Show more', - 'inlineComment.input.placeholder': 'Add a comment... (Cmd+Enter to save)', + 'inlineComment.input.placeholder': 'Dodaj komentarz... ({shortcut}, aby zapisać)', 'inlineComment.input.placeholderShort': 'Dodaj komentarz...', 'inlineComment.range.lines': 'Lines {start}-{end}', 'inlineComment.toast.selectSessionToSave': 'Select a session to save comment', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index f095bb16..f536f6e3 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1101,7 +1101,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.overwritePrompt": "Esta combinação já está sendo usada por outro atalho. Sobrescrever e limpar essa outra atribuição?", "settings.openchamber.keyboardShortcuts.field.pressKeys": "Pressione as teclas...", "settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura um atalho primeiro.", - "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atalho pode entrar em conflito com os padrões do navegador. Ainda assim, ele será salvo.", + "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.action.open_go_to_line.label": "Ir para linha (editor de arquivos)", "settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos", "settings.openchamber.keyboardShortcuts.action.focus_input.label": "Focar entrada", @@ -1116,6 +1116,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão", + "settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Fechar aba da sessão", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atalhos de teclado", @@ -1130,6 +1131,27 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada", "settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir linha do tempo da conversa", "settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Mostrar ou ocultar navegador de prompts", + "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.category.session": "Controles de sessão", + "settings.openchamber.keyboardShortcuts.category.models": "Modelos e agentes", + "settings.openchamber.keyboardShortcuts.category.panels": "Painéis e ferramentas", + "settings.openchamber.keyboardShortcuts.category.navigation": "Navegação", + "settings.openchamber.keyboardShortcuts.category.application": "Aplicação", + "settings.openchamber.keyboardShortcuts.actions.edit": "Editar", + "settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar", + "settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}", + "settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas, com no máximo três teclas em cada uma. Após a primeira, aguarde até 3 segundos por uma segunda combinação. Use Confirmar para aplicar ou Cancelar para descartar. Backspace remove a última.", + "settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primeira combinação", + "settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação", + "settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…", + "settings.openchamber.keyboardShortcuts.unassigned": "Não atribuído", + "settings.openchamber.keyboardShortcuts.error.prefixConflict": "Isto entra em conflito com a sequência usada por {action}. Escolha outra combinação.", + "settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinação já é usada por {action}.", + "settings.openchamber.keyboardShortcuts.error.internalConflict": "Esta combinação entra em conflito com um atalho integrado, que não pode ser substituído.", + "settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir seletor de projeto do rascunho", + "settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir seletor de worktree do rascunho", + "settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sessões recentes", + "settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada por voz", "settings.projects.sidebar.total": "Total {count}", "settings.projects.sidebar.actions.addProject": "Adicionar projeto", "settings.projects.page.empty.noProjects": "Não há projetos disponíveis.", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2723fd6e..36a06ede 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1646,7 +1646,7 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.toast.planImported": "Plano importado", "rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "Não foi possível ler o arquivo do plano", "inlineComment.range.lines": "Linhas {start}-{end}", - "inlineComment.input.placeholder": "Adicionar um comentário... (Cmd+Enter para salvar)", + "inlineComment.input.placeholder": "Adicionar um comentário... ({shortcut} para salvar)", "inlineComment.input.placeholderShort": "Adicionar um comentário...", "inlineComment.actions.cancel": "Cancelar", "inlineComment.actions.save": "Salvar", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 49c34b87..11afb49c 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1101,7 +1101,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.overwritePrompt": "Ця комбінація вже використовується іншою комбінацією клавіш. Перезаписати та очистити інше зіставлення?", "settings.openchamber.keyboardShortcuts.field.pressKeys": "Натисніть клавіші...", "settings.openchamber.keyboardShortcuts.error.captureFirst": "Спочатку запишіть комбінацію клавіш.", - "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Її все одно збережено.", + "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Ви все одно можете її зберегти.", "settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Перейти до рядка (редактор файлів)", "settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Відкрити палітру команд", "settings.openchamber.keyboardShortcuts.action.focus_input.label": "Фокус на полі вводу", @@ -1116,6 +1116,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія", + "settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Закрити вкладку сесії", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Відкрити комбінації клавіш", @@ -1130,6 +1131,27 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.expand_input.label": "Розгорнути введення", "settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Відкрити хронологію розмови", "settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Показати або приховати навігатор промптів", + "settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Ця послідовність має спільний контекстний префікс із дією {action}. Коли її контекст активний, ця дія має пріоритет.", + "settings.openchamber.keyboardShortcuts.category.session": "Керування сесією", + "settings.openchamber.keyboardShortcuts.category.models": "Моделі й агенти", + "settings.openchamber.keyboardShortcuts.category.panels": "Панелі та інструменти", + "settings.openchamber.keyboardShortcuts.category.navigation": "Навігація", + "settings.openchamber.keyboardShortcuts.category.application": "Застосунок", + "settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати", + "settings.openchamber.keyboardShortcuts.actions.confirm": "Підтвердити", + "settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}", + "settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш, не більше трьох клавіш у кожній. Після першої зачекайте до 3 секунд на другу комбінацію. Виберіть Підтвердити, щоб застосувати, або Скасувати, щоб відхилити. Backspace видаляє останню.", + "settings.openchamber.keyboardShortcuts.dialog.firstChord": "Перша комбінація", + "settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація", + "settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…", + "settings.openchamber.keyboardShortcuts.unassigned": "Не призначено", + "settings.openchamber.keyboardShortcuts.error.prefixConflict": "Це конфліктує з послідовністю, яку використовує {action}. Виберіть іншу комбінацію.", + "settings.openchamber.keyboardShortcuts.error.exactConflict": "Цю комбінацію вже використовує {action}.", + "settings.openchamber.keyboardShortcuts.error.internalConflict": "Ця комбінація конфліктує з вбудованим скороченням, яке не можна замінити.", + "settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Відкрити вибір проєкту чернетки", + "settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Відкрити вибір worktree чернетки", + "settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Відкрити останні сесії", + "settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Голосове введення", "settings.projects.sidebar.total": "Усього {count}", "settings.projects.sidebar.actions.addProject": "Додати проєкт", "settings.projects.page.empty.noProjects": "Немає доступних проєктів.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 90c85f21..61163aa0 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1646,7 +1646,7 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.toast.planImported": "План імпортовано", "rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "Не вдалося прочитати файл плану", "inlineComment.range.lines": "Рядки {start}-{end}", - "inlineComment.input.placeholder": "Додайте коментар... (Cmd+Enter, щоб зберегти)", + "inlineComment.input.placeholder": "Додайте коментар... ({shortcut}, щоб зберегти)", "inlineComment.input.placeholderShort": "Додайте коментар...", "inlineComment.actions.cancel": "Скасувати", "inlineComment.actions.save": "Зберегти", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 7874ce8e..59714a11 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1101,7 +1101,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': '该组合已被其他快捷键使用。是否覆盖并清除原映射?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按键...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': '请先录入一个快捷键。', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍已保存。', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍可保存。', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳转到行(文件编辑器)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '打开命令面板', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦输入框', @@ -1116,6 +1116,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '关闭会话标签页', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '打开键盘快捷键', @@ -1130,6 +1131,27 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展开输入框', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '打开对话时间线', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '显示或隐藏提示词导航', + 'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列与“{action}”共享上下文前缀。对应上下文生效时,该操作会优先执行。', + 'settings.openchamber.keyboardShortcuts.category.session': '会话控制', + 'settings.openchamber.keyboardShortcuts.category.models': '模型和智能体', + 'settings.openchamber.keyboardShortcuts.category.panels': '面板和工具', + 'settings.openchamber.keyboardShortcuts.category.navigation': '导航', + 'settings.openchamber.keyboardShortcuts.category.application': '应用程序', + 'settings.openchamber.keyboardShortcuts.actions.edit': '编辑', + 'settings.openchamber.keyboardShortcuts.actions.confirm': '确认', + 'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多输入两个按键组合,每个组合最多同时按下三个按键。输入第一个组合后,最多等待 3 秒以输入第二个组合。点击确认应用,或点击取消放弃;按 Backspace 删除最后一个组合。', + 'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一个组合', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合', + 'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…', + 'settings.openchamber.keyboardShortcuts.unassigned': '未分配', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': '这与 {action} 使用的序列冲突。请选择其他组合。', + 'settings.openchamber.keyboardShortcuts.error.exactConflict': '此组合已被 {action} 使用。', + 'settings.openchamber.keyboardShortcuts.error.internalConflict': '此组合与内置快捷键冲突,内置快捷键不能被替换。', + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '打开草稿项目选择器', + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '打开草稿工作树选择器', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '打开最近会话', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '语音输入', 'settings.projects.sidebar.total': '总计 {count}', 'settings.projects.sidebar.actions.addProject': '添加项目', 'settings.projects.page.empty.noProjects': '暂无项目。', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 007a243b..6ebc17c6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1634,7 +1634,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.planImported': '计划已导入', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '读取计划文件失败', 'inlineComment.range.lines': '行 {start}-{end}', - 'inlineComment.input.placeholder': '添加评论...(Cmd+Enter 保存)', + 'inlineComment.input.placeholder': '添加评论...({shortcut} 保存)', 'inlineComment.input.placeholderShort': '添加评论…', 'inlineComment.actions.cancel': '取消', 'inlineComment.actions.save': '保存', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 273a5631..af6b2fcb 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1008,7 +1008,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': '該組合已被其他快速鍵使用。是否覆寫並清除原對應?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按鍵...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': '請先錄入一個快速鍵。', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍已儲存。', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍可儲存。', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳轉到行(檔案編輯器)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '開啟命令面板', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦輸入方塊', @@ -1023,6 +1023,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '關閉工作階段分頁', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '開啟鍵盤快速鍵', @@ -1037,6 +1038,27 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展開輸入方塊', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '開啟對話時間軸', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '顯示或隱藏提示詞導覽', + 'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列與「{action}」共用情境前綴。對應情境生效時,該操作會優先執行。', + 'settings.openchamber.keyboardShortcuts.category.session': '工作階段控制', + 'settings.openchamber.keyboardShortcuts.category.models': '模型與代理', + 'settings.openchamber.keyboardShortcuts.category.panels': '面板與工具', + 'settings.openchamber.keyboardShortcuts.category.navigation': '導覽', + 'settings.openchamber.keyboardShortcuts.category.application': '應用程式', + 'settings.openchamber.keyboardShortcuts.actions.edit': '編輯', + 'settings.openchamber.keyboardShortcuts.actions.confirm': '確認', + 'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多輸入兩個按鍵組合,每個組合最多同時按下三個按鍵。輸入第一個組合後,最多等待 3 秒以輸入第二個組合。點擊確認套用,或點擊取消放棄;按 Backspace 刪除最後一個組合。', + 'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一個組合', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合', + 'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…', + 'settings.openchamber.keyboardShortcuts.unassigned': '未指派', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': '這與 {action} 使用的序列衝突。請選擇其他組合。', + 'settings.openchamber.keyboardShortcuts.error.exactConflict': '此組合已由 {action} 使用。', + 'settings.openchamber.keyboardShortcuts.error.internalConflict': '此組合與內建快捷鍵衝突,內建快捷鍵不能被取代。', + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '開啟草稿專案選擇器', + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '開啟草稿 worktree 選擇器', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '開啟最近工作階段', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '語音輸入', 'settings.projects.sidebar.total': '總計 {count}', 'settings.projects.sidebar.actions.addProject': '新增專案', 'settings.projects.page.empty.noProjects': '暫無專案。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 1e4750e3..c3cecc5e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1644,7 +1644,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.planImported': '計畫已匯入', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '讀取計畫檔案失敗', 'inlineComment.range.lines': '行 {start}-{end}', - 'inlineComment.input.placeholder': '新增留言...(Cmd+Enter 儲存)', + 'inlineComment.input.placeholder': '新增留言...({shortcut} 儲存)', 'inlineComment.input.placeholderShort': '新增留言…', 'inlineComment.actions.cancel': '取消', 'inlineComment.actions.save': '儲存', diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts deleted file mode 100644 index aab2f9f1..00000000 --- a/packages/ui/src/lib/shortcuts.ts +++ /dev/null @@ -1,688 +0,0 @@ -import { isMacOS } from '@/lib/utils'; -import { isDesktopShell } from '@/lib/desktop'; - -type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl'; -type ShortcutKey = string; -export type ShortcutCombo = string; - -export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__'; - -export interface ShortcutAction { - id: string; - defaultCombo: ShortcutCombo; - label: string; - description?: string; - customizable?: boolean; -} - -interface ParsedShortcut { - modifiers: Set; - key: ShortcutKey; -} - -const MODIFIER_KEY_MAP: Record = { - 'mod': 'mod', - 'shift': 'shift', - 'alt': 'alt', - 'option': 'alt', - 'ctrl': 'ctrl', - 'meta': 'mod', - 'cmd': 'mod', - 'command': 'mod', -}; - -const DISPLAY_LABEL_MAP: Record = { - 'mod': isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl', - 'shift': '⇧', - 'alt': '⌥', - 'option': '⌥', - 'ctrl': '⌃', -}; - -// Physical `event.key` values (lowercased) that satisfy each modifier while a -// chord is being held. `mod` maps to the platform primary key; on web macOS it -// accepts either Meta or Ctrl, matching eventMatchesShortcut. -const MODIFIER_KEY_ALIASES: Record = { - 'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'], - 'shift': ['shift'], - 'alt': ['alt'], - 'option': ['alt'], - 'ctrl': ['control'], -}; - -const KEY_LABEL_MAP: Record = { - 'comma': ',', - 'period': '.', - 'enter': 'Enter', - 'escape': 'Esc', - 'tab': 'Tab', - 'space': 'Space', - 'backspace': '⌫', - 'delete': '⌦', - 'arrowup': '↑', - 'arrowdown': '↓', - 'arrowleft': '←', - 'arrowright': '→', - 'home': 'Home', - 'end': 'End', - 'pageup': 'Page Up', - 'pagedown': 'Page Down', -}; - -const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt']; - -const SHIFTED_KEY_BASE_MAP: Record = { - '{': '[', - '}': ']', - ':': ';', - '"': "'", - '<': ',', - '>': '.', - '?': '/', - '|': '\\', - '~': '`', - '!': '1', - '@': '2', - '#': '3', - '$': '4', - '%': '5', - '^': '6', - '&': '7', - '*': '8', - '(': '9', - ')': '0', -}; - -function isUnassignedShortcut(combo: ShortcutCombo): boolean { - return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT; -} - -export function keyToShortcutToken(key: string): string { - const lowered = key.toLowerCase(); - - if (lowered === ',') return 'comma'; - if (lowered === '.') return 'period'; - if (lowered === ' ') return 'space'; - if (lowered === 'esc') return 'escape'; - if (lowered === '+') return 'plus'; - if (lowered === '-' || lowered === '_') return 'minus'; - if (lowered === 'arrowup') return 'arrowup'; - if (lowered === 'arrowdown') return 'arrowdown'; - if (lowered === 'arrowleft') return 'arrowleft'; - if (lowered === 'arrowright') return 'arrowright'; - - return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered; -} - -const SHORTCUT_ACTIONS: ReadonlyArray = [ - { - id: 'open_go_to_line', - defaultCombo: 'alt+g', - label: 'Go to line (files editor)', - description: 'Open go to line in the files editor', - customizable: true, - }, - { - id: 'open_command_palette', - defaultCombo: 'mod+p', - label: 'Open command palette', - description: 'Open the command palette', - customizable: true, - }, - { - id: 'focus_input', - defaultCombo: 'mod+i', - label: 'Focus input', - description: 'Focus the chat input field', - customizable: true, - }, - { - id: 'open_status', - defaultCombo: 'mod+shift+o', - label: 'Open OpenCode status', - description: 'Open the OpenCode status dialog', - }, - { - id: 'open_settings', - defaultCombo: 'mod+comma', - label: 'Open settings', - description: 'Open the settings panel', - customizable: true, - }, - { - id: 'toggle_terminal', - defaultCombo: 'mod+j', - label: 'Toggle terminal dock', - description: 'Toggle the bottom terminal dock', - customizable: true, - }, - { - id: 'toggle_terminal_expanded', - defaultCombo: 'mod+shift+j', - label: 'Toggle terminal expanded', - description: 'Toggle terminal expanded or collapsed', - customizable: true, - }, - { - id: 'toggle_files', - defaultCombo: 'mod+shift+f', - label: 'Toggle files', - description: 'Toggle the files panel', - }, - { - id: 'add_selection_to_chat', - defaultCombo: 'mod+l', - label: 'Add selection to chat', - description: 'Add the selected text to the chat input', - customizable: true, - }, - { - id: 'toggle_sidebar', - defaultCombo: 'mod+alt+l', - label: 'Toggle sidebar', - description: 'Toggle the session sidebar', - customizable: true, - }, - { - id: 'open_timeline_dialog', - defaultCombo: 'mod+t', - label: 'Open conversation timeline', - description: 'Search and navigate within current conversation', - customizable: true, - }, - { - id: 'toggle_prompt_navigator', - defaultCombo: 'mod+alt+p', - label: 'Toggle prompt navigator', - description: 'Show or hide the prompt navigator panel in chat', - customizable: true, - }, - { - id: 'toggle_right_sidebar', - defaultCombo: 'mod+b', - label: 'Toggle right sidebar', - description: 'Toggle the right sidebar', - customizable: true, - }, - { - id: 'open_right_sidebar_git', - defaultCombo: 'mod+shift+g', - label: 'Open right sidebar Git tab', - description: 'Open right sidebar and select Git', - customizable: true, - }, - { - id: 'open_right_sidebar_files', - defaultCombo: 'mod+shift+f', - label: 'Open right sidebar Files tab', - description: 'Open right sidebar and select Files', - customizable: true, - }, - { - id: 'switch_context_surface', - defaultCombo: 'mod', - label: 'Switch context panel surface', - description: 'Hold the modifier and press a number to open or close the matching rail icon', - customizable: true, - }, - { - id: 'new_chat', - defaultCombo: 'mod+n', - label: 'New session', - description: 'Start a new session', - customizable: true, - }, - { - id: 'new_chat_worktree', - defaultCombo: 'mod+shift+n', - label: 'New worktree draft', - description: 'Create a new worktree and open a draft in it', - customizable: true, - }, - { - id: 'close_session_tab', - defaultCombo: 'alt+w', - label: 'Close session tab', - description: 'Close the active session tab in the header (the session itself stays)', - customizable: true, - }, - { - id: 'new_mini_chat', - defaultCombo: 'mod+alt+n', - label: 'New Mini Chat window', - description: 'Open a new Mini Chat draft window', - customizable: true, - }, - { - id: 'submit_message', - defaultCombo: 'mod+enter', - label: 'Submit message', - description: 'Submit the current message', - }, - { - id: 'clear_input', - defaultCombo: 'escape', - label: 'Clear input', - description: 'Clear the input field', - }, - { - id: 'open_help', - defaultCombo: 'mod+.', - label: 'Open keyboard shortcuts', - description: 'Show the keyboard shortcuts help', - customizable: true, - }, - { - id: 'toggle_context_plan', - defaultCombo: 'mod+shift+p', - label: 'Toggle plan context panel', - description: 'Open or close plan in the context panel', - customizable: true, - }, - { - id: 'toggle_services_menu', - defaultCombo: 'mod+shift+s', - label: 'Toggle services menu', - description: 'Open or close the services menu', - customizable: true, - }, - { - id: 'cycle_services_tab', - defaultCombo: 'mod+shift+[', - label: 'Cycle services tab', - description: 'Cycle through tabs in the services menu', - customizable: true, - }, - { - id: 'cycle_theme', - defaultCombo: 'mod+/', - label: 'Cycle theme', - description: 'Cycle between light, dark, and system theme', - customizable: true, - }, - { - id: 'open_model_selector', - defaultCombo: 'mod+shift+m', - label: 'Open model selector', - description: 'Open model selector while in chat', - customizable: true, - }, - { - id: 'cycle_thinking_variant', - defaultCombo: 'mod+shift+t', - label: 'Cycle thinking variant', - description: 'Cycle thinking variant while in chat', - }, - { - id: 'cycle_agent', - defaultCombo: 'tab', - label: 'Cycle agent', - description: 'Cycle agent while the model selector is open', - customizable: true, - }, - { - id: 'cycle_favorite_model_forward', - defaultCombo: 'ctrl+]', - label: 'Cycle favorite model forward', - description: 'Cycle forward through starred models without opening the picker', - customizable: true, - }, - { - id: 'cycle_favorite_model_backward', - defaultCombo: 'ctrl+[', - label: 'Cycle favorite model backward', - description: 'Cycle backward through starred models without opening the picker', - customizable: true, - }, - { - id: 'expand_input', - defaultCombo: 'mod+shift+e', - label: 'Expand input', - description: 'Toggle focus mode for the chat input', - customizable: true, - }, - { - id: 'toggle_dictation', - defaultCombo: 'mod+alt+v', - label: 'Voice input', - description: 'Start dictation; press again to confirm and insert the transcript', - customizable: true, - }, - { - id: 'abort_run', - defaultCombo: 'escape', - label: 'Abort active run', - description: 'Abort the currently running task (double press)', - }, -] as const; - -export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo { - if (isUnassignedShortcut(combo)) { - return UNASSIGNED_SHORTCUT; - } - - const rawParts = combo - .toLowerCase() - .trim() - .split('+') - .map((part) => part.trim()) - .filter(Boolean); - - const modifiers = new Set(); - let key = ''; - - for (const rawPart of rawParts) { - const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart; - const modifier = MODIFIER_KEY_MAP[part]; - if (modifier) { - modifiers.add(modifier); - continue; - } - key = part; - } - - const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier)); - return [...orderedModifiers, key].filter(Boolean).join('+'); -} - -function isValidShortcutCombo(combo: ShortcutCombo): boolean { - if (isUnassignedShortcut(combo)) { - return true; - } - - const parsed = parseShortcut(combo); - return parsed.key.trim().length > 0; -} - -function parseShortcut(combo: ShortcutCombo): ParsedShortcut { - if (isUnassignedShortcut(combo)) { - return { modifiers: new Set(), key: UNASSIGNED_SHORTCUT }; - } - - const normalized = normalizeCombo(combo); - const parts = normalized.split('+'); - const modifiers: Set = new Set(); - let key: ShortcutKey = ''; - - for (const part of parts) { - const modifier = MODIFIER_KEY_MAP[part]; - if (modifier) { - modifiers.add(modifier); - } else { - key = part; - } - } - - return { modifiers, key }; -} - -export function formatShortcutForDisplay(combo: ShortcutCombo): string { - if (isUnassignedShortcut(combo)) { - return 'Unassigned'; - } - - const parsed = parseShortcut(combo); - - if (!parsed.key && parsed.modifiers.size === 0) { - return 'Unassigned'; - } - - const parts: string[] = []; - - for (const modifier of MODIFIER_PRIORITY) { - if (parsed.modifiers.has(modifier)) { - parts.push(DISPLAY_LABEL_MAP[modifier]); - } - } - - if (parsed.key) { - const keyLabel = KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase(); - parts.push(keyLabel); - } - - return parts.join(' + '); -} - -export function getShortcutAction(id: string): ShortcutAction | undefined { - return SHORTCUT_ACTIONS.find((action) => action.id === id); -} - -export function getCustomizableShortcutActions(): ReadonlyArray { - return SHORTCUT_ACTIONS.filter((action) => action.customizable === true); -} - -export function getEffectiveShortcutCombo( - actionId: string, - overrides?: Record -): ShortcutCombo { - const action = getShortcutAction(actionId); - if (!action) { - return ''; - } - - const override = overrides?.[actionId]; - if (typeof override === 'string') { - if (override.trim().toLowerCase() === UNASSIGNED_SHORTCUT) { - return ''; - } - - const normalized = normalizeCombo(override); - if (normalized === UNASSIGNED_SHORTCUT) { - return UNASSIGNED_SHORTCUT; - } - - if (isValidShortcutCombo(normalized)) { - return normalized; - } - } - - return action.defaultCombo; -} - -export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean { - if (isUnassignedShortcut(combo)) { - return false; - } - - const parsed = parseShortcut(combo); - if (!parsed.modifiers.has('mod')) { - return false; - } - - const key = parsed.key.toLowerCase(); - const dangerousPrimary = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']); - return dangerousPrimary.has(key) && !parsed.modifiers.has('shift') && !parsed.modifiers.has('alt'); -} - -export function eventMatchesShortcut( - event: KeyboardEvent | React.KeyboardEvent, - shortcut: ShortcutAction | ShortcutCombo -): boolean { - const combo = typeof shortcut === 'string' ? shortcut : shortcut.defaultCombo; - if (isUnassignedShortcut(combo)) { - return false; - } - - const parsed = parseShortcut(combo); - - const expectedMod = parsed.modifiers.has('mod'); - const expectedShift = parsed.modifiers.has('shift'); - const expectedAlt = parsed.modifiers.has('alt'); - const expectedCtrl = parsed.modifiers.has('ctrl'); - const isDesktopMac = isMacOS() && isDesktopShell(); - const isMac = isMacOS(); - - const modMatches = isDesktopMac - ? event.metaKey - : isMac - ? (event.metaKey || event.ctrlKey) - : event.ctrlKey; - - if (expectedMod && !modMatches) { - return false; - } - - if (!expectedMod && event.metaKey) { - return false; - } - - if (expectedShift !== event.shiftKey) { - return false; - } - - if (expectedAlt !== event.altKey) { - return false; - } - - if (expectedCtrl) { - if (!event.ctrlKey) { - return false; - } - } else { - const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; - if (event.ctrlKey && !ctrlUsedAsMod) { - return false; - } - } - - let eventKeyRaw = event.key; - if (event.altKey) { - if (event.code.startsWith('Key') && event.code.length === 4) { - eventKeyRaw = event.code.slice(3).toLowerCase(); - } else if (event.code.startsWith('Digit') && event.code.length === 6) { - eventKeyRaw = event.code.slice(5); - } - } - - const eventKey = keyToShortcutToken(eventKeyRaw); - const expectedKey = keyToShortcutToken(parsed.key); - - return eventKey === expectedKey; -} - -export function getModifierLabel(): string { - return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl'; -} - -/** - * Resolves the configurable prefix for chord-style shortcuts such as - * "switch context panel surface", where a trailing digit key completes the - * combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the - * bare `mod` primary key) are honored so the prefix can omit a primary key. - * Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix. - */ -export function getEffectiveShortcutPrefix( - actionId: string, - overrides?: Record, -): ShortcutCombo { - const action = getShortcutAction(actionId); - if (!action) { - return ''; - } - - const override = overrides?.[actionId]; - if (typeof override === 'string' && override.trim() !== '') { - const normalized = normalizeCombo(override); - if (normalized === UNASSIGNED_SHORTCUT) { - return UNASSIGNED_SHORTCUT; - } - if (normalized) { - const parsed = parseShortcut(normalized); - if (parsed.modifiers.size > 0 || parsed.key) { - return normalized; - } - } - } - - return action.defaultCombo; -} - -/** - * True when the physical keys required to "arm" a prefix combo are currently - * held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at - * least one alias must be held. - */ -export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet): boolean { - if (isUnassignedShortcut(prefixCombo)) { - return false; - } - - const parsed = parseShortcut(prefixCombo); - - for (const modifier of parsed.modifiers) { - const aliases = MODIFIER_KEY_ALIASES[modifier]; - if (!aliases.some((alias) => heldKeys.has(alias))) { - return false; - } - } - - if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) { - return false; - } - - return true; -} - -/** - * Matches an activating keydown (the caller checks the event's own key, e.g. a - * digit) against a chord prefix: the event's modifier state must match the - * prefix's modifiers, and when the prefix has a primary key that key must - * currently be held. - */ -export function eventMatchesShortcutPrefix( - event: KeyboardEvent | React.KeyboardEvent, - prefixCombo: ShortcutCombo, - heldKeys?: ReadonlySet, -): boolean { - if (isUnassignedShortcut(prefixCombo)) { - return false; - } - - const parsed = parseShortcut(prefixCombo); - - const expectedMod = parsed.modifiers.has('mod'); - const expectedShift = parsed.modifiers.has('shift'); - const expectedAlt = parsed.modifiers.has('alt'); - const expectedCtrl = parsed.modifiers.has('ctrl'); - const isDesktopMac = isMacOS() && isDesktopShell(); - const isMac = isMacOS(); - - const modMatches = isDesktopMac - ? event.metaKey - : isMac - ? (event.metaKey || event.ctrlKey) - : event.ctrlKey; - - if (expectedMod && !modMatches) { - return false; - } - - if (!expectedMod && event.metaKey) { - return false; - } - - if (expectedShift !== event.shiftKey) { - return false; - } - - if (expectedAlt !== event.altKey) { - return false; - } - - if (expectedCtrl) { - if (!event.ctrlKey) { - return false; - } - } else { - const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; - if (event.ctrlKey && !ctrlUsedAsMod) { - return false; - } - } - - if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) { - return false; - } - - return true; -} diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md new file mode 100644 index 00000000..e2d3db84 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -0,0 +1,65 @@ +# Registration boundary + +Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both accept only action IDs derived from `SHORTCUT_SCHEMA`. Batch registration also rejects undeclared keys in prebuilt objects, including objects that mix valid and misspelled IDs. Both hooks use the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command. + +Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `config.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components. + +# Schema contract + +`config.ts` is the declaration-only source for application commands. It organizes entries into `session`, `models`, `panels`, `navigation`, and `application` groups, then explicitly concatenates them into `SHORTCUT_SCHEMA`. Every entry declares an ID, default binding, and whether users can customize it. Customizable entries also declare their Settings translation key, so Settings must not maintain an action-ID switch or English fallback labels. + +Configuration must not contain lookup functions, override resolution, event matching, registry state, or runtime handlers. Those concerns belong to the owning modules below. Keeping configuration declarative makes the complete shortcut inventory reviewable without reading execution code. + +Component interaction keys that are not application commands, such as list navigation or text editing, do not belong in the schema. Contextual application commands do belong there even when they are not customizable; `save_file` and `find_in_file` are examples. + +# Module roles + +- `index.ts` is the only public import surface, exposed as `@/lib/shortcuts`. +- `config.ts` owns grouped declarations and the final `SHORTCUT_SCHEMA`. +- `schema.ts` derives action and category types and provides schema lookup and effective binding resolution. +- `bindings.ts` owns chord parsing, normalization, display, browser-risk checks, and conflict rules. +- `registry.ts` owns the active handler for each action ID and stack-safe temporary suspension of all application handlers. +- `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls. +- `useKeybind.ts` ties registrations to React component lifetimes while keeping handlers current without re-registering after every render. +- Runtime hooks install one dispatcher listener for their window. The main application and Mini Chat have separate windows but use the same contracts. + +# Binding rules + +Bindings remain persisted as `Record`. Each binding has one chord or at most two space-separated chords, such as `mod+s p`. `mod` is the platform-neutral primary modifier (Command on macOS, Control elsewhere), while `alt` is the platform-neutral alternate modifier (Option on macOS, Alt elsewhere); `command`, `cmd`, `meta`, and `option` are accepted input aliases but normalize to those canonical tokens. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. Display formatting uses macOS keyboard symbols (`⌘`, `⌥`, `⌃`, `⇧`) on macOS and named modifiers (`Ctrl`, `Alt`, `Shift`) elsewhere, including tooltip and accessible text consumers. A single chord conflicts with a sequence sharing its first chord; sibling sequences are valid. + +Contextual internal commands may deliberately share a sequence leader. The single-chord handler gets the first chance to handle the event; returning `false` lets the dispatcher start the sequence. The active file editor therefore owns `mod+s` for saving, while a mounted but unfocused editor yields `mod+s p`, `mod+s g`, and `mod+s l` to the draft target pickers and session list. + +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 captures up to two chords with at most three simultaneous physical keys per chord and checks the complete schema, not only customizable actions. After the first chord it waits up to 3000ms for a second; conflict and browser-risk feedback appears only when the second chord, timeout, or Confirm settles the recording. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts unless the single-chord action explicitly allows sequence fallback. Those contextual prefixes remain saveable with a warning because their handler yields outside its owning context. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced. + +`add_selection_to_chat` is contextual. A visible text-selection toolbar publishes its Add to chat and dismiss actions, suspends the shared application registry, and clears both synchronously when hidden or unmounted. The main application route also gates directly on active toolbar ownership before global dispatch, so unrelated shortcuts cannot escape the scoped interaction even if runtime bundling isolates registry state. The newest visible toolbar owns a dedicated scoped dispatcher; it ignores IME composition, stops IME Escape before the global Escape route without preventing its native default, handles non-IME Escape and the configured Add to chat binding (including a two-chord binding), and lets native input continue for unrelated keys. The application handler returns `false` when no toolbar action is active, so an unselected or stale DOM range can instead become a sequence leader. Opening, closing, or replacing a toolbar invalidates any pending scoped or global prefix. + +# Dispatching + +`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 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 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. 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. + +Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording. + +# Adding shortcuts + +1. Add the command to the matching group in `config.ts`. Use a stable action ID and a normalized default binding. Keep sequences to at most two chords. +2. Mark the command `customizable: true` only when it should appear in Settings. Add its `settingsLabelKey` and provide that key in every locale in the same change. +3. Register the handler with `useKeybind` or `useKeybinds` near the state or UI that owns the behavior. Do not pass shortcut callbacks through unrelated components or move local UI state into a global store. +4. Return `false` when the mounted handler is not applicable in the current runtime or focus context. This lets another command sharing the binding or prefix continue dispatching. +5. Add or update schema, binding, registry, or dispatcher tests for the changed contract. Update Help Dialog metadata when the command should be discoverable there. + +# Best practices + +- Import production APIs only from `@/lib/shortcuts`; deep imports are reserved for files and tests inside this module. +- Keep `config.ts` declarative and grouped. Do not add helpers there for querying state or executing behavior. +- Every application command must appear exactly once in `SHORTCUT_SCHEMA`, including internal and debug commands. Component-only editing and navigation keys stay local and out of the schema. +- Avoid exact default-binding conflicts. When runtime-exclusive commands intentionally share one, document the reason beside both declarations and make each handler return `false` outside its runtime. +- Persist bindings as normalized strings. Never change the `Record` override contract without an explicit migration and compatibility tests. +- Preserve the two-chord maximum in configuration, recording UI, parsing, conflict detection, display, and tests. diff --git a/packages/ui/src/lib/shortcuts/bindings.test.ts b/packages/ui/src/lib/shortcuts/bindings.test.ts new file mode 100644 index 00000000..be955841 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/bindings.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'bun:test'; + +import { + eventMatchesShortcutPrefix, + formatShortcutForDisplay, + getEffectiveShortcutPrefix, + getShortcutConflict, + isRiskyBrowserShortcut, + isShortcutPrefixHeld, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, +} from './index'; + +describe('getEffectiveShortcutPrefix', () => { + test('falls back to the action default (bare mod) when unset', () => { + expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod'); + }); + + test('honors modifier + key overrides', () => { + expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'mod+p' })).toBe('mod+p'); + }); + + test('honors modifier-only overrides', () => { + expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'shift' })).toBe('shift'); + }); + + test('returns UNASSIGNED for an explicit unassignment', () => { + expect( + getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: UNASSIGNED_SHORTCUT }), + ).toBe(UNASSIGNED_SHORTCUT); + }); + + test('returns empty string for an unknown action', () => { + expect(getEffectiveShortcutPrefix('does_not_exist', {})).toBe(''); + }); +}); + +describe('isShortcutPrefixHeld', () => { + test('false for an unassigned prefix', () => { + expect(isShortcutPrefixHeld(UNASSIGNED_SHORTCUT, new Set(['control']))).toBe(false); + }); + + test('requires the prefix primary key to be held', () => { + expect(isShortcutPrefixHeld('mod+p', new Set(['control']))).toBe(false); + expect(isShortcutPrefixHeld('mod+p', new Set(['control', 'p']))).toBe(true); + }); + + test('requires every prefix modifier to be held', () => { + expect(isShortcutPrefixHeld('mod+shift', new Set(['control']))).toBe(false); + expect(isShortcutPrefixHeld('mod+shift', new Set(['control', 'shift']))).toBe(true); + }); +}); + +const keydown = (key: string, mods: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyboardEvent => + ({ + key, + metaKey: mods.meta ?? false, + ctrlKey: mods.ctrl ?? false, + shiftKey: mods.shift ?? false, + altKey: mods.alt ?? false, + }) as KeyboardEvent; + +describe('eventMatchesShortcutPrefix', () => { + test('matches a bare mod prefix when the primary modifier is held', () => { + expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod')).toBe(true); + }); + + test('rejects a bare mod prefix without the primary modifier', () => { + expect(eventMatchesShortcutPrefix(keydown('1', {}), 'mod')).toBe(false); + }); + + test('rejects when the event carries modifiers the prefix does not expect', () => { + expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true, shift: true }), 'mod')).toBe(false); + }); + + test('requires the prefix primary key to be held at match time', () => { + expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control']))).toBe(false); + expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control', 'p']))).toBe(true); + }); + + test('false for an unassigned prefix', () => { + expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), UNASSIGNED_SHORTCUT)).toBe(false); + }); +}); + +describe('shortcut sequences', () => { + test('normalizes, parses, and formats up to two chords', () => { + expect(normalizeCombo(' command + S P ')).toBe('mod+s p'); + expect(parseShortcut('mod+s p')?.chords).toHaveLength(2); + expect(formatShortcutForDisplay('mod+s p')).toBe('Ctrl + S, P'); + }); + + test('rejects bindings with more than two chords', () => { + expect(normalizeCombo('mod+s p q')).toBe(''); + expect(parseShortcut('mod+s p q')).toBe(undefined); + }); + + test('reports exact and prefix conflicts but allows sibling sequences', () => { + expect(getShortcutConflict('mod+s', 'mod+s')).toBe('exact'); + expect(getShortcutConflict('mod+s', 'mod+s p')).toBe('prefix'); + expect(getShortcutConflict('mod+s p', 'mod+s q')).toBe(undefined); + }); + + test('warns when a sequence leader conflicts with a browser shortcut', () => { + expect(isRiskyBrowserShortcut('mod+s p')).toBe(true); + }); +}); + +describe('platform shortcut labels', () => { + test('normalizes Command and Option to platform-neutral modifiers', () => { + expect(normalizeCombo('command+option+n')).toBe('mod+alt+n'); + }); + + test('uses macOS modifier symbols', () => { + expect(formatShortcutForDisplay('mod+ctrl+shift+alt+n', 'Unassigned', 'macos')).toBe( + '⌘ + ⌃ + ⇧ + ⌥ + N', + ); + expect(formatShortcutForDisplay('alt', 'Unassigned', 'macos')).toBe('⌥'); + }); + + test('uses named modifiers on other platforms', () => { + expect(formatShortcutForDisplay('mod+shift+alt+n', 'Unassigned', 'other')).toBe( + 'Ctrl + Shift + Alt + N', + ); + expect(formatShortcutForDisplay('alt', 'Unassigned', 'other')).toBe('Alt'); + }); +}); diff --git a/packages/ui/src/lib/shortcuts/bindings.ts b/packages/ui/src/lib/shortcuts/bindings.ts new file mode 100644 index 00000000..fa45a358 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/bindings.ts @@ -0,0 +1,335 @@ +import type React from 'react'; +import { isDesktopShell } from '@/lib/desktop'; +import { isMacOS } from '@/lib/utils'; + +type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'ctrl'; +type ShortcutDisplayPlatform = 'macos' | 'other'; +type ShortcutKey = string; + +export type ShortcutCombo = string; +export type ShortcutConflict = 'exact' | 'prefix'; + +export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__'; + +interface ParsedShortcutChord { + modifiers: Set; + key: ShortcutKey; +} + +export interface ParsedShortcut { + chords: ReadonlyArray; +} + +const MODIFIER_KEY_MAP: Record = { + mod: 'mod', + shift: 'shift', + alt: 'alt', + option: 'alt', + ctrl: 'ctrl', + meta: 'mod', + cmd: 'mod', + command: 'mod', +}; + +const MODIFIER_LABELS: Record> = { + macos: { + mod: '⌘', + shift: '⇧', + alt: '⌥', + ctrl: '⌃', + }, + other: { + mod: 'Ctrl', + shift: 'Shift', + alt: 'Alt', + ctrl: 'Ctrl', + }, +}; + +const KEY_LABEL_MAP: Record = { + comma: ',', + period: '.', + enter: 'Enter', + escape: 'Esc', + tab: 'Tab', + space: 'Space', + backspace: '⌫', + delete: '⌦', + arrowup: '↑', + arrowdown: '↓', + arrowleft: '←', + arrowright: '→', + home: 'Home', + end: 'End', + pageup: 'Page Up', + pagedown: 'Page Down', +}; + +const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt']; +const RISKY_BROWSER_SHORTCUT_KEYS = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n', 'q', 'd', 'h', 'j', 'o', 'u']); +const MODIFIER_KEY_ALIASES: Record = { + mod: isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'], + shift: ['shift'], + alt: ['alt'], + ctrl: ['control'], +}; + +const SHIFTED_KEY_BASE_MAP: Record = { + '{': '[', + '}': ']', + ':': ';', + '"': "'", + '<': ',', + '>': '.', + '?': '/', + '|': '\\', + '~': '`', + '!': '1', + '@': '2', + '#': '3', + '$': '4', + '%': '5', + '^': '6', + '&': '7', + '*': '8', + '(': '9', + ')': '0', +}; + +function isUnassignedShortcut(combo: ShortcutCombo): boolean { + return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT; +} + +export function keyToShortcutToken(key: string): string { + const lowered = key.toLowerCase(); + + if (lowered === ',') return 'comma'; + if (lowered === '.') return 'period'; + if (lowered === ' ') return 'space'; + if (lowered === 'esc') return 'escape'; + if (lowered === '+') return 'plus'; + if (lowered === '-' || lowered === '_') return 'minus'; + if (lowered === 'arrowup') return 'arrowup'; + if (lowered === 'arrowdown') return 'arrowdown'; + if (lowered === 'arrowleft') return 'arrowleft'; + if (lowered === 'arrowright') return 'arrowright'; + + return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered; +} + +export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo { + if (isUnassignedShortcut(combo)) return UNASSIGNED_SHORTCUT; + + const chords = combo + .trim() + .replace(/\s*\+\s*/g, '+') + .split(/\s+/) + .filter(Boolean); + if (chords.length === 0 || chords.length > 2) return ''; + + return chords.map(normalizeChord).join(' '); +} + +function normalizeChord(combo: ShortcutCombo): ShortcutCombo { + const rawParts = combo + .toLowerCase() + .trim() + .split('+') + .map((part) => part.trim()) + .filter(Boolean); + const modifiers = new Set(); + let key = ''; + + for (const rawPart of rawParts) { + const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart; + const modifier = MODIFIER_KEY_MAP[part]; + if (modifier) { + modifiers.add(modifier); + } else { + key = part; + } + } + + const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier)); + return [...orderedModifiers, key].filter(Boolean).join('+'); +} + +export function isValidShortcutCombo(combo: ShortcutCombo): boolean { + if (isUnassignedShortcut(combo)) return true; + const parsed = parseShortcut(combo); + return parsed !== undefined && parsed.chords.every((chord) => chord.key.trim().length > 0); +} + +export function parseShortcut(combo: ShortcutCombo): ParsedShortcut | undefined { + if (isUnassignedShortcut(combo)) { + return { chords: [{ modifiers: new Set(), key: UNASSIGNED_SHORTCUT }] }; + } + + const normalized = normalizeCombo(combo); + if (!normalized) return undefined; + + return { + chords: normalized.split(' ').map((chord) => { + const modifiers = new Set(); + let key: ShortcutKey = ''; + for (const part of chord.split('+')) { + const modifier = MODIFIER_KEY_MAP[part]; + if (modifier) { + modifiers.add(modifier); + } else { + key = part; + } + } + return { modifiers, key }; + }), + }; +} + +function getShortcutDisplayPlatform(): ShortcutDisplayPlatform { + return isMacOS() ? 'macos' : 'other'; +} + +export function formatShortcutForDisplay( + combo: ShortcutCombo, + unassignedLabel = 'Unassigned', + platform = getShortcutDisplayPlatform(), +): string { + if (isUnassignedShortcut(combo)) return unassignedLabel; + const parsed = parseShortcut(combo); + if (!parsed || parsed.chords.some((chord) => !chord.key && chord.modifiers.size === 0)) { + return unassignedLabel; + } + return parsed.chords.map((chord) => formatChordForDisplay(chord, platform)).join(', '); +} + +function formatChordForDisplay( + parsed: ParsedShortcutChord, + platform: ShortcutDisplayPlatform, +): string { + const modifierLabels = MODIFIER_LABELS[platform]; + const parts = MODIFIER_PRIORITY + .filter((modifier) => parsed.modifiers.has(modifier)) + .map((modifier) => modifierLabels[modifier]); + if (parsed.key) { + parts.push(KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase()); + } + return parts.join(' + '); +} + +export function getShortcutConflict(left: ShortcutCombo, right: ShortcutCombo): ShortcutConflict | undefined { + const normalizedLeft = normalizeCombo(left); + const normalizedRight = normalizeCombo(right); + const hasInvalidBinding = !isValidShortcutCombo(normalizedLeft) || !isValidShortcutCombo(normalizedRight); + const hasUnassignedBinding = normalizedLeft === UNASSIGNED_SHORTCUT + || normalizedRight === UNASSIGNED_SHORTCUT; + if (hasInvalidBinding || hasUnassignedBinding) return undefined; + if (normalizedLeft === normalizedRight) return 'exact'; + + const leftChords = normalizedLeft.split(' '); + const rightChords = normalizedRight.split(' '); + const sharesLeader = leftChords[0] === rightChords[0]; + return sharesLeader && leftChords.length !== rightChords.length ? 'prefix' : undefined; +} + +export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean { + if (isUnassignedShortcut(combo)) return false; + const parsed = parseShortcut(combo); + if (!parsed) return false; + // Every chord counts: a second chord like "mod+w" is just as capable of + // closing the tab as a first one, and mod+shift+w closes a window. + return parsed.chords.some((chord) => { + if (!chord.modifiers.has('mod')) return false; + if (chord.modifiers.has('alt')) return false; + if (chord.modifiers.has('shift')) { + return chord.key.toLowerCase() === 'w' || chord.key.toLowerCase() === 'q'; + } + return RISKY_BROWSER_SHORTCUT_KEYS.has(chord.key.toLowerCase()); + }); +} + +export function eventMatchesShortcut( + event: KeyboardEvent | React.KeyboardEvent, + combo: ShortcutCombo, +): boolean { + if (isUnassignedShortcut(combo)) return false; + const parsed = parseShortcut(combo); + if (!parsed || parsed.chords.length !== 1) return false; + const chord = parsed.chords[0]; + + const expectedMod = chord.modifiers.has('mod'); + const expectedShift = chord.modifiers.has('shift'); + const expectedAlt = chord.modifiers.has('alt'); + const expectedCtrl = chord.modifiers.has('ctrl'); + const isDesktopMac = isMacOS() && isDesktopShell(); + const isMac = isMacOS(); + let modMatches = event.ctrlKey; + if (isDesktopMac) { + modMatches = event.metaKey; + } else if (isMac) { + modMatches = event.metaKey || event.ctrlKey; + } + + if (expectedMod && !modMatches) return false; + if (!expectedMod && event.metaKey) return false; + if (expectedShift !== event.shiftKey) return false; + if (expectedAlt !== event.altKey) return false; + if (expectedCtrl) { + if (!event.ctrlKey) return false; + } else { + const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; + if (event.ctrlKey && !ctrlUsedAsMod) return false; + } + + let eventKeyRaw = event.key; + if (event.altKey) { + if (event.code.startsWith('Key') && event.code.length === 4) { + eventKeyRaw = event.code.slice(3).toLowerCase(); + } else if (event.code.startsWith('Digit') && event.code.length === 6) { + eventKeyRaw = event.code.slice(5); + } + } + + return keyToShortcutToken(eventKeyRaw) === keyToShortcutToken(chord.key); +} + +export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet): boolean { + if (isUnassignedShortcut(prefixCombo)) return false; + const parsed = parseShortcut(prefixCombo); + if (!parsed || parsed.chords.length !== 1) return false; + const chord = parsed.chords[0]; + + for (const modifier of chord.modifiers) { + if (!MODIFIER_KEY_ALIASES[modifier].some((alias) => heldKeys.has(alias))) return false; + } + return !chord.key || heldKeys.has(chord.key.toLowerCase()); +} + +export function eventMatchesShortcutPrefix( + event: KeyboardEvent | React.KeyboardEvent, + prefixCombo: ShortcutCombo, + heldKeys?: ReadonlySet, +): boolean { + if (isUnassignedShortcut(prefixCombo)) return false; + const parsed = parseShortcut(prefixCombo); + if (!parsed || parsed.chords.length !== 1) return false; + const chord = parsed.chords[0]; + const expectedMod = chord.modifiers.has('mod'); + const expectedShift = chord.modifiers.has('shift'); + const expectedAlt = chord.modifiers.has('alt'); + const expectedCtrl = chord.modifiers.has('ctrl'); + const isDesktopMac = isMacOS() && isDesktopShell(); + const isMac = isMacOS(); + const modMatches = isDesktopMac ? event.metaKey : isMac ? event.metaKey || event.ctrlKey : event.ctrlKey; + + if (expectedMod && !modMatches) return false; + if (!expectedMod && event.metaKey) return false; + if (expectedShift !== event.shiftKey || expectedAlt !== event.altKey) return false; + if (expectedCtrl) { + if (!event.ctrlKey) return false; + } else { + const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; + if (event.ctrlKey && !ctrlUsedAsMod) return false; + } + + return !chord.key || Boolean(heldKeys?.has(chord.key.toLowerCase())); +} diff --git a/packages/ui/src/lib/shortcuts/config.ts b/packages/ui/src/lib/shortcuts/config.ts new file mode 100644 index 00000000..88cd8e4a --- /dev/null +++ b/packages/ui/src/lib/shortcuts/config.ts @@ -0,0 +1,286 @@ +import type { ShortcutCombo } from './bindings'; + +type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application'; + +type ShortcutConfig = { + id: string; + defaultBinding: ShortcutCombo; + allowsSequenceFallback?: true; + /** The binding is a bare-modifier chord prefix (completed by another key); + conflict resolution compares its prefix rather than a full combo. */ + prefixStyle?: true; +} & ( + | { customizable: false } + | { + customizable: true; + settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${string}.label`; + } +); + +const SHORTCUT_GROUPS = { + session: [ + { + id: 'add_selection_to_chat', + defaultBinding: 'mod+l', + allowsSequenceFallback: true, + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label', + }, + { + id: 'focus_input', + defaultBinding: 'mod+i', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.focus_input.label', + }, + { + id: 'open_timeline_dialog', + defaultBinding: 'mod+t', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label', + }, + { + id: 'new_chat', + defaultBinding: 'mod+n', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_chat.label', + }, + { + id: 'close_session_tab', + defaultBinding: 'alt+w', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label', + }, + { + id: 'open_draft_project_picker', + defaultBinding: 'mod+s p', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label', + }, + { + id: 'open_draft_worktree_picker', + defaultBinding: 'mod+s g', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label', + }, + { + id: 'open_session_list', + defaultBinding: 'mod+s l', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label', + }, + { + id: 'new_chat_worktree', + defaultBinding: 'mod+shift+n', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label', + }, + { + id: 'new_mini_chat', + defaultBinding: 'mod+alt+n', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label', + }, + { + id: 'expand_input', + defaultBinding: 'mod+shift+e', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.expand_input.label', + }, + { + id: 'toggle_dictation', + defaultBinding: 'mod+alt+v', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label', + }, + { id: 'abort_run', defaultBinding: 'escape', customizable: false }, + ], + models: [ + { + id: 'open_model_selector', + defaultBinding: 'mod+shift+m', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label', + }, + { id: 'cycle_thinking_variant', defaultBinding: 'mod+shift+t', customizable: false }, + { + id: 'cycle_agent', + defaultBinding: 'tab', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label', + }, + { + id: 'cycle_favorite_model_forward', + defaultBinding: 'ctrl+]', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label', + }, + { + id: 'cycle_favorite_model_backward', + defaultBinding: 'ctrl+[', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label', + }, + ], + panels: [ + { + id: 'toggle_terminal', + defaultBinding: 'mod+j', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label', + }, + { + id: 'toggle_terminal_expanded', + defaultBinding: 'mod+shift+j', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label', + }, + { + id: 'toggle_sidebar', + defaultBinding: 'mod+alt+l', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label', + }, + { + id: 'toggle_prompt_navigator', + defaultBinding: 'mod+alt+p', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label', + }, + { + id: 'toggle_right_sidebar', + defaultBinding: 'mod+b', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label', + }, + { + id: 'open_right_sidebar_git', + defaultBinding: 'mod+shift+g', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label', + }, + { + id: 'open_right_sidebar_files', + defaultBinding: 'mod+shift+f', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label', + }, + { + id: 'switch_context_surface', + defaultBinding: 'mod', + // The binding is a bare modifier acting as a chord prefix (completed by + // a digit); conflict resolution must compare its PREFIX, not a combo. + prefixStyle: true, + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label', + }, + { + id: 'toggle_context_plan', + defaultBinding: 'mod+shift+p', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label', + }, + { + id: 'toggle_services_menu', + defaultBinding: 'mod+shift+s', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label', + }, + ], + navigation: [ + { id: 'save_file', defaultBinding: 'mod+s', customizable: false, allowsSequenceFallback: true }, + { id: 'find_in_file', defaultBinding: 'mod+f', customizable: false }, + { + id: 'open_go_to_line', + defaultBinding: 'alt+g', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label', + }, + { + id: 'cycle_services_tab', + defaultBinding: 'mod+shift+[', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label', + }, + { id: 'switch_tab_1', defaultBinding: 'mod+1', customizable: false }, + // Mobile tab shortcuts may share numeric bindings with desktop-only panel commands. + { id: 'switch_tab_2', defaultBinding: 'mod+2', customizable: false }, + { id: 'switch_tab_3', defaultBinding: 'mod+3', customizable: false }, + { id: 'switch_tab_4', defaultBinding: 'mod+4', customizable: false }, + { id: 'switch_tab_5', defaultBinding: 'mod+5', customizable: false }, + { id: 'switch_tab_6', defaultBinding: 'mod+6', customizable: false }, + { id: 'switch_tab_7', defaultBinding: 'mod+7', customizable: false }, + { id: 'switch_tab_8', defaultBinding: 'mod+8', customizable: false }, + { id: 'switch_tab_9', defaultBinding: 'mod+9', customizable: false }, + ], + application: [ + { + id: 'open_command_palette', + defaultBinding: 'mod+p', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label', + }, + { id: 'open_status', defaultBinding: 'mod+shift+o', customizable: false }, + { + id: 'open_settings', + defaultBinding: 'mod+comma', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_settings.label', + }, + { + id: 'open_help', + defaultBinding: 'mod+.', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_help.label', + }, + { + id: 'cycle_theme', + defaultBinding: 'mod+/', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label', + }, + { id: 'toggle_memory_debug', defaultBinding: 'mod+shift+d', customizable: false }, + ], +} as const satisfies Record; + +/** All application shortcuts, flattened in the same order used by Settings. */ +export const SHORTCUT_SCHEMA = [ + ...SHORTCUT_GROUPS.session.map((shortcut) => ({ + ...shortcut, + category: 'session' as const, + })), + ...SHORTCUT_GROUPS.models.map((shortcut) => ({ + ...shortcut, + category: 'models' as const, + })), + ...SHORTCUT_GROUPS.panels.map((shortcut) => ({ + ...shortcut, + category: 'panels' as const, + })), + ...SHORTCUT_GROUPS.navigation.map((shortcut) => ({ + ...shortcut, + category: 'navigation' as const, + })), + ...SHORTCUT_GROUPS.application.map((shortcut) => ({ + ...shortcut, + category: 'application' as const, + })), +] as const; diff --git a/packages/ui/src/lib/shortcuts/dispatcher.test.ts b/packages/ui/src/lib/shortcuts/dispatcher.test.ts new file mode 100644 index 00000000..f41fa88f --- /dev/null +++ b/packages/ui/src/lib/shortcuts/dispatcher.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from 'bun:test'; +import { ShortcutDispatcher } from './dispatcher'; +import { ShortcutRegistry } from './registry'; + +function key(key: string, options: Partial = {}): KeyboardEvent { + return { + key, + code: `Key${key.toUpperCase()}`, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + repeat: false, + isComposing: false, + ...options, + } as KeyboardEvent; +} + +describe('ShortcutDispatcher', () => { + test('dispatches a sequence and consumes only leaders with active handlers', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + const unregister = registry.register('open_command_palette', (event) => { + calls.push(event.key); + }); + const dispatcher = new ShortcutDispatcher({ + registry, + getBinding: (id) => id === 'open_command_palette' ? 'g h' : '', + }); + + expect(dispatcher.dispatch(key('g'))).toBe(true); + expect(dispatcher.dispatch(key('h'))).toBe(true); + expect(calls).toEqual(['h']); + + unregister(); + expect(dispatcher.dispatch(key('g'))).toBe(false); + }); + + test('re-matches a prefix mismatch and clears on escape or blur', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('sequence'); }); + registry.register('open_help', () => { calls.push('single'); }); + const dispatcher = new ShortcutDispatcher({ + registry, + getBinding: (id) => id === 'open_command_palette' ? 'g h' : 'x', + }); + + dispatcher.dispatch(key('g')); + expect(dispatcher.dispatch(key('x'))).toBe(true); + expect(calls).toEqual(['single']); + dispatcher.dispatch(key('g')); + expect(dispatcher.dispatch(key('Escape'))).toBe(true); + expect(dispatcher.handleEscape()).toBe(false); + dispatcher.dispatch(key('g')); + dispatcher.handleBlur(); + expect(dispatcher.dispatch(key('h'))).toBe(false); + }); + + test('expires prefixes and ignores repeats, composition, and modifier keys', () => { + let now = 0; + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h', now: () => now }); + + expect(dispatcher.dispatch(key('g'))).toBe(true); + now = 2999; + expect(dispatcher.hasActivePrefix()).toBe(true); + now = 3000; + expect(dispatcher.dispatch(key('h'))).toBe(false); + expect(dispatcher.dispatch(key('g', { repeat: true }))).toBe(false); + expect(dispatcher.dispatch(key('g', { isComposing: true }))).toBe(false); + expect(dispatcher.dispatch(key('Shift'))).toBe(false); + expect(calls).toEqual([]); + }); + + test('does not consume a completed binding when every handler declines it', () => { + const registry = new ShortcutRegistry(); + registry.register('open_command_palette', () => false); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' }); + + expect(dispatcher.dispatch(key('g'))).toBe(true); + expect(dispatcher.dispatch(key('h'))).toBe(false); + }); + + test('does not consume a single chord when its handler declines it', () => { + const registry = new ShortcutRegistry(); + registry.register('open_command_palette', () => false); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' }); + + expect(dispatcher.dispatch(key('x'))).toBe(false); + }); + + test('starts a sequence when a single-chord handler with the same leader declines', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('save_file', () => false); + registry.register('open_draft_project_picker', () => { calls.push('project'); }); + const dispatcher = new ShortcutDispatcher({ + registry, + getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p', + }); + + expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true); + expect(dispatcher.dispatch(key('p'))).toBe(true); + expect(calls).toEqual(['project']); + }); + + test('does not start a sequence when a single-chord handler accepts the leader', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('save_file', () => { calls.push('save'); }); + registry.register('open_draft_project_picker', () => { calls.push('project'); }); + const dispatcher = new ShortcutDispatcher({ + registry, + getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p', + }); + + expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true); + expect(dispatcher.dispatch(key('p'))).toBe(false); + expect(calls).toEqual(['save']); + }); + + test('resolves bindings at dispatch time', () => { + const registry = new ShortcutRegistry(); + let binding = 'x'; + const calls: string[] = []; + registry.register('open_command_palette', (event) => { calls.push(event.key); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => binding }); + + expect(dispatcher.dispatch(key('x'))).toBe(true); + binding = 'y'; + expect(dispatcher.dispatch(key('x'))).toBe(false); + expect(dispatcher.dispatch(key('y'))).toBe(true); + expect(calls).toEqual(['x', 'y']); + }); + + test('invalidates a prefix when shortcut suspension changes', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' }); + + expect(dispatcher.dispatch(key('g'))).toBe(true); + const resume = registry.suspend(); + expect(dispatcher.hasActivePrefix()).toBe(false); + expect(dispatcher.handleEscape()).toBe(false); + resume(); + expect(dispatcher.dispatch(key('h'))).toBe(false); + expect(calls).toEqual([]); + }); + + test('marks a second key dispatched from capture so bubble does not dispatch it again', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' }); + const secondKey = key('h'); + + dispatcher.dispatch(key('g')); + expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true); + expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true); + expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(false); + expect(calls).toEqual(['sequence']); + }); + + test('consumes a matching captured prefix key during IME composition', () => { + for (const compositionState of [{ isComposing: true }, { keyCode: 229 }]) { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_session_list', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' }); + const secondKey = key('l', compositionState); + + expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true); + expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true); + expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true); + expect(calls).toEqual(['sequence']); + } + }); + + test('clears an active prefix but preserves an unmatched IME key', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_session_list', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' }); + const secondKey = key('x', { isComposing: true }); + + dispatcher.dispatch(key('s', { ctrlKey: true })); + expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(false); + expect(dispatcher.hasActivePrefix()).toBe(false); + expect(calls).toEqual([]); + }); + + test('stops after the first handler that accepts a conflicting binding', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('declined'); return false; }); + registry.register('open_help', () => { calls.push('first'); }); + registry.register('open_settings', () => { calls.push('second'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' }); + + expect(dispatcher.dispatch(key('x'))).toBe(true); + expect(calls).toEqual(['declined', 'first']); + }); +}); diff --git a/packages/ui/src/lib/shortcuts/dispatcher.ts b/packages/ui/src/lib/shortcuts/dispatcher.ts new file mode 100644 index 00000000..f70933d9 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/dispatcher.ts @@ -0,0 +1,170 @@ +import { + eventMatchesShortcut, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, + type ShortcutCombo, +} from './bindings'; +import { type ShortcutHandler, ShortcutRegistry } from './registry'; +import type { ShortcutActionId } from './schema'; +import { isIMECompositionEvent } from '../ime'; + +const SEQUENCE_TIMEOUT_MS = 3000; +const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']); + +export interface ShortcutDispatcherOptions { + registry: ShortcutRegistry; + getBinding: (actionId: ShortcutActionId) => ShortcutCombo; + now?: () => number; + timeoutMs?: number; +} + +interface BindingMatch { + chords: string[]; + handler: ShortcutHandler; +} + +/** Stateless with respect to the DOM; callers decide whether a consumed event is prevented. */ +export class ShortcutDispatcher { + private readonly now: () => number; + private readonly timeoutMs: number; + private prefix: string | undefined; + // The target the leader chord was pressed on. DOM-agnostic (opaque + // EventTarget): callers use it to decide whether an unmodified completion + // key arriving from an EDITABLE target is a deliberate sequence (same + // target as the arming press) or typing that must not be swallowed. + private prefixTarget: EventTarget | null = null; + private expiresAt = 0; + private prefixSuspensionVersion = 0; + private readonly capturedPrefixEvents = new WeakSet(); + + constructor(private readonly options: ShortcutDispatcherOptions) { + this.now = options.now ?? Date.now; + this.timeoutMs = options.timeoutMs ?? SEQUENCE_TIMEOUT_MS; + } + + dispatch(event: KeyboardEvent): boolean { + if (event.repeat || isIMECompositionEvent(event) || MODIFIER_KEYS.has(event.key.toLowerCase())) { + return false; + } + if (event.key === 'Escape' && this.hasActivePrefix()) { + return this.handleEscape(); + } + this.hasActivePrefix(); + + const matches = this.getMatches(); + if (this.prefix) { + const pending = this.getPrefixMatches(matches, event); + if (pending.length > 0) { + this.clear(); + return this.invoke(pending, event); + } + this.clear(); + } + + const singles = matches.filter((match) => ( + match.chords.length === 1 && eventMatchesShortcut(event, match.chords[0]) + )); + if (singles.length > 0 && this.invoke(singles, event)) { + return true; + } + + const leader = matches.find((match) => ( + match.chords.length === 2 && eventMatchesShortcut(event, match.chords[0]) + )); + if (leader) { + this.prefix = leader.chords[0]; + this.prefixTarget = event.target; + this.expiresAt = this.now() + this.timeoutMs; + this.prefixSuspensionVersion = this.options.registry.getSuspensionVersion(); + return true; + } + return false; + } + + clear(): void { + this.prefix = undefined; + this.prefixTarget = null; + this.expiresAt = 0; + this.prefixSuspensionVersion = 0; + } + + getActivePrefixTarget(): EventTarget | null { + return this.hasActivePrefix() ? this.prefixTarget : null; + } + + handleBlur(): void { + this.clear(); + } + + handleEscape(): boolean { + const hadPrefix = this.hasActivePrefix(); + this.clear(); + return hadPrefix; + } + + hasActivePrefix(): boolean { + if (!this.prefix) return false; + if ( + this.now() >= this.expiresAt + || this.prefixSuspensionVersion !== this.options.registry.getSuspensionVersion() + ) { + this.clear(); + return false; + } + return true; + } + + dispatchActivePrefix(event: KeyboardEvent): boolean { + this.capturedPrefixEvents.add(event); + if (isIMECompositionEvent(event)) { + if (event.repeat || MODIFIER_KEYS.has(event.key.toLowerCase()) || !this.hasActivePrefix()) { + return false; + } + const pending = this.getPrefixMatches(this.getMatches(), event); + this.clear(); + return pending.length > 0 ? this.invoke(pending, event) : false; + } + return this.dispatch(event); + } + + consumeCapturedPrefixEvent(event: KeyboardEvent): boolean { + if (!this.capturedPrefixEvents.has(event)) return false; + this.capturedPrefixEvents.delete(event); + return true; + } + + private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean { + for (const match of matches) { + if (match.handler(event) !== false) { + return true; + } + } + return false; + } + + private getPrefixMatches(matches: BindingMatch[], event: KeyboardEvent): BindingMatch[] { + return matches.filter((match) => ( + match.chords.length === 2 + && match.chords[0] === this.prefix + && eventMatchesShortcut(event, match.chords[1]) + )); + } + + private getMatches(): BindingMatch[] { + const matches: BindingMatch[] = []; + for (const actionId of this.options.registry.actionIds()) { + const handler = this.options.registry.get(actionId); + if (!handler) continue; + + const binding = normalizeCombo(this.options.getBinding(actionId)); + const parsed = parseShortcut(binding); + if (!parsed || parsed.chords.some((chord) => !chord.key || chord.key === UNASSIGNED_SHORTCUT)) { + continue; + } + + matches.push({ chords: binding.split(' '), handler }); + } + return matches; + } +} diff --git a/packages/ui/src/lib/shortcuts/index.ts b/packages/ui/src/lib/shortcuts/index.ts new file mode 100644 index 00000000..b75c3200 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/index.ts @@ -0,0 +1,30 @@ +export { + eventMatchesShortcut, + eventMatchesShortcutPrefix, + formatShortcutForDisplay, + getShortcutConflict, + isRiskyBrowserShortcut, + isShortcutPrefixHeld, + keyToShortcutToken, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, +} from './bindings'; +export type { ShortcutCombo } from './bindings'; +export { ShortcutDispatcher } from './dispatcher'; +export { shortcutRegistry } from './registry'; +export type { ShortcutHandler } from './registry'; +export { + getCustomizableShortcutActions, + getShortcutBindingConflicts, + getEffectiveShortcutCombo, + getEffectiveShortcutPrefix, + getShortcutAction, + SHORTCUT_SCHEMA, +} from './schema'; +export type { + CustomizableShortcutAction, + ShortcutBindingConflict, + ShortcutActionId, + ShortcutCategory, +} from './schema'; diff --git a/packages/ui/src/lib/shortcuts/registry.test.ts b/packages/ui/src/lib/shortcuts/registry.test.ts new file mode 100644 index 00000000..4df88b7a --- /dev/null +++ b/packages/ui/src/lib/shortcuts/registry.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'bun:test'; +import { ShortcutRegistry } from './registry'; + +test('the first registration wins and a later unregister cannot remove it', () => { + const registry = new ShortcutRegistry(); + const firstHandler = () => undefined; + const first = registry.register('open_settings', firstHandler); + const replacement = registry.register('open_settings', () => false); + + replacement(); + + expect(registry.get('open_settings')).toBe(firstHandler); + first(); + expect(registry.get('open_settings')).toBe(undefined); +}); + +test('a later registration takes over after the first unregisters', () => { + const registry = new ShortcutRegistry(); + const firstHandler = () => undefined; + const secondHandler = () => false; + const first = registry.register('open_settings', firstHandler); + registry.register('open_settings', secondHandler); + + expect(registry.get('open_settings')).toBe(firstHandler); + first(); + expect(registry.get('open_settings')).toBe(secondHandler); +}); + +test('suspends all handlers until every idempotent cleanup completes', () => { + const registry = new ShortcutRegistry(); + const handler = () => undefined; + registry.register('open_settings', handler); + + const resumeFirst = registry.suspend(); + const resumeSecond = registry.suspend(); + expect(registry.get('open_settings')).toBe(undefined); + expect(registry.isSuspended()).toBe(true); + + resumeFirst(); + resumeFirst(); + expect(registry.get('open_settings')).toBe(undefined); + resumeSecond(); + resumeSecond(); + expect(registry.get('open_settings')).toBe(handler); + expect(registry.isSuspended()).toBe(false); +}); diff --git a/packages/ui/src/lib/shortcuts/registry.ts b/packages/ui/src/lib/shortcuts/registry.ts new file mode 100644 index 00000000..dca7971c --- /dev/null +++ b/packages/ui/src/lib/shortcuts/registry.ts @@ -0,0 +1,71 @@ +import type { ShortcutActionId } from './schema'; + +export type ShortcutHandler = (event: KeyboardEvent) => boolean | void; + +interface RegisteredHandler { + handler: ShortcutHandler; +} + +/** Active application command handlers, keyed by shortcut action ID. */ +export class ShortcutRegistry { + private readonly handlers = new Map(); + private suspensionCount = 0; + private suspensionVersion = 0; + + register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void { + const registration = { handler }; + const registered = this.handlers.get(actionId) ?? []; + if (registered.length > 0 && typeof console !== 'undefined' && import.meta.env?.DEV) { + // First registration wins at dispatch; a silent second registration is + // almost always two components fighting over one action. + console.warn(`[shortcuts] duplicate handler registration for "${actionId}" — only the first will dispatch`); + } + registered.push(registration); + this.handlers.set(actionId, registered); + return () => { + const current = this.handlers.get(actionId); + if (!current) return; + const index = current.indexOf(registration); + if (index === -1) return; + current.splice(index, 1); + if (current.length === 0) { + this.handlers.delete(actionId); + } + }; + } + + get(actionId: ShortcutActionId): ShortcutHandler | undefined { + if (this.suspensionCount > 0) return undefined; + return this.handlers.get(actionId)?.[0]?.handler; + } + + /** Temporarily disables every registered application shortcut. */ + suspend(): () => void { + this.suspensionCount += 1; + this.suspensionVersion += 1; + let active = true; + return () => { + if (!active) return; + active = false; + this.suspensionCount -= 1; + if (this.suspensionCount === 0) { + this.suspensionVersion += 1; + } + }; + } + + getSuspensionVersion(): number { + return this.suspensionVersion; + } + + isSuspended(): boolean { + return this.suspensionCount > 0; + } + + actionIds(): IterableIterator { + return this.handlers.keys(); + } +} + +/** Shared registry for application commands registered by React surfaces. */ +export const shortcutRegistry = new ShortcutRegistry(); diff --git a/packages/ui/src/lib/shortcuts/schema.test.ts b/packages/ui/src/lib/shortcuts/schema.test.ts new file mode 100644 index 00000000..d12499dd --- /dev/null +++ b/packages/ui/src/lib/shortcuts/schema.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'bun:test'; +import { + getCustomizableShortcutActions, + getEffectiveShortcutCombo, + getShortcutBindingConflicts, + getShortcutAction, + parseShortcut, + SHORTCUT_SCHEMA, + type ShortcutCategory, +} from './index'; + +describe('shortcut schema', () => { + test('declares unique IDs and valid bindings for every application shortcut', () => { + const ids = SHORTCUT_SCHEMA.map((action) => action.id); + const hasValidMetadata = SHORTCUT_SCHEMA.every((action) => { + const chordCount = parseShortcut(action.defaultBinding)?.chords.length; + return Boolean(action.category) + && chordCount !== undefined + && chordCount >= 1 + && chordCount <= 2; + }); + + expect(new Set(ids).size).toBe(ids.length); + expect(hasValidMetadata).toBe(true); + }); + + test('keeps the flattened schema grouped in Settings order', () => { + const groupOrder: ShortcutCategory[] = []; + for (const action of SHORTCUT_SCHEMA) { + if (groupOrder.at(-1) !== action.category) { + groupOrder.push(action.category); + } + } + + expect(groupOrder).toEqual([ + 'session', + 'models', + 'panels', + 'navigation', + 'application', + ]); + }); + + test('derives settings labels for every customizable shortcut', () => { + const customizable = getCustomizableShortcutActions(); + expect(customizable.length).toBeGreaterThan(0); + expect(customizable.every((action) => ( + action.settingsLabelKey === `settings.openchamber.keyboardShortcuts.action.${action.id}.label` + ))).toBe(true); + }); + + test('includes session prefix bindings and metadata', () => { + expect(getShortcutAction('open_draft_project_picker')?.defaultBinding).toBe('mod+s p'); + expect(getShortcutAction('open_draft_worktree_picker')?.defaultBinding).toBe('mod+s g'); + expect(getShortcutAction('open_session_list')?.defaultBinding).toBe('mod+s l'); + expect(getShortcutAction('focus_input')?.category).toBe('session'); + }); + + test('preserves valid overrides and falls back from malformed bindings', () => { + expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k' })).toBe('mod+k'); + expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k x y' })).toBe('mod+n'); + }); + + test('keeps internal bindings authoritative over persisted overrides', () => { + expect(getEffectiveShortcutCombo('save_file', { save_file: 'mod+k' })).toBe('mod+s'); + expect(getEffectiveShortcutCombo('save_file', { save_file: '__unassigned__' })).toBe('mod+s'); + }); + + test('detects conflicts against customizable and internal bindings', () => { + const customizableConflict = getShortcutBindingConflicts('new_chat', 'mod+p') + .find((conflict) => conflict.action.id === 'open_command_palette'); + const internalConflict = getShortcutBindingConflicts('new_chat', 'mod+f') + .find((conflict) => conflict.action.id === 'find_in_file'); + const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x') + .find((conflict) => conflict.action.id === 'save_file'); + const contextualPrefixConflict = getShortcutBindingConflicts('focus_input', 'mod+l l') + .find((conflict) => conflict.action.id === 'add_selection_to_chat'); + const contextualLeaderConflict = getShortcutBindingConflicts('add_selection_to_chat', 'mod+s') + .find((conflict) => conflict.action.id === 'open_draft_project_picker'); + const blockingPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+p x') + .find((conflict) => conflict.action.id === 'open_command_palette'); + + expect(customizableConflict?.kind).toBe('exact'); + expect(customizableConflict?.action.customizable).toBe(true); + expect(internalConflict?.kind).toBe('exact'); + expect(internalConflict?.action.customizable).toBe(false); + expect(internalPrefixConflict?.kind).toBe('contextual-prefix'); + expect(internalPrefixConflict?.action.customizable).toBe(false); + expect(contextualPrefixConflict?.kind).toBe('contextual-prefix'); + expect(contextualLeaderConflict?.kind).toBe('contextual-prefix'); + expect(blockingPrefixConflict?.kind).toBe('prefix'); + }); +}); + +describe('shortcut defaults', () => { + // Two actions silently sharing a default binding would race at dispatch + // (registry insertion order decides). Pairs that intentionally share a + // combo because they can never be active in the same runtime must be + // whitelisted here explicitly. + const RUNTIME_EXCLUSIVE_BINDING_PAIRS: ReadonlyArray> = []; + + test('no two actions share a normalized default binding', () => { + const byBinding = new Map(); + for (const action of SHORTCUT_SCHEMA) { + const combo = getEffectiveShortcutCombo(action.id); + if (!combo) continue; + const list = byBinding.get(combo) ?? []; + list.push(action.id); + byBinding.set(combo, list); + } + for (const [combo, ids] of byBinding) { + if (ids.length <= 1) continue; + const whitelisted = RUNTIME_EXCLUSIVE_BINDING_PAIRS.some( + (pair) => ids.every((id) => pair.has(id)), + ); + expect(whitelisted, `default binding "${combo}" shared by ${ids.join(', ')}`).toBe(true); + } + }); + + test('overrides recorded under the flat-file era still resolve', () => { + // The persisted override format is a flat Record and + // must keep resolving through the schema after the module split. + const overrides = { close_session_tab: 'alt+q', open_command_palette: 'mod+shift+k' }; + expect(getEffectiveShortcutCombo('close_session_tab', overrides)).toBe('alt+q'); + expect(getEffectiveShortcutCombo('open_command_palette', overrides)).toBe('mod+shift+k'); + // Unknown ids stay inert rather than throwing. + expect(getEffectiveShortcutCombo('close_session_tab', { ghost_action: 'mod+z', close_session_tab: 'alt+q' } as Record)).toBe('alt+q'); + }); +}); diff --git a/packages/ui/src/lib/shortcuts/schema.ts b/packages/ui/src/lib/shortcuts/schema.ts new file mode 100644 index 00000000..6afe81ec --- /dev/null +++ b/packages/ui/src/lib/shortcuts/schema.ts @@ -0,0 +1,111 @@ +import { + getShortcutConflict, + isValidShortcutCombo, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, + type ShortcutCombo, + type ShortcutConflict, +} from './bindings'; +import { SHORTCUT_SCHEMA } from './config'; + +export { SHORTCUT_SCHEMA } from './config'; + +export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number]; +export type ShortcutActionId = ShortcutAction['id']; +export type ShortcutCategory = ShortcutAction['category']; +export type CustomizableShortcutAction = Extract; +export type ShortcutBindingConflictKind = ShortcutConflict | 'contextual-prefix'; +export type ShortcutBindingConflict = { + action: ShortcutAction; + kind: ShortcutBindingConflictKind; +}; + +function allowsContextualPrefix( + action: ShortcutAction, + combo: ShortcutCombo, + candidate: ShortcutAction, + candidateCombo: ShortcutCombo, +): boolean { + const chordCount = parseShortcut(combo)?.chords.length; + const candidateChordCount = parseShortcut(candidateCombo)?.chords.length; + if (chordCount === 1 && candidateChordCount === 2) { + return 'allowsSequenceFallback' in action && action.allowsSequenceFallback; + } + if (chordCount === 2 && candidateChordCount === 1) { + return 'allowsSequenceFallback' in candidate && candidate.allowsSequenceFallback; + } + return false; +} + +export function getShortcutAction(id: string): ShortcutAction | undefined { + return SHORTCUT_SCHEMA.find((action) => action.id === id); +} + +export function getCustomizableShortcutActions(): ReadonlyArray { + return SHORTCUT_SCHEMA.filter( + (action): action is CustomizableShortcutAction => action.customizable, + ); +} + +export function getEffectiveShortcutCombo( + actionId: string, + overrides?: Record, +): ShortcutCombo { + const action = getShortcutAction(actionId); + if (!action) return ''; + if (!action.customizable) return action.defaultBinding; + + const override = overrides?.[actionId]; + if (typeof override === 'string') { + const normalized = normalizeCombo(override); + if (normalized === UNASSIGNED_SHORTCUT) return ''; + if (isValidShortcutCombo(normalized)) return normalized; + } + + return action.defaultBinding; +} + +export function getEffectiveShortcutPrefix( + actionId: string, + overrides?: Record, +): ShortcutCombo { + const action = getShortcutAction(actionId); + if (!action) return ''; + if (!action.customizable) return action.defaultBinding; + + const override = overrides?.[actionId]; + if (typeof override === 'string' && override.trim() !== '') { + const normalized = normalizeCombo(override); + if (normalized === UNASSIGNED_SHORTCUT) return UNASSIGNED_SHORTCUT; + const chord = parseShortcut(normalized)?.chords[0]; + if (chord && (chord.modifiers.size > 0 || chord.key)) return normalized; + } + + return action.defaultBinding; +} + +export function getShortcutBindingConflicts( + actionId: ShortcutActionId, + combo: ShortcutCombo, + overrides?: Record, +): ShortcutBindingConflict[] { + const conflicts: ShortcutBindingConflict[] = []; + const action = getShortcutAction(actionId); + if (!action) return conflicts; + for (const candidate of SHORTCUT_SCHEMA) { + if (candidate.id === actionId) continue; + const candidateCombo = ('prefixStyle' in candidate && candidate.prefixStyle) + ? getEffectiveShortcutPrefix(candidate.id, overrides) + : getEffectiveShortcutCombo(candidate.id, overrides); + const kind = getShortcutConflict(combo, candidateCombo); + if (!kind) continue; + conflicts.push({ + action: candidate, + kind: kind === 'prefix' && allowsContextualPrefix(action, combo, candidate, candidateCombo) + ? 'contextual-prefix' + : kind, + }); + } + return conflicts; +} diff --git a/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml b/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml new file mode 100644 index 00000000..f072de87 --- /dev/null +++ b/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml @@ -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.