Skip to content

Commit 23232ff

Browse files
claudeDragonnZhang
authored andcommitted
Add a search box to the Settings → Keyboard Shortcuts page
The Shortcuts settings page listed ~30 shortcuts across many sections with no way to filter them. Add a search box that filters the rows by both the action label and the rendered key combination (keypress search): typing "theme" finds the theme shortcut, typing "shift" finds every shortcut that uses Shift. Hides sections that have no remaining rows, shows a "No results found" empty state, and clears with the ✕ button or Esc. Frontend-only: the page is a pure renderer view over the action registry, so filtering is client-side React state. Reuses common.search / common.noResultsFound / common.clear (no new i18n keys). Adds data-testid="settings-item-<id>" to the settings navigator items so the e2e assertion can open the sub-page. Closes #58
1 parent 4393be0 commit 23232ff

4 files changed

Lines changed: 341 additions & 86 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ function SettingsItemRow({ item, isSelected, isFirst, onSelect }: SettingsItemRo
9393
<button
9494
type="button"
9595
onClick={onSelect}
96+
data-testid={`settings-item-${item.id}`}
9697
className={cn(
9798
'flex w-full items-start gap-2 pl-2 pr-4 py-3 text-left text-sm outline-none rounded-[8px]',
9899
// Fast hover transition (75ms vs default 150ms)

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

Lines changed: 166 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -2,61 +2,56 @@
22
* ShortcutsPage
33
*
44
* Displays keyboard shortcuts reference from the centralized action registry.
5+
*
6+
* A search box at the top filters the shortcuts by their action label and their
7+
* key combination (so typing either "theme" or "shift" narrows the list),
8+
* matching the searchable keyboard-shortcut affordance found in comparable
9+
* desktop apps (Codex's "keypress search", VS Code / Claude keybindings).
510
*/
611

712
import * as React from 'react'
813
import { useTranslation } from 'react-i18next'
14+
import { Search, X } from 'lucide-react'
915
import { PanelHeader } from '@/components/app-shell/PanelHeader'
1016
import { ScrollArea } from '@/components/ui/scroll-area'
1117
import { SettingsSection, SettingsCard, SettingsRow } from '@/components/settings'
1218
import type { DetailsPageMeta } from '@/lib/navigation-registry'
1319
import { isMac } from '@/lib/platform'
14-
import { actionsByCategory, useActionLabel, ACTION_LABEL_KEYS, type ActionId } from '@/actions'
20+
import {
21+
actionsByCategory,
22+
useActionRegistry,
23+
ACTION_LABEL_KEYS,
24+
type ActionId,
25+
} from '@/actions'
1526

1627
export const meta: DetailsPageMeta = {
1728
navigator: 'settings',
1829
slug: 'shortcuts',
1930
}
2031

21-
interface ShortcutItem {
32+
interface ShortcutRow {
33+
/** Stable key for React. */
34+
key: string
35+
/** Visible label for the shortcut. */
36+
label: string
37+
/** Individual keys to render as <kbd> chips. */
2238
keys: string[]
23-
description: string
2439
}
2540

2641
interface ShortcutSection {
42+
key: string
2743
title: string
28-
shortcuts: ShortcutItem[]
44+
rows: ShortcutRow[]
2945
}
3046

31-
// Component-specific shortcuts that aren't in the centralized registry
32-
function useComponentSpecificSections(): ShortcutSection[] {
33-
const { t } = useTranslation()
34-
return [
35-
{
36-
title: t('shortcuts.listNavigation'),
37-
shortcuts: [
38-
{ keys: ['↑', '↓'], description: t('shortcuts.navigateItems') },
39-
{ keys: ['Home'], description: t('shortcuts.goToFirst') },
40-
{ keys: ['End'], description: t('shortcuts.goToLast') },
41-
],
42-
},
43-
{
44-
title: t('shortcuts.sessionList'),
45-
shortcuts: [
46-
{ keys: ['Enter'], description: t('shortcuts.focusChatInput') },
47-
{ keys: ['Right-click'], description: t('shortcuts.openContextMenu') },
48-
{ keys: [isMac ? '⌥' : 'Alt', 'Click'], description: t('shortcuts.addFilterExcluded') },
49-
],
50-
},
51-
{
52-
title: t('shortcuts.chatInput'),
53-
shortcuts: [
54-
{ keys: ['Enter'], description: t('shortcuts.sendMessage') },
55-
{ keys: ['Shift', 'Enter'], description: t('shortcuts.newLine') },
56-
{ keys: ['Esc'], description: t('shortcuts.closeDialogBlur') },
57-
],
58-
},
59-
]
47+
/** Split a display hotkey (e.g. "⌘⇧N" on Mac, "Ctrl+Shift+N" elsewhere) into keys. */
48+
function splitHotkey(hotkey: string): string[] {
49+
return isMac ? hotkey.match(/[]|Tab|Esc|./g) || [] : hotkey.split('+')
50+
}
51+
52+
/** Lowercased haystack for a row: its label plus every key token. */
53+
function rowHaystack(row: ShortcutRow): string {
54+
return `${row.label} ${row.keys.join(' ')}`.toLowerCase()
6055
}
6156

6257
function Kbd({ children }: { children: React.ReactNode }) {
@@ -67,69 +62,154 @@ function Kbd({ children }: { children: React.ReactNode }) {
6762
)
6863
}
6964

70-
/**
71-
* Renders a shortcut row for an action from the registry
72-
*/
73-
function ActionShortcutRow({ actionId }: { actionId: ActionId }) {
65+
export default function ShortcutsPage() {
7466
const { t } = useTranslation()
75-
const { label, hotkey } = useActionLabel(actionId)
67+
const { getAction, getHotkeyDisplay } = useActionRegistry()
68+
const [query, setQuery] = React.useState('')
69+
const inputRef = React.useRef<HTMLInputElement>(null)
7670

77-
if (!hotkey) return null
71+
// Build the full, flat section model up front so we can filter it purely.
72+
// Registry-driven sections come from actionsByCategory; hotkey-less actions
73+
// are omitted (they have no shortcut to show), matching prior behaviour.
74+
const sections = React.useMemo<ShortcutSection[]>(() => {
75+
const registrySections: ShortcutSection[] = Object.entries(actionsByCategory).map(
76+
([category, categoryActions]) => {
77+
const rows: ShortcutRow[] = []
78+
for (const action of categoryActions) {
79+
const actionId = action.id as ActionId
80+
const hotkey = getHotkeyDisplay(actionId)
81+
if (!hotkey) continue
82+
const labelKey = ACTION_LABEL_KEYS[actionId]
83+
const label = labelKey ? t(labelKey) : getAction(actionId).label
84+
rows.push({ key: actionId, label, keys: splitHotkey(hotkey) })
85+
}
86+
return {
87+
key: `category-${category}`,
88+
title: t(`shortcuts.category.${category.toLowerCase()}`),
89+
rows,
90+
}
91+
},
92+
)
7893

79-
// Split hotkey into individual keys for display
80-
// Mac: symbols are concatenated (⌘⇧N) - need smart splitting
81-
// Windows: separated by + (Ctrl+Shift+N) - split on +
82-
const keys = isMac
83-
? hotkey.match(/[]|Tab|Esc|./g) || []
84-
: hotkey.split('+')
94+
// Component-specific shortcuts that aren't in the centralized registry.
95+
const componentSections: ShortcutSection[] = [
96+
{
97+
key: 'listNavigation',
98+
title: t('shortcuts.listNavigation'),
99+
rows: [
100+
{ key: 'navigateItems', label: t('shortcuts.navigateItems'), keys: ['↑', '↓'] },
101+
{ key: 'goToFirst', label: t('shortcuts.goToFirst'), keys: ['Home'] },
102+
{ key: 'goToLast', label: t('shortcuts.goToLast'), keys: ['End'] },
103+
],
104+
},
105+
{
106+
key: 'sessionList',
107+
title: t('shortcuts.sessionList'),
108+
rows: [
109+
{ key: 'focusChatInput', label: t('shortcuts.focusChatInput'), keys: ['Enter'] },
110+
{ key: 'openContextMenu', label: t('shortcuts.openContextMenu'), keys: ['Right-click'] },
111+
{
112+
key: 'addFilterExcluded',
113+
label: t('shortcuts.addFilterExcluded'),
114+
keys: [isMac ? '⌥' : 'Alt', 'Click'],
115+
},
116+
],
117+
},
118+
{
119+
key: 'chatInput',
120+
title: t('shortcuts.chatInput'),
121+
rows: [
122+
{ key: 'sendMessage', label: t('shortcuts.sendMessage'), keys: ['Enter'] },
123+
{ key: 'newLine', label: t('shortcuts.newLine'), keys: ['Shift', 'Enter'] },
124+
{ key: 'closeDialogBlur', label: t('shortcuts.closeDialogBlur'), keys: ['Esc'] },
125+
],
126+
},
127+
]
85128

86-
return (
87-
<SettingsRow label={ACTION_LABEL_KEYS[actionId] ? t(ACTION_LABEL_KEYS[actionId]!) : label}>
88-
<div className="flex items-center gap-1">
89-
{keys.map((key, keyIndex) => (
90-
<Kbd key={keyIndex}>{key}</Kbd>
91-
))}
92-
</div>
93-
</SettingsRow>
94-
)
95-
}
129+
return [...registrySections, ...componentSections].filter((s) => s.rows.length > 0)
130+
}, [t, getAction, getHotkeyDisplay])
131+
132+
// Filter rows by label + key tokens; drop sections that end up empty.
133+
const trimmedQuery = query.trim().toLowerCase()
134+
const filteredSections = React.useMemo<ShortcutSection[]>(() => {
135+
if (!trimmedQuery) return sections
136+
return sections
137+
.map((section) => ({
138+
...section,
139+
rows: section.rows.filter((row) => rowHaystack(row).includes(trimmedQuery)),
140+
}))
141+
.filter((section) => section.rows.length > 0)
142+
}, [sections, trimmedQuery])
143+
144+
const hasResults = filteredSections.length > 0
96145

97-
export default function ShortcutsPage() {
98-
const { t } = useTranslation()
99-
const componentSpecificSections = useComponentSpecificSections()
100146
return (
101147
<div className="h-full flex flex-col">
102-
<PanelHeader title={t("settings.shortcuts.title")} />
148+
<PanelHeader title={t('settings.shortcuts.title')} />
149+
{/* Search box — filters shortcuts by action label + key combination */}
150+
<div className="shrink-0 px-5 pt-4 max-w-3xl mx-auto w-full">
151+
<div className="relative rounded-[8px] shadow-minimal bg-muted/50 has-[:focus-visible]:bg-background">
152+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
153+
<input
154+
ref={inputRef}
155+
type="text"
156+
role="searchbox"
157+
aria-label={t('common.search')}
158+
data-testid="shortcuts-search-input"
159+
value={query}
160+
onChange={(e) => setQuery(e.target.value)}
161+
onKeyDown={(e) => {
162+
if (e.key === 'Escape' && query) {
163+
e.stopPropagation()
164+
setQuery('')
165+
}
166+
}}
167+
placeholder={t('common.search')}
168+
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"
169+
/>
170+
{query && (
171+
<button
172+
type="button"
173+
aria-label={t('common.clear')}
174+
data-testid="shortcuts-search-clear"
175+
onClick={() => {
176+
setQuery('')
177+
inputRef.current?.focus()
178+
}}
179+
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-foreground/10"
180+
>
181+
<X className="h-3.5 w-3.5 text-muted-foreground" />
182+
</button>
183+
)}
184+
</div>
185+
</div>
103186
<div className="flex-1 min-h-0 mask-fade-y">
104187
<ScrollArea className="h-full">
105188
<div className="px-5 py-7 max-w-3xl mx-auto space-y-8">
106-
{/* Registry-driven sections */}
107-
{Object.entries(actionsByCategory).map(([category, actions]) => (
108-
<SettingsSection key={category} title={t(`shortcuts.category.${category.toLowerCase()}`)}>
109-
<SettingsCard>
110-
{actions.map(action => (
111-
<ActionShortcutRow key={action.id} actionId={action.id as ActionId} />
112-
))}
113-
</SettingsCard>
114-
</SettingsSection>
115-
))}
116-
117-
{/* Component-specific sections */}
118-
{componentSpecificSections.map((section) => (
119-
<SettingsSection key={section.title} title={section.title}>
120-
<SettingsCard>
121-
{section.shortcuts.map((shortcut, index) => (
122-
<SettingsRow key={index} label={shortcut.description}>
123-
<div className="flex items-center gap-1">
124-
{shortcut.keys.map((key, keyIndex) => (
125-
<Kbd key={keyIndex}>{key}</Kbd>
126-
))}
127-
</div>
128-
</SettingsRow>
129-
))}
130-
</SettingsCard>
131-
</SettingsSection>
132-
))}
189+
{hasResults ? (
190+
filteredSections.map((section) => (
191+
<SettingsSection key={section.key} title={section.title}>
192+
<SettingsCard>
193+
{section.rows.map((row) => (
194+
<SettingsRow key={row.key} label={row.label}>
195+
<div className="flex items-center gap-1">
196+
{row.keys.map((key, keyIndex) => (
197+
<Kbd key={keyIndex}>{key}</Kbd>
198+
))}
199+
</div>
200+
</SettingsRow>
201+
))}
202+
</SettingsCard>
203+
</SettingsSection>
204+
))
205+
) : (
206+
<div
207+
data-testid="shortcuts-search-empty"
208+
className="px-4 py-8 text-center text-sm text-muted-foreground"
209+
>
210+
{t('common.noResultsFound')}
211+
</div>
212+
)}
133213
</div>
134214
</ScrollArea>
135215
</div>

docs/loop/feature-ledger.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ log, not the system of record.
3333

3434
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3535
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
36+
| 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). |
3637
| 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. |
3738
| 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). |
3839
| 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. |

0 commit comments

Comments
 (0)