Skip to content
Open
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
12 changes: 8 additions & 4 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-03 | Renderer-only pref (localStorage) applied app-wide via `<MotionConfig reducedMotion>` + `data-reduce-motion` on `<html>` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion included; **could not run locally** (egress 403s Electron binary download). |
| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude/ChatGPT/Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. |
| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code / ChatGPT / Codex desktop | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Opened by a prior run. Floating jump button in `ChatDisplay` + `seed()` harness hook. Awaiting review. |
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-03 | **Merged** into `main` (2026-07-02). `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). |
| 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 group 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 | Prior round: branch + PR #57 already on GitHub (this run reconciled the ledger; PR #57 was missing the `loop-bot` label — added). |
| thinking-menu-shortcut | ⌘⇧E keyboard shortcut to open the composer thinking menu | Claude Code Desktop effort-menu shortcut (⌘⇧E) | 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 | Prior round; PR open awaiting review. |
| prompt-history | Recall previously-sent prompts with Up / Down in the composer | Codex desktop composer up-arrow recall / shell history | 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 | Prior round; PR open awaiting review. |
| reduce-motion | "Reduce motion" accessibility toggle in Appearance | Claude desktop / macOS / Windows reduce-motion; `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-03 | Prior round; PR open awaiting review. |
| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude / ChatGPT / Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-03 | Prior round; PR open awaiting review. |
| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the transcript | Claude Code Desktop / ChatGPT / Codex desktop jump-to-latest | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Prior round; PR open awaiting review. Added a reusable `seed()` harness hook (not yet on `main`). |
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-05 | **Merged into `main`.** `thinkingLevel`/`onThinkingLevelChange` were already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). |
| 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 | merged | [#41](https://github.com/modelstudioai/openwork/issues/41) | [#42](https://github.com/modelstudioai/openwork/pull/42) | loop/command-palette | 2026-07-02 | Merged into `main`. 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