This is part of my "Claude Code environment" series. Last time, in The Environment-Degradation Sentinel, I wrote about self-diagnosing a Claude Code environment. This time it's a different kind of workflow cost.
Picture eight Terminal.app windows scattered across an ultrawide monitor. A single command snaps them into a 4×2 grid. It looks like "just line up windows with osascript," but there are traps across three layers: coordinate-system conversion, AppleScript's asynchronous behavior, and macOS's row rounding. In this article I'll explain the two design cores and the five traps I hit during implementation, with real code.
The problem: dragging windows into place cost me a minute
When I line up multiple Claude Code sessions on a 3440×1440 ultrawide, the windows open in random positions every time. Fullscreen split isn't enough. Mission Control can't pin positions. In the end I was spending a minute every morning dragging all eight windows by mouse into a 4×2 arrangement.
Writing the position and size directly in AppleScript should have been the end of it. But once I actually started writing, three things became clear:
- There are two coordinate systems (Cocoa's Y-up and screen coordinates' Y-down)
-
set positiongets silently ignored in some situations - The window's height doesn't end up at the value I specified
I'll walk through the design of a script that solves all of these together (~/.claude/scripts/terminal-grid8.sh).
Design core ①: Convert visibleFrame to global coordinates in Swift and hand it to the shell
AppleScript's position of window returns global coordinates (top-left origin, Y-down). Meanwhile macOS's NSScreen.visibleFrame uses the Cocoa coordinate system (Y-up). Mixing the two makes the math go wrong. I do the conversion all at once in Swift and hand it off via the shell variables GX GY GW GH.
GEOM=$(swift - <<'EOF'
import AppKit
let screens = NSScreen.screens
guard let main = screens.first else { exit(1) }
// 最も横長の画面を対象にする(ウルトラワイド)。同率ならメイン以外を優先
let target = screens.max(by: { $0.frame.width < $1.frame.width })!
let v = target.visibleFrame // Dock除外(Cocoa座標: 左下原点Y上向き)
let mainH = main.frame.height
var topY = mainH - (v.origin.y + v.height) // グローバル(左上原点)座標へ変換
var h = v.height
// visibleFrameがメニューバーを反映しない画面がある → 手動で差し引く
if v.height == target.frame.height {
topY += 30
h -= 30
}
print("\(Int(v.origin.x)) \(Int(topY)) \(Int(v.width)) \(Int(h))")
EOF
)
read -r GX GY GW GH <<< "$GEOM"
The key is the conversion formula mainH - (v.origin.y + v.height). Cocoa's visibleFrame.origin.y is the distance from the bottom edge of the main screen (Y-up), so to turn it into a global "distance from the top-left (Y-down)," you subtract from the height of the main screen.
The if v.height == target.frame.height branch is a fallback that emerged from real measurement. On non-main screens, visibleFrame can return the same value as the frame without subtracting the 30px menu bar. In that case I subtract it manually on the Swift side (trap ④, below).
The cell dimensions are a simple integer division.
CW=$(( GW / COLS )) # 列幅
CH=$(( GH / ROWS )) # 行高
Design core ②: AppleScript's convergence loop
The backbone of the convergence loop is that it re-scans indexes on every pass — not by name, not by ID.
-- 収束ループ: 毎パス再スキャンして未配置の窓を1枚だけ動かす
repeat with pass from 1 to (nCells * 3)
set occupied to {}
repeat nCells times
set end of occupied to false
end repeat
set moverIdx to 0
set n to count windows
-- 1) 対象画面上の窓を走査し、セル一致済みをマーク・最初の未配置窓を記録
repeat with i from 1 to n
try
set p to position of window i
set px to item 1 of p
set py to item 2 of p
on error
set px to -99999
set py to -99999
end try
if px ≥ (gx - tol) and px < (gx + nCols * cw) and
py ≥ (gy - tol) and py < (gy + nRows * ch) then
-- セル一致判定 ...(tol=40px の誤差許容)
if (not matched) and moverIdx = 0 then set moverIdx to i
end if
end repeat
-- 2) 未配置窓が無ければ収束
if moverIdx = 0 then exit repeat
-- 3) 最初の空きセルへ移動 (size→delay→position→delay→size、delay 必須)
set size of window moverIdx to {cw, ch}
delay 0.2
set position of window moverIdx to {tx, ty}
delay 0.2
set size of window moverIdx to {cw, ch}
delay 0.2
-- 読み返して position を最終補正(リサイズでのドリフト対策)
set p2 to position of window moverIdx
if (item 1 of p2) is not tx or (item 2 of p2) is not ty then
set position of window moverIdx to {tx, ty}
delay 0.2
end if
end repeat
There are two reasons I designed this as "move exactly one window and re-scan as long as any remain unplaced," rather than "move all the windows at once with a forEach":
- Name references don't work (trap ①), so you can't hold an object reference across passes
- Moving one window changes the cells' occupancy state, so you have to redo the scan
Even in the worst case (all 8 windows are out of place), nCells * 3 = 24 passes are enough to converge.
:::message
If you drop delay 0.2, set position is silently ignored (trap ②). AppleScript doesn't throw an error either. You need the five steps size → delay → position → delay → size, plus the fallback that reads back and re-corrects.
:::
After the convergence loop, there's a normalization pass that unifies the bottom row's y by actual height.
-- 最下段yの正規化: 行丸めでセル高を超える実高のクランプ揺れ対策
set bottomEdge to gy + nRows * ch
set lastRowTop to gy + (nRows - 1) * ch
repeat with i from 1 to (count windows)
try
set p to position of window i
-- 最下段の窓だけを対象に実高で下端揃え
if py ≥ (lastRowTop - tol) and py < bottomEdge then
set s to size of window i
set wh to item 2 of s -- 実際のピクセル高
set targetY to bottomEdge - wh
if py is not targetY then
set position of window i to {bestX, targetY}
delay 0.2
end if
end if
end try
end repeat
The traps I hit
① Window name references break down while the spinner is rotating
Terminal.app replaces the title with spinner characters (⠐⠂✳) while a tab is loading. If you reference a window by name right after a session starts, you get -1728 (object not found). Stop referencing by name; operate directly by the window i index on every pass.
② A position set right after size is ignored without a delay
If you put set position of window i to {tx, ty} right after a resize, the coordinate change arrives before macOS's window server has finished the previous resize, and it gets dropped. Put delay 0.2 in two places (after size and after position), then read back and correct.
③ Terminal's row-height rounding keeps the cell height off the specified value
Terminal.app rounds a window's height to "rows × row height." For example, even if you specify a cell height of 705px, the actual value becomes 44 rows × 16px = 704px, or 45 rows × 16px = 720px. This error, combined with macOS's boundary clamping, shifts the bottom row's windows by a few px and makes the cell-match check fail. Run a "bottom-edge alignment by actual height" normalization pass after the convergence loop to absorb it.
④ visibleFrame sometimes doesn't subtract the menu bar
On non-main screens, NSScreen.visibleFrame can return the same value as NSScreen.frame (observed in practice). In that case the computed top edge of the grid overlaps the menu bar. On the Swift side, apply topY += 30 / h -= 30 manually, conditioned on v.height == target.frame.height.
if v.height == target.frame.height {
topY += 30
h -= 30
}
⑤ Transient errors (-10006 / -1728) recover naturally on retry
While a window is initializing or its name is changing, you get -10006 (invalid index) or -1728. If you insert delay 0.3 in an on error and fall through to the next pass, the convergence loop re-scans that same window on the following pass and picks it up. The key is not to abort immediately on an error.
on error
delay 0.3 -- 過渡エラーは次パスで再試行
end try
Usage and verification
~/.claude/scripts/terminal-grid8.sh
One command aligns Terminal.app's 8 windows. You can change the number of divisions with just the two lines at the top.
COLS=4
ROWS=2
If you want 6 divisions, rewrite it to COLS=3 ROWS=2.
The final report prints each window's x,y WxH title. On a 3440-wide screen, success means x converges to the four values 0/860/1720/2580, and y converges to one value each for the top and bottom rows.
Wrap-up
-
Cocoa Y-up → global Y-down conversion is handled by the single Swift line
mainH - (v.origin.y + v.height)and handed to the shell - AppleScript's convergence loop repeats "move one window and re-scan" instead of a foreach. Drop name references and operate by index on every pass
- If you omit
delay 0.2,set positionis silently ignored - The y drift from Terminal's row-height rounding is normalized by a post-convergence "bottom-edge alignment by actual height" pass
- Because some screens don't reflect the menu bar in
visibleFrame, add a manual 30px correction conditioned onheight == frame.height
Next time, I'll write about an environment-alignment hook that fires on Claude Code startup using this script as a trigger — placing Terminal windows, the status line, and Obsidian into fixed positions all at once from launchd.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)