Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/electron/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { AppShellContextType } from '@/context/AppShellContext'
import { OnboardingWizard, ReauthScreen } from '@/components/onboarding'
import { WorkspacePicker } from '@/components/workspace'
import { ResetConfirmationDialog } from '@/components/ResetConfirmationDialog'
import { CommandPalette } from '@/components/CommandPalette'
import { SplashScreen } from '@/components/SplashScreen'
import { TooltipProvider } from '@craft-agent/ui'
import { FocusProvider } from '@/context/FocusContext'
Expand Down Expand Up @@ -2496,6 +2497,9 @@ export default function App() {
{/* Handle window close requests (X button, Cmd+W) - close modal first if open */}
<WindowCloseHandler />

{/* Global command palette (⌘K / Ctrl+K) — search and run any action */}
<CommandPalette />

{/* Splash screen overlay - fades out when fully ready */}
{showSplash && (
<SplashScreen
Expand Down
46 changes: 46 additions & 0 deletions apps/electron/src/renderer/actions/action-i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* i18n mapping for action registry entries.
*
* Action definitions carry raw English `label`s (used as a fallback). This maps
* each action ID to a translation key so surfaces that show actions — the
* Settings → Shortcuts page and the command palette — render localized labels.
*
* Actions without an entry fall back to their raw `label`.
*/

import type { ActionId } from './definitions'

/** Map action IDs to i18n keys for translated labels. */
export const ACTION_LABEL_KEYS: Partial<Record<ActionId, string>> = {
'app.newChat': 'shortcuts.action.newChat',
'app.newChatInPanel': 'shortcuts.action.newChatInPanel',
'app.settings': 'shortcuts.action.settings',
'app.toggleTheme': 'shortcuts.action.toggleTheme',
'app.search': 'shortcuts.action.search',
'app.keyboardShortcuts': 'shortcuts.action.keyboardShortcuts',
'app.newWindow': 'shortcuts.action.newWindow',
'app.quit': 'shortcuts.action.quit',
'nav.focusSidebar': 'shortcuts.action.focusSidebar',
'nav.focusNavigator': 'shortcuts.action.focusNavigator',
'nav.focusChat': 'shortcuts.action.focusChat',
'nav.nextZone': 'shortcuts.action.focusNextZone',
'nav.goBack': 'shortcuts.action.goBack',
'nav.goForward': 'shortcuts.action.goForward',
'nav.goBackAlt': 'shortcuts.action.goBack',
'nav.goForwardAlt': 'shortcuts.action.goForward',
'view.toggleSidebar': 'shortcuts.action.toggleSidebar',
'view.toggleFocusMode': 'shortcuts.action.toggleFocusMode',
'navigator.selectAll': 'shortcuts.action.selectAll',
'navigator.clearSelection': 'shortcuts.action.clearSelection',
'panel.focusNext': 'shortcuts.action.focusNextPanel',
'panel.focusPrev': 'shortcuts.action.focusPrevPanel',
'chat.stopProcessing': 'shortcuts.action.stopProcessing',
'chat.cyclePermissionMode': 'shortcuts.action.cyclePermissionMode',
'chat.nextSearchMatch': 'shortcuts.action.nextSearchMatch',
'chat.prevSearchMatch': 'shortcuts.action.prevSearchMatch',
}

/** i18n key for a category heading (e.g. "General" → "shortcuts.category.general"). */
export function categoryLabelKey(category: string): string {
return `shortcuts.category.${category.toLowerCase()}`
}
7 changes: 7 additions & 0 deletions apps/electron/src/renderer/actions/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export const actions = {
defaultHotkey: 'mod+f',
category: 'General',
},
'app.commandPalette': {
id: 'app.commandPalette',
label: 'Command Palette',
description: 'Search and run any command',
defaultHotkey: 'mod+k',
category: 'General',
},
'app.keyboardShortcuts': {
id: 'app.keyboardShortcuts',
label: 'Keyboard Shortcuts',
Expand Down
1 change: 1 addition & 0 deletions apps/electron/src/renderer/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export { ActionRegistryProvider, useActionRegistry } from './registry'
export { useAction } from './useAction'
export { useHotkeyLabel, useActionLabel } from './useHotkeyLabel'
export { actions, actionList, actionsByCategory, type ActionId } from './definitions'
export { ACTION_LABEL_KEYS, categoryLabelKey } from './action-i18n'
export type { ActionDefinition, ActionHandler, ActionScope } from './types'
11 changes: 11 additions & 0 deletions apps/electron/src/renderer/actions/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ interface ActionRegistryContextType {
// Execute an action by ID
execute: (actionId: ActionId) => void

// Whether an action has a registered handler that is currently enabled
// (i.e. calling execute() right now would actually run something).
canExecute: (actionId: ActionId) => boolean

// Get the current hotkey for an action (respects user overrides)
getHotkey: (actionId: ActionId) => string | null

Expand Down Expand Up @@ -55,6 +59,12 @@ export function ActionRegistryProvider({ children }: { children: React.ReactNode
}
}, [])

// Whether execute() would currently run a handler for this action.
const canExecute = useCallback((actionId: ActionId): boolean => {
const handlers = handlersRef.current.get(actionId) || []
return handlers.some(handler => !handler.enabled || handler.enabled())
}, [])

// Get hotkey for action
const getHotkey = useCallback((actionId: ActionId): string | null => {
// Check user overrides first
Expand Down Expand Up @@ -110,6 +120,7 @@ export function ActionRegistryProvider({ children }: { children: React.ReactNode
const value: ActionRegistryContextType = {
register,
execute,
canExecute,
getHotkey,
getHotkeyDisplay,
getAction,
Expand Down
10 changes: 9 additions & 1 deletion apps/electron/src/renderer/components/AppMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { isMac } from "@/lib/platform"
import { useActionLabel } from "@/actions"
import { useActionLabel, useActionRegistry } from "@/actions"
import {
DropdownMenu,
DropdownMenuTrigger,
Expand Down Expand Up @@ -187,6 +187,8 @@ export function AppMenu({
const newWindowHotkey = useActionLabel('app.newWindow').hotkey
const settingsHotkey = useActionLabel('app.settings').hotkey
const keyboardShortcutsHotkey = useActionLabel('app.keyboardShortcuts').hotkey
const commandPaletteHotkey = useActionLabel('app.commandPalette').hotkey
const { execute } = useActionRegistry()
const quitHotkey = useActionLabel('app.quit').hotkey
const goBackHotkey = useActionLabel('nav.goBackAlt').hotkey
const goForwardHotkey = useActionLabel('nav.goForwardAlt').hotkey
Expand Down Expand Up @@ -226,6 +228,12 @@ export function AppMenu({
</StyledDropdownMenuItem>
)}

<StyledDropdownMenuItem onClick={() => execute('app.commandPalette')}>
<Icons.Command className="h-3.5 w-3.5" />
{t('commands.title')}
{commandPaletteHotkey && <DropdownMenuShortcut className="pl-6">{commandPaletteHotkey}</DropdownMenuShortcut>}
</StyledDropdownMenuItem>

<StyledDropdownMenuSeparator />

{/* Edit, View, Window submenus from shared schema */}
Expand Down
151 changes: 151 additions & 0 deletions apps/electron/src/renderer/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* CommandPalette
*
* A global, keyboard-driven overlay (⌘K / Ctrl+K) to search for and run any
* registered app action — the "run surface" that pairs with the read-only
* Keyboard Shortcuts reference.
*
* Fully frontend-only: it lists the centralized action registry, filters with
* cmdk's built-in fuzzy match, and dispatches the chosen action through the
* registry's `execute()` — exactly as if the action's hotkey had been pressed.
*
* Self-contained: it registers its own open handler (`app.commandPalette`),
* owns its open state, and integrates with the modal stack for layered close.
*/

import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
} from '@/components/ui/command'
import { useRegisterModal } from '@/context/ModalContext'
import {
useAction,
useActionRegistry,
actionsByCategory,
ACTION_LABEL_KEYS,
categoryLabelKey,
type ActionId,
} from '@/actions'

// Actions that should not appear as palette entries:
// - the palette's own open action (running it from inside the palette is a no-op)
// - the arrow-key alternates of Go Back / Go Forward (duplicate labels; the
// primary bracket-key actions already represent them)
const EXCLUDED_ACTIONS = new Set<ActionId>([
'app.commandPalette',
'nav.goBackAlt',
'nav.goForwardAlt',
])

export function CommandPalette() {
const { t } = useTranslation()
const { execute, canExecute, getHotkeyDisplay } = useActionRegistry()
const [open, setOpen] = useState(false)

// ⌘K / Ctrl+K toggles the palette.
useAction('app.commandPalette', () => setOpen(prev => !prev))

// Participate in the layered modal stack (Cmd+W / X close the topmost modal).
const handleClose = useCallback(() => setOpen(false), [])
useRegisterModal(open, handleClose)

// Build the grouped, display-ready action list once.
const groups = useMemo(() => {
return Object.entries(actionsByCategory)
.map(([category, actions]) => ({
category,
heading: t(categoryLabelKey(category)),
items: actions
.filter(action => !EXCLUDED_ACTIONS.has(action.id as ActionId))
// Only list actions that can actually run right now — hide
// context-scoped ones (e.g. navigator/search actions) whose handler
// is disabled, so the palette never shows a dead entry. Evaluated as
// the palette opens, i.e. against the focus you're returning to.
.filter(action => canExecute(action.id as ActionId))
.map(action => {
const id = action.id as ActionId
const labelKey = ACTION_LABEL_KEYS[id]
return {
id,
label: labelKey ? t(labelKey) : action.label,
hotkey: getHotkeyDisplay(id),
}
}),
}))
.filter(group => group.items.length > 0)
// getHotkeyDisplay / t are stable enough for a menu; recompute on open.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])

const runAction = useCallback(
(id: ActionId) => {
// Close first, then run on the next tick. Closing the dialog restores
// focus to the element that was active before the palette opened, so the
// action runs in the app's real focus context — actions that open a panel
// or move focus (e.g. Search) would otherwise fire mid-teardown and get
// clobbered by the dialog's focus restoration.
setOpen(false)
setTimeout(() => execute(id), 0)
},
[execute],
)

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
showCloseButton={false}
className="overflow-hidden p-0"
data-testid="command-palette"
aria-label={t('commands.title')}
>
<DialogTitle className="sr-only">{t('commands.title')}</DialogTitle>
<DialogDescription className="sr-only">
{t('commands.searchCommands')}
</DialogDescription>
<Command
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"
>
<CommandInput
data-testid="command-palette-input"
placeholder={t('commands.searchCommands')}
/>
<CommandList>
<CommandEmpty data-testid="command-palette-empty">
{t('common.noResultsFound')}
</CommandEmpty>
{groups.map(group => (
<CommandGroup key={group.category} heading={group.heading}>
{group.items.map(item => (
<CommandItem
key={item.id}
value={`${item.label} ${item.id}`}
data-testid="command-palette-item"
onSelect={() => runAction(item.id)}
>
<span>{item.label}</span>
{item.hotkey && (
<CommandShortcut>{item.hotkey}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogContent>
</Dialog>
)
}
32 changes: 1 addition & 31 deletions apps/electron/src/renderer/pages/settings/ShortcutsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { SettingsSection, SettingsCard, SettingsRow } from '@/components/settings'
import type { DetailsPageMeta } from '@/lib/navigation-registry'
import { isMac } from '@/lib/platform'
import { actionsByCategory, useActionLabel, type ActionId } from '@/actions'
import { actionsByCategory, useActionLabel, ACTION_LABEL_KEYS, type ActionId } from '@/actions'

export const meta: DetailsPageMeta = {
navigator: 'settings',
Expand Down Expand Up @@ -70,36 +70,6 @@ function Kbd({ children }: { children: React.ReactNode }) {
/**
* Renders a shortcut row for an action from the registry
*/
// Map action IDs to i18n keys for translated labels
const ACTION_LABEL_KEYS: Partial<Record<ActionId, string>> = {
'app.newChat': 'shortcuts.action.newChat',
'app.newChatInPanel': 'shortcuts.action.newChatInPanel',
'app.settings': 'shortcuts.action.settings',
'app.toggleTheme': 'shortcuts.action.toggleTheme',
'app.search': 'shortcuts.action.search',
'app.keyboardShortcuts': 'shortcuts.action.keyboardShortcuts',
'app.newWindow': 'shortcuts.action.newWindow',
'app.quit': 'shortcuts.action.quit',
'nav.focusSidebar': 'shortcuts.action.focusSidebar',
'nav.focusNavigator': 'shortcuts.action.focusNavigator',
'nav.focusChat': 'shortcuts.action.focusChat',
'nav.nextZone': 'shortcuts.action.focusNextZone',
'nav.goBack': 'shortcuts.action.goBack',
'nav.goForward': 'shortcuts.action.goForward',
'nav.goBackAlt': 'shortcuts.action.goBack',
'nav.goForwardAlt': 'shortcuts.action.goForward',
'view.toggleSidebar': 'shortcuts.action.toggleSidebar',
'view.toggleFocusMode': 'shortcuts.action.toggleFocusMode',
'navigator.selectAll': 'shortcuts.action.selectAll',
'navigator.clearSelection': 'shortcuts.action.clearSelection',
'panel.focusNext': 'shortcuts.action.focusNextPanel',
'panel.focusPrev': 'shortcuts.action.focusPrevPanel',
'chat.stopProcessing': 'shortcuts.action.stopProcessing',
'chat.cyclePermissionMode': 'shortcuts.action.cyclePermissionMode',
'chat.nextSearchMatch': 'shortcuts.action.nextSearchMatch',
'chat.prevSearchMatch': 'shortcuts.action.prevSearchMatch',
}

function ActionShortcutRow({ actionId }: { actionId: ActionId }) {
const { t } = useTranslation()
const { label, hotkey } = useActionLabel(actionId)
Expand Down
3 changes: 2 additions & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 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). |
| 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. |
| 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. |
Loading