Skip to content

feat(web): keyboard shortcuts settings page & migrate hotkeys#654

Merged
sheepbox8646 merged 14 commits into
memohai:mainfrom
ChrAlpha:feat/keyboard-shortcuts-settings
Jun 17, 2026
Merged

feat(web): keyboard shortcuts settings page & migrate hotkeys#654
sheepbox8646 merged 14 commits into
memohai:mainfrom
ChrAlpha:feat/keyboard-shortcuts-settings

Conversation

@ChrAlpha

Copy link
Copy Markdown
Member

Summary

  • 新增 /settings/keyboard 设置页:分组列出所有快捷键、支持重绑/重置默认/全部重置,冲突检测(reserved 阻断、same-scope 阻断、cross-scope 警告)
  • 把 keyboard-bindings.ts 升级为「单一声明表 + 用户覆盖」架构:每条 binding 带 scope 和 i18nKey,Pinia store 维护 localStorage 覆盖,dispatcher 改为 reactive 读取以热生效
  • 迁移原本散落的全局快捷键到声明表:SidebarProvider 的 Cmd/Ctrl+B(加 disableDefaultShortcut prop 保持向后兼容)、media-gallery-lightbox 的 Esc/←/→
  • 新增 Mod+K 全局打开设置(在 settings 内 no-op,避免重定向打断用户)
  • 桌面端:renderer 改用 live store dispatcher,新增 IPC desktop:set-menu-accelerators 让 Electron 原生菜单 accelerator 跟随用户绑定;SETTINGS_ROUTE_SPECS 加 keyboard 入口
  • en/zh/ja 全套翻译;ShortcutRow 用 Kbd/KbdGroup 渲染当前组合(mac glyphs vs 文字 label)

ChrAlpha added 12 commits June 16, 2026 09:10
Pure functions that lift the existing { key, mod, alt, shift } binding shape
into a canonical 'Mod+Shift+K' string for storage, parse user input from a
capture dialog, and render display tokens for the Kbd component. Mac glyphs
(⌘ ⌥ ⇧ ⎋ ⏎ ⌫ ←→↑↓) vs platform text labels are handled here so callers stay
platform-agnostic.
…rated rows

Adds four new commands (toggleSidebar, closeMediaLightbox, mediaLightboxPrev,
mediaLightboxNext) so the previously hardcoded Sidebar Cmd/Ctrl+B and the
media-lightbox Escape/Arrow keys can flow through the same single-source
binding table the settings page renders. Each row now carries:

  - scope    'global' (always live) vs 'mediaLightbox' (component-scoped via
             useKeyboardCommand) — drives section grouping on the settings
             page and conflict-detection rules.
  - i18nKey  camelCase id forming 'settings.keyboard.commands.<i18nKey>.label'
             and '.description' so translators have a stable surface.
…ct detection

The store holds a per-command override map in localStorage and exposes a
reactive 'effectiveBindings' that merges those overrides onto the default
table. setBinding() rejects invalid combos, OS-reserved combos (Mod+W/Q/T/N),
and same-scope collisions; cross-scope collisions are a soft warning since
the mediaLightbox scope is only live while the component is mounted.

Overrides force browser: 'intercept' on the merged binding so a rebound
commands like closeCurrentWorkspaceTab — which is browser: 'passthrough' on
its reserved default Mod+W — actually gets dispatched once moved off the
reserved combo.
The matcher now continues past bindings whose commands have no live handler so
a scoped binding (e.g. mediaLightbox Escape) can hand the key back to a global
while its owning component is unmounted, instead of silently swallowing it.

connectBrowserKeyboardShortcutsLive takes a bindings getter the listener
re-reads on each keydown, so the shortcuts store's effectiveBindings (defaults
merged with user overrides) take effect the instant the user rebinds — without
churn or re-subscription.
SidebarProvider grows a disableDefaultShortcut prop so consumers that already
own a keyboard dispatcher can opt out of the built-in window listener (the
default stays false so other consumers — including the dev component wall —
keep their behavior unchanged).

apps/web's main layout opts in: it disables the provider's listener and binds
toggleSidebar through useKeyboardCommand, so the combo is now configurable
from the upcoming Keyboard Shortcuts settings page. The master-detail nested
layout disables the listener without re-binding — its inner sidebar is layout
chrome, not a user-toggleable surface, and shouldn't double-fire the global.
The lightbox's hardcoded window.keydown listener is replaced with three
scoped useKeyboardCommand registrations whose handlers return false while the
lightbox is closed — the dispatcher then keeps iterating, so a future global
binding on the same key isn't shadowed by a deactivated lightbox handler.

Focus management (saving the previously focused element on open, restoring on
close) stays where it was; only the keyboard wiring moves.
The page lists bindings grouped by scope (Global / Media Lightbox) using the
existing SettingsSection layout. Each row shows the i18n label + description
and renders the current combo via the design system's Kbd/KbdGroup chips,
with a reset icon shown only when the user has overridden the default.

KeyCaptureDialog runs a window-level capture-phase keydown listener while
open, so any combination — including Escape — is captureable; the listener
stopImmediatePropagation()'s past the global dispatcher and reka-ui's
escape-to-close. Conflict feedback is reactive: reserved/same-scope blocks
Save, cross-scope is allowed with an amber warning. Reset-to-default is
exposed inline so users can recover a single binding without nuking the rest.
Adds the /settings/keyboard route (Account group, after Appearance) with the
Keyboard lucide icon, plus full en/zh/ja entries for the page chrome, scope
labels, conflict-dialog copy, and per-command label/description pairs that the
row + dialog read by binding.i18nKey.
…or IPC

The desktop renderer now drives its keyboard dispatcher from the shared
Pinia store (same as web), so a user override applied in the settings
window takes effect the instant the chat window's next keydown fires. A
watchEffect on effectiveBindings pushes the menu-delivered commands'
Electron accelerators over a new 'desktop:set-menu-accelerators' IPC to
the main process; main keeps an overrides Map that takes precedence over
the static table when building the native menu, and rebuilds the menu only
when the incoming set actually differs.

SETTINGS_ROUTE_SPECS gets the /settings/keyboard entry next to Appearance
so the desktop settings window can mount the page. The renderer type stubs
are extended with the new commands, the KeyboardScope/i18nKey fields, the
connectBrowserKeyboardShortcutsLive export, and the
@memohai/web/store/keyboard-shortcuts subpath.
Routes the press to the same /settings entry both web and desktop already
redirect to bots, so it works as a single-step jump from anywhere — chat,
workspace, modal, lightbox — without needing a contextual handler. The combo
is non-reserved, so the Keyboard Shortcuts page lets users rebind it like any
other row; the existing dispatcher + store wiring carry the rest.

The handler is registered directly on the keyboard registry (not via a Vue
composable) so it survives every route transition and works in both
@memohai/web and the desktop renderer entry without duplicating mount-time
plumbing.
- (bug) Sort effectiveBindings so scoped rows precede globals. The
  first-handled-wins dispatcher previously gave a global the first crack at
  any combo it shared with a scoped binding; a stable sort fixes the
  shadowing risk before it bites a future cross-scope collision.
- (risk) KeyCaptureDialog now lets Enter/Space pass through when the focused
  target is a button, so Save/Cancel/Reset stay usable via keyboard. Binding
  Enter/Space themselves still works as long as focus is off those buttons.
- (risk) The desktop renderer's setMenuAccelerators IPC was fire-and-forget;
  add a .catch that warns so a silent reject doesn't leave the native menu
  frozen at table defaults with no diagnostic.
- (risk) Mod+K open-settings no-ops when already inside /settings, so
  pressing it from /settings/keyboard doesn't yank the user back to
  /settings/bots via the default redirect.
- (risk) zh.json and ja.json keyboard.intro had a hardcoded English 'Edit';
  swap in 「编辑」/「編集」 so the prose matches the localized button label.
…ator

The earlier menu-accelerator override commit added a helper typed
`(command: AppKeyboardCommand)` but only imported the runtime values
`acceleratorForCommand` and `appKeyboardCommands`. tsc --noEmit (Electron
CI's typecheck:node step) errors with TS2304 'Cannot find name
AppKeyboardCommand'. Add the type import so the desktop main process
typecheck is clean again.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

parts.push(normalizeAcceleratorKey(binding.key))

P2 Badge Normalize DOM key names before Electron accelerators

When a desktop user rebinds the menu-delivered close-tab command to keys captured from KeyboardEvent.key, values like ArrowLeft or the space character are sent directly to Electron. Electron accelerator key codes use names such as Left/Right and Space, not DOM names like ArrowLeft or raw ' ', so these saved menu shortcuts silently lose their native accelerator even though the settings UI accepts them. Please translate captured DOM keys to Electron accelerator names before rebuilding the menu.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +81 to +84
mod: isMac ? event.metaKey : event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
key: canonicalKey(event.key),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unsupported modifier presses during capture

On macOS, capturing a shortcut such as Control+S reaches this path with isMac === true, so ctrlKey is discarded and the stored combo becomes plain s. The global dispatcher then matches that plain letter anywhere, including while typing in normal inputs, so a user trying to bind a Ctrl-based shortcut can accidentally make ordinary text entry trigger the command. Please either model Ctrl separately or reject/ignore captures that contain unsupported modifier keys instead of dropping them.

Useful? React with 👍 / 👎.

Comment on lines +46 to +48
useKeyboardCommand(appKeyboardCommands.toggleSidebar, () => {
isOpen.value = !isOpen.value
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep desktop settings sidebar pinned

In the desktop shell this layout explicitly skips auto-collapse because the settings sidebar is meant to stay pinned open, but the new global toggle still flips isOpen. Pressing the default Mod+B while a desktop settings page is focused now collapses that sidebar despite the desktop layout having no collapse affordance and assuming it remains open; guard this handler with desktopShell or avoid registering it for the desktop settings layout.

Useful? React with 👍 / 👎.

</div>
<div
v-else-if="conflict.kind === 'cross-scope'"
class="text-amber-600 dark:text-amber-400"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use semantic tokens for warning text

apps/web/AGENTS.md requires semantic color tokens and explicitly forbids hardcoded Tailwind colors for themed elements. This warning state hardcodes amber shades with a dark: override, so it can drift from Memoh color schemes and violates the documented UI contract; use an existing semantic token or add a token for this warning color.

Useful? React with 👍 / 👎.

…arning token

- keyComboFromEvent rejects any macOS capture with ctrlKey set. Previously
  ctrl was silently dropped to mod=false, so binding Ctrl+S on macOS would
  store as plain 's' and the dispatcher would fire on every literal 's'
  keypress — including normal typing.
- KeyCaptureDialog's cross-scope warning swaps the hardcoded amber Tailwind
  classes for the existing semantic --warning token so it tracks Memoh
  color schemes and stops violating apps/web/AGENTS.md's UI contract.

The third review note (Mod+B toggling the desktop chat sidebar) was not
addressed: that behavior was already present in the pre-migration
SidebarProvider's unconditional keydown listener, so the centralization
preserves rather than introduces it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c36ae5854

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/web/src/lib/keyboard-combo.ts Outdated
if (combo.mod) parts.push('Mod')
if (combo.alt) parts.push('Alt')
if (combo.shift) parts.push('Shift')
parts.push(combo.key === ' ' ? 'Space' : combo.key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode literal plus before persisting shortcuts

When the user captures the literal + key, formatKeyCombo persists strings like + or Mod+Shift++, but parseKeyCombo later splits on + and filters empty tokens, so applyOverride treats the saved shortcut as invalid. The dialog appears to save the binding, yet the effective binding immediately falls back to the default and remains broken after reload; serialize plus as a distinct token such as Plus, or make the parser round-trip it.

Useful? React with 👍 / 👎.

const overrides: Record<string, string> = {}
for (const binding of shortcutsStore.effectiveBindings) {
if (binding.desktop !== 'menu') continue
overrides[binding.command] = toElectronAccelerator(binding)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Translate DOM arrow names for menu accelerators

When a desktop user rebinds the menu-delivered close-tab command to an arrow key, this sends DOM key names such as ArrowLeft through toElectronAccelerator. The Electron accelerator docs list arrow key codes as Left/Right/Up/Down, so the native menu accelerator for that override is invalid and the shortcut will not work in desktop. Map captured KeyboardEvent.key values to Electron accelerator key names before pushing them to the main process.

Useful? React with 👍 / 👎.

Comment on lines +100 to +101
if (conflict.kind === 'same-scope' || conflict.kind === 'reserved') return conflict
overrides.value = { ...overrides.value, [command]: formatKeyCombo(parsed) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject bare keys for global shortcuts

For global commands this accepts any non-reserved combo, including a bare printable key such as b or an unmodified arrow key. The window-level dispatcher does not skip focused inputs or contenteditable areas, and handlers like toggleSidebar/openSettings return true, so after such a binding, typing that key in a form or editor prevents text entry and runs the command. Require a modifier for global shortcuts, or ignore unmodified global matches from editable targets.

Useful? React with 👍 / 👎.

Comment on lines +46 to +49
useKeyboardCommand(appKeyboardCommands.toggleSidebar, () => {
isOpen.value = !isOpen.value
return true
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle sidebar shortcut in chat shell

This is the only toggleSidebar handler registered in the app, but MainLayout is mounted only for the settings overlay; the normal / and /bot chat shell uses components/sidebar/index.vue and useWorkspaceTabsStore().toggleWorkbench() instead. On chat routes the advertised global Mod+B/custom binding dispatches with no live handler and does nothing, so register the command while MainSection is active as well.

Useful? React with 👍 / 👎.

if (binding.command === command) continue
if (!keyCombosEqual(comboFromBinding(binding), combo)) continue
if (binding.scope === ownBinding.scope) return { kind: 'same-scope', collidesWith: binding.command }
return { kind: 'cross-scope', collidesWith: binding.command }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scan all collisions before allowing cross-scope binding

When a combo collides with both a scoped binding and an existing global override, this returns cross-scope on the first scoped match and never checks for the later same-scope collision. For example, after Save active file is rebound to ArrowLeft (allowed because it first collides with media previous), rebinding Toggle sidebar to ArrowLeft is also allowed, leaving two global commands on the same key and making the dispatcher claim whichever one happens to handle first. Keep scanning and let any same-scope collision block the save.

Useful? React with 👍 / 👎.

…+ global rules

Five issues from the second review batch, all accepted:

- (memohai#4) keyboard-combo encodes literal '+' as the 'Plus' token both ways.
  'Shift++' split on '+' to ['Shift','',''] and filtered to ['Shift'],
  silently corrupting the saved binding into an unparseable string; the
  dialog appeared to save but the override fell back to the default.
- (memohai#5) toElectronAccelerator now maps DOM key names (ArrowLeft/Escape/Space)
  to Electron's accelerator names (Left/Esc/Space). Pushing the raw DOM
  name produced an invalid accelerator string that the native menu silently
  rejected, so a rebound close-tab to Mod+ArrowLeft would never fire.
- (memohai#6) Global bindings now require Cmd/Ctrl or Alt. The window-level
  dispatcher does not skip focused inputs, so a bare-key global like 'b'
  would fire every literal keystroke and break text entry. Scoped bindings
  keep accepting bare keys because their handlers only register while the
  owning component is mounted. A new 'no-modifier' ConflictKind and
  three-language error string surface the rule in the capture dialog.
- (memohai#7 + the earlier memohai#2) toggleSidebar moves from main-layout (settings
  overlay) to main-section (chat shell). MainLayout was only mounted on
  /settings, so chat routes had no live handler and Mod+B silently fell
  through to the browser; MainSection is persistent, so the handler runs
  on both chat and settings. We no-op on settings routes to preserve the
  desktop sidebar's pinned-open intent AND claim the press so the browser
  doesn't see Mod+B.
- (memohai#8) detectConflict now scans all colliding bindings before deciding,
  so a same-scope match found later still blocks the save even when a
  cross-scope match was iterated first.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8b0185c8e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// user picks a Cmd/Alt/Shift-based combo instead.
if (isMac && event.ctrlKey) return null
return {
mod: isMac ? event.metaKey : event.ctrlKey,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unsupported Meta captures on non-macOS

The macOS Ctrl guard fixes one unsupported modifier path, but on Windows/Linux metaKey is still ignored here. If a user captures a Super/Windows-key combo that also has Alt (for example Super+Alt+K) or a scoped key like Super+ArrowLeft, the stored binding drops Meta and becomes Alt+K/bare ArrowLeft; the dispatcher then triggers on that easier combo and will not match the original Meta combo. Reject non-macOS metaKey captures or model Super separately instead of silently downgrading them.

Useful? React with 👍 / 👎.

if (binding.scope === ownBinding.scope) return { kind: 'same-scope', collidesWith: binding.command }
crossScopeMatch = crossScopeMatch ?? binding.command
}
if (crossScopeMatch) return { kind: 'cross-scope', collidesWith: crossScopeMatch }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block menu shortcuts from cross-scope sharing

When desktop users assign the menu-delivered Close current workspace tab command to a combo that also belongs to a media-lightbox command (for example after rebinding both to Mod+Shift+P), this cross-scope result is allowed. The scoped-first dispatch ordering only applies to renderer keydown bindings, but close tab is excluded from that listener and is fired by Electron's native menu accelerator, so pressing the combo with a lightbox open can close the workspace tab instead of letting the lightbox claim it. Treat cross-scope collisions involving desktop: 'menu' as blocking or route them through the same dispatcher.

Useful? React with 👍 / 👎.

ArrowUp: 'Up',
ArrowDown: 'Down',
Escape: 'Esc',
' ': 'Space',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode plus for menu accelerators

Fresh evidence beyond the earlier plus persistence issue is that the new Electron key-name map still omits the literal + key. After a desktop user rebinds the menu-delivered close-tab command to + (or Shift++), toElectronAccelerator emits strings such as CmdOrCtrl++ / CmdOrCtrl+Shift++; Electron accelerator strings use + as the separator and list the key as Plus, so the native menu shortcut can be rejected or fail to match even though the setting was saved. Map '+' to Plus before rebuilding the menu.

Useful? React with 👍 / 👎.

@sheepbox8646 sheepbox8646 merged commit 43b7e9a into memohai:main Jun 17, 2026
14 checks passed
@ChrAlpha ChrAlpha deleted the feat/keyboard-shortcuts-settings branch June 17, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants