Fix claude-teams pane anchoring with main-vertical layout (#2119)
* Fix claude-teams pane anchoring: main-vertical layout + focus Claude's agent teams sends `split-window -h` for each teammate then `select-layout main-vertical` to stack them vertically. Three fixes: 1. Implement select-layout main-vertical: track layout state in the tmux compat store so subsequent horizontal splits of the leader pane get redirected to vertical splits of the right-side column. 2. Pass focus:false to surface.split from the split-window handler so internal bonsplit focus stays on the leader pane. 3. Fix tmuxCompatStoreURL to respect $HOME env var (was using NSString.expandingTildeInPath which ignores $HOME). Closes https://github.com/manaflow-ai/cmux/issues/2118 * Fix claude-teams split routing: auto-seed main-vertical on first right split The split-window routing was broken in two ways: 1. After the first teammate split (right), the main-vertical state wasn't being created, so all subsequent splits also went right instead of stacking down in the right column. 2. The old code only redirected splits when main-vertical was already active AND the target was the leader surface. Claude's teams protocol targets arbitrary panes from list-panes, not necessarily the leader. Now the first teammate split goes right (creating the column), auto-seeds the main-vertical state, and all subsequent splits stack downward. The caller's surface (CMUX_SURFACE_ID) is used as the anchor regardless of which pane Claude targets. Also adds caller surface preference in pane target resolution so the caller's exact surface is used when the target pane matches, preventing stale selected-surface references after tab switches. --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
parent
fe0443fa2b
commit
f507fd8141
3 changed files with 517 additions and 8 deletions
102
CLI/cmux.swift
102
CLI/cmux.swift
|
|
@ -9015,6 +9015,18 @@ struct CMUXCLI {
|
|||
) throws -> (workspaceId: String, paneId: String?, surfaceId: String) {
|
||||
if tmuxPaneSelector(from: raw) != nil {
|
||||
let resolved = try tmuxResolvePaneTarget(raw, client: client)
|
||||
// When the target pane matches the caller's pane, prefer the caller's
|
||||
// exact surface (CMUX_SURFACE_ID) over the pane's currently selected
|
||||
// surface. The selected surface can change (e.g. tab switches) after
|
||||
// claude-teams started, but the caller surface stays fixed.
|
||||
let callerPane = tmuxCallerPaneHandle()
|
||||
let callerSurface = tmuxCallerSurfaceHandle()
|
||||
let canonicalCallerPane = callerPane.flatMap { try? tmuxCanonicalPaneId($0, workspaceId: resolved.workspaceId, client: client) }
|
||||
let paneMatch = callerPane != nil && (resolved.paneId == callerPane! || resolved.paneId == canonicalCallerPane)
|
||||
let canonicalSurface = callerSurface.flatMap { try? tmuxCanonicalSurfaceId($0, workspaceId: resolved.workspaceId, client: client) }
|
||||
if paneMatch, let surfaceId = canonicalSurface {
|
||||
return (resolved.workspaceId, resolved.paneId, surfaceId)
|
||||
}
|
||||
let surfaceId = try tmuxSelectedSurfaceId(
|
||||
workspaceId: resolved.workspaceId,
|
||||
paneId: resolved.paneId,
|
||||
|
|
@ -9548,23 +9560,62 @@ struct CMUXCLI {
|
|||
valueFlags: ["-c", "-F", "-l", "-t"],
|
||||
boolFlags: ["-P", "-b", "-d", "-h", "-v"]
|
||||
)
|
||||
let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
|
||||
let direction: String
|
||||
var target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
|
||||
var direction: String
|
||||
if parsed.hasFlag("-h") {
|
||||
direction = parsed.hasFlag("-b") ? "left" : "right"
|
||||
} else {
|
||||
direction = parsed.hasFlag("-b") ? "up" : "down"
|
||||
}
|
||||
|
||||
// Claude's agent teams targets arbitrary panes (from list-panes),
|
||||
// not necessarily the leader pane from TMUX_PANE. Override the
|
||||
// target to anchor all teammate splits to the leader surface.
|
||||
let store = loadTmuxCompatStore()
|
||||
if let callerSurface = tmuxCallerSurfaceHandle(),
|
||||
let callerWorkspace = tmuxCallerWorkspaceHandle() {
|
||||
let wsId = (try? resolveWorkspaceId(callerWorkspace, client: client)) ?? target.workspaceId
|
||||
if let mvState = store.mainVerticalLayouts[wsId],
|
||||
let lastColumn = mvState.lastColumnSurfaceId {
|
||||
// Main-vertical active: stack in right column.
|
||||
target = (wsId, nil, lastColumn)
|
||||
direction = "down"
|
||||
} else {
|
||||
// First teammate: split the leader surface to the right.
|
||||
target = (wsId, nil, callerSurface)
|
||||
direction = "right"
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the leader pane focused while Claude starts teammates beside it.
|
||||
let created = try client.sendV2(method: "surface.split", params: [
|
||||
"workspace_id": target.workspaceId,
|
||||
"surface_id": target.surfaceId,
|
||||
"direction": direction
|
||||
"direction": direction,
|
||||
"focus": false
|
||||
])
|
||||
guard let surfaceId = created["surface_id"] as? String else {
|
||||
throw CLIError(message: "surface.split did not return surface_id")
|
||||
}
|
||||
let paneId = created["pane_id"] as? String
|
||||
// Keep the leader pane focused while Claude starts teammates beside it.
|
||||
|
||||
// Track the newly created pane for main-vertical layout.
|
||||
do {
|
||||
var updatedStore = loadTmuxCompatStore()
|
||||
updatedStore.lastSplitSurface[target.workspaceId] = surfaceId
|
||||
if updatedStore.mainVerticalLayouts[target.workspaceId] != nil {
|
||||
updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId
|
||||
} else if direction == "right", let callerSurface = tmuxCallerSurfaceHandle() {
|
||||
// First right split created the column; seed main-vertical
|
||||
// state so subsequent splits stack downward.
|
||||
updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState(
|
||||
mainSurfaceId: callerSurface,
|
||||
lastColumnSurfaceId: surfaceId
|
||||
)
|
||||
}
|
||||
try saveTmuxCompatStore(updatedStore)
|
||||
}
|
||||
|
||||
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
|
||||
_ = try client.sendV2(method: "surface.send_text", params: [
|
||||
"workspace_id": target.workspaceId,
|
||||
|
|
@ -9785,7 +9836,27 @@ struct CMUXCLI {
|
|||
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
|
||||
_ = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
|
||||
|
||||
case "select-layout", "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
|
||||
case "select-layout":
|
||||
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
|
||||
let layoutName = parsed.positional.first ?? ""
|
||||
if layoutName == "main-vertical" {
|
||||
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
|
||||
if let callerSurface = tmuxCallerSurfaceHandle() {
|
||||
var store = loadTmuxCompatStore()
|
||||
// Seed lastColumnSurfaceId from the most recent split if
|
||||
// this is the first time main-vertical is set and a split
|
||||
// already happened (the normal flow: split then layout).
|
||||
let existingColumn = store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId
|
||||
let seedColumn = existingColumn ?? store.lastSplitSurface[workspaceId]
|
||||
store.mainVerticalLayouts[workspaceId] = MainVerticalState(
|
||||
mainSurfaceId: callerSurface,
|
||||
lastColumnSurfaceId: seedColumn
|
||||
)
|
||||
try saveTmuxCompatStore(store)
|
||||
}
|
||||
}
|
||||
|
||||
case "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
|
||||
return
|
||||
|
||||
default:
|
||||
|
|
@ -9793,14 +9864,31 @@ struct CMUXCLI {
|
|||
}
|
||||
}
|
||||
|
||||
private struct MainVerticalState: Codable {
|
||||
/// The surface ID of the "main" (leader) pane on the left side.
|
||||
var mainSurfaceId: String
|
||||
/// The surface ID of the bottom-most pane in the right column.
|
||||
/// Subsequent teammate splits target this pane with direction "down".
|
||||
var lastColumnSurfaceId: String?
|
||||
}
|
||||
|
||||
private struct TmuxCompatStore: Codable {
|
||||
var buffers: [String: String] = [:]
|
||||
var hooks: [String: String] = [:]
|
||||
/// Tracks main-vertical layout state per workspace, keyed by workspace ID.
|
||||
var mainVerticalLayouts: [String: MainVerticalState] = [:]
|
||||
/// Tracks the last surface created by split-window per workspace.
|
||||
/// Used to seed lastColumnSurfaceId when select-layout main-vertical
|
||||
/// is called after the first split.
|
||||
var lastSplitSurface: [String: String] = [:]
|
||||
}
|
||||
|
||||
private func tmuxCompatStoreURL() -> URL {
|
||||
let root = NSString(string: "~/.cmuxterm").expandingTildeInPath
|
||||
return URL(fileURLWithPath: root).appendingPathComponent("tmux-compat-store.json")
|
||||
let homePath = ProcessInfo.processInfo.environment["HOME"]
|
||||
?? NSString(string: "~").expandingTildeInPath
|
||||
return URL(fileURLWithPath: homePath)
|
||||
.appendingPathComponent(".cmuxterm")
|
||||
.appendingPathComponent("tmux-compat-store.json")
|
||||
}
|
||||
|
||||
private func loadTmuxCompatStore() -> TmuxCompatStore {
|
||||
|
|
|
|||
|
|
@ -4565,7 +4565,8 @@ class TerminalController {
|
|||
v2MaybeFocusWindow(for: tabManager)
|
||||
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
||||
|
||||
if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) {
|
||||
let focus = v2Bool(params, "focus") ?? true
|
||||
if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {
|
||||
let paneUUID = ws.paneId(forPanelId: newId)?.id
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
|
|
|
|||
420
tests/test_cli_claude_teams_main_vertical.py
Normal file
420
tests/test_cli_claude_teams_main_vertical.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: `cmux claude-teams` main-vertical layout stacks teammates
|
||||
vertically in a right-side column instead of creating nested horizontal splits.
|
||||
|
||||
Simulates Claude creating 3 teammates:
|
||||
1. split-window -h (right of leader)
|
||||
2. select-layout main-vertical
|
||||
3. split-window -h (should redirect to vertical split of T1)
|
||||
4. select-layout main-vertical
|
||||
5. split-window -h (should redirect to vertical split of T2)
|
||||
6. select-layout main-vertical
|
||||
|
||||
Expected layout:
|
||||
[Leader] [T1]
|
||||
[T2]
|
||||
[T3]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from claude_teams_test_utils import resolve_cmux_cli
|
||||
|
||||
INITIAL_WORKSPACE_ID = "11111111-1111-4111-8111-111111111111"
|
||||
INITIAL_WINDOW_ID = "22222222-2222-4222-8222-222222222222"
|
||||
INITIAL_PANE_ID = "33333333-3333-4333-8333-333333333333"
|
||||
INITIAL_SURFACE_ID = "44444444-4444-4444-8444-444444444444"
|
||||
INITIAL_TAB_ID = "55555555-5555-4555-8555-555555555555"
|
||||
|
||||
# IDs for dynamically created teammate panes
|
||||
TEAMMATE_PANE_IDS = [
|
||||
"aa000001-0001-4001-8001-000000000001",
|
||||
"aa000002-0002-4002-8002-000000000002",
|
||||
"aa000003-0003-4003-8003-000000000003",
|
||||
]
|
||||
TEAMMATE_SURFACE_IDS = [
|
||||
"bb000001-0001-4001-8001-000000000001",
|
||||
"bb000002-0002-4002-8002-000000000002",
|
||||
"bb000003-0003-4003-8003-000000000003",
|
||||
]
|
||||
|
||||
|
||||
def make_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
class FakeCmuxState:
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.requests: list[str] = []
|
||||
self.split_calls: list[dict] = []
|
||||
self.split_counter = 0
|
||||
self.workspace = {
|
||||
"id": INITIAL_WORKSPACE_ID,
|
||||
"ref": "workspace:1",
|
||||
"index": 1,
|
||||
"title": "demo-team",
|
||||
}
|
||||
self.window = {"id": INITIAL_WINDOW_ID, "ref": "window:1"}
|
||||
self.current_pane_id = INITIAL_PANE_ID
|
||||
self.current_surface_id = INITIAL_SURFACE_ID
|
||||
self.panes = [
|
||||
{
|
||||
"id": INITIAL_PANE_ID,
|
||||
"ref": "pane:1",
|
||||
"index": 7,
|
||||
"surface_ids": [INITIAL_SURFACE_ID],
|
||||
}
|
||||
]
|
||||
self.surfaces = [
|
||||
{
|
||||
"id": INITIAL_SURFACE_ID,
|
||||
"ref": "surface:1",
|
||||
"pane_id": INITIAL_PANE_ID,
|
||||
"title": "leader",
|
||||
}
|
||||
]
|
||||
|
||||
def handle(self, method: str, params: dict) -> dict:
|
||||
with self.lock:
|
||||
self.requests.append(method)
|
||||
|
||||
if method == "system.identify":
|
||||
return {
|
||||
"socket_path": str(params.get("socket_path", "")),
|
||||
"focused": {
|
||||
"workspace_id": self.workspace["id"],
|
||||
"workspace_ref": self.workspace["ref"],
|
||||
"window_id": self.window["id"],
|
||||
"window_ref": self.window["ref"],
|
||||
"pane_id": self.current_pane_id,
|
||||
"pane_ref": self._pane_ref(self.current_pane_id),
|
||||
"surface_id": self.current_surface_id,
|
||||
"surface_ref": self._surface_ref(self.current_surface_id),
|
||||
"tab_id": INITIAL_TAB_ID,
|
||||
"tab_ref": "tab:1",
|
||||
"surface_type": "terminal",
|
||||
"is_browser_surface": False,
|
||||
},
|
||||
}
|
||||
if method == "workspace.current":
|
||||
return {
|
||||
"workspace_id": self.workspace["id"],
|
||||
"workspace_ref": self.workspace["ref"],
|
||||
}
|
||||
if method == "workspace.list":
|
||||
return {
|
||||
"workspaces": [
|
||||
{
|
||||
"id": self.workspace["id"],
|
||||
"ref": self.workspace["ref"],
|
||||
"index": self.workspace["index"],
|
||||
"title": self.workspace["title"],
|
||||
}
|
||||
]
|
||||
}
|
||||
if method == "window.list":
|
||||
return {
|
||||
"windows": [
|
||||
{
|
||||
"id": self.window["id"],
|
||||
"ref": self.window["ref"],
|
||||
"workspace_id": self.workspace["id"],
|
||||
"workspace_ref": self.workspace["ref"],
|
||||
}
|
||||
]
|
||||
}
|
||||
if method == "pane.list":
|
||||
return {
|
||||
"panes": [
|
||||
{"id": p["id"], "ref": p["ref"], "index": p["index"]}
|
||||
for p in self.panes
|
||||
]
|
||||
}
|
||||
if method == "pane.surfaces":
|
||||
pane_id = str(params.get("pane_id") or "")
|
||||
pane = self._pane_by_id(pane_id)
|
||||
return {
|
||||
"surfaces": [
|
||||
{
|
||||
"id": sid,
|
||||
"selected": sid == self._selected_surface_for_pane(pane),
|
||||
}
|
||||
for sid in pane["surface_ids"]
|
||||
]
|
||||
}
|
||||
if method == "surface.current":
|
||||
return {
|
||||
"workspace_id": self.workspace["id"],
|
||||
"workspace_ref": self.workspace["ref"],
|
||||
"pane_id": self.current_pane_id,
|
||||
"pane_ref": self._pane_ref(self.current_pane_id),
|
||||
"surface_id": self.current_surface_id,
|
||||
"surface_ref": self._surface_ref(self.current_surface_id),
|
||||
}
|
||||
if method == "surface.list":
|
||||
return {
|
||||
"surfaces": [
|
||||
{
|
||||
"id": s["id"],
|
||||
"ref": s["ref"],
|
||||
"title": s["title"],
|
||||
"pane_id": s["pane_id"],
|
||||
"pane_ref": self._pane_ref(s["pane_id"]),
|
||||
}
|
||||
for s in self.surfaces
|
||||
]
|
||||
}
|
||||
if method == "surface.split":
|
||||
idx = self.split_counter
|
||||
if idx >= len(TEAMMATE_PANE_IDS):
|
||||
raise RuntimeError(f"Too many splits: {idx}")
|
||||
new_pane_id = TEAMMATE_PANE_IDS[idx]
|
||||
new_surface_id = TEAMMATE_SURFACE_IDS[idx]
|
||||
self.split_counter += 1
|
||||
|
||||
self.split_calls.append({
|
||||
"surface_id": str(params.get("surface_id", "")),
|
||||
"direction": str(params.get("direction", "")),
|
||||
"focus": params.get("focus"),
|
||||
})
|
||||
|
||||
self.panes.append({
|
||||
"id": new_pane_id,
|
||||
"ref": f"pane:{idx + 2}",
|
||||
"index": 8 + idx,
|
||||
"surface_ids": [new_surface_id],
|
||||
})
|
||||
self.surfaces.append({
|
||||
"id": new_surface_id,
|
||||
"ref": f"surface:{idx + 2}",
|
||||
"pane_id": new_pane_id,
|
||||
"title": f"teammate-{idx + 1}",
|
||||
})
|
||||
return {
|
||||
"surface_id": new_surface_id,
|
||||
"pane_id": new_pane_id,
|
||||
}
|
||||
if method == "surface.focus":
|
||||
self.current_surface_id = str(
|
||||
params.get("surface_id") or self.current_surface_id
|
||||
)
|
||||
surface = self._surface_by_id(self.current_surface_id)
|
||||
self.current_pane_id = surface["pane_id"]
|
||||
return {"ok": True}
|
||||
if method == "pane.resize":
|
||||
return {"ok": True}
|
||||
if method == "surface.send_text":
|
||||
return {"ok": True}
|
||||
raise RuntimeError(f"Unsupported fake cmux method: {method}")
|
||||
|
||||
def _pane_by_id(self, pane_id: str) -> dict:
|
||||
for p in self.panes:
|
||||
if p["id"] == pane_id or p["ref"] == pane_id:
|
||||
return p
|
||||
raise RuntimeError(f"Unknown pane id: {pane_id}")
|
||||
|
||||
def _surface_by_id(self, surface_id: str) -> dict:
|
||||
for s in self.surfaces:
|
||||
if s["id"] == surface_id or s["ref"] == surface_id:
|
||||
return s
|
||||
raise RuntimeError(f"Unknown surface id: {surface_id}")
|
||||
|
||||
def _pane_ref(self, pane_id: str) -> str:
|
||||
return self._pane_by_id(pane_id)["ref"]
|
||||
|
||||
def _surface_ref(self, surface_id: str) -> str:
|
||||
return self._surface_by_id(surface_id)["ref"]
|
||||
|
||||
def _selected_surface_for_pane(self, pane: dict) -> str:
|
||||
sids = pane["surface_ids"]
|
||||
return sids[0] if sids else ""
|
||||
|
||||
|
||||
class FakeCmuxUnixServer(socketserver.ThreadingUnixStreamServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, socket_path: str, state: FakeCmuxState) -> None:
|
||||
self.state = state
|
||||
super().__init__(socket_path, FakeCmuxHandler)
|
||||
|
||||
|
||||
class FakeCmuxHandler(socketserver.StreamRequestHandler):
|
||||
def handle(self) -> None:
|
||||
while True:
|
||||
line = self.rfile.readline()
|
||||
if not line:
|
||||
return
|
||||
request = json.loads(line.decode("utf-8"))
|
||||
response = {
|
||||
"ok": True,
|
||||
"result": self.server.state.handle(
|
||||
request["method"],
|
||||
request.get("params", {}),
|
||||
),
|
||||
"id": request.get("id"),
|
||||
}
|
||||
self.wfile.write((json.dumps(response) + "\n").encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-mv-") as td:
|
||||
tmp = Path(td)
|
||||
home = tmp / "home"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
socket_path = tmp / "fake-cmux.sock"
|
||||
state = FakeCmuxState()
|
||||
server = FakeCmuxUnixServer(str(socket_path), state)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
real_bin = tmp / "real-bin"
|
||||
real_bin.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Fake claude binary that creates 3 teammates using the same flow
|
||||
# Claude uses: split-window -h, select-layout main-vertical, repeat.
|
||||
make_executable(
|
||||
real_bin / "claude",
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Get window target (session:window_index)
|
||||
window_target="$(tmux display-message -t "${TMUX_PANE}" -p '#{session_name}:#{window_index}')"
|
||||
|
||||
# Teammate 1: horizontal split right
|
||||
t1="$(tmux split-window -t "${TMUX_PANE}" -h -l 70% -P -F '#{pane_id}')"
|
||||
tmux select-layout -t "$window_target" main-vertical
|
||||
tmux resize-pane -t "${TMUX_PANE}" -x 30%
|
||||
|
||||
# Teammate 2: horizontal split right (should redirect to vertical)
|
||||
t2="$(tmux split-window -t "${TMUX_PANE}" -h -l 70% -P -F '#{pane_id}')"
|
||||
tmux select-layout -t "$window_target" main-vertical
|
||||
tmux resize-pane -t "${TMUX_PANE}" -x 30%
|
||||
|
||||
# Teammate 3: horizontal split right (should redirect to vertical)
|
||||
t3="$(tmux split-window -t "${TMUX_PANE}" -h -l 70% -P -F '#{pane_id}')"
|
||||
tmux select-layout -t "$window_target" main-vertical
|
||||
tmux resize-pane -t "${TMUX_PANE}" -x 30%
|
||||
|
||||
# Write results for verification
|
||||
printf '%s\\n%s\\n%s\\n' "$t1" "$t2" "$t3" > "$RESULT_LOG"
|
||||
""",
|
||||
)
|
||||
|
||||
result_log = tmp / "result.log"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home)
|
||||
env["PATH"] = f"{real_bin}:/usr/bin:/bin"
|
||||
env["CMUX_SOCKET_PATH"] = str(socket_path)
|
||||
env["RESULT_LOG"] = str(result_log)
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[cli_path, "claude-teams", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("FAIL: timed out")
|
||||
return 1
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
if proc.returncode != 0:
|
||||
print(f"FAIL: exit={proc.returncode}")
|
||||
print(f"stdout={proc.stdout.strip()}")
|
||||
print(f"stderr={proc.stderr.strip()}")
|
||||
return 1
|
||||
|
||||
# Verify split calls
|
||||
if len(state.split_calls) != 3:
|
||||
print(f"FAIL: expected 3 splits, got {len(state.split_calls)}")
|
||||
for i, call in enumerate(state.split_calls):
|
||||
print(f" split[{i}]: {call}")
|
||||
return 1
|
||||
|
||||
# Split 1: should be a normal right split of the leader surface
|
||||
s1 = state.split_calls[0]
|
||||
if s1["direction"] != "right":
|
||||
print(f"FAIL: split[0] expected direction=right, got {s1['direction']}")
|
||||
return 1
|
||||
if s1["surface_id"] != INITIAL_SURFACE_ID:
|
||||
print(
|
||||
f"FAIL: split[0] expected surface_id={INITIAL_SURFACE_ID}, "
|
||||
f"got {s1['surface_id']}"
|
||||
)
|
||||
return 1
|
||||
|
||||
# Split 2: should be redirected to a DOWN split of T1's surface
|
||||
s2 = state.split_calls[1]
|
||||
if s2["direction"] != "down":
|
||||
print(
|
||||
f"FAIL: split[1] expected direction=down (main-vertical redirect), "
|
||||
f"got {s2['direction']}"
|
||||
)
|
||||
return 1
|
||||
if s2["surface_id"] != TEAMMATE_SURFACE_IDS[0]:
|
||||
print(
|
||||
f"FAIL: split[1] expected surface_id={TEAMMATE_SURFACE_IDS[0]} "
|
||||
f"(T1), got {s2['surface_id']}"
|
||||
)
|
||||
return 1
|
||||
|
||||
# Split 3: should be redirected to a DOWN split of T2's surface
|
||||
s3 = state.split_calls[2]
|
||||
if s3["direction"] != "down":
|
||||
print(
|
||||
f"FAIL: split[2] expected direction=down (main-vertical redirect), "
|
||||
f"got {s3['direction']}"
|
||||
)
|
||||
return 1
|
||||
if s3["surface_id"] != TEAMMATE_SURFACE_IDS[1]:
|
||||
print(
|
||||
f"FAIL: split[2] expected surface_id={TEAMMATE_SURFACE_IDS[1]} "
|
||||
f"(T2), got {s3['surface_id']}"
|
||||
)
|
||||
return 1
|
||||
|
||||
# All splits should have focus=false
|
||||
for i, call in enumerate(state.split_calls):
|
||||
if call["focus"] is not False:
|
||||
print(f"FAIL: split[{i}] expected focus=false, got {call['focus']}")
|
||||
return 1
|
||||
|
||||
# Focus should remain on leader
|
||||
if state.current_pane_id != INITIAL_PANE_ID:
|
||||
print(
|
||||
f"FAIL: focus moved from leader pane to {state.current_pane_id}"
|
||||
)
|
||||
return 1
|
||||
|
||||
print("PASS: main-vertical layout stacks teammates vertically")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue