The App.async_apply_layout API you asked about is now in. It reshapes one or more tabs in a single call: swap panes, change splitter orientation, move sessions across tabs and windows, close sessions/tabs/windows. Validation happens up front so a malformed spec leaves your workspace untouched.
Getting set up
You'll need:
2. iterm2 Python module 2.17 or later. Once tomorrow's nightly is out, pip3 install --upgrade iterm2 will pick it up.
How it works
You build a spec (a plain Python dict) describing the target state, then call await app.async_apply_layout(spec).
spec = {
"tabs": [{"tab_id": "...", "root": <node>}, ...],
"close_sessions": ["<session-guid>", ...],
"close_tabs": ["<tab-id>", ...],
"close_windows": ["<window-guid>", ...],
}
A <node> is either a leaf:
{"session_id": "<session-guid>"}
…or a splitter with at least two children:
{"vertical": True, "children": [<node>, <node>, ...]}
Sessions, tabs, and windows are referred to by the same identifiers the rest of the Python API uses (session.session_id, tab.tab_id, window.window_id).
Working example
This script registers three RPCs you can bind to keystrokes via Prefs > Keys > Invoke Script Function:
#!/usr/bin/env python3.7
import iterm2
def leaf(session):
return {"session_id": session.session_id}
def vrow(sessions):
"""One leaf if a single session, otherwise a vertical-divider row."""
if len(sessions) == 1:
return leaf(sessions[0])
return {"vertical": True,
"children": [leaf(s) for s in sessions]}
async def main(connection):
app = await iterm2.async_get_app(connection)
@iterm2.RPC
async def swap_first_two_panes():
tab = app.current_terminal_window.current_tab
if len(tab.sessions) < 2:
return
a, b = tab.sessions[0], tab.sessions[1]
spec = {"tabs": [{
"tab_id": tab.tab_id,
"root": {"vertical": True,
"children": [leaf(b), leaf(a)] +
[leaf(s) for s in tab.sessions[2:]]},
}]}
await app.async_apply_layout(spec)
await swap_first_two_panes.async_register(connection)
@iterm2.RPC
async def move_active_pane_to_next_tab():
window = app.current_terminal_window
tabs = window.tabs
if len(tabs) < 2:
return
src = window.current_tab
i = next(idx for idx, t in enumerate(tabs)
if t.tab_id == src.tab_id)
dst = tabs[(i + 1) % len(tabs)]
active = src.current_session
if active is None:
return
remaining = [s for s in src.sessions
if s.session_id != active.session_id]
spec = {"tabs": []}
if remaining:
spec["tabs"].append({"tab_id": src.tab_id, "root": vrow(remaining)})
spec["tabs"].append({
"tab_id": dst.tab_id,
"root": vrow(list(dst.sessions) + [active]),
})
# If src loses its last pane, apply_layout auto-closes it.
await app.async_apply_layout(spec)
await move_active_pane_to_next_tab.async_register(connection)
@iterm2.RPC
async def move_active_pane_to_other_window():
windows = app.terminal_windows
if len(windows) < 2:
return
src_window = app.current_terminal_window
i = next(idx for idx, w in enumerate(windows)
if w.window_id == src_window.window_id)
dst_window = windows[(i + 1) % len(windows)]
src_tab = src_window.current_tab
dst_tab = dst_window.current_tab
active = src_tab.current_session
if active is None:
return
remaining = [s for s in src_tab.sessions
if s.session_id != active.session_id]
spec = {"tabs": []}
if remaining:
spec["tabs"].append({"tab_id": src_tab.tab_id,
"root": vrow(remaining)})
spec["tabs"].append({
"tab_id": dst_tab.tab_id,
"root": vrow(list(dst_tab.sessions) + [active]),
})
await app.async_apply_layout(spec)
await move_active_pane_to_other_window.async_register(connection)
iterm2.run_forever(main)
Save it under ~/Library/Application Support/iTerm2/Scripts/ and launch from the Scripts menu.
Things to know
- Atomicity. The spec is fully validated before any mutation begins; malformed specs are rejected with no side effects. If a per-tab mutation throws partway through (rare
— usually a race where a tab disappeared between validation and execution), already-applied changes stay applied. There is no rollback.
- Auto-close. When a tab loses its last session as a side effect of a session moving away, the tab closes automatically — no need to list it in close_tabs. Same for
windows whose last tab goes away.
- Cross-tab and cross-window moves are the same operation. Both are expressed as a session GUID appearing in a different tab's root than where it currently lives. apply_layout figures out the detach/reattach.
- Apply-then-close ordering. The new layout is applied first, then close_sessions runs against the result. So a session may legally appear in both a tab's root and close_sessions — it'll be placed in the tree by the reshape, then terminated immediately after.
- Splitter rules. Splitters need at least two children, and you can't nest a splitter of the same orientation as its parent (flatten it instead). Both are validation errors.
- Out of scope (for now). Inline session creation (new_session leaves), new_tabs, and new_windows are not supported — apply_layout only reshapes around live sessions. Use
the existing Window.async_create / Window.async_create_tab / Session.async_split_pane to make new things, then call apply_layout to arrange them.
- tmux integration tabs are not supported — tmux owns its own layout server-side.
Reach out if you hit anything unexpected.