Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ export type { GatewayDispatcherDeps } from './services/gateway-dispatcher.js';
export { createChatComposer } from './services/chat-composer.js';
export type { ChatComposerDeps, ChatComposer, SendOptions } from './services/chat-composer.js';

export {
hasPreferencesShortcutConflict,
isTextInputElement,
resolveGlobalShortcutAction,
shouldDeferGlobalShortcut,
} from './lib/global-shortcuts.js';
export type { GlobalShortcutAction, GlobalShortcutConfig } from './lib/global-shortcuts.js';

export { createSystemSessionService } from './services/system-session-service.js';
export type { SystemSessionServiceDeps, SystemSessionService } from './services/system-session-service.js';

Expand Down
73 changes: 73 additions & 0 deletions packages/core/src/lib/global-shortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { PanelShortcutLeft, PanelShortcutRight } from '../stores/ui-store.js';

export type GlobalShortcutAction =
| 'new-task'
| 'open-files'
| 'toggle-command-palette'
| 'toggle-left-nav'
| 'toggle-right-panel';

export interface GlobalShortcutConfig {
leftNavShortcut: PanelShortcutLeft;
rightPanelShortcut: PanelShortcutRight;
}

const TEXT_INPUT_SELECTOR =
'input:not([type=checkbox]):not([type=radio]):not([type=button]):not([type=submit]):not([type=reset]), textarea, select, [contenteditable=""], [contenteditable="true"]';
Comment on lines +15 to +16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The TEXT_INPUT_SELECTOR only matches contenteditable="true" and contenteditable="". However, modern rich text editors and chat inputs frequently use contenteditable="plaintext-only" to allow auto-resizing while preventing formatted rich-text pasting. We should update the selector to match any element with a contenteditable attribute that is not explicitly set to "false".

Suggested change
const TEXT_INPUT_SELECTOR =
'input:not([type=checkbox]):not([type=radio]):not([type=button]):not([type=submit]):not([type=reset]), textarea, select, [contenteditable=""], [contenteditable="true"]';
const TEXT_INPUT_SELECTOR =
'input:not([type=checkbox]):not([type=radio]):not([type=button]):not([type=submit]):not([type=reset]), textarea, select, [contenteditable]:not([contenteditable="false"])';


type SelectorCapableElement = EventTarget & {
matches: (selector: string) => boolean;
closest: (selector: string) => Element | null;
};

function isElementWithSelectorSupport(target: EventTarget | null): target is SelectorCapableElement {
return (
typeof target === 'object' &&
target !== null &&
'matches' in target &&
typeof (target as { matches?: unknown }).matches === 'function' &&
'closest' in target &&
typeof (target as { closest?: unknown }).closest === 'function'
);
}

export function isTextInputElement(target: EventTarget | null): boolean {
if (!isElementWithSelectorSupport(target)) return false;
if (target.matches(TEXT_INPUT_SELECTOR)) return true;
return Boolean(target.closest('[contenteditable="true"], [contenteditable=""]'));
}
Comment on lines +34 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To fully support contenteditable="plaintext-only" elements, the closest check in isTextInputElement should also be updated to match any non-false contenteditable ancestor.

Suggested change
export function isTextInputElement(target: EventTarget | null): boolean {
if (!isElementWithSelectorSupport(target)) return false;
if (target.matches(TEXT_INPUT_SELECTOR)) return true;
return Boolean(target.closest('[contenteditable="true"], [contenteditable=""]'));
}
export function isTextInputElement(target: EventTarget | null): boolean {
if (!isElementWithSelectorSupport(target)) return false;
if (target.matches(TEXT_INPUT_SELECTOR)) return true;
return Boolean(target.closest('[contenteditable]:not([contenteditable="false"])'));
}


