fix: preserve right-side modifier-only hotkeys (RightCommand etc.)#449
fix: preserve right-side modifier-only hotkeys (RightCommand etc.)#449symphonix84 wants to merge 1 commit into
Conversation
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>
Semver Impact of This PR🟢 Patch (bug fixes) 📋 Changelog PreviewThis is how your changes will appear in the changelog. Bug Fixes 🐛
🤖 This preview updates automatically when you update the PR. |
MathurAditya724
left a comment
There was a problem hiding this comment.
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 newkey-listener.test.tspasses 20/20. (The oneapp.test.tsfailure is a headless-envlibglib-2.0.so.0launch error, unrelated to this change.)pnpm typecheckclean,biome checkclean,knipreports nothing on the new symbols.- Confirmed the save/reload round-trip:
RightCommandserializes viacomboToAccelerator,acceleratorToCombo("RightCommand")returns{modifiers:[], key:"RightCommand"}(it's not inMODIFIER_ORDER/MODIFIER_ALIASES, so it's treated as the key),isValidHotkeyComboaccepts it viaMODIFIER_KEYS, andkeyDisplayLabelrendersRight ⌘. 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:
-
Dead display fallbacks.
KEY_SYMBOLS(the non-mac branch ofkeyDisplayLabel) gainedRightAlt/RightOption/RightControl/RightShift/RightSuper/RightCommand. But off macOS, side-specific tokens are never produced —domRightModifierTokenreturnsnullwhen!IS_MAC, andRIGHT_MOD_DOWNis 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. -
Two near-identical maps.
RIGHT_MODIFIER_TO_GENERICduplicatesCAPTURED_MODIFIER_KEYS(minus theFnentry). Consider deriving one from the other to avoid them drifting apart. -
Native-only both-⌘ edge. If someone holds right ⌘ and then also presses left ⌘ during recording, the DOM
MetaLeftkeydown setsexplicitLeftand correctly clears the latch — good. The purely-native path (no DOM, settings window unfocused) can't distinguish sides on the genericFLAGS: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.
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
Commandstored, the matcher fires on either ⌘ key, and — worse — on the app's own synthetic Cmd+V paste (macos-fast-pasteposts V withmaskCommand; the key listener's CGEvent tap emitsFLAGS:commandfor it), so recording ghost-restarts right after every hold-release. Full diagnosis in #448.The runtime already supports side-specific accelerators end-to-end (
NativeKeyListenermatching,isValidAccelerator,normalizeAccelerator,isValidHotkeyCombo); only the recording path lost the information — in two places (hotkey-recorder.tsRIGHT_MODIFIER_KEYScollapse, and the renderer hook's generic-onlyorderModifiers).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.FLAGS:commandthe Swift binary emits from the sameflagsChangedcallback asRIGHT_MOD_DOWN:RightCommand, and the duplicate DOM delivery when the settings window is focused.e.code === "MetaLeft"etc.) — so pressing left ⌘ still records genericCommand.Right ⌘/Right ⌥/ … (mac) andRight Alt/Right Super/ … (elsewhere) instead of rawRightCommand.Tests
apps/electron/tests/key-listener.test.ts: 20 passed (6 existing + 14 new), covering:NativeKeyListenerwithRightCommand:FLAGS:commandalone (left ⌘ or the synthetic paste) never fires;RIGHT_MOD_DOWN/UPdrives hold-to-talk correctly.NativeKeyListenerwith genericCommand: existing behavior unchanged.HotkeyRecorder:RIGHT_MOD_DOWN:RightCommandnow reaches the renderer side-specific.nextRightModifierLatch: realistic native event sequence (incl. the trailingFLAGS:command), DOM left-clear, chord-clear, and the ambiguous left-then-right case (stays generic).comboToAccelerator/isValidHotkeyCombowith 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
CGEventSourceuser-data marker) so even deliberately-genericCommandhotkeys can't self-trigger — say the word and I'll send it.🤖 Generated with Claude Code