「Claude Code環境」シリーズです。前回の環境劣化センチネルでClaude Code環境の自己診断を書きましたが、今回は別の作業コストの話です。
ウルトラワイドモニタ上に Terminal.app の窓が 8 枚散乱している状態を、コマンド一発で 4×2 グリッドに整列させます。「osascript で窓を並べるだけ」に見えて、座標系の変換・AppleScript の非同期挙動・macOS の行丸めという 3 つのレイヤーに罠があります。この記事では設計の 2 つの核心と、実装でぶつかった 5 つの罠を実コードで解説します。
困りごと:ドラッグ整列に 1 分かかっていた
3440×1440 のウルトラワイドで複数の Claude Code セッションを並べると、窓は毎回バラバラの位置に開きます。フルスクリーン分割では足りない。Mission Control では位置が固定できない。結局マウスで 8 枚をドラッグして 4×2 に揃えるのに毎朝 1 分かけていました。
AppleScript で position と size を直書きすれば終わりのはずでした。が、実際に書き始めると 3 つのことが分かりました。
- 座標系が 2 つある(Cocoa の Y 上向きとスクリーン座標の Y 下向き)
-
set positionが無音で無視される状況がある - 窓の高さが指定値にならない
これらをまとめて解決するスクリプト(~/.claude/scripts/terminal-grid8.sh)の設計を書きます。
設計の核心①:Swift で visibleFrame をグローバル座標へ変換してシェルに渡す
AppleScript は position of window でグローバル座標(左上原点、Y 下向き)を返します。一方 macOS の NSScreen.visibleFrame は Cocoa 座標系(Y 上向き)です。これを混在させると計算がおかしくなります。変換を Swift でまとめて済ませ、シェル変数 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"
mainH - (v.origin.y + v.height) の変換式がポイントです。Cocoa の visibleFrame.origin.y はメイン画面下端からの距離(Y 上向き)なので、グローバルの「左上からの距離(Y 下向き)」にするには、メイン画面の高さから引き算します。
if v.height == target.frame.height の分岐は実測で出てきたフォールバックです。メインでない画面では visibleFrame がメニューバー 30px を差し引かずフレームと同値を返すことがあります。この場合は Swift 側で手動で引きます(後述の罠④)。
セル寸法は単純な整数除算です。
CW=$(( GW / COLS )) # 列幅
CH=$(( GH / ROWS )) # 行高
設計の核心②:AppleScript の収束ループ
名前でも ID でもなく ループパスごとに index を再スキャンするのが収束ループの骨子です。
-- 収束ループ: 毎パス再スキャンして未配置の窓を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
「forEach で全窓を一気に動かす」ではなく「未配置が残る限り 1 枚だけ動かして再スキャン」を繰り返す設計にしている理由は 2 つあります。
- 名前参照が使えない(罠①)のでパスをまたいでオブジェクト参照を保持できない
- 1 枚動かすとセルの占有状態が変わるのでスキャンをやり直す必要がある
最悪ケース(8 窓が全部外れている)でも nCells * 3 = 24 パスあれば収束します。
delay 0.2 を抜くと set position が無音で無視されます(罠②)。AppleScript 側のエラーも出ません。size → delay → position → delay → size の 5 ステップと、読み返して再補正するフォールバックが必要です。
収束ループの後に、最下段の y を実高で統一する正規化パスが入ります。
-- 最下段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
踏んだ落とし穴
① 窓の名前参照はスピナー回転で失効する
Terminal.app はタブのロード中にタイトルをスピナー文字(⠐⠂✳)で置き換えます。セッション開始直後に名前で窓を参照すると -1728(object not found)が返ります。名前による参照をやめ、毎パス window i の index で直接操作します。
② size 直後の position 設定は delay なしで無視される
set position of window i to {tx, ty} をリサイズ直後に置くと、macOS のウィンドウサーバが前のリサイズ処理を完了させる前に座標変更が届いて捨てられます。delay 0.2 を 2 箇所(size 後・position 後)に入れてから読み返し補正します。
③ Terminal の行高丸めでセル高が指定値にならない
Terminal.app は窓の高さを「行数×行高」に丸めます。例えばセル高を 705px に指定しても実際は 44行×16px=704px や 45行×16px=720px になります。この誤差が macOS の境界クランプと組み合わさり、最下段の窓の y が数 px ずれてセル一致判定が通らなくなります。収束ループの後に「実高で下端揃え」の正規化パスを走らせて吸収します。
④ visibleFrame がメニューバー分を引かないことがある
メインでない画面では NSScreen.visibleFrame が NSScreen.frame と同じ値を返す場合があります(実測)。この場合、計算上のグリッド上端がメニューバーに重なります。Swift 側で v.height == target.frame.height を条件に topY += 30 / h -= 30 を手動適用します。
if v.height == target.frame.height {
topY += 30
h -= 30
}
⑤ 過渡エラー(-10006/-1728)は再試行で自然回復する
窓の初期化中や名前変化のタイミングで -10006(invalid index)や -1728 が飛びます。on error で delay 0.3 を挟んで次パスへ抜けると、収束ループが次のパスで同じ窓をスキャンし直して回収します。エラーを即 abort にしないのがポイントです。
on error
delay 0.3 -- 過渡エラーは次パスで再試行
end try
使い方と検証
~/.claude/scripts/terminal-grid8.sh
コマンド一発で Terminal.app の 8 窓が整列します。分割数は冒頭の 2 行だけで変更できます。
COLS=4
ROWS=2
6 分割にしたければ COLS=3 ROWS=2 に書き換えます。
最終レポートに各窓の x,y WxH タイトル が出力されます。3440 幅の画面なら x が 0/860/1720/2580 の 4 値に揃い、y が上段・下段それぞれ 1 つの値に収束していれば成功です。
まとめ
-
Cocoa Y-up → グローバル Y-down 変換は
mainH - (v.origin.y + v.height)の Swift 一行で済ませてシェルに渡す - AppleScript の収束ループは foreach でなく「1 枚動かして再スキャン」の繰り返し。名前参照を捨て、毎パス index で操作する
-
delay 0.2は省略するとset positionが無音で無視される - Terminal の行高丸めによる y ずれは、収束後の「実高で下端揃え」パスで正規化する
-
visibleFrameがメニューバーを反映しない画面があるため、height == frame.heightの条件で手動 30px 補正を入れる
次回は、このスクリプトをトリガーにした Claude Code 起動時の環境整列フック ―― launchd から Terminal 窓・ステータスライン・Obsidian を一括で定位置に配置する話を書きます。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!