Skip to content

fix(whispering): hotkey overhaul (gating + Alt+Space default + layout-independent capture)#1774

Open
mrummuka wants to merge 5 commits into
EpicenterHQ:mainfrom
mrummuka:fix/whispering-hotkeys
Open

fix(whispering): hotkey overhaul (gating + Alt+Space default + layout-independent capture)#1774
mrummuka wants to merge 5 commits into
EpicenterHQ:mainfrom
mrummuka:fix/whispering-hotkeys

Conversation

@mrummuka

@mrummuka mrummuka commented May 18, 2026

Copy link
Copy Markdown

Why this PR exists

The Whispering hotkey system had three independent bugs that made the app effectively unusable on a Finnish (ISO) keyboard on macOS Tahoe, plus one runtime bug that prevented the local Parakeet model from downloading at all. Symptoms reported during testing:

1. Global hotkeys "are created with some std US keyboard layout in mind and
   thus fail to work in FI keyboard layout."

2. "The local shortcuts stupidly activate when / if you are trying to modify
   the global hotkeys."

3. "Reading mac keypresses (like option + command + space or option + command + -)
   never actually get interpreted correctly; and even with the manual editing,
   I have no clue what to type and it gives you errors still or at least produces
   a non working combo."

4. Runtime: "Failed to download model / invalid args 'streamChannel' for command
   'fetch_read_body': command fetch_read_body missing required key streamChannel"

Each has a distinct root cause; this PR addresses all four. Full design rationale, decisions log, post-implementation review, and follow-up scope are in specs/20260518T121956-hotkey-overhaul.md.

Architecture: before and after

The recorder and the local listener were both bound to window and both fired for the same keydown. Recording any global combo also triggered any local shortcut that matched the keys being recorded.

Before

                          window keydown / keyup
                                    │
                ┌───────────────────┴────────────────────┐
                ▼                                        ▼
   LocalShortcutManagerLive               createPressedKeys (recorder UI)
   (always listening,                     (e.key.toLowerCase,
    isTypingInInput guard fails           layout-dependent character,
    on non-input focus)                    dropped if unsupported)
                │                                        │
                ▼                                        ▼
        in-app actions                         pressedKeysToTauriAccelerator
                                                         │
                                                         ▼
                                          tauriRegister(accelerator)
                                          error swallowed at line 125-133

After

settings.shortcuts.local.enabled  (default false)
settings.shortcuts.global.enabled (default true)
                │
                ▼
window keydown / keyup
                │
                ├──▶ LocalShortcutManagerLive (only if local.enabled)
                │
                └──▶ createPressedKeys (recorder, when popover open)
                     e.code (physical, layout-neutral)  ──▶ codeToLogicalKey
                     e.key (modifiers only)             ──▶ as-is
                                                              │
                                                              ▼
                                                pressedKeysToTauriAccelerator
                                                              │
                                                              ▼
                                                tauriRegister(accelerator)
                                                error: surfaced to user toast

Changes in detail

1. Subsystem toggles (Phase 1)

Two new settings, both in synced user settings (alongside analytics.enabled):

// apps/whispering/src/lib/workspace/definition.ts
'shortcuts.local.enabled':  defineKv(type('boolean'), false),  // OFF by default
'shortcuts.global.enabled': defineKv(type('boolean'), true),   // ON by default

The local listener now only mounts when enabled:

<!-- apps/whispering/src/routes/(app)/+layout.svelte -->
$effect(() => {
    if (!settings.get('shortcuts.local.enabled')) return;
    const unlisten = services.localShortcutManager.listen();
    return () => unlisten();
});

syncLocalShortcutsWithSettings() early-returns when local is off. syncGlobalShortcutsWithSettings() calls desktopRpc.globalShortcuts.unregisterAll() and returns when global is off, so flipping global off actually frees the OS hotkeys system-wide. Both sync functions re-run via reactive $effects on toggle change, so no app restart needed.

UI: a <Switch> at the top of each shortcuts settings page. When OFF, the table below is dimmed and the "Reset to defaults" button is disabled. Existing-user grandfathering uses a one-shot localStorage marker and detects pre-existing installs via the prior monolithic-to-per-key migration marker OR presence of the legacy whispering-settings blob (handles the async race with migrateOldSettings).

2. Sensible default (Phase 2)

Drops the entire US-punctuation-default set, ships one cross-layout combo:

-const DEFAULT_GLOBAL_SHORTCUTS: Record<string, string | null> = {
-    pushToTalk: `${CommandOrAlt}+Shift+D`,
-    toggleManualRecording: `${CommandOrControl}+Shift+;`,
-    startManualRecording: null,
-    stopManualRecording: null,
-    cancelManualRecording: `${CommandOrControl}+Shift+'`,
-    startVadRecording: null,
-    stopVadRecording: null,
-    toggleVadRecording: null,
-    openTransformationPicker: `${CommandOrControl}+Shift+X`,
-    runTransformationOnClipboard: `${CommandOrControl}+Shift+R`,
-};
+const DEFAULT_GLOBAL_SHORTCUTS: Record<string, string | null> = {
+    pushToTalk: null,
+    toggleManualRecording: 'Alt+Space',
+    startManualRecording: null,
+    stopManualRecording: null,
+    cancelManualRecording: null,
+    startVadRecording: null,
+    stopVadRecording: null,
+    toggleVadRecording: null,
+    openTransformationPicker: null,
+    runTransformationOnClipboard: null,
+};

Rationale: Tauri's plugin-global-shortcut accepts Alt and routes to the Option key on macOS automatically. Spacebar is layout-independent. Matches Superwhisper / Wispr Flow conventions (single default combo, users add the rest).

A one-shot migration migrateGlobalToggleDefaultToOptionSpace() seeds Alt+Space when the toggle is unset (fresh install) or exactly matches the literal old default (Command+Shift+; / Control+Shift+;). Custom user values are preserved.

3. Layout-independent capture via e.code (Phase 3)

The core fix for the Finnish keyboard bug. The recorder previously read e.key.toLowerCase() for every pressed key, which is the layout-dependent character. On FI ISO, the physical Semicolon-position key reports e.key === 'Ö'. That value is not in the supported-key set and was silently dropped, so FI users could not record any shortcut whose non-modifier key was a non-letter.

       Recorder side                              Tauri / OS side
       ─────────────                              ───────────────
       e.key.toLowerCase()                        Accelerator like "Command+Shift+Semicolon"
       (layout-dependent character)               maps to a Code (layout-independent physical key)

                       ┌─────────────────────────────────────┐
                       │  MISMATCH — what gets recorded was  │
                       │  layout-aware, what gets registered │
                       │  was supposed to be physical        │
                       └─────────────────────────────────────┘

New helper:

// apps/whispering/src/lib/constants/keyboard/browser/code-to-key.ts
export function codeToLogicalKey(code: string): KeyboardEventPossibleKey | null {
    if (code.startsWith('Key')   && code.length === 4) return code.slice(3).toLowerCase();  // KeyA → a
    if (code.startsWith('Digit') && code.length === 6) return code.slice(5);                // Digit5 → 5
    if (code.startsWith('Numpad')&& code.length === 7) { /* numpad digits */ }
    if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return code.toLowerCase();                   // F1..F24
    return SPECIAL_CODE_MAP[code] ?? null;  // Semicolon → ';', Space → ' ', ArrowUp → 'arrowup', etc.
}

Both keydown and keyup in createPressedKeys.svelte.ts translate non-modifier keys through this; modifier keys still use e.key because that name is already platform-canonical. The OPTION_KEY_CHARACTER_MAP is now a defensive fallback for unmapped codes.

Side benefit: bypasses the macOS Option dead-key recording bug (Option+E producing e.key === 'Dead') because e.code === 'KeyE' regardless. The recorder UI warning about Option dead keys was removed since it no longer applies.

4. Honest manual entry (Phase 3)

