Skip to content

Commit 8741a7a

Browse files
DragonnZhangclaude
andauthored
Add a global command palette (⌘K / Ctrl+K) (#42)
* feat(desktop): add global command palette (⌘K / Ctrl+K) Add a keyboard-driven command palette that lets you search for and run any registered app action — the "run" surface that pairs with the read-only Keyboard Shortcuts reference. Every comparable desktop app (Claude Code Desktop, VS Code / Codex, Linear) ships one; OpenWork had the action registry, cmdk primitives, and translated labels, but nothing tied them together. Frontend-only. The palette lists the centralized action registry grouped by category, filters with cmdk's built-in fuzzy match, and dispatches the chosen action via the registry's execute() — exactly as if its hotkey were pressed. Opened with ⌘K / Ctrl+K or from the app menu. No backend / qwen-code change, and no new i18n keys (reuses commands.*, common.noResultsFound, and the existing shortcuts.action.* / shortcuts.category.* labels). - action registry: new app.commandPalette action (mod+k) - CommandPalette component (self-contained: registers its open handler, owns state, integrates with the modal stack) - extract ACTION_LABEL_KEYS to a shared actions/action-i18n module and reuse it in the Shortcuts settings page - AppMenu entry for discoverability - e2e: command-palette CDP assertion (open → filter → empty → clear → run Toggle Theme and assert the theme flips) - e2e harness: per-port isolated profile dirs so multiple assertions can each launch their own app instance without single-instance-lock collisions Closes #41 * fix(desktop): command palette only lists runnable actions, run after close Two issues surfaced from testing the palette: - It listed context-scoped actions whose handler is disabled in the palette's context (e.g. "Next Search Match" / "Previous Search Match", which need an active in-conversation search, and the navigator/panel focus actions). Selecting one was a dead no-op — clicking appeared to "do nothing". The palette now filters to actions the registry reports as executable right now (new ActionRegistry.canExecute), so only runnable commands show. - It ran the selected action synchronously while the dialog was still tearing down, so actions that open a panel or move focus (e.g. Search) could be clobbered by the dialog's focus restoration. It now closes first and runs the action on the next tick, in the app's restored-focus context. The e2e assertion additionally verifies a known context-scoped action ("Next Search Match") is absent from the palette. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 36bac52 commit 8741a7a

10 files changed

Lines changed: 393 additions & 33 deletions

File tree

apps/electron/src/renderer/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { AppShellContextType } from '@/context/AppShellContext'
1616
import { OnboardingWizard, ReauthScreen } from '@/components/onboarding'
1717
import { WorkspacePicker } from '@/components/workspace'
1818
import { ResetConfirmationDialog } from '@/components/ResetConfirmationDialog'
19+
import { CommandPalette } from '@/components/CommandPalette'
1920
import { SplashScreen } from '@/components/SplashScreen'
2021
import { TooltipProvider } from '@craft-agent/ui'
2122
import { FocusProvider } from '@/context/FocusContext'
@@ -2496,6 +2497,9 @@ export default function App() {
24962497
{/* Handle window close requests (X button, Cmd+W) - close modal first if open */}
24972498
<WindowCloseHandler />
24982499

2500+
{/* Global command palette (⌘K / Ctrl+K) — search and run any action */}
2501+
<CommandPalette />
2502+
24992503
{/* Splash screen overlay - fades out when fully ready */}
25002504
{showSplash && (
25012505
<SplashScreen
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* i18n mapping for action registry entries.
3+
*
4+
* Action definitions carry raw English `label`s (used as a fallback). This maps
5+
* each action ID to a translation key so surfaces that show actions — the
6+
* Settings → Shortcuts page and the command palette — render localized labels.
7+
*
8+
* Actions without an entry fall back to their raw `label`.
9+
*/
10+
11+
import type { ActionId } from './definitions'
12+
13+
/** Map action IDs to i18n keys for translated labels. */
14+
export const ACTION_LABEL_KEYS: Partial<Record<ActionId, string>> = {
15+
'app.newChat': 'shortcuts.action.newChat',
16+
'app.newChatInPanel': 'shortcuts.action.newChatInPanel',
17+
'app.settings': 'shortcuts.action.settings',
18+
'app.toggleTheme': 'shortcuts.action.toggleTheme',
19+
'app.search': 'shortcuts.action.search',
20+
'app.keyboardShortcuts': 'shortcuts.action.keyboardShortcuts',
21+
'app.newWindow': 'shortcuts.action.newWindow',
22+
'app.quit': 'shortcuts.action.quit',
23+
'nav.focusSidebar': 'shortcuts.action.focusSidebar',
24+
'nav.focusNavigator': 'shortcuts.action.focusNavigator',
25+
'nav.focusChat': 'shortcuts.action.focusChat',
26+
'nav.nextZone': 'shortcuts.action.focusNextZone',
27+
'nav.goBack': 'shortcuts.action.goBack',
28+
'nav.goForward': 'shortcuts.action.goForward',
29+
'nav.goBackAlt': 'shortcuts.action.goBack',
30+
'nav.goForwardAlt': 'shortcuts.action.goForward',
31+
'view.toggleSidebar': 'shortcuts.action.toggleSidebar',
32+
'view.toggleFocusMode': 'shortcuts.action.toggleFocusMode',
33+
'navigator.selectAll': 'shortcuts.action.selectAll',
34+
'navigator.clearSelection': 'shortcuts.action.clearSelection',
35+
'panel.focusNext': 'shortcuts.action.focusNextPanel',
36+
'panel.focusPrev': 'shortcuts.action.focusPrevPanel',
37+
'chat.stopProcessing': 'shortcuts.action.stopProcessing',
38+
'chat.cyclePermissionMode': 'shortcuts.action.cyclePermissionMode',
39+
'chat.nextSearchMatch': 'shortcuts.action.nextSearchMatch',
40+
'chat.prevSearchMatch': 'shortcuts.action.prevSearchMatch',
41+
}
42+
43+
/** i18n key for a category heading (e.g. "General" → "shortcuts.category.general"). */
44+
export function categoryLabelKey(category: string): string {
45+
return `shortcuts.category.${category.toLowerCase()}`
46+
}

apps/electron/src/renderer/actions/definitions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ export const actions = {
3939
defaultHotkey: 'mod+f',
4040
category: 'General',
4141
},
42+
'app.commandPalette': {
43+
id: 'app.commandPalette',
44+
label: 'Command Palette',
45+
description: 'Search and run any command',
46+
defaultHotkey: 'mod+k',
47+
category: 'General',
48+
},
4249
'app.keyboardShortcuts': {
4350
id: 'app.keyboardShortcuts',
4451
label: 'Keyboard Shortcuts',

apps/electron/src/renderer/actions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export { ActionRegistryProvider, useActionRegistry } from './registry'
33
export { useAction } from './useAction'
44
export { useHotkeyLabel, useActionLabel } from './useHotkeyLabel'
55
export { actions, actionList, actionsByCategory, type ActionId } from './definitions'
6+
export { ACTION_LABEL_KEYS, categoryLabelKey } from './action-i18n'
67
export type { ActionDefinition, ActionHandler, ActionScope } from './types'

apps/electron/src/renderer/actions/registry.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ interface ActionRegistryContextType {
1111
// Execute an action by ID
1212
execute: (actionId: ActionId) => void
1313

14+
// Whether an action has a registered handler that is currently enabled
15+
// (i.e. calling execute() right now would actually run something).
16+
canExecute: (actionId: ActionId) => boolean
17+
1418
// Get the current hotkey for an action (respects user overrides)
1519
getHotkey: (actionId: ActionId) => string | null
1620

@@ -55,6 +59,12 @@ export function ActionRegistryProvider({ children }: { children: React.ReactNode
5559
}
5660
}, [])
5761

62+
// Whether execute() would currently run a handler for this action.
63+
const canExecute = useCallback((actionId: ActionId): boolean => {
64+
const handlers = handlersRef.current.get(actionId) || []
65+
return handlers.some(handler => !handler.enabled || handler.enabled())
66+
}, [])
67+
5868
// Get hotkey for action
5969
const getHotkey = useCallback((actionId: ActionId): string | null => {
6070
// Check user overrides first
@@ -110,6 +120,7 @@ export function ActionRegistryProvider({ children }: { children: React.ReactNode
110120
const value: ActionRegistryContextType = {
111121
register,
112122
execute,
123+
canExecute,
113124
getHotkey,
114125
getHotkeyDisplay,
115126
getAction,

apps/electron/src/renderer/components/AppMenu.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useState } from "react"
22
import { useTranslation } from "react-i18next"
33
import { isMac } from "@/lib/platform"
4-
import { useActionLabel } from "@/actions"
4+
import { useActionLabel, useActionRegistry } from "@/actions"
55
import {
66
DropdownMenu,
77
DropdownMenuTrigger,
@@ -187,6 +187,8 @@ export function AppMenu({
187187
const newWindowHotkey = useActionLabel('app.newWindow').hotkey
188188
const settingsHotkey = useActionLabel('app.settings').hotkey
189189
const keyboardShortcutsHotkey = useActionLabel('app.keyboardShortcuts').hotkey
190+
const commandPaletteHotkey = useActionLabel('app.commandPalette').hotkey
191+
const { execute } = useActionRegistry()
190192
const quitHotkey = useActionLabel('app.quit').hotkey
191193
const goBackHotkey = useActionLabel('nav.goBackAlt').hotkey
192194
const goForwardHotkey = useActionLabel('nav.goForwardAlt').hotkey
@@ -226,6 +228,12 @@ export function AppMenu({
226228
</StyledDropdownMenuItem>
227229
)}
228230

231+
<StyledDropdownMenuItem onClick={() => execute('app.commandPalette')}>
232+
<Icons.Command className="h-3.5 w-3.5" />
233+
{t('commands.title')}
234+
{commandPaletteHotkey && <DropdownMenuShortcut className="pl-6">{commandPaletteHotkey}</DropdownMenuShortcut>}
235+
</StyledDropdownMenuItem>
236+
229237
<StyledDropdownMenuSeparator />
230238

231239
{/* Edit, View, Window submenus from shared schema */}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* CommandPalette
3+
*
4+
* A global, keyboard-driven overlay (⌘K / Ctrl+K) to search for and run any
5+
* registered app action — the "run surface" that pairs with the read-only
6+
* Keyboard Shortcuts reference.
7+
*
8+
* Fully frontend-only: it lists the centralized action registry, filters with
9+
* cmdk's built-in fuzzy match, and dispatches the chosen action through the
10+
* registry's `execute()` — exactly as if the action's hotkey had been pressed.
11+
*
12+
* Self-contained: it registers its own open handler (`app.commandPalette`),
13+
* owns its open state, and integrates with the modal stack for layered close.
14+
*/
15+
16+
import { useCallback, useMemo, useState } from 'react'
17+
import { useTranslation } from 'react-i18next'
18+
import {
19+
Dialog,
20+
DialogContent,
21+
DialogTitle,
22+
DialogDescription,
23+
} from '@/components/ui/dialog'
24+
import {
25+
Command,
26+
CommandInput,
27+
CommandList,
28+
CommandEmpty,
29+
CommandGroup,
30+
CommandItem,
31+
CommandShortcut,
32+
} from '@/components/ui/command'
33+
import { useRegisterModal } from '@/context/ModalContext'
34+
import {
35+
useAction,
36+
useActionRegistry,
37+
actionsByCategory,
38+
ACTION_LABEL_KEYS,
39+
categoryLabelKey,
40+
type ActionId,
41+
} from '@/actions'
42+
43+
// Actions that should not appear as palette entries:
44+
// - the palette's own open action (running it from inside the palette is a no-op)
45+
// - the arrow-key alternates of Go Back / Go Forward (duplicate labels; the
46+
// primary bracket-key actions already represent them)
47+
const EXCLUDED_ACTIONS = new Set<ActionId>([
48+
'app.commandPalette',
49+
'nav.goBackAlt',
50+
'nav.goForwardAlt',
51+
])
52+
53+
export function CommandPalette() {
54+
const { t } = useTranslation()
55+
const { execute, canExecute, getHotkeyDisplay } = useActionRegistry()
56+
const [open, setOpen] = useState(false)
57+
58+
// ⌘K / Ctrl+K toggles the palette.
59+
useAction('app.commandPalette', () => setOpen(prev => !prev))
60+
61+
// Participate in the layered modal stack (Cmd+W / X close the topmost modal).
62+
const handleClose = useCallback(() => setOpen(false), [])
63+
useRegisterModal(open, handleClose)
64+
65+
// Build the grouped, display-ready action list once.
66+
const groups = useMemo(() => {
67+
return Object.entries(actionsByCategory)
68+
.map(([category, actions]) => ({
69+
category,
70+
heading: t(categoryLabelKey(category)),
71+
items: actions
72+
.filter(action => !EXCLUDED_ACTIONS.has(action.id as ActionId))
73+
// Only list actions that can actually run right now — hide
74+
// context-scoped ones (e.g. navigator/search actions) whose handler
75+
// is disabled, so the palette never shows a dead entry. Evaluated as
76+
// the palette opens, i.e. against the focus you're returning to.
77+
.filter(action => canExecute(action.id as ActionId))
78+
.map(action => {
79+
const id = action.id as ActionId
80+
const labelKey = ACTION_LABEL_KEYS[id]
81+
return {
82+
id,
83+
label: labelKey ? t(labelKey) : action.label,
84+
hotkey: getHotkeyDisplay(id),
85+
}
86+
}),
87+
}))
88+
.filter(group => group.items.length > 0)
89+
// getHotkeyDisplay / t are stable enough for a menu; recompute on open.
90+
// eslint-disable-next-line react-hooks/exhaustive-deps
91+
}, [open])
92+
93+
const runAction = useCallback(
94+
(id: ActionId) => {
95+
// Close first, then run on the next tick. Closing the dialog restores
96+
// focus to the element that was active before the palette opened, so the
97+
// action runs in the app's real focus context — actions that open a panel
98+
// or move focus (e.g. Search) would otherwise fire mid-teardown and get
99+
// clobbered by the dialog's focus restoration.
100+
setOpen(false)
101+
setTimeout(() => execute(id), 0)
102+
},
103+
[execute],
104+
)
105+
106+
return (
107+
<Dialog open={open} onOpenChange={setOpen}>
108+
<DialogContent
109+
showCloseButton={false}
110+
className="overflow-hidden p-0"
111+
data-testid="command-palette"
112+
aria-label={t('commands.title')}
113+
>
114+
<DialogTitle className="sr-only">{t('commands.title')}</DialogTitle>
115+
<DialogDescription className="sr-only">
116+
{t('commands.searchCommands')}
117+
</DialogDescription>
118+
<Command
119+
className="[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
120+
>
121+
<CommandInput
122+
data-testid="command-palette-input"
123+
placeholder={t('commands.searchCommands')}
124+
/>
125+
<CommandList>
126+
<CommandEmpty data-testid="command-palette-empty">
127+
{t('common.noResultsFound')}
128+
</CommandEmpty>
129+
{groups.map(group => (
130+
<CommandGroup key={group.category} heading={group.heading}>
131+
{group.items.map(item => (
132+
<CommandItem
133+
key={item.id}
134+
value={`${item.label} ${item.id}`}
135+
data-testid="command-palette-item"
136+
onSelect={() => runAction(item.id)}
137+
>
138+
<span>{item.label}</span>
139+
{item.hotkey && (
140+
<CommandShortcut>{item.hotkey}</CommandShortcut>
141+
)}
142+
</CommandItem>
143+
))}
144+
</CommandGroup>
145+
))}
146+
</CommandList>
147+
</Command>
148+
</DialogContent>
149+
</Dialog>
150+
)
151+
}

apps/electron/src/renderer/pages/settings/ShortcutsPage.tsx

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
1111
import { SettingsSection, SettingsCard, SettingsRow } from '@/components/settings'
1212
import type { DetailsPageMeta } from '@/lib/navigation-registry'
1313
import { isMac } from '@/lib/platform'
14-
import { actionsByCategory, useActionLabel, type ActionId } from '@/actions'
14+
import { actionsByCategory, useActionLabel, ACTION_LABEL_KEYS, type ActionId } from '@/actions'
1515

1616
export const meta: DetailsPageMeta = {
1717
navigator: 'settings',
@@ -70,36 +70,6 @@ function Kbd({ children }: { children: React.ReactNode }) {
7070
/**
7171
* Renders a shortcut row for an action from the registry
7272
*/
73-
// Map action IDs to i18n keys for translated labels
74-
const ACTION_LABEL_KEYS: Partial<Record<ActionId, string>> = {
75-
'app.newChat': 'shortcuts.action.newChat',
76-
'app.newChatInPanel': 'shortcuts.action.newChatInPanel',
77-
'app.settings': 'shortcuts.action.settings',
78-
'app.toggleTheme': 'shortcuts.action.toggleTheme',
79-
'app.search': 'shortcuts.action.search',
80-
'app.keyboardShortcuts': 'shortcuts.action.keyboardShortcuts',
81-
'app.newWindow': 'shortcuts.action.newWindow',
82-
'app.quit': 'shortcuts.action.quit',
83-
'nav.focusSidebar': 'shortcuts.action.focusSidebar',
84-
'nav.focusNavigator': 'shortcuts.action.focusNavigator',
85-
'nav.focusChat': 'shortcuts.action.focusChat',
86-
'nav.nextZone': 'shortcuts.action.focusNextZone',
87-
'nav.goBack': 'shortcuts.action.goBack',
88-
'nav.goForward': 'shortcuts.action.goForward',
89-
'nav.goBackAlt': 'shortcuts.action.goBack',
90-
'nav.goForwardAlt': 'shortcuts.action.goForward',
91-
'view.toggleSidebar': 'shortcuts.action.toggleSidebar',
92-
'view.toggleFocusMode': 'shortcuts.action.toggleFocusMode',
93-
'navigator.selectAll': 'shortcuts.action.selectAll',
94-
'navigator.clearSelection': 'shortcuts.action.clearSelection',
95-
'panel.focusNext': 'shortcuts.action.focusNextPanel',
96-
'panel.focusPrev': 'shortcuts.action.focusPrevPanel',
97-
'chat.stopProcessing': 'shortcuts.action.stopProcessing',
98-
'chat.cyclePermissionMode': 'shortcuts.action.cyclePermissionMode',
99-
'chat.nextSearchMatch': 'shortcuts.action.nextSearchMatch',
100-
'chat.prevSearchMatch': 'shortcuts.action.prevSearchMatch',
101-
}
102-
10373
function ActionShortcutRow({ actionId }: { actionId: ActionId }) {
10474
const { t } = useTranslation()
10575
const { label, hotkey } = useActionLabel(actionId)

docs/loop/feature-ledger.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ log, not the system of record.
3232

3333
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3434
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
35-
| settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | pr-open | #39 | #40 | loop/settings-search | 2026-06-30 | Filters `SettingsNavigator` by title+description; reuses `common.search`/`common.noResultsFound` (no new locale keys). Also hardened `e2e/app.ts` teardown (per-launch profile dir + setsid process-group kill) so multiple CDP assertions run under headless xvfb. CDP assertion `e2e/assertions/settings-search.assert.ts` passes (2/2). |
35+
| command-palette | Global command palette (⌘K/Ctrl+K) to search & run any action | Claude Code Desktop ⌘K / VS Code & Codex ⌘⇧P / Linear ⌘K | frontend-only | pr-open | [#41](https://github.com/modelstudioai/openwork/issues/41) | [#42](https://github.com/modelstudioai/openwork/pull/42) | loop/command-palette | 2026-07-01 | Reuses action registry `execute()` + cmdk primitives; zero new i18n keys. CDP e2e 2/2 pass. typecheck/test +0 vs main. |
36+
| settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | merged | [#39](https://github.com/modelstudioai/openwork/issues/39) | [#40](https://github.com/modelstudioai/openwork/pull/40) | loop/settings-search | 2026-07-01 | Merged into `main`. Filters `SettingsNavigator` by title+description; reuses `common.search`/`common.noResultsFound` (no new locale keys). Also hardened `e2e/app.ts` teardown (per-launch profile dir + setsid process-group kill) so multiple CDP assertions run under headless xvfb. |

0 commit comments

Comments
 (0)