---
title: Keyboard shortcuts (every binding, and how to change them) (part 4)
---

# Keyboard shortcuts (every binding, and how to change them) (part 4)

## What it is

This is part 4 of the [Keyboard shortcuts](keyboard-shortcuts.md) page. Where the earlier parts list what each key does, this one explains the machinery underneath: how a press is routed, how a binding is registered and kept in sync, and how a conflict is detected.

## Where to find it

There is nothing to open for this part — it describes behaviour you observe rather than a screen you visit. The bindings it explains are all listed and edited in **Settings → Keyboard shortcuts**.

## How it behaves

### How it works

The full default binding table is in [src/shared/keybindings.ts](../../src/shared/keybindings.ts) — `SHORTCUT_ACTIONS` is the source of truth, every entry has an `id`, `label`, `description`, `defaultBindings`, `passthroughInputs` flag (whether it fires while typing), a `noRepeat` flag (destructive / one-shot actions — archive session + email, diff accept/reject, digest dismiss, and the one-shot sends — plus the discrete session switches, next / previous session — set it so a held or laggy key fires the action once, not once per OS key-repeat event, so one physical press moves exactly one session even when the app is momentarily busy; continuous conversation scroll (message step), project switch, view toggles, and text-size deliberately omit it so holding those keys repeats), and a `category` (`global` / `navigation` / `session` / `gmail` / `diff-review` / `daily-digest`). User overrides live in `userBindings` on `AppSettings` and are merged at runtime via `resolveBindings()`. The dispatcher is [useKeyboardShortcuts.ts](../../src/renderer/src/hooks/useKeyboardShortcuts.ts) — it builds a binding-string lookup table per active scope (Gmail / diff-review / daily-digest get layered on top of the always-active "app" scope), matches each `keydown` event against the lookup, and calls the appropriate action handler. The `?` overlay is [KeyboardOverlay.tsx](../../src/renderer/src/components/ui/KeyboardOverlay.tsx) (lazy-loaded) and reads the same `resolved` bindings from [keybindings-store.ts](../../src/renderer/src/stores/keybindings-store.ts), so it always reflects your live overrides. The thread jump-to-ends actions (`jumpToConversationTop` / `jumpToConversationBottom`, default `Ctrl+Home` / `Ctrl+End`) dispatch a `jump-conversation` CustomEvent that `SessionPanel`'s keep-alive-gated listener turns into `scrollToConversationTop()` / `scrollToBottom()` — the same path the existing `navigate-message` (step-one-message) event uses. Before that dispatch, each handler asks `resolveHoveredSidebarScroller()` ([jump-to-scroll-edge.ts](../../src/renderer/src/lib/jump-to-scroll-edge.ts)) whether the mouse is resting on the hub or session list: if it is, and the pointer moved within the last second ([pointer-recency.ts](../../src/renderer/src/lib/pointer-recency.ts) — passive `pointermove` + `wheel`, cleared on window blur), that sidebar's scroll container is jumped directly and the event is never dispatched, so the transcript stays put and there is no claim race with the panel's synchronous listener. The aim needs the pointer to land inside `[data-ui-anchor="sidebar-projects-list"]` / `[data-ui-anchor="sidebar-rail"]` (the hub's compact-rail skin under the rail layout mod) / `[data-ui-anchor="sidebar-sessions-list"]` AND the innermost scrollable ancestor to still be inside that sidebar — anything else (stale pointer, mouse elsewhere, a short list that doesn't overflow) falls through to the normal page-level jump unchanged. The `continueSession` (default `C`) and `keepWaiting` (default `W`) actions reuse that same dispatch-to-visible-panel path: each dispatches a `continue-session` / `keep-waiting` CustomEvent (constants in [session-continue-events.ts](../../src/renderer/src/lib/session-continue-events.ts)) that `useContinueHotkeyListener` — the panel's `isVisible`-gated hook — turns into the matching composer-banner button handler (Continue/Check in or Keep-waiting), but ONLY when that banner is actually showing, so the key is a silent no-op on a normal running session. The combined-`C` routing (`pickContinueAction`) and the listener live in [useContinueHotkeyListener.ts](../../src/renderer/src/features/sessions/useContinueHotkeyListener.ts) + [nudge-affordance.ts](../../src/renderer/src/features/sessions/nudge-affordance.ts), extracted so the wiring is unit-testable without mounting `SessionPanel`. Because `continueSession`/`keepWaiting` are `passthroughInputs:false`, a bare `C`/`W` is suppressed inside the composer textarea (there it types the literal letter) — there is exactly ONE keydown handler and the composer intercepts nothing. Omniscio autofocuses the composer on entry when `sessionAutoFocusInput` is ON (it ships OFF since 2026-08-25 — Gmail-style, where entry focus is the conversation and `R` opens the box), which would make the shortcut unreachable on a recovery session — so rather than piercing the box, Omniscio moves the _focus_: `pickSessionEntryFocusTarget` ([session-entry-focus.ts](../../src/renderer/src/features/sessions/session-entry-focus.ts)) lands entry focus on the messages container instead of the composer for a recovery status (paused / stalled / errored / ended — but NOT a question-awaiting `needs_you`, which still focuses the box to type your answer), so a bare `C` reaches the untouched global router and fires on entry. The one `needs_you` sub-state it DOES focus-to-conversation is `auth_error` (the login-expired "re-authenticate" park): it's a stable `pendingAction` (not the racy wait-timeout banner), so it's handled via `CONTINUE_PRIMARY_NEEDS_YOU_PENDING_ACTIONS` + a `showAuthErrorContinue` arm on `pickContinueAction` — key and the on-screen Continue button share one arming authority, no new keydown path. Full invariants: [composer-recovery-shortcut-contract.md](../../.claude/memory/contracts/composer-recovery-shortcut-contract.md). The opt-in **one-handed layout** is `ONE_HANDED_PRESET` + `applyOneHandedPreset()` in [keybindings.ts](../../src/shared/keybindings.ts): an override-only, idempotent merge the **Apply one-handed layout** button writes (it never touches `defaultBindings`), dropping the `Ctrl+W` collision from `closePanel` so the left-hand keys win under `buildBindingLookup`'s first-registered-wins rule (the old second entry — clearing `toggleFocusMode` so bare `F` could become pause — went with the filter's move to `Shift+F` on 2026-09-28, which removed the collision entirely). The preset also layers `Shift+W`/`Shift+S` onto `prevMessage`/`nextMessage`, giving the bare `W`/`S` session keys a Shift-held message-step twin; because a bare Shift+letter folds to the letter in `eventToBindingString`, these resolve through the dispatcher's explicit `Shift+<letter>` fallback (tried before the folded letter — the same path behind `Shift+J`/`Shift+K` selection), so they cost no new collision handling. Via that same fallback, `Shift+W` (→ `prevMessage`) and `Shift+D` (→ `nextMessage`) ALSO ship as **default** message-nav twins in `core.keybindings.ts`, so letter-key message-stepping works out of the box without the one-handed layout; the preset's distinctive down-key `Shift+S` stays layout-only, and `Shift+D` reuses the daily-digest "dismiss all" glyph harmlessly because that scope's dispatcher fires before this app lookup.

The Settings → Keyboard Shortcuts UI is [KeybindingsSettings.tsx](../../src/renderer/src/features/settings/sections/keybindings/KeybindingsSettings.tsx). It uses `eventToBindingString()` from [keybindings.ts](../../src/shared/keybindings.ts) to capture a user's key press into the canonical binding string format (e.g. `Ctrl+Shift+A`), `detectConflicts()` to flag duplicates, and `isOsReservedBinding()` to block unsafe combinations. The panel is a master–detail layout: a left **groups rail** (with live per-group counts and a search box that filters across every group at once) and a detail pane that edits the selected group. The search matches each action's label, description, AND its bindings' **keys**: `bindingSearchTokens()` in [keybinding-search.ts](../../src/renderer/src/features/settings/sections/keybindings/keybinding-search.ts) expands every binding into its key/modifier names, the on-cap symbols, and common synonyms (Ctrl↔Control↔⌘, ArrowUp↔Up↔↑, CommandOrControl↔Ctrl, Super↔Win), so a shortcut is found by the trigger the user types OR sees; the same helper (`bindingsSearchText`) feeds the System-wide row matcher (`systemRowMatches`), so an OS hotkey stored as `CommandOrControl+…`/`Super+…` is reachable by `ctrl`/`win`. A pinned **System-wide** group hosts the `SystemWideHotkeysSection`, which renders a normalized `GlobalHotkeyRow[]` assembled by `buildSystemWideRows()` from three sources: the core OS fields (`globalHotkey`, `quickLaunchHotkey`, `jarvisBriefingHotkey`, `kmsWindowHotkey`, `kmsQuickFindHotkey`, `supermailWindowHotkey`, `scratchpadWindowHotkey`) via the still-exported `OS_LEVEL_HOTKEY_ROWS`, one row per Quick Launch tab (read/write `quickLaunchTabHotkeys[id]` with the same merge-on-set / delete-key-on-clear semantics as `QuickLaunchTabsCard`), and one row per saved Quick Email contact (read/write each recipient's `.hotkey`). A single capture state machine — keyed on the row `id`, not a field name — serves all three sources, so there is still only one `keydown` capture listener in the file. Conflicts among the _enabled_ global hotkeys are computed by `detectGlobalHotkeyConflicts()` in [src/shared/global-hotkeys.ts](../../src/shared/global-hotkeys.ts), which is driven by the SAME planner the runtime registers from (its enabled set is `plan.toRegister` plus the `reason: 'conflict'` skips) so the amber "Also used by …" note can never disagree with what actually registers; the returned `Map<rowId, warning>` keys exactly match each row's `id` (`globalHotkey` / `tab:<actionId>` / `contact:<recipientId>`). The **General / Navigation / Session / Gmail / Diff Review / Daily Digest** groups are sourced from `SHORTCUT_ACTIONS` filtered by `category` (the `global` category is shown under the display name "General"). Bindings render as grouped pills that auto-stack to one-per-line at 3+, with a `Customized` badge when an action's effective bindings differ from `defaultBindings`. Because master–detail only mounts the active group, a small id→group map (`settingIdToGroup`) selects the owning group when settings-search jumps to a sub-entry (`keyboard-shortcuts-os` → System-wide, `gmail-keyboard-shortcuts` → Gmail, `diff-review-keyboard-shortcuts` → Diff Review) so `useScrollToSetting` finds the target `data-setting-id`. Mutations also flow through the CLI control server's `/keybindings/*` endpoints (see [cli-keybindings-control.md](cli-keybindings-control.md)) — those are not approval-gated since keybindings are personal preferences. The global hotkeys (the five core ones plus every assigned Quick Launch tab and Quick Email contact) are registered in `registerGlobalHotkeys()` in [src/main/index.ts](../../src/main/index.ts) via Electron's `globalShortcut` API. The function builds its planner input with the shared `buildHotkeyConfigFromSettings(getSettings())` — the **exact** builder the renderer's conflict guard uses, so UI and runtime can't drift — then `planHotkeyRegistrations()` decides what to register: it treats an empty string as "user-disabled" (no `globalShortcut.register` call at all), gates Quick Launch behind `quickLaunchHotkeyEnabled` / Omni behind `jarvisBriefingHotkeyEnabled` / the per-recipient and per-tab hotkeys behind their masters, and walks the candidates in a fixed order (five core, then recipients, then tabs) skipping any later combo that collides with an earlier one (instead of letting Electron throw on the second register call). Settings → System edits, Settings → Voice & Speech edits, Settings → Quick Launch tabs / Quick Email edits, AND the unified Keybindings → System-wide group all write the same fields and trigger a re-register via the IPC settings handler — the trigger list in [settings-apply.ts](../../src/main/services/settings-apply.ts) covers `globalHotkey`, `jarvisBriefingHotkey(+Enabled)`, `quickLaunchHotkey(+Enabled)`, `quickLaunchTabHotkeys`, `kmsWindowHotkey(+Enabled)`, `kmsQuickFindHotkey(+Enabled)`, `nothariEnabled` (the KMS-quick-find hotkey is KMS-gated, so toggling KMS re-claims/releases it live), **and** `quickEmailEnabled` / `quickEmailHotkeysEnabled` / `quickEmailRecipients` (the last three added so a contact hotkey set from anywhere takes effect live, not on next restart). Search ranking: the `searchSettings()` function in [settings-search-index.ts](../../src/renderer/src/features/settings/settings-search-index.ts) defines a `HOTKEY_PIN_EXACT_QUERIES` set; when the normalized query exactly matches one of `hotkey` / `hotkeys` / `hot key` / `hot keys` / `keyboard shortcut(s)` / `shortcut(s)`, the `keyboard-shortcuts` entry's score is bumped to 4 (above the usual 1-3 range) so it pins to the top of results.