export function shouldDeferGlobalShortcut(
event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'shiftKey' | 'code' | 'target'>,
): boolean {
if (!isTextInputElement(event.target)) return false;
const meta = event.metaKey || event.ctrlKey;
if (meta && !event.shiftKey && event.code === 'KeyK') return false;
return true;
}
Comment on lines +40 to +47

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of shouldDeferGlobalShortcut defers all global shortcuts except Cmd/Ctrl+K when focus is inside a text input. This introduces a regression where standard, non-conflicting application shortcuts like Cmd+Shift+O (New Task) and Cmd+Shift+F (Open Files) will no longer work when the user is focusing the chat input or any other text field. We should explicitly allow these non-conflicting global shortcuts to pass through even when a text input is focused, while still deferring configurable panel shortcuts.

export function shouldDeferGlobalShortcut(
  event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'shiftKey' | 'code' | 'target'>,
): boolean {
  if (!isTextInputElement(event.target)) return false;
  const meta = event.metaKey || event.ctrlKey;
  if (meta) {
    if (!event.shiftKey && event.code === 'KeyK') return false;
    if (event.shiftKey && (event.code === 'KeyO' || event.code === 'KeyF')) return false;
  }
  return true;
}


export function resolveGlobalShortcutAction(
event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'shiftKey' | 'code'>,
config: GlobalShortcutConfig,
): GlobalShortcutAction | null {
const meta = event.metaKey || event.ctrlKey;
if (!meta) return null;

if (event.shiftKey && event.code === 'KeyO') return 'new-task';
if (event.shiftKey && event.code === 'KeyF') return 'open-files';
if (!event.shiftKey && event.code === 'KeyK') return 'toggle-command-palette';

if (!event.shiftKey && config.leftNavShortcut !== 'None' && event.code === config.leftNavShortcut) {
return 'toggle-left-nav';
}

if (!event.shiftKey && config.rightPanelShortcut !== 'None' && event.code === config.rightPanelShortcut) {
return 'toggle-right-panel';
}

return null;
}

