Fix tmux-compat split-window surface resolution (#2351)

* Add tmux-compat split-window ref regression tests

* Fix tmux-compat split-window surface resolution

* Fix stale tmux caller surface fallback

* Add stale tmux-compat split-window regressions

* Fix stale tmux-compat split-window anchors

* Preserve tmux fallback and column anchor
This commit is contained in:
Austin Wang 2026-03-30 03:28:25 -07:00 committed by GitHub
parent 867c93e4fa
commit 2c5c4fcf8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 870 additions and 68 deletions

View file

@ -1837,6 +1837,14 @@ struct CMUXCLI {
let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.close", params: params)
if let closedWorkspaceId = (payload["workspace_id"] as? String) ?? wsId,
let closedSurfaceId = (payload["surface_id"] as? String) ?? sfId {
try? tmuxPruneCompatSurfaceState(
workspaceId: closedWorkspaceId,
surfaceId: closedSurfaceId,
client: client
)
}
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "drag-surface-to-split":
@ -1979,6 +1987,9 @@ struct CMUXCLI {
let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "workspace.close", params: params)
if let closedWorkspaceId = (payload["workspace_id"] as? String) ?? wsId {
try? tmuxPruneCompatWorkspaceState(workspaceId: closedWorkspaceId)
}
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "select-workspace":
@ -8890,6 +8901,13 @@ struct CMUXCLI {
normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"])
}
private func tmuxResolvedCallerWorkspaceId(client: SocketClient) -> String? {
guard let callerWorkspace = tmuxCallerWorkspaceHandle() else {
return nil
}
return try? resolveWorkspaceId(callerWorkspace, client: client)
}
private func tmuxCanonicalPaneId(
_ handle: String,
workspaceId: String,
@ -8925,10 +8943,6 @@ struct CMUXCLI {
workspaceId: String,
client: SocketClient
) throws -> String {
if isUUID(handle) {
return handle
}
let payload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
for surface in surfaces {
@ -9040,7 +9054,7 @@ struct CMUXCLI {
let paneId: String
if let paneSelector {
paneId = try tmuxCanonicalPaneId(paneSelector, workspaceId: workspaceId, client: client)
} else if tmuxCallerWorkspaceHandle() == workspaceId,
} else if tmuxResolvedCallerWorkspaceId(client: client) == workspaceId,
let callerPane = tmuxCallerPaneHandle(),
let callerPaneId = try? tmuxCanonicalPaneId(callerPane, workspaceId: workspaceId, client: client) {
paneId = callerPaneId
@ -9084,8 +9098,13 @@ struct CMUXCLI {
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 {
if paneMatch,
let callerSurface,
let surfaceId = try? tmuxCanonicalSurfaceId(
callerSurface,
workspaceId: resolved.workspaceId,
client: client
) {
return (resolved.workspaceId, resolved.paneId, surfaceId)
}
let surfaceId = try tmuxSelectedSurfaceId(
@ -9098,15 +9117,64 @@ struct CMUXCLI {
let workspaceId = try tmuxResolveWorkspaceTarget(tmuxWindowSelector(from: raw), client: client)
if tmuxWindowSelector(from: raw) == nil,
tmuxCallerWorkspaceHandle() == workspaceId,
tmuxResolvedCallerWorkspaceId(client: client) == workspaceId,
let callerSurface = tmuxCallerSurfaceHandle(),
let surfaceId = try? tmuxCanonicalSurfaceId(callerSurface, workspaceId: workspaceId, client: client) {
let surfaceId = try? tmuxCanonicalSurfaceId(
callerSurface,
workspaceId: workspaceId,
client: client
) {
return (workspaceId, nil, surfaceId)
}
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
return (workspaceId, nil, surfaceId)
}
private func tmuxAnchoredSplitTarget(
workspaceId: String,
client: SocketClient
) -> (targetSurfaceId: String, callerSurfaceId: String?, direction: String)? {
var store = loadTmuxCompatStore()
if let lastColumn = store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId {
if let lastColumnId = try? tmuxCanonicalSurfaceId(
lastColumn,
workspaceId: workspaceId,
client: client
) {
// Once the agent column exists, keep stacking into it even if the
// caller surface handle has churned from a stale surface:<n> ref.
return (lastColumnId, nil, "down")
}
// Right-column anchors can outlive the pane they pointed at.
// Drop stale state and rebuild from the caller surface instead.
store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId = nil
store.lastSplitSurface.removeValue(forKey: workspaceId)
try? saveTmuxCompatStore(store)
}
let candidateAnchors = [
tmuxCallerSurfaceHandle(),
store.mainVerticalLayouts[workspaceId]?.mainSurfaceId
].compactMap { $0 }
for candidate in candidateAnchors {
if let anchorSurfaceId = try? tmuxCanonicalSurfaceId(
candidate,
workspaceId: workspaceId,
client: client
) {
return (anchorSurfaceId, anchorSurfaceId, "right")
}
}
let removedLayout = store.mainVerticalLayouts.removeValue(forKey: workspaceId) != nil
let removedSplit = store.lastSplitSurface.removeValue(forKey: workspaceId) != nil
if removedLayout || removedSplit {
try? saveTmuxCompatStore(store)
}
return nil
}
private func tmuxRenderFormat(
_ format: String?,
context: [String: String],
@ -10205,6 +10273,7 @@ struct CMUXCLI {
)
var target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
var direction: String
var anchoredCallerSurfaceId: String?
if parsed.hasFlag("-h") {
direction = parsed.hasFlag("-b") ? "left" : "right"
} else {
@ -10218,20 +10287,12 @@ struct CMUXCLI {
// successfully. Falling back to target.workspaceId would pair
// the caller's surface with a different workspace, creating an
// invalid cross-workspace split.
if let callerSurface = tmuxCallerSurfaceHandle(),
let callerWorkspace = tmuxCallerWorkspaceHandle(),
let wsId = try? resolveWorkspaceId(callerWorkspace, client: client) {
let store = loadTmuxCompatStore()
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"
}
if let callerWorkspace = tmuxCallerWorkspaceHandle(),
let wsId = try? resolveWorkspaceId(callerWorkspace, client: client),
let anchoredTarget = tmuxAnchoredSplitTarget(workspaceId: wsId, client: client) {
target = (wsId, nil, anchoredTarget.targetSurfaceId)
direction = anchoredTarget.direction
anchoredCallerSurfaceId = anchoredTarget.callerSurfaceId
}
// Keep the leader pane focused while agents spawn beside it.
@ -10254,11 +10315,11 @@ struct CMUXCLI {
updatedStore.lastSplitSurface[target.workspaceId] = surfaceId
if updatedStore.mainVerticalLayouts[target.workspaceId] != nil {
updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId
} else if direction == "right", let callerSurface = tmuxCallerSurfaceHandle() {
} else if direction == "right", let anchoredCallerSurfaceId {
// First right split created the column; seed main-vertical
// state so subsequent splits stack downward.
updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState(
mainSurfaceId: callerSurface,
mainSurfaceId: anchoredCallerSurfaceId,
lastColumnSurfaceId: surfaceId
)
}
@ -10311,6 +10372,7 @@ struct CMUXCLI {
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
_ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId])
try? tmuxPruneCompatWorkspaceState(workspaceId: workspaceId)
case "kill-pane", "killp":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
@ -10319,6 +10381,11 @@ struct CMUXCLI {
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId
])
try? tmuxPruneCompatSurfaceState(
workspaceId: target.workspaceId,
surfaceId: target.surfaceId,
client: client
)
// Re-equalize the agent column after removing a pane
_ = try? client.sendV2(method: "workspace.equalize_splits", params: [
"workspace_id": target.workspaceId,
@ -10582,12 +10649,7 @@ struct CMUXCLI {
} else if !layoutName.isEmpty {
// Non-main-vertical layout selected: clear stale state so
// future splits don't incorrectly redirect to the old column.
var store = loadTmuxCompatStore()
let removedLayout = store.mainVerticalLayouts.removeValue(forKey: workspaceId) != nil
let removedSplit = store.lastSplitSurface.removeValue(forKey: workspaceId) != nil
if removedLayout || removedSplit {
try saveTmuxCompatStore(store)
}
try tmuxPruneCompatWorkspaceState(workspaceId: workspaceId)
}
case "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
@ -10659,6 +10721,130 @@ struct CMUXCLI {
try data.write(to: url, options: .atomic)
}
private func tmuxPruneCompatWorkspaceState(workspaceId: String) throws {
var store = loadTmuxCompatStore()
let removedLayout = store.mainVerticalLayouts.removeValue(forKey: workspaceId) != nil
let removedSplit = store.lastSplitSurface.removeValue(forKey: workspaceId) != nil
if removedLayout || removedSplit {
try saveTmuxCompatStore(store)
}
}
private func tmuxCompatPaneAnchorSurfaceId(_ pane: [String: Any]) -> String? {
if let selected = pane["selected_surface_id"] as? String, !selected.isEmpty {
return selected
}
let surfaceIds = pane["surface_ids"] as? [String] ?? []
return surfaceIds.first
}
private func tmuxCompatPanePixelFrame(_ pane: [String: Any]) -> (x: Double, y: Double)? {
guard let frame = pane["pixel_frame"] as? [String: Any],
let x = doubleFromAny(frame["x"]),
let y = doubleFromAny(frame["y"]) else {
return nil
}
return (x, y)
}
private func tmuxReplacementColumnSurfaceId(
workspaceId: String,
layout: MainVerticalState,
client: SocketClient
) -> String? {
guard let payload = try? client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) else {
return nil
}
let panes = payload["panes"] as? [[String: Any]] ?? []
guard !panes.isEmpty else { return nil }
guard let mainPane = panes.first(where: { pane in
let surfaceIds = pane["surface_ids"] as? [String] ?? []
if surfaceIds.contains(layout.mainSurfaceId) {
return true
}
return (pane["selected_surface_id"] as? String) == layout.mainSurfaceId
}) else {
return nil
}
let mainPaneId = mainPane["id"] as? String
let nonMainPanes = panes.filter { ($0["id"] as? String) != mainPaneId }
guard !nonMainPanes.isEmpty else { return nil }
let candidatePanes: [[String: Any]]
if let mainFrame = tmuxCompatPanePixelFrame(mainPane) {
let rightColumn = nonMainPanes.filter { pane in
guard let frame = tmuxCompatPanePixelFrame(pane) else { return false }
return frame.x > mainFrame.x + 0.5
}
candidatePanes = rightColumn.isEmpty ? nonMainPanes : rightColumn
} else {
candidatePanes = nonMainPanes
}
let bottomMostPane = candidatePanes.max { lhs, rhs in
let lhsFrame = tmuxCompatPanePixelFrame(lhs)
let rhsFrame = tmuxCompatPanePixelFrame(rhs)
switch (lhsFrame, rhsFrame) {
case let (.some(lhsFrame), .some(rhsFrame)):
if lhsFrame.y == rhsFrame.y {
return lhsFrame.x < rhsFrame.x
}
return lhsFrame.y < rhsFrame.y
case (.none, .some):
return true
case (.some, .none):
return false
case (.none, .none):
return false
}
}
return bottomMostPane.flatMap { tmuxCompatPaneAnchorSurfaceId($0) }
}
private func tmuxPruneCompatSurfaceState(
workspaceId: String,
surfaceId: String,
client: SocketClient
) throws {
var store = loadTmuxCompatStore()
var changed = false
if store.lastSplitSurface[workspaceId] == surfaceId {
store.lastSplitSurface.removeValue(forKey: workspaceId)
changed = true
}
if let layout = store.mainVerticalLayouts[workspaceId] {
if layout.mainSurfaceId == surfaceId {
store.mainVerticalLayouts.removeValue(forKey: workspaceId)
store.lastSplitSurface.removeValue(forKey: workspaceId)
changed = true
} else if layout.lastColumnSurfaceId == surfaceId {
var updatedLayout = layout
let replacementSurfaceId = tmuxReplacementColumnSurfaceId(
workspaceId: workspaceId,
layout: layout,
client: client
)
updatedLayout.lastColumnSurfaceId = replacementSurfaceId
store.mainVerticalLayouts[workspaceId] = updatedLayout
if let replacementSurfaceId {
store.lastSplitSurface[workspaceId] = replacementSurfaceId
} else {
store.lastSplitSurface.removeValue(forKey: workspaceId)
}
changed = true
}
}
if changed {
try saveTmuxCompatStore(store)
}
}
private func runShellCommand(_ command: String, stdinText: String) throws -> (status: Int32, stdout: String, stderr: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")

View file

@ -383,6 +383,18 @@ func tmuxCallerSurfaceHandle() string {
return strings.TrimSpace(os.Getenv("CMUX_SURFACE_ID"))
}
func tmuxResolvedCallerWorkspaceId(rc *rpcContext) string {
caller := tmuxCallerWorkspaceHandle()
if caller == "" {
return ""
}
wsId, err := tmuxResolveWorkspaceId(rc, caller)
if err != nil {
return ""
}
return wsId
}
func tmuxCallerPaneHandle() string {
for _, key := range []string{"TMUX_PANE", "CMUX_PANE_ID"} {
v := strings.TrimSpace(os.Getenv(key))
@ -570,6 +582,29 @@ func tmuxCanonicalPaneId(rc *rpcContext, handle string, workspaceId string) (str
return "", fmt.Errorf("pane not found: %s", handle)
}
func tmuxCanonicalSurfaceId(rc *rpcContext, handle string, workspaceId string) (string, error) {
payload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId})
if err != nil {
return "", err
}
surfaces, _ := payload["surfaces"].([]any)
for _, s := range surfaces {
surface, _ := s.(map[string]any)
if surface == nil {
continue
}
if ref, _ := surface["ref"].(string); ref == handle {
if id, _ := surface["id"].(string); id != "" {
return id, nil
}
}
if id, _ := surface["id"].(string); id == handle {
return id, nil
}
}
return "", fmt.Errorf("surface not found: %s", handle)
}
func tmuxFocusedPaneId(rc *rpcContext, workspaceId string) (string, error) {
payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId})
if err != nil {
@ -648,7 +683,7 @@ func tmuxResolvePaneTarget(rc *rpcContext, raw string) (workspaceId string, pane
if err != nil {
return "", "", err
}
} else if callerWs := tmuxCallerWorkspaceHandle(); callerWs == workspaceId {
} else if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId {
if callerPane := tmuxCallerPaneHandle(); callerPane != "" {
if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil {
paneId = pid
@ -707,10 +742,12 @@ func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, p
if callerPane != "" && callerSurface != "" {
canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId)
if paneId == callerPane || paneId == canonicalCallerPane {
surfaceId = callerSurface
surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId)
if err == nil {
return
}
}
}
surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId)
return
}
@ -723,13 +760,15 @@ func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, p
// When no explicit target and caller workspace matches, use caller's surface
if winSel == "" {
if callerWs := tmuxCallerWorkspaceHandle(); callerWs == workspaceId {
if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId {
if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" {
surfaceId = callerSurface
surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId)
if err == nil {
return
}
}
}
}
// Fall back to focused surface
payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId})
@ -769,6 +808,58 @@ func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, p
return "", "", "", fmt.Errorf("unable to resolve surface")
}
type tmuxSplitAnchor struct {
targetSurfaceId string
callerSurfaceId string
direction string
}
func tmuxAnchoredSplitTarget(rc *rpcContext, workspaceId string) *tmuxSplitAnchor {
store := loadTmuxCompatStore()
if mvState, ok := store.MainVerticalLayouts[workspaceId]; ok && mvState.LastColumnSurfaceId != "" {
lastColumnId, err := tmuxCanonicalSurfaceId(rc, mvState.LastColumnSurfaceId, workspaceId)
if err == nil {
return &tmuxSplitAnchor{
targetSurfaceId: lastColumnId,
callerSurfaceId: "",
direction: "down",
}
}
// Right-column anchors can outlive the pane they pointed at.
// Drop stale state and rebuild from the caller surface instead.
mvState.LastColumnSurfaceId = ""
store.MainVerticalLayouts[workspaceId] = mvState
delete(store.LastSplitSurface, workspaceId)
_ = saveTmuxCompatStore(store)
}
candidateAnchors := []string{tmuxCallerSurfaceHandle()}
if mvState, ok := store.MainVerticalLayouts[workspaceId]; ok && mvState.MainSurfaceId != "" {
candidateAnchors = append(candidateAnchors, mvState.MainSurfaceId)
}
for _, candidate := range candidateAnchors {
if candidate == "" {
continue
}
anchorSurfaceId, err := tmuxCanonicalSurfaceId(rc, candidate, workspaceId)
if err == nil {
return &tmuxSplitAnchor{
targetSurfaceId: anchorSurfaceId,
callerSurfaceId: anchorSurfaceId,
direction: "right",
}
}
}
if _, ok := store.MainVerticalLayouts[workspaceId]; ok {
delete(store.MainVerticalLayouts, workspaceId)
delete(store.LastSplitSurface, workspaceId)
_ = saveTmuxCompatStore(store)
}
return nil
}
// --- TmuxCompatStore (local JSON state) ---
type mainVerticalState struct {
@ -834,6 +925,47 @@ func saveTmuxCompatStore(store tmuxCompatStore) error {
return os.WriteFile(path, data, 0644)
}
func tmuxPruneCompatWorkspaceState(workspaceId string) error {
store := loadTmuxCompatStore()
changed := false
if _, ok := store.MainVerticalLayouts[workspaceId]; ok {
delete(store.MainVerticalLayouts, workspaceId)
changed = true
}
if _, ok := store.LastSplitSurface[workspaceId]; ok {
delete(store.LastSplitSurface, workspaceId)
changed = true
}
if changed {
return saveTmuxCompatStore(store)
}
return nil
}
func tmuxPruneCompatSurfaceState(workspaceId string, surfaceId string) error {
store := loadTmuxCompatStore()
changed := false
if lastSplit := store.LastSplitSurface[workspaceId]; lastSplit == surfaceId {
delete(store.LastSplitSurface, workspaceId)
changed = true
}
if layout, ok := store.MainVerticalLayouts[workspaceId]; ok {
if layout.MainSurfaceId == surfaceId {
delete(store.MainVerticalLayouts, workspaceId)
delete(store.LastSplitSurface, workspaceId)
changed = true
} else if layout.LastColumnSurfaceId == surfaceId {
layout.LastColumnSurfaceId = ""
store.MainVerticalLayouts[workspaceId] = layout
changed = true
}
}
if changed {
return saveTmuxCompatStore(store)
}
return nil
}
// --- Special key translation ---
func tmuxSpecialKeyText(token string) string {
@ -1069,20 +1201,16 @@ func tmuxSplitWindow(rc *rpcContext, args []string) error {
direction = "up"
}
// Anchor splits to the leader surface for agent teams
callerSurface := tmuxCallerSurfaceHandle()
// Anchor splits to the leader surface for agent teams.
callerWorkspace := tmuxCallerWorkspaceHandle()
if callerSurface != "" && callerWorkspace != "" {
anchoredCallerSurface := ""
if callerWorkspace != "" {
if wsId, err := tmuxResolveWorkspaceId(rc, callerWorkspace); err == nil {
store := loadTmuxCompatStore()
if mvState, ok := store.MainVerticalLayouts[wsId]; ok && mvState.LastColumnSurfaceId != "" {
if anchored := tmuxAnchoredSplitTarget(rc, wsId); anchored != nil {
targetWs = wsId
targetSurface = mvState.LastColumnSurfaceId
direction = "down"
} else {
targetWs = wsId
targetSurface = callerSurface
direction = "right"
targetSurface = anchored.targetSurfaceId
direction = anchored.direction
anchoredCallerSurface = anchored.callerSurfaceId
}
}
}
@ -1110,9 +1238,9 @@ func tmuxSplitWindow(rc *rpcContext, args []string) error {
mvs := store.MainVerticalLayouts[targetWs]
mvs.LastColumnSurfaceId = surfaceId
store.MainVerticalLayouts[targetWs] = mvs
} else if direction == "right" && callerSurface != "" {
} else if direction == "right" && anchoredCallerSurface != "" {
store.MainVerticalLayouts[targetWs] = mainVerticalState{
MainSurfaceId: callerSurface,
MainSurfaceId: anchoredCallerSurface,
LastColumnSurfaceId: surfaceId,
}
}
@ -1178,7 +1306,11 @@ func tmuxKillWindow(rc *rpcContext, args []string) error {
return err
}
_, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId})
if err != nil {
return err
}
_ = tmuxPruneCompatWorkspaceState(wsId)
return nil
}
func tmuxKillPane(rc *rpcContext, args []string) error {
@ -1191,6 +1323,7 @@ func tmuxKillPane(rc *rpcContext, args []string) error {
if err != nil {
return err
}
_ = tmuxPruneCompatSurfaceState(wsId, surfId)
// Re-equalize after removal
rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId, "orientation": "vertical"})
return nil
@ -1576,19 +1709,7 @@ func tmuxSelectLayout(rc *rpcContext, args []string) error {
saveTmuxCompatStore(store)
}
} else if layoutName != "" {
store := loadTmuxCompatStore()
changed := false
if _, ok := store.MainVerticalLayouts[wsId]; ok {
delete(store.MainVerticalLayouts, wsId)
changed = true
}
if _, ok := store.LastSplitSurface[wsId]; ok {
delete(store.LastSplitSurface, wsId)
changed = true
}
if changed {
saveTmuxCompatStore(store)
}
_ = tmuxPruneCompatWorkspaceState(wsId)
}
return nil

View file

@ -0,0 +1,241 @@
package main
import (
"bufio"
"encoding/json"
"net"
"os"
"path/filepath"
"testing"
)
func startMockTmuxCompatSocket(t *testing.T) string {
t.Helper()
sockPath := makeShortUnixSocketPath(t)
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
splitCreated := false
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var req map[string]any
if err := json.Unmarshal(line, &req); err != nil {
_, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n"))
return
}
method, _ := req["method"].(string)
params, _ := req["params"].(map[string]any)
resp := map[string]any{
"id": req["id"],
"ok": true,
}
switch method {
case "workspace.list":
resp["result"] = map[string]any{
"workspaces": []map[string]any{{
"id": "11111111-1111-4111-8111-111111111111",
"ref": "workspace:1",
"index": 1,
"title": "demo",
}},
}
case "surface.list":
surfaces := []map[string]any{{
"id": "44444444-4444-4444-8444-444444444444",
"ref": "surface:1",
"focused": true,
"pane_id": "33333333-3333-4333-8333-333333333333",
"title": "leader",
}}
if splitCreated {
surfaces = append(surfaces, map[string]any{
"id": "77777777-7777-4777-8777-777777777777",
"ref": "surface:2",
"focused": false,
"pane_id": "66666666-6666-4666-8666-666666666666",
"title": "teammate",
})
}
resp["result"] = map[string]any{"surfaces": surfaces}
case "surface.current":
resp["result"] = map[string]any{
"workspace_id": "11111111-1111-4111-8111-111111111111",
"workspace_ref": "workspace:1",
"pane_id": "33333333-3333-4333-8333-333333333333",
"pane_ref": "pane:1",
"surface_id": "44444444-4444-4444-8444-444444444444",
"surface_ref": "surface:1",
}
case "pane.list":
panes := []map[string]any{{
"id": "33333333-3333-4333-8333-333333333333",
"ref": "pane:1",
"index": 1,
}}
if splitCreated {
panes = append(panes, map[string]any{
"id": "66666666-6666-4666-8666-666666666666",
"ref": "pane:2",
"index": 2,
})
}
resp["result"] = map[string]any{"panes": panes}
case "surface.split":
if got, _ := params["surface_id"].(string); got != "44444444-4444-4444-8444-444444444444" {
resp["ok"] = false
resp["error"] = map[string]any{
"code": "not_found",
"message": "Surface not found",
}
break
}
splitCreated = true
resp["result"] = map[string]any{
"surface_id": "77777777-7777-4777-8777-777777777777",
"pane_id": "66666666-6666-4666-8666-666666666666",
}
case "workspace.equalize_splits":
resp["result"] = map[string]any{"ok": true}
default:
resp["ok"] = false
resp["error"] = map[string]any{
"code": "unsupported",
"message": method,
}
}
payload, _ := json.Marshal(resp)
_, _ = conn.Write(append(payload, '\n'))
}(conn)
}
}()
return sockPath
}
func TestTmuxSplitWindowCanonicalizesCallerSurfaceRefs(t *testing.T) {
origHome := os.Getenv("HOME")
origWorkspace := os.Getenv("CMUX_WORKSPACE_ID")
origSurface := os.Getenv("CMUX_SURFACE_ID")
origPane := os.Getenv("TMUX_PANE")
os.Setenv("HOME", t.TempDir())
os.Setenv("CMUX_WORKSPACE_ID", "workspace:1")
os.Setenv("CMUX_SURFACE_ID", "surface:1")
os.Setenv("TMUX_PANE", "%pane:1")
defer func() {
os.Setenv("HOME", origHome)
if origWorkspace != "" {
os.Setenv("CMUX_WORKSPACE_ID", origWorkspace)
} else {
os.Unsetenv("CMUX_WORKSPACE_ID")
}
if origSurface != "" {
os.Setenv("CMUX_SURFACE_ID", origSurface)
} else {
os.Unsetenv("CMUX_SURFACE_ID")
}
if origPane != "" {
os.Setenv("TMUX_PANE", origPane)
} else {
os.Unsetenv("TMUX_PANE")
}
}()
sockPath := startMockTmuxCompatSocket(t)
rc := &rpcContext{socketPath: sockPath}
output := captureStdout(t, func() {
if err := dispatchTmuxCommand(rc, "split-window", []string{"-h", "-P", "-F", "#{pane_id}"}); err != nil {
t.Fatalf("split-window: %v", err)
}
})
if got := output; got != "%66666666-6666-4666-8666-666666666666\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestTmuxSplitWindowIgnoresStaleUUIDColumnSurface(t *testing.T) {
origHome := os.Getenv("HOME")
origWorkspace := os.Getenv("CMUX_WORKSPACE_ID")
origSurface := os.Getenv("CMUX_SURFACE_ID")
origPane := os.Getenv("TMUX_PANE")
home := t.TempDir()
os.Setenv("HOME", home)
os.Setenv("CMUX_WORKSPACE_ID", "workspace:1")
os.Setenv("CMUX_SURFACE_ID", "surface:1")
os.Setenv("TMUX_PANE", "%pane:1")
defer func() {
os.Setenv("HOME", origHome)
if origWorkspace != "" {
os.Setenv("CMUX_WORKSPACE_ID", origWorkspace)
} else {
os.Unsetenv("CMUX_WORKSPACE_ID")
}
if origSurface != "" {
os.Setenv("CMUX_SURFACE_ID", origSurface)
} else {
os.Unsetenv("CMUX_SURFACE_ID")
}
if origPane != "" {
os.Setenv("TMUX_PANE", origPane)
} else {
os.Unsetenv("TMUX_PANE")
}
}()
storePath := filepath.Join(home, ".cmuxterm", "tmux-compat-store.json")
if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil {
t.Fatalf("mkdir store dir: %v", err)
}
storeBytes, err := json.Marshal(tmuxCompatStore{
Buffers: make(map[string]string),
Hooks: make(map[string]string),
MainVerticalLayouts: map[string]mainVerticalState{
"11111111-1111-4111-8111-111111111111": {
MainSurfaceId: "44444444-4444-4444-8444-444444444444",
LastColumnSurfaceId: "77777777-7777-4777-8777-777777777777",
},
},
LastSplitSurface: map[string]string{
"11111111-1111-4111-8111-111111111111": "77777777-7777-4777-8777-777777777777",
},
})
if err != nil {
t.Fatalf("marshal store: %v", err)
}
if err := os.WriteFile(storePath, storeBytes, 0o644); err != nil {
t.Fatalf("write store: %v", err)
}
sockPath := startMockTmuxCompatSocket(t)
rc := &rpcContext{socketPath: sockPath}
output := captureStdout(t, func() {
if err := dispatchTmuxCommand(rc, "split-window", []string{"-h", "-P", "-F", "#{pane_id}"}); err != nil {
t.Fatalf("split-window: %v", err)
}
})
if got := output; got != "%66666666-6666-4666-8666-666666666666\n" {
t.Fatalf("stdout = %q", got)
}
}

View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""
Regression tests for `cmux __tmux-compat split-window`.
"""
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
WORKSPACE_ID = "11111111-1111-4111-8111-111111111111"
PANE_ID = "33333333-3333-4333-8333-333333333333"
SURFACE_ID = "44444444-4444-4444-8444-444444444444"
NEW_PANE_ID = "66666666-6666-4666-8666-666666666666"
NEW_SURFACE_ID = "77777777-7777-4777-8777-777777777777"
class FakeCmuxState:
def __init__(self) -> None:
self.split_created = False
def handle(self, method: str, params: dict[str, object]) -> dict[str, object]:
if method == "workspace.list":
return {
"workspaces": [
{
"id": WORKSPACE_ID,
"ref": "workspace:1",
"index": 1,
"title": "demo",
}
]
}
if method == "surface.list":
surfaces = [
{
"id": SURFACE_ID,
"ref": "surface:1",
"focused": True,
"pane_id": PANE_ID,
"pane_ref": "pane:1",
"title": "leader",
}
]
if self.split_created:
surfaces.append(
{
"id": NEW_SURFACE_ID,
"ref": "surface:2",
"focused": False,
"pane_id": NEW_PANE_ID,
"pane_ref": "pane:2",
"title": "teammate",
}
)
return {"surfaces": surfaces}
if method == "surface.current":
return {
"workspace_id": WORKSPACE_ID,
"workspace_ref": "workspace:1",
"pane_id": PANE_ID,
"pane_ref": "pane:1",
"surface_id": SURFACE_ID,
"surface_ref": "surface:1",
}
if method == "pane.list":
panes = [
{
"id": PANE_ID,
"ref": "pane:1",
"index": 1,
}
]
if self.split_created:
panes.append(
{
"id": NEW_PANE_ID,
"ref": "pane:2",
"index": 2,
}
)
return {"panes": panes}
if method == "surface.split":
target_surface = str(params.get("surface_id") or "")
if target_surface != SURFACE_ID:
raise RuntimeError(
f"expected split target {SURFACE_ID}, got {target_surface!r}"
)
self.split_created = True
return {
"surface_id": NEW_SURFACE_ID,
"pane_id": NEW_PANE_ID,
}
if method == "surface.close":
target_surface = str(params.get("surface_id") or "")
if target_surface != NEW_SURFACE_ID:
raise RuntimeError(
f"expected close target {NEW_SURFACE_ID}, got {target_surface!r}"
)
self.split_created = False
return {
"workspace_id": WORKSPACE_ID,
"surface_id": NEW_SURFACE_ID,
}
if method == "workspace.equalize_splits":
return {"ok": True}
raise RuntimeError(f"Unsupported fake cmux method: {method}")
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"))
try:
result = self.server.state.handle( # type: ignore[attr-defined]
request["method"],
request.get("params", {}),
)
response = {"ok": True, "result": result, "id": request.get("id")}
except Exception as exc:
response = {
"ok": False,
"error": {"code": "not_found", "message": str(exc)},
"id": request.get("id"),
}
self.wfile.write((json.dumps(response) + "\n").encode("utf-8"))
self.wfile.flush()
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)
def run_cli(
cli_path: str,
socket_path: Path,
fake_home: Path,
args: list[str],
) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["CMUX_SOCKET_PATH"] = str(socket_path)
env["CMUX_WORKSPACE_ID"] = "workspace:1"
env["CMUX_SURFACE_ID"] = "surface:1"
env["TMUX_PANE"] = "%pane:1"
env["HOME"] = str(fake_home)
return subprocess.run(
[cli_path, "--socket", str(socket_path), *args],
capture_output=True,
text=True,
check=False,
env=env,
timeout=30,
)
def assert_successful_split(
cli_path: str,
socket_path: Path,
fake_home: Path,
label: str,
) -> None:
proc = run_cli(
cli_path,
socket_path,
fake_home,
["__tmux-compat", "split-window", "-h", "-P", "-F", "#{pane_id}"],
)
if proc.returncode != 0:
raise AssertionError(
f"{label} returned non-zero\n"
f"stdout={proc.stdout.strip()}\n"
f"stderr={proc.stderr.strip()}"
)
if proc.stdout.strip() != f"%{NEW_PANE_ID}":
raise AssertionError(
f"{label} expected %{NEW_PANE_ID}, got {proc.stdout.strip()!r}"
)
def assert_resplit_after_close(
cli_path: str,
socket_path: Path,
fake_home: Path,
) -> None:
assert_successful_split(cli_path, socket_path, fake_home, "initial split-window")
closed = run_cli(
cli_path,
socket_path,
fake_home,
["close-surface", "--workspace", "workspace:1", "--surface", "surface:2"],
)
if closed.returncode != 0:
raise AssertionError(
"close-surface returned non-zero\n"
f"stdout={closed.stdout.strip()}\n"
f"stderr={closed.stderr.strip()}"
)
assert_successful_split(cli_path, socket_path, fake_home, "second split-window")
def main() -> int:
try:
cli_path = resolve_cmux_cli()
except Exception as exc:
print(f"FAIL: {exc}")
return 1
try:
with tempfile.TemporaryDirectory(prefix="cmux-tmux-surface-ref-") as td:
tmp = Path(td)
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()
fake_home = tmp / "home"
fake_home.mkdir(parents=True, exist_ok=True)
try:
assert_resplit_after_close(cli_path, socket_path, fake_home)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
except AssertionError as exc:
print(f"FAIL: {exc}")
return 1
print(
"PASS: tmux-compat split-window handles caller refs and close/re-split flows"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())