I use a lot of terminal tools (Helix, tmux, lazygit) alongside desktop apps like Safari, Ghostty, and Slack. Switching between them meant either reaching for awkward key combos or installing heavyweight apps like Karabiner-Elements or Raycast just for hotkeys.
I wanted one key that does three things:
- Tap sends Escape (essential for Helix, lazygit, vim-style workflows)
- Double tap toggles Caps Lock (for when I actually need it)
- Hold + letter switches to an app (Safari, Ghostty, Slack, etc.)
And a bonus: when Ghostty is focused, hold Caps Lock acts as Ctrl instead of app switching. This means Caps Lock + Space sends Ctrl+Space, which is my tmux prefix, without moving my hand off the home row.
The solution: two lightweight tools that are already on or trivially added to macOS.
This post is part of my terminal workflow series.
Why hidutil
hidutil is built into macOS. It remaps keys at the IOKit level, before any app sees them. No installation, no background process eating RAM, no GUI. One command remaps Caps Lock to F18, a key that physically does not exist on Apple keyboards, so there are zero conflicts with anything.
Why Hammerspoon
Hammerspoon is a lightweight (~30MB) scripting bridge for macOS automation. It uses Lua. The entire configuration is a single init.lua file. It intercepts the F18 key events and implements the tap / double-tap / hold logic.
Why not Karabiner? Karabiner is a kernel-level input rewriter with a complex JSON config format. It is a great tool but massive overkill for remapping one key. Why not Raycast? Raycast can do Hyper Key + app switching, but it is a full productivity suite when all I needed was hotkeys. Hammerspoon does exactly what I need and nothing more. And if I ever want window management or other automation, I add a few lines of Lua instead of installing another app.
Setup
Prerequisites
Install Hammerspoon:
brew install --cask hammerspoonStep 1: Remap Caps Lock to F18 with hidutil
Run once to test:
hidutil property --set '{"UserKeyMapping":[{"HIDKeyboardModifierMappingSrc":0x700000039,"HIDKeyboardModifierMappingDst":0x70000006D}]}'To persist across restarts, create a LaunchAgent. Save this as ~/Library/LaunchAgents/com.local.capslock-remap.plist:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>Label</key> <string>com.local.capslock-remap</string> <key>ProgramArguments</key> <array> <string>/usr/bin/hidutil</string> <string>property</string> <string>--set</string> <string>{"UserKeyMapping":[{"HIDKeyboardModifierMappingSrc":0x700000039,"HIDKeyboardModifierMappingDst":0x70000006D}]}</string> </array> <key>RunAtLoad</key> <true/></dict></plist>Load it:
launchctl load ~/Library/LaunchAgents/com.local.capslock-remap.plistVerify it is working:
hidutil property --get "UserKeyMapping"You should see a mapping from 30064771129 to 30064771181.
Step 2: Hammerspoon config
Save this as ~/.hammerspoon/init.lua:
-- =============================================================-- Caps Lock (remapped to F18 via hidutil) - triple behavior:-- tap - Escape-- double tap - toggle Caps Lock-- hold + key - switch app (or Ctrl in Ghostty)-- =============================================================
-- App mappings (hold Caps Lock + letter outside Ghostty)Apps = { s = "Safari", f = "Firefox", c = "Google Chrome", g = "Ghostty", l = "Slack", i = "Signal", e = "Mail",}
-- ConfigDOUBLE_TAP_WINDOW = 0.25F18 = 79
-- State (global so recovery timer and console can access them)CapsDown = falseUsedAsModifier = falseLastTapTime = 0PendingEscape = nil
-- Caps Lock indicatorlocal function showCapsState(on) hs.alert.closeAll() hs.alert.show(on and "CAPS ON" or "caps off", 0.6)end
-- Key down handlerDownTap = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(e) if e:getKeyCode() == F18 then CapsDown = true UsedAsModifier = false return true end
if CapsDown then local frontApp = hs.application.frontmostApplication() local isGhostty = frontApp and frontApp:name() == "Ghostty"
if isGhostty then UsedAsModifier = true hs.eventtap.keyStroke({"ctrl"}, e:getKeyCode(), 0) return true end
local char = hs.keycodes.map[e:getKeyCode()] if char and Apps[char] then UsedAsModifier = true hs.application.launchOrFocus(Apps[char]) return true end end
return falseend)
-- Key up handlerUpTap = hs.eventtap.new({ hs.eventtap.event.types.keyUp }, function(e) if e:getKeyCode() ~= F18 then return false end
CapsDown = false
if UsedAsModifier then return true end
local now = hs.timer.secondsSinceEpoch() local delta = now - LastTapTime LastTapTime = now
if PendingEscape then PendingEscape:stop() PendingEscape = nil end
if delta < DOUBLE_TAP_WINDOW then hs.hid.capslock.toggle() showCapsState(hs.hid.capslock.get()) LastTapTime = 0 else PendingEscape = hs.timer.doAfter(DOUBLE_TAP_WINDOW, function() hs.eventtap.keyStroke({}, "escape") PendingEscape = nil end) end
return trueend)
-- StartDownTap:start()UpTap:start()
-- ── Auto-recover eventtaps ──────────────────────────────────local function restartTaps() if DownTap then DownTap:stop() end if UpTap then UpTap:stop() end hs.timer.doAfter(0.5, function() DownTap:start() UpTap:start() end)end
local function ensureTapsRunning() if not DownTap:isEnabled() or not UpTap:isEnabled() then restartTaps() endend
-- Restart on wake / unlockCaffeinateWatcher = hs.caffeinate.watcher.new(function(event) if event == hs.caffeinate.watcher.systemDidWake or event == hs.caffeinate.watcher.screensDidUnlock then hs.timer.doAfter(1, restartTaps) endend)CaffeinateWatcher:start()
-- Safety net: check every 30sRecoveryTimer = hs.timer.doEvery(30, ensureTapsRunning)
hs.alert.show("Caps Lock remapped", 1)All state variables are global so they are accessible from the Hammerspoon console and the recovery timer. The auto-recovery section at the bottom handles a common annoyance: macOS sometimes silently kills eventtaps after sleep, screen unlock, or system updates. A caffeinate.watcher restarts the taps on wake and unlock, and a 30-second timer acts as a safety net in case they die for any other reason.
Step 3: Enable Accessibility
This is required. Without it, Hammerspoon eventtap cannot intercept key events and the whole thing silently fails.
Go to System Settings > Privacy & Security > Accessibility and enable the toggle for Hammerspoon. If it was already enabled and things are not working, turn it off and on again, then Reload Config in Hammerspoon.
Step 4: Launch at login
Open Hammerspoon preferences and enable Launch Hammerspoon at login. Without this, the Caps Lock behavior will not work after a restart until you manually open Hammerspoon.
Step 5: Reload
Open Hammerspoon and hit Reload Config (or press Cmd+R). You should see a brief “Caps Lock remapped” alert on screen.
Result
One key, context-aware behavior, two minimal tools, zero bloat:
| Action | Context | Result |
|---|---|---|
| Tap Caps Lock | Anywhere | Escape |
| Double tap Caps Lock | Anywhere | Toggle Caps Lock on/off |
| Hold Caps Lock + S | Outside Ghostty | Safari |
| Hold Caps Lock + F | Outside Ghostty | Firefox |
| Hold Caps Lock + C | Outside Ghostty | Chrome |
| Hold Caps Lock + G | Outside Ghostty | Ghostty |
| Hold Caps Lock + L | Outside Ghostty | Slack |
| Hold Caps Lock + I | Outside Ghostty | Signal |
| Hold Caps Lock + E | Outside Ghostty | |
| Hold Caps Lock + any key | Inside Ghostty | Ctrl + that key |
The Ghostty behavior means Caps Lock + Space sends Ctrl+Space, which is my tmux prefix. Caps Lock + A, Caps Lock + C, etc. all send their Ctrl equivalents. Useful for tmux, Helix, and anything else running in the terminal.
To add more apps, edit the Apps table in init.lua and reload. That is it.
Troubleshooting
Caps Lock types F18 / does nothing: Check that the hidutil mapping is active with hidutil property --get "UserKeyMapping". If empty, re-run the hidutil property --set command or reload the LaunchAgent.
Keys pass through (typing letters instead of switching apps): Hammerspoon likely does not have Accessibility permissions. Check System Settings > Privacy & Security > Accessibility.
Stops working after sleep/unlock: The config auto-recovers eventtaps on wake and screen unlock. If it still fails, open the Hammerspoon console and check DownTap:isEnabled() and UpTap:isEnabled(). If either returns false, the recovery timer should pick it up within 30 seconds, or you can run DownTap:start(); UpTap:start() manually.
Want to verify the keycode? Open Hammerspoon Console and run:
hs.eventtap.new({hs.eventtap.event.types.keyDown}, function(e) print("keycode: " .. e:getKeyCode()) return false end):start()Press Caps Lock. It should print keycode: 79. If it prints something else, update the F18 variable in init.lua to match.
File locations
~/Library/LaunchAgents/com.local.capslock-remap.plist # hidutil remap~/.hammerspoon/init.lua # Hammerspoon configBoth are good candidates for your dotfiles repo.