diff --git a/CLI/cmux.swift b/CLI/cmux.swift index ef7d6aed..bf3ba20d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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: 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") diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat.go index 5045ec00..0f060086 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat.go @@ -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,8 +742,10 @@ func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, p if callerPane != "" && callerSurface != "" { canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId) if paneId == callerPane || paneId == canonicalCallerPane { - surfaceId = callerSurface - return + surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) + if err == nil { + return + } } } surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId) @@ -723,10 +760,12 @@ 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 - return + surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) + if err == nil { + return + } } } } @@ -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 { @@ -777,10 +868,10 @@ type mainVerticalState struct { } type tmuxCompatStore struct { - Buffers map[string]string `json:"buffers,omitempty"` - Hooks map[string]string `json:"hooks,omitempty"` - MainVerticalLayouts map[string]mainVerticalState `json:"mainVerticalLayouts,omitempty"` - LastSplitSurface map[string]string `json:"lastSplitSurface,omitempty"` + Buffers map[string]string `json:"buffers,omitempty"` + Hooks map[string]string `json:"hooks,omitempty"` + MainVerticalLayouts map[string]mainVerticalState `json:"mainVerticalLayouts,omitempty"` + LastSplitSurface map[string]string `json:"lastSplitSurface,omitempty"` } func tmuxCompatStoreURL() string { @@ -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}) - return err + 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 diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_split_ref_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_split_ref_test.go new file mode 100644 index 00000000..f3811173 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/tmux_split_ref_test.go @@ -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) + } +} diff --git a/tests/test_cli_tmux_compat_split_window_surface_ref.py b/tests/test_cli_tmux_compat_split_window_surface_ref.py new file mode 100644 index 00000000..b1063b3e --- /dev/null +++ b/tests/test_cli_tmux_compat_split_window_surface_ref.py @@ -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())