**One dispatch per physical keypress.** Every keydown is de-duplicated at the very top of `useKeyboardShortcuts`'s handler — the event is tagged on first handling, and any repeat handling of the SAME physical press is ignored — so one press fires each action exactly once, even when the app is momentarily busy. This defends a rare failure mode where a UI stall could leave more than one keydown listener alive at once (the browser delivers the same event to each), which otherwise turned one press into several actions — most dangerously archive, which auto-advances the selection, so a burst could clear a run of sessions. It keys off the keystroke's IDENTITY, never a timing window, so two genuinely-separate presses are never merged and fast deliberate archiving/navigation is untouched. Kill switch `AMC_DISABLE_KEYDOWN_DEDUP=1`; full invariant (`per-event-dispatch-idempotency`) in [keyboard-shortcut-dispatch-contract.md](../../.claude/memory/contracts/keyboard-shortcut-dispatch-contract.md).



**Typing-guard — a shortcut fires the moment you act.** A text box can briefly lose focus mid-type (a re-render / freeze blurs the composer) and the still-incoming keystrokes leak to the window, where a bare **E** would archive or **Ctrl+Z** would undo — so for ~half a second (500 ms) after your last keystroke in a field the dispatcher treats a bare key as leaked typing and stands down. It **releases that guard the instant you do something deliberate**: a real click / tap, or a commit key (**Enter** to send, **Tab** to leave the field — but NOT a **Shift+Enter** newline or a mid-composition IME key). Both signals happen before focus leaves and never occur during an accidental focus-slip, so a hotkey you press right after an action fires on the FIRST press, while only a genuinely leaked keystroke stays suppressed. This is why the send→archive flow works with no double-tap: the composer disabling itself on send moves focus away with no blur to key off, so the release rides on the send keystroke itself. Full invariant (`typing-recency-guard`) in [keyboard-shortcut-dispatch-contract.md](../../.claude/memory/contracts/keyboard-shortcut-dispatch-contract.md).

**Reserved (universal) navigation.** The Go-to-Inbox + Ctrl+number switcher actions are dispatched at the very top of `useKeyboardShortcuts`'s keydown handler — before the modal gate and every sub-view yield — via `buildReservedNavLookup` (modifier chords only) in [keybindings.ts](../../src/shared/keybindings.ts), then `stopImmediatePropagation()` so no competing capture listener (e.g. the KMS panel) or editor binding (the KMS note editor's Ctrl+1 = task-list) can also fire. An embedded `<webview>` (the in-app browser, plugin panels, Help, Image Studio, Running Apps, and the isolated session view) is its own document the window listener never sees, so the Ctrl+number digit chords are ALSO forwarded out of each webview to the shell via a main-process `before-input-event` interceptor (`attachWebviewReservedNavForward` in [webview-nav-forward.ts](../../src/main/app/webview-nav-forward.ts), wired from the main window's `did-attach-webview`) — the slot-jump fires from inside those panels too (digits only; kill switch `AMC_DISABLE_WEBVIEW_NAV_FORWARD=1`). A `data-app-hard-gate` marker on the sign-in gate plus the onboarding wizard's `aria-labelledby="onboarding-title"` make those two screens stand the dispatcher down so universal nav can't skip sign-in or first-run setup. The invariant — universal + hard-gated, no sub-view may reclaim a reserved chord — is locked by a gate-blocking guard ([reserved-global-nav-universal.test.ts](../../tests/unit/lint/reserved-global-nav-universal.test.ts)) plus a behavioral suite fired from every sub-view ([universal-nav-hotkeys.test.tsx](../../tests/unit/hooks/universal-nav-hotkeys.test.tsx)). A SUBSET — the **inbox hotkeys** (`goToInbox` = Ctrl+Q, `jumpToProject1` = Ctrl+1) — is additionally an **un-overridable floor**: `buildReservedNavLookup` seeds their default chords FIRST, so clearing or remapping them in Settings can never remove them (a cleared inbox binding still fires Ctrl+Q). The Keybindings panel renders those pills **LOCKED** (`buildActionBindingRows` + `isSacredReservedNavBinding`, injected even when the stored override omits them), and `buildKmsBindingLookup` drops any `isSacredReservedNavChord` binding so no subapp capture listener can reclaim one. Ctrl+I is deliberately NOT in that set: it was dropped from `goToInbox`'s defaults because it is the universal italic chord, so Ctrl+I no longer navigates and KMS's shipped `editor.italic` = Ctrl+I works normally. The sibling project jumps (Ctrl+2..0) stay fully rebindable. Full detail in [keyboard-shortcut-dispatch-contract.md](../../.claude/memory/contracts/keyboard-shortcut-dispatch-contract.md).



**Undo (Ctrl+Z) — the focused text editor owns it.** `undoLastAction` runs the app-level undo (`useUndoStore.performUndo()`, a bounded multi-level history in [undo-store.ts](../../src/renderer/src/stores/undo-store.ts)) — but ONLY when focus is NOT in a text box. Inside ANY editable field (a message composer, search box, settings input, a code editor) the field owns Ctrl+Z: `undoLastAction` is a plain non-passthrough shortcut with no in-input firing rule ([fires-in.ts](../../src/renderer/src/hooks/keyboard-shortcuts/fires-in.ts)), so the input guard stands it down and the field's own undo runs — a text box behaves like a normal editor and Ctrl+Z never fires the app-wide undo (owner request, 2026-08-06; it used to hijack Ctrl+Z over an empty/pristine field). React controlled `<textarea>` composers destroy native undo, so each keeps its OWN snapshot stack via the shared `useTextUndoRedo` + `handleTextUndoRedoKeydown` building block ([text-undo-redo-keydown-contract.md](../../.claude/memory/contracts/text-undo-redo-keydown-contract.md)) — including the team-chat and Slack message boxes; the session composer additionally falls back to the app undo when its own text stack is empty. Bare `Z` is not an undo binding (removed — a stray "z" used to revert silently). Full invariants: [session-action-undo-contract.md](../../.claude/memory/contracts/session-action-undo-contract.md).

**Open-first-link hover hint.** The first link the **Ctrl+Shift+Q** handler (`openFirstLinkInLastResponse` in [conversation.ts](../../src/renderer/src/hooks/keyboard-shortcuts/conversation.ts)) would open also advertises the shortcut on hover. The hint and the handler share one set of DOM selectors exported from [useOpenFirstLinkHint.ts](../../src/renderer/src/hooks/useOpenFirstLinkHint.ts) — the latest reply's bubble (`data-last-agent-message`), a reading pane's body (`data-open-link-region`), or the **focused Tasks row's body** (`data-task-focused-link`), then the first link inside — so the tooltip can never point at a link the key wouldn't open. The focused task is checked **first** (so a session pane shown beside the task list can't steal the key) and opens its first `a[href]` via the validated `openExternalUrl`; the reply / reading-pane regions click the first `a, [data-preview-link]`. Whether a link is in scope is supplied through an `OpenFirstLinkScopeContext` set per message bubble (`value={isLastAgent}`), so the flag flips _through_ `React.memo` the instant a newer reply lands — a pure-DOM check would leave a stale "open me" hint on the previous reply's link. The label reads the live resolved binding (so it follows a rebind and renders nothing when the binding is cleared), and links outside any target region pay nothing because both hooks no-op. Links elsewhere render through the shared [`<Tooltip>`](../../src/renderer/src/components/ui/Tooltip.tsx) only when in scope, so this added no new raw `title=` attributes. When **Supermail** is the active surface the same handler instead **delegates**: it fires a `supermail-open-first-link` window event whose Supermail app-root listener ([use-amc-open-first-link-bridge.ts](../../src/plugins/supermail/ui/src/features/inbox/use-amc-open-first-link-bridge.ts)) opens the first http(s) link of the email being read (the focused message, via Supermail's own `extractMessageLinks`). The email body renders in a sandboxed iframe the host DOM-scan can't read, which is why this surface delegates rather than DOM-clicking — and carries no hover hint.

**Panel-specific keyboards are editable too.** Two surfaces run their own keyboards alongside this app dispatcher — the **KMS Notes** panel + rich-text editor, and **Tasks** — and both are now fully rebindable from their own tabs in **Settings → Keyboard Shortcuts** (the **KMS Notes** tab's "Panel commands" / "Editor formatting" groups, and the **Tasks** tab once that feature is enabled). A build-failing guard ([editable-hotkeys-completeness.test.ts](../../tests/unit/lint/editable-hotkeys-completeness.test.ts)) keeps every command hotkey editable — each renderer keyboard resolves its keys from a registry, and any new TipTap `addKeyboardShortcuts()` or OS `globalShortcut.register()` keymap must be classified (registry-driven vs structural). Full invariant: [editable-hotkey-coverage-contract.md](../../.claude/memory/contracts/editable-hotkey-coverage-contract.md).

**Tabbed-dialog tab cycling.** Ctrl+Tab / Ctrl+Shift+Tab in a tabbed dialog (or across the Settings sections) is driven by [useTabCycleShortcut.ts](../../src/renderer/src/hooks/useTabCycleShortcut.ts) — a document-level listener the shared [DialogShell](../../src/renderer/src/components/ui/DialogShell.tsx) runs via its optional `tabCycle` prop, active only while the dialog is the topmost open one (`isOpen && !paused`); Settings calls the hook directly with `yieldToModal` so it stands down while any dialog is open on top. It consumes the key (`preventDefault` + `stopImmediatePropagation`) so it can't leak to the global `Tab` = next-session binding, handles Ctrl/Cmd only (never Alt, never the reserved Ctrl+1..9), and [useFocusTrap](../../src/renderer/src/hooks/useFocusTrap.ts) ignores a modified Tab so it never wraps focus on the chord. Full invariant: [frontend-modal-contract.md](../../.claude/memory/contracts/frontend-modal-contract.md) `ctrl-tab-cycles-tabs`.

## Related

The bindings themselves are in part 1, the [Keyboard shortcuts](keyboard-shortcuts.md) page, with sessions and composer in [part 2](keyboard-shortcuts-part-2.md) and navigation, search and display in [part 3](keyboard-shortcuts-part-3.md).
