From 4c304fb5c00021ea99a1c8fb37ff732720e4da23 Mon Sep 17 00:00:00 2001 From: samzong Date: Sun, 15 Mar 2026 02:42:28 +0800 Subject: [PATCH] feat: add keyboard shortcuts with platform-aware modifier keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cmd/Ctrl+Shift+O: create new task - Cmd/Ctrl+Shift+F: switch to file browser - Cmd/Ctrl+K: focus search box - Send shortcut toggle (Enter / Cmd+Enter) persisted to AppConfig - Fix: use e.code instead of e.key for reliable macOS shortcut detection - Fix: platform-aware modifier symbol (⌘ on macOS, Ctrl elsewhere) - Fix: startup hydration no longer triggers redundant disk write - Fix: Settings shortcut toggle guards against no-op clicks --- packages/desktop/src/main/workspace/config.ts | 2 +- packages/desktop/src/preload/clawwork.d.ts | 2 +- packages/desktop/src/renderer/App.tsx | 42 ++++++++++++++++++- .../src/renderer/components/ChatInput.tsx | 23 ++++++---- .../desktop/src/renderer/i18n/locales/en.json | 14 +++++-- .../desktop/src/renderer/i18n/locales/zh.json | 6 +++ .../src/renderer/layouts/LeftNav/index.tsx | 9 ++++ .../src/renderer/layouts/Settings/index.tsx | 31 +++++++++++++- packages/desktop/src/renderer/lib/utils.ts | 4 ++ .../desktop/src/renderer/stores/uiStore.ts | 17 ++++++++ 10 files changed, 134 insertions(+), 16 deletions(-) diff --git a/packages/desktop/src/main/workspace/config.ts b/packages/desktop/src/main/workspace/config.ts index 0f5759b..d645ba7 100644 --- a/packages/desktop/src/main/workspace/config.ts +++ b/packages/desktop/src/main/workspace/config.ts @@ -21,7 +21,7 @@ export interface AppConfig { language?: 'en' | 'zh'; gateways: GatewayServerConfig[]; defaultGatewayId?: string; - // Legacy fields kept for migration detection + sendShortcut?: 'enter' | 'cmdEnter'; gatewayUrl?: string; bootstrapToken?: string; password?: string; diff --git a/packages/desktop/src/preload/clawwork.d.ts b/packages/desktop/src/preload/clawwork.d.ts index 96b1db7..18155e4 100644 --- a/packages/desktop/src/preload/clawwork.d.ts +++ b/packages/desktop/src/preload/clawwork.d.ts @@ -46,7 +46,7 @@ interface AppSettings { language?: 'en' | 'zh'; gateways: GatewayServerConfig[]; defaultGatewayId?: string; - // Legacy fields kept for migration detection + sendShortcut?: 'enter' | 'cmdEnter'; gatewayUrl?: string; bootstrapToken?: string; password?: string; diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 16f8dc9..9839441 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Toaster } from 'sonner' import LeftNav from './layouts/LeftNav' @@ -7,6 +7,7 @@ import RightPanel from './layouts/RightPanel' import Setup from './layouts/Setup' import Settings from './layouts/Settings' import { useUiStore } from './stores/uiStore' +import { useTaskStore } from './stores/taskStore' import { useGatewayEventDispatcher } from './hooks/useGatewayDispatcher' import { useTheme } from './hooks/useTheme' import { useUpdateCheck } from './hooks/useUpdateCheck' @@ -23,6 +24,9 @@ export default function App() { const settingsOpen = useUiStore((s) => s.settingsOpen) const setSettingsOpen = useUiStore((s) => s.setSettingsOpen) const theme = useUiStore((s) => s.theme) + const setMainView = useUiStore((s) => s.setMainView) + const focusSearch = useUiStore((s) => s.focusSearch) + const createTask = useTaskStore((s) => s.createTask) useGatewayEventDispatcher() useTheme() @@ -38,6 +42,42 @@ export default function App() { }) }, []) + useEffect(() => { + if (!ready) return + window.clawwork.getSettings().then((settings) => { + if (settings?.sendShortcut) { + useUiStore.setState({ sendShortcut: settings.sendShortcut }) + } + }) + }, [ready]) + + const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => { + const meta = e.metaKey || e.ctrlKey + if (!meta) return + + if (e.shiftKey && e.code === 'KeyO') { + e.preventDefault() + createTask() + return + } + + if (e.shiftKey && e.code === 'KeyF') { + e.preventDefault() + setMainView('files') + return + } + + if (!e.shiftKey && e.code === 'KeyK') { + e.preventDefault() + focusSearch() + } + }, [createTask, setMainView, focusSearch]) + + useEffect(() => { + window.addEventListener('keydown', handleGlobalKeyDown) + return () => window.removeEventListener('keydown', handleGlobalKeyDown) + }, [handleGlobalKeyDown]) + if (needsSetup) { return ( diff --git a/packages/desktop/src/renderer/components/ChatInput.tsx b/packages/desktop/src/renderer/components/ChatInput.tsx index b2b992a..e92f166 100644 --- a/packages/desktop/src/renderer/components/ChatInput.tsx +++ b/packages/desktop/src/renderer/components/ChatInput.tsx @@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { Send, Paperclip, X, ChevronDown, Cpu, Brain } from 'lucide-react'; import type { MessageImageAttachment } from '@clawwork/shared'; import { toast } from 'sonner'; -import { cn } from '@/lib/utils'; +import { cn, modKey } from '@/lib/utils'; import { motion as motionPresets } from '@/styles/design-tokens'; import { Button } from '@/components/ui/button'; import { @@ -78,6 +78,8 @@ export default function ChatInput() { const [slashIndex, setSlashIndex] = useState(0); const slashCommands = filterSlashCommands(slashQuery); + const sendShortcut = useUiStore((s) => s.sendShortcut); + /** Evaluate the current textarea value and cursor position to determine * whether to show the slash command menu. */ const updateSlashMenu = useCallback(() => { @@ -247,13 +249,18 @@ export default function ChatInput() { } } - // ── Normal send ─────────────────────────────────────────────────────────── - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); + // ── Send ────────────────────────────────────────────────────────────────── + if (e.key === 'Enter') { + const isCmdEnterMode = sendShortcut === 'cmdEnter'; + const meta = e.metaKey || e.ctrlKey; + const shouldSend = isCmdEnterMode ? (meta && !e.shiftKey) : (!e.shiftKey && !meta); + if (shouldSend) { + e.preventDefault(); + handleSend(); + } } }, - [slashMenuVisible, slashCommands, slashIndex, commitSlashCommand, handleSend], + [slashMenuVisible, slashCommands, slashIndex, commitSlashCommand, handleSend, sendShortcut], ); const handleInput = useCallback(() => { @@ -491,7 +498,9 @@ export default function ChatInput() {

{isOffline ? t('chatInput.offlineHint') - : t('chatInput.poweredBy')} + : sendShortcut === 'cmdEnter' + ? t('chatInput.poweredBy') + ' · ' + t('chatInput.toSend', { mod: modKey }) + : t('chatInput.poweredBy')}

