) : null}
- {exactConflict && !prefixConflict ? (
-
-
-
- ) : null}
+
+
+
+
);
diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx
index 0d4c19bb..7cc3b7f6 100644
--- a/packages/ui/src/components/ui/select.tsx
+++ b/packages/ui/src/components/ui/select.tsx
@@ -286,13 +286,17 @@ function SelectLabel({
function SelectItem({
className,
children,
+ showSelectedBackground = true,
...props
-}: React.ComponentProps) {
+}: React.ComponentProps & {
+ showSelectedBackground?: boolean;
+}) {
return (
`. Each binding has one chor
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.
-Runtime-specific commands may also share an exact binding when their handlers are mutually exclusive. `open_diff_panel` handles `mod+2` on desktop, while `switch_tab_2` handles it on mobile; each returns `false` outside its runtime so the dispatcher can try the next registered action.
+The internal `switch_tab_*` bindings remain available to mobile handlers. Desktop numeric context-surface switching is resolved by the configurable `switch_context_surface` prefix before normal dispatcher matching and falls through on mobile.
-The settings recorder also stops at two chords. It keeps the recording local until the user explicitly saves, allows an exact conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous.
+The settings recorder also stops at two chords and checks the complete schema, not only customizable actions. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced.
# Dispatching
@@ -43,7 +43,7 @@ Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGloba
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.
+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
diff --git a/packages/ui/src/lib/shortcuts/index.ts b/packages/ui/src/lib/shortcuts/index.ts
index 7ef4ab99..53f0f1b0 100644
--- a/packages/ui/src/lib/shortcuts/index.ts
+++ b/packages/ui/src/lib/shortcuts/index.ts
@@ -17,6 +17,7 @@ export { shortcutRegistry } from './registry';
export type { ShortcutHandler } from './registry';
export {
getCustomizableShortcutActions,
+ getShortcutBindingConflicts,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
getShortcutAction,
@@ -24,6 +25,7 @@ export {
} from './schema';
export type {
CustomizableShortcutAction,
+ ShortcutBindingConflict,
ShortcutActionId,
ShortcutCategory,
} from './schema';
diff --git a/packages/ui/src/lib/shortcuts/schema.test.ts b/packages/ui/src/lib/shortcuts/schema.test.ts
index 463b92a0..a40512cc 100644
--- a/packages/ui/src/lib/shortcuts/schema.test.ts
+++ b/packages/ui/src/lib/shortcuts/schema.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import {
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
+ getShortcutBindingConflicts,
getShortcutAction,
parseShortcut,
SHORTCUT_SCHEMA,
@@ -59,4 +60,25 @@ describe('shortcut schema', () => {
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');
+
+ expect(customizableConflict?.kind).toBe('exact');
+ expect(customizableConflict?.action.customizable).toBe(true);
+ expect(internalConflict?.kind).toBe('exact');
+ expect(internalConflict?.action.customizable).toBe(false);
+ expect(internalPrefixConflict?.kind).toBe('prefix');
+ expect(internalPrefixConflict?.action.customizable).toBe(false);
+ });
});
diff --git a/packages/ui/src/lib/shortcuts/schema.ts b/packages/ui/src/lib/shortcuts/schema.ts
index 23ad1077..514b3e13 100644
--- a/packages/ui/src/lib/shortcuts/schema.ts
+++ b/packages/ui/src/lib/shortcuts/schema.ts
@@ -1,9 +1,11 @@
import {
+ getShortcutConflict,
isValidShortcutCombo,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
+ type ShortcutConflict,
} from './bindings';
import { SHORTCUT_SCHEMA } from './config';
@@ -13,6 +15,10 @@ export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number];
export type ShortcutActionId = ShortcutAction['id'];
export type ShortcutCategory = ShortcutAction['category'];
export type CustomizableShortcutAction = Extract;
+export type ShortcutBindingConflict = {
+ action: ShortcutAction;
+ kind: ShortcutConflict;
+};
export function getShortcutAction(id: string): ShortcutAction | undefined {
return SHORTCUT_SCHEMA.find((action) => action.id === id);
@@ -30,6 +36,7 @@ export function getEffectiveShortcutCombo(
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) return '';
+ if (!action.customizable) return action.defaultBinding;
const override = overrides?.[actionId];
if (typeof override === 'string') {
@@ -47,6 +54,7 @@ export function getEffectiveShortcutPrefix(
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) return '';
+ if (!action.customizable) return action.defaultBinding;
const override = overrides?.[actionId];
if (typeof override === 'string' && override.trim() !== '') {
@@ -58,3 +66,20 @@ export function getEffectiveShortcutPrefix(
return action.defaultBinding;
}
+
+export function getShortcutBindingConflicts(
+ actionId: ShortcutActionId,
+ combo: ShortcutCombo,
+ overrides?: Record,
+): ShortcutBindingConflict[] {
+ const conflicts: ShortcutBindingConflict[] = [];
+ for (const candidate of SHORTCUT_SCHEMA) {
+ if (candidate.id === actionId) continue;
+ const candidateCombo = candidate.id === 'switch_context_surface'
+ ? getEffectiveShortcutPrefix(candidate.id, overrides)
+ : getEffectiveShortcutCombo(candidate.id, overrides);
+ const kind = getShortcutConflict(combo, candidateCombo);
+ if (kind) conflicts.push({ action: candidate, kind });
+ }
+ return conflicts;
+}