Skip to content

Commit 870749a

Browse files
ChipWolfclaude
andcommitted
fix(hammerspoon): make ⌥0 consolidation work on macOS 26 (Tahoe)
moveWindowToSpace is a no-op on macOS 26 (returns true, moves nothing), and allWindows() only enumerates the current Space, so the first cut found 0 windows and moved nothing. Rebuild on mechanisms that work on Tahoe: enumerate every Space via an all-Spaces hs.window.filter, un-full-screen those windows, then removeSpace every user Space except each screen's focused one (removeSpace migrates a Space's windows onto a neighbour, funnelling everything onto the survivor). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 84a597b commit 870749a

2 files changed

Lines changed: 38 additions & 37 deletions

File tree

.agents/skills/hammerspoon/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ Hammerspoon provides direct macOS Space switching: `⌥1`–`⌥5` (Option, matc
1111

1212
- **Bind number-row Space switches with `hs.eventtap`, not `hs.hotkey.bind`.** The keys are `⌥1`..`⌥5` (Option, matching Komorebi's `alt`+number). Two reasons the tap is needed: (1) on macOS ``+number normally types special characters (`¡™£¢∞`), and the tap returns `true` to swallow the keystroke before the input system inserts the character, so the Space switches and nothing is typed (verified by reading a focused text field after a synthetic `⌥1`); (2) ``+number can't go through `hs.hotkey` at all: macOS reserves `⌃1`..`⌃N` as Carbon hotkeys for "Switch to Desktop" by the number of Mission Control *user* desktops even when the System Settings checkboxes are off, so `RegisterEventHotKey` rejects them (`hs.hotkey:enable()` returns nil, console logs `-9878 ... already registered`). Diagnose hotkey issues with `hs -c 'return #hs.hotkey.getHotkeys()'` plus the Hammerspoon console.
1313
- **`hs.spaces` reaches full-screen spaces.** `hs.spaces.spacesForScreen(uuid)` lists every Space (user and full screen/tiled, per `hs.spaces.spaceType`) in Mission Control order, and `hs.spaces.gotoSpace(id)` activates one by driving its Mission Control thumbnail, so it can land on full-screen spaces (the native ``+number shortcuts cannot). Map "Space N" by indexing that list by position.
14-
- **Window consolidation (`⌥0`) is per-screen because `hs.spaces.moveWindowToSpace` cannot cross displays.** It retargets a window's Space only on the *same* screen, so the consolidate action resolves each screen's `activeSpaceOnScreen` and collects that screen's windows onto it. `hs.window.allWindows()` does reach windows on non-focused Spaces (the AX API only enumerates the current Space, but Hammerspoon works around it — verified live: it listed a window on Space 1 while another Space was active), and `hs.spaces.windowSpaces(win)` reports a window's Space(s) so already-placed windows are skipped. Un-full-screen via `win:setFullScreen(false)` first (it collapses the app's dedicated full-screen Space back to a normal window), then wait ~1s before moving so macOS has torn the full-screen Space down. Filter to `win:isStandard()` to skip Notification Centre and other non-app windows.
14+
- **Window consolidation (`⌥0`) is built on `removeSpace`, NOT `moveWindowToSpace`, because the latter is broken on macOS 26 (Tahoe / Darwin 25).** Verified live on 26.4: `hs.spaces.moveWindowToSpace(id, space)` returns `true` but never moves the window, for both off-Space *and* active-Space windows (so it is not a "window must be visible" constraint, it is simply non-functional). The working approach instead deletes the other Spaces: `hs.spaces.removeSpace(sp)` migrates that Space's windows onto a neighbour, so per screen, removing every `spaceType == "user"` Space except `activeSpaceOnScreen(uuid)` funnels all windows onto the one that remains (sequential `removeSpace` calls serialise fine — each drives Mission Control). Per-screen because Spaces belong to a screen.
15+
- **`hs.window.allWindows()` only sees the *current* Space; use an all-Spaces `hs.window.filter` to reach off-Space windows.** The AX API behind `allWindows()` does not enumerate other Spaces (the original `⌥0` used it and found 0 windows because the full-screen apps were on other Spaces). `hs.window.filter.new(nil):setCurrentSpace(nil):setDefaultFilter():getWindows()` *does* enumerate every Space and reports `isFullScreen()` correctly, and `win:setFullScreen(false)` works on those off-Space windows. Consolidation un-full-screens via this filter first (collapsing each app's dedicated full-screen Space into a user Space), waits ~1s for the Spaces to tear down, then removes the leftover user Spaces.
1516
- **Space positions are not stable while "rearrange by most recent use" is on.** With `com.apple.dock` `mru-spaces` unset or true (the macOS default), visiting a full-screen space reorders it in Mission Control, so the index `⌥N` and the menu bar indicator use drifts (the order is stable only while idle). `defaults write com.apple.dock mru-spaces -bool false && killall Dock` gives stable positions; this is applied declaratively by `home/.chezmoiscripts/run_onchange_after_macos_defaults.sh.tmpl` (darwin-guarded, restarts the Dock only when it is running). The indicator (`hs.menubar` badge driven by `hs.spaces.watcher`) recomputes ~0.25s after a change so it reads the settled order, not a mid-transition one; the watcher fires before `activeSpaceOnScreen` settles otherwise.
1617
- **Auto-reload works.** `hs.pathwatcher` uses FSEvents (path-based), so it survives chezmoi's atomic-rename writes (new inode each apply). By contrast, Finicky's `fsnotify`/kqueue watcher is inode-based and loses the watch on every chezmoi write, so Finicky needs a manual restart (see the `finicky` skill).
1718
- **`hs.ipc` is enabled** in `init.lua`, so the running config is queryable from the `hs` CLI (`hs -c '...'`), the main way to debug Spaces and hotkeys.

home/dot_hammerspoon/spaces.lua

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -90,62 +90,62 @@ local function gotoSpaceIndex(index)
9090
end
9191
end
9292

93-
-- Pull every standard window onto each screen's currently-focused Space,
93+
-- Consolidate every window onto each screen's currently-focused Space,
9494
-- un-full-screening any full-screen apps first so their dedicated full-screen
9595
-- spaces collapse back into normal windows.
9696
--
97-
-- Per screen: hs.spaces.moveWindowToSpace cannot move a window across displays,
98-
-- so each screen collects its own windows onto its own active Space.
97+
-- Why this is built on removeSpace rather than moveWindowToSpace:
98+
-- On macOS 26 (Tahoe), hs.spaces.moveWindowToSpace is a no-op: it returns
99+
-- true but never moves the window (verified on both off-Space and active-Space
100+
-- windows). So instead of carrying windows to one Space, we delete the other
101+
-- Spaces: hs.spaces.removeSpace() migrates a removed Space's windows onto an
102+
-- adjacent Space, so removing every Space except the focused one funnels all
103+
-- windows onto the one that remains.
99104
--
100-
-- hs.window.allWindows() reaches windows on Spaces other than the focused one
101-
-- (the macOS Accessibility API only enumerates the current Space, but
102-
-- Hammerspoon works around that), and hs.spaces.windowSpaces(win) reports which
103-
-- Space(s) a window lives on so already-placed windows are left untouched.
105+
-- Why hs.window.filter and not hs.window.allWindows():
106+
-- allWindows() (the AX API) only enumerates the *current* Space, so it cannot
107+
-- see or un-full-screen apps living on other Spaces. An all-Spaces window
108+
-- filter (setCurrentSpace(nil)) does enumerate every Space, and setFullScreen
109+
-- works on the windows it returns even when they are off-Space.
104110
local function consolidateWindows()
105-
-- 1) Un-full-screen everything; this dissolves the dedicated full-screen
106-
-- spaces back into user spaces.
111+
-- 1) Un-full-screen everything; this dissolves each app's dedicated
112+
-- full-screen Space back into a user Space. allWindows() can't see
113+
-- off-Space windows, so enumerate via an all-Spaces window filter.
114+
local filter = hs.window.filter.new(nil):setCurrentSpace(nil):setDefaultFilter()
107115
local hadFullscreen = false
108-
for _, win in ipairs(hs.window.allWindows()) do
116+
for _, win in ipairs(filter:getWindows()) do
109117
if win:isFullScreen() then
110118
win:setFullScreen(false)
111119
hadFullscreen = true
112120
end
113121
end
114122

115-
local function gather()
116-
-- Resolve each screen's active Space after any full-screen spaces have
117-
-- collapsed, since positions can shift.
118-
local targetForScreen = {}
123+
local function collapse()
124+
-- 2) Per screen: keep the focused Space and remove the other user
125+
-- Spaces. Each removeSpace migrates that Space's windows onto a
126+
-- neighbour, so removing all but the target funnels everything onto
127+
-- it. Leftover full-screen Spaces (if an un-full-screen failed) are
128+
-- skipped rather than removed.
129+
local removed = 0
119130
for _, screen in ipairs(hs.screen.allScreens()) do
120-
targetForScreen[screen:getUUID()] = hs.spaces.activeSpaceOnScreen(screen:getUUID())
121-
end
122-
123-
local moved = 0
124-
for _, win in ipairs(hs.window.allWindows()) do
125-
local screen = win:isStandard() and win:screen()
126-
local target = screen and targetForScreen[screen:getUUID()]
127-
if target then
128-
local onTarget = false
129-
for _, s in ipairs(hs.spaces.windowSpaces(win) or {}) do
130-
if s == target then
131-
onTarget = true
132-
break
133-
end
134-
end
135-
if not onTarget then
136-
hs.spaces.moveWindowToSpace(win:id(), target)
137-
moved = moved + 1
131+
local uuid = screen:getUUID()
132+
local target = hs.spaces.activeSpaceOnScreen(uuid)
133+
for _, sp in ipairs(hs.spaces.spacesForScreen(uuid) or {}) do
134+
if sp ~= target and hs.spaces.spaceType(sp) == "user" then
135+
hs.spaces.removeSpace(sp)
136+
removed = removed + 1
138137
end
139138
end
140139
end
141-
hs.alert.show(string.format("Consolidated %d window(s)", moved))
140+
hs.alert.show(string.format("Consolidated (removed %d Space(s))", removed))
142141
end
143142

144-
-- Give macOS a moment to tear down full-screen spaces before moving windows.
143+
-- Give macOS a moment to tear down full-screen spaces before removing the
144+
-- now-empty user spaces.
145145
if hadFullscreen then
146-
hs.timer.doAfter(1.0, gather)
146+
hs.timer.doAfter(1.0, collapse)
147147
else
148-
gather()
148+
collapse()
149149
end
150150
end
151151

0 commit comments

Comments
 (0)