How to Integrate FluidVoice with System-Wide Hotkeys: A Complete Implementation Guide

FluidVoice captures system-wide key and mouse events through the GlobalHotkeyManager service using a low-level CGEvent tap that runs on the main run-loop, enabling global shortcuts even when the app is not frontmost.

This guide explains how FluidVoice implements macOS system-wide hotkeys to start dictation, trigger command mode, and control transcription workflows from anywhere on your Mac. You'll learn how the event tap system works, how to configure HotkeyShortcut definitions, and how to customize activation modes to match your workflow preferences.

How Global Hotkeys Work in FluidVoice

FluidVoice's hotkey system operates through three coordinated components in the altic-dev/FluidVoice codebase:

  • GlobalHotkeyManager – Creates and manages the CGEvent tap that intercepts system-wide keyboard and mouse events
  • HotkeyShortcut – Defines the key codes, modifier flags, and mouse buttons that trigger actions
  • SettingsStore – Persists user preferences and provides the central configuration interface

When FluidVoice launches, GlobalHotkeyManager.init schedules initializeWithDelay(), which attempts to set up the event tap up to five times while verifying that the app has accessibility permissions (AXIsProcessTrusted). Without this permission, macOS blocks the global event tap creation.

Setting Up the Global Event Tap

The core of system-wide hotkey integration happens in Sources/Fluid/Services/GlobalHotkeyManager.swift. Here's how the initialization flow works:

  1. Permission check – Verify AXIsProcessTrusted status
  2. Tap creation – Call CGEvent.tapCreate with masks for key-down, key-up, flag-changed, and mouse-button events
  3. Run-loop integration – Add the tap source to RunLoop.main
  4. Event dispatch – Route captured events to handleKeyEvent

If the tap cannot be enabled, the manager logs an error and aborts rather than fail silently.

// Simplified initialization pattern from GlobalHotkeyManager.swift
let hotkeyManager = GlobalHotkeyManager(
    asrService: asrService,
    primaryShortcuts: SettingsStore.shared.primaryDictationShortcuts,
    promptModeShortcut: SettingsStore.shared.promptModeHotkeyShortcut,
    commandModeShortcut: SettingsStore.shared.commandModeHotkeyShortcut,
    rewriteModeShortcut: SettingsStore.shared.rewriteModeHotkeyShortcut,
    promptShortcutAssignments: [],
    promptModeShortcutEnabled: true,
    commandModeShortcutEnabled: true,
    rewriteModeShortcutEnabled: true,
    startRecordingCallback: {
        await asrService.startRecording()
    },
    commandModeCallback: {
        await asrService.startCommandMode()
    },
    rewriteModeCallback: {
        await asrService.startRewriteMode()
    }
)

The callbacks connect the low-level event system to FluidVoice's ASR (automatic speech recognition) service, enabling real-time transcription control.

Defining Hotkey Shortcuts with HotkeyShortcut

A HotkeyShortcut struct in Sources/Fluid/Models/HotkeyShortcut.swift encodes three essential properties:

  • keyCode – The hardware key code (e.g., 0 for "A", 3 for "F")
  • modifierFlags – NSEvent.ModifierFlags combination (.control, .option, .command, .shift)
  • mouseButton – Alternative to keyCode for mouse-driven shortcuts

Keyboard Shortcuts

import Fluid

// Control+Option+A to start dictation
let recordHotkey = HotkeyShortcut(
    keyCode: 0,
    modifierFlags: [.control, .option]
)

// Command+F for command mode
let commandHotkey = HotkeyShortcut(
    keyCode: 3,
    modifierFlags: [.command]
)

// Persist to SettingsStore
SettingsStore.shared.primaryDictationShortcuts = [recordHotkey]
SettingsStore.shared.commandModeShortcut = commandHotkey

Mouse Shortcuts

Mouse buttons work alongside keyboard modifiers for hardware-button triggers:

// Control+side mouse button (button 3)
let sideClickHotkey = HotkeyShortcut(
    mouseButton: 3,
    modifierFlags: [.control]
)

SettingsStore.shared.primaryDictationShortcuts.append(sideClickHotkey)
hotkeyManager.updatePrimaryShortcuts(SettingsStore.shared.primaryDictationShortcuts)

Choosing a Hotkey Activation Mode

FluidVoice supports three HotkeyActivationMode behaviors, stored in SettingsStore.shared.hotkeyMode:

Hold Mode

Press and hold to start, release to stop. The manager sets isCommandModeKeyPressed = true on keyDown, triggers triggerCommandMode(), then on keyUp clears the flag and calls stopRecordingAfterRelease.

Best for: Momentary actions like push-to-talk dictation.

SettingsStore.shared.hotkeyMode = .hold

Toggle Mode

Single press switches state on/off. The manager toggles on each keyDown without tracking releases.

Best for: Actions you want to leave running while typing elsewhere.

SettingsStore.shared.hotkeyMode = .toggle
hotkeyManager.hotkeyMode = SettingsStore.shared.hotkeyMode

Automatic Mode

A quick tap starts the action; a second tap (or extended hold) ends it after a 0.4-second timeout. On keyDown, the manager calls beginAutomaticPress and records the press time. On release, handleAutomaticKeyRelease determines if the press was a clean tap or a hold.

Best for: Flexible workflows where you want both tap and hold behaviors.

SettingsStore.shared.hotkeyMode = .automatic

Handling Modifier-Only Shortcuts

FluidVoice supports modifier-only shortcuts (e.g., "Option+Shift" with no regular key) through specialized logic in ModifierOnlyShortcutFlagsDecision.evaluate. This pure, side-effect-free function determines whether a modifier-only press has started, finished, or should be ignored.

This enables using modifiers as hold-to-record keys without interfering with normal typing. The manager tracks currently pressed modifier key codes in pressedModifierKeyCodes, allowing combinations like holding Option+Shift to activate dictation while preserving Option-Shift-character combinations for regular input.

Event Processing Order

When handleKeyEvent receives a CGEvent, it parses the raw event into a key code and NSEvent.ModifierFlags, then checks shortcuts in this priority order:

  1. Cancel – Immediate termination of current operation
  2. Paste last transcription – Insert previous result
  3. Prompt assignment – Assign prompt to shortcut slot
  4. Prompt mode – Enter prompt selection interface
  5. Command mode – Execute voice commands
  6. Rewrite mode – Transform selected text
  7. Primary transcription shortcuts – Start/stop dictation

Each check may initiate a hold, automatic press, or toggle operation based on the current hotkeyMode value.

Runtime Hotkey Updates

FluidVoice allows changing shortcuts without restarting. After modifying SettingsStore.shared, call the appropriate update method:

// Update primary transcription shortcuts
hotkeyManager.updatePrimaryShortcuts(SettingsStore.shared.primaryDictationShortcuts)

// Update mode-specific shortcuts
hotkeyManager.updateCommandModeShortcut(SettingsStore.shared.commandModeShortcut)
hotkeyManager.updateRewriteModeShortcut(SettingsStore.shared.rewriteModeShortcut)

These methods reconfigure the internal shortcut matching without recreating the event tap, ensuring zero-downtime updates.

Key Source Files

File Purpose Direct Link
GlobalHotkeyManager.swift Event tap setup, mode logic, callback dispatch View on GitHub
HotkeyShortcut.swift Shortcut model, conversion helpers, matching View on GitHub
SettingsStore.swift Persisted preferences, hotkey storage View on GitHub
SettingsView.swift UI for editing shortcuts View on GitHub
fluidApp.swift Application entry, manager instantiation View on GitHub

Summary

  • Global hotkeys require accessibility permissions – macOS blocks the CGEvent tap without AXIsProcessTrusted
  • GlobalHotkeyManager creates a system-wide event tap that intercepts key and mouse events even when FluidVoice is backgrounded
  • HotkeyShortcut defines triggers through key codes, modifier flags, or mouse buttons
  • Three activation modes – hold, toggle, and automatic – adapt to different interaction patterns
  • Modifier-only shortcuts work through ModifierOnlyShortcutFlagsDecision for non-interfering hold-to-record behavior
  • Runtime updates via updatePrimaryShortcuts() and related methods reconfigure without restart

Frequently Asked Questions

Why doesn't my hotkey work immediately after installing FluidVoice?

macOS requires explicit accessibility permissions for global event taps. FluidVoice checks AXIsProcessTrusted and retries initialization up to five times, but you must manually grant permission in System Settings > Privacy & Security > Accessibility. Without this, CGEvent.tapCreate fails and the global hotkey system cannot activate.

Can I use the same shortcut for different modes in different contexts?

No. FluidVoice processes shortcuts in a fixed priority order (cancel → paste → prompt assignment → prompt mode → command mode → rewrite mode → primary). The first matching shortcut wins. For context-specific behavior, modify SettingsStore.shared.*Enabled flags to disable unwanted modes, or use updatePrimaryShortcuts() to swap configurations dynamically.

How do I find the correct keyCode for a specific keyboard key?

Key codes are hardware-specific scan codes, not character codes. The most reliable method is using a keycode detection utility or referencing NSEvent's keyCode property in a test app. Common values include: 0 (A), 3 (F), 36 (Return), 49 (Space), 53 (Escape). Mouse buttons use separate numbering: 1 (left), 2 (right), 3 (side), etc.

Does FluidVoice support complex chord shortcuts like "Command+K, Command+S"?

No. HotkeyShortcut only supports single-beat shortcuts: one key or mouse button plus modifiers. Sequential chords would require extending GlobalHotkeyManager to track pending states across event boundaries. Current implementation focuses on immediate-activation shortcuts for low-latency voice control.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →