diff --git a/package-lock.json b/package-lock.json index a695e3d3e0..c5bfce8f87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.16.10-RC", + "version": "0.16.11-RC", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.16.10-RC", + "version": "0.16.11-RC", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { diff --git a/src/__tests__/web/hooks/useMobileKeyboardHandler.test.ts b/src/__tests__/web/hooks/useMobileKeyboardHandler.test.ts index 7360c49060..5f54043fe5 100644 --- a/src/__tests__/web/hooks/useMobileKeyboardHandler.test.ts +++ b/src/__tests__/web/hooks/useMobileKeyboardHandler.test.ts @@ -8,6 +8,7 @@ import { useMobileKeyboardHandler, type MobileKeyboardSession, } from '../../../web/hooks/useMobileKeyboardHandler'; +import { WEB_DEFAULT_SHORTCUTS } from '../../../web/constants/webShortcuts'; import type { AITabData } from '../../../web/hooks/useWebSocket'; function createTabs(): AITabData[] { @@ -51,17 +52,15 @@ describe('useMobileKeyboardHandler', () => { vi.restoreAllMocks(); }); - it('toggles input mode with Cmd+J', () => { - const handleModeToggle = vi.fn(); - const handleSelectTab = vi.fn(); + it('dispatches toggleMode on the configured shortcut (Cmd+J)', () => { + const toggleMode = vi.fn(); const activeSession: MobileKeyboardSession = { inputMode: 'ai' }; renderHook(() => useMobileKeyboardHandler({ - activeSessionId: 'session-1', + shortcuts: WEB_DEFAULT_SHORTCUTS, activeSession, - handleModeToggle, - handleSelectTab, + actions: { toggleMode }, }) ); @@ -71,78 +70,106 @@ describe('useMobileKeyboardHandler', () => { document.dispatchEvent(event); }); - expect(handleModeToggle).toHaveBeenCalledTimes(1); - expect(handleModeToggle).toHaveBeenCalledWith('terminal'); + expect(toggleMode).toHaveBeenCalledTimes(1); }); - it('cycles to previous and next tabs with Cmd+[ and Cmd+]', () => { - const handleModeToggle = vi.fn(); - const handleSelectTab = vi.fn(); - const tabs = createTabs(); + it('dispatches prevTab/nextTab on Cmd+Shift+[ and Cmd+Shift+]', () => { + const prevTab = vi.fn(); + const nextTab = vi.fn(); const activeSession: MobileKeyboardSession = { inputMode: 'ai', - aiTabs: tabs, + aiTabs: createTabs(), activeTabId: 'tab-2', }; renderHook(() => useMobileKeyboardHandler({ - activeSessionId: 'session-1', + shortcuts: WEB_DEFAULT_SHORTCUTS, activeSession, - handleModeToggle, - handleSelectTab, + actions: { prevTab, nextTab }, }) ); - const prevEvent = new KeyboardEvent('keydown', { key: '[', metaKey: true, cancelable: true }); - const nextEvent = new KeyboardEvent('keydown', { key: ']', metaKey: true, cancelable: true }); + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: '[', + metaKey: true, + shiftKey: true, + cancelable: true, + }) + ); + }); + expect(prevTab).toHaveBeenCalledTimes(1); act(() => { - document.dispatchEvent(prevEvent); + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: ']', + metaKey: true, + shiftKey: true, + cancelable: true, + }) + ); }); + expect(nextTab).toHaveBeenCalledTimes(1); + }); - expect(handleSelectTab).toHaveBeenCalledWith('tab-1'); + it('dispatches cyclePrev/cycleNext on Cmd+[ and Cmd+]', () => { + const cyclePrev = vi.fn(); + const cycleNext = vi.fn(); + + renderHook(() => + useMobileKeyboardHandler({ + shortcuts: WEB_DEFAULT_SHORTCUTS, + activeSession: { inputMode: 'ai' }, + actions: { cyclePrev, cycleNext }, + }) + ); act(() => { - document.dispatchEvent(nextEvent); + document.dispatchEvent( + new KeyboardEvent('keydown', { key: '[', metaKey: true, cancelable: true }) + ); }); + expect(cyclePrev).toHaveBeenCalledTimes(1); - expect(handleSelectTab).toHaveBeenCalledWith('tab-3'); + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: ']', metaKey: true, cancelable: true }) + ); + }); + expect(cycleNext).toHaveBeenCalledTimes(1); }); - it('does not handle shortcuts when there is no active session', () => { - const handleModeToggle = vi.fn(); - const handleSelectTab = vi.fn(); + it('closes the command palette on Escape when open', () => { + const onCloseCommandPalette = vi.fn(); renderHook(() => useMobileKeyboardHandler({ - activeSessionId: null, + shortcuts: WEB_DEFAULT_SHORTCUTS, activeSession: null, - handleModeToggle, - handleSelectTab, + isCommandPaletteOpen: true, + onCloseCommandPalette, + actions: {}, }) ); - const event = new KeyboardEvent('keydown', { key: 'j', metaKey: true, cancelable: true }); - act(() => { - document.dispatchEvent(event); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); }); - expect(handleModeToggle).not.toHaveBeenCalled(); + expect(onCloseCommandPalette).toHaveBeenCalledTimes(1); }); it('does not steal shortcuts from xterm when terminal is focused', () => { - const handleModeToggle = vi.fn(); - const handleSelectTab = vi.fn(); - const activeSession: MobileKeyboardSession = { inputMode: 'terminal' }; + const toggleMode = vi.fn(); renderHook(() => useMobileKeyboardHandler({ - activeSessionId: 'session-1', - activeSession, - handleModeToggle, - handleSelectTab, + shortcuts: WEB_DEFAULT_SHORTCUTS, + activeSession: { inputMode: 'terminal' }, + actions: { toggleMode }, }) ); @@ -161,7 +188,107 @@ describe('useMobileKeyboardHandler', () => { xtermInput.dispatchEvent(event); }); - expect(handleModeToggle).not.toHaveBeenCalled(); + expect(toggleMode).not.toHaveBeenCalled(); xtermInput.remove(); }); + + it('ignores events when no handler is registered for the matched shortcut', () => { + const quickAction = vi.fn(); + + renderHook(() => + useMobileKeyboardHandler({ + shortcuts: WEB_DEFAULT_SHORTCUTS, + activeSession: null, + actions: { quickAction }, + }) + ); + + // Cmd+J (toggleMode) should be a no-op since only quickAction is registered. + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'j', metaKey: true, cancelable: true }) + ); + }); + + expect(quickAction).not.toHaveBeenCalled(); + }); + + it('skips plain typing inside an input field', () => { + const newInstance = vi.fn(); + // Simulate a user-customized shortcut bound to a single bare key. + const shortcuts = { + ...WEB_DEFAULT_SHORTCUTS, + newInstance: { id: 'newInstance', label: 'New Agent', keys: ['n'] }, + }; + + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + + renderHook(() => + useMobileKeyboardHandler({ + shortcuts, + activeSession: null, + actions: { newInstance }, + }) + ); + + const event = new KeyboardEvent('keydown', { key: 'n', cancelable: true, bubbles: true }); + act(() => { + input.dispatchEvent(event); + }); + + expect(newInstance).not.toHaveBeenCalled(); + input.remove(); + }); + + it('still fires modifier shortcuts while an input field is focused', () => { + const quickAction = vi.fn(); + + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + + renderHook(() => + useMobileKeyboardHandler({ + shortcuts: WEB_DEFAULT_SHORTCUTS, + activeSession: null, + actions: { quickAction }, + }) + ); + + const event = new KeyboardEvent('keydown', { + key: 'k', + metaKey: true, + cancelable: true, + bubbles: true, + }); + act(() => { + input.dispatchEvent(event); + }); + + expect(quickAction).toHaveBeenCalledTimes(1); + input.remove(); + }); + + it('does not match an empty or modifier-only shortcut definition', () => { + const newInstance = vi.fn(); + const shortcuts = { + newInstance: { id: 'newInstance', label: 'New Agent', keys: [] }, + }; + + renderHook(() => + useMobileKeyboardHandler({ + shortcuts, + activeSession: null, + actions: { newInstance }, + }) + ); + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', cancelable: true })); + }); + + expect(newInstance).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/web/mobile/App.test.tsx b/src/__tests__/web/mobile/App.test.tsx index b2378027b8..2ba315a0a7 100644 --- a/src/__tests__/web/mobile/App.test.tsx +++ b/src/__tests__/web/mobile/App.test.tsx @@ -2112,7 +2112,7 @@ describe('MobileApp', () => { }); }); - it('navigates to previous tab with Cmd+[', async () => { + it('navigates to previous tab with Cmd+Shift+[', async () => { render(); await act(async () => { @@ -2129,7 +2129,7 @@ describe('MobileApp', () => { ]); }); - fireEvent.keyDown(document, { key: '[', metaKey: true }); + fireEvent.keyDown(document, { key: '[', metaKey: true, shiftKey: true }); expect(mockSend).toHaveBeenCalledWith({ type: 'select_tab', @@ -2138,7 +2138,7 @@ describe('MobileApp', () => { }); }); - it('navigates to next tab with Cmd+]', async () => { + it('navigates to next tab with Cmd+Shift+]', async () => { render(); await act(async () => { @@ -2155,7 +2155,7 @@ describe('MobileApp', () => { ]); }); - fireEvent.keyDown(document, { key: ']', metaKey: true }); + fireEvent.keyDown(document, { key: ']', metaKey: true, shiftKey: true }); expect(mockSend).toHaveBeenCalledWith({ type: 'select_tab', @@ -2181,7 +2181,7 @@ describe('MobileApp', () => { ]); }); - fireEvent.keyDown(document, { key: ']', metaKey: true }); + fireEvent.keyDown(document, { key: ']', metaKey: true, shiftKey: true }); expect(mockSend).toHaveBeenCalledWith({ type: 'select_tab', diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index 3157ca47b6..70fe4ba3c6 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -377,6 +377,7 @@ export class CallbackRegistry { audioFeedbackEnabled: false, colorBlindMode: 'false', conductorProfile: '', + shortcuts: {}, }; } diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index a9d278e733..7b90964935 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -5,6 +5,7 @@ import type { WebSocket } from 'ws'; import type { Theme } from '../../shared/theme-types'; +import type { Shortcut } from '../../shared/shortcut-types'; // Re-export Theme for convenience export type { Theme } from '../../shared/theme-types'; @@ -377,6 +378,8 @@ export interface WebSettings { audioFeedbackEnabled: boolean; colorBlindMode: string; conductorProfile: string; + /** User-customized keyboard shortcuts (partial overrides of DEFAULT_SHORTCUTS). */ + shortcuts: Record; } /** diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index f23037e122..62726920f1 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -13,6 +13,7 @@ import { isWebContentsAvailable } from '../utils/safe-send'; import type { ProcessManager } from '../process-manager'; import type { StoredSession, SettingsStoreInterface as SettingsStore } from '../stores/types'; import type { Group } from '../../shared/types'; +import type { Shortcut } from '../../shared/shortcut-types'; import { getDefaultShell } from '../stores/defaults'; /** UUID v4 format regex for validating stored security tokens. @@ -960,6 +961,7 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { audioFeedbackEnabled: settingsStore.get('audioFeedbackEnabled', false) as boolean, colorBlindMode: settingsStore.get('colorBlindMode', 'false') as string, conductorProfile: settingsStore.get('conductorProfile', '') as string, + shortcuts: settingsStore.get('shortcuts', {}) as Record, }; }); @@ -996,6 +998,10 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { audioFeedbackEnabled: settingsStore.get('audioFeedbackEnabled', false) as boolean, colorBlindMode: settingsStore.get('colorBlindMode', 'false') as string, conductorProfile: settingsStore.get('conductorProfile', '') as string, + shortcuts: settingsStore.get('shortcuts', {}) as Record< + string, + import('../../shared/shortcut-types').Shortcut + >, }; server.broadcastSettingsChanged(settings); } diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 6afd8fa8e8..8bebec2ebe 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -174,11 +174,7 @@ export interface SessionWizardState { toolExecutions?: Array<{ toolName: string; state?: unknown; timestamp: number }>; } -export interface Shortcut { - id: string; - label: string; - keys: string[]; -} +export type { Shortcut } from '../../shared/shortcut-types'; export interface FileArtifact { path: string; diff --git a/src/shared/shortcut-types.ts b/src/shared/shortcut-types.ts new file mode 100644 index 0000000000..a0a4dc1a3c --- /dev/null +++ b/src/shared/shortcut-types.ts @@ -0,0 +1,9 @@ +/** + * Shared keyboard shortcut type used by renderer, main (web server), and web client. + */ + +export interface Shortcut { + id: string; + label: string; + keys: string[]; +} diff --git a/src/web/constants/webShortcuts.ts b/src/web/constants/webShortcuts.ts new file mode 100644 index 0000000000..41c7dbb596 --- /dev/null +++ b/src/web/constants/webShortcuts.ts @@ -0,0 +1,56 @@ +/** + * Curated subset of desktop shortcuts that map to actions the mobile web UI + * actually implements. The web client reads user customizations from + * `settings.shortcuts` and merges them on top of these defaults. + */ + +import type { Shortcut } from '../../shared/shortcut-types'; +import { DEFAULT_SHORTCUTS } from '../../renderer/constants/shortcuts'; + +/** Action IDs the mobile web UI supports. Keys match desktop shortcut IDs. */ +export const WEB_SHORTCUT_IDS = [ + 'quickAction', + 'toggleMode', + 'prevTab', + 'nextTab', + 'cyclePrev', + 'cycleNext', + 'newInstance', + 'settings', + 'goToFiles', + 'goToHistory', + 'goToAutoRun', + 'agentSessions', + 'usageDashboard', + 'openCue', + 'newGroupChat', + 'killInstance', +] as const; + +export type WebShortcutId = (typeof WEB_SHORTCUT_IDS)[number]; + +/** Defaults for the web-supported subset (filtered from DEFAULT_SHORTCUTS). */ +export const WEB_DEFAULT_SHORTCUTS: Record = WEB_SHORTCUT_IDS.reduce( + (acc, id) => { + const sc = DEFAULT_SHORTCUTS[id]; + if (sc) acc[id] = sc; + return acc; + }, + {} as Record +); + +/** + * Merge user shortcut overrides on top of the web defaults. + * Ignores overrides for action IDs the web UI doesn't implement. + */ +export function resolveWebShortcuts( + userOverrides: Record | undefined +): Record { + if (!userOverrides) return WEB_DEFAULT_SHORTCUTS; + const merged: Record = { ...WEB_DEFAULT_SHORTCUTS }; + for (const id of WEB_SHORTCUT_IDS) { + const override = userOverrides[id]; + if (override) merged[id] = override; + } + return merged; +} diff --git a/src/web/hooks/index.ts b/src/web/hooks/index.ts index 5bd05cb003..e63edf133c 100644 --- a/src/web/hooks/index.ts +++ b/src/web/hooks/index.ts @@ -148,7 +148,7 @@ export { export type { MobileKeyboardSession, - MobileInputMode, + MobileShortcutActions, UseMobileKeyboardHandlerDeps, } from './useMobileKeyboardHandler'; diff --git a/src/web/hooks/useMobileKeyboardHandler.ts b/src/web/hooks/useMobileKeyboardHandler.ts index 1d40f660e0..bd43aea051 100644 --- a/src/web/hooks/useMobileKeyboardHandler.ts +++ b/src/web/hooks/useMobileKeyboardHandler.ts @@ -1,178 +1,204 @@ /** * useMobileKeyboardHandler - Mobile keyboard shortcuts handler hook * - * Handles keyboard shortcuts for the mobile web interface: - * - Cmd+K / Ctrl+K: Toggle command palette - * - Escape: Close command palette - * - Cmd+J / Ctrl+J: Toggle between AI and Terminal mode - * - Cmd+[ / Ctrl+[: Switch to previous tab - * - Cmd+] / Ctrl+]: Switch to next tab - * - * Extracted from mobile App.tsx for code organization. + * Matches DOM KeyboardEvents against a user-configurable shortcuts map (shared + * with the desktop app) and dispatches to per-action handler callbacks. The + * palette's Escape-to-close behavior is hardcoded since desktop treats modal + * Escape the same way. * * @example * ```tsx * useMobileKeyboardHandler({ - * activeSessionId, + * shortcuts, * activeSession, - * handleModeToggle, - * handleSelectTab, + * isCommandPaletteOpen, + * onCloseCommandPalette, + * actions: { + * quickAction: openPalette, + * toggleMode: () => handleModeToggle('terminal'), + * prevTab: prevTab, + * nextTab: nextTab, + * }, * }); * ``` */ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; +import type { Shortcut } from '../../shared/shortcut-types'; +import type { WebShortcutId } from '../constants/webShortcuts'; import type { AITabData } from './useWebSocket'; /** - * Session type for the mobile keyboard handler - * Only includes fields needed for keyboard handling - * Kept minimal to accept any object with these optional fields + * Session type for the mobile keyboard handler. + * Only includes fields needed for xterm isolation. */ export type MobileKeyboardSession = { - /** Current input mode */ inputMode?: string; - /** Array of AI tabs */ aiTabs?: AITabData[]; - /** Currently active tab ID */ activeTabId?: string; }; -/** - * Input mode type for the handler - */ -export type MobileInputMode = 'ai' | 'terminal'; +/** Per-action handler map. Each key is a web-supported shortcut ID. */ +export type MobileShortcutActions = Partial void>>; /** * Dependencies for useMobileKeyboardHandler */ export interface UseMobileKeyboardHandlerDeps { - /** ID of the currently active session */ - activeSessionId: string | null; - /** The currently active session object */ + /** Resolved shortcut map (defaults merged with user overrides from settings). */ + shortcuts: Record; + /** The currently active session (used for xterm isolation). */ activeSession: MobileKeyboardSession | null | undefined; - /** Handler to toggle between AI and Terminal mode */ - handleModeToggle: (mode: MobileInputMode) => void; - /** Handler to select a tab */ - handleSelectTab: (tabId: string) => void; - /** Handler to open the command palette (Cmd+K / Ctrl+K) */ - onOpenCommandPalette?: () => void; - /** Handler to close the command palette (Escape) */ - onCloseCommandPalette?: () => void; - /** Whether the command palette is currently open */ + /** Whether the command palette is currently open (for Escape handling). */ isCommandPaletteOpen?: boolean; + /** Close handler invoked on Escape when the palette is open. */ + onCloseCommandPalette?: () => void; + /** Dispatch table: action ID -> callback. */ + actions: MobileShortcutActions; } /** - * Hook for handling keyboard shortcuts in the mobile web interface + * Match a KeyboardEvent against a Shortcut definition. * - * Registers event listeners for keyboard shortcuts and invokes the - * appropriate handlers when shortcuts are pressed. + * Mirrors the logic in `src/renderer/hooks/keyboard/useKeyboardShortcutHelpers.ts` + * but inlined here to avoid importing a React hook from a renderer path. Kept in + * sync manually — update both if matching rules change. + */ +const MODIFIER_KEYS = new Set(['meta', 'ctrl', 'command', 'shift', 'alt']); + +function matchesShortcut(e: KeyboardEvent, sc: Shortcut | undefined): boolean { + if (!sc) return false; + const keys = sc.keys.map((k) => k.toLowerCase()); + if (keys.length === 0) return false; + + const mainKey = keys[keys.length - 1]; + // Skip cleared / modifier-only shortcut definitions to avoid matching ordinary typing. + if (!mainKey || MODIFIER_KEYS.has(mainKey)) return false; + + const metaPressed = e.metaKey || e.ctrlKey; + const shiftPressed = e.shiftKey; + const altPressed = e.altKey; + const key = e.key.toLowerCase(); + + const configMeta = keys.includes('meta') || keys.includes('ctrl') || keys.includes('command'); + const configShift = keys.includes('shift'); + const configAlt = keys.includes('alt'); + + if (metaPressed !== configMeta) return false; + if (shiftPressed !== configShift) return false; + if (altPressed !== configAlt) return false; + + if (mainKey === '/' && key === '/') return true; + if (mainKey === 'arrowleft' && key === 'arrowleft') return true; + if (mainKey === 'arrowright' && key === 'arrowright') return true; + if (mainKey === 'arrowup' && key === 'arrowup') return true; + if (mainKey === 'arrowdown' && key === 'arrowdown') return true; + if (mainKey === 'backspace' && key === 'backspace') return true; + if (mainKey === '[' && (key === '[' || key === '{')) return true; + if (mainKey === ']' && (key === ']' || key === '}')) return true; + if (mainKey === ',' && (key === ',' || key === '<')) return true; + if (mainKey === '.' && (key === '.' || key === '>')) return true; + + const shiftNumberMap: Record = { + '!': '1', + '@': '2', + '#': '3', + $: '4', + '%': '5', + '^': '6', + '&': '7', + '*': '8', + '(': '9', + ')': '0', + }; + if (shiftNumberMap[key] === mainKey) return true; + + // macOS Alt produces special characters; fall back to physical key via e.code. + if (altPressed && e.code) { + const codeKey = e.code.replace('Key', '').toLowerCase(); + const codeToKey: Record = { + comma: ',', + period: '.', + slash: '/', + backslash: '\\', + bracketleft: '[', + bracketright: ']', + semicolon: ';', + quote: "'", + backquote: '`', + minus: '-', + equal: '=', + }; + const mappedKey = codeToKey[codeKey] || codeKey; + return mappedKey === mainKey; + } + + return key === mainKey; +} + +/** + * Hook for handling keyboard shortcuts in the mobile web interface. * - * @param deps - Dependencies including session state and handlers + * Registers a single stable event listener (ref-based context updates) and + * dispatches matched events to the supplied action callbacks. */ export function useMobileKeyboardHandler(deps: UseMobileKeyboardHandlerDeps): void { - const { - activeSessionId, - activeSession, - handleModeToggle, - handleSelectTab, - onOpenCommandPalette, - onCloseCommandPalette, - isCommandPaletteOpen, - } = deps; + const depsRef = useRef(deps); + depsRef.current = deps; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + const { shortcuts, activeSession, isCommandPaletteOpen, onCloseCommandPalette, actions } = + depsRef.current; + + // Keep keystrokes inside a live terminal UI. const target = e.target; const activeElement = document.activeElement; const isXtermElement = (el: EventTarget | null) => el instanceof Element && (el.classList.contains('xterm-helper-textarea') || !!el.closest('.xterm')); const isXtermTarget = isXtermElement(target) || isXtermElement(activeElement); - if (activeSession?.inputMode === 'terminal' && isXtermTarget) { - // Keep terminal keystrokes inside xterm while a terminal app is active. - return; - } - - // Cmd+K / Ctrl+K: Open command palette - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - if (isCommandPaletteOpen && onCloseCommandPalette) { - e.preventDefault(); - onCloseCommandPalette(); - } else if (onOpenCommandPalette) { - e.preventDefault(); - onOpenCommandPalette(); - } - return; - } + if (activeSession?.inputMode === 'terminal' && isXtermTarget) return; - // Escape: Close command palette if open + // Escape closes the command palette. Not a configurable shortcut — mirrors + // desktop modal behavior. if (e.key === 'Escape' && isCommandPaletteOpen && onCloseCommandPalette) { e.preventDefault(); onCloseCommandPalette(); return; } - // Check for Cmd+J (Mac) or Ctrl+J (Windows/Linux) to toggle AI/CLI mode - if ((e.metaKey || e.ctrlKey) && e.key === 'j') { - e.preventDefault(); - if (!activeSessionId) return; - - // Toggle mode - const currentMode = activeSession?.inputMode || 'ai'; - const newMode: MobileInputMode = currentMode === 'ai' ? 'terminal' : 'ai'; - handleModeToggle(newMode); - return; - } - - // Cmd+[ or Ctrl+[ - Previous tab - if ((e.metaKey || e.ctrlKey) && e.key === '[') { - e.preventDefault(); - if (!activeSession?.aiTabs || activeSession.aiTabs.length < 2) return; - - const currentIndex = activeSession.aiTabs.findIndex( - (t) => t.id === activeSession.activeTabId - ); - if (currentIndex === -1) return; - - // Wrap around to last tab if at beginning - const prevIndex = - (currentIndex - 1 + activeSession.aiTabs.length) % activeSession.aiTabs.length; - const prevTab = activeSession.aiTabs[prevIndex]; - handleSelectTab(prevTab.id); + // Don't fire shortcuts on plain typing inside editable fields. Modifier-key + // shortcuts (Cmd/Ctrl/Alt) still fire so palette / mode toggle work from the input. + const isEditableElement = (el: EventTarget | null) => + el instanceof HTMLElement && + (el.isContentEditable || + el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.tagName === 'SELECT'); + if ( + !e.metaKey && + !e.ctrlKey && + !e.altKey && + (isEditableElement(target) || isEditableElement(activeElement)) + ) { return; } - // Cmd+] or Ctrl+] - Next tab - if ((e.metaKey || e.ctrlKey) && e.key === ']') { - e.preventDefault(); - if (!activeSession?.aiTabs || activeSession.aiTabs.length < 2) return; - - const currentIndex = activeSession.aiTabs.findIndex( - (t) => t.id === activeSession.activeTabId - ); - if (currentIndex === -1) return; - - // Wrap around to first tab if at end - const nextIndex = (currentIndex + 1) % activeSession.aiTabs.length; - const nextTab = activeSession.aiTabs[nextIndex]; - handleSelectTab(nextTab.id); - return; + for (const id of Object.keys(actions) as WebShortcutId[]) { + const handler = actions[id]; + if (!handler) continue; + if (matchesShortcut(e, shortcuts[id])) { + e.preventDefault(); + handler(); + return; + } } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [ - activeSessionId, - activeSession, - handleModeToggle, - handleSelectTab, - onOpenCommandPalette, - onCloseCommandPalette, - isCommandPaletteOpen, - ]); + }, []); } export default useMobileKeyboardHandler; diff --git a/src/web/hooks/useWebSocket.ts b/src/web/hooks/useWebSocket.ts index 773ca72d8f..a43f0b2521 100644 --- a/src/web/hooks/useWebSocket.ts +++ b/src/web/hooks/useWebSocket.ts @@ -354,6 +354,8 @@ export interface SettingsChangedMessage extends ServerMessage { audioFeedbackEnabled: boolean; colorBlindMode: string; conductorProfile: string; + /** User-customized keyboard shortcut overrides (sparse; unset = use default). */ + shortcuts: Record; }; } diff --git a/src/web/mobile/App.tsx b/src/web/mobile/App.tsx index 28271c3568..921049f812 100644 --- a/src/web/mobile/App.tsx +++ b/src/web/mobile/App.tsx @@ -66,6 +66,7 @@ import type { Session, LastResponsePreview } from '../hooks/useSessions'; // Keeping import for TypeScript types only if needed import { QuickActionsMenu, type CommandPaletteAction } from './QuickActionsMenu'; import { useMobileKeyboardHandler } from '../hooks/useMobileKeyboardHandler'; +import { resolveWebShortcuts } from '../constants/webShortcuts'; import { useMobileViewState } from '../hooks/useMobileViewState'; import { useMobileAutoReconnect } from '../hooks/useMobileAutoReconnect'; @@ -2117,15 +2118,67 @@ export default function MobileApp() { setShowCommandPalette(false); }, []); - // Keyboard shortcuts (Cmd+J mode toggle, Cmd+[/] tab navigation, Cmd+K command palette) + // Configurable keyboard shortcuts — defaults merged with user overrides from + // desktop settings. Web-supported action IDs are curated in webShortcuts.ts. + const resolvedShortcuts = useMemo( + () => resolveWebShortcuts(settingsHook.settings?.shortcuts), + [settingsHook.settings?.shortcuts] + ); + useMobileKeyboardHandler({ - activeSessionId, + shortcuts: resolvedShortcuts, activeSession, - handleModeToggle, - handleSelectTab, - onOpenCommandPalette: handleOpenCommandPalette, - onCloseCommandPalette: handleCloseCommandPalette, isCommandPaletteOpen: showCommandPalette, + onCloseCommandPalette: handleCloseCommandPalette, + actions: { + quickAction: () => { + if (showCommandPalette) handleCloseCommandPalette(); + else handleOpenCommandPalette(); + }, + toggleMode: () => { + if (!activeSessionId) return; + const currentMode = activeSession?.inputMode || 'ai'; + handleModeToggle(currentMode === 'ai' ? 'terminal' : 'ai'); + }, + prevTab: () => { + const tabs = activeSession?.aiTabs; + if (!tabs || tabs.length < 2) return; + const i = tabs.findIndex((t) => t.id === activeSession?.activeTabId); + if (i === -1) return; + handleSelectTab(tabs[(i - 1 + tabs.length) % tabs.length].id); + }, + nextTab: () => { + const tabs = activeSession?.aiTabs; + if (!tabs || tabs.length < 2) return; + const i = tabs.findIndex((t) => t.id === activeSession?.activeTabId); + if (i === -1) return; + handleSelectTab(tabs[(i + 1) % tabs.length].id); + }, + cyclePrev: () => { + if (sessions.length < 2) return; + const i = sessions.findIndex((s) => s.id === activeSessionId); + if (i === -1) return; + handleSelectSession(sessions[(i - 1 + sessions.length) % sessions.length].id); + }, + cycleNext: () => { + if (sessions.length < 2) return; + const i = sessions.findIndex((s) => s.id === activeSessionId); + if (i === -1) return; + handleSelectSession(sessions[(i + 1) % sessions.length].id); + }, + newInstance: () => setShowAgentCreation(true), + settings: () => setShowSettingsPanel(true), + goToFiles: () => handleOpenRightDrawer('files'), + goToHistory: () => handleOpenRightDrawer('history'), + goToAutoRun: () => handleOpenRightDrawer('autorun'), + agentSessions: () => setShowAllSessions(true), + usageDashboard: () => setShowUsageDashboard(true), + openCue: () => setShowCuePanel(true), + newGroupChat: () => setShowGroupChatSetup(true), + killInstance: () => { + void handleInterrupt(); + }, + }, }); // Swipe-from-edge gestures to open left panel / right drawer