Created
July 27, 2025 02:17
-
-
Save rodgtr1/8374e463c0cbee9f8834750683084951 to your computer and use it in GitHub Desktop.
Hammerspoon to toggle terminal, 40% to the right, on top
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Hammerspoon Terminal Toggle with Double-Shift | |
-- Toggles terminal (Ghostty) visibility with double-shift keypress | |
-- Positions terminal on right side of screen | |
-- I use it for quick Claude Code access | |
-- Basic Hammerspoon setup | |
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "R", function() | |
hs.reload() | |
hs.alert.show("Hammerspoon config reloaded") | |
end) | |
-- Auto-reload config when files change (debounced) | |
local function reloadConfig(files) | |
local doReload = false | |
for _, file in pairs(files) do | |
if file:sub(-4) == ".lua" then | |
doReload = true | |
break | |
end | |
end | |
if doReload then | |
hs.timer.doAfter(0.5, hs.reload) | |
end | |
end | |
hs.pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon/", reloadConfig):start() | |
hs.alert.show("Config loaded") | |
-- Configuration | |
local TERMINAL_APP = "Ghostty" -- Change this to your preferred terminal | |
local TERMINAL_WIDTH_RATIO = 0.4 -- 40% of screen width | |
local LAUNCH_DELAY = 0.5 -- Seconds to wait for app launch | |
local DOUBLE_PRESS_TIME = 0.3 -- Max time between shift presses | |
-- Calculate terminal dimensions and position | |
local screen = hs.screen.mainScreen() | |
local frame = screen:frame() | |
local terminalWidth = frame.w * TERMINAL_WIDTH_RATIO | |
local terminalHeight = frame.h | |
local terminalX = frame.x + frame.w - terminalWidth | |
local terminalY = frame.y | |
-- Check if terminal is currently visible | |
function isTerminalVisible() | |
local app = hs.application.get(TERMINAL_APP) | |
if not app then return false end | |
local windows = app:allWindows() | |
if #windows == 0 then return false end | |
-- Check if any window is visible and not minimized | |
for _, win in ipairs(windows) do | |
if not win:isMinimized() and win:isVisible() then | |
return true | |
end | |
end | |
return false | |
end | |
-- Main toggle function | |
function toggleTerminal() | |
local app = hs.application.get(TERMINAL_APP) | |
-- If app doesn't exist, launch it | |
if not app then | |
hs.application.launchOrFocus(TERMINAL_APP) | |
-- Wait for app to launch and position window | |
hs.timer.doAfter(LAUNCH_DELAY, function() | |
local newApp = hs.application.get(TERMINAL_APP) | |
if newApp then | |
local windows = newApp:allWindows() | |
if #windows > 0 then | |
local win = windows[1] | |
win:setFrame({ | |
x = terminalX, | |
y = terminalY, | |
w = terminalWidth, | |
h = terminalHeight | |
}) | |
win:focus() | |
end | |
end | |
end) | |
return | |
end | |
local windows = app:allWindows() | |
-- If no windows exist, try to create one | |
if #windows == 0 then | |
app:activate() | |
-- Uncomment next line if your terminal doesn't auto-create windows: | |
-- hs.eventtap.keyStroke({"cmd"}, "n") | |
return | |
end | |
local win = windows[1] | |
-- Toggle based on actual visibility | |
if isTerminalVisible() then | |
win:minimize() | |
else | |
win:unminimize() | |
win:setFrame({ | |
x = terminalX, | |
y = terminalY, | |
w = terminalWidth, | |
h = terminalHeight | |
}) | |
win:focus() | |
app:activate() | |
end | |
end | |
-- Double-shift detection system | |
local doublePressTime = DOUBLE_PRESS_TIME | |
local lastShiftReleaseTime = 0 | |
local shiftPressCount = 0 | |
local shiftWasPressed = false | |
local resetTimer = nil | |
local shiftTap = hs.eventtap.new({hs.eventtap.event.types.flagsChanged}, function(event) | |
local flags = event:getFlags() | |
if flags.shift then | |
shiftWasPressed = true | |
else | |
if shiftWasPressed then | |
shiftWasPressed = false | |
local currentTime = hs.timer.secondsSinceEpoch() | |
-- Cancel existing reset timer | |
if resetTimer then | |
resetTimer:stop() | |
resetTimer = nil | |
end | |
if currentTime - lastShiftReleaseTime < doublePressTime then | |
shiftPressCount = shiftPressCount + 1 | |
else | |
shiftPressCount = 1 | |
end | |
lastShiftReleaseTime = currentTime | |
if shiftPressCount == 2 then | |
toggleTerminal() | |
shiftPressCount = 0 | |
return true -- Consume the event | |
else | |
-- Reset count after timeout | |
resetTimer = hs.timer.doAfter(doublePressTime, function() | |
shiftPressCount = 0 | |
resetTimer = nil | |
end) | |
end | |
end | |
end | |
return false | |
end) | |
-- Start the event tap with retry on failure | |
local function startShiftTap() | |
if shiftTap then | |
shiftTap:start() | |
if not shiftTap:isEnabled() then | |
-- Retry after brief delay if startup fails | |
hs.timer.doAfter(2, startShiftTap) | |
end | |
end | |
end | |
startShiftTap() | |
-- Clean up when terminal app quits | |
local appWatcher = hs.application.watcher.new(function(name, eventType, app) | |
if name == TERMINAL_APP and eventType == hs.application.watcher.terminated then | |
if resetTimer then | |
resetTimer:stop() | |
resetTimer = nil | |
end | |
end | |
end) | |
appWatcher:start() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment