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 (CGEventTap → NSEvent → Carbon) 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:
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:
- If the key matches a registered Zed binding → run the Zed action, return handled
- 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.
- Carbon loses the first. That's our bug.
CGEventTapwins both — but Apple does not allow it in Mac App Store sandboxed apps. (Officially,com.apple.security.app-sandbox = true+ event tap creation returns nil. Unofficially, confirmed across a half-decade of developer forum posts.)NSEventglobal monitor wins the first column and runs fine under sandbox, but cannot swallow events. So if you bind⌘1and the user is in Chrome, they'll both switch to the first tab and fire your shortcut. That's a bad experience for a text expansion tool.
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:
HotkeyManager— the existing Carbon code, untouchedEventTapHotkeyManager— new, for non-sandbox + AX-authorizedNSEventHotkeyManager— 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:
NSEventHotkeyManager— when Accessibility is grantedHotkeyManager— 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:
⌘1fires (good — NSEvent monitor is working)- The
onHotkeyTriggeredcallback runs (good) TextOutputManager.outputwrites "hello world" to the pasteboard and runs the AppleScript- The AppleScript returns without error — my code logs
paste path=menu_en, marks it as autoPasted, and celebrates - 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:
- The three-way return value is a live diagnostic signal. In production logs I now see
paste path=menu_enfor iTerm2,paste path=menu_zhfor localized apps, andpaste path=keystrokefor 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. - 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:
HotkeyMonitoring.startreturnsVoid, notBool. It can't fail-and-fall-through because there's nothing to fall through to.chooseAndStartMonitoris a singleif/else, not a factory array with a for loop.- Readers of the code see exactly two backends and one branch. The structure matches the runtime reality.
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:
com.apple.security.app-sandbox— already set for MAS targetcom.apple.security.automation.apple-events— already set for pasting
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
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
- 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.
- Test in Zed, VS Code, and at least one Electron/Tauri app.
NSTextView-based apps will always work. Self-drawn UIs are the canary. - 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.
- 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. - Don't trust "AppleScript didn't throw" as "paste succeeded." In self-drawn hosts,
click menu itemcan claim success on a menu that doesn't actually route to the focused view. Checkenabledexplicitly and keep akeystrokefallback. - Two local monitors on the same
keyDownis a race. Use a shared suspended flag, not priority ordering. The recorder UI and the hotkey monitor cannot both assume they go first. - Check
popover.isShownwhen defending against self-paste.NSWorkspace.frontmostApplicationwon'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:
- Output side:
CGEvent.post()is blocked by sandbox. Workaround: clipboard + AppleScriptSystem Events, with menu-enabled checks for self-drawn hosts. - Input side: Carbon
RegisterEventHotKeyis blocked by self-drawn frontmost apps. Workaround: upgrade toNSEventglobal monitor whenever Accessibility is granted; keep Carbon as the zero-permission fallback.
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 StoreAbout 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.