export function hasPreferencesShortcutConflict(leftNavShortcut: PanelShortcutLeft, platform: string): boolean {
return platform === 'darwin' && leftNavShortcut === 'Comma';
}
4 changes: 2 additions & 2 deletions packages/core/src/ports/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export interface AppSettings {
density?: 'compact' | 'comfortable' | 'spacious';
language?: string;
sendShortcut?: 'enter' | 'cmdEnter';
leftNavShortcut?: 'Comma' | 'BracketLeft';
rightPanelShortcut?: 'Period' | 'BracketRight';
leftNavShortcut?: 'Comma' | 'BracketLeft' | 'None';
rightPanelShortcut?: 'Period' | 'BracketRight' | 'None';
devMode?: boolean;
notifications?: NotificationSettings;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/stores/ui-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export type Theme = 'dark' | 'light' | 'auto';
export type DensityMode = 'compact' | 'comfortable' | 'spacious';
export type SendShortcut = 'enter' | 'cmdEnter';
export type MessageLayout = 'centered' | 'wide';
export type PanelShortcutLeft = 'Comma' | 'BracketLeft';
export type PanelShortcutRight = 'Period' | 'BracketRight';
export type PanelShortcutLeft = 'Comma' | 'BracketLeft' | 'None';
export type PanelShortcutRight = 'Period' | 'BracketRight' | 'None';
export interface GatewayInfo {
id: string;
name: string;
Expand Down
106 changes: 106 additions & 0 deletions packages/core/test/global-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import {
hasPreferencesShortcutConflict,
isTextInputElement,
resolveGlobalShortcutAction,
shouldDeferGlobalShortcut,
} from '../src/lib/global-shortcuts.js';

function keyEvent(
partial: Partial<KeyboardEvent> & Pick<KeyboardEvent, 'code'>,
target: EventTarget | null = null,
): KeyboardEvent {
return {
metaKey: false,
ctrlKey: false,
shiftKey: false,
target,
...partial,
} as KeyboardEvent;
}

function mockTextInput(): EventTarget {
return {
matches: () => true,
closest: () => null,
} as unknown as EventTarget;
}

function mockNonInput(): EventTarget {
return {
matches: () => false,
closest: () => null,
} as unknown as EventTarget;
}

const defaultConfig = {
leftNavShortcut: 'Comma' as const,
rightPanelShortcut: 'Period' as const,
};

describe('resolveGlobalShortcutAction', () => {
it('does not bind Ctrl/Cmd+P', () => {
expect(resolveGlobalShortcutAction(keyEvent({ ctrlKey: true, code: 'KeyP' }), defaultConfig)).toBeNull();
expect(resolveGlobalShortcutAction(keyEvent({ metaKey: true, code: 'KeyP' }), defaultConfig)).toBeNull();
});

it('resolves built-in shortcuts', () => {
expect(resolveGlobalShortcutAction(keyEvent({ metaKey: true, shiftKey: true, code: 'KeyO' }), defaultConfig)).toBe(
'new-task',
);
expect(resolveGlobalShortcutAction(keyEvent({ metaKey: true, shiftKey: true, code: 'KeyF' }), defaultConfig)).toBe(
'open-files',
);
expect(resolveGlobalShortcutAction(keyEvent({ metaKey: true, code: 'KeyK' }), defaultConfig)).toBe(
'toggle-command-palette',
);
});

it('respects configurable panel shortcuts', () => {
expect(resolveGlobalShortcutAction(keyEvent({ metaKey: true, code: 'Comma' }), defaultConfig)).toBe(
'toggle-left-nav',
);
expect(resolveGlobalShortcutAction(keyEvent({ metaKey: true, code: 'Period' }), defaultConfig)).toBe(
'toggle-right-panel',
);
});

it('skips panel shortcuts when set to None', () => {
const disabled = { leftNavShortcut: 'None' as const, rightPanelShortcut: 'None' as const };
expect(resolveGlobalShortcutAction(keyEvent({ metaKey: true, code: 'Comma' }), disabled)).toBeNull();
expect(resolveGlobalShortcutAction(keyEvent({ metaKey: true, code: 'Period' }), disabled)).toBeNull();
});
});

describe('shouldDeferGlobalShortcut', () => {
it('defers panel shortcuts while typing in a text field', () => {
expect(shouldDeferGlobalShortcut(keyEvent({ metaKey: true, code: 'Comma' }, mockTextInput()))).toBe(true);
});

it('still allows Cmd/Ctrl+K inside text fields', () => {
expect(shouldDeferGlobalShortcut(keyEvent({ metaKey: true, code: 'KeyK' }, mockTextInput()))).toBe(false);
});

it('does not defer shortcuts outside text inputs', () => {
expect(shouldDeferGlobalShortcut(keyEvent({ metaKey: true, code: 'Comma' }, mockNonInput()))).toBe(false);
});

it('defers Ctrl/Cmd+P inside text fields so print can pass through', () => {
expect(shouldDeferGlobalShortcut(keyEvent({ ctrlKey: true, code: 'KeyP' }, mockTextInput()))).toBe(true);
});
Comment on lines +80 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We should add tests to verify that standard application shortcuts like Cmd+Shift+O and Cmd+Shift+F are not deferred when typing in a text field, ensuring our shortcut passthrough logic is fully covered and regression-tested.

  it('still allows Cmd/Ctrl+K inside text fields', () => {
    expect(shouldDeferGlobalShortcut(keyEvent({ metaKey: true, code: 'KeyK' }, mockTextInput()))).toBe(false);
  });

  it('still allows Cmd/Ctrl+Shift+O and Cmd/Ctrl+Shift+F inside text fields', () => {
    expect(shouldDeferGlobalShortcut(keyEvent({ metaKey: true, shiftKey: true, code: 'KeyO' }, mockTextInput()))).toBe(false);
    expect(shouldDeferGlobalShortcut(keyEvent({ metaKey: true, shiftKey: true, code: 'KeyF' }, mockTextInput()))).toBe(false);
  });

  it('does not defer shortcuts outside text inputs', () => {
    expect(shouldDeferGlobalShortcut(keyEvent({ metaKey: true, code: 'Comma' }, mockNonInput()))).toBe(false);
  });

  it('defers Ctrl/Cmd+P inside text fields so print can pass through', () => {
    expect(shouldDeferGlobalShortcut(keyEvent({ ctrlKey: true, code: 'KeyP' }, mockTextInput()))).toBe(true);
  });

});

describe('isTextInputElement', () => {
it('detects editable targets', () => {
expect(isTextInputElement(mockTextInput())).toBe(true);
expect(isTextInputElement(mockNonInput())).toBe(false);
});
});

describe('hasPreferencesShortcutConflict', () => {
it('flags Cmd+, on macOS', () => {
expect(hasPreferencesShortcutConflict('Comma', 'darwin')).toBe(true);
expect(hasPreferencesShortcutConflict('BracketLeft', 'darwin')).toBe(false);
expect(hasPreferencesShortcutConflict('Comma', 'win32')).toBe(false);
});
});
13 changes: 12 additions & 1 deletion packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,18 @@ function buildAppMenu(devMode = false): Menu {
: []),
{
label: 'File',
submenu: [isMac ? { role: 'close' as const, accelerator: 'Command+W' } : { role: 'quit' as const }],
submenu: [
{
label: 'Print',
accelerator: isMac ? 'Command+P' : 'Ctrl+P',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.print({});
},
},
{ type: 'separator' as const },
isMac ? { role: 'close' as const, accelerator: 'Command+W' } : { role: 'quit' as const },
],
},
{ role: 'editMenu' as const },
{
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/src/main/workspace/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export interface AppConfig {
quickLaunch?: QuickLaunchConfig;
trayEnabled?: boolean;
notifications?: NotificationConfig;
leftNavShortcut?: 'Comma' | 'BracketLeft';
rightPanelShortcut?: 'Period' | 'BracketRight';
leftNavShortcut?: 'Comma' | 'BracketLeft' | 'None';
rightPanelShortcut?: 'Period' | 'BracketRight' | 'None';
deviceId?: string;
zoomLevel?: number;
devMode?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/src/preload/clawwork.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ interface AppSettings {
quickLaunch?: QuickLaunchSettings;
trayEnabled?: boolean;
notifications?: NotificationSettings;
leftNavShortcut?: 'Comma' | 'BracketLeft';
rightPanelShortcut?: 'Period' | 'BracketRight';
leftNavShortcut?: 'Comma' | 'BracketLeft' | 'None';
rightPanelShortcut?: 'Period' | 'BracketRight' | 'None';
devMode?: boolean;
teamHubRegistries?: Array<{ id: string; url: string; isOfficial: boolean }>;
updateChannel?: 'stable' | 'beta';
Expand Down
54 changes: 24 additions & 30 deletions packages/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import CommandPalette from './components/CommandPalette';
import { useUiStore, type Theme } from './stores/uiStore';
import { useTaskStore } from './stores/taskStore';
import { useFileStore } from './stores/fileStore';
import { resolveGlobalShortcutAction, shouldDeferGlobalShortcut } from '@clawwork/core';
import { composer } from './platform';
import { useGatewayBootstrap } from './hooks/useGatewayBootstrap';
import { useWorkspaceRefresh } from './hooks/useWorkspaceRefresh';
Expand Down Expand Up @@ -130,39 +131,32 @@ export default function App() {

const handleGlobalKeyDown = useCallback(
(e: KeyboardEvent) => {
const meta = e.metaKey || e.ctrlKey;
if (!meta) return;
if (shouldDeferGlobalShortcut(e)) return;

if (e.shiftKey && e.code === 'KeyO') {
e.preventDefault();
startNewTask();
return;
}

if (e.shiftKey && e.code === 'KeyF') {
e.preventDefault();
setMainView('files');
return;
}

if (!e.shiftKey && e.code === 'KeyK') {
e.preventDefault();
toggleCommandPalette();
return;
}

const leftCode = leftNavShortcut;
const rightCode = rightPanelShortcut;
const action = resolveGlobalShortcutAction(e, {
leftNavShortcut,
rightPanelShortcut,
});
if (!action) return;

if (!e.shiftKey && e.code === leftCode) {
e.preventDefault();
toggleLeftNavCollapsed();
return;
}
e.preventDefault();

if (!e.shiftKey && e.code === rightCode) {
e.preventDefault();
if (useUiStore.getState().mainView === 'chat') toggleRightPanel();
switch (action) {
case 'new-task':
startNewTask();
return;
case 'open-files':
setMainView('files');
return;
case 'toggle-command-palette':
toggleCommandPalette();
return;
case 'toggle-left-nav':
toggleLeftNavCollapsed();
return;
case 'toggle-right-panel':
if (useUiStore.getState().mainView === 'chat') toggleRightPanel();
return;
}
},
[
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@
"setupCodeParsed": "Gateway-URL erkannt",
"setupCodePlaceholder": "Setup-Code aus der Gateway-Weboberflaeche oder openclaw qr einfuegen",
"shortcuts": "Tastenkuerzel",
"shortcutConflictPreferences": "Dieses Tastenkuerzel kann mit den macOS-Systemeinstellungen (⌘,) kollidieren",
"shortcutDisabled": "Deaktiviert",
"shortcutUpdated": "Tastenkuerzel aktualisiert",
"showSecret": "Zugangsdaten anzeigen",
"skillApiKeySave": "Speichern",
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@
"setupCodeParsed": "Gateway URL detected",
"setupCodePlaceholder": "Paste setup code from gateway web UI or openclaw qr",
"shortcuts": "Shortcuts",
"shortcutConflictPreferences": "This shortcut may conflict with system Preferences (⌘,) on macOS",
"shortcutDisabled": "Disabled",
"shortcutUpdated": "Shortcut updated",
"showSecret": "Show secret",
"skillApiKeySave": "Save",
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@
"setupCodeParsed": "URL del Gateway detectada",
"setupCodePlaceholder": "Pega el código de configuración desde la interfaz web del Gateway o el QR de OpenClaw",
"shortcuts": "Atajos",
"shortcutConflictPreferences": "Este atajo puede entrar en conflicto con Preferencias del sistema (⌘,) en macOS",
"shortcutDisabled": "Desactivado",
"shortcutUpdated": "Atajo actualizado",
"showSecret": "Mostrar secreto",
"skillApiKeySave": "Guardar",
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@
"setupCodeParsed": "ゲートウェイ URL を検出しました",
"setupCodePlaceholder": "ゲートウェイ Web UI または openclaw qr からセットアップコードを貼り付け",
"shortcuts": "ショートカット",
"shortcutConflictPreferences": "このショートカットは macOS のシステム設定 (⌘,) と競合する可能性があります",
"shortcutDisabled": "無効",
"shortcutUpdated": "ショートカットを更新しました",
"showSecret": "シークレットを表示",
"skillApiKeySave": "保存",
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@
"setupCodeParsed": "게이트웨이 URL이 감지되었습니다",
"setupCodePlaceholder": "게이트웨이 웹 UI 또는 openclaw qr에서 설정 코드를 붙여넣으세요",
"shortcuts": "단축키",
"shortcutConflictPreferences": "이 단축키는 macOS 시스템 설정(⌘,)과 충돌할 수 있습니다",
"shortcutDisabled": "사용 안 함",
"shortcutUpdated": "단축키가 변경되었습니다",
"showSecret": "비밀 표시",
"skillApiKeySave": "저장",
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@
"setupCodeParsed": "URL do Gateway detectada",
"setupCodePlaceholder": "Cole o código de configuração da interface web do gateway ou do openclaw qr",
"shortcuts": "Atalhos",
"shortcutConflictPreferences": "Este atalho pode entrar em conflito com Preferências do sistema (⌘,) no macOS",
"shortcutDisabled": "Desativado",
"shortcutUpdated": "Atalho atualizado",
"showSecret": "Mostrar segredo",
"skillApiKeySave": "Salvar",
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@
"setupCodeParsed": "已偵測到 Gateway URL",
"setupCodePlaceholder": "貼上來自 Gateway Web UI 或 openclaw qr 的設定碼",
"shortcuts": "快捷鍵",
"shortcutConflictPreferences": "此快捷鍵可能與 macOS 系統偏好設定 (⌘,) 衝突",
"shortcutDisabled": "已停用",
"shortcutUpdated": "快捷鍵已更新",
"showSecret": "顯示憑證",
"skillApiKeySave": "儲存",
Expand Down
Loading