← back to Iterm Project Grouper

iterm-regroup.py

112 lines

#!/usr/bin/env python3
"""
iterm-regroup.py — (Option C) iTerm2 Python API tool to read the AUTHORITATIVE
project grouping of all live sessions and, where the platform allows, assist
regrouping.

Run AFTER enabling the API: iTerm2 → Settings → General → Magic →
"Enable Python API" (it prompts to allow on first connect). Requires a
restart of iTerm2 to take effect (which ends current sessions — do it only
when they can be dropped).

    python3 iterm-regroup.py --plan     # print the authoritative grouping (stable window ids)
    python3 iterm-regroup.py --apply    # attempt to regroup (see the honest note below)

HONEST NOTE ON --apply
----------------------
iTerm2 does NOT expose moving an existing tab from one window to another —
neither in AppleScript nor (as of this writing) in the Python API. The API can
CREATE windows/tabs, split panes, activate, run commands, and read variables,
but there is no supported "reparent this live tab into that window."

Consequences:
  * --plan is fully supported and reliable (reads each session's shell `path`).
  * --apply cannot MOVE live tabs without recreating (killing) the sessions,
    which this tool will NOT do. If a future iTerm2 build adds a tab-move API,
    wire it into `move_tab_into()` below and flip SUPPORTS_MOVE = True.

The durable, non-destructive answer is the `claude-proj` launcher (Option B):
new project sessions open as tabs inside their project's window from the start.
"""
import sys, os, subprocess, collections

SUPPORTS_MOVE = False  # flip to True only when a real tab-move API is wired in

def git_root(path):
    try:
        r = subprocess.run(["git", "-C", path, "rev-parse", "--show-toplevel"],
                           capture_output=True, text=True)
        return r.stdout.strip() or path
    except Exception:
        return path

async def main(connection):
    import iterm2
    app = await iterm2.async_get_app(connection)
    groups = collections.defaultdict(list)  # project -> [(window_id, tab_id, path)]
    for window in app.terminal_windows:
        for tab in window.tabs:
            # a tab's project = its (active) session's shell path
            sess = tab.current_session or (tab.sessions[0] if tab.sessions else None)
            path = ""
            if sess:
                try:
                    path = await sess.async_get_variable("path") or ""
                except Exception:
                    path = ""
            proj = os.path.basename(git_root(path)) if path else "(no path)"
            groups[proj].append((window.window_id, tab.tab_id, path))

    print("=== AUTHORITATIVE grouping (iTerm2 Python API) ===")
    for proj, items in sorted(groups.items(), key=lambda kv: -len(kv[1])):
        wins = sorted({w for (w, _, _) in items})
        print(f"{len(items):2d} tab(s) / {len(wins)} window(s)  {proj}")
        for (w, t, p) in items:
            print(f"      window={w} tab={t}  {p}")

    if "--apply" in sys.argv:
        multi = {p: it for p, it in groups.items()
                 if p != "(no path)" and len({w for (w, _, _) in it}) > 1}
        print("\n--apply requested.")
        if not SUPPORTS_MOVE:
            print("iTerm2 exposes no supported tab-move-between-windows API, so a")
            print("NON-DESTRUCTIVE regroup of existing sessions is not possible.")
            print("Projects that are currently split across windows:")
            for p, it in multi.items():
                print(f"  {p}: windows {sorted({w for (w,_,_) in it})}")
            print("\nUse the `claude-proj` launcher for all NEW sessions (Option B),")
            print("or drag these tabs together manually. This tool will not recreate")
            print("(kill) live sessions to force a regroup.")
        else:
            for p, it in multi.items():
                await regroup_project(app, p, it)

async def move_tab_into(app, tab, target_window):
    """Placeholder for a future iTerm2 tab-move API. Not currently supported."""
    raise NotImplementedError("iTerm2 has no supported move-tab-between-windows API")

async def regroup_project(app, proj, items):
    wins = sorted({w for (w, _, _) in items})
    target = app.get_window_by_id(wins[0])
    for (w, t, _) in items:
        if w == wins[0]:
            continue
        tab = None
        for win in app.terminal_windows:
            for tb in win.tabs:
                if tb.tab_id == t:
                    tab = tb
        if tab:
            await move_tab_into(app, tab, target)

if __name__ == "__main__":
    if not any(a in sys.argv for a in ("--plan", "--apply")):
        print(__doc__); sys.exit(0)
    try:
        import iterm2
    except ModuleNotFoundError:
        sys.exit("iterm2 module not found. Enable the API and run with iTerm2's "
                 "python3, e.g.:  ~/Library/ApplicationSupport/iTerm2/iterm2env/versions/*/bin/python3 "
                 "iterm-regroup.py --plan")
    iterm2.run_until_complete(main)