Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

Keyboard shortcuts (every binding, and how to change them) (part 4)

Part 4 of the keyboard shortcuts reference: how the binding system actually works underneath — what happens when you press a key, how a binding is registered and checked for conflicts, and how the one-handed layout and training mode reuse the same machinery.

What it is

This is part 4 of the Keyboard shortcuts 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 — 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 — 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 (lazy-loaded) and reads the same resolved bindings from 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) 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 — 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) 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 + 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) 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. The opt-in one-handed layout is ONE_HANDED_PRESET + applyOneHandedPreset() in 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. It uses eventToBindingString() from 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 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. Every system-wide recorder (AcceleratorListField, QuickLaunchTabHotkeyField, the Quick Launch / Quick Email / Quick Chat hotkey inputs) turns a keypress into an Electron accelerator through ONE function, acceleratorFromKeyboardEvent() in keyboard-accelerator.ts: off macOS metaKey is the Windows key and is recorded as Super (on macOS it is Cmd → CommandOrControl). Each recorder input spreads HOTKEY_RECORDER_PROPS, and a pre-lookup row in surface-yields.ts stands the in-app dispatcher down while one is focused — otherwise a passthrough-in-inputs binding fires first (the in-app matcher reads Win as Ctrl, so Win+Shift+K opened Super Prompts and cancelled the capture). The renderer-vs-global shadow check (canonicalizeHotkey in global-hotkey-conflicts.ts) keeps Super/Meta as its own Win modifier, so a Win hotkey never "shadows" an in-app Ctrl binding. Conflicts among the enabled global hotkeys are computed by detectGlobalHotkeyConflicts() in 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) — 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 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 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 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.

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.

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, 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, 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) plus a behavioral suite fired from every sub-view (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.

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) — 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), 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) — 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.

Open-first-link hover hint. The first link the Ctrl+Shift+Q handler (openFirstLinkInLastResponse in 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 — 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> 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) 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) 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.

Tabbed-dialog tab cycling. Ctrl+Tab / Ctrl+Shift+Tab in a tabbed dialog (or across the Settings sections) is driven by useTabCycleShortcut.ts — a document-level listener the shared DialogShell 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 ignores a modified Tab so it never wraps focus on the chord. Full invariant: frontend-modal-contract.md ctrl-tab-cycles-tabs.

Related

The bindings themselves are in part 1, the Keyboard shortcuts page, with sessions and composer in part 2 and navigation, search and display in part 3.

Last verified 2026-09-28