-
Keyboard Shortcuts
+
+
Keyboard Shortcuts
+
+
+
+
+
+ Select your OpenCode cli.json (or legacy tui.json), usually in ~/.config/opencode.
+
+
+
@@ -316,7 +385,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..52dca097b 100644
--- a/frontend/src/components/ui/bottom-sheet.test.tsx
+++ b/frontend/src/components/ui/bottom-sheet.test.tsx
@@ -1,5 +1,6 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+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 +12,21 @@ vi.mock('@/hooks/useMobile', () => ({
})),
}))
+function renderSheet(isOpen: boolean) {
+ return render(
+ <>
+
+
{}} ariaLabel="Test sheet">
+
+
+ >,
+ )
+}
+
+afterEach(() => {
+ Reflect.deleteProperty(window, 'matchMedia')
+})
+
describe('BottomSheet', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -132,6 +148,58 @@ 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">
+
+
+ >,
+ )
+ screen.getByRole('button', { name: 'inside' }).focus()
+
+ rerender(
+ <>
+
+
{}} ariaLabel="Test sheet">
+
+
+ >,
+ )
+
+ 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">
+
+
+ >,
+ )
+ screen.getByRole('button', { name: 'inside' }).focus()
+
+ rerender(
+ <>
+
+
{}} ariaLabel="Test sheet">
+
+
+ >,
+ )
+
+ expect(screen.getByLabelText('prompt')).not.toHaveFocus()
+ })
})
describe('BottomSheetHeader', () => {
diff --git a/frontend/src/components/ui/bottom-sheet.tsx b/frontend/src/components/ui/bottom-sheet.tsx
index fb2aa5a7d..67a9f1788 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 { getFocusedElement, restoreOverlayFocus } from '@/lib/overlayFocus'
export interface BottomSheetProps {
isOpen: boolean
@@ -23,6 +24,8 @@ export function BottomSheet({
}: BottomSheetProps) {
const [shouldRender, setShouldRender] = useState(false)
const panelRef = useRef
(null)
+ const returnFocusRef = useRef(null)
+ const wasOpenRef = useRef(isOpen)
const { bind, swipeStyles } = useSwipeDismiss(onClose, {
enabled: isOpen,
@@ -42,6 +45,15 @@ export function BottomSheet({
}
}, [isOpen])
+ useEffect(() => {
+ if (isOpen) {
+ returnFocusRef.current = getFocusedElement()
+ } else if (wasOpenRef.current) {
+ restoreOverlayFocus(returnFocusRef.current, panelRef.current)
+ }
+ wasOpenRef.current = isOpen
+ }, [isOpen])
+
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
@@ -86,6 +98,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 (
+ <>
+
+
+
+ >
+ )
+}
+
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..358df0eba 100644
--- a/frontend/src/components/ui/side-drawer.test.tsx
+++ b/frontend/src/components/ui/side-drawer.test.tsx
@@ -1,7 +1,23 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
-import { describe, it, expect, vi } from 'vitest'
+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">
+
+
+ >,
+ )
+}
+
+afterEach(() => {
+ Reflect.deleteProperty(window, 'matchMedia')
+})
+
describe('SideDrawer', () => {
it('renders when isOpen is true', () => {
render(
@@ -79,6 +95,32 @@ 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">
+
+
+ >,
+ )
+ screen.getByRole('button', { name: 'inside' }).focus()
+
+ rerender(
+ <>
+
+ {}} ariaLabel="Test drawer">
+
+
+ >,
+ )
+
+ expect(screen.getByLabelText('prompt')).toHaveFocus()
+ })
})
describe('SideDrawerHeader', () => {
diff --git a/frontend/src/components/ui/side-drawer.tsx b/frontend/src/components/ui/side-drawer.tsx
index 870ed9d6a..081578ebc 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 { getFocusedElement, restoreOverlayFocus } from '@/lib/overlayFocus'
const openDrawerStack: symbol[] = []
@@ -25,6 +26,9 @@ export function SideDrawer({
ariaLabel,
}: SideDrawerProps) {
const [shouldRender, setShouldRender] = useState(false)
+ const panelRef = useRef(null)
+ const returnFocusRef = useRef(null)
+ const wasOpenRef = useRef(isOpen)
useEffect(() => {
if (isOpen) {
@@ -35,6 +39,15 @@ export function SideDrawer({
}
}, [isOpen])
+ useEffect(() => {
+ if (isOpen) {
+ returnFocusRef.current = getFocusedElement()
+ } else if (wasOpenRef.current) {
+ restoreOverlayFocus(returnFocusRef.current, panelRef.current)
+ }
+ wasOpenRef.current = isOpen
+ }, [isOpen])
+
const onCloseRef = useRef(onClose)
useEffect(() => {
onCloseRef.current = onClose
@@ -81,6 +94,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..1738d48e2
--- /dev/null
+++ b/frontend/src/contexts/KeyboardShortcutsContext.test.tsx
@@ -0,0 +1,302 @@
+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+O',
+ 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: 'o', ctrlKey: true }).defaultPrevented).toBe(true)
+ const followUp = pressKey(prompt, { key: 'n' })
+
+ expect(newSession).toHaveBeenCalledTimes(1)
+ expect(followUp.defaultPrevented).toBe(true)
+ })
+
+ it('applies new default actions as direct shortcuts for users with legacy stored preferences', () => {
+ const toggleTerminal = vi.fn()
+ renderProvider()
+ const input = mountElement('textarea')
+
+ pressKey(input, { key: '`', ctrlKey: true })
+
+ expect(toggleTerminal).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 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: 'o', ctrlKey: true })
+ pressKey(terminalInput, { key: 'n' })
+ pressKey(terminalInput, { key: '`', ctrlKey: true })
+ pressKey(terminalInput, { key: 'l' })
+
+ expect(newSession).toHaveBeenCalledTimes(1)
+ expect(toggleTerminal).toHaveBeenCalledTimes(1)
+ expect(shellInput.mock.calls).toEqual([['l']])
+ })
+
+ 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: 'o', 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..4deec6acc
--- /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'
+
+const LEADER_TIMEOUT = 2000
+
+export type ShortcutActions = Partial void>>
+
+interface ShortcutLayer {
+ actionsRef: { current: ShortcutActions }
+}
+
+interface ShortcutRegistry {
+ global: ShortcutLayer[]
+ page: ShortcutLayer[]
+}
+
+const KeyboardShortcutsRegistryContext = createContext(null)
+
+function isTextInput(target: HTMLElement): boolean {
+ return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
+}
+
+const PROMPT_ONLY_DIRECT_ACTIONS: ReadonlySet = new Set(['toggleMode', 'clearPrompt'])
+
+function isPromptInput(target: HTMLElement): boolean {
+ return target.hasAttribute('data-prompt-input')
+}
+
+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; `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 (!isTextInput(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
+ 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 && (!isTextInput(target) || hasCommandModifier(shortcut))) {
+ e.preventDefault()
+ setLeader(true)
+ return true
+ }
+
+ const directAction = findShortcutAction(bindings, shortcut, true)
+ if (!directAction) 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/useGlobalShortcutActions.test.tsx b/frontend/src/hooks/useGlobalShortcutActions.test.tsx
new file mode 100644
index 000000000..125749394
--- /dev/null
+++ b/frontend/src/hooks/useGlobalShortcutActions.test.tsx
@@ -0,0 +1,136 @@
+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
+}
+
+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('/')
+
+ pressKey({ key: '`', ctrlKey: true })
+
+ 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')
+
+ pressKey({ key: '`', ctrlKey: true })
+
+ 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')
+
+ pressKey({ key: '`', ctrlKey: true })
+ await waitFor(() => expect(location()).toBe('/repos/5?panel=terminal'))
+
+ pressKey({ key: '`', ctrlKey: true })
+ 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')
+
+ pressKey({ key: '`', ctrlKey: true })
+
+ 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..abac29bcc
--- /dev/null
+++ b/frontend/src/hooks/useGlobalShortcutActions.ts
@@ -0,0 +1,66 @@
+import { useCallback } 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 } 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 hasFilesTool = buildToolItems(pathname).some((item) => (item.panelTool ?? item.dialog) === 'files')
+
+ const toggleRouteTool = useCallback((tool: PanelTool) => {
+ if (buildToolItems(pathname).some((item) => (item.panelTool ?? item.dialog) === 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)}`)
+ }, [pathname, 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/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..413892be2 100644
--- a/frontend/src/hooks/useToolPanel.ts
+++ b/frontend/src/hooks/useToolPanel.ts
@@ -26,6 +26,27 @@ 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
+ }
+ params.set('dialog', tool)
+ params.delete('mobileTab')
+ 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/keyboardShortcuts.test.ts b/frontend/src/lib/keyboardShortcuts.test.ts
new file mode 100644
index 000000000..06ddfad00
--- /dev/null
+++ b/frontend/src/lib/keyboardShortcuts.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { DEFAULT_DIRECT_SHORTCUTS } from '@/api/types/settings'
+import { formatShortcutEvent, normalizeShortcut, resolveDirectShortcuts } from './keyboardShortcuts'
+
+describe('keyboardShortcuts', () => {
+ it('normalizes key aliases, modifier order and bare symbol keys', () => {
+ expect(normalizeShortcut('Ctrl+Enter')).toBe('Ctrl+Return')
+ expect(normalizeShortcut('Escape')).toBe('Esc')
+ expect(normalizeShortcut('Shift+Ctrl+z')).toBe('Ctrl+Shift+Z')
+ expect(normalizeShortcut('Ctrl++')).toBe('Ctrl++')
+ expect(normalizeShortcut('+')).toBe('+')
+ })
+
+ it('reads letters from the physical key when Alt rewrites the character', () => {
+ expect(formatShortcutEvent(new KeyboardEvent('keydown', { key: '†', code: 'KeyT', altKey: true }))).toBe('Alt+T')
+ expect(formatShortcutEvent(new KeyboardEvent('keydown', { key: 'Shift', shiftKey: true }))).toBeNull()
+ })
+
+ it('upgrades only the untouched legacy direct-shortcut list', () => {
+ expect(resolveDirectShortcuts(['abort', 'submit'])).toBe(DEFAULT_DIRECT_SHORTCUTS)
+ expect(resolveDirectShortcuts(['submit'])).toEqual(['submit'])
+ expect(resolveDirectShortcuts(undefined)).toBe(DEFAULT_DIRECT_SHORTCUTS)
+ })
+
+ it('adds default direct actions the stored shortcut map does not know about', () => {
+ expect(resolveDirectShortcuts(['submit'], { submit: 'Return' })).toEqual(DEFAULT_DIRECT_SHORTCUTS)
+ expect(resolveDirectShortcuts(['submit'], { submit: 'Return', abort: 'Escape' })).toEqual(
+ DEFAULT_DIRECT_SHORTCUTS.filter((action) => action !== 'abort'),
+ )
+ expect(resolveDirectShortcuts(['abort', 'submit'], { abort: 'Escape', submit: 'Return' })).toBe(DEFAULT_DIRECT_SHORTCUTS)
+ })
+})
diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts
new file mode 100644
index 000000000..c299104cb
--- /dev/null
+++ b/frontend/src/lib/keyboardShortcuts.ts
@@ -0,0 +1,113 @@
+import { DEFAULT_DIRECT_SHORTCUTS, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY } from '@/api/types/settings'
+
+const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC')
+
+const CMD_KEY = isMac ? 'Cmd' : 'Ctrl'
+
+const MODIFIER_ORDER = ['Ctrl', 'Cmd', 'Alt', 'Shift']
+
+const MODIFIER_EVENT_KEYS = ['Control', 'Meta', 'Alt', 'Shift']
+
+const KEY_ALIASES: Record = {
+ ' ': 'Space',
+ ArrowUp: 'Up',
+ ArrowDown: 'Down',
+ ArrowLeft: 'Left',
+ ArrowRight: 'Right',
+ Enter: 'Return',
+ Escape: 'Esc',
+}
+
+const LEGACY_DEFAULT_DIRECT_SHORTCUTS = ['submit', 'abort']
+
+interface ShortcutPreferences {
+ leaderKey?: string
+ directShortcuts?: string[]
+ keyboardShortcuts?: Record
+}
+
+export interface ShortcutBindings {
+ leaderKey: string
+ shortcuts: Record
+ directShortcuts: string[]
+}
+
+function canonicalKey(key: string): string {
+ return KEY_ALIASES[key] ?? (key.length === 1 ? key.toUpperCase() : key)
+}
+
+function eventModifiers(e: KeyboardEvent): string[] {
+ return [e.ctrlKey && 'Ctrl', e.metaKey && 'Cmd', e.altKey && 'Alt', e.shiftKey && 'Shift'].filter((key): key is string => Boolean(key))
+}
+
+/**
+ * The main key of an event; with Alt held, letters and digits come from the physical key because macOS Option rewrites `key`.
+ */
+function eventMainKey(e: KeyboardEvent): string {
+ if (e.altKey && /^Key[A-Z]$/.test(e.code)) return e.code.slice(3)
+ if (e.altKey && /^Digit\d$/.test(e.code)) return e.code.slice(5)
+ return canonicalKey(e.key)
+}
+
+export function isModifierOnlyEvent(e: KeyboardEvent): boolean {
+ return MODIFIER_EVENT_KEYS.includes(e.key)
+}
+
+export function formatEventModifiers(e: KeyboardEvent): string {
+ return eventModifiers(e).join('+')
+}
+
+/**
+ * Formats a keydown event as a canonical shortcut string such as `Cmd+Shift+Z`, or null for a lone modifier key.
+ */
+export function formatShortcutEvent(e: KeyboardEvent): string | null {
+ if (isModifierOnlyEvent(e)) return null
+ return [...eventModifiers(e), eventMainKey(e)].join('+')
+}
+
+/**
+ * Canonicalizes a stored shortcut: `Cmd` becomes `Ctrl` off macOS, key aliases such as `Enter`/`Return` collapse, and modifiers are ordered.
+ */
+export function normalizeShortcut(shortcut: string): string {
+ if (!shortcut) return ''
+ const separator = shortcut.length < 2 ? -1 : shortcut.lastIndexOf('+', shortcut.length - 2)
+ const mainKey = canonicalKey(shortcut.slice(separator + 1))
+ const modifiers = separator < 0 ? [] : shortcut.slice(0, separator).split('+').map((modifier) => (modifier === 'Cmd' ? CMD_KEY : modifier))
+ const orderedModifiers = MODIFIER_ORDER.filter((modifier) => modifiers.includes(modifier))
+ return [...orderedModifiers, mainKey].join('+')
+}
+
+export function hasCommandModifier(shortcut: string): boolean {
+ return /(^|\+)(Ctrl|Cmd|Alt)\+./.test(shortcut)
+}
+
+/**
+ * The direct-shortcut list, upgrading the untouched legacy default and adding any default direct action that the stored
+ * shortcut map does not yet know about, so newly added direct defaults reach users who customized their list. When
+ * `storedShortcuts` is omitted the list is returned unchanged apart from the legacy upgrade.
+ */
+export function resolveDirectShortcuts(directShortcuts: string[] | undefined, storedShortcuts?: Record): string[] {
+ if (!directShortcuts) return DEFAULT_DIRECT_SHORTCUTS
+ const isLegacyDefault = directShortcuts.length === LEGACY_DEFAULT_DIRECT_SHORTCUTS.length
+ && LEGACY_DEFAULT_DIRECT_SHORTCUTS.every((action) => directShortcuts.includes(action))
+ const resolved = isLegacyDefault ? DEFAULT_DIRECT_SHORTCUTS : directShortcuts
+ if (!storedShortcuts) return resolved
+ const additions = DEFAULT_DIRECT_SHORTCUTS.filter(
+ (action) => !(action in storedShortcuts) && !resolved.includes(action),
+ )
+ return additions.length > 0 ? [...resolved, ...additions] : resolved
+}
+
+export function resolveShortcutBindings(preferences: ShortcutPreferences | undefined): ShortcutBindings {
+ return {
+ leaderKey: normalizeShortcut(preferences?.leaderKey || DEFAULT_LEADER_KEY),
+ shortcuts: { ...DEFAULT_KEYBOARD_SHORTCUTS, ...preferences?.keyboardShortcuts },
+ directShortcuts: resolveDirectShortcuts(preferences?.directShortcuts, preferences?.keyboardShortcuts),
+ }
+}
+
+export function findShortcutAction(bindings: ShortcutBindings, shortcut: string, direct: boolean): string | undefined {
+ return Object.entries(bindings.shortcuts).find(([action, keys]) => (
+ Boolean(keys) && bindings.directShortcuts.includes(action) === direct && normalizeShortcut(keys) === shortcut
+ ))?.[0]
+}
diff --git a/frontend/src/lib/overlayFocus.test.ts b/frontend/src/lib/overlayFocus.test.ts
new file mode 100644
index 000000000..2faac1ba9
--- /dev/null
+++ b/frontend/src/lib/overlayFocus.test.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect, afterEach } from 'vitest'
+import { stubMatchMedia } from '@/test/test-utils'
+import { getFocusedElement, restoreOverlayFocus } from './overlayFocus'
+
+function createElement(
+ tag: K,
+ attrs: Record = {},
+): HTMLElementTagNameMap[K] {
+ const element = document.createElement(tag)
+ for (const [name, value] of Object.entries(attrs)) element.setAttribute(name, value)
+ document.body.appendChild(element)
+ return element
+}
+
+afterEach(() => {
+ Reflect.deleteProperty(window, 'matchMedia')
+ document.body.innerHTML = ''
+})
+
+describe('getFocusedElement', () => {
+ it('returns the focused element', () => {
+ const button = createElement('button')
+ button.focus()
+ expect(getFocusedElement()).toBe(button)
+ })
+})
+
+describe('restoreOverlayFocus', () => {
+ it('focuses the chat prompt on desktop when focus was on a button', () => {
+ stubMatchMedia(true)
+ const prompt = createElement('textarea', { 'data-prompt-input': '' })
+ const button = createElement('button')
+ button.focus()
+
+ const moved = restoreOverlayFocus(button, null)
+
+ expect(moved).toBe(true)
+ expect(document.activeElement).toBe(prompt)
+ })
+
+ it('returns focus to a specific text field on desktop', () => {
+ stubMatchMedia(true)
+ const prompt = createElement('textarea', { 'data-prompt-input': '' })
+ const search = createElement('input')
+
+ const moved = restoreOverlayFocus(search, null)
+
+ expect(moved).toBe(true)
+ expect(document.activeElement).toBe(search)
+ expect(document.activeElement).not.toBe(prompt)
+ })
+
+ it('keeps focus inside another open overlay', () => {
+ stubMatchMedia(true)
+ const prompt = createElement('textarea', { 'data-prompt-input': '' })
+ const overlay = createElement('div', { role: 'dialog', 'data-state': 'open' })
+ const overlayInput = createElement('input')
+ overlay.appendChild(overlayInput)
+
+ const moved = restoreOverlayFocus(overlayInput, null)
+
+ expect(moved).toBe(true)
+ expect(document.activeElement).toBe(overlayInput)
+ expect(document.activeElement).not.toBe(prompt)
+ })
+
+ it('does not jump to the prompt behind another open overlay', () => {
+ stubMatchMedia(true)
+ const prompt = createElement('textarea', { 'data-prompt-input': '' })
+ createElement('div', { role: 'dialog', 'data-state': 'open' })
+ const button = createElement('button')
+
+ const moved = restoreOverlayFocus(button, null)
+
+ expect(moved).toBe(false)
+ expect(document.activeElement).not.toBe(prompt)
+ })
+
+ it('focuses a non-text return target on touch devices', () => {
+ stubMatchMedia(false)
+ const prompt = createElement('textarea', { 'data-prompt-input': '' })
+ const button = createElement('button')
+
+ const moved = restoreOverlayFocus(button, null)
+
+ expect(moved).toBe(true)
+ expect(document.activeElement).toBe(button)
+ expect(document.activeElement).not.toBe(prompt)
+ })
+
+ it('does not refocus a text field on touch devices', () => {
+ stubMatchMedia(false)
+ const comment = createElement('textarea')
+
+ const moved = restoreOverlayFocus(comment, null)
+
+ expect(moved).toBe(false)
+ expect(document.activeElement).not.toBe(comment)
+ })
+
+ it('falls back to the return target when no prompt exists', () => {
+ stubMatchMedia(true)
+ const button = createElement('button')
+
+ const moved = restoreOverlayFocus(button, null)
+
+ expect(moved).toBe(true)
+ expect(document.activeElement).toBe(button)
+ })
+})
diff --git a/frontend/src/lib/overlayFocus.ts b/frontend/src/lib/overlayFocus.ts
new file mode 100644
index 000000000..45958b46c
--- /dev/null
+++ b/frontend/src/lib/overlayFocus.ts
@@ -0,0 +1,100 @@
+import { FINE_POINTER_MEDIA_QUERY } from '@/hooks/useMediaQuery'
+
+const TEXT_FIELD_SELECTOR = 'input, textarea, [contenteditable="true"]'
+const PROMPT_INPUT_SELECTOR = 'textarea[data-prompt-input]'
+const OPEN_OVERLAY_SELECTOR = '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'
+
+/**
+ * Returns the element that currently owns focus, or null outside a DOM environment.
+ */
+export function getFocusedElement(): HTMLElement | null {
+ return typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null
+}
+
+function isFinePointer(): boolean {
+ return (
+ typeof window !== 'undefined' &&
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia(FINE_POINTER_MEDIA_QUERY).matches
+ )
+}
+
+function isTextField(element: Element): boolean {
+ return element.matches(TEXT_FIELD_SELECTOR)
+}
+
+function isDisabledTextField(element: HTMLElement): boolean {
+ return (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && element.disabled
+}
+
+function isInside(element: Element, container: Element | null): boolean {
+ return container !== null && container.contains(element)
+}
+
+function findOpenOverlays(closing: Element | null): Element[] {
+ if (typeof document === 'undefined') return []
+ return Array.from(document.querySelectorAll(OPEN_OVERLAY_SELECTOR)).filter(
+ (overlay) => overlay !== closing && !isInside(overlay, closing),
+ )
+}
+
+function findPromptInput(): HTMLTextAreaElement | null {
+ if (typeof document === 'undefined') return null
+ const prompt = document.querySelector(PROMPT_INPUT_SELECTOR)
+ return prompt && prompt.isConnected && !prompt.disabled ? prompt : null
+}
+
+/**
+ * Moves focus after an overlay closes, preferring the chat prompt on desktop while leaving
+ * touch devices untouched so the on-screen keyboard stays closed.
+ *
+ * @returns true when focus was moved, so the caller can prevent its default handling.
+ */
+export function restoreOverlayFocus(returnFocus: HTMLElement | null, closing: Element | null = null): boolean {
+ if (typeof document === 'undefined') return false
+
+ const isDesktop = isFinePointer()
+ const openOverlays = findOpenOverlays(closing)
+
+ if (openOverlays.length > 0) {
+ if (!returnFocus?.isConnected) return false
+ if (!openOverlays.some((overlay) => overlay.contains(returnFocus))) return false
+ if (isTextField(returnFocus) && !isDesktop) return false
+ returnFocus.focus({ preventScroll: true })
+ return true
+ }
+
+ if (
+ isDesktop &&
+ returnFocus?.isConnected &&
+ isTextField(returnFocus) &&
+ !returnFocus.matches(PROMPT_INPUT_SELECTOR) &&
+ !isDisabledTextField(returnFocus) &&
+ !isInside(returnFocus, closing)
+ ) {
+ returnFocus.focus({ preventScroll: true })
+ return true
+ }
+
+ if (isDesktop) {
+ const prompt = findPromptInput()
+ if (prompt) {
+ prompt.focus({ preventScroll: true })
+ return true
+ }
+ }
+
+ if (
+ returnFocus?.isConnected &&
+ returnFocus !== document.body &&
+ !isInside(returnFocus, closing) &&
+ !(isTextField(returnFocus) && !isDesktop)
+ ) {
+ returnFocus.focus({ preventScroll: true })
+ return true
+ }
+
+ return false
+}
diff --git a/frontend/src/lib/tuiKeybindImport.test.ts b/frontend/src/lib/tuiKeybindImport.test.ts
new file mode 100644
index 000000000..ecacabf95
--- /dev/null
+++ b/frontend/src/lib/tuiKeybindImport.test.ts
@@ -0,0 +1,218 @@
+import { describe, expect, it } from 'vitest'
+import {
+ applyTuiKeybindImport,
+ parseTuiKeybindConfig,
+ type TuiKeybindImport,
+} from './tuiKeybindImport'
+
+const REAL_FILE = `{
+ "$schema": "https://opencode.ai/config.json",
+ "theme": "system",
+ "keybinds": {
+ "leader": "ctrl+o",
+ "terminal.toggle": "t",
+ "session.new": "ctrl+n,n",
+ "session.interrupt": "ctrl+s",
+ "session.compact": "c",
+ "model.list": "m",
+ "agent.cycle": "tab",
+ "session.undo": "u",
+ "session.redo": "none",
+ "session.tab.next": "ctrl+tab",
+ "app.exit": "ctrl+c,ctrl+d,q"
+ }
+}`
+
+describe('parseTuiKeybindConfig', () => {
+ it('converts the real cli.json keybinds excerpt', () => {
+ const result = parseTuiKeybindConfig(REAL_FILE)
+ expect(result.leaderKey).toBe('Ctrl+O')
+ expect(result.shortcuts).toEqual({
+ toggleTerminal: 'T',
+ newSession: 'N',
+ abort: 'Ctrl+S',
+ compact: 'C',
+ selectModel: 'M',
+ toggleMode: 'Tab',
+ undo: 'U',
+ redo: '',
+ })
+ expect(result.leaderActions).toEqual(['toggleTerminal', 'newSession', 'compact', 'selectModel', 'undo'])
+ expect(result.directActions).toEqual(['abort', 'toggleMode'])
+ expect(result.skipped).toEqual([])
+ })
+
+ it('parses JSONC with comments and trailing commas', () => {
+ const content = `{
+ // leader is ctrl+o
+ "keybinds": {
+ "leader": "ctrl+o",
+ "session.compact": "c", // compact
+ },
+ }`
+ const result = parseTuiKeybindConfig(content)
+ expect(result.leaderKey).toBe('Ctrl+O')
+ expect(result.shortcuts.compact).toBe('C')
+ expect(result.leaderActions).toEqual(['compact'])
+ })
+
+ it('reads legacy snake_case names', () => {
+ const content = `{
+ "keybinds": {
+ "session_new": "ctrl+n",
+ "messages_undo": "u",
+ "sidebar_toggle": "b",
+ "session_list": "l"
+ }
+ }`
+ const result = parseTuiKeybindConfig(content)
+ expect(result.shortcuts).toEqual({
+ newSession: 'Ctrl+N',
+ undo: 'U',
+ toggleSidebar: 'B',
+ sessions: 'L',
+ })
+ expect(result.leaderActions).toEqual(['undo', 'toggleSidebar', 'sessions'])
+ expect(result.directActions).toEqual(['newSession'])
+ })
+
+ it('prefers the dotted name over the legacy alias', () => {
+ const content = `{
+ "keybinds": {
+ "session_new": "ctrl+n",
+ "session.new": "n"
+ }
+ }`
+ const result = parseTuiKeybindConfig(content)
+ expect(result.shortcuts.newSession).toBe('N')
+ expect(result.leaderActions).toEqual(['newSession'])
+ expect(result.directActions).toEqual([])
+ })
+
+ it('converts KeyStroke, binding object and array values', () => {
+ const content = `{
+ "keybinds": {
+ "session.interrupt": { "name": "t", "ctrl": true },
+ "session.compact": { "key": "ctrl+k", "preventDefault": false },
+ "agent.cycle": ["ctrl+a", "z"],
+ "model.list": { "name": "pageup", "meta": true }
+ }
+ }`
+ const result = parseTuiKeybindConfig(content)
+ expect(result.shortcuts).toEqual({
+ abort: 'Ctrl+T',
+ compact: 'Ctrl+K',
+ toggleMode: 'Z',
+ selectModel: 'Alt+PageUp',
+ })
+ expect(result.leaderActions).toEqual(['toggleMode'])
+ expect(result.directActions).toEqual(['abort', 'compact', 'selectModel'])
+ })
+
+ it('converts pageup and ctrl++', () => {
+ const pageUp = parseTuiKeybindConfig('{ "keybinds": { "session.compact": "pageup" } }')
+ expect(pageUp.shortcuts.compact).toBe('PageUp')
+ expect(pageUp.leaderActions).toEqual(['compact'])
+
+ const ctrlPlus = parseTuiKeybindConfig('{ "keybinds": { "session.interrupt": "ctrl++" } }')
+ expect(ctrlPlus.shortcuts.abort).toBe('Ctrl++')
+ expect(ctrlPlus.directActions).toEqual(['abort'])
+ })
+
+ it('imports prompt.clear as a direct clearPrompt binding', () => {
+ const result = parseTuiKeybindConfig('{ "keybinds": { "prompt.clear": "ctrl+c" } }')
+ expect(result.shortcuts.clearPrompt).toBe('Ctrl+C')
+ expect(result.directActions).toEqual(['clearPrompt'])
+ expect(result.leaderActions).toEqual([])
+ })
+
+ it('imports the legacy input_clear alias as clearPrompt', () => {
+ const result = parseTuiKeybindConfig('{ "keybinds": { "input_clear": "ctrl+c" } }')
+ expect(result.shortcuts.clearPrompt).toBe('Ctrl+C')
+ expect(result.directActions).toEqual(['clearPrompt'])
+ })
+
+ it('records unconvertible bindings in skipped', () => {
+ const result = parseTuiKeybindConfig('{ "keybinds": { "session.undo": "gg" } }')
+ expect(result.shortcuts.undo).toBeUndefined()
+ expect(result.leaderActions).toEqual([])
+ expect(result.directActions).toEqual([])
+ expect(result.skipped).toEqual([{ name: 'session.undo', reason: 'unsupported key "gg"' }])
+ })
+
+ it('treats false and empty strings as disabled', () => {
+ const result = parseTuiKeybindConfig(`{
+ "keybinds": {
+ "session.fork": false,
+ "session.redo": ""
+ }
+ }`)
+ expect(result.shortcuts).toEqual({ fork: '', redo: '' })
+ expect(result.leaderActions).toEqual([])
+ expect(result.directActions).toEqual([])
+ expect(result.skipped).toEqual([])
+ })
+
+ it('ignores unknown keys silently', () => {
+ const result = parseTuiKeybindConfig('{ "keybinds": { "unknown.binding": "ctrl+x" } }')
+ expect(result.shortcuts).toEqual({})
+ expect(result.skipped).toEqual([])
+ })
+
+ it('throws when the file has no keybinds', () => {
+ expect(() => parseTuiKeybindConfig('{ "theme": "system" }')).toThrow('No keybinds found in this file')
+ expect(() => parseTuiKeybindConfig('[]')).toThrow('No keybinds found in this file')
+ expect(() => parseTuiKeybindConfig('{ "keybinds": [] }')).toThrow('No keybinds found in this file')
+ })
+
+ it('throws a SyntaxError for invalid JSONC', () => {
+ expect(() => parseTuiKeybindConfig('{ "keybinds": {')).toThrow(SyntaxError)
+ })
+})
+
+describe('applyTuiKeybindImport', () => {
+ it('merges shortcuts and updates the direct list', () => {
+ const imported: TuiKeybindImport = {
+ leaderKey: 'Ctrl+O',
+ shortcuts: { newSession: 'N', redo: '' },
+ leaderActions: ['newSession'],
+ directActions: ['abort'],
+ skipped: [],
+ }
+ const applied = applyTuiKeybindImport(imported, {
+ keyboardShortcuts: { abort: 'Escape', submit: 'Ctrl+Return', newSession: 'Ctrl+N' },
+ directShortcuts: ['submit', 'abort', 'newSession'],
+ })
+ expect(applied.keyboardShortcuts).toEqual({
+ abort: 'Escape',
+ submit: 'Ctrl+Return',
+ newSession: 'N',
+ redo: '',
+ })
+ expect(applied.directShortcuts).toEqual(['submit', 'abort'])
+ expect(applied.leaderKey).toBe('Ctrl+O')
+ })
+
+ it('adds new direct actions without duplicates and omits an undefined leader', () => {
+ const applied = applyTuiKeybindImport(
+ { shortcuts: {}, leaderActions: [], directActions: ['compact', 'submit'], skipped: [] },
+ { keyboardShortcuts: {}, directShortcuts: ['submit'] },
+ )
+ expect(applied.directShortcuts).toEqual(['submit', 'compact'])
+ expect(applied.leaderKey).toBeUndefined()
+ expect('leaderKey' in applied).toBe(false)
+ })
+
+ it('unbinds a current action that conflicts with an imported binding of the same kind', () => {
+ const applied = applyTuiKeybindImport(
+ { shortcuts: { toggleTerminal: 'T', abort: 'Ctrl+S' }, leaderActions: ['toggleTerminal'], directActions: ['abort'], skipped: [] },
+ {
+ keyboardShortcuts: { toggleMode: 'T', toggleTerminal: 'Ctrl+`', variantCycle: 't', submit: 'Ctrl+S', abort: 'Escape' },
+ directShortcuts: ['submit', 'abort', 'variantCycle', 'toggleTerminal'],
+ },
+ )
+ expect(applied.cleared).toEqual(['toggleMode', 'submit'])
+ expect(applied.keyboardShortcuts).toMatchObject({ toggleMode: '', submit: '', variantCycle: 't', toggleTerminal: 'T', abort: 'Ctrl+S' })
+ expect(applied.directShortcuts).toEqual(['submit', 'abort', 'variantCycle'])
+ })
+})
diff --git a/frontend/src/lib/tuiKeybindImport.ts b/frontend/src/lib/tuiKeybindImport.ts
new file mode 100644
index 000000000..85f8ce15d
--- /dev/null
+++ b/frontend/src/lib/tuiKeybindImport.ts
@@ -0,0 +1,285 @@
+import type { KeyboardShortcutAction } from '@/api/types/settings'
+import { parseJsonc } from '@/lib/jsonc'
+import { normalizeShortcut } from '@/lib/keyboardShortcuts'
+
+export interface TuiKeybindImport {
+ leaderKey?: string
+ shortcuts: Partial>
+ leaderActions: KeyboardShortcutAction[]
+ directActions: KeyboardShortcutAction[]
+ skipped: Array<{ name: string; reason: string }>
+}
+
+interface KeyStroke {
+ name: string
+ ctrl?: boolean
+ shift?: boolean
+ meta?: boolean
+ super?: boolean
+ hyper?: boolean
+}
+
+type Alternative = string | KeyStroke
+
+type ResolvedBinding =
+ | { kind: 'disabled' }
+ | { kind: 'leader'; shortcut: string }
+ | { kind: 'direct'; shortcut: string }
+ | { kind: 'unconvertible'; reason: string }
+
+type ConvertedAlternative = { shortcut: string; leader: boolean } | { reason: string }
+
+const ACTION_BY_TUI_NAME = new Map([
+ ['session.interrupt', 'abort'],
+ ['prompt.clear', 'clearPrompt'],
+ ['agent.cycle', 'toggleMode'],
+ ['variant.cycle', 'variantCycle'],
+ ['session.undo', 'undo'],
+ ['session.redo', 'redo'],
+ ['session.compact', 'compact'],
+ ['session.fork', 'fork'],
+ ['opencode.settings', 'settings'],
+ ['session.list', 'sessions'],
+ ['session.new', 'newSession'],
+ ['session.tab.close', 'closeSession'],
+ ['session.sidebar.toggle', 'toggleSidebar'],
+ ['model.list', 'selectModel'],
+ ['terminal.toggle', 'toggleTerminal'],
+ ['diff.open', 'toggleSourceControl'],
+ ['session.timeline', 'timeline'],
+ ['session.export', 'exportSession'],
+])
+
+const ACTION_BY_LEGACY_NAME = new Map([
+ ['session_interrupt', 'abort'],
+ ['input_clear', 'clearPrompt'],
+ ['agent_cycle', 'toggleMode'],
+ ['variant_cycle', 'variantCycle'],
+ ['messages_undo', 'undo'],
+ ['messages_redo', 'redo'],
+ ['session_compact', 'compact'],
+ ['session_fork', 'fork'],
+ ['session_list', 'sessions'],
+ ['session_new', 'newSession'],
+ ['session_tab_close', 'closeSession'],
+ ['sidebar_toggle', 'toggleSidebar'],
+ ['model_list', 'selectModel'],
+ ['diff_open', 'toggleSourceControl'],
+ ['session_timeline', 'timeline'],
+ ['session_export', 'exportSession'],
+])
+
+const MODIFIER_LABELS = new Map([
+ ['ctrl', 'Ctrl'],
+ ['shift', 'Shift'],
+ ['meta', 'Alt'],
+ ['alt', 'Alt'],
+ ['option', 'Alt'],
+ ['super', 'Cmd'],
+ ['cmd', 'Cmd'],
+ ['hyper', null],
+])
+
+const KEY_LABELS = new Map([
+ ['return', 'Return'],
+ ['enter', 'Return'],
+ ['escape', 'Esc'],
+ ['esc', 'Esc'],
+ ['space', 'Space'],
+ ['tab', 'Tab'],
+ ['up', 'Up'],
+ ['down', 'Down'],
+ ['left', 'Left'],
+ ['right', 'Right'],
+ ['pageup', 'PageUp'],
+ ['pagedown', 'PageDown'],
+ ['home', 'Home'],
+ ['end', 'End'],
+ ['delete', 'Delete'],
+ ['backspace', 'Backspace'],
+ ['insert', 'Insert'],
+])
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function isKeyStroke(value: unknown): value is KeyStroke {
+ return isRecord(value) && typeof value.name === 'string'
+}
+
+function convertKey(name: string): string | null {
+ const lower = name.toLowerCase()
+ const mapped = KEY_LABELS.get(lower)
+ if (mapped) return mapped
+ if (/^f([1-9]|1\d|2[0-4])$/.test(lower)) return `F${lower.slice(1)}`
+ if (name.length === 1) return name.toUpperCase()
+ return null
+}
+
+function splitToken(token: string): string[] {
+ const trailingPlus = token.endsWith('+')
+ const body = trailingPlus ? token.slice(0, -1) : token
+ const parts = body.split('+').filter((part) => part.length > 0)
+ return trailingPlus ? [...parts, '+'] : parts
+}
+
+function convertToken(token: string): ConvertedAlternative {
+ const lower = token.trim().toLowerCase()
+ if (!lower) return { reason: 'empty key' }
+ const leader = lower.startsWith('')
+ const body = leader ? lower.slice(''.length) : lower
+ if (!body) return { reason: 'missing key after ' }
+ const parts = splitToken(body)
+ if (parts.length === 0) return { reason: `unsupported key "${token.trim()}"` }
+ const keyPart = parts[parts.length - 1]!
+ const key = convertKey(keyPart)
+ if (!key) return { reason: `unsupported key "${keyPart}"` }
+ const modifiers: string[] = []
+ for (const part of parts.slice(0, -1)) {
+ const label = MODIFIER_LABELS.get(part)
+ if (!label) return { reason: `unsupported modifier "${part}"` }
+ modifiers.push(label)
+ }
+ return { shortcut: normalizeShortcut([...modifiers, key].join('+')), leader }
+}
+
+function convertAlternative(alternative: Alternative): ConvertedAlternative {
+ if (typeof alternative === 'string') return convertToken(alternative)
+ const leader = alternative.name.startsWith('')
+ const name = leader ? alternative.name.slice(''.length) : alternative.name
+ if (!name) return { reason: 'missing key after ' }
+ if (alternative.hyper) return { reason: 'unsupported modifier "hyper"' }
+ const key = convertKey(name)
+ if (!key) return { reason: `unsupported key "${name}"` }
+ const modifiers: string[] = []
+ if (alternative.ctrl) modifiers.push('Ctrl')
+ if (alternative.shift) modifiers.push('Shift')
+ if (alternative.meta) modifiers.push('Alt')
+ if (alternative.super) modifiers.push('Cmd')
+ return { shortcut: normalizeShortcut([...modifiers, key].join('+')), leader }
+}
+
+function collectAlternatives(value: unknown): Alternative[] {
+ if (typeof value === 'string') {
+ return value.split(',').map((part) => part.trim()).filter((part) => part.length > 0)
+ }
+ if (Array.isArray(value)) return value.flatMap(collectAlternatives)
+ if (isKeyStroke(value)) return [value]
+ if (isRecord(value) && 'key' in value) return collectAlternatives(value.key)
+ return []
+}
+
+function isMeaningfulAlternative(alternative: Alternative): boolean {
+ return typeof alternative !== 'string' || alternative.toLowerCase() !== 'none'
+}
+
+function resolveBinding(value: unknown, preferLeader: boolean): ResolvedBinding {
+ if (value === false) return { kind: 'disabled' }
+ if (typeof value === 'string' && (value.trim() === '' || value.trim().toLowerCase() === 'none')) {
+ return { kind: 'disabled' }
+ }
+ const alternatives = collectAlternatives(value).filter(isMeaningfulAlternative)
+ let reason: string | null = null
+ let leaderShortcut: string | null = null
+ let directShortcut: string | null = null
+ for (const alternative of alternatives) {
+ const result = convertAlternative(alternative)
+ if ('reason' in result) {
+ reason ??= result.reason
+ continue
+ }
+ if (result.leader) {
+ leaderShortcut ??= result.shortcut
+ continue
+ }
+ directShortcut ??= result.shortcut
+ }
+ if (preferLeader && leaderShortcut !== null) return { kind: 'leader', shortcut: leaderShortcut }
+ if (directShortcut !== null) return { kind: 'direct', shortcut: directShortcut }
+ return { kind: 'unconvertible', reason: reason ?? 'unsupported value' }
+}
+
+/**
+ * Converts an OpenCode TUI keybind config (cli.json, JSONC, or legacy tui.json) into this app's shortcut preferences.
+ */
+export function parseTuiKeybindConfig(content: string): TuiKeybindImport {
+ const root = parseJsonc(content)
+ if (!isRecord(root) || !isRecord(root.keybinds)) {
+ throw new Error('No keybinds found in this file')
+ }
+ const keybinds = root.keybinds
+ const shortcuts: Partial> = {}
+ const leaderActions: KeyboardShortcutAction[] = []
+ const directActions: KeyboardShortcutAction[] = []
+ const skipped: Array<{ name: string; reason: string }> = []
+ let leaderKey: string | undefined
+
+ if ('leader' in keybinds) {
+ const resolved = resolveBinding(keybinds.leader, false)
+ if (resolved.kind === 'disabled') {
+ skipped.push({ name: 'leader', reason: 'leader key is disabled' })
+ } else if (resolved.kind === 'unconvertible') {
+ skipped.push({ name: 'leader', reason: resolved.reason })
+ } else {
+ leaderKey = resolved.shortcut
+ }
+ }
+
+ const byAction = new Map()
+ for (const [name, value] of Object.entries(keybinds)) {
+ if (name === 'leader') continue
+ const action = ACTION_BY_TUI_NAME.get(name) ?? ACTION_BY_LEGACY_NAME.get(name)
+ if (!action) continue
+ const dotted = ACTION_BY_TUI_NAME.has(name)
+ const existing = byAction.get(action)
+ if (!existing || (dotted && !existing.dotted)) byAction.set(action, { name, value, dotted })
+ }
+
+ for (const [action, entry] of byAction) {
+ const resolved = resolveBinding(entry.value, true)
+ if (resolved.kind === 'disabled') {
+ shortcuts[action] = ''
+ continue
+ }
+ if (resolved.kind === 'unconvertible') {
+ skipped.push({ name: entry.name, reason: resolved.reason })
+ continue
+ }
+ shortcuts[action] = resolved.shortcut
+ if (resolved.kind === 'leader') leaderActions.push(action)
+ else directActions.push(action)
+ }
+
+ return { leaderKey, shortcuts, leaderActions, directActions, skipped }
+}
+
+/**
+ * Merges an imported TUI keybind result into the current shortcut preferences. The imported bindings win: an action the
+ * file does not set is unbound (and listed in `cleared`) when it has the same key and leader/direct kind as an imported one.
+ */
+export function applyTuiKeybindImport(
+ imported: TuiKeybindImport,
+ current: { keyboardShortcuts: Record; directShortcuts: string[] },
+): { keyboardShortcuts: Record; directShortcuts: string[]; leaderKey?: string; cleared: string[] } {
+ const leaderSet = new Set(imported.leaderActions)
+ const directShortcuts = [
+ ...current.directShortcuts.filter((action) => !leaderSet.has(action)),
+ ...imported.directActions.filter((action) => !current.directShortcuts.includes(action)),
+ ]
+ const bindingKey = (action: string, keys: string) => `${directShortcuts.includes(action) ? 'direct' : 'leader'}:${normalizeShortcut(keys)}`
+ const importedBindings = new Set(
+ Object.entries(imported.shortcuts).filter(([, keys]) => Boolean(keys)).map(([action, keys]) => bindingKey(action, keys ?? '')),
+ )
+ const cleared = Object.entries(current.keyboardShortcuts)
+ .filter(([action, keys]) => !(action in imported.shortcuts) && Boolean(keys) && importedBindings.has(bindingKey(action, keys)))
+ .map(([action]) => action)
+ const keyboardShortcuts = {
+ ...current.keyboardShortcuts,
+ ...Object.fromEntries(cleared.map((action) => [action, ''])),
+ ...imported.shortcuts,
+ }
+ return imported.leaderKey !== undefined
+ ? { keyboardShortcuts, directShortcuts, leaderKey: imported.leaderKey, cleared }
+ : { keyboardShortcuts, directShortcuts, cleared }
+}
diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx
index 954918ce9..93f0226b2 100644
--- a/frontend/src/pages/SessionDetail.tsx
+++ b/frontend/src/pages/SessionDetail.tsx
@@ -23,7 +23,7 @@ import { useSSE } from "@/hooks/useSSE";
import { useUIState } from "@/stores/uiStateStore";
import { useSettings } from "@/hooks/useSettings";
import { useModelSelection } from "@/hooks/useModelSelection";
-import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
+import { useShortcutActions } from "@/contexts/KeyboardShortcutsContext";
import { useSettingsDialog } from "@/hooks/useSettingsDialog";
import { useAutoScroll } from "@/hooks/useAutoScroll";
import { useMobile } from "@/hooks/useMobile";
@@ -455,34 +455,6 @@ export function SessionDetail() {
}
};
- const { leaderActive } = useKeyboardShortcuts({
- openModelDialog: handleOpenModelDialog,
- openSessions: handleShowSessionsDialog,
- openSettings,
- newSession: handleNewSession,
- closeSession: handleCloseSession,
- compact: handleCompact,
- undo: handleUndo,
- redo: handleRedo,
- fork: openForkPicker,
- toggleSidebar: () => (docked ? sidePanel.toggleTool('files') : setFileBrowserOpen(!fileBrowserOpen)),
- toggleMode: () => {
- const modeButton = document.querySelector(
- "[data-toggle-mode]",
- ) as HTMLButtonElement;
- modeButton?.click();
- },
- submitPrompt: () => {
- const submitButton = document.querySelector(
- "[data-submit-prompt]",
- ) as HTMLButtonElement;
- submitButton?.click();
- },
- interruptSession: handleInterruptSession,
- });
-
-
-
const handleFileClick = useCallback((filePath: string) => {
setSelectedFilePath(getWorkspaceFilePath(filePath, {
directory: sessionDirectory,
@@ -611,14 +583,32 @@ export function SessionDetail() {
handleConnectProvider,
]);
- const handleUndoMessage = useCallback((restoredPrompt: string) => {
- promptInputRef.current?.setPromptValue(restoredPrompt)
- }, []);
-
const handleClearPrompt = useCallback(() => {
promptInputRef.current?.clearPrompt()
}, []);
+ useShortcutActions({
+ selectModel: handleOpenModelDialog,
+ sessions: handleShowSessionsDialog,
+ newSession: handleNewSession,
+ closeSession: handleCloseSession,
+ compact: handleCompact,
+ undo: handleUndo,
+ redo: handleRedo,
+ fork: openForkPicker,
+ timeline: openTimelinePicker,
+ exportSession: handleExportSession,
+ toggleMode: () => promptInputRef.current?.cycleAgent(),
+ variantCycle: () => promptInputRef.current?.cycleVariant(),
+ submit: () => document.querySelector("[data-submit-prompt]")?.click(),
+ abort: isSessionActive ? handleInterruptSession : undefined,
+ clearPrompt: handleClearPrompt,
+ });
+
+ const handleUndoMessage = useCallback((restoredPrompt: string) => {
+ promptInputRef.current?.setPromptValue(restoredPrompt)
+ }, []);
+
@@ -773,11 +763,6 @@ export function SessionDetail() {
)}
- {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..104aeff32 100644
--- a/shared/src/schemas/settings.ts
+++ b/shared/src/schemas/settings.ts
@@ -69,9 +69,10 @@ const CMD_KEY = isMac ? 'Cmd' : 'Ctrl';
export const DEFAULT_LEADER_KEY = `${CMD_KEY}+O`;
-export const DEFAULT_KEYBOARD_SHORTCUTS: Record = {
+export const DEFAULT_KEYBOARD_SHORTCUTS = {
submit: `${CMD_KEY}+Enter`,
abort: 'Escape',
+ clearPrompt: isMac ? 'Ctrl+C' : '',
toggleMode: 'T',
undo: 'Z',
redo: 'Shift+Z',
@@ -83,8 +84,16 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: Record = {
closeSession: 'W',
toggleSidebar: 'B',
selectModel: 'M',
- variantCycle: `${CMD_KEY}+T`,
-};
+ variantCycle: 'Ctrl+T',
+ toggleTerminal: 'Ctrl+`',
+ toggleSourceControl: 'D',
+ timeline: 'G',
+ exportSession: 'X',
+} satisfies Record;
+
+export type KeyboardShortcutAction = keyof typeof DEFAULT_KEYBOARD_SHORTCUTS;
+
+export const DEFAULT_DIRECT_SHORTCUTS = ['submit', 'abort', 'clearPrompt', 'variantCycle', 'toggleTerminal'];
export const GitCredentialSchema = z.object({
id: z.string().optional(),
@@ -233,7 +242,7 @@ export const DEFAULT_USER_PREFERENCES = {
showReasoning: false,
simpleChatMode: false,
leaderKey: DEFAULT_LEADER_KEY,
- directShortcuts: ['submit', 'abort'],
+ directShortcuts: DEFAULT_DIRECT_SHORTCUTS,
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
customCommands: [],
customAgents: [],
From 7e8443f54fb99b23532ef5744e4080c985e3e276 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:50:37 -0400
Subject: [PATCH 2/4] feat(shortcuts): add half-page message scroll shortcuts
---
.../src/components/settings/KeyboardShortcuts.tsx | 2 +-
.../src/contexts/KeyboardShortcutsContext.test.tsx | 12 ++++++++++++
frontend/src/lib/tuiKeybindImport.ts | 4 ++++
frontend/src/pages/SessionDetail.tsx | 8 ++++++++
shared/src/schemas/settings.ts | 4 +++-
5 files changed, 28 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/settings/KeyboardShortcuts.tsx b/frontend/src/components/settings/KeyboardShortcuts.tsx
index d15fb0423..6c43f0f96 100644
--- a/frontend/src/components/settings/KeyboardShortcuts.tsx
+++ b/frontend/src/components/settings/KeyboardShortcuts.tsx
@@ -8,7 +8,7 @@ import { formatEventModifiers, formatShortcutEvent, isModifierOnlyEvent, normali
import { applyTuiKeybindImport, parseTuiKeybindConfig } from '@/lib/tuiKeybindImport'
import { showToast } from '@/lib/toast'
-const CONVERSATION_ACTIONS = ['submit', 'abort', 'clearPrompt', 'toggleMode', 'undo', 'redo', 'compact', 'fork', 'timeline', 'exportSession', 'selectModel', 'variantCycle']
+const CONVERSATION_ACTIONS = ['submit', 'abort', 'clearPrompt', 'toggleMode', 'undo', 'redo', 'compact', 'fork', 'timeline', 'exportSession', 'selectModel', 'variantCycle', 'halfPageUp', 'halfPageDown']
const NAVIGATION_ACTIONS = ['settings', 'sessions', 'newSession', 'closeSession', 'toggleSidebar', 'toggleTerminal', 'toggleSourceControl']
const formatShortcutLabel = (action: string): string => {
diff --git a/frontend/src/contexts/KeyboardShortcutsContext.test.tsx b/frontend/src/contexts/KeyboardShortcutsContext.test.tsx
index 1738d48e2..cd66a203c 100644
--- a/frontend/src/contexts/KeyboardShortcutsContext.test.tsx
+++ b/frontend/src/contexts/KeyboardShortcutsContext.test.tsx
@@ -186,6 +186,18 @@ describe('KeyboardShortcutsProvider', () => {
}
})
+ 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 leader and direct shortcuts from a terminal before it consumes the keys, passing other keys through', () => {
const newSession = vi.fn()
const toggleTerminal = vi.fn()
diff --git a/frontend/src/lib/tuiKeybindImport.ts b/frontend/src/lib/tuiKeybindImport.ts
index 85f8ce15d..eb8b8d928 100644
--- a/frontend/src/lib/tuiKeybindImport.ts
+++ b/frontend/src/lib/tuiKeybindImport.ts
@@ -48,6 +48,8 @@ const ACTION_BY_TUI_NAME = new Map([
['diff.open', 'toggleSourceControl'],
['session.timeline', 'timeline'],
['session.export', 'exportSession'],
+ ['session.half.page.up', 'halfPageUp'],
+ ['session.half.page.down', 'halfPageDown'],
])
const ACTION_BY_LEGACY_NAME = new Map([
@@ -67,6 +69,8 @@ const ACTION_BY_LEGACY_NAME = new Map([
['diff_open', 'toggleSourceControl'],
['session_timeline', 'timeline'],
['session_export', 'exportSession'],
+ ['messages_half_page_up', 'halfPageUp'],
+ ['messages_half_page_down', 'halfPageDown'],
])
const MODIFIER_LABELS = new Map([
diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx
index 93f0226b2..004ca443e 100644
--- a/frontend/src/pages/SessionDetail.tsx
+++ b/frontend/src/pages/SessionDetail.tsx
@@ -587,6 +587,12 @@ export function SessionDetail() {
promptInputRef.current?.clearPrompt()
}, []);
+ const scrollMessagesByHalfPage = useCallback((direction: 1 | -1) => {
+ const container = messageContainerRef.current;
+ if (!container) return;
+ container.scrollBy({ top: direction * container.clientHeight / 2 });
+ }, []);
+
useShortcutActions({
selectModel: handleOpenModelDialog,
sessions: handleShowSessionsDialog,
@@ -603,6 +609,8 @@ export function SessionDetail() {
submit: () => document.querySelector("[data-submit-prompt]")?.click(),
abort: isSessionActive ? handleInterruptSession : undefined,
clearPrompt: handleClearPrompt,
+ halfPageUp: () => scrollMessagesByHalfPage(-1),
+ halfPageDown: () => scrollMessagesByHalfPage(1),
});
const handleUndoMessage = useCallback((restoredPrompt: string) => {
diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts
index 104aeff32..919de9a26 100644
--- a/shared/src/schemas/settings.ts
+++ b/shared/src/schemas/settings.ts
@@ -89,11 +89,13 @@ export const DEFAULT_KEYBOARD_SHORTCUTS = {
toggleSourceControl: 'D',
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', 'variantCycle', 'toggleTerminal'];
+export const DEFAULT_DIRECT_SHORTCUTS = ['submit', 'abort', 'clearPrompt', 'variantCycle', 'toggleTerminal', 'halfPageUp', 'halfPageDown'];
export const GitCredentialSchema = z.object({
id: z.string().optional(),
From 994a8b82e830a2f819638188b2ac759fd5a589aa Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Thu, 8 Oct 2026 12:40:04 -0400
Subject: [PATCH 3/4] feat(shortcuts): rework shortcut bindings, focus restore,
and input guards
Resolve effective bindings from stored prefs plus defaults with leader/direct collision handling and default leader Ctrl+X; let direct actions pass through in terminals and suppress abort while an overlay is open. Add half-page scroll disengage, verified overlay focus restore, and consolidate prompt reset.
---
backend/src/services/assistant-mode.ts | 14 ++-
.../message/PromptInput.command.test.tsx | 17 +++
.../src/components/message/PromptInput.tsx | 40 +++----
.../model/ModelQuickSelect.test.tsx | 109 ++++++++++++++++++
.../src/components/model/ModelQuickSelect.tsx | 23 +++-
.../components/navigation/ToolSidePanel.tsx | 4 +-
.../components/navigation/moreDrawerItems.ts | 5 +
.../components/session/FormPrompt.test.tsx | 35 +++++-
.../src/components/session/FormPrompt.tsx | 6 +
.../settings/KeyboardShortcuts.test.tsx | 2 +-
.../components/settings/KeyboardShortcuts.tsx | 13 +--
.../src/components/ui/bottom-sheet.test.tsx | 56 +++++++++
frontend/src/components/ui/bottom-sheet.tsx | 15 +--
.../src/components/ui/side-drawer.test.tsx | 27 +++++
frontend/src/components/ui/side-drawer.tsx | 15 +--
.../KeyboardShortcutsContext.test.tsx | 71 ++++++++++--
.../src/contexts/KeyboardShortcutsContext.tsx | 22 ++--
frontend/src/hooks/useAutoScroll.test.tsx | 46 ++++++++
frontend/src/hooks/useAutoScroll.ts | 40 ++++---
frontend/src/hooks/useDialogParam.ts | 15 ++-
.../hooks/useGlobalShortcutActions.test.tsx | 15 ++-
.../src/hooks/useGlobalShortcutActions.ts | 11 +-
frontend/src/hooks/useRestoreFocusOnClose.ts | 27 +++++
frontend/src/hooks/useToolPanel.ts | 4 +-
frontend/src/lib/domTargets.ts | 25 ++++
frontend/src/lib/keyboardShortcuts.test.ts | 57 ++++++++-
frontend/src/lib/keyboardShortcuts.ts | 58 +++++++---
frontend/src/lib/overlayFocus.test.ts | 99 +++++++++++++++-
frontend/src/lib/overlayFocus.ts | 50 ++++----
frontend/src/lib/tuiKeybindImport.ts | 5 +-
frontend/src/pages/GlobalSchedules.tsx | 13 ---
frontend/src/pages/SessionDetail.tsx | 6 +-
shared/src/schemas/settings.ts | 34 +++---
33 files changed, 784 insertions(+), 195 deletions(-)
create mode 100644 frontend/src/hooks/useRestoreFocusOnClose.ts
create mode 100644 frontend/src/lib/domTargets.ts
diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts
index fdde614e1..8f44f38e3 100644
--- a/backend/src/services/assistant-mode.ts
+++ b/backend/src/services/assistant-mode.ts
@@ -12,7 +12,7 @@ import {
fileExists,
ensureDirectoryExists,
} from './file-operations'
-import { ASSISTANT_NOTIFICATION_LIMITS, OpenCodeConfigSchema } from '@opencode-manager/shared/schemas'
+import { ASSISTANT_NOTIFICATION_LIMITS, OpenCodeConfigSchema, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY } from '@opencode-manager/shared/schemas'
import { MANAGER_COLOR_THEME_ID, OPENCODE_THEMES } from '@opencode-manager/shared/themes'
import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH, ASSISTANT_OPENCODE_DIR_NAME } from '@opencode-manager/shared/utils'
import { getAssistantModePath, getReposPath } from '@opencode-manager/shared/config/env'
@@ -699,6 +699,18 @@ Partial object with any of the allowed keys.
**Response:**
Returns the updated settings object with the same structure as GET.
+### Keyboard Shortcut Preferences
+
+\`keyboardShortcuts\`, \`leaderKey\`, and \`directShortcuts\` control keyboard bindings.
+
+**Actions:** ${Object.keys(DEFAULT_KEYBOARD_SHORTCUTS).join(', ')}
+
+**Key format:** modifiers joined with \`+\`, using \`Ctrl\`, \`Cmd\`, \`Alt\`, and \`Shift\` (for example \`Ctrl+Alt+U\` or \`Shift+Tab\`). \`Cmd\` is the Command key on macOS and Ctrl on other platforms. An empty string unbinds the action.
+
+**Leader vs direct:** by default a binding is a leader binding, pressed after the \`leaderKey\` (default \`${DEFAULT_LEADER_KEY}\`). List an action in \`directShortcuts\` to fire it without the leader key.
+
+**Sending changes:** \`keyboardShortcuts\` and \`directShortcuts\` are replaced wholesale. Read the current \`keyboardShortcuts\` (and \`directShortcuts\` when changing binding kinds) from GET /settings, modify only the actions you need, and send the complete map or list back; actions absent from the stored map use their built-in defaults.
+
### POST /assistant/reload
Reload the OpenCode configuration without restarting the server, rebuilding every loaded location. OpenCode re-reads \`opencode.json\`/\`opencode.jsonc\`, agents, commands, skills, plugins, and \`AGENTS.md\`; running sessions, including this one, keep running. Use this after editing \`.opencode/agents/assistant.md\` or other workspace files so changes take effect on the next message. Returns \`400\` with \`validationIssues\` when the OpenCode configuration is invalid.
diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx
index 806e376cc..7a9c87236 100644
--- a/frontend/src/components/message/PromptInput.command.test.tsx
+++ b/frontend/src/components/message/PromptInput.command.test.tsx
@@ -446,6 +446,23 @@ describe('PromptInput command submission', () => {
expect(screen.queryByTestId('composer-mode-badge')).not.toBeInTheDocument()
})
+ it('clears attachments and exits bash mode on Escape while idle', async () => {
+ const { container } = renderComponent()
+
+ const input = await screen.findByPlaceholderText('Send a message...')
+ fireEvent.change(input, { target: { value: '!' } })
+ expect(screen.getByTestId('composer-mode-badge')).toHaveTextContent('BASH')
+
+ await attachImage(container)
+ expect(screen.getByText('pic.png')).toBeInTheDocument()
+
+ fireEvent.keyDown(input, { key: 'Escape' })
+
+ expect(input).toHaveValue('')
+ expect(screen.queryByTestId('composer-mode-badge')).not.toBeInTheDocument()
+ expect(screen.queryByText('pic.png')).not.toBeInTheDocument()
+ })
+
it('keeps attachments and clears only the text when a built-in command is submitted', async () => {
const { container } = renderComponent()
diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx
index a8cb2538c..034e1228e 100644
--- a/frontend/src/components/message/PromptInput.tsx
+++ b/frontend/src/components/message/PromptInput.tsx
@@ -988,8 +988,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
const handleKeyDown = (e: KeyboardEvent) => {
if (isBashMode && e.key === 'Escape') {
e.preventDefault()
- setIsBashMode(false)
- setPrompt('')
+ resetPrompt()
return
}
@@ -1070,12 +1069,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
}
handleSubmit()
} else if (e.key === 'Escape' && !isSessionActive) {
- closeSuggestions()
- setPrompt('')
- revokeBlobUrls(imageAttachments)
- setImageAttachments([])
- resetVoiceGestureState()
- clearSTT()
+ resetPrompt()
}
}
@@ -1109,6 +1103,21 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
setMentionRange(null)
}, [])
+ const resetPrompt = useCallback(() => {
+ setPrompt('')
+ setAttachedFiles(new Map())
+ revokeBlobUrls(imageAttachments)
+ setImageAttachments([])
+ closeSuggestions()
+ setIsBashMode(false)
+ resetVoiceGestureState()
+ if (isRecording) {
+ abortRecording()
+ } else {
+ clearSTT()
+ }
+ }, [imageAttachments, clearSTT, isRecording, abortRecording, resetVoiceGestureState, closeSuggestions])
+
const updateSuggestionTriggers = (value: string, cursorPosition: number) => {
const mentionTrigger = detectMentionTrigger(value, cursorPosition)
@@ -1272,18 +1281,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
textareaRef.current?.focus()
},
clearPrompt: () => {
- setPrompt('')
- setAttachedFiles(new Map())
- revokeBlobUrls(imageAttachments)
- setImageAttachments([])
- closeSuggestions()
- setIsBashMode(false)
- resetVoiceGestureState()
- if (isRecording) {
- abortRecording()
- } else {
- clearSTT()
- }
+ resetPrompt()
textareaRef.current?.focus()
},
triggerFileUpload: openFilePicker,
@@ -1292,7 +1290,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
},
cycleAgent: handleCycleAgent,
cycleVariant: handleCycleVariant,
- }), [imageAttachments, clearSTT, isRecording, abortRecording, resetVoiceGestureState, closeSuggestions, openFilePicker, handleCycleAgent, handleCycleVariant])
+ }), [resetPrompt, openFilePicker, handleCycleAgent, handleCycleVariant])
const commandActionsWithPrompt = useMemo(
() => ({
diff --git a/frontend/src/components/model/ModelQuickSelect.test.tsx b/frontend/src/components/model/ModelQuickSelect.test.tsx
index d32d3be14..56a1867d1 100644
--- a/frontend/src/components/model/ModelQuickSelect.test.tsx
+++ b/frontend/src/components/model/ModelQuickSelect.test.tsx
@@ -9,8 +9,17 @@ import type { Provider } from '@/api/providers'
const mocks = vi.hoisted(() => ({
useModelSelection: vi.fn(),
useModelSections: vi.fn(),
+ deferredSearchValue: { current: null as string | null },
}))
+vi.mock('react', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useDeferredValue: (value: string) => mocks.deferredSearchValue.current ?? value,
+ }
+})
+
vi.mock('@/hooks/useModelSelection', () => ({
useModelSelection: mocks.useModelSelection,
}))
@@ -151,8 +160,23 @@ function focusDetachedTextarea() {
return textarea
}
+const originalInnerWidth = window.innerWidth
+
+function setViewportWidth(width: number) {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width })
+}
+
+function mobileModelList() {
+ const lists = Array.from(document.querySelectorAll('.overflow-y-auto.px-4.pb-4'))
+ const list = lists.find((element) => !element.classList.contains('pt-2'))
+ if (!list) throw new Error('No mobile model list found')
+ return list
+}
+
afterEach(() => {
document.querySelectorAll('textarea').forEach((element) => element.remove())
+ setViewportWidth(originalInnerWidth)
+ mocks.deferredSearchValue.current = null
})
beforeAll(() => {
@@ -453,4 +477,89 @@ describe('ModelQuickSelect keyboard navigation', () => {
expect(fireEvent.keyDown(textarea, { key: 'ArrowDown' })).toBe(true)
})
+
+ it('ignores keyboard navigation when the mobile provider list hides the models', async () => {
+ const user = userEvent.setup()
+ const { setModel } = setModelSelection()
+ setViewportWidth(500)
+
+ render()
+ await user.click(screen.getByRole('button', { name: /More models/ }))
+
+ const textarea = focusDetachedTextarea()
+
+ expect(fireEvent.keyDown(textarea, { key: 'ArrowDown' })).toBe(true)
+ expect(fireEvent.keyDown(textarea, { key: 'Enter' })).toBe(true)
+
+ expect(setModel).not.toHaveBeenCalled()
+ })
+
+ it('navigates the provider models that are visible on mobile', async () => {
+ const user = userEvent.setup()
+ const { setModel } = setModelSelection()
+ setViewportWidth(500)
+
+ render()
+ await user.click(screen.getByRole('button', { name: /More models/ }))
+ await user.click(screen.getAllByRole('button', { name: /Anthropic 2 models/ })[0])
+
+ const textarea = focusDetachedTextarea()
+
+ expect(fireEvent.keyDown(textarea, { key: 'ArrowDown' })).toBe(false)
+ expect(fireEvent.keyDown(textarea, { key: 'Enter' })).toBe(false)
+
+ expect(setModel).toHaveBeenCalledWith(selection('anthropic', 'claude-opus-5-5-fast'))
+ })
+
+ it('ignores keydown events while an IME composition is active', () => {
+ const { setModel } = setModelSelection({ model: selection('opencode-go', 'go-1') })
+
+ render()
+
+ const textarea = focusDetachedTextarea()
+
+ expect(fireEvent.keyDown(textarea, { key: 'ArrowDown', isComposing: true })).toBe(true)
+ expect(fireEvent.keyDown(textarea, { key: 'Enter', isComposing: true })).toBe(true)
+
+ expect(setModel).not.toHaveBeenCalled()
+ })
+
+ it('does not select while the deferred search query is still catching up', async () => {
+ const user = userEvent.setup()
+ const { setModel } = setModelSelection()
+
+ render()
+ await user.click(screen.getByRole('button', { name: /More models/ }))
+
+ const searchInput = screen.getByPlaceholderText('Search models...')
+ mocks.deferredSearchValue.current = ''
+ await user.type(searchInput, '5')
+
+ expect(fireEvent.keyDown(searchInput, { key: 'Enter' })).toBe(false)
+
+ expect(setModel).not.toHaveBeenCalled()
+ })
+
+ it('scrolls the mobile provider model list to follow the active row', async () => {
+ const user = userEvent.setup()
+ const clientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
+ Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 60 })
+ setViewportWidth(500)
+
+ try {
+ render()
+ await user.click(screen.getByRole('button', { name: /More models/ }))
+ await user.click(screen.getAllByRole('button', { name: /Anthropic 2 models/ })[0])
+
+ const list = mobileModelList()
+ expect(list.scrollTop).toBe(0)
+
+ const textarea = focusDetachedTextarea()
+ fireEvent.keyDown(textarea, { key: 'ArrowDown' })
+
+ expect(list.scrollTop).toBe(60)
+ } finally {
+ Object.defineProperty(HTMLElement.prototype, 'clientHeight', clientHeight!)
+ }
+ })
})
diff --git a/frontend/src/components/model/ModelQuickSelect.tsx b/frontend/src/components/model/ModelQuickSelect.tsx
index e381a7c3d..3c9343a75 100644
--- a/frontend/src/components/model/ModelQuickSelect.tsx
+++ b/frontend/src/components/model/ModelQuickSelect.tsx
@@ -1,6 +1,7 @@
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, ChevronLeft, ChevronRight, Search, Star, Trash2, X, type LucideIcon } from 'lucide-react'
import { useModelSelection, type ModelSelectionSession } from '@/hooks/useModelSelection'
+import { useMobile } from '@/hooks/useMobile'
import { useModelSections } from '@/hooks/useModelSections'
import { useVariants } from '@/hooks/useVariants'
import { modelSelectionRef, type Model } from '@/api/providers'
@@ -14,6 +15,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { buildModelSections, filterModelSections, type ModelOption } from '@/lib/modelSections'
+import { isTextEntryElement } from '@/lib/domTargets'
interface ModelQuickSelectProps {
directory?: string
@@ -183,6 +185,7 @@ export function ModelQuickSelect({
const [selectedProviderId, setSelectedProviderId] = useState(null)
const [activeIndex, setActiveIndex] = useState(0)
const quickListRef = useRef(null)
+ const isMobile = useMobile()
const { model, modelString, info, recentModels, favoriteModels, setModel, toggleFavorite, removeRecentModel } = useModelSelection(directory, session)
const { availableVariants, currentVariant, setVariant, clearVariant, hasVariants } = useVariants(directory, session)
@@ -286,10 +289,12 @@ export function ModelQuickSelect({
const navigableItems = useMemo((): ModelListItem[] => {
if (showAllModels) {
- return isSearching ? searchResults : browseModels
+ if (isSearching) return searchResults
+ if (isMobile && !selectedProviderId) return EMPTY_MODELS
+ return browseModels
}
return quickSections.flatMap((section) => section.models)
- }, [showAllModels, isSearching, searchResults, browseModels, quickSections])
+ }, [showAllModels, isSearching, isMobile, selectedProviderId, searchResults, browseModels, quickSections])
const quickSectionOffsets = useMemo(() => {
const offsets = new Map()
@@ -306,6 +311,10 @@ export function ModelQuickSelect({
navigableItemsRef.current = navigableItems
const modelStringRef = useRef(modelString)
modelStringRef.current = modelString
+ const searchQueryRef = useRef(searchQuery)
+ searchQueryRef.current = searchQuery
+ const deferredSearchQueryRef = useRef(deferredSearchQuery)
+ deferredSearchQueryRef.current = deferredSearchQuery
useEffect(() => {
const selectedIndex = navigableItemsRef.current.findIndex((item) => item.key === modelStringRef.current)
@@ -341,15 +350,20 @@ export function ModelQuickSelect({
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey || event.altKey) return
+ if (event.isComposing) return
const target = event.target
if (target instanceof HTMLElement && target.closest('[role="menu"]')) return
- const isTextInput =
- target instanceof HTMLElement && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
+ const isTextInput = target instanceof Element && isTextEntryElement(target)
if (event.key === 'Enter') {
if (target instanceof HTMLElement && target.closest('button')) return
+ if (searchQueryRef.current !== deferredSearchQueryRef.current) {
+ event.preventDefault()
+ event.stopPropagation()
+ return
+ }
const item = navigableItems[activeIndex]
if (!item) return
event.preventDefault()
@@ -676,6 +690,7 @@ export function ModelQuickSelect({
emptyLabel="No models found"
className="px-4 pb-4"
resetKey={selectedProviderId}
+ activeIndex={activeIndex}
/>
) : (
diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx
index 5ef0ba2a5..1c4bd2b9f 100644
--- a/frontend/src/components/navigation/ToolSidePanel.tsx
+++ b/frontend/src/components/navigation/ToolSidePanel.tsx
@@ -16,7 +16,7 @@ import { RepoActionsContent } from '@/components/repo/RepoActionsDialog'
import { RepoSkillsContent } from '@/components/repo/RepoSkillsDialog'
import { RepoSchedulesContent } from '@/components/schedules/RepoSchedulesContent'
import type { SkillFileInfo } from '@opencode-manager/shared'
-import { buildToolItems, type MoreDrawerItem } from '@/components/navigation/moreDrawerItems'
+import { buildToolItems, toolKeyOf, type MoreDrawerItem } from '@/components/navigation/moreDrawerItems'
import { isPanelTool, type ToolPanelState, type PanelTool } from '@/hooks/useToolPanel'
import { useOpenNavItem } from '@/hooks/useOpenNavItem'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -108,7 +108,7 @@ export function ToolSidePanel({
const panelTools: Array<{ item: MoreDrawerItem; tool: PanelTool }> = []
const actionItems: MoreDrawerItem[] = []
for (const item of buildToolItems(location.pathname)) {
- const tool = item.panelTool ?? item.dialog ?? null
+ const tool = toolKeyOf(item)
if (isPanelTool(tool)) panelTools.push({ item, tool })
else actionItems.push(item)
}
diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts
index cd366f52e..bbed2aa43 100644
--- a/frontend/src/components/navigation/moreDrawerItems.ts
+++ b/frontend/src/components/navigation/moreDrawerItems.ts
@@ -60,6 +60,11 @@ export function buildToolItems(pathname: string): MoreDrawerItem[] {
return buildNavModel(pathname).items.filter((item) => !NON_TOOL_ITEM_KEYS.has(item.key))
}
+/** The `?panel=`/`?dialog=` tool identifier an item targets, preferring its panel tool. */
+export function toolKeyOf(item: MoreDrawerItem): string | null {
+ return item.panelTool ?? item.dialog ?? null
+}
+
function buildRouteNavModel(pathname: string): NavModel {
const baseItems = getBaseItems()
diff --git a/frontend/src/components/session/FormPrompt.test.tsx b/frontend/src/components/session/FormPrompt.test.tsx
index 0a976055f..45c42320b 100644
--- a/frontend/src/components/session/FormPrompt.test.tsx
+++ b/frontend/src/components/session/FormPrompt.test.tsx
@@ -1,9 +1,24 @@
-import { render, screen, waitFor, within } from '@testing-library/react'
+import { render as renderRaw, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
+import type { ReactElement } from 'react'
import type { FormInfo } from '@opencode-manager/shared/opencode'
+import { KeyboardShortcutsProvider, useShortcutActions } from '@/contexts/KeyboardShortcutsContext'
import { FormPrompt } from './FormPrompt'
+vi.mock('@/hooks/useSettings', () => ({
+ useSettings: () => ({ preferences: undefined }),
+}))
+
+function render(ui: ReactElement) {
+ return renderRaw(ui, { wrapper: KeyboardShortcutsProvider })
+}
+
+function AbortLayer({ onAbort }: { onAbort: () => void }) {
+ useShortcutActions({ abort: onAbort })
+ return null
+}
+
const selectForm: FormInfo = {
id: 'form-1',
sessionID: 'session-1',
@@ -612,4 +627,22 @@ describe('FormPrompt', () => {
expect(screen.queryByRole('link', { name: /Approve access/ })).not.toBeInTheDocument()
})
})
+
+ describe('keyboard shortcuts', () => {
+ it('minimizes once on Escape while expanded without triggering a registered session abort', async () => {
+ const sessionAbort = vi.fn()
+ const onMinimize = vi.fn()
+ render(
+ <>
+
+
+ >
+ )
+
+ await userEvent.keyboard('{Escape}')
+
+ expect(onMinimize).toHaveBeenCalledTimes(1)
+ expect(sessionAbort).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/frontend/src/components/session/FormPrompt.tsx b/frontend/src/components/session/FormPrompt.tsx
index b6648068f..cde00e1ff 100644
--- a/frontend/src/components/session/FormPrompt.tsx
+++ b/frontend/src/components/session/FormPrompt.tsx
@@ -7,6 +7,7 @@ import type { FormAnswer, FormField, FormInfo } from '@opencode-manager/shared/o
import { buildAnswer, hasMissingAnswers, visibleFields } from '@/lib/formFields'
import { cn } from '@/lib/utils'
import { showToast } from '@/lib/toast'
+import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext'
type AnswerableFormField = Exclude
@@ -136,6 +137,10 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
onMinimize?.()
}
+ useShortcutActions({
+ abort: !isMinimized && !expandedCustom ? handleMinimize : undefined,
+ })
+
const handleSubmit = async () => {
if (!canSubmit) return
setIsSubmitting(true)
@@ -161,6 +166,7 @@ export function FormPrompt({ form, onReply, onCancel, onMinimize }: FormPromptPr
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.defaultPrevented) return
if (e.key === 'Escape' && !isMinimized && !expandedCustom) {
setIsMinimized(true)
onMinimize?.()
diff --git a/frontend/src/components/settings/KeyboardShortcuts.test.tsx b/frontend/src/components/settings/KeyboardShortcuts.test.tsx
index 20190e91d..da4e65aab 100644
--- a/frontend/src/components/settings/KeyboardShortcuts.test.tsx
+++ b/frontend/src/components/settings/KeyboardShortcuts.test.tsx
@@ -75,7 +75,7 @@ describe('KeyboardShortcuts layout', () => {
fireEvent.click(screen.getByRole('button', { name: /Ctrl\+T/ }))
const input = screen.getByPlaceholderText('Press keys...')
- fireEvent.keyDown(input, { key: 'o', ctrlKey: true })
+ fireEvent.keyDown(input, { key: 'x', ctrlKey: true })
expect(updateSettings).not.toHaveBeenCalled()
expect(input).toHaveValue(`${DEFAULT_LEADER_KEY} → `)
diff --git a/frontend/src/components/settings/KeyboardShortcuts.tsx b/frontend/src/components/settings/KeyboardShortcuts.tsx
index 6c43f0f96..57cd44878 100644
--- a/frontend/src/components/settings/KeyboardShortcuts.tsx
+++ b/frontend/src/components/settings/KeyboardShortcuts.tsx
@@ -3,8 +3,7 @@ import { useSettings } from '@/hooks/useSettings'
import { useMobile } from '@/hooks/useMobile'
import { Loader2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
-import { DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY } from '@/api/types/settings'
-import { formatEventModifiers, formatShortcutEvent, isModifierOnlyEvent, normalizeShortcut, resolveDirectShortcuts } from '@/lib/keyboardShortcuts'
+import { formatEventModifiers, formatShortcutEvent, isModifierOnlyEvent, normalizeShortcut, resolveShortcutBindings } from '@/lib/keyboardShortcuts'
import { applyTuiKeybindImport, parseTuiKeybindConfig } from '@/lib/tuiKeybindImport'
import { showToast } from '@/lib/toast'
@@ -131,14 +130,14 @@ export function KeyboardShortcuts() {
const [tempLeaderKey, setTempLeaderKey] = useState(null)
const [currentKeys, setCurrentKeys] = useState('')
- const leaderKey = tempLeaderKey ?? preferences?.leaderKey ?? DEFAULT_LEADER_KEY
- const directShortcuts = resolveDirectShortcuts(preferences?.directShortcuts, preferences?.keyboardShortcuts)
+ const bindings = useMemo(() => resolveShortcutBindings(preferences), [preferences])
+ const leaderKey = tempLeaderKey ?? bindings.leaderKey
+ const directShortcuts = bindings.directShortcuts
const shortcuts = useMemo>(() => ({
- ...DEFAULT_KEYBOARD_SHORTCUTS,
- ...preferences?.keyboardShortcuts,
+ ...bindings.shortcuts,
...tempShortcuts
- }), [preferences?.keyboardShortcuts, tempShortcuts])
+ }), [bindings.shortcuts, tempShortcuts])
const shortcutGroups = useMemo(() => buildShortcutGroups(shortcuts), [shortcuts])
diff --git a/frontend/src/components/ui/bottom-sheet.test.tsx b/frontend/src/components/ui/bottom-sheet.test.tsx
index 52dca097b..77e94f03e 100644
--- a/frontend/src/components/ui/bottom-sheet.test.tsx
+++ b/frontend/src/components/ui/bottom-sheet.test.tsx
@@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+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'
@@ -23,6 +24,18 @@ function renderSheet(isOpen: boolean) {
)
}
+function AutoFocusSheetHost() {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+
+ setOpen(false)} ariaLabel="Auto focus sheet">
+
+
+ >
+ )
+}
+
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
})
@@ -200,6 +213,49 @@ describe('BottomSheet', () => {
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(
+ ,
+ )
+ const opener = screen.getByRole('button', { name: 'opener' })
+ opener.focus()
+
+ rerender(
+ <>
+
+ {}} ariaLabel="Mounted open sheet">
+
+
+ >,
+ )
+
+ rerender(
+ <>
+
+ {}} ariaLabel="Mounted open sheet">
+
+
+ >,
+ )
+
+ expect(opener).toHaveFocus()
+ })
})
describe('BottomSheetHeader', () => {
diff --git a/frontend/src/components/ui/bottom-sheet.tsx b/frontend/src/components/ui/bottom-sheet.tsx
index 67a9f1788..14b7fa6fc 100644
--- a/frontend/src/components/ui/bottom-sheet.tsx
+++ b/frontend/src/components/ui/bottom-sheet.tsx
@@ -3,7 +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 { getFocusedElement, restoreOverlayFocus } from '@/lib/overlayFocus'
+import { useRestoreFocusOnClose } from '@/hooks/useRestoreFocusOnClose'
export interface BottomSheetProps {
isOpen: boolean
@@ -24,8 +24,8 @@ export function BottomSheet({
}: BottomSheetProps) {
const [shouldRender, setShouldRender] = useState(false)
const panelRef = useRef(null)
- const returnFocusRef = useRef(null)
- const wasOpenRef = useRef(isOpen)
+
+ useRestoreFocusOnClose(isOpen, panelRef)
const { bind, swipeStyles } = useSwipeDismiss(onClose, {
enabled: isOpen,
@@ -45,15 +45,6 @@ export function BottomSheet({
}
}, [isOpen])
- useEffect(() => {
- if (isOpen) {
- returnFocusRef.current = getFocusedElement()
- } else if (wasOpenRef.current) {
- restoreOverlayFocus(returnFocusRef.current, panelRef.current)
- }
- wasOpenRef.current = isOpen
- }, [isOpen])
-
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
diff --git a/frontend/src/components/ui/side-drawer.test.tsx b/frontend/src/components/ui/side-drawer.test.tsx
index 358df0eba..4b667af33 100644
--- a/frontend/src/components/ui/side-drawer.test.tsx
+++ b/frontend/src/components/ui/side-drawer.test.tsx
@@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+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'
@@ -14,6 +15,18 @@ function renderDrawer(isOpen: boolean) {
)
}
+function AutoFocusDrawerHost() {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+
+ setOpen(false)} ariaLabel="Auto focus drawer">
+
+
+ >
+ )
+}
+
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
})
@@ -121,6 +134,20 @@ describe('SideDrawer', () => {
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 081578ebc..3e88f88c5 100644
--- a/frontend/src/components/ui/side-drawer.tsx
+++ b/frontend/src/components/ui/side-drawer.tsx
@@ -2,7 +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 { getFocusedElement, restoreOverlayFocus } from '@/lib/overlayFocus'
+import { useRestoreFocusOnClose } from '@/hooks/useRestoreFocusOnClose'
const openDrawerStack: symbol[] = []
@@ -27,8 +27,8 @@ export function SideDrawer({
}: SideDrawerProps) {
const [shouldRender, setShouldRender] = useState(false)
const panelRef = useRef(null)
- const returnFocusRef = useRef(null)
- const wasOpenRef = useRef(isOpen)
+
+ useRestoreFocusOnClose(isOpen, panelRef)
useEffect(() => {
if (isOpen) {
@@ -39,15 +39,6 @@ export function SideDrawer({
}
}, [isOpen])
- useEffect(() => {
- if (isOpen) {
- returnFocusRef.current = getFocusedElement()
- } else if (wasOpenRef.current) {
- restoreOverlayFocus(returnFocusRef.current, panelRef.current)
- }
- wasOpenRef.current = isOpen
- }, [isOpen])
-
const onCloseRef = useRef(onClose)
useEffect(() => {
onCloseRef.current = onClose
diff --git a/frontend/src/contexts/KeyboardShortcutsContext.test.tsx b/frontend/src/contexts/KeyboardShortcutsContext.test.tsx
index cd66a203c..b772ebc57 100644
--- a/frontend/src/contexts/KeyboardShortcutsContext.test.tsx
+++ b/frontend/src/contexts/KeyboardShortcutsContext.test.tsx
@@ -5,7 +5,7 @@ import { KeyboardShortcutsProvider, useShortcutActions, type ShortcutActions } f
const { preferences } = vi.hoisted(() => ({
preferences: {
- leaderKey: 'Ctrl+O',
+ leaderKey: 'Ctrl+X',
keyboardShortcuts: { submit: 'Ctrl+Enter', abort: 'Escape', newSession: 'N' } as Record,
directShortcuts: ['submit', 'abort'],
},
@@ -66,21 +66,21 @@ describe('KeyboardShortcutsProvider', () => {
renderProvider()
const prompt = mountElement('textarea', { 'data-prompt-input': '' })
- expect(pressKey(prompt, { key: 'o', ctrlKey: true }).defaultPrevented).toBe(true)
+ 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 actions as direct shortcuts for users with legacy stored preferences', () => {
- const toggleTerminal = vi.fn()
- renderProvider()
+ 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: '`', ctrlKey: true })
+ pressKey(input, { key: 't', ctrlKey: true })
- expect(toggleTerminal).toHaveBeenCalledTimes(1)
+ expect(variantCycle).toHaveBeenCalledTimes(1)
})
it('leaves submit to the focused input and only interrupts from the prompt', () => {
@@ -198,6 +198,38 @@ describe('KeyboardShortcutsProvider', () => {
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()
@@ -210,9 +242,10 @@ describe('KeyboardShortcutsProvider', () => {
event.preventDefault()
})
- pressKey(terminalInput, { key: 'o', ctrlKey: true })
+ pressKey(terminalInput, { key: 'x', ctrlKey: true })
pressKey(terminalInput, { key: 'n' })
- pressKey(terminalInput, { key: '`', ctrlKey: true })
+ pressKey(terminalInput, { key: 'x', ctrlKey: true })
+ pressKey(terminalInput, { key: 't' })
pressKey(terminalInput, { key: 'l' })
expect(newSession).toHaveBeenCalledTimes(1)
@@ -220,6 +253,24 @@ describe('KeyboardShortcutsProvider', () => {
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()
@@ -296,7 +347,7 @@ describe('KeyboardShortcutsProvider', () => {
const newSession = vi.fn()
renderProvider()
- pressKey(document.body, { key: 'o', ctrlKey: true })
+ pressKey(document.body, { key: 'x', ctrlKey: true })
expect(screen.getByText('Waiting for shortcut key...')).toBeInTheDocument()
pressKey(document.body, { key: 'n' })
diff --git a/frontend/src/contexts/KeyboardShortcutsContext.tsx b/frontend/src/contexts/KeyboardShortcutsContext.tsx
index 4deec6acc..8a7a48a98 100644
--- a/frontend/src/contexts/KeyboardShortcutsContext.tsx
+++ b/frontend/src/contexts/KeyboardShortcutsContext.tsx
@@ -3,6 +3,8 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, ty
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
@@ -19,15 +21,9 @@ interface ShortcutRegistry {
const KeyboardShortcutsRegistryContext = createContext(null)
-function isTextInput(target: HTMLElement): boolean {
- return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
-}
-
const PROMPT_ONLY_DIRECT_ACTIONS: ReadonlySet = new Set(['toggleMode', 'clearPrompt'])
-function isPromptInput(target: HTMLElement): boolean {
- return target.hasAttribute('data-prompt-input')
-}
+const HALF_PAGE_DIRECT_ACTIONS: ReadonlySet = new Set(['halfPageUp', 'halfPageDown'])
function typesCharacter(shortcut: string): boolean {
return !hasCommandModifier(shortcut) && /(^|\+).$/.test(shortcut)
@@ -36,13 +32,15 @@ function typesCharacter(shortcut: string): boolean {
/**
* 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; `abort` runs from the prompt or with
- * no text field focused; other text inputs only accept modified shortcuts.
+ * 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 (!isTextInput(target)) return true
+ 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)
}
@@ -89,6 +87,7 @@ export function KeyboardShortcutsProvider({ children }: { children: ReactNode })
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()
@@ -112,7 +111,7 @@ export function KeyboardShortcutsProvider({ children }: { children: ReactNode })
return runAction(findShortcutAction(bindings, shortcut, false), e)
}
- if (shortcut === bindings.leaderKey && (!isTextInput(target) || hasCommandModifier(shortcut))) {
+ if (shortcut === bindings.leaderKey && (!isTextEntryElement(target) || hasCommandModifier(shortcut))) {
e.preventDefault()
setLeader(true)
return true
@@ -120,6 +119,7 @@ export function KeyboardShortcutsProvider({ children }: { children: ReactNode })
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)
}
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
index 125749394..b166cfc13 100644
--- a/frontend/src/hooks/useGlobalShortcutActions.test.tsx
+++ b/frontend/src/hooks/useGlobalShortcutActions.test.tsx
@@ -47,6 +47,11 @@ function pressKey(init: KeyboardEventInit) {
return event
}
+function pressLeaderFollowUp(key: string) {
+ pressKey({ key: 'o', ctrlKey: true })
+ pressKey({ key })
+}
+
describe('useGlobalShortcutActions', () => {
let queryClient: QueryClient
@@ -81,7 +86,7 @@ describe('useGlobalShortcutActions', () => {
it('opens the terminal dialog on the assistant host from the root route', async () => {
const { location } = renderHarness('/')
- pressKey({ key: '`', ctrlKey: true })
+ pressLeaderFollowUp('t')
await waitFor(() => expect(location()).toBe('/assistant?dialog=terminal'))
})
@@ -89,7 +94,7 @@ describe('useGlobalShortcutActions', () => {
it('navigates to the nearest repo host from a repo sub-route without the tool', async () => {
const { location } = renderHarness('/repos/5/schedules')
- pressKey({ key: '`', ctrlKey: true })
+ pressLeaderFollowUp('t')
await waitFor(() => expect(location()).toBe('/repos/5?dialog=terminal'))
})
@@ -97,10 +102,10 @@ describe('useGlobalShortcutActions', () => {
it('toggles the docked panel on and off when the route hosts the tool', async () => {
const { location } = renderHarness('/repos/5')
- pressKey({ key: '`', ctrlKey: true })
+ pressLeaderFollowUp('t')
await waitFor(() => expect(location()).toBe('/repos/5?panel=terminal'))
- pressKey({ key: '`', ctrlKey: true })
+ pressLeaderFollowUp('t')
await waitFor(() => expect(location()).toBe('/repos/5'))
})
@@ -108,7 +113,7 @@ describe('useGlobalShortcutActions', () => {
mocks.useMobile.mockReturnValue(true)
const { location } = renderHarness('/repos/5')
- pressKey({ key: '`', ctrlKey: true })
+ pressLeaderFollowUp('t')
await waitFor(() => expect(location()).toBe('/repos/5?dialog=terminal'))
})
diff --git a/frontend/src/hooks/useGlobalShortcutActions.ts b/frontend/src/hooks/useGlobalShortcutActions.ts
index abac29bcc..56befd1fe 100644
--- a/frontend/src/hooks/useGlobalShortcutActions.ts
+++ b/frontend/src/hooks/useGlobalShortcutActions.ts
@@ -1,9 +1,9 @@
-import { useCallback } from 'react'
+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 } from '@/components/navigation/moreDrawerItems'
+import { buildToolItems, toolKeyOf } from '@/components/navigation/moreDrawerItems'
import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext'
import { dialogSearch } from '@/hooks/useDialogParam'
import { useCreateSession } from '@/hooks/useOpenCode'
@@ -37,10 +37,11 @@ export function useGlobalShortcutActions(): void {
navigate(getSessionPath(repoId, session.id))
})
- const hasFilesTool = buildToolItems(pathname).some((item) => (item.panelTool ?? item.dialog) === 'files')
+ const toolItems = useMemo(() => buildToolItems(pathname), [pathname])
+ const hasFilesTool = toolItems.some((item) => toolKeyOf(item) === 'files')
const toggleRouteTool = useCallback((tool: PanelTool) => {
- if (buildToolItems(pathname).some((item) => (item.panelTool ?? item.dialog) === tool)) {
+ if (toolItems.some((item) => toolKeyOf(item) === tool)) {
updateParams((params) => {
if (isMobile) toggleToolDialogParams(params, tool)
else toggleToolPanelParams(params, tool)
@@ -50,7 +51,7 @@ export function useGlobalShortcutActions(): void {
if (tool === 'files') return
const host = repoId !== null ? getRepoPath(repoId) : getAssistantPath()
navigate(`${host}${dialogSearch(tool)}`)
- }, [pathname, isMobile, updateParams, navigate, repoId])
+ }, [toolItems, isMobile, updateParams, navigate, repoId])
const newSession = repoId !== null && repo?.fullPath && !createSession.isPending
? () => createSession.mutate({})
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.ts b/frontend/src/hooks/useToolPanel.ts
index 413892be2..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'
@@ -42,8 +43,7 @@ export function toggleToolDialogParams(params: URLSearchParams, tool: PanelTool)
clearToolParams(params)
return
}
- params.set('dialog', tool)
- params.delete('mobileTab')
+ openDialogParams(params, tool)
clearToolParams(params, tool)
}
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: ``, `