diff --git a/packages/desktop/src/renderer/i18n/locales/en.json b/packages/desktop/src/renderer/i18n/locales/en.json index c20440d..56737ca 100644 --- a/packages/desktop/src/renderer/i18n/locales/en.json +++ b/packages/desktop/src/renderer/i18n/locales/en.json @@ -33,6 +33,11 @@ "appearance": "Appearance", "theme": "Theme", "themeUpdated": "Theme updated", + "shortcuts": "Shortcuts", + "sendShortcut": "Send message", + "sendEnter": "Enter", + "sendCmdEnter": "{{mod}} Enter", + "shortcutUpdated": "Shortcut updated", "gateways": "Gateways", "addGateway": "Add Gateway", "editGateway": "Edit Gateway", @@ -137,11 +142,12 @@ "sendFailed": "Send failed" }, "chatInput": { - "describeTask": "Describe your task\u2026", - "offlineReadOnly": "Offline \u2014 read-only", - "createTaskFirst": "Create a task first\u2026", + "describeTask": "Describe your task…", + "offlineReadOnly": "Offline — read-only", + "createTaskFirst": "Create a task first…", "poweredBy": "AI can make mistakes. Check important info.", - "offlineHint": "Offline \u2014 browse past tasks and files", + "offlineHint": "Offline — browse past tasks and files", + "toSend": "{{mod}}↵ to send", "image": "Image", "model": "Model", "thinking": "Thinking", diff --git a/packages/desktop/src/renderer/i18n/locales/zh.json b/packages/desktop/src/renderer/i18n/locales/zh.json index b8ae39a..1eb08ce 100644 --- a/packages/desktop/src/renderer/i18n/locales/zh.json +++ b/packages/desktop/src/renderer/i18n/locales/zh.json @@ -33,6 +33,11 @@ "appearance": "外观", "theme": "主题", "themeUpdated": "主题已更新", + "shortcuts": "快捷键", + "sendShortcut": "发送消息", + "sendEnter": "回车", + "sendCmdEnter": "{{mod}} 回车", + "shortcutUpdated": "快捷键已更新", "gateways": "网关", "addGateway": "添加网关", "editGateway": "编辑网关", @@ -142,6 +147,7 @@ "createTaskFirst": "请先创建一个任务…", "poweredBy": "由 OpenClaw 驱动 · 任务文件自动 Git 归档", "offlineHint": "离线模式 — 可浏览历史任务和文件", + "toSend": "{{mod}}↵ 发送", "image": "图片", "model": "模型", "thinking": "思考", diff --git a/packages/desktop/src/renderer/layouts/LeftNav/index.tsx b/packages/desktop/src/renderer/layouts/LeftNav/index.tsx index c5713e9..353c097 100644 --- a/packages/desktop/src/renderer/layouts/LeftNav/index.tsx +++ b/packages/desktop/src/renderer/layouts/LeftNav/index.tsx @@ -33,6 +33,7 @@ export default function LeftNav() { const hasUpdate = useUiStore((s) => s.hasUpdate) const agentCatalog = useUiStore((s) => s.agentCatalog) const hasMultipleAgents = agentCatalog.length > 1 + const searchFocusTrigger = useUiStore((s) => s.searchFocusTrigger) // Aggregate: if any gateway connected → connected; any connecting → connecting; else disconnected const gwStatusValues = Object.values(gwStatusMap) @@ -46,6 +47,13 @@ export default function LeftNav() { const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const timerRef = useRef>(undefined) + const searchInputRef = useRef(null) + + useEffect(() => { + if (searchFocusTrigger === 0) return + searchInputRef.current?.focus() + searchInputRef.current?.select() + }, [searchFocusTrigger]) useEffect(() => { clearTimeout(timerRef.current) @@ -114,6 +122,7 @@ export default function LeftNav() {
setSearchQuery(e.target.value)} diff --git a/packages/desktop/src/renderer/layouts/Settings/index.tsx b/packages/desktop/src/renderer/layouts/Settings/index.tsx index 2afe869..df366bb 100644 --- a/packages/desktop/src/renderer/layouts/Settings/index.tsx +++ b/packages/desktop/src/renderer/layouts/Settings/index.tsx @@ -6,11 +6,11 @@ import { } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { cn } from '@/lib/utils' +import { cn, modKey } from '@/lib/utils' import { motion as motionPresets } from '@/styles/design-tokens' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' -import { useUiStore, type Language, type GatewayConnectionStatus } from '@/stores/uiStore' +import { useUiStore, type Language, type GatewayConnectionStatus, type SendShortcut } from '@/stores/uiStore' interface UpdateCheckResult { currentVersion: string @@ -148,6 +148,8 @@ export default function Settings({ onClose }: SettingsProps) { const setTheme = useUiStore((s) => s.setTheme) const language = useUiStore((s) => s.language) const setLanguage = useUiStore((s) => s.setLanguage) + const sendShortcut = useUiStore((s) => s.sendShortcut) + const setSendShortcut = useUiStore((s) => s.setSendShortcut) const gatewayStatusMap = useUiStore((s) => s.gatewayStatusMap) const setDefaultGatewayId = useUiStore((s) => s.setDefaultGatewayId) const setGatewayInfoMap = useUiStore((s) => s.setGatewayInfoMap) @@ -206,6 +208,12 @@ export default function Settings({ onClose }: SettingsProps) { toast.success(t('settings.themeUpdated')) }, [setTheme, t]) + const handleShortcutChange = useCallback((next: SendShortcut) => { + if (next === sendShortcut) return + setSendShortcut(next) + toast.success(t('settings.shortcutUpdated')) + }, [sendShortcut, setSendShortcut, t]) + const openAddForm = useCallback(() => { setEditingId(null) setForm(EMPTY_FORM) @@ -366,6 +374,25 @@ export default function Settings({ onClose }: SettingsProps) { ))}
+
+ {t('settings.sendShortcut')} +
+ {(['enter', 'cmdEnter'] as const).map((s) => ( + + ))} +
+
diff --git a/packages/desktop/src/renderer/lib/utils.ts b/packages/desktop/src/renderer/lib/utils.ts index d063e46..c564b85 100644 --- a/packages/desktop/src/renderer/lib/utils.ts +++ b/packages/desktop/src/renderer/lib/utils.ts @@ -39,3 +39,7 @@ export function formatTokenCount(tokens: number): string { if (tokens < 100_000) return `${(tokens / 1000).toFixed(1)}k` return `${(tokens / 1000).toFixed(0)}k` } + +export const isMac = navigator.platform.toUpperCase().includes('MAC') + +export const modKey = isMac ? '⌘' : 'Ctrl' diff --git a/packages/desktop/src/renderer/stores/uiStore.ts b/packages/desktop/src/renderer/stores/uiStore.ts index 4f626d1..4dcdb13 100644 --- a/packages/desktop/src/renderer/stores/uiStore.ts +++ b/packages/desktop/src/renderer/stores/uiStore.ts @@ -8,6 +8,8 @@ type Theme = 'dark' | 'light'; export type Language = 'en' | 'zh'; +export type SendShortcut = 'enter' | 'cmdEnter'; + export type GatewayConnectionStatus = 'connected' | 'connecting' | 'disconnected'; export interface GatewayInfo { @@ -62,6 +64,12 @@ interface UiState { agentCatalog: AgentInfo[]; defaultAgentId: string; setAgentCatalog: (agents: AgentInfo[], defaultId: string) => void; + + sendShortcut: SendShortcut; + setSendShortcut: (shortcut: SendShortcut) => void; + + searchFocusTrigger: number; + focusSearch: () => void; } export const useUiStore = create((set) => ({ @@ -120,4 +128,13 @@ export const useUiStore = create((set) => ({ agentCatalog: [], defaultAgentId: 'main', setAgentCatalog: (agents, defaultId) => set({ agentCatalog: agents, defaultAgentId: defaultId }), + + sendShortcut: 'enter', + setSendShortcut: (shortcut) => { + set({ sendShortcut: shortcut }); + window.clawwork.updateSettings({ sendShortcut: shortcut }); + }, + + searchFocusTrigger: 0, + focusSearch: () => set((s) => ({ searchFocusTrigger: s.searchFocusTrigger + 1 })), }));