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
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function SettingsItemRow({ item, isSelected, isFirst, onSelect }: SettingsItemRo
<button
type="button"
onClick={onSelect}
data-testid={`settings-item-${item.id}`}
className={cn(
'flex w-full items-start gap-2 pl-2 pr-4 py-3 text-left text-sm outline-none rounded-[8px]',
// Fast hover transition (75ms vs default 150ms)
Expand Down
252 changes: 166 additions & 86 deletions apps/electron/src/renderer/pages/settings/ShortcutsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,56 @@
* ShortcutsPage
*
* Displays keyboard shortcuts reference from the centralized action registry.
*
* A search box at the top filters the shortcuts by their action label and their
* key combination (so typing either "theme" or "shift" narrows the list),
* matching the searchable keyboard-shortcut affordance found in comparable
* desktop apps (Codex's "keypress search", VS Code / Claude keybindings).
*/

import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Search, X } from 'lucide-react'
import { PanelHeader } from '@/components/app-shell/PanelHeader'
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, ACTION_LABEL_KEYS, type ActionId } from '@/actions'
import {
actionsByCategory,
useActionRegistry,
ACTION_LABEL_KEYS,
type ActionId,
} from '@/actions'

export const meta: DetailsPageMeta = {
navigator: 'settings',
slug: 'shortcuts',
}

interface ShortcutItem {
interface ShortcutRow {
/** Stable key for React. */
key: string
/** Visible label for the shortcut. */
label: string
/** Individual keys to render as <kbd> chips. */
keys: string[]
description: string
}

interface ShortcutSection {
key: string
title: string
shortcuts: ShortcutItem[]
rows: ShortcutRow[]
}

// Component-specific shortcuts that aren't in the centralized registry
function useComponentSpecificSections(): ShortcutSection[] {
const { t } = useTranslation()
return [
{
title: t('shortcuts.listNavigation'),
shortcuts: [
{ keys: ['↑', '↓'], description: t('shortcuts.navigateItems') },
{ keys: ['Home'], description: t('shortcuts.goToFirst') },
{ keys: ['End'], description: t('shortcuts.goToLast') },
],
},
{
title: t('shortcuts.sessionList'),
shortcuts: [
{ keys: ['Enter'], description: t('shortcuts.focusChatInput') },
{ keys: ['Right-click'], description: t('shortcuts.openContextMenu') },
{ keys: [isMac ? '⌥' : 'Alt', 'Click'], description: t('shortcuts.addFilterExcluded') },
],
},
{
title: t('shortcuts.chatInput'),
shortcuts: [
{ keys: ['Enter'], description: t('shortcuts.sendMessage') },
{ keys: ['Shift', 'Enter'], description: t('shortcuts.newLine') },
{ keys: ['Esc'], description: t('shortcuts.closeDialogBlur') },
],
},
]
/** Split a display hotkey (e.g. "⌘⇧N" on Mac, "Ctrl+Shift+N" elsewhere) into keys. */
function splitHotkey(hotkey: string): string[] {
return isMac ? hotkey.match(/[⌘⇧⌥←→]|Tab|Esc|./g) || [] : hotkey.split('+')
}

/** Lowercased haystack for a row: its label plus every key token. */
function rowHaystack(row: ShortcutRow): string {
return `${row.label} ${row.keys.join(' ')}`.toLowerCase()
}

function Kbd({ children }: { children: React.ReactNode }) {
Expand All @@ -67,69 +62,154 @@ function Kbd({ children }: { children: React.ReactNode }) {
)
}

/**
* Renders a shortcut row for an action from the registry
*/
function ActionShortcutRow({ actionId }: { actionId: ActionId }) {
export default function ShortcutsPage() {
const { t } = useTranslation()
const { label, hotkey } = useActionLabel(actionId)
const { getAction, getHotkeyDisplay } = useActionRegistry()
const [query, setQuery] = React.useState('')
const inputRef = React.useRef<HTMLInputElement>(null)

if (!hotkey) return null
// Build the full, flat section model up front so we can filter it purely.
// Registry-driven sections come from actionsByCategory; hotkey-less actions
// are omitted (they have no shortcut to show), matching prior behaviour.
const sections = React.useMemo<ShortcutSection[]>(() => {
const registrySections: ShortcutSection[] = Object.entries(actionsByCategory).map(
([category, categoryActions]) => {
const rows: ShortcutRow[] = []
for (const action of categoryActions) {
const actionId = action.id as ActionId
const hotkey = getHotkeyDisplay(actionId)
if (!hotkey) continue
const labelKey = ACTION_LABEL_KEYS[actionId]
const label = labelKey ? t(labelKey) : getAction(actionId).label
rows.push({ key: actionId, label, keys: splitHotkey(hotkey) })
}
return {
key: `category-${category}`,
title: t(`shortcuts.category.${category.toLowerCase()}`),
rows,
}
},
)

// Split hotkey into individual keys for display
// Mac: symbols are concatenated (⌘⇧N) - need smart splitting
// Windows: separated by + (Ctrl+Shift+N) - split on +
const keys = isMac
? hotkey.match(/[⌘⇧⌥←→]|Tab|Esc|./g) || []
: hotkey.split('+')
// Component-specific shortcuts that aren't in the centralized registry.
const componentSections: ShortcutSection[] = [
{
key: 'listNavigation',
title: t('shortcuts.listNavigation'),
rows: [
{ key: 'navigateItems', label: t('shortcuts.navigateItems'), keys: ['↑', '↓'] },
{ key: 'goToFirst', label: t('shortcuts.goToFirst'), keys: ['Home'] },
{ key: 'goToLast', label: t('shortcuts.goToLast'), keys: ['End'] },
],
},
{
key: 'sessionList',
title: t('shortcuts.sessionList'),
rows: [
{ key: 'focusChatInput', label: t('shortcuts.focusChatInput'), keys: ['Enter'] },
{ key: 'openContextMenu', label: t('shortcuts.openContextMenu'), keys: ['Right-click'] },
{
key: 'addFilterExcluded',
label: t('shortcuts.addFilterExcluded'),
keys: [isMac ? '⌥' : 'Alt', 'Click'],
},
],
},
{
key: 'chatInput',
title: t('shortcuts.chatInput'),
rows: [
{ key: 'sendMessage', label: t('shortcuts.sendMessage'), keys: ['Enter'] },
{ key: 'newLine', label: t('shortcuts.newLine'), keys: ['Shift', 'Enter'] },
{ key: 'closeDialogBlur', label: t('shortcuts.closeDialogBlur'), keys: ['Esc'] },
],
},
]

return (
<SettingsRow label={ACTION_LABEL_KEYS[actionId] ? t(ACTION_LABEL_KEYS[actionId]!) : label}>
<div className="flex items-center gap-1">
{keys.map((key, keyIndex) => (
<Kbd key={keyIndex}>{key}</Kbd>
))}
</div>
</SettingsRow>
)
}
return [...registrySections, ...componentSections].filter((s) => s.rows.length > 0)
}, [t, getAction, getHotkeyDisplay])

// Filter rows by label + key tokens; drop sections that end up empty.
const trimmedQuery = query.trim().toLowerCase()
const filteredSections = React.useMemo<ShortcutSection[]>(() => {
if (!trimmedQuery) return sections
return sections
.map((section) => ({
...section,
rows: section.rows.filter((row) => rowHaystack(row).includes(trimmedQuery)),
}))
.filter((section) => section.rows.length > 0)
}, [sections, trimmedQuery])

const hasResults = filteredSections.length > 0

export default function ShortcutsPage() {
const { t } = useTranslation()
const componentSpecificSections = useComponentSpecificSections()
return (
<div className="h-full flex flex-col">
<PanelHeader title={t("settings.shortcuts.title")} />
<PanelHeader title={t('settings.shortcuts.title')} />
{/* Search box — filters shortcuts by action label + key combination */}
<div className="shrink-0 px-5 pt-4 max-w-3xl mx-auto w-full">
<div className="relative rounded-[8px] shadow-minimal bg-muted/50 has-[:focus-visible]:bg-background">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
ref={inputRef}
type="text"
role="searchbox"
aria-label={t('common.search')}
data-testid="shortcuts-search-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && query) {
e.stopPropagation()
setQuery('')
}
}}
placeholder={t('common.search')}
className="w-full h-8 pl-8 pr-8 text-sm bg-transparent border-0 rounded-[8px] outline-none focus-visible:ring-0 focus-visible:outline-none placeholder:text-muted-foreground/50"
/>
{query && (
<button
type="button"
aria-label={t('common.clear')}
data-testid="shortcuts-search-clear"
onClick={() => {
setQuery('')
inputRef.current?.focus()
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-foreground/10"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>
<div className="flex-1 min-h-0 mask-fade-y">
<ScrollArea className="h-full">
<div className="px-5 py-7 max-w-3xl mx-auto space-y-8">
{/* Registry-driven sections */}
{Object.entries(actionsByCategory).map(([category, actions]) => (
<SettingsSection key={category} title={t(`shortcuts.category.${category.toLowerCase()}`)}>
<SettingsCard>
{actions.map(action => (
<ActionShortcutRow key={action.id} actionId={action.id as ActionId} />
))}
</SettingsCard>
</SettingsSection>
))}

{/* Component-specific sections */}
{componentSpecificSections.map((section) => (
<SettingsSection key={section.title} title={section.title}>
<SettingsCard>
{section.shortcuts.map((shortcut, index) => (
<SettingsRow key={index} label={shortcut.description}>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, keyIndex) => (
<Kbd key={keyIndex}>{key}</Kbd>
))}
</div>
</SettingsRow>
))}
</SettingsCard>
</SettingsSection>
))}
{hasResults ? (
filteredSections.map((section) => (
<SettingsSection key={section.key} title={section.title}>
<SettingsCard>
{section.rows.map((row) => (
<SettingsRow key={row.key} label={row.label}>
<div className="flex items-center gap-1">
{row.keys.map((key, keyIndex) => (
<Kbd key={keyIndex}>{key}</Kbd>
))}
</div>
</SettingsRow>
))}
</SettingsCard>
</SettingsSection>
))
) : (
<div
data-testid="shortcuts-search-empty"
className="px-4 py-8 text-center text-sm text-muted-foreground"
>
{t('common.noResultsFound')}
</div>
)}
</div>
</ScrollArea>
</div>
Expand Down
1 change: 1 addition & 0 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| shortcuts-search | Search box on the Settings → Keyboard Shortcuts page | Codex "keypress search" + VS Code / Claude keybindings search; mirrors OpenWork's own settings-navigator search (#40) | frontend-only | pr-open | [#58](https://github.com/modelstudioai/openwork/issues/58) | [#59](https://github.com/modelstudioai/openwork/pull/59) | loop/shortcuts-search | 2026-07-05 | Pure renderer view over the action registry (`actionsByCategory` + `getHotkeyDisplay`); filters rows by action label **and** rendered key tokens ("keypress search"), hides empty sections, shows empty state. Reuses `common.search`/`noResultsFound`/`clear` (**zero** new i18n keys). Added `data-testid="settings-item-<id>"` to settings-nav items for e2e navigation. typecheck/renderer-build/i18n-parity zero-delta vs main; touched-area tests pass. CDP assertion written (app launch egress-blocked locally, same as prior rounds). |
| command-palette-recents | "Recently used" section in the Command Palette (⌘K) | VS Code ⌘⇧P / Raycast / Linear ⌘K / Claude Code Desktop command menu | frontend-only | pr-open | [#56](https://github.com/modelstudioai/openwork/issues/56) | [#57](https://github.com/modelstudioai/openwork/pull/57) | loop/command-palette-recents | 2026-07-05 | Palette (#42) had no memory of what you run. Adds a persisted recents list (`localStorage` `command-palette-recents`, dedupe/cap-6, newest-first) surfaced as a top "Recently used" group, hidden while searching. Pure `pushRecent` module + 6 unit tests (pass). One new i18n key `commands.recent` × 7 locales. typecheck/`bun test` zero-delta vs main (56 baseline fails on both, 0 new); renderer build ✅. **CDP could not run locally** (same env block as prior rounds: egress 403s Electron binary + `libsignal`/WhatsApp-worker dep) — assertion included for CI/reviewer. |
| thinking-menu-shortcut | Keyboard shortcut (⌘⇧E) to open the composer's thinking (effort) menu | Claude Code Desktop effort-menu shortcut (⌘⇧E); explicit follow-up flagged in #44 | frontend-only | pr-open | [#54](https://github.com/modelstudioai/openwork/issues/54) | [#55](https://github.com/modelstudioai/openwork/pull/55) | loop/thinking-menu-shortcut | 2026-07-04 | New `chat.openThinkingMenu` action (`mod+shift+e`) → scoped `craft:open-composer-menu` event → `FreeFormInput` opens its already-controlled `thinkingDropdownOpen`. Mirrors `craft:focus-input`/`shouldHandleScopedInputEvent` for multi-panel safety. Auto-appears in Command Palette + Shortcuts reference. 1 new i18n key across 7 locales. Avoids ⌘⇧I (reserved for DevTools) / ⌘⇧M (permission mode already has Shift+Tab). typecheck zero-delta (11 pre-existing); `bun test` zero-delta (56 identical failures, diffed vs main); i18n parity OK; renderer build ✅; assertion transpiles ✅. **CDP blocked locally** (same egress limit: `libsignal` GitHub dep + Electron binary 403). |
| prompt-history-recall | Recall previously-sent prompts in the composer with Up/Down arrows | Claude Code CLI `↑` history + Codex desktop "recover your previous prompt by pressing the up arrow" | frontend-only | pr-open | [#52](https://github.com/modelstudioai/openwork/issues/52) | [#53](https://github.com/modelstudioai/openwork/pull/53) | loop/composer-prompt-history | 2026-07-04 | New pure module `prompt-history.ts` (push/prev/next/entry-at) + global `localStorage['craft-prompt-history']`. Wired into `FreeFormInput.handleKeyDown`: Up recalls when composer empty/recalling, Down walks newer & restores draft, edit/submit/session-switch exit recall. Zero new i18n keys. `data-testid="composer-input"` added for e2e. **DoD:** typecheck:all +0 vs main (11 pre-existing electron errors only); packages/ui clean; `bun test` failure set byte-identical to main (56 pre-existing, verified by stash-diff) + 20 new passing unit tests; renderer build ✅; assertion transpiles. **CDP could not run locally** (egress 403s the Electron binary download, same as prior loop PRs) — assertion included for CI/reviewer. |
Expand Down
Loading