Old behavior:

// User types "ctrl+cmd+-" in the popover text input.
keyRecorder.register(manualValue.split('+') as KeyboardEventSupportedKey[]);

// → split: ['ctrl', 'cmd', '-']
// → pressedKeysToTauriAccelerator: convertToModifier switch is case-sensitive
//   and only knows 'control' | 'shift' | 'alt' | 'meta' | ...
//   'ctrl' → null  → silently dropped
//   'cmd'  → null  → silently dropped
//   '-'    → in punctuation set, kept
// → Accelerator: "-"  (just the dash, no modifiers)
// → tauriRegister: registers a bare minus globally, OR silently fails (next bug).

New behavior:

// apps/whispering/src/lib/constants/keyboard/browser/parse-manual-shortcut.ts
export function parseManualShortcut(input: string): {
    keys: KeyboardEventSupportedKey[];
    invalidTokens: string[];
};

// Recognises aliases: ctrl|cmd|command|⌘|option|opt|⌥|alt|shift|⇧|space|esc|...
// Returns invalidTokens for any token that does not normalise to a supported key.

The recorder onsubmit now uses this and shows an error toast naming any invalid tokens:

const { keys, invalidTokens } = parseManualShortcut(manualValue);
if (invalidTokens.length > 0) {
    rpc.notify.error({
        title: 'Invalid shortcut',
        description: `Could not recognize: ${invalidTokens.join(', ')}. Try aliases like ctrl, cmd, option, shift, space, or a single letter / digit / punctuation key.`,
    });
    return;
}

5. Surface registration errors (Phase 3)

- // NOTE: We often get "RegisterEventHotKey failed for <key>" errors when
- // registering global shortcuts, even though the shortcut was valid and
- // registered successfully. (...) We gracefully return Ok(undefined) in these
- // cases to avoid propagating the error as an unnecessary error toast,
- // allowing the shortcut system to continue functioning for other valid keys.
- if (registerError) return Ok(undefined);
+ // Surface the registration failure to the caller so the error toast plumbing
+ // in syncGlobalShortcutsWithSettings can show the actual cause (reserved by
+ // OS, accelerator parse failure, etc.).
+ if (registerError) return Err(registerError);

The previous swallow was speculative ("we often get"). The cost was that real failures (OS-reserved combos like Cmd+Space, accelerator parse errors, conflicts) produced a misleading "shortcut saved" UX that bound nothing. If a false-positive recurs in practice, we will gate per-error rather than swallow the whole class.

6. Pin @tauri-apps/plugin-http to 2.5.4

The Rust workspace resolved tauri-plugin-http to 2.5.4 (constrained by the resolved Tauri crate version). The JS package was declared as ^2.5.1, which bun resolved to 2.5.8. The two versions disagree on the fetch_read_body IPC payload shape: 2.5.8 sends one key name, 2.5.4 expects another. Manifested at runtime when downloading any local transcription model (Parakeet, Moonshine, Whisper C++):

Failed to download model
invalid args 'streamChannel' for command 'fetch_read_body':
command fetch_read_body missing required key streamChannel

Tried bumping the Rust side to 2.5.8 first; that cascaded Tauri to 2.10.3 and broke tauri-runtime-wry 2.10.1 (trait impl + Sync constraint mismatch). Pinning the JS side down to 2.5.4 is the minimal change.

Migration behavior (what existing users see)

Scenario Behavior
Fresh install shortcuts.local.enabled = false. shortcuts.global.enabled = true. No global shortcut bound; first launch of the global settings page lets the user record one or use the new Alt+Space default after a reset.
Existing user, any local shortcut configured shortcuts.local.enabled = true (grandfathered). Existing local shortcuts continue working.
Existing user, stored global toggle is Cmd+Shift+; or Control+Shift+; Reseat to Alt+Space on first boot. Other formerly-defaulted global bindings (pushToTalk, etc.) untouched.
Existing user, customized global toggle Untouched.
Existing user upgrading from a build with the version-skew bug Local Parakeet model downloads work for the first time.

No data is deleted; the local subsystem code path is still present and re-enables instantly.

Test plan

Smoke (manual)

  • Subsystem toggles. Fresh install: local OFF, global ON. Existing-user upgrade: local ON (grandfathered). Flip either toggle → effect immediate (no app restart).
  • Default. Existing user on stock settings has Cmd+Shift+; auto-flipped to Alt+Space on first boot. Custom values preserved. Press Option+Space → recording toggles.
  • FI keyboard capture. Open global shortcut editor, record Cmd+Shift+<physical-Semicolon-position-key> (labeled Ö on FI ISO) → stored accelerator is Command+Shift+; → triggering the same physical combo fires the shortcut.
  • Manual entry. Type ctrl+shift+a → registers. Type cmd+option+space → registers. Type garbage+foo → red toast naming the invalid tokens.
  • Error surfacing. Try to register a known-reserved combo like Cmd+Space on macOS → error toast appears (no silent "saved").
  • Recorder vs local listener. With local enabled, open the global shortcut editor and record a combo that matches a local shortcut → the local action does NOT fire while the recorder is open.
  • Local model download (Parakeet end-to-end on macOS aarch64). Stream completes without the streamChannel IPC error.

Automated

  • bun run typecheck clean for apps/whispering on every commit. 21 pre-existing errors in unrelated files (transformations, migrations) remain; zero new errors or warnings introduced.
  • bun tauri build succeeds end-to-end on macOS aarch64 (unsigned, used for local verification).

Risk assessment

Risk Likelihood Mitigation
e.code translator misses a key on a layout we did not test medium DEV-gated console.debug log emits {key, code, modifiers} on every keydown. Falls back to e.key when codeToLogicalKey returns null.
Grandfather detection misclassifies a fresh install as existing low Dual signal (migration marker OR old blob present); fresh installs match neither. Idempotent via own marker.
Surfacing tauriRegister errors floods users with toasts low The pre-existing toast plumbing already deduplicates and only fires on real registration failures. If recurrent false positives appear, gate per-error rather than swallow the whole class.
Local users flipped to OFF on upgrade miss configured local shortcuts n/a Grandfather migration leaves existing local users at ON. Only users with zero local shortcuts configured get OFF.
Tauri plugin-http version pin (2.5.4) blocks future plugin upgrades low Pin documented in commit; can be relaxed once the Rust side upgrades transitively via a future Tauri bump.

Commits

945373556  fix(whispering): pin @tauri-apps/plugin-http to 2.5.4 to match Rust crate
71d26b914  docs(whispering): add spec review section after Phase 1-3 implementation
c2c6b7a4d  fix(whispering): make global hotkey capture layout-independent
52833f158  feat(whispering): ship Alt+Space as the only global hotkey default
438ca3653  feat(whispering): gate hotkey subsystems behind toggles

Each phase is one commit and one logical unit; any can be reverted independently if needed.

Out of scope (follow-ups noted in the spec)

  • Remove the DEV-gated console.debug diagnostic in createPressedKeys.svelte.ts once layout coverage is confirmed.
  • Apply the same e.code translation to local-shortcut-manager.ts (still uses raw e.key; only matters for users who re-enable local).
  • Delete unused OPTION_DEAD_KEYS set in macos-option-key-map.ts (only documented an unaddressed bug, never imported).
  • Consider replacing the manual-entry text input with a structured editor (modifier checkboxes + key dropdown).
  • Tauri capability hardening from the prior security audit (CSP, narrow shell:allow-execute, narrow fs:scope, narrow assetProtocol.scope) is a separate effort on its own branch.

🤖 Generated with Claude Code

mrummuka and others added 5 commits May 18, 2026 17:14
Adds shortcuts.local.enabled (default false) and shortcuts.global.enabled
(default true) as master switches. The local listener mounts only when
enabled; global registrations are unregistered system-wide when disabled.
Toggles are surfaced as Switches on each shortcuts settings page and take
effect immediately via reactive $effects.

Existing users with at least one configured local shortcut are
grandfathered to local=true (detected via the prior migration marker or
the presence of the legacy whispering-settings blob). Fresh installs
default to local=false, which resolves the recorder-vs-local-listener
interference for the common case.

Refs: specs/20260518T121956-hotkey-overhaul.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the US-layout-specific defaults (Cmd+Shift+;, Cmd+Shift+',
Cmd+Shift+X, Cmd+Shift+R, Cmd+Alt+Shift+D) with one cross-layout
default: Alt+Space for toggleManualRecording. Tauri's
plugin-global-shortcut accepts Alt and routes it to the Option key on
macOS automatically; spacebar position is layout-independent.

Adds migrateGlobalToggleDefaultToOptionSpace(), a one-shot migration
that seeds Alt+Space when the toggle is unset (fresh install) or
exactly equals the old default (existing users on stock settings).
Custom user values are preserved. Other formerly-defaulted bindings
(pushToTalk, cancelManualRecording, openTransformationPicker,
runTransformationOnClipboard) are no longer seeded; users add what
they actually want.

Refs: specs/20260518T121956-hotkey-overhaul.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The recorder previously stored e.key.toLowerCase() for every pressed key,
which captures the character produced by the current keyboard layout. On
FI ISO the physical Semicolon-position key reports e.key === 'Ö'; that
value is not in the supported-key set and was silently dropped, so FI
users could not record any shortcut whose non-modifier key was a
non-letter.

Switch non-modifier capture to e.code-derived values via a new
codeToLogicalKey() helper that maps W3C codes (Semicolon, Minus, KeyA,
Digit0, Space, F1..F24, arrows, navigation, editing, numpad) to the
canonical lowercase form. Same translation is applied in keydown and
keyup so stored and removed keys match. Modifier keys keep using e.key
because that name is already platform-canonical. The OPTION_KEY_CHARACTER_MAP
fallback survives for unmapped codes; the macOS Option-dead-key recorder
warning is removed because e.code bypasses dead-key behavior.

Manual entry of shortcuts now goes through parseManualShortcut(), which
accepts user-friendly aliases (ctrl|cmd|command|option|opt|shift|space|
esc|enter|tab|...) and surfaces unrecognized tokens as a validation error
toast. Previously "ctrl+cmd+-" silently dropped the modifiers because
the case-sensitive convertToModifier switch only knew 'control'/'meta',
and the user was left with a bare "-" accelerator and no feedback.

Stop swallowing tauriRegister errors in global-shortcut-manager: the
previous comment claimed the underlying plugin emitted false-positive
errors for valid registrations, but the result was that real failures
(OS-reserved combos, accelerator parse errors) silently produced a
"shortcut saved" UX that did not bind anything. Errors now propagate to
the existing toast plumbing in syncGlobalShortcutsWithSettings.

DEV-gated console.debug log on keydown captures key/code/modifier state
to help diagnose any layout that the e.code translation table misses.

Refs: specs/20260518T121956-hotkey-overhaul.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the per-phase commit refs, what landed, post-implementation
review findings with their dispositions, deliberate non-scope, the
pending user verification checklist, and follow-up work that did not
fit this branch.

Refs: specs/20260518T121956-hotkey-overhaul.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rate

The Rust workspace resolves tauri-plugin-http to 2.5.4 (constrained by
the resolved tauri crate version). The JS package was declared as
^2.5.1, which bun resolved to 2.5.8. The two versions disagree on the
fetch_read_body IPC payload shape: 2.5.8 sends one key name, 2.5.4
expects another. The skew surfaced at runtime as:

  Failed to download model
  invalid args 'streamChannel' for command 'fetch_read_body':
  command fetch_read_body missing required key streamChannel

Tried bumping Rust to 2.5.8 first; that cascaded tauri to 2.10.3 and
broke tauri-runtime-wry 2.10.1 (trait impl + Sync constraint mismatch).
Pinning the JS side down is the minimal change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant