|
|
|
@@ -0,0 +1,472 @@
|
|
|
|
|
import { useEffect, useRef } from 'react'
|
|
|
|
|
import { useLocation, useNavigate } from 'react-router'
|
|
|
|
|
|
|
|
|
|
import { closeActiveTab } from '@/app/chat/close-tab'
|
|
|
|
|
import { hudTargetSessionId } from '@/app/hud/handoff'
|
|
|
|
|
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
|
|
|
|
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
|
|
|
|
|
import { appViewForPath, isOverlayView } from '@/app/routes'
|
|
|
|
|
import {
|
|
|
|
|
activateTreeTabSlot,
|
|
|
|
|
cycleTreeTabInFocusedZone,
|
|
|
|
|
isPaneVisible,
|
|
|
|
|
layoutHasRootSide,
|
|
|
|
|
togglePaneVisible,
|
|
|
|
|
toggleTargetZoneTabStrip
|
|
|
|
|
} from '@/components/pane-shell/tree/store'
|
|
|
|
|
import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope'
|
|
|
|
|
import { onReleaseTypingFocus } from '@/components/ui/keyboard-first'
|
|
|
|
|
import { findBarClaimsCombo } from '@/lib/find-in-page'
|
|
|
|
|
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
|
|
|
|
import { actionAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
|
|
|
|
import { composerFocusKeysAllowed, isComposerFocusSoftCombo, typeToFocusChar } from '@/lib/keybinds/composer-focus-keys'
|
|
|
|
|
import { openWorktreeDialog } from '@/store/coding-status'
|
|
|
|
|
import { $commandPaletteOpen, openCommandPalettePage, toggleCommandPalette } from '@/store/command-palette'
|
|
|
|
|
import {
|
|
|
|
|
$findInPage,
|
|
|
|
|
findNext as findNextMatch,
|
|
|
|
|
findPrevious as findPreviousMatch,
|
|
|
|
|
openFindBar
|
|
|
|
|
} from '@/store/find-in-page'
|
|
|
|
|
import { toggleHud } from '@/store/hud'
|
|
|
|
|
import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds'
|
|
|
|
|
import {
|
|
|
|
|
requestSessionSearchFocus,
|
|
|
|
|
setFileBrowserOpen,
|
|
|
|
|
toggleFileBrowserOpen,
|
|
|
|
|
togglePanesFlipped,
|
|
|
|
|
toggleSidebarOpen
|
|
|
|
|
} from '@/store/layout'
|
|
|
|
|
import { openBrowserTab } from '@/store/preview'
|
|
|
|
|
import {
|
|
|
|
|
$newChatProfile,
|
|
|
|
|
cycleProfile,
|
|
|
|
|
requestProfileCreate,
|
|
|
|
|
switchProfileToSlot,
|
|
|
|
|
switchToDefaultProfile,
|
|
|
|
|
toggleShowAllProfiles
|
|
|
|
|
} from '@/store/profile'
|
|
|
|
|
import { openFolderAsProject } from '@/store/projects'
|
|
|
|
|
import { toggleReview } from '@/store/review'
|
|
|
|
|
import { $selectedStoredSessionId, setModelPickerOpen } from '@/store/session'
|
|
|
|
|
import { reopenLastClosedTile } from '@/store/session-states'
|
|
|
|
|
import {
|
|
|
|
|
$switcherOpen,
|
|
|
|
|
closeSwitcher,
|
|
|
|
|
commitOnCtrlUp,
|
|
|
|
|
onSwitcherTabDown,
|
|
|
|
|
onSwitcherTabUp,
|
|
|
|
|
openOrAdvanceSwitcher,
|
|
|
|
|
slotSessionId,
|
|
|
|
|
switcherActive,
|
|
|
|
|
switcherJustClosed
|
|
|
|
|
} from '@/store/session-switcher'
|
|
|
|
|
import { toggleStatusbarVisible } from '@/store/statusbar-prefs'
|
|
|
|
|
import { openNewWindow } from '@/store/windows'
|
|
|
|
|
import { useTheme } from '@/themes/context'
|
|
|
|
|
|
|
|
|
|
import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus'
|
|
|
|
|
import { handleComposerFocusChord } from '../chat/composer/focus-chord'
|
|
|
|
|
import { handleWindowPaste } from '../chat/composer/paste-to-focus'
|
|
|
|
|
import { openSession } from '../open-session'
|
|
|
|
|
import {
|
|
|
|
|
$workspaceIsPage,
|
|
|
|
|
AGENTS_ROUTE,
|
|
|
|
|
ARTIFACTS_ROUTE,
|
|
|
|
|
CRON_ROUTE,
|
|
|
|
|
MESSAGING_ROUTE,
|
|
|
|
|
navigateToWorkspacePage,
|
|
|
|
|
NEW_CHAT_ROUTE,
|
|
|
|
|
PROFILES_ROUTE,
|
|
|
|
|
sessionRoute,
|
|
|
|
|
SETTINGS_ROUTE,
|
|
|
|
|
SKILLS_ROUTE
|
|
|
|
|
} from '../routes'
|
|
|
|
|
|
|
|
|
|
export interface KeybindRuntimeDeps {
|
|
|
|
|
/** Open/close the command center overlay (sessions / system / usage). */
|
|
|
|
|
toggleCommandCenter: () => void
|
|
|
|
|
/** Drop to a fresh new-session draft. */
|
|
|
|
|
startFreshSession: () => void
|
|
|
|
|
/** Open a fresh session as a tab in the main zone (⌘T), leaving the primary. */
|
|
|
|
|
openNewSessionTab: () => void
|
|
|
|
|
/** Pin/unpin the active session. */
|
|
|
|
|
toggleSelectedPin: () => void
|
|
|
|
|
/** Archive the active session. */
|
|
|
|
|
archiveSelectedSession: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type HandlerMap = Record<string, () => void>
|
|
|
|
|
|
|
|
|
|
// Mount once near the top of the app. Owns the single global keydown listener
|
|
|
|
|
// for every rebindable hotkey: it runs the matched action, or — while capture
|
|
|
|
|
// mode is active (edit overlay / panel rebind) — records the pressed combo.
|
|
|
|
|
export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
const location = useLocation()
|
|
|
|
|
const { resolvedMode, setMode } = useTheme()
|
|
|
|
|
|
|
|
|
|
// Keep the latest closures without re-subscribing the listener.
|
|
|
|
|
const handlersRef = useRef<HandlerMap>({})
|
|
|
|
|
const commitSwitcherRef = useRef<() => void>(() => {})
|
|
|
|
|
|
|
|
|
|
const profileSwitchHandlers: HandlerMap = {}
|
|
|
|
|
|
|
|
|
|
// A tab key that lands on the WORKSPACE tab while a full page (skills /
|
|
|
|
|
// messaging / artifacts / a plugin route) covers it must also route back to
|
|
|
|
|
// the chat: the workspace pane is already the zone's active tab behind the
|
|
|
|
|
// page, so fronting it alone changes nothing on screen and the key reads
|
|
|
|
|
// dead. Mirrors `openSession`'s full-page rule — only a route change puts
|
|
|
|
|
// the chat back.
|
|
|
|
|
const leavePageForWorkspaceChat = (paneId: null | string) => {
|
|
|
|
|
if (paneId === 'workspace' && $workspaceIsPage.get()) {
|
|
|
|
|
const selected = $selectedStoredSessionId.get()
|
|
|
|
|
|
|
|
|
|
navigate(selected ? sessionRoute(selected) : NEW_CHAT_ROUTE)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) {
|
|
|
|
|
// ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a
|
|
|
|
|
// single-pane (or unfocused) layout falls through to the profile switch.
|
|
|
|
|
profileSwitchHandlers[`profile.switch.${slot}`] = () => {
|
|
|
|
|
const pane = activateTreeTabSlot(slot)
|
|
|
|
|
|
|
|
|
|
if (pane) {
|
|
|
|
|
leavePageForWorkspaceChat(pane)
|
|
|
|
|
} else {
|
|
|
|
|
switchProfileToSlot(slot)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const goToSession = (sessionId: null | string) => {
|
|
|
|
|
if (sessionId) {
|
|
|
|
|
openSession(sessionId, navigate)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ^N jumps straight to the Nth recent session and dismisses the switcher.
|
|
|
|
|
const sessionSlotHandlers: HandlerMap = {}
|
|
|
|
|
|
|
|
|
|
for (let slot = 1; slot <= SESSION_SLOT_COUNT; slot += 1) {
|
|
|
|
|
sessionSlotHandlers[`session.slot.${slot}`] = () => {
|
|
|
|
|
closeSwitcher()
|
|
|
|
|
goToSession(slotSessionId(slot))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
commitSwitcherRef.current = () => goToSession(commitOnCtrlUp())
|
|
|
|
|
|
|
|
|
|
const stepSession = (direction: 1 | -1) => {
|
|
|
|
|
onSwitcherTabDown()
|
|
|
|
|
goToSession(openOrAdvanceSwitcher(direction))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
|
|
|
|
|
// falls through to the recent-session switcher. Landing on the workspace
|
|
|
|
|
// under a full page routes back to the chat (same as ⌘1).
|
|
|
|
|
const cycleTab = (direction: 1 | -1) => {
|
|
|
|
|
const pane = cycleTreeTabInFocusedZone(direction)
|
|
|
|
|
|
|
|
|
|
if (pane) {
|
|
|
|
|
leavePageForWorkspaceChat(pane)
|
|
|
|
|
} else {
|
|
|
|
|
stepSession(direction)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const showFiles = () => {
|
|
|
|
|
setFileBrowserOpen(true)
|
|
|
|
|
setTerminalTakeover(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handlersRef.current = {
|
|
|
|
|
'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`),
|
|
|
|
|
|
|
|
|
|
'composer.focus': () => requestComposerFocus('active'),
|
|
|
|
|
// Toggle the composer pill's live model dropdown (pane under the pointer,
|
|
|
|
|
// else active composer); no chat surface on screen → the full dialog.
|
|
|
|
|
'composer.modelPicker': () => {
|
|
|
|
|
if (!requestModelMenuToggle()) {
|
|
|
|
|
setModelPickerOpen(true)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
'composer.voice': requestVoiceToggle,
|
|
|
|
|
|
|
|
|
|
// On the Settings overlay, ⌘K scopes to settings search; the second press
|
|
|
|
|
// (or Esc) still closes as usual via toggle.
|
|
|
|
|
'nav.commandPalette': () => {
|
|
|
|
|
if (!$commandPaletteOpen.get() && appViewForPath(location.pathname) === 'settings') {
|
|
|
|
|
openCommandPalettePage('settings')
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleCommandPalette()
|
|
|
|
|
},
|
|
|
|
|
'nav.commandCenter': deps.toggleCommandCenter,
|
|
|
|
|
'nav.settings': () => navigate(SETTINGS_ROUTE),
|
|
|
|
|
'nav.profiles': () => navigate(PROFILES_ROUTE),
|
|
|
|
|
'nav.skills': () => navigateToWorkspacePage(navigate, SKILLS_ROUTE),
|
|
|
|
|
'nav.messaging': () => navigateToWorkspacePage(navigate, MESSAGING_ROUTE),
|
|
|
|
|
'nav.artifacts': () => navigateToWorkspacePage(navigate, ARTIFACTS_ROUTE),
|
|
|
|
|
'nav.cron': () => navigate(CRON_ROUTE),
|
|
|
|
|
'nav.agents': () => navigate(AGENTS_ROUTE),
|
|
|
|
|
|
|
|
|
|
'session.new': () => {
|
|
|
|
|
// Match the sidebar New Session button. A plain keyboard new chat should
|
|
|
|
|
// target the current live profile, not a stale per-profile quick-create
|
|
|
|
|
// selection from a prior action.
|
|
|
|
|
setWorkspaceScope('sessions')
|
|
|
|
|
$newChatProfile.set(null)
|
|
|
|
|
deps.startFreshSession()
|
|
|
|
|
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
|
|
|
|
},
|
|
|
|
|
'session.newTab': () => deps.openNewSessionTab(),
|
|
|
|
|
'session.newWindow': () => void openNewWindow(),
|
|
|
|
|
'session.next': () => cycleTab(1),
|
|
|
|
|
'session.prev': () => cycleTab(-1),
|
|
|
|
|
...sessionSlotHandlers,
|
|
|
|
|
'session.focusSearch': requestSessionSearchFocus,
|
|
|
|
|
'session.togglePin': deps.toggleSelectedPin,
|
|
|
|
|
'session.archive': deps.archiveSelectedSession,
|
|
|
|
|
// openWorktreeDialog resolves the target. There is no test for a repo
|
|
|
|
|
// here, so the key works from a detached session that sits inside a
|
|
|
|
|
// project, and not only from a session with a repo. When no repo is in
|
|
|
|
|
// reach, openWorktreeDialog does nothing.
|
|
|
|
|
'workspace.newWorktree': () => void openWorktreeDialog(),
|
|
|
|
|
// ⌘O: native folder picker → open the folder as a project (upsert) with a
|
|
|
|
|
// fresh session anchored there.
|
|
|
|
|
'workspace.openFolder': () => void openFolderAsProject(),
|
|
|
|
|
|
|
|
|
|
// Narrow-viewport reveal is handled inside the store toggles now.
|
|
|
|
|
'view.toggleSidebar': toggleSidebarOpen,
|
|
|
|
|
// ⌘J toggles the right sidebar — but a layout with no right side (e.g.
|
|
|
|
|
// terminal-on-bottom) would leave it a dead key, so it falls back to the
|
|
|
|
|
// terminal there. The single "secondary panel" toggle.
|
|
|
|
|
'view.toggleRightSidebar': () =>
|
|
|
|
|
layoutHasRootSide('right') ? toggleFileBrowserOpen() : togglePaneVisible('terminal'),
|
|
|
|
|
'view.toggleReview': toggleReview,
|
|
|
|
|
'view.toggleStatusbar': toggleStatusbarVisible,
|
|
|
|
|
'view.toggleTabStrip': () => void toggleTargetZoneTabStrip(),
|
|
|
|
|
'view.showFiles': showFiles,
|
|
|
|
|
'view.showBrowser': openBrowserTab,
|
|
|
|
|
'view.toggleHud': () => toggleHud(hudTargetSessionId()),
|
|
|
|
|
'view.showTerminal': () => togglePaneVisible('terminal'),
|
|
|
|
|
// Create first so the pane's open-effect ensure sees a non-empty set and
|
|
|
|
|
// doesn't also spawn one — net effect is exactly one fresh terminal.
|
|
|
|
|
'view.newTerminal': () => {
|
|
|
|
|
createTerminal()
|
|
|
|
|
setTerminalTakeover(true)
|
|
|
|
|
},
|
|
|
|
|
// Switch / close only act while the terminal is actually ON SCREEN — ask
|
|
|
|
|
// the tree, not the toggle store (which stays true behind a stacked
|
|
|
|
|
// sibling tab or a minimized zone).
|
|
|
|
|
'view.nextTerminal': () => isPaneVisible('terminal') && cycleTerminal(1),
|
|
|
|
|
'view.prevTerminal': () => isPaneVisible('terminal') && cycleTerminal(-1),
|
|
|
|
|
'view.closeTerminal': () => isPaneVisible('terminal') && closeActiveTerminal(),
|
|
|
|
|
'view.flipPanes': togglePanesFlipped,
|
|
|
|
|
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
|
|
|
|
|
// On the main tab with session tabs stacked, it shifts the next one in —
|
|
|
|
|
// the loader navigates to that session's route (loads it into main). On
|
|
|
|
|
// macOS the menu accelerator owns ⌘W and routes through the same
|
|
|
|
|
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
|
|
|
|
|
// the Win/Linux path where ⌘W reaches the renderer directly.
|
|
|
|
|
'view.closeTab': () => void closeActiveTab(id => navigate(sessionRoute(id))),
|
|
|
|
|
'view.reopenTab': reopenLastClosedTile,
|
|
|
|
|
'view.findInPage': () => {
|
|
|
|
|
// Suppress on overlay routes so it doesn't collide with overlay-specific
|
|
|
|
|
// search surfaces (e.g. Settings search bar).
|
|
|
|
|
if (!isOverlayView(appViewForPath(location.pathname))) {
|
|
|
|
|
openFindBar()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
// ⌘G / ⌘⇧G are handled by the find bar's own capture-phase listener while
|
|
|
|
|
// it is open (so they don't collide with `view.toggleReview`). These
|
|
|
|
|
// registry handlers cover a user-assigned dedicated chord: stepping is a
|
|
|
|
|
// no-op unless the bar is open with a query, so a bound key can't search
|
|
|
|
|
// invisibly.
|
|
|
|
|
'view.findNext': findNextMatch,
|
|
|
|
|
'view.findPrevious': findPreviousMatch,
|
|
|
|
|
|
|
|
|
|
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
|
|
|
|
|
|
|
|
|
'profile.default': switchToDefaultProfile,
|
|
|
|
|
...profileSwitchHandlers,
|
|
|
|
|
'profile.next': () => cycleProfile(1),
|
|
|
|
|
'profile.prev': () => cycleProfile(-1),
|
|
|
|
|
'profile.toggleAll': toggleShowAllProfiles,
|
|
|
|
|
'profile.create': requestProfileCreate
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A keyboard-driven overlay closing hands typing back to the composer: Radix
|
|
|
|
|
// restores focus to the trigger (a toolbar button for the model pill), so
|
|
|
|
|
// without this the Enter that committed a model also eats the next keystroke.
|
|
|
|
|
// Deferred one frame and skipped when something else editable has claimed
|
|
|
|
|
// focus, because a palette action can legitimately open a dialog or navigate
|
|
|
|
|
// — the release must never steal focus from the surface it just opened.
|
|
|
|
|
useEffect(
|
|
|
|
|
() =>
|
|
|
|
|
onReleaseTypingFocus(() =>
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (!isEditableTarget(document.activeElement)) {
|
|
|
|
|
requestComposerFocus('active')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
[]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
// An active IME composition owns the keyboard. Windows Chinese IMEs
|
|
|
|
|
// (Microsoft Pinyin, Sogou) use Ctrl+, as their punctuation-mode toggle,
|
|
|
|
|
// so without this guard that keystroke ALSO matched `nav.settings` and
|
|
|
|
|
// navigated away mid-word — unmounting the composer and destroying the
|
|
|
|
|
// unsent draft (#41079). The draft stash below makes navigation safe;
|
|
|
|
|
// this makes the IME keystroke not navigate at all.
|
|
|
|
|
if (event.isComposing) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Capture mode: the next real key becomes the binding. Swallow everything
|
|
|
|
|
// so e.g. ⌘K rebinds instead of opening the palette.
|
|
|
|
|
const capturing = $capture.get()
|
|
|
|
|
|
|
|
|
|
if (capturing) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
|
|
|
|
|
if (event.key === 'Escape') {
|
|
|
|
|
endCapture()
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const combo = comboFromEvent(event)
|
|
|
|
|
|
|
|
|
|
if (!combo) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setBinding(capturing, [combo])
|
|
|
|
|
endCapture()
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// While the session switcher is up, Esc abandons it (stay put) before any
|
|
|
|
|
// combo dispatch — ⌃Tab keeps stepping through the existing handler.
|
|
|
|
|
if (switcherActive() && event.key === 'Escape') {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
closeSwitcher()
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const combo = comboFromEvent(event)
|
|
|
|
|
|
|
|
|
|
if (!combo) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The open find bar owns ⌘G / ⌘⇧G / Escape. Its own capture-phase
|
|
|
|
|
// listener runs those actions; bail here so the registry doesn't ALSO
|
|
|
|
|
// fire the action bound to the same combo (⌘G = view.toggleReview,
|
|
|
|
|
// Escape = composer.cancel, which would abort a live turn). Both
|
|
|
|
|
// listeners are on `window`, so stopPropagation in the bar can't
|
|
|
|
|
// suppress this one — the dispatcher has to yield explicitly.
|
|
|
|
|
if ($findInPage.get().active && findBarClaimsCombo(combo)) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const actionId = $comboIndex.get().get(combo)
|
|
|
|
|
|
|
|
|
|
// Unbound printable → type-to-focus. Bound chords (shift+n, …) win above.
|
|
|
|
|
if (!actionId) {
|
|
|
|
|
const typeChar = typeToFocusChar(event)
|
|
|
|
|
|
|
|
|
|
if (typeChar && composerFocusKeysAllowed(event, 'type')) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
requestComposerFocus('active', { typeChar })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isEditableTarget(event.target) && !actionAllowedInInput(actionId, combo)) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Soft `/` / Enter: gated so dialogs/buttons/terminal keep those keys.
|
|
|
|
|
// Rebound chords fall through to the normal handler.
|
|
|
|
|
if (actionId === 'composer.focus' && isComposerFocusSoftCombo(combo)) {
|
|
|
|
|
if (!composerFocusKeysAllowed(event, combo)) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
requestComposerFocus('active', { typeChar: combo === '/' ? '/' : undefined })
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Built-in handlers first (they carry React context); contributed
|
|
|
|
|
// actions bring their own `run` through the registry.
|
|
|
|
|
const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId)
|
|
|
|
|
|
|
|
|
|
if (!handler) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
handler()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mac-app-switcher commit: lifting Ctrl with the overlay open lands on the
|
|
|
|
|
// highlighted session. A window blur (Cmd+Tab away mid-switch) cancels so
|
|
|
|
|
// the overlay never gets stranded waiting for a keyup that never comes.
|
|
|
|
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
|
|
|
if (event.key === 'Tab') {
|
|
|
|
|
onSwitcherTabUp()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.key === 'Control') {
|
|
|
|
|
commitSwitcherRef.current()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onBlur = () => switcherActive() && closeSwitcher()
|
|
|
|
|
|
|
|
|
|
// Swallow trailing contextmenu after Ctrl+click commit (Electron main menu).
|
|
|
|
|
const onContextMenu = (event: MouseEvent) => {
|
|
|
|
|
if ($switcherOpen.get() || switcherJustClosed()) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.addEventListener('keydown', onKeyDown, { capture: true })
|
|
|
|
|
window.addEventListener('keyup', onKeyUp, { capture: true })
|
|
|
|
|
window.addEventListener('blur', onBlur)
|
|
|
|
|
window.addEventListener('contextmenu', onContextMenu, { capture: true })
|
|
|
|
|
// Paste twin of type-to-focus: ⌘V on non-editable chrome routes the
|
|
|
|
|
// clipboard (text AND images) into the active composer. Bubble phase so
|
|
|
|
|
// editables' own paste handlers run first and mark the event handled.
|
|
|
|
|
window.addEventListener('paste', handleWindowPaste)
|
|
|
|
|
// ⌘/Ctrl+L moves focus to the composer. Bubble phase so capture-phase
|
|
|
|
|
// claimants run first; the priority ladder lives in focus-chord.ts.
|
|
|
|
|
window.addEventListener('keydown', handleComposerFocusChord)
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
|
|
|
|
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
|
|
|
|
window.removeEventListener('blur', onBlur)
|
|
|
|
|
window.removeEventListener('contextmenu', onContextMenu, { capture: true })
|
|
|
|
|
window.removeEventListener('paste', handleWindowPaste)
|
|
|
|
|
window.removeEventListener('keydown', handleComposerFocusChord)
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
}
|