TL;DR

A user reported that QUICOPY's global shortcut ⌘1 fires in iTerm2, Ghostty, Warp, and even in Zed's editor — but silently does nothing inside Zed's built-in terminal. Changing the key combo doesn't help. Restarting doesn't help.

The cause is not a key conflict. It's an event dispatch architecture mismatch: Carbon's RegisterEventHotKey (the API almost every indie macOS app uses for global hotkeys) only fires when the frontmost app doesn't consume the event. Zed's GPUI-based terminal consumes every keyDown: it sees. Carbon never gets a chance.

I originally planned a three-tier fallback (CGEventTapNSEventCarbon) to cover every regime. What I actually shipped for the Mac App Store is two tiers (NSEvent + Carbon) plus a separate paste-strategy rewrite I didn't see coming. This post is the design log for both.

If you ship a macOS app with global shortcuts in 2026 and your users live in Zed, VS Code, or any Electron/Tauri/Flutter app that self-draws its text UI — you're going to hit this. Here's the full autopsy.


Correction from my last post

In my previous post I wrote that QUICOPY uses CGEvent.tapCreate to capture global shortcuts. That was a lie — or more precisely, what I had originally prototyped but never shipped. The released build uses Carbon RegisterEventHotKey, because CGEventTap requires an Accessibility permission prompt I wanted to avoid, and Carbon works out of the box in a sandboxed app with zero entitlements.

That decision is exactly what broke in Zed. This post is the correction.


The Bug Report

"I pressed ⌘1 in Zed's terminal. Nothing happens. Menu bar icon doesn't flash. No text inserted."

Compatibility matrix after reproducing:

Host app⌘1 fires?
iTerm2
Ghostty
Warp
Zed — editor
Zed — built-in terminal
VS Code — editor
VS Code — built-in terminal
Chrome / Safari

The failure is not about Zed the application. It's specifically about the terminal view inside Zed. That's a strange enough shape to rule out the usual suspects immediately.


Wrong Hypothesis #1: "Zed stole the key"

First instinct: Zed has its own ⌘1 binding (switch to tab 1, or switch pane), and it's grabbing the key before the OS routes it to anything else.

I unbound Zed's ⌘1 and tried again. Still broken.
I switched QUICOPY's shortcut to ⌘⌃F9 — a combination no sane app would ever claim. Still broken.

If you change the key and the bug stays, you're not looking at a key conflict. You're looking at something upstream of key identity.


Wrong Hypothesis #2: "My handler isn't registered"

Second instinct: maybe Carbon failed to install the hotkey when Zed became frontmost. Some shell-out race condition.

Logs from HotkeyManager:

[HotkeyManager] Started with 7 hot keys registered

All seven mappings register cleanly. But the Carbon callback — the function that Carbon is supposed to invoke when my hotkey matches — is never called when I press ⌘1 inside Zed's terminal.

Which means the problem isn't in my code. It's in the pipe before my code.


The Real Culprit: How Carbon Actually Dispatches

Carbon's event model is documented, but the documentation buries the critical detail. Here's the dispatch order for a keyDown event on modern macOS:

┌───────────────────────────────────────┐ │ Hardware → WindowServer → NSEvent │ └──────────────────┬────────────────────┘ ▼ ┌──────────────────┐ │ Frontmost App │ NSApplication.sendEvent: │ (AppKit) │ → NSWindow.sendEvent: └────────┬─────────┘ → NSResponder chain │ ┌───────┴────────┐ │ │ event consumed event not consumed (returns YES) (returns NO / unhandled) │ │ ▼ ▼ STOP ┌────────────────────┐ │ Carbon Event Mgr │ │ RegisterEventHotKey│ ← your callback fires here └────────────────────┘

Carbon's hotkey dispatch runs after the frontmost app has had a chance to handle the event, and only if the app reports "unhandled." This design comes from Carbon's original purpose: application-level shortcut registration for the frontmost app, extended over time to become a pseudo-global mechanism by registering at the process-group level.

For a traditional AppKit app, the responder chain dutifully returns "unhandled" for any key that doesn't match a menu item or an active text field. Carbon fires. All is well.

For an app whose text area is an NSTextView — iTerm2, Terminal.app, TextEdit — same story. NSTextView gracefully forwards keys it doesn't recognize.

But Zed's editor and terminal are not NSTextView.


Why Zed's Terminal Specifically: GPUI + Unconditional Consumption

Zed is built on GPUI, a Rust UI framework that renders its entire interface with Metal shaders. There are no NSTextView instances inside Zed's main content area — just a single NSView that captures raw input and dispatches it to GPUI's own keyboard handling.

Zed's terminal view uses a class called TerminalInputHandler (you can see traces of this in the open-source repo and in related bug reports: Zed #15517, Zed #16187). For every keyDown: event, TerminalInputHandler does one of two things:

  1. If the key matches a registered Zed binding → run the Zed action, return handled
  2. If nothing matches → forward the keystroke to the shell process (PTY write), return handled

Note the important word in both branches: handled. Zed's terminal never returns "unhandled" — because from its perspective, there's no such thing as an unhandled keystroke. Either it's a command or it goes to the shell.

This is perfectly correct behavior for a terminal. A terminal's whole job is to eat keystrokes. But it means Carbon upstream sees "app consumed the event" and refuses to fire any registered hotkey.

Changing the shortcut combo changes nothing. The event was consumed before Carbon was even consulted.

This is not a Zed bug. VS Code's integrated terminal, which uses xterm.js inside an Electron <canvas>, has the same behavior. Any modern self-drawn UI framework — Flutter macOS, Tauri with custom keyboard handling, JetBrains IDEs in certain modes — can reproduce this. Carbon's assumption that "the frontmost app will pass unknown keys through" has quietly rotted as native text views have been replaced by self-drawn ones.


What I Planned: A Three-Tier Auto Fallback

Three APIs can observe keyboard events system-wide. The tradeoffs are what make this interesting:

Mechanism Intercept point Fires even when app consumes Sandbox-friendly Permission Can swallow event
RegisterEventHotKey (Carbon) After frontmost app None
CGEvent.tapCreate (.cgSessionEventTap) Before frontmost app ❌ in MAS Accessibility
NSEvent.addGlobalMonitorForEvents Outside frontmost app Accessibility

The two columns that matter: Fires even when app consumes and Can swallow event.

No single backend wins. My original plan was to run all three behind a single protocol and pick at startup based on runtime conditions:

protocol HotkeyMonitoring: AnyObject {
    var isRunning: Bool { get }
    var isRecordingSuspended: Bool { get set }
    var onHotkeyTriggered: ((KeyMapping) -> Void)? { get set }
    func start(mappings: [KeyMapping]) -> Bool   // return false → caller falls through
    func stop()
    func updateMappings(_ mappings: [KeyMapping])
}

Three implementations:

  1. HotkeyManager — the existing Carbon code, untouched
  2. EventTapHotkeyManager — new, for non-sandbox + AX-authorized
  3. NSEventHotkeyManager — new, for sandbox-safe fallback

Selection logic at launch and whenever Accessibility permission flips:

private func chooseBackend() -> any HotkeyMonitoring {
    let sandboxed = SandboxInfo.isSandboxed
    let axGranted = permissionManager.isAccessibilityGranted

    if !sandboxed && axGranted {
        return EventTapHotkeyManager()        // best: Zed-compatible + swallows
    }
    if axGranted {
        return NSEventHotkeyManager()          // sandbox fallback: Zed-compatible
    }
    return HotkeyManager()                     // zero-permission baseline
}

To detect the sandbox at runtime (rather than #if SANDBOXED), I planned to read the app's own entitlements via Security.framework:

enum SandboxInfo {
    static let isSandboxed: Bool = {
        guard let task = SecTaskCreateFromSelf(nil) else { return false }
        let value = SecTaskCopyValueForEntitlement(
            task,
            "com.apple.security.app-sandbox" as CFString,
            nil
        )
        return (value as? Bool) ?? false
    }()
}

I also sketched out the CGEventTap edge cases I'd have to handle: .tapDisabledByTimeout / .tapDisabledByUserInput callbacks, circuit-breaker after 3 consecutive failures, a generation token to reject stale callbacks after a backend swap, @MainActor isolation for the C callback.

This was the plan. It's a satisfying bit of architecture on paper. It's not what I shipped.


What Actually Shipped: Two Tiers and a Paste Rewrite

QUICOPY ships only on the Mac App Store. The sandbox makes CGEventTap permanently unavailable for this target. Rather than maintain a tier of code that's statically unreachable in my only distribution channel, I collapsed the architecture to what actually runs:

protocol HotkeyMonitoring: AnyObject {
    var isRunning: Bool { get }
    var isRecordingSuspended: Bool { get set }
    var onHotkeyTriggered: ((KeyMapping) -> Void)? { get set }
    func start(mappings: [KeyMapping])
    func stop()
    func updateMappings(_ mappings: [KeyMapping])
}

Two implementations:

  1. NSEventHotkeyManager — when Accessibility is granted
  2. HotkeyManager — Carbon, when it isn't

Selection logic collapses to a single if:

private func chooseAndStartMonitor() {
    dispatchPrecondition(condition: .onQueue(.main))
    let suspended = activeMonitor?.isRecordingSuspended ?? false
    activeMonitor?.stop()

    let authed = permissionManager.isAccessibilityGranted
    let monitor: any HotkeyMonitoring = authed
        ? NSEventHotkeyManager()
        : HotkeyManager()
    installCallbacks(on: monitor)
    monitor.isRecordingSuspended = suspended
    monitor.start(mappings: mappingStore.mappings)
    activeMonitor = monitor
}

No start() -> Bool. No factory array. No assertionFailure hard-floor. No generation tokens. The code reads exactly as the design is: two states, one switch.

I still subscribe to the permission flip so the user upgrades automatically once they grant Accessibility:

permissionManager.$isAccessibilityGranted
    .removeDuplicates()
    .dropFirst()
    .receive(on: DispatchQueue.main)
    .sink { [weak self] _ in self?.chooseAndStartMonitor() }
    .store(in: &cancellables)

End state: grant AX → NSEventHotkeyManager starts → Zed's terminal receives ⌘1 events via the global monitor → QUICOPY fires its mapping. Revoke AX → chooseAndStartMonitor re-runs → Carbon comes back as the fallback → Zed's terminal stops responding (expected).

The trade-off you're making by shipping only NSEvent + Carbon on sandboxed macOS: when the user is in Chrome and hits ⌘1, Chrome still switches to its first tab and QUICOPY inserts its text. No backend can swallow the event in the sandbox. In practice users learn to avoid ⌘1..9 for bindings that collide with browser tab shortcuts, and the in-app permission screen suggests ⌘⌃1..9 or ⌘⌥1..9 combos instead.


The Surprise: Carbon Is Not the Only Thing That Broke in Zed

Getting the event to fire was half the fight. Getting text to actually appear in Zed's terminal was the other half — and this part isn't in any original design doc, because it only surfaced during the regression pass.

QUICOPY's text-output strategy on the MAS side has always been: write to the pasteboard → trigger Edit → Paste via AppleScript click menu item "Paste" of menu 1 of menu bar item "Edit". Menu click goes through AppKit's first-responder chain, doesn't depend on physical modifier keys, and is immune to IME state. It works beautifully in every NSTextView-based app.

In Zed's terminal, something very strange happens instead:

  1. ⌘1 fires (good — NSEvent monitor is working)
  2. The onHotkeyTriggered callback runs (good)
  3. TextOutputManager.output writes "hello world" to the pasteboard and runs the AppleScript
  4. The AppleScript returns without error — my code logs paste path=menu_en, marks it as autoPasted, and celebrates
  5. Nothing appears in the terminal

No error. No failure. The AppleScript says it clicked the Paste menu item, the menu item exists in Zed's Edit menu, and the click is reported as successful. But the paste doesn't actually happen.

The autoPasted Illusion

This is a deeply subtle bug. The old TextOutputManager judged "paste succeeded" purely by whether the AppleScript threw an exception:

script?.executeAndReturnError(&error)
if let error = error {
    return .clipboardOnly
}
return .autoPasted

But AppleScript's click menu item on a disabled-looking-but-technically-present menu item in a non-AppKit host returns no error and performs no visible action. Zed's Edit → Paste menu item is routed to whichever buffer GPUI considers focused — and when that focused buffer is the built-in terminal, the menu-click route apparently doesn't reach the terminal's input handler.

So the fix: stop trusting "AppleScript didn't throw" as a proxy for "paste happened." Ask AppleScript to check the menu item's enabled state explicitly, and fall back to synthesizing the keystroke directly when the menu route looks unreliable:

tell process frontProcess
    try
        set editMenu to menu 1 of menu bar item "Edit" of menu bar 1
        if exists menu item "Paste" of editMenu then
            if enabled of menu item "Paste" of editMenu then
                click menu item "Paste" of editMenu
                return "menu_en"
            end if
        end if
    end try
    try
        set editMenuZh to menu 1 of menu bar item "编辑" of menu bar 1
        if exists menu item "粘贴" of editMenuZh then
            if enabled of menu item "粘贴" of editMenuZh then
                click menu item "粘贴" of editMenuZh
                return "menu_zh"
            end if
        end if
    end try
    keystroke "v" using command down   -- fallback
    return "keystroke"
end tell

Two useful wins from this structure:

  1. The three-way return value is a live diagnostic signal. In production logs I now see paste path=menu_en for iTerm2, paste path=menu_zh for localized apps, and paste path=keystroke for Zed, VS Code, and anything else self-drawn. When users report "paste doesn't work," the first question I ask is what path they're on.
  2. keystroke is a real fallback, not a silent substitute. Menu click still wins by default for every normal AppKit app (IME-safe, modifier-key-safe), and keystroke kicks in only when the menu route is genuinely missing or disabled.

The Self-Paste Trap (a.k.a. error 1002)

While I was testing the paste rewrite, I hit another class of bug: what happens when the user presses a bound shortcut while QUICOPY's own popover is in the foreground?

Old behavior (Carbon): Carbon fires the callback, the callback writes to the pasteboard, AppleScript tries to keystroke ⌘V into the frontmost process — which is QUICOPY itself. macOS Accessibility policy then says:

"QUICOPY" is not allowed to send keystrokes. (Error 1002)

No app is allowed to synthesize keystrokes into itself through System Events. The paste silently fails. The user sees a stray "Copied! Press ⌘V to paste" toast they never asked for. It looks broken.

This bug has been in the Carbon code since day one. It's just that nobody ever deliberately sits in QUICOPY's own popover and hits a bound shortcut. I found it because the regression matrix includes that exact case.

I fixed it in two places, because Carbon and NSEvent reach the self-paste code path through different doors:

NSEvent local monitor intercepts key events that are dispatched inside QUICOPY. Instead of firing the mapping and letting it fail downstream, I just swallow the event at the monitor layer:

private func handleLocalKeyDown(_ event: NSEvent) -> NSEvent? {
    if isRecordingSuspended { return event }
    if matchMapping(event) != nil {
        // Popover is frontmost; don't trigger, don't beep, don't paste to self
        return nil
    }
    return event
}

AppDelegate.handleTriggered is the universal junction the Carbon callback also goes through. I added a frontmost check there as a second layer:

private func handleTriggered(_ mapping: KeyMapping) {
    let frontBundle = NSWorkspace.shared.frontmostApplication?.bundleIdentifier
    let ownBundle = Bundle.main.bundleIdentifier
    let popoverShown = popover?.isShown ?? false

    if frontBundle == ownBundle || popoverShown {
        return   // self-paste would trigger error 1002, abort
    }
    // ... proceed to paste ...
}

The popoverShown check is load-bearing. NSWorkspace.frontmostApplication has an interesting property: when a menu-bar popover is displayed, frontmostApplication still returns whatever app was frontmost before the popover opened, not QUICOPY itself. I discovered this the hard way when the first version of my defense only checked frontBundle == ownBundle and still let the popover case through. Reading popover.isShown is the reliable way to detect "QUICOPY's UI is currently on screen."

Neither of those checks is elegant. But together they cover both Carbon (which doesn't distinguish foreground/background) and NSEvent (which does), and neither backend now produces the stray error 1002 → clipboardOnly → toast cascade.


The Shortcut Recorder Problem (And Why It Almost Bit Me)

QUICOPY has a "record a shortcut" UI, implemented with NSEvent.addLocalMonitorForEvents(matching: .keyDown) to capture whatever combo the user presses. Perfectly fine in isolation.

But the new NSEventHotkeyManager also uses addLocalMonitorForEvents to handle self-triggered shortcuts when the popover is frontmost. Two local monitors, same app, racing to consume the same keyDown. One of them wins, which one depends on registration order and call site — and Apple's docs don't guarantee ordering.

The fix is a shared isRecordingSuspended: Bool flag on the monitor. When the user opens the mapping edit screen and taps "record shortcut," MappingEditView.onChange(of: isRecordingShortcut) flips the flag on via a closure provider. The hotkey monitor's local callback short-circuits:

// In NSEventHotkeyManager's local monitor:
if isRecordingSuspended { return event }   // pass through untouched
if matchMapping(event) != nil {
    return nil                              // popover self-paste — swallow, don't trigger
}
return event                                // unrelated key — forward

The order in which the two local monitors fire no longer matters. If the shortcut recorder fires first, it captures the key and returns nil; my monitor never sees it. If mine fires first, the isRecordingSuspended check passes the event straight through and the recorder captures it. Either way, the user's recorded shortcut ends up in the recorder, not triggering a mapping.

The closure-provider pattern is worth flagging: I initially passed HotkeyMonitoring instances directly into MappingEditView. That worked until the backend swapped mid-edit (AX permission flipped while the edit screen was open), and the edit screen kept writing isRecordingSuspended to the old, now-stopped monitor. Passing a closure that resolves appDelegate.activeMonitor on every call instead of a captured instance reference made the whole chain swap-safe.


Why I Narrowed the Design for a Mac App Store-only Release

Three tiers on paper was intellectually satisfying. Two tiers in production is what QUICOPY actually runs. Here's why the simpler thing is the right thing for my distribution:

Constraint 1: MAS sandbox rejects CGEventTap.
Apple's Developer Forums have confirmed this across several macOS releases. There's no entitlement to request, no review workaround, no "just ask for Input Monitoring." CGEvent.tapCreate under com.apple.security.app-sandbox = true returns nil. If I shipped the three-tier code, the CGEventTap branch would be unreachable in every App Store build I'd ever produce.

Constraint 2: I'm not shipping a Developer ID notarized build.
Originally I'd kept the Developer ID target as an option for users who wanted the "best" experience — event-swallowing included. Maintaining two distribution channels for a solo-developer indie app, with different permission flows, different crash-report pipelines, different update mechanisms, and a marketing story that has to explain which version to download, turned out to be more work than the feature was worth. I scoped down to MAS-only.

Consequence: The entire CGEventTap branch is orphan code.
EventTapHotkeyManager, SandboxInfo runtime detection, the circuit-breaker, the generation token — all of it exists to serve a tier that will never run. Carrying it adds maintenance cost (tests, comments that reference non-existent types, code reviewers asking "why is there a fallback that can't trigger?") without buying anything.

Shipping the two-tier version in full means:

If I ever do re-introduce a Developer ID channel, I'll add the CGEventTap tier back then — along with the test matrix, the release pipeline, and the marketing copy that all go with it. Reintroducing a protocol member in Swift is a 10-minute mechanical change; scoping the whole effort correctly is the harder work. Keeping placeholder scaffolding around today doesn't reduce that cost materially.

The word I'd avoid here is "killed." Nothing was killed. The scope was narrowed to the distribution I actually ship, and the code now reflects that.


The Boring Problem of Entitlements

People get scared of new entitlements because they're scared of App Review.

For this migration, the entitlements file does not change at all:

NSAccessibilityUsageDescription is not required for QUICOPY today. AXIsProcessTrustedWithOptions(nil) performs a silent check that doesn't trigger the system permission prompt, and my UI routes the user to System Settings directly when they opt into the Zed-compatible mode. If your app calls any of the prompting variants of that API, you'll want to supply the usage string — but QUICOPY never does.

Hardened Runtime is already on (ENABLE_HARDENED_RUNTIME = YES) and requires no additional com.apple.security.cs.* exceptions for NSEvent monitors. I'd been paranoid about that for weeks before actually testing it. No exception needed.


What the Final Flow Looks Like

User launches QUICOPY │ ▼ PermissionManager probes AX via AXIsProcessTrustedWithOptions(nil) │ ▼ AppDelegate.chooseAndStartMonitor() │ ├─ AX granted → NSEventHotkeyManager starts └─ AX not granted → HotkeyManager starts (Carbon legacy) │ ▼ User grants AX later → applicationDidBecomeActive → probeAccessibility() → $isAccessibilityGranted fires → chooseAndStartMonitor() re-runs → swap to NSEvent │ ▼ User presses ⌘1 inside Zed's terminal │ ▼ (with NSEvent backend) Event captured via addGlobalMonitorForEvents BEFORE Zed's TerminalInputHandler sees it │ ▼ KeyMapping matched → AppDelegate.handleTriggered │ ▼ TextOutputManager writes text to pasteboard, then AppleScript: • Checks Edit→Paste enabled state • enabled → click menu item "Paste" • disabled / absent → fallback to keystroke ⌘V │ ▼ Text appears at the cursor inside Zed's terminal

The bottleneck in the chain is the clipboard → AppleScript round-trip. Hotkey capture itself is negligible on any backend.


What I'd Tell You If You're Building Something Similar

  1. Don't start with Carbon. It's tempting because it's the zero-permission path. But in 2026, "zero permission" means "silently broken in a growing list of self-drawn apps." Budget an AX permission request into your onboarding from day one.
  2. Test in Zed, VS Code, and at least one Electron/Tauri app. NSTextView-based apps will always work. Self-drawn UIs are the canary.
  3. Pick your distribution channel before you pick your hotkey API. MAS-only means you can't use CGEventTap, full stop. Developer ID gives you that option but costs you a separate release pipeline. Decide first, architect second.
  4. Subscribe to AX permission changes. applicationDidBecomeActive + AXIsProcessTrustedWithOptions(nil) is the closest macOS gives you to a "permission flipped" signal. There's no proper notification API.
  5. Don't trust "AppleScript didn't throw" as "paste succeeded." In self-drawn hosts, click menu item can claim success on a menu that doesn't actually route to the focused view. Check enabled explicitly and keep a keystroke fallback.
  6. Two local monitors on the same keyDown is a race. Use a shared suspended flag, not priority ordering. The recorder UI and the hotkey monitor cannot both assume they go first.
  7. Check popover.isShown when defending against self-paste. NSWorkspace.frontmostApplication won't tell you that a menu-bar popover is currently visible; it still reports the app underneath.

Meta: Two Posts, Two Sides of the Same Wall

If you read the previous post, you have the full story now:

Both problems stem from the same underlying fact: macOS's global-shortcut APIs were designed when the frontmost app was always an AppKit app with an NSTextView in it, and always happy to let the OS see events it didn't care about. That world is gone. Zed, VS Code, Warp's Rust UI, Flutter desktop apps, every Electron app with a canvas text surface — they all break the old assumptions, each in their own way.

What you ship for this class of problem is not a single API choice. It's an adaptive architecture that picks the right tool for the permission and distribution channel you're actually in.

Try QUICOPY

A macOS menu bar app that turns global shortcuts into instant text output, with 7 built-in AI prompt templates. Works in Zed, VS Code, and every AppKit host.

download Get on the Mac App Store

About QUICOPY

I build QUICOPY — a macOS menu bar app that turns global shortcuts into instant text output, with 7 built-in AI prompt templates. It's on the Mac App Store for $9.99 lifetime or $1.99/month with a 7-day free trial.

Questions, corrections, or better approaches? Especially interested in hearing from anyone building non-AppKit macOS apps who has opinions on whether Carbon should finally be retired, or whether GPUI / Flutter should be patched to forward unhandled keys upstream. I don't think either fix is coming. So here we are.