Skip to content

fix: preserve right-side modifier-only hotkeys (RightCommand etc.)#449

Open
symphonix84 wants to merge 1 commit into
freestyle-voice:mainfrom
symphonix84:fix/right-modifier-hotkey
Open

fix: preserve right-side modifier-only hotkeys (RightCommand etc.)#449
symphonix84 wants to merge 1 commit into
freestyle-voice:mainfrom
symphonix84:fix/right-modifier-hotkey

Conversation

@symphonix84

Copy link
Copy Markdown

Fixes #448.

What

Recording a right-side modifier (e.g. right ⌘) as a modifier-only push-to-talk hotkey now saves the side-specific accelerator (RightCommand) instead of silently collapsing to the generic category (Command).

With generic Command stored, the matcher fires on either ⌘ key, and — worse — on the app's own synthetic Cmd+V paste (macos-fast-paste posts V with maskCommand; the key listener's CGEvent tap emits FLAGS:command for it), so recording ghost-restarts right after every hold-release. Full diagnosis in #448.

The runtime already supports side-specific accelerators end-to-end (NativeKeyListener matching, isValidAccelerator, normalizeAccelerator, isValidHotkeyCombo); only the recording path lost the information — in two places (hotkey-recorder.ts RIGHT_MODIFIER_KEYS collapse, and the renderer hook's generic-only orderModifiers).

How

Chord behavior is deliberately unchanged — right-⌘+Space still records as Command+Space, matching current matcher semantics. Only a solo right-modifier capture becomes side-specific:

  • hotkey-recorder.ts (main): forward the side-specific token (RightCommand) to the renderer instead of pre-collapsing it.
  • use-hotkey-recorder.ts (renderer): modifiers stay generic for display/merging, but a small latch (nextRightModifierLatch, pure + exported for tests) tracks "this draft is exactly one modifier, produced solely by a right-side key." On completion, a latched solo-modifier combo saves the side-specific accelerator.
    • The latch survives the trailing FLAGS:command the Swift binary emits from the same flagsChanged callback as RIGHT_MOD_DOWN:RightCommand, and the duplicate DOM delivery when the settings window is focused.
    • It clears on chords, on a captured non-modifier key, and on a definitively left-side DOM modifier press (e.code === "MetaLeft" etc.) — so pressing left ⌘ still records generic Command.
    • Display labels added so saved side-specific hotkeys render as Right ⌘ / Right ⌥ / … (mac) and Right Alt / Right Super / … (elsewhere) instead of raw RightCommand.

Tests

apps/electron/tests/key-listener.test.ts: 20 passed (6 existing + 14 new), covering:

  • NativeKeyListener with RightCommand: FLAGS:command alone (left ⌘ or the synthetic paste) never fires; RIGHT_MOD_DOWN/UP drives hold-to-talk correctly.
  • NativeKeyListener with generic Command: existing behavior unchanged.
  • HotkeyRecorder: RIGHT_MOD_DOWN:RightCommand now reaches the renderer side-specific.
  • nextRightModifierLatch: realistic native event sequence (incl. the trailing FLAGS:command), DOM left-clear, chord-clear, and the ambiguous left-then-right case (stays generic).
  • Pure helpers: comboToAccelerator / isValidHotkeyCombo with side-specific values.

Happy to split or adjust if you'd prefer a different approach (e.g. first-class right-modifier tokens in chords too). A separate follow-up could harden the listener against the app's own synthetic paste events (tagging them with a CGEventSource user-data marker) so even deliberately-generic Command hotkeys can't self-trigger — say the word and I'll send it.

🤖 Generated with Claude Code

Recording a right-side modifier (e.g. right-⌘) as a push-to-talk hotkey
saved the generic accelerator ("Command") instead of the side-specific
one ("RightCommand"). The generic accelerator then matched either ⌘ key,
including the app's own synthetic Cmd+V paste, causing recording to
ghost-restart after every dictation.

The runtime already fully supported side-specific accelerators
(NativeKeyListener, isValidAccelerator, normalizeAccelerator,
isValidHotkeyCombo, comboToAccelerator) — only the recording path lost
the side information:

- hotkey-recorder.ts sent the collapsed generic token for RIGHT_MOD_DOWN
  lines instead of the side-specific one already produced by
  MAC_RIGHT_MOD_KEYS.
- use-hotkey-recorder.ts's modifier merge/normalize path only tracked
  generic modifier names, with no way to know a draft combo of exactly
  one modifier came solely from a right-side key.

Adds a "solo right-modifier latch": when the final captured combo is
exactly one modifier produced solely by a right-side key, the saved
accelerator becomes the side-specific token instead of the generic one.
All existing chord/compound-hotkey behavior is unchanged.

Fixes freestyle-voice#448.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Semver Impact of This PR

🟢 Patch (bug fixes)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


Bug Fixes 🐛

  • Preserve right-side modifier-only hotkeys (RightCommand etc.) by symphonix84 in #449

🤖 This preview updates automatically when you update the PR.

@MathurAditya724 MathurAditya724 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff, traced the recorder → renderer → matcher round-trip, and ran the suites locally on this branch.

Verification

  • pnpm --filter @freestyle-voice/electron test:e2e → the new key-listener.test.ts passes 20/20. (The one app.test.ts failure is a headless-env libglib-2.0.so.0 launch error, unrelated to this change.)
  • pnpm typecheck clean, biome check clean, knip reports nothing on the new symbols.
  • Confirmed the save/reload round-trip: RightCommand serializes via comboToAccelerator, acceleratorToCombo("RightCommand") returns {modifiers:[], key:"RightCommand"} (it's not in MODIFIER_ORDER/MODIFIER_ALIASES, so it's treated as the key), isValidHotkeyCombo accepts it via MODIFIER_KEYS, and keyDisplayLabel renders Right ⌘. So a saved side-specific hotkey re-renders correctly in Settings.

The diagnosis in #448 is solid and the fix is appropriately scoped — chords still collapse to generic, only a solo right-modifier capture becomes side-specific. The nextRightModifierLatch latch handling (trailing FLAGS:command survival, DOM MetaLeft clear, chord clear, left-then-right ambiguity) is well covered by the pure-function tests.

A few minor, non-blocking notes:

  1. Dead display fallbacks. KEY_SYMBOLS (the non-mac branch of keyDisplayLabel) gained RightAlt/RightOption/RightControl/RightShift/RightSuper/RightCommand. But off macOS, side-specific tokens are never produced — domRightModifierToken returns null when !IS_MAC, and RIGHT_MOD_DOWN is emitted only by the macOS binary. So those entries are unreachable in practice. Not wrong, just dead weight; worth a comment noting they're defensive if you keep them.

  2. Two near-identical maps. RIGHT_MODIFIER_TO_GENERIC duplicates CAPTURED_MODIFIER_KEYS (minus the Fn entry). Consider deriving one from the other to avoid them drifting apart.

  3. Native-only both-⌘ edge. If someone holds right ⌘ and then also presses left ⌘ during recording, the DOM MetaLeft keydown sets explicitLeft and correctly clears the latch — good. The purely-native path (no DOM, settings window unfocused) can't distinguish sides on the generic FLAGS:command (count stays 1), so the latch would survive. In practice the settings window is focused during recording so the DOM handler covers it; just flagging it as a known narrow limitation, consistent with the scope you already called out.

Nothing here blocks merge. Nice, thorough work — the tests in particular make the tricky latch logic reviewable.

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.

macOS: recording right ⌘ as hotkey saves generic Command — left ⌘ also triggers, and the app's own synthetic paste restarts recording

2 participants