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