-
Keyboard Shortcuts
+
+
Keyboard Shortcuts
+
+
+
fileInputRef.current?.click()}>
+ Import from OpenCode TUI
+
+
+
+ Select your OpenCode cli.json (or legacy tui.json), usually in ~/.config/opencode.
+
+
+
@@ -316,7 +384,7 @@ export function KeyboardShortcuts() {
- Click on any shortcut to record a new key combination. Click on the status text below each action to toggle whether it requires the leader key.
+ Click on any shortcut to record a new key combination. Press the leader key first to record a leader shortcut, or press a combination directly to record a direct one. Click on the status text below each action to toggle whether it requires the leader key.
)
diff --git a/frontend/src/components/ui/bottom-sheet.test.tsx b/frontend/src/components/ui/bottom-sheet.test.tsx
index 0e3ef98ed..77e94f03e 100644
--- a/frontend/src/components/ui/bottom-sheet.test.tsx
+++ b/frontend/src/components/ui/bottom-sheet.test.tsx
@@ -1,5 +1,7 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { useState } from 'react'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { stubMatchMedia } from '@/test/test-utils'
import { BottomSheet, BottomSheetHeader, BottomSheetContent } from './bottom-sheet'
import { useSwipeDismiss } from '@/hooks/useMobile'
@@ -11,6 +13,33 @@ vi.mock('@/hooks/useMobile', () => ({
})),
}))
+function renderSheet(isOpen: boolean) {
+ return render(
+ <>
+
+
{}} ariaLabel="Test sheet">
+ inside
+
+ >,
+ )
+}
+
+function AutoFocusSheetHost() {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+
setOpen(true)}>open sheet
+
setOpen(false)} ariaLabel="Auto focus sheet">
+
+
+ >
+ )
+}
+
+afterEach(() => {
+ Reflect.deleteProperty(window, 'matchMedia')
+})
+
describe('BottomSheet', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -132,6 +161,101 @@ describe('BottomSheet', () => {
expect(mockBind).toHaveBeenCalled()
})
+
+ it('moves focus to the chat prompt on desktop when it closes', () => {
+ stubMatchMedia(true)
+ const { rerender } = renderSheet(false)
+
+ rerender(
+ <>
+
+
{}} ariaLabel="Test sheet">
+ inside
+
+ >,
+ )
+ screen.getByRole('button', { name: 'inside' }).focus()
+
+ rerender(
+ <>
+
+
{}} ariaLabel="Test sheet">
+ inside
+
+ >,
+ )
+
+ expect(screen.getByLabelText('prompt')).toHaveFocus()
+ })
+
+ it('does not refocus the chat prompt on touch when it closes', () => {
+ stubMatchMedia(false)
+ const { rerender } = renderSheet(false)
+
+ rerender(
+ <>
+
+
{}} ariaLabel="Test sheet">
+ inside
+
+ >,
+ )
+ screen.getByRole('button', { name: 'inside' }).focus()
+
+ rerender(
+ <>
+
+
{}} ariaLabel="Test sheet">
+ inside
+
+ >,
+ )
+
+ expect(screen.getByLabelText('prompt')).not.toHaveFocus()
+ })
+
+ it('restores focus to the opener when an autoFocus child takes focus on open', async () => {
+ stubMatchMedia(true)
+ render(
)
+ const opener = screen.getByRole('button', { name: 'open sheet' })
+ opener.focus()
+
+ fireEvent.click(opener)
+ expect(screen.getByLabelText('sheet input')).toHaveFocus()
+
+ fireEvent.keyDown(document, { key: 'Escape' })
+
+ await waitFor(() => expect(opener).toHaveFocus())
+ })
+
+ it('captures the opener when mounted already open and restores it on close', () => {
+ stubMatchMedia(true)
+ const { rerender } = render(
+
opener ,
+ )
+ const opener = screen.getByRole('button', { name: 'opener' })
+ opener.focus()
+
+ rerender(
+ <>
+
opener
+
{}} ariaLabel="Mounted open sheet">
+ inside
+
+ >,
+ )
+
+ rerender(
+ <>
+
opener
+
{}} ariaLabel="Mounted open sheet">
+ inside
+
+ >,
+ )
+
+ expect(opener).toHaveFocus()
+ })
})
describe('BottomSheetHeader', () => {
diff --git a/frontend/src/components/ui/bottom-sheet.tsx b/frontend/src/components/ui/bottom-sheet.tsx
index fb2aa5a7d..14b7fa6fc 100644
--- a/frontend/src/components/ui/bottom-sheet.tsx
+++ b/frontend/src/components/ui/bottom-sheet.tsx
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { MODAL_TRANSITION_MS } from '@/lib/utils'
import { useSwipeDismiss } from '@/hooks/useMobile'
+import { useRestoreFocusOnClose } from '@/hooks/useRestoreFocusOnClose'
export interface BottomSheetProps {
isOpen: boolean
@@ -24,6 +25,8 @@ export function BottomSheet({
const [shouldRender, setShouldRender] = useState(false)
const panelRef = useRef
(null)
+ useRestoreFocusOnClose(isOpen, panelRef)
+
const { bind, swipeStyles } = useSwipeDismiss(onClose, {
enabled: isOpen,
threshold: 60,
@@ -86,6 +89,7 @@ export function BottomSheet({
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
+ data-state={isOpen ? 'open' : 'closed'}
style={swipeStyles}
>
diff --git a/frontend/src/components/ui/dialog.test.tsx b/frontend/src/components/ui/dialog.test.tsx
index 95b8c72e2..2301ecb02 100644
--- a/frontend/src/components/ui/dialog.test.tsx
+++ b/frontend/src/components/ui/dialog.test.tsx
@@ -441,6 +441,22 @@ function ReturnFocusHost({ onCloseAutoFocus }: { onCloseAutoFocus?: (event: Even
)
}
+function PromptReturnFocusHost() {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+
+
setOpen(true)}>open prompt dialog
+
+
+ Prompt Focus Dialog
+
+
+
+ >
+ )
+}
+
async function openAndEscape(returnTarget: HTMLElement) {
returnTarget.focus()
fireEvent.click(screen.getByText('open'))
@@ -495,6 +511,22 @@ describe('Dialog return focus', () => {
expect(prompt).not.toHaveFocus()
})
+
+ it('moves focus to the chat prompt when the dialog closes on desktop', async () => {
+ stubMatchMedia(true)
+ render(
)
+ const opener = screen.getByText('open prompt dialog')
+ opener.focus()
+
+ fireEvent.click(opener)
+ const input = await screen.findByLabelText('prompt dialog input')
+ await waitFor(() => expect(input).toHaveFocus())
+ fireEvent.keyDown(input, { key: 'Escape' })
+ await waitFor(() => expect(screen.queryByLabelText('prompt dialog input')).not.toBeInTheDocument())
+ await act(() => new Promise((resolve) => setTimeout(resolve, 0)))
+
+ expect(screen.getByLabelText('chat prompt')).toHaveFocus()
+ })
})
function stubVisualViewport(height: number) {
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
index 9e49fecaa..fb784690b 100644
--- a/frontend/src/components/ui/dialog.tsx
+++ b/frontend/src/components/ui/dialog.tsx
@@ -5,23 +5,11 @@ import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useSwipeBack } from '@/hooks/useMobile'
import { useVisualViewport } from '@/hooks/useVisualViewport'
-import { FINE_POINTER_MEDIA_QUERY } from '@/hooks/useMediaQuery'
+import { getFocusedElement, restoreOverlayFocus } from '@/lib/overlayFocus'
const DialogOpenContext = React.createContext
(true)
const DialogReturnFocusContext = React.createContext(null)
-function getFocusedElement(): HTMLElement | null {
- return typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
- ? document.activeElement
- : null
-}
-
-function canRestoreFocus(element: HTMLElement): boolean {
- if (!element.isConnected || element === document.body) return false
- if (!element.matches('input, textarea, [contenteditable="true"]')) return true
- return typeof window.matchMedia === 'function' && window.matchMedia(FINE_POINTER_MEDIA_QUERY).matches
-}
-
function Dialog({ open, ...props }: React.ComponentProps) {
const [openState, setOpenState] = React.useState(() => ({
open,
@@ -83,9 +71,10 @@ const DialogContent = React.forwardRef<
const returnFocus = React.useContext(DialogReturnFocusContext)
const handleCloseAutoFocus = (event: Event) => {
onCloseAutoFocus?.(event)
- if (event.defaultPrevented || !returnFocus || !canRestoreFocus(returnFocus)) return
- event.preventDefault()
- returnFocus.focus()
+ if (event.defaultPrevented) return
+ if (restoreOverlayFocus(returnFocus, event.currentTarget as Element | null)) {
+ event.preventDefault()
+ }
}
const handleEscapeKeyDown = (event: KeyboardEvent) => {
onEscapeKeyDown?.(event)
diff --git a/frontend/src/components/ui/side-drawer.test.tsx b/frontend/src/components/ui/side-drawer.test.tsx
index fe594230e..4b667af33 100644
--- a/frontend/src/components/ui/side-drawer.test.tsx
+++ b/frontend/src/components/ui/side-drawer.test.tsx
@@ -1,7 +1,36 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
-import { describe, it, expect, vi } from 'vitest'
+import { useState } from 'react'
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { stubMatchMedia } from '@/test/test-utils'
import { SideDrawer, SideDrawerHeader, SideDrawerContent } from './side-drawer'
+function renderDrawer(isOpen: boolean) {
+ return render(
+ <>
+
+ {}} ariaLabel="Test drawer">
+ inside
+
+ >,
+ )
+}
+
+function AutoFocusDrawerHost() {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+ setOpen(true)}>open drawer
+ setOpen(false)} ariaLabel="Auto focus drawer">
+
+
+ >
+ )
+}
+
+afterEach(() => {
+ Reflect.deleteProperty(window, 'matchMedia')
+})
+
describe('SideDrawer', () => {
it('renders when isOpen is true', () => {
render(
@@ -79,6 +108,46 @@ describe('SideDrawer', () => {
expect(closeInner).toHaveBeenCalledTimes(1)
expect(closeOuter).not.toHaveBeenCalled()
})
+
+ it('moves focus to the chat prompt on desktop when it closes', () => {
+ stubMatchMedia(true)
+ const { rerender } = renderDrawer(false)
+
+ rerender(
+ <>
+
+ {}} ariaLabel="Test drawer">
+ inside
+
+ >,
+ )
+ screen.getByRole('button', { name: 'inside' }).focus()
+
+ rerender(
+ <>
+
+ {}} ariaLabel="Test drawer">
+ inside
+
+ >,
+ )
+
+ expect(screen.getByLabelText('prompt')).toHaveFocus()
+ })
+
+ it('restores focus to the opener when an autoFocus child takes focus on open', async () => {
+ stubMatchMedia(true)
+ render( )
+ const opener = screen.getByRole('button', { name: 'open drawer' })
+ opener.focus()
+
+ fireEvent.click(opener)
+ expect(screen.getByLabelText('drawer input')).toHaveFocus()
+
+ fireEvent.keyDown(document, { key: 'Escape' })
+
+ await waitFor(() => expect(opener).toHaveFocus())
+ })
})
describe('SideDrawerHeader', () => {
diff --git a/frontend/src/components/ui/side-drawer.tsx b/frontend/src/components/ui/side-drawer.tsx
index 870ed9d6a..3e88f88c5 100644
--- a/frontend/src/components/ui/side-drawer.tsx
+++ b/frontend/src/components/ui/side-drawer.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { MODAL_TRANSITION_MS } from '@/lib/utils'
import { X } from 'lucide-react'
+import { useRestoreFocusOnClose } from '@/hooks/useRestoreFocusOnClose'
const openDrawerStack: symbol[] = []
@@ -25,6 +26,9 @@ export function SideDrawer({
ariaLabel,
}: SideDrawerProps) {
const [shouldRender, setShouldRender] = useState(false)
+ const panelRef = useRef(null)
+
+ useRestoreFocusOnClose(isOpen, panelRef)
useEffect(() => {
if (isOpen) {
@@ -81,6 +85,7 @@ export function SideDrawer({
aria-hidden="true"
/>
{children}
diff --git a/frontend/src/contexts/KeyboardShortcutsContext.test.tsx b/frontend/src/contexts/KeyboardShortcutsContext.test.tsx
new file mode 100644
index 000000000..b772ebc57
--- /dev/null
+++ b/frontend/src/contexts/KeyboardShortcutsContext.test.tsx
@@ -0,0 +1,365 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, render, renderHook, screen } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { KeyboardShortcutsProvider, useShortcutActions, type ShortcutActions } from './KeyboardShortcutsContext'
+
+const { preferences } = vi.hoisted(() => ({
+ preferences: {
+ leaderKey: 'Ctrl+X',
+ keyboardShortcuts: { submit: 'Ctrl+Enter', abort: 'Escape', newSession: 'N' } as Record,
+ directShortcuts: ['submit', 'abort'],
+ },
+}))
+
+vi.mock('@/hooks/useSettings', () => ({
+ useSettings: () => ({ preferences }),
+}))
+
+function pressKey(target: Element, init: KeyboardEventInit) {
+ const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
+ act(() => {
+ target.dispatchEvent(event)
+ })
+ return event
+}
+
+function mountElement(tag: K, attributes: Record = {}) {
+ const element = document.createElement(tag)
+ for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value)
+ return document.body.appendChild(element)
+}
+
+function ActionLayer({ actions, scope = 'page' }: { actions: ShortcutActions; scope?: 'global' | 'page' }) {
+ useShortcutActions(actions, scope)
+ return null
+}
+
+function ConditionalLayer({ show, actions }: { show: boolean; actions: ShortcutActions }) {
+ if (!show) return null
+ return
+}
+
+function renderProvider(children: ReactNode) {
+ return render({children} )
+}
+
+describe('KeyboardShortcutsProvider', () => {
+ afterEach(() => {
+ document.body.innerHTML = ''
+ })
+
+ it('matches stored Enter and Escape bindings against Return and Esc key events', () => {
+ const submit = vi.fn()
+ const abort = vi.fn()
+ renderProvider( )
+ const prompt = mountElement('textarea', { 'data-prompt-input': '' })
+
+ pressKey(prompt, { key: 'Enter', ctrlKey: true })
+ pressKey(document.body, { key: 'Escape' })
+
+ expect(submit).toHaveBeenCalledTimes(1)
+ expect(abort).toHaveBeenCalledTimes(1)
+ })
+
+ it('activates the leader from the prompt and runs the follow-up action without typing it', () => {
+ const newSession = vi.fn()
+ renderProvider( )
+ const prompt = mountElement('textarea', { 'data-prompt-input': '' })
+
+ expect(pressKey(prompt, { key: 'x', ctrlKey: true }).defaultPrevented).toBe(true)
+ const followUp = pressKey(prompt, { key: 'n' })
+
+ expect(newSession).toHaveBeenCalledTimes(1)
+ expect(followUp.defaultPrevented).toBe(true)
+ })
+
+ it('applies new default direct actions for users with stored preferences that predate them', () => {
+ const variantCycle = vi.fn()
+ renderProvider( )
+ const input = mountElement('textarea')
+
+ pressKey(input, { key: 't', ctrlKey: true })
+
+ expect(variantCycle).toHaveBeenCalledTimes(1)
+ })
+
+ it('leaves submit to the focused input and only interrupts from the prompt', () => {
+ const submit = vi.fn()
+ const abort = vi.fn()
+ renderProvider( )
+ const otherInput = mountElement('input')
+ const prompt = mountElement('textarea', { 'data-prompt-input': '' })
+
+ pressKey(otherInput, { key: 'Enter', ctrlKey: true })
+ pressKey(otherInput, { key: 'Escape' })
+ expect(submit).not.toHaveBeenCalled()
+ expect(abort).not.toHaveBeenCalled()
+
+ pressKey(prompt, { key: 'Escape' })
+ expect(abort).toHaveBeenCalledTimes(1)
+ })
+
+ it('submits from the prompt on plain Enter when submit is bound to Enter, leaving Enter alone elsewhere', () => {
+ const original = preferences.keyboardShortcuts
+ preferences.keyboardShortcuts = { ...original, submit: 'Return' }
+ try {
+ const submit = vi.fn()
+ renderProvider( )
+ const prompt = mountElement('textarea', { 'data-prompt-input': '' })
+ const button = mountElement('button')
+
+ expect(pressKey(prompt, { key: 'Enter' }).defaultPrevented).toBe(true)
+ expect(submit).toHaveBeenCalledTimes(1)
+
+ expect(pressKey(prompt, { key: 'Enter', shiftKey: true }).defaultPrevented).toBe(false)
+ expect(pressKey(prompt, { key: 'Enter', isComposing: true }).defaultPrevented).toBe(false)
+ expect(pressKey(button, { key: 'Enter' }).defaultPrevented).toBe(false)
+ expect(pressKey(document.body, { key: 'Enter' }).defaultPrevented).toBe(false)
+ expect(submit).toHaveBeenCalledTimes(1)
+ } finally {
+ preferences.keyboardShortcuts = original
+ }
+ })
+
+ it('cycles the agent with a direct Tab only from the prompt, keeping Tab for focus elsewhere', () => {
+ const original = { keyboardShortcuts: preferences.keyboardShortcuts, directShortcuts: preferences.directShortcuts }
+ preferences.keyboardShortcuts = { ...original.keyboardShortcuts, toggleMode: 'Tab' }
+ preferences.directShortcuts = ['submit', 'abort', 'toggleMode']
+ try {
+ const toggleMode = vi.fn()
+ renderProvider( )
+ const prompt = mountElement('textarea', { 'data-prompt-input': '' })
+ const button = mountElement('button')
+ const otherInput = mountElement('input')
+
+ expect(pressKey(button, { key: 'Tab' }).defaultPrevented).toBe(false)
+ expect(pressKey(document.body, { key: 'Tab' }).defaultPrevented).toBe(false)
+ expect(pressKey(otherInput, { key: 'Tab' }).defaultPrevented).toBe(false)
+ expect(toggleMode).not.toHaveBeenCalled()
+
+ expect(pressKey(prompt, { key: 'Tab' }).defaultPrevented).toBe(true)
+ expect(toggleMode).toHaveBeenCalledTimes(1)
+ } finally {
+ preferences.keyboardShortcuts = original.keyboardShortcuts
+ preferences.directShortcuts = original.directShortcuts
+ }
+ })
+
+ it('never runs a prompt-only shortcut on a key that types a character', () => {
+ const original = { keyboardShortcuts: preferences.keyboardShortcuts, directShortcuts: preferences.directShortcuts }
+ preferences.keyboardShortcuts = { ...original.keyboardShortcuts, toggleMode: 'T' }
+ preferences.directShortcuts = ['submit', 'abort', 'toggleMode']
+ try {
+ const toggleMode = vi.fn()
+ renderProvider( )
+ const prompt = mountElement('textarea', { 'data-prompt-input': '' })
+
+ expect(pressKey(prompt, { key: 't' }).defaultPrevented).toBe(false)
+ expect(toggleMode).not.toHaveBeenCalled()
+ } finally {
+ preferences.keyboardShortcuts = original.keyboardShortcuts
+ preferences.directShortcuts = original.directShortcuts
+ }
+ })
+
+ it('clears the prompt with a direct Ctrl+C only from the prompt', () => {
+ const original = { keyboardShortcuts: preferences.keyboardShortcuts, directShortcuts: preferences.directShortcuts }
+ preferences.keyboardShortcuts = { ...original.keyboardShortcuts, clearPrompt: 'Ctrl+C' }
+ preferences.directShortcuts = ['submit', 'abort', 'clearPrompt']
+ try {
+ const clearPrompt = vi.fn()
+ renderProvider( )
+ const prompt = mountElement('textarea', { 'data-prompt-input': '' })
+ const otherInput = mountElement('textarea')
+ const button = mountElement('button')
+
+ expect(pressKey(otherInput, { key: 'c', ctrlKey: true }).defaultPrevented).toBe(false)
+ expect(pressKey(button, { key: 'c', ctrlKey: true }).defaultPrevented).toBe(false)
+ expect(pressKey(document.body, { key: 'c', ctrlKey: true }).defaultPrevented).toBe(false)
+ expect(clearPrompt).not.toHaveBeenCalled()
+
+ expect(pressKey(prompt, { key: 'c', ctrlKey: true }).defaultPrevented).toBe(true)
+ expect(clearPrompt).toHaveBeenCalledTimes(1)
+ } finally {
+ preferences.keyboardShortcuts = original.keyboardShortcuts
+ preferences.directShortcuts = original.directShortcuts
+ }
+ })
+
+ it('runs a direct half-page-down shortcut with Ctrl+D from the body and the prompt', () => {
+ const halfPageDown = vi.fn()
+ renderProvider( )
+ const prompt = mountElement('textarea', { 'data-prompt-input': '' })
+
+ expect(pressKey(document.body, { key: 'd', ctrlKey: true }).defaultPrevented).toBe(true)
+ expect(halfPageDown).toHaveBeenCalledTimes(1)
+
+ expect(pressKey(prompt, { key: 'd', ctrlKey: true }).defaultPrevented).toBe(true)
+ expect(halfPageDown).toHaveBeenCalledTimes(2)
+ })
+
+ it('runs a direct half-page-up shortcut with Ctrl+U from the body and the prompt, never from another text field', () => {
+ const halfPageUp = vi.fn()
+ renderProvider( )
+ const prompt = mountElement('textarea', { 'data-prompt-input': '' })
+ const otherInput = mountElement('input')
+
+ expect(pressKey(otherInput, { key: 'u', ctrlKey: true }).defaultPrevented).toBe(false)
+ expect(halfPageUp).not.toHaveBeenCalled()
+
+ expect(pressKey(document.body, { key: 'u', ctrlKey: true }).defaultPrevented).toBe(true)
+ expect(pressKey(prompt, { key: 'u', ctrlKey: true }).defaultPrevented).toBe(true)
+ expect(halfPageUp).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not abort while a dialog overlay is open, leaving Escape to the overlay', () => {
+ const abort = vi.fn()
+ renderProvider( )
+ mountElement('div', { role: 'dialog', 'data-state': 'open' })
+
+ expect(pressKey(document.body, { key: 'Escape' }).defaultPrevented).toBe(false)
+ expect(abort).not.toHaveBeenCalled()
+ })
+
+ it('aborts with Escape when no dialog overlay is open', () => {
+ const abort = vi.fn()
+ renderProvider( )
+ mountElement('div', { role: 'dialog', 'data-state': 'closed' })
+
+ expect(pressKey(document.body, { key: 'Escape' }).defaultPrevented).toBe(true)
+ expect(abort).toHaveBeenCalledTimes(1)
+ })
+
+ it('runs leader and direct shortcuts from a terminal before it consumes the keys, passing other keys through', () => {
+ const newSession = vi.fn()
+ const toggleTerminal = vi.fn()
+ renderProvider( )
+ const terminal = mountElement('div', { 'data-terminal-id': 'pty-1' })
+ const terminalInput = terminal.appendChild(document.createElement('textarea'))
+ const shellInput = vi.fn()
+ terminalInput.addEventListener('keydown', (event) => {
+ shellInput(event.key)
+ event.preventDefault()
+ })
+
+ pressKey(terminalInput, { key: 'x', ctrlKey: true })
+ pressKey(terminalInput, { key: 'n' })
+ pressKey(terminalInput, { key: 'x', ctrlKey: true })
+ pressKey(terminalInput, { key: 't' })
+ pressKey(terminalInput, { key: 'l' })
+
+ expect(newSession).toHaveBeenCalledTimes(1)
+ expect(toggleTerminal).toHaveBeenCalledTimes(1)
+ expect(shellInput.mock.calls).toEqual([['l']])
+ })
+
+ it('passes direct shortcuts through in a terminal while keeping the leader and toggleTerminal', () => {
+ const variantCycle = vi.fn()
+ const halfPageUp = vi.fn()
+ const toggleTerminal = vi.fn()
+ renderProvider( )
+ const terminal = mountElement('div', { 'data-terminal-id': 'pty-2' })
+ const terminalInput = terminal.appendChild(document.createElement('textarea'))
+
+ expect(pressKey(terminalInput, { key: 't', ctrlKey: true }).defaultPrevented).toBe(false)
+ expect(pressKey(terminalInput, { key: 'u', ctrlKey: true }).defaultPrevented).toBe(false)
+ expect(variantCycle).not.toHaveBeenCalled()
+ expect(halfPageUp).not.toHaveBeenCalled()
+
+ pressKey(terminalInput, { key: 'x', ctrlKey: true })
+ expect(pressKey(terminalInput, { key: 't' }).defaultPrevented).toBe(true)
+ expect(toggleTerminal).toHaveBeenCalledTimes(1)
+ })
+
+ it('ignores key events a component already handled', () => {
+ const abort = vi.fn()
+ renderProvider( )
+ const element = mountElement('div')
+ element.addEventListener('keydown', (event) => event.preventDefault())
+
+ pressKey(element, { key: 'Escape' })
+
+ expect(abort).not.toHaveBeenCalled()
+ })
+
+ it('does not swallow a key whose action has no handler', () => {
+ renderProvider( )
+
+ expect(pressKey(document.body, { key: 'Escape' }).defaultPrevented).toBe(false)
+ })
+
+ it('prefers a page layer over a global layer for the same action', () => {
+ const globalAbort = vi.fn()
+ const pageAbort = vi.fn()
+ renderProvider(
+ <>
+
+
+ >
+ )
+
+ pressKey(document.body, { key: 'Escape' })
+
+ expect(pageAbort).toHaveBeenCalledTimes(1)
+ expect(globalAbort).not.toHaveBeenCalled()
+ })
+
+ it('falls through to the global handler when a page layer leaves the action undefined', () => {
+ const globalAbort = vi.fn()
+ renderProvider(
+ <>
+
+
+ >
+ )
+
+ pressKey(document.body, { key: 'Escape' })
+
+ expect(globalAbort).toHaveBeenCalledTimes(1)
+ })
+
+ it('restores the global handler when a page layer unmounts', () => {
+ const globalAbort = vi.fn()
+ const pageAbort = vi.fn()
+ const view = renderProvider(
+ <>
+
+
+ >
+ )
+
+ pressKey(document.body, { key: 'Escape' })
+ expect(pageAbort).toHaveBeenCalledTimes(1)
+ expect(globalAbort).not.toHaveBeenCalled()
+
+ view.rerender(
+
+
+
+
+ )
+
+ pressKey(document.body, { key: 'Escape' })
+ expect(globalAbort).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows the leader indicator after the leader key and hides it after the follow-up key', () => {
+ const newSession = vi.fn()
+ renderProvider( )
+
+ pressKey(document.body, { key: 'x', ctrlKey: true })
+ expect(screen.getByText('Waiting for shortcut key...')).toBeInTheDocument()
+
+ pressKey(document.body, { key: 'n' })
+ expect(screen.queryByText('Waiting for shortcut key...')).not.toBeInTheDocument()
+ expect(newSession).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws when used outside the provider', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ expect(() => renderHook(() => useShortcutActions({ abort: vi.fn() }))).toThrow(
+ 'useShortcutActions must be used within a KeyboardShortcutsProvider'
+ )
+ consoleError.mockRestore()
+ })
+})
diff --git a/frontend/src/contexts/KeyboardShortcutsContext.tsx b/frontend/src/contexts/KeyboardShortcutsContext.tsx
new file mode 100644
index 000000000..8a7a48a98
--- /dev/null
+++ b/frontend/src/contexts/KeyboardShortcutsContext.tsx
@@ -0,0 +1,177 @@
+/* eslint-disable react-refresh/only-export-components */
+import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
+import { useSettings } from '@/hooks/useSettings'
+import type { KeyboardShortcutAction } from '@/api/types/settings'
+import { findShortcutAction, formatShortcutEvent, hasCommandModifier, resolveShortcutBindings } from '@/lib/keyboardShortcuts'
+import { isPromptInput, isTextEntryElement } from '@/lib/domTargets'
+import { hasOpenOverlay } from '@/lib/overlayFocus'
+
+const LEADER_TIMEOUT = 2000
+
+export type ShortcutActions = Partial void>>
+
+interface ShortcutLayer {
+ actionsRef: { current: ShortcutActions }
+}
+
+interface ShortcutRegistry {
+ global: ShortcutLayer[]
+ page: ShortcutLayer[]
+}
+
+const KeyboardShortcutsRegistryContext = createContext(null)
+
+const PROMPT_ONLY_DIRECT_ACTIONS: ReadonlySet = new Set(['toggleMode', 'clearPrompt'])
+
+const HALF_PAGE_DIRECT_ACTIONS: ReadonlySet = new Set(['halfPageUp', 'halfPageDown'])
+
+function typesCharacter(shortcut: string): boolean {
+ return !hasCommandModifier(shortcut) && /(^|\+).$/.test(shortcut)
+}
+
+/**
+ * Whether a direct shortcut may run for this focus target. Prompt-only actions (agent cycling, prompt clearing) run only
+ * from the prompt and never on a key that types a character. `submit` runs only from the prompt, so a submit bound to
+ * plain Enter sends the prompt without hijacking Enter on buttons or other fields. Half-page scrolling runs only from the
+ * prompt or a non-text target, so it never hijacks Ctrl+U/Ctrl+D inside another text field. `abort` runs from the prompt
+ * or with no text field focused; other text inputs only accept modified shortcuts.
+ */
+function isDirectActionAllowed(action: string, shortcut: string, target: HTMLElement): boolean {
+ if (PROMPT_ONLY_DIRECT_ACTIONS.has(action)) return isPromptInput(target) && !typesCharacter(shortcut)
+ if (action === 'submit') return isPromptInput(target)
+ if (HALF_PAGE_DIRECT_ACTIONS.has(action)) return isPromptInput(target) || !isTextEntryElement(target)
+ if (!isTextEntryElement(target)) return true
+ if (action === 'abort') return isPromptInput(target)
+ return hasCommandModifier(shortcut)
+}
+
+function isTerminalTarget(target: EventTarget | null): boolean {
+ return target instanceof Element && target.closest('[data-terminal-id]') !== null
+}
+
+function findLayerHandler(layers: ShortcutLayer[], action: string): (() => void) | undefined {
+ for (let index = layers.length - 1; index >= 0; index -= 1) {
+ const handler = layers[index].actionsRef.current[action as KeyboardShortcutAction]
+ if (handler) return handler
+ }
+ return undefined
+}
+
+function findHandler(registry: ShortcutRegistry, action: string): (() => void) | undefined {
+ return findLayerHandler(registry.page, action) ?? findLayerHandler(registry.global, action)
+}
+
+export function KeyboardShortcutsProvider({ children }: { children: ReactNode }) {
+ const { preferences } = useSettings()
+ const [leaderActive, setLeaderActive] = useState(false)
+ const leaderActiveRef = useRef(false)
+ const leaderTimeoutRef = useRef | null>(null)
+ const preferencesRef = useRef(preferences)
+ preferencesRef.current = preferences
+ const registryRef = useRef({ global: [], page: [] })
+
+ const setLeader = useCallback((active: boolean) => {
+ if (leaderTimeoutRef.current) {
+ clearTimeout(leaderTimeoutRef.current)
+ leaderTimeoutRef.current = null
+ }
+ leaderActiveRef.current = active
+ setLeaderActive(active)
+ if (!active) return
+ leaderTimeoutRef.current = setTimeout(() => {
+ leaderTimeoutRef.current = null
+ leaderActiveRef.current = false
+ setLeaderActive(false)
+ }, LEADER_TIMEOUT)
+ }, [])
+
+ const runAction = useCallback((action: string | undefined, e: KeyboardEvent): boolean => {
+ if (!action) return false
+ if (action === 'abort' && hasOpenOverlay()) return false
+ const handler = findHandler(registryRef.current, action)
+ if (!handler) return false
+ e.preventDefault()
+ handler()
+ return true
+ }, [])
+
+ useEffect(() => {
+ const handleShortcut = (e: KeyboardEvent): boolean => {
+ if (e.defaultPrevented || e.isComposing) return false
+ const shortcut = formatShortcutEvent(e)
+ if (!shortcut) return false
+
+ const target = e.target as HTMLElement
+ if (target.closest?.('[data-file-editor="true"]')) return false
+
+ const bindings = resolveShortcutBindings(preferencesRef.current)
+
+ if (leaderActiveRef.current) {
+ setLeader(false)
+ return runAction(findShortcutAction(bindings, shortcut, false), e)
+ }
+
+ if (shortcut === bindings.leaderKey && (!isTextEntryElement(target) || hasCommandModifier(shortcut))) {
+ e.preventDefault()
+ setLeader(true)
+ return true
+ }
+
+ const directAction = findShortcutAction(bindings, shortcut, true)
+ if (!directAction) return false
+ if (isTerminalTarget(e.target) && directAction !== 'toggleTerminal') return false
+ if (!isDirectActionAllowed(directAction, shortcut, target)) return false
+ return runAction(directAction, e)
+ }
+
+ const handleTerminalKeyDown = (e: KeyboardEvent) => {
+ if (!isTerminalTarget(e.target) || !handleShortcut(e)) return
+ e.stopPropagation()
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (isTerminalTarget(e.target)) return
+ handleShortcut(e)
+ }
+
+ document.addEventListener('keydown', handleTerminalKeyDown, true)
+ document.addEventListener('keydown', handleKeyDown)
+ return () => {
+ document.removeEventListener('keydown', handleTerminalKeyDown, true)
+ document.removeEventListener('keydown', handleKeyDown)
+ setLeader(false)
+ }
+ }, [runAction, setLeader])
+
+ return (
+
+ {children}
+ {leaderActive && (
+
+ Waiting for shortcut key...
+
+ )}
+
+ )
+}
+
+/**
+ * Registers a layer of shortcut handlers for the lifetime of the calling component. Page layers take precedence over global
+ * layers, and within a scope the newest layer wins; an action left `undefined` in a layer falls through to lower layers.
+ */
+export function useShortcutActions(actions: ShortcutActions, scope: 'global' | 'page' = 'page'): void {
+ const registry = useContext(KeyboardShortcutsRegistryContext)
+ if (!registry) throw new Error('useShortcutActions must be used within a KeyboardShortcutsProvider')
+ const layerRef = useRef({ actionsRef: { current: actions } })
+ layerRef.current.actionsRef.current = actions
+
+ useEffect(() => {
+ const layers = scope === 'global' ? registry.global : registry.page
+ const layer = layerRef.current
+ layers.push(layer)
+ return () => {
+ const index = layers.indexOf(layer)
+ if (index >= 0) layers.splice(index, 1)
+ }
+ }, [registry, scope])
+}
diff --git a/frontend/src/hooks/useAutoScroll.test.tsx b/frontend/src/hooks/useAutoScroll.test.tsx
index 914b46220..b3d3370bf 100644
--- a/frontend/src/hooks/useAutoScroll.test.tsx
+++ b/frontend/src/hooks/useAutoScroll.test.tsx
@@ -30,6 +30,13 @@ function createScrollContainer() {
},
})
+ Object.defineProperty(div, 'scrollBy', {
+ configurable: true,
+ value: (options?: ScrollToOptions) => {
+ div.scrollTop = scrollTopValue + (options?.top ?? 0)
+ },
+ })
+
return {
div,
setScrollHeight: (value: number) => {
@@ -367,6 +374,45 @@ describe('useAutoScroll', () => {
expect(containerHarness.getScrollTop()).toBe(100)
})
+ it('does not scroll to bottom on content update after a half-page scroll up', () => {
+ const messages = [createMessage('1', 'user'), createMessage('2', 'assistant')]
+ const { renderResult, containerHarness, onScrollStateChange } = setupHook(messages)
+
+ act(() => {
+ vi.advanceTimersByTime(100)
+ })
+
+ const bottomPosition = containerHarness.div.scrollHeight - containerHarness.div.clientHeight
+ expect(containerHarness.getScrollTop()).toBe(bottomPosition)
+
+ act(() => {
+ renderResult.result.current.scrollByUser(-containerHarness.div.clientHeight / 2)
+ })
+
+ const halfPagePosition = containerHarness.getScrollTop()
+ expect(halfPagePosition).toBe(bottomPosition - containerHarness.div.clientHeight / 2)
+ expect(onScrollStateChange).toHaveBeenCalledWith(true)
+
+ act(() => {
+ vi.advanceTimersByTime(400)
+ })
+
+ containerHarness.setScrollHeight(containerHarness.div.scrollHeight + 200)
+
+ act(() => {
+ renderResult.rerender({
+ containerRef: { current: containerHarness.div },
+ messages,
+ sessionId: 'session-1',
+ contentVersion: messages.length + 1,
+ onScrollStateChange,
+ })
+ vi.advanceTimersByTime(100)
+ })
+
+ expect(containerHarness.getScrollTop()).toBe(halfPagePosition)
+ })
+
it('shows scroll button when streaming growth carries disengaged user past threshold', () => {
const messages = [createMessage('1', 'user')]
const { renderResult, containerHarness, onScrollStateChange } = setupHook(messages)
diff --git a/frontend/src/hooks/useAutoScroll.ts b/frontend/src/hooks/useAutoScroll.ts
index 11df6fb15..e801256e4 100644
--- a/frontend/src/hooks/useAutoScroll.ts
+++ b/frontend/src/hooks/useAutoScroll.ts
@@ -16,6 +16,7 @@ interface UseAutoScrollOptions {
interface UseAutoScrollReturn {
scrollToBottom: () => void
+ scrollByUser: (delta: number) => void
}
export function useAutoScroll({
@@ -62,6 +63,28 @@ export function useAutoScroll({
onScrollStateChangeRef.current?.(false)
}, [containerRef])
+ const markDisengaged = useCallback(() => {
+ const container = containerRef?.current
+ if (!container) return
+ const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
+ userScrolledAtRef.current = Date.now()
+ scrollRequestIdRef.current += 1
+ if (!isScrollButtonVisibleRef.current && distanceFromBottom > SHOW_SCROLL_BUTTON_THRESHOLD_PX) {
+ isScrollButtonVisibleRef.current = true
+ onScrollStateChangeRef.current?.(true)
+ }
+ userDisengagedRef.current = true
+ }, [containerRef])
+
+ const scrollByUser = useCallback((delta: number) => {
+ const container = containerRef?.current
+ if (!container) return
+ if (delta < 0) {
+ markDisengaged()
+ }
+ container.scrollBy({ top: delta })
+ }, [containerRef, markDisengaged])
+
useEffect(() => {
lastMessageCountRef.current = 0
lastMessageIdRef.current = undefined
@@ -76,19 +99,6 @@ export function useAutoScroll({
const container = containerRef?.current
if (!container) return
- const markDisengaged = () => {
- const container = containerRef?.current
- if (!container) return
- const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
- userScrolledAtRef.current = Date.now()
- scrollRequestIdRef.current += 1
- if (!isScrollButtonVisibleRef.current && distanceFromBottom > SHOW_SCROLL_BUTTON_THRESHOLD_PX) {
- isScrollButtonVisibleRef.current = true
- onScrollStateChangeRef.current?.(true)
- }
- userDisengagedRef.current = true
- }
-
const updateScrollButtonVisibility = () => {
const container = containerRef?.current
if (!container || !userDisengagedRef.current || isScrollButtonVisibleRef.current) return
@@ -196,7 +206,7 @@ export function useAutoScroll({
container.removeEventListener('keydown', handleKeyDown)
container.removeEventListener('scroll', handleScroll)
}
- }, [containerRef, sessionId, messages])
+ }, [containerRef, sessionId, messages, markDisengaged])
useEffect(() => {
if (!containerRef?.current || !messages) return
@@ -243,5 +253,5 @@ export function useAutoScroll({
scrollToBottom()
}, [messages, containerRef, scrollToBottom, contentVersion])
- return { scrollToBottom }
+ return { scrollToBottom, scrollByUser }
}
diff --git a/frontend/src/hooks/useDialogParam.ts b/frontend/src/hooks/useDialogParam.ts
index de40cce0a..63b3aaf26 100644
--- a/frontend/src/hooks/useDialogParam.ts
+++ b/frontend/src/hooks/useDialogParam.ts
@@ -20,14 +20,25 @@ export function dialogSearch(name: string, extraParams?: Record)
return `?${params.toString()}`
}
+/**
+ * Opens a dialog in an existing param set: sets `dialog` to `name`, applies `extraParams`, and clears `mobileTab`.
+ */
+export function openDialogParams(
+ params: URLSearchParams,
+ name: string,
+ extraParams?: Record,
+): void {
+ setDialogParams(params, name, extraParams)
+ params.delete('mobileTab')
+}
+
export function openDialogParam(
updateParams: ReturnType['updateParams'],
name: string,
extraParams?: Record,
): void {
updateParams((p) => {
- setDialogParams(p, name, extraParams)
- p.delete('mobileTab')
+ openDialogParams(p, name, extraParams)
}, 'push')
}
diff --git a/frontend/src/hooks/useGlobalShortcutActions.test.tsx b/frontend/src/hooks/useGlobalShortcutActions.test.tsx
new file mode 100644
index 000000000..b166cfc13
--- /dev/null
+++ b/frontend/src/hooks/useGlobalShortcutActions.test.tsx
@@ -0,0 +1,141 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter, useLocation } from 'react-router-dom'
+import { KeyboardShortcutsProvider } from '@/contexts/KeyboardShortcutsContext'
+import { useGlobalShortcutActions } from './useGlobalShortcutActions'
+
+const mocks = vi.hoisted(() => ({
+ getRepo: vi.fn(),
+ createSession: vi.fn(),
+ useMobile: vi.fn(),
+}))
+
+vi.mock('@/hooks/useSettings', () => ({
+ useSettings: () => ({
+ preferences: { leaderKey: 'Ctrl+O', keyboardShortcuts: {}, directShortcuts: undefined },
+ }),
+}))
+
+vi.mock('@/hooks/useMobile', () => ({
+ useMobile: mocks.useMobile,
+}))
+
+vi.mock('@/api/repos', () => ({
+ getRepo: mocks.getRepo,
+}))
+
+vi.mock('@/api/opencode', () => ({
+ createSession: mocks.createSession,
+}))
+
+function Harness() {
+ useGlobalShortcutActions()
+ return null
+}
+
+function LocationDisplay() {
+ const location = useLocation()
+ return {`${location.pathname}${location.search}`}
+}
+
+function pressKey(init: KeyboardEventInit) {
+ const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
+ act(() => {
+ document.body.dispatchEvent(event)
+ })
+ return event
+}
+
+function pressLeaderFollowUp(key: string) {
+ pressKey({ key: 'o', ctrlKey: true })
+ pressKey({ key })
+}
+
+describe('useGlobalShortcutActions', () => {
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ mocks.useMobile.mockReturnValue(false)
+ mocks.getRepo.mockResolvedValue({ id: 5, fullPath: '/repo/five' })
+ mocks.createSession.mockResolvedValue({ id: 'session-new' })
+ })
+
+ afterEach(() => {
+ queryClient.clear()
+ })
+
+ function renderHarness(initialEntry: string) {
+ render(
+
+
+
+
+
+
+
+ ,
+ )
+ return {
+ location: () => screen.getByTestId('location').textContent,
+ }
+ }
+
+ it('opens the terminal dialog on the assistant host from the root route', async () => {
+ const { location } = renderHarness('/')
+
+ pressLeaderFollowUp('t')
+
+ await waitFor(() => expect(location()).toBe('/assistant?dialog=terminal'))
+ })
+
+ it('navigates to the nearest repo host from a repo sub-route without the tool', async () => {
+ const { location } = renderHarness('/repos/5/schedules')
+
+ pressLeaderFollowUp('t')
+
+ await waitFor(() => expect(location()).toBe('/repos/5?dialog=terminal'))
+ })
+
+ it('toggles the docked panel on and off when the route hosts the tool', async () => {
+ const { location } = renderHarness('/repos/5')
+
+ pressLeaderFollowUp('t')
+ await waitFor(() => expect(location()).toBe('/repos/5?panel=terminal'))
+
+ pressLeaderFollowUp('t')
+ await waitFor(() => expect(location()).toBe('/repos/5'))
+ })
+
+ it('toggles the dialog instead of the panel on mobile', async () => {
+ mocks.useMobile.mockReturnValue(true)
+ const { location } = renderHarness('/repos/5')
+
+ pressLeaderFollowUp('t')
+
+ await waitFor(() => expect(location()).toBe('/repos/5?dialog=terminal'))
+ })
+
+ it('creates a session in the repo directory and navigates to it from the leader shortcut', async () => {
+ queryClient.setQueryData(['repo', 5], { id: 5, fullPath: '/repo/five' })
+ const { location } = renderHarness('/repos/5')
+
+ pressKey({ key: 'o', ctrlKey: true })
+ pressKey({ key: 'n' })
+
+ await waitFor(() => expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/repo/five' }))
+ await waitFor(() => expect(location()).toBe('/repos/5/sessions/session-new'))
+ })
+
+ it('does not swallow the follow-up key when the route has no new-session host', async () => {
+ renderHarness('/')
+
+ pressKey({ key: 'o', ctrlKey: true })
+ const followUp = pressKey({ key: 'n' })
+
+ expect(followUp.defaultPrevented).toBe(false)
+ expect(mocks.createSession).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/hooks/useGlobalShortcutActions.ts b/frontend/src/hooks/useGlobalShortcutActions.ts
new file mode 100644
index 000000000..56befd1fe
--- /dev/null
+++ b/frontend/src/hooks/useGlobalShortcutActions.ts
@@ -0,0 +1,67 @@
+import { useCallback, useMemo } from 'react'
+import { useLocation, useNavigate } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils'
+import { getRepo } from '@/api/repos'
+import { buildToolItems, toolKeyOf } from '@/components/navigation/moreDrawerItems'
+import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext'
+import { dialogSearch } from '@/hooks/useDialogParam'
+import { useCreateSession } from '@/hooks/useOpenCode'
+import { useMobile } from '@/hooks/useMobile'
+import { useSettingsDialog } from '@/hooks/useSettingsDialog'
+import { useUrlParams } from '@/hooks/useUrlParams'
+import { toggleToolDialogParams, toggleToolPanelParams, type PanelTool } from '@/hooks/useToolPanel'
+import { getAssistantPath, getRepoPath, getSessionPath, isAssistantPath, parseRepoRoute } from '@/lib/navigation'
+
+/**
+ * Registers the app-wide shortcut actions: the settings dialog, the route tool panels, and repo-scoped session
+ * creation. Page layers can override any of these by registering the same action name.
+ */
+export function useGlobalShortcutActions(): void {
+ const { pathname } = useLocation()
+ const navigate = useNavigate()
+ const { updateParams } = useUrlParams()
+ const { open: openSettings } = useSettingsDialog()
+ const isMobile = useMobile()
+
+ const repoId = isAssistantPath(pathname) ? ASSISTANT_REPO_ID : parseRepoRoute(pathname).repoId
+
+ const { data: repo } = useQuery({
+ queryKey: ['repo', repoId],
+ queryFn: () => getRepo(repoId as number),
+ enabled: repoId !== null,
+ })
+
+ const createSession = useCreateSession(repo?.fullPath, (session) => {
+ if (repoId === null) return
+ navigate(getSessionPath(repoId, session.id))
+ })
+
+ const toolItems = useMemo(() => buildToolItems(pathname), [pathname])
+ const hasFilesTool = toolItems.some((item) => toolKeyOf(item) === 'files')
+
+ const toggleRouteTool = useCallback((tool: PanelTool) => {
+ if (toolItems.some((item) => toolKeyOf(item) === tool)) {
+ updateParams((params) => {
+ if (isMobile) toggleToolDialogParams(params, tool)
+ else toggleToolPanelParams(params, tool)
+ }, 'push')
+ return
+ }
+ if (tool === 'files') return
+ const host = repoId !== null ? getRepoPath(repoId) : getAssistantPath()
+ navigate(`${host}${dialogSearch(tool)}`)
+ }, [toolItems, isMobile, updateParams, navigate, repoId])
+
+ const newSession = repoId !== null && repo?.fullPath && !createSession.isPending
+ ? () => createSession.mutate({})
+ : undefined
+
+ useShortcutActions({
+ settings: openSettings,
+ toggleTerminal: () => toggleRouteTool('terminal'),
+ toggleSourceControl: () => toggleRouteTool('sourceControl'),
+ toggleSidebar: hasFilesTool ? () => toggleRouteTool('files') : undefined,
+ newSession,
+ }, 'global')
+}
diff --git a/frontend/src/hooks/useKeyboardShortcuts.test.tsx b/frontend/src/hooks/useKeyboardShortcuts.test.tsx
deleted file mode 100644
index d5d1f33eb..000000000
--- a/frontend/src/hooks/useKeyboardShortcuts.test.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-import { renderHook } from '@testing-library/react'
-import { useKeyboardShortcuts } from './useKeyboardShortcuts'
-
-vi.mock('./useSettings', () => ({
- useSettings: () => ({
- preferences: { keyboardShortcuts: { submit: 'Return' }, directShortcuts: ['submit'] },
- }),
-}))
-
-describe('useKeyboardShortcuts', () => {
- it('runs a direct shortcut from a text input', () => {
- const submitPrompt = vi.fn()
- renderHook(() => useKeyboardShortcuts({ submitPrompt }))
- const textarea = document.body.appendChild(document.createElement('textarea'))
-
- textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
-
- expect(submitPrompt).toHaveBeenCalledTimes(1)
- textarea.remove()
- })
-
- it('ignores key events a component already handled', () => {
- const submitPrompt = vi.fn()
- renderHook(() => useKeyboardShortcuts({ submitPrompt }))
- const textarea = document.body.appendChild(document.createElement('textarea'))
- textarea.addEventListener('keydown', (event) => event.preventDefault())
-
- textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
-
- expect(submitPrompt).not.toHaveBeenCalled()
- textarea.remove()
- })
-})
diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts
deleted file mode 100644
index 034ab4c78..000000000
--- a/frontend/src/hooks/useKeyboardShortcuts.ts
+++ /dev/null
@@ -1,193 +0,0 @@
-import { useEffect, useCallback, useRef, useState } from 'react'
-import { useSettings } from './useSettings'
-import { DEFAULT_LEADER_KEY } from '@/api/types/settings'
-
-const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
-
-const normalizeShortcut = (shortcut: string): string => {
- return shortcut.replace(/Cmd/g, isMac ? 'Cmd' : 'Ctrl')
-}
-
-const parseEventShortcut = (e: KeyboardEvent): string => {
- const keys = []
- if (e.ctrlKey) keys.push('Ctrl')
- if (e.metaKey) keys.push('Cmd')
- if (e.altKey) keys.push('Alt')
- if (e.shiftKey) keys.push('Shift')
-
- const mainKey = e.key
- if (!['Control', 'Meta', 'Alt', 'Shift'].includes(mainKey)) {
- let displayKey = mainKey
- if (mainKey === ' ') displayKey = 'Space'
- else if (mainKey === 'ArrowUp') displayKey = 'Up'
- else if (mainKey === 'ArrowDown') displayKey = 'Down'
- else if (mainKey === 'ArrowLeft') displayKey = 'Left'
- else if (mainKey === 'ArrowRight') displayKey = 'Right'
- else if (mainKey === 'Enter') displayKey = 'Return'
- else if (mainKey === 'Escape') displayKey = 'Esc'
- else if (mainKey === 'Tab') displayKey = 'Tab'
- else if (mainKey === 'Backspace') displayKey = 'Backspace'
- else if (mainKey === 'Delete') displayKey = 'Delete'
- else if (mainKey.length === 1) displayKey = mainKey.toUpperCase()
-
- keys.push(displayKey)
- return keys.join('+')
- }
- return ''
-}
-
-const DEFAULT_DIRECT_SHORTCUTS = ['submit', 'abort']
-const LEADER_TIMEOUT = 1500
-
-interface ShortcutActions {
- openModelDialog?: () => void
- openSessions?: () => void
- sessions?: () => void
- newSession?: () => void
- closeSession?: () => void
- toggleSidebar?: () => void
- submitPrompt?: () => void
- interruptSession?: () => void
- toggleMode?: () => void
- undo?: () => void
- redo?: () => void
- compact?: () => void
- fork?: () => void
- openSettings?: () => void
-}
-
-export function useKeyboardShortcuts(actions: ShortcutActions = {}) {
- const { preferences } = useSettings()
- const [leaderActive, setLeaderActive] = useState(false)
- const leaderTimeoutRef = useRef | null>(null)
- const preferencesRef = useRef(preferences)
- preferencesRef.current = preferences
-
- const actionsRef = useRef(actions)
- actionsRef.current = actions
-
- const clearLeaderTimeout = useCallback(() => {
- if (leaderTimeoutRef.current) {
- clearTimeout(leaderTimeoutRef.current)
- leaderTimeoutRef.current = null
- }
- }, [])
-
- const executeAction = useCallback((action: string, e: KeyboardEvent) => {
- e.preventDefault()
- const currentActions = actionsRef.current
-
- switch (action) {
- case 'selectModel':
- currentActions.openModelDialog?.()
- break
- case 'sessions':
- currentActions.openSessions?.()
- break
- case 'newSession':
- currentActions.newSession?.()
- break
- case 'closeSession':
- currentActions.closeSession?.()
- break
- case 'toggleSidebar':
- currentActions.toggleSidebar?.()
- break
- case 'submit':
- currentActions.submitPrompt?.()
- break
- case 'abort':
- currentActions.interruptSession?.()
- break
- case 'toggleMode':
- currentActions.toggleMode?.()
- break
- case 'undo':
- currentActions.undo?.()
- break
- case 'redo':
- currentActions.redo?.()
- break
- case 'compact':
- currentActions.compact?.()
- break
- case 'fork':
- currentActions.fork?.()
- break
- case 'settings':
- currentActions.openSettings?.()
- break
- }
- }, [])
-
- const handleKeyDown = useCallback((e: KeyboardEvent) => {
- if (e.defaultPrevented) return
- const shortcut = parseEventShortcut(e)
- if (!shortcut) return
-
- const prefs = preferencesRef.current
- const shortcuts = prefs?.keyboardShortcuts || {}
- const leaderKey = normalizeShortcut(prefs?.leaderKey || DEFAULT_LEADER_KEY)
- const directShortcuts = prefs?.directShortcuts ?? DEFAULT_DIRECT_SHORTCUTS
-
- const activeFileEditor = document.querySelector('[data-file-editor="true"]')
- if (activeFileEditor && document.activeElement === activeFileEditor) {
- return
- }
-
- const target = e.target as HTMLElement
- const isInInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true'
- const isFileEditor = target.getAttribute('data-file-editor') === 'true'
-
- if (isFileEditor) return
-
- if (leaderActive) {
- clearLeaderTimeout()
- setLeaderActive(false)
-
- const action = Object.entries(shortcuts).find(([actionName, keys]) => {
- if (directShortcuts.includes(actionName)) return false
- if (!keys) return false
- return normalizeShortcut(keys) === shortcut
- })?.[0]
-
- if (action) {
- executeAction(action, e)
- }
- return
- }
-
- if (shortcut === leaderKey && !isInInput) {
- e.preventDefault()
- setLeaderActive(true)
- clearLeaderTimeout()
- leaderTimeoutRef.current = setTimeout(() => {
- setLeaderActive(false)
- }, LEADER_TIMEOUT)
- return
- }
-
- const directAction = Object.entries(shortcuts).find(([actionName, keys]) => {
- if (!directShortcuts.includes(actionName)) return false
- if (!keys) return false
- return normalizeShortcut(keys) === shortcut
- })?.[0]
-
- if (directAction) {
- if (isInInput && directAction !== 'submit' && directAction !== 'abort') {
- return
- }
- executeAction(directAction, e)
- }
- }, [leaderActive, clearLeaderTimeout, executeAction])
-
- useEffect(() => {
- document.addEventListener('keydown', handleKeyDown)
- return () => {
- document.removeEventListener('keydown', handleKeyDown)
- clearLeaderTimeout()
- }
- }, [handleKeyDown, clearLeaderTimeout])
-
- return { leaderActive }
-}
\ No newline at end of file
diff --git a/frontend/src/hooks/useRestoreFocusOnClose.ts b/frontend/src/hooks/useRestoreFocusOnClose.ts
new file mode 100644
index 000000000..34e6b320a
--- /dev/null
+++ b/frontend/src/hooks/useRestoreFocusOnClose.ts
@@ -0,0 +1,27 @@
+import { useEffect, useRef, type RefObject } from 'react'
+import { getFocusedElement, restoreOverlayFocus } from '@/lib/overlayFocus'
+
+/**
+ * Captures the focused element when an overlay opens and restores focus to it when the overlay closes.
+ *
+ * The opener is captured during render on the closed-to-open transition, before children with
+ * `autoFocus` claim focus during commit, and is idempotent across StrictMode's double render.
+ */
+export function useRestoreFocusOnClose(
+ isOpen: boolean,
+ containerRef: RefObject,
+): void {
+ const returnFocusRef = useRef(null)
+ const wasOpenRef = useRef(false)
+
+ if (isOpen && !wasOpenRef.current) {
+ returnFocusRef.current = getFocusedElement()
+ }
+
+ useEffect(() => {
+ if (!isOpen && wasOpenRef.current) {
+ restoreOverlayFocus(returnFocusRef.current, containerRef.current)
+ }
+ wasOpenRef.current = isOpen
+ }, [isOpen, containerRef])
+}
diff --git a/frontend/src/hooks/useToolPanel.test.ts b/frontend/src/hooks/useToolPanel.test.ts
new file mode 100644
index 000000000..24b41de9f
--- /dev/null
+++ b/frontend/src/hooks/useToolPanel.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest'
+import { toggleToolDialogParams, toggleToolPanelParams } from './useToolPanel'
+
+describe('toggleToolPanelParams', () => {
+ it('opens a tool, clears other tools owned params, and preserves unrelated params', () => {
+ const params = new URLSearchParams('previewPort=3000&previewPath=%2Fx&keep=1')
+
+ toggleToolPanelParams(params, 'terminal')
+
+ expect(params.get('panel')).toBe('terminal')
+ expect(params.has('previewPort')).toBe(false)
+ expect(params.has('previewPath')).toBe(false)
+ expect(params.get('keep')).toBe('1')
+ })
+
+ it('keeps the opened tool owned params', () => {
+ const params = new URLSearchParams('previewPort=3000&previewPath=%2Fx')
+
+ toggleToolPanelParams(params, 'preview')
+
+ expect(params.get('panel')).toBe('preview')
+ expect(params.get('previewPort')).toBe('3000')
+ expect(params.get('previewPath')).toBe('/x')
+ })
+
+ it('closes the active tool and clears every tool param', () => {
+ const params = new URLSearchParams('panel=terminal&terminal=%2Fbin%2Fsh&terminalDirectory=%2Fhome&previewPort=3000&keep=1')
+
+ toggleToolPanelParams(params, 'terminal')
+
+ expect(params.has('panel')).toBe(false)
+ expect(params.has('terminal')).toBe(false)
+ expect(params.has('terminalDirectory')).toBe(false)
+ expect(params.has('previewPort')).toBe(false)
+ expect(params.get('keep')).toBe('1')
+ })
+
+ it('switches from one tool to another and clears the previous owned params', () => {
+ const params = new URLSearchParams('panel=preview&previewPort=3000&previewPath=%2Fx')
+
+ toggleToolPanelParams(params, 'files')
+
+ expect(params.get('panel')).toBe('files')
+ expect(params.has('previewPort')).toBe(false)
+ expect(params.has('previewPath')).toBe(false)
+ })
+})
+
+describe('toggleToolDialogParams', () => {
+ it('opens a dialog, clears mobileTab and other tools owned params', () => {
+ const params = new URLSearchParams('mobileTab=more&previewPort=3000&previewPath=%2Fx&keep=1')
+
+ toggleToolDialogParams(params, 'terminal')
+
+ expect(params.get('dialog')).toBe('terminal')
+ expect(params.has('mobileTab')).toBe(false)
+ expect(params.has('previewPort')).toBe(false)
+ expect(params.has('previewPath')).toBe(false)
+ expect(params.get('keep')).toBe('1')
+ })
+
+ it('keeps the opened tool owned params', () => {
+ const params = new URLSearchParams('mobileTab=more&terminal=%2Fbin%2Fsh&terminalDirectory=%2Fhome&previewPort=3000')
+
+ toggleToolDialogParams(params, 'terminal')
+
+ expect(params.get('dialog')).toBe('terminal')
+ expect(params.get('terminal')).toBe('/bin/sh')
+ expect(params.get('terminalDirectory')).toBe('/home')
+ expect(params.has('previewPort')).toBe(false)
+ })
+
+ it('closes the active dialog and clears every tool param', () => {
+ const params = new URLSearchParams('dialog=terminal&terminal=%2Fbin%2Fsh&terminalDirectory=%2Fhome&previewPort=3000&keep=1')
+
+ toggleToolDialogParams(params, 'terminal')
+
+ expect(params.has('dialog')).toBe(false)
+ expect(params.has('terminal')).toBe(false)
+ expect(params.has('terminalDirectory')).toBe(false)
+ expect(params.has('previewPort')).toBe(false)
+ expect(params.get('keep')).toBe('1')
+ })
+})
diff --git a/frontend/src/hooks/useToolPanel.ts b/frontend/src/hooks/useToolPanel.ts
index 29cc045f4..d51987b0e 100644
--- a/frontend/src/hooks/useToolPanel.ts
+++ b/frontend/src/hooks/useToolPanel.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect } from 'react'
+import { openDialogParams } from './useDialogParam'
import { useUrlParams } from './useUrlParams'
const PANEL_PARAM = 'panel'
@@ -26,6 +27,26 @@ function clearToolParams(params: URLSearchParams, keep?: PanelTool): void {
}
}
+export function toggleToolPanelParams(params: URLSearchParams, tool: PanelTool): void {
+ if (params.get(PANEL_PARAM) === tool) {
+ params.delete(PANEL_PARAM)
+ clearToolParams(params)
+ return
+ }
+ params.set(PANEL_PARAM, tool)
+ clearToolParams(params, tool)
+}
+
+export function toggleToolDialogParams(params: URLSearchParams, tool: PanelTool): void {
+ if (params.get('dialog') === tool) {
+ params.delete('dialog')
+ clearToolParams(params)
+ return
+ }
+ openDialogParams(params, tool)
+ clearToolParams(params, tool)
+}
+
export interface ToolPanelState {
activeTool: PanelTool | null
toggleTool: (tool: PanelTool) => void
@@ -65,15 +86,7 @@ export function useToolPanel(docked: boolean): ToolPanelState {
}, [docked, dialogParam, panelParam, updateParams])
const toggleTool = useCallback((tool: PanelTool) => {
- updateParams((params) => {
- if (params.get(PANEL_PARAM) === tool) {
- params.delete(PANEL_PARAM)
- clearToolParams(params)
- return
- }
- params.set(PANEL_PARAM, tool)
- clearToolParams(params, tool)
- }, 'push')
+ updateParams((params) => toggleToolPanelParams(params, tool), 'push')
}, [updateParams])
const closePanel = useCallback(() => {
diff --git a/frontend/src/lib/domTargets.ts b/frontend/src/lib/domTargets.ts
new file mode 100644
index 000000000..23e1f6e19
--- /dev/null
+++ b/frontend/src/lib/domTargets.ts
@@ -0,0 +1,25 @@
+const CONTENT_EDITABLE_VALUES = new Set(['', 'true', 'plaintext-only'])
+
+/**
+ * Returns true for free-text entry targets: ` `, `
- {leaderActive && (
-
- Waiting for shortcut key...
-
- )}
{minimizedForm && (
({
useSettingsDialog: vi.fn(),
useMobile: vi.fn(),
useVisualViewport: vi.fn(),
- useKeyboardShortcuts: vi.fn(),
+ useShortcutActions: vi.fn(),
useAutoScroll: vi.fn(),
useDialogParam: vi.fn(),
RepoSkillsDialog: vi.fn(() => null),
@@ -63,8 +63,8 @@ vi.mock('@/hooks/useVisualViewport', () => ({
useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })),
}))
-vi.mock('@/hooks/useKeyboardShortcuts', () => ({
- useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })),
+vi.mock('@/contexts/KeyboardShortcutsContext', () => ({
+ useShortcutActions: mocks.useShortcutActions,
}))
vi.mock('@/hooks/useAutoScroll', () => ({
@@ -210,7 +210,6 @@ describe('SessionDetail assistant loading at repoId=0', () => {
mocks.useSettingsDialog.mockReturnValue({ open: vi.fn() })
mocks.useMobile.mockReturnValue(false)
mocks.useVisualViewport.mockReturnValue({ keyboardHeight: 0 })
- mocks.useKeyboardShortcuts.mockReturnValue({ leaderActive: false })
mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn() })
mocks.useDialogParam.mockReturnValue([false, vi.fn()])
})
diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx
index 2f772ede0..85d20b266 100644
--- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx
@@ -126,8 +126,8 @@ vi.mock('@/hooks/useVisualViewport', () => ({
useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })),
}))
-vi.mock('@/hooks/useKeyboardShortcuts', () => ({
- useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })),
+vi.mock('@/contexts/KeyboardShortcutsContext', () => ({
+ useShortcutActions: vi.fn(),
}))
vi.mock('@/hooks/useAutoScroll', () => ({
diff --git a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx
index 6926575d3..287ecce28 100644
--- a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx
@@ -86,8 +86,8 @@ vi.mock('@/hooks/useVisualViewport', () => ({
useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })),
}))
-vi.mock('@/hooks/useKeyboardShortcuts', () => ({
- useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })),
+vi.mock('@/contexts/KeyboardShortcutsContext', () => ({
+ useShortcutActions: vi.fn(),
}))
vi.mock('@/hooks/useAutoScroll', () => ({
diff --git a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx
index f4ce58a64..e055c3c7d 100644
--- a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx
@@ -17,7 +17,7 @@ const mocks = vi.hoisted(() => ({
useSettingsDialog: vi.fn(),
useMobile: vi.fn(),
useVisualViewport: vi.fn(),
- useKeyboardShortcuts: vi.fn(),
+ useShortcutActions: vi.fn(),
useAutoScroll: vi.fn(),
useDialogParam: vi.fn(),
useSessionStatusForSession: vi.fn(),
@@ -65,8 +65,8 @@ vi.mock('@/hooks/useVisualViewport', () => ({
useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })),
}))
-vi.mock('@/hooks/useKeyboardShortcuts', () => ({
- useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })),
+vi.mock('@/contexts/KeyboardShortcutsContext', () => ({
+ useShortcutActions: mocks.useShortcutActions,
}))
vi.mock('@/hooks/useAutoScroll', () => ({
@@ -185,7 +185,6 @@ describe('SessionDetail pending-actions polling gating', () => {
mocks.useSettingsDialog.mockReturnValue({ open: vi.fn() })
mocks.useMobile.mockReturnValue(false)
mocks.useVisualViewport.mockReturnValue({ keyboardHeight: 0 })
- mocks.useKeyboardShortcuts.mockReturnValue({ leaderActive: false })
mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn() })
mocks.useDialogParam.mockReturnValue([false, vi.fn()])
mocks.useSessionStatusForSession.mockReturnValue({ type: 'idle' })
diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx
index 761323a78..9a00bc0d2 100644
--- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx
@@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({
useSettingsDialog: vi.fn(),
useMobile: vi.fn(),
useVisualViewport: vi.fn(),
- useKeyboardShortcuts: vi.fn(),
+ useShortcutActions: vi.fn(),
useAutoScroll: vi.fn(),
useDialogParam: vi.fn(),
useSessionStatusForSession: vi.fn(),
@@ -81,8 +81,8 @@ vi.mock('@/hooks/useVisualViewport', () => ({
useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })),
}))
-vi.mock('@/hooks/useKeyboardShortcuts', () => ({
- useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })),
+vi.mock('@/contexts/KeyboardShortcutsContext', () => ({
+ useShortcutActions: mocks.useShortcutActions,
}))
vi.mock('@/hooks/useAutoScroll', () => ({
@@ -273,7 +273,6 @@ describe('SessionDetail scroll floating button', () => {
mocks.useSSEHealth.mockReturnValue({ isHealthy: true })
mocks.useConfig.mockReturnValue({ data: undefined, isLoading: false })
mocks.useVisualViewport.mockReturnValue({ keyboardHeight: 0 })
- mocks.useKeyboardShortcuts.mockReturnValue({ leaderActive: false })
mocks.useDialogParam.mockReturnValue([false, vi.fn()])
mocks.useSessionStatusForSession.mockReturnValue({ type: 'idle' })
mocks.PromptInput.mockImplementation(() => MockedPromptInput
)
diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts
index b51cf006f..fa0ed2dfb 100644
--- a/shared/src/schemas/settings.ts
+++ b/shared/src/schemas/settings.ts
@@ -63,28 +63,36 @@ export type STTConfig = {
lastModelsFetch?: number;
};
-const isBrowser = typeof navigator !== 'undefined';
-const isMac = isBrowser && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
-const CMD_KEY = isMac ? 'Cmd' : 'Ctrl';
+export const DEFAULT_LEADER_KEY = 'Ctrl+X';
-export const DEFAULT_LEADER_KEY = `${CMD_KEY}+O`;
-
-export const DEFAULT_KEYBOARD_SHORTCUTS: Record = {
- submit: `${CMD_KEY}+Enter`,
+export const DEFAULT_KEYBOARD_SHORTCUTS = {
+ submit: 'Cmd+Enter',
abort: 'Escape',
- toggleMode: 'T',
- undo: 'Z',
- redo: 'Shift+Z',
- compact: 'K',
- fork: 'F',
- settings: ',',
- sessions: 'S',
+ clearPrompt: '',
+ toggleMode: 'Shift+Tab',
+ undo: 'U',
+ redo: 'R',
+ compact: 'C',
+ fork: '',
+ settings: '',
+ sessions: 'L',
newSession: 'N',
closeSession: 'W',
toggleSidebar: 'B',
selectModel: 'M',
- variantCycle: `${CMD_KEY}+T`,
-};
+ variantCycle: 'Ctrl+T',
+ favoriteCycle: 'Ctrl+R',
+ toggleTerminal: 'T',
+ toggleSourceControl: '',
+ timeline: 'G',
+ exportSession: 'X',
+ halfPageUp: 'Ctrl+U',
+ halfPageDown: 'Ctrl+D',
+} satisfies Record;
+
+export type KeyboardShortcutAction = keyof typeof DEFAULT_KEYBOARD_SHORTCUTS;
+
+export const DEFAULT_DIRECT_SHORTCUTS = ['submit', 'abort', 'clearPrompt', 'toggleMode', 'variantCycle', 'favoriteCycle', 'halfPageUp', 'halfPageDown'];
export const GitCredentialSchema = z.object({
id: z.string().optional(),
@@ -232,9 +240,7 @@ export const DEFAULT_USER_PREFERENCES = {
groupToolCalls: true,
showReasoning: false,
simpleChatMode: false,
- leaderKey: DEFAULT_LEADER_KEY,
- directShortcuts: ['submit', 'abort'],
- keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
+ keyboardShortcuts: {} as Record,
customCommands: [],
customAgents: [],
gitCredentials: [] as GitCredential[],