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:
parent
867c93e4fa
commit
2c5c4fcf8d
4 changed files with 870 additions and 68 deletions
248
CLI/cmux.swift
248
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:<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")
|
||||
|
|
|
|||
|
|
@ -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,8 +1306,12 @@ 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 {
|
||||
p := parseTmuxArgs(args, []string{"-t"}, nil)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
241
daemon/remote/cmd/cmuxd-remote/tmux_split_ref_test.go
Normal file
241
daemon/remote/cmd/cmuxd-remote/tmux_split_ref_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
254
tests/test_cli_tmux_compat_split_window_surface_ref.py
Normal file
254
tests/test_cli_tmux_compat_split_window_surface_ref.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue