Skip to content

Instantly share code, notes, and snippets.

@xiexingwu
Last active November 26, 2025 03:22
Show Gist options
  • Select an option

  • Save xiexingwu/991ff58ba970038c7f14a28588a00b16 to your computer and use it in GitHub Desktop.

Select an option

Save xiexingwu/991ff58ba970038c7f14a28588a00b16 to your computer and use it in GitHub Desktop.
Wezterm - Quake/Quick Terminal using Hammerspoon (optional Aerospace)

Quick Terminal setup for Wezterm

Make Wezterm behave like Ghostty's Quick Terminal, i.e. use a keybind to focus/hide a scratch terminal that always stays on top.

wezterm_quickterm

Keybind: Alt-`

  • First press: Spawns a wezterm terminal with workspace "_scratch" and focuses it in the middle of the screen.
  • Subsequent pressees: Hides the terminal if it is focused. Otherwise bring the terminal to the centre and focus.

How it works

Wezterm

Define a "_scratch" workspace (or any other name of your choice) so Wezterm can do special handling for this specific GUI window, e.g. resize it to preference and always keep it above other apps. See wezterm.lua below.

Hammerspoon

Hammerspoon is responsible for summoning and hiding the scratch terminal. See hammerspoon.lua below.

Aerospace (Optional)

If you have a tiling window manager like Aerospace, you will probably want to set an exception to not manage the scratch terminal, e.g.

[[on-window-detected]]
if.app-name-regex-substring = 'wezterm'
if.window-title-regex-substring = '_scratch'
run = 'layout floating'

Version snapshot:

  • wezterm --version: 20250511-222021-ad44b159
  • Hammerspoon: Version 1.0.0 (6864)
  • aerospace --version: 0.18.5-Beta 4213dfd9d958dbfe3f801e07d7d5af53303baa75

FAQ

  • I don't want to use Wezterm's multiplexer.

    In the hammerspoon script, find the wez.spawn method and remove --domain unix --attach from the args variable. In fact, play around with this part of the script and please let me know if there's a better way to interact with wezterm.

  • I don't like the terminal in the center of the screen and prefer the curtain-like apperance of Quake mode.

    Update your wezterm configuration to change how the window is positioned. See https://gist.github.com/xieve/fc67361a2a0cb8fc23ab8369a8fc1170 for example.

  • Are there other ways to achieve this result? How about on other platforms?

    Yes, please see this discussion wezterm/wezterm#1751 and another relevant gist: https://gist.github.com/xieve/fc67361a2a0cb8fc23ab8369a8fc1170.

-- .config/hammerspoon/init.lua
Logger = hs.logger.new("default", "info")
local scratch = "_scratch" -- keep this consitent with Wezterm
local wez = {}
function wez.spawn(workspace)
local args = " --class org.wezfurlong.wezterm." ..
workspace .. " --workspace " .. workspace .. " --domain unix --attach"
local command = "wezterm start" .. args .. " zsh -i & detach"
io.popen(os.getenv("SHELL") .. [[ -l -i -c "]] .. command .. [["]])
-- hs.execute(os.getenv("SHELL") .. [[ -l -i -c "]] .. command .. [["]])
-- Wezterm may still be starting up, so we will keep looking for it and finally focus it when we do
local count = 0
hs.timer.waitUntil(
function()
count = count + 1
if count >= 20 then
Logger.w("Failed to find newly spawned Wezterm window after 20 tries.")
return true
end
return wez.findWindow(workspace) ~= nil
end,
function() wez.toggleFocus(workspace, { force_show = true }) end,
0.1)
end
---Return the first window whose name includes the workspace name
---TODO: This is a very fragile process since the window title may inadvertently contain the workspace name.
function wez.findWindow(workspace)
local terms = table.pack(hs.application.find("wezterm"))
for _, term in ipairs(terms) do
local windows = table.pack(term:findWindow(workspace))
if #windows > 0 then
return windows[1]
end
end
end
---Toggles the focus of the wezterm window named `workspace`
---@param workspace string The workspace name
---@param opts { force_show: boolean } Options:
--- force_show=nil: If true, always try to focus/center the window
---@return boolean isSuccess Whether the window was successfully focused
function wez.toggleFocus(workspace, opts)
opts = opts or {}
local win = wez.findWindow(workspace)
if win == nil then
Logger.e("Failed to find a wezterm window [" .. workspace .. "] to focus.")
return false
end
-- hide/unhide
if workspace == scratch then
local app = win:application()
-- Toggle scratch primary/minimised
if win:isVisible() then
if opts.force_show then
win:centerOnScreen('0,0')
win:focus()
else
app:hide()
end
elseif win:application():isHidden() then
app:unhide()
win:centerOnScreen('0,0')
win:focus()
end
else
win:focus()
end
return true
end
-- Setup keybinds
function wez.summon(workspace)
local terms = table.pack(hs.application.find("wezterm"))
if #terms == 0 then
Logger.e("No Wezterm found, spawning [" .. workspace .. "]")
-- No Wezterm instances
wez.spawn(workspace)
else
local found = wez.toggleFocus(workspace)
if not found then
Logger.e("[" .. workspace .. "] not found, spawning")
-- No windows to focus
wez.spawn(workspace)
end
end
end
hs.hotkey.bind({ "alt" }, "`", function() wez.summon(scratch) end)
hs.hotkey.bind({ "alt" }, "1", function() wez.summon("work") end)
hs.hotkey.bind({ "alt" }, "2", function() wez.summon("pers") end)
-- .config/wezterm/wezterm.lua
local wezterm = require("wezterm") --[[@as Wezterm]]
local mux = wezterm.mux
local act = wezterm.action
local scratch = "_scratch" -- Keep this consistent with Hammerspoon
wezterm.on("gui-attached", function(domain)
local workspace = mux.get_active_workspace()
if workspace ~= scratch then return end
-- Compute width: 66% of screen width, up to 1000 px
local width_ratio = 0.66
local width_max = 1000
local aspect_ratio = 16 / 9
local screen = wezterm.gui.screens().active
local width = math.min(screen.width * width_ratio, width_max)
local height = width / aspect_ratio
for _, window in ipairs(mux.all_windows()) do
local gwin = window:gui_window()
if gwin ~= nil then
gwin:perform_action(act.SetWindowLevel "AlwaysOnTop", gwin:active_pane())
gwin:set_inner_size(width, height)
end
end
end)
@maauk
Copy link

maauk commented Jul 7, 2025

Cheers for the gist. Works awesome and I especially appreciate the aerospace config reminder. Not sure how my config differs but I also had to add the following to my wezterm.lua to prevent the title from changing:

wezterm.on('format-window-title', function()
  local workspace = wezterm.mux.get_active_workspace()
  if workspace ~= scratch then return end
  return scratch
end)

@xiexingwu
Copy link
Author

Thanks for the kind words, and I'm glad you got it working the way you want it to.
Nice callout on the window title---I don't really look at mine so I never bothered configuring it.

@BitCalSaul
Copy link

Hi, thanks for this great gist! I'm trying to set up the quick terminal but running into some issues on macOS.
Environment

macOS: Tahoe 26.0.1
WezTerm: installed via brew install --cask wezterm at /Applications/WezTerm.app, 20240203-110809-5046fc22
Hammerspoon: Version 1.0.0 (6864)

Issues

  1. wezterm-gui not found
    When running the spawn command, I get:
    failed to exec "/usr/local/bin/wezterm-gui" "start" "--class" "org.wezfurlong.wezterm._scratch" "--workspace" "_scratch": Os { code: 2, kind: NotFound, message: "No such file or directory" }
    The wezterm binary at /usr/local/bin/wezterm expects wezterm-gui to be in the same directory, but it's actually at /Applications/WezTerm.app/Contents/MacOS/wezterm-gui.
    Workaround: Creating a symlink solved this:
    bashsudo ln -sf /Applications/WezTerm.app/Contents/MacOS/wezterm-gui /usr/local/bin/wezterm-gui
  2. Hammerspoon can't find windows spawned by io.popen
    The original script uses:
    luaio.popen(os.getenv("SHELL") .. [[ -l -i -c "]] .. command .. [["]])
    But Hammerspoon's wez.findWindow() consistently fails to detect the spawned window, even though the window does appear with the correct _scratch title.
    Attempted fixes:

Using hs.execute() instead of io.popen() - blocks execution
Using open -na WezTerm --args start --workspace _scratch - spawns window but doesn't trigger gui-attached event
Increasing wait time and retry count - still can't find window

The manual command works fine:
bashwezterm start --class org.wezfurlong.wezterm._scratch --workspace _scratch
Questions

Is there a recommended way to install WezTerm that avoids the wezterm-gui path issue?
Has anyone else experienced Hammerspoon's hs.application.find() or hs.window.allWindows() failing to detect newly spawned wezterm windows?
Would it be better to use a different approach for spawning, like AppleScript or a different Lua API?

Any guidance would be appreciated! The setup looks amazing when it works.

@xiexingwu
Copy link
Author

Hi @BitCalSaul, I no longer use this setup and have migrated to Ghostty, so apologies in advance if I can't offer too much help here.

I think the first thing I might recommend is using the nightly version of WezTerm (I found it pretty stable at the time). As you can see from the homebrew install, the last official release is almost 2 years old, and I recall several features/flags have been added/deprecated/renamed compared to the official docs. You can either build from source (what I did) or brew install wezterm@nightly (not sure if this is equivalent). I hope this helps with the path issue, but no promises.

Regarding Hammerspoon, I think I ended up using io.popen instead of hs.execute for precisely the reason you state. I don't understand the internals too well, so most of it was trial and error. For finding the window/application, I recall Hammerspoon has a debug console where you can do some print debugging. You may have to play around with this and inspect the state of findWindow when it runs to find out why it's not finding your window. It's possibly related to the outdated wezterm version and/or the window title changing. See if the above comment helps

@BitCalSaul
Copy link

@xiexingwu Thank you for your detail reply. I have migrated to Ghostty too since its convenient quick terminal feature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment