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)
|
let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId)
|
||||||
if let sfId { params["surface_id"] = sfId }
|
if let sfId { params["surface_id"] = sfId }
|
||||||
let payload = try client.sendV2(method: "surface.close", params: params)
|
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))
|
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
|
||||||
|
|
||||||
case "drag-surface-to-split":
|
case "drag-surface-to-split":
|
||||||
|
|
@ -1979,6 +1987,9 @@ struct CMUXCLI {
|
||||||
let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client)
|
let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client)
|
||||||
if let wsId { params["workspace_id"] = wsId }
|
if let wsId { params["workspace_id"] = wsId }
|
||||||
let payload = try client.sendV2(method: "workspace.close", params: params)
|
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"]))
|
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
|
||||||
|
|
||||||
case "select-workspace":
|
case "select-workspace":
|
||||||
|
|
@ -8890,6 +8901,13 @@ struct CMUXCLI {
|
||||||
normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"])
|
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(
|
private func tmuxCanonicalPaneId(
|
||||||
_ handle: String,
|
_ handle: String,
|
||||||
workspaceId: String,
|
workspaceId: String,
|
||||||
|
|
@ -8925,10 +8943,6 @@ struct CMUXCLI {
|
||||||
workspaceId: String,
|
workspaceId: String,
|
||||||
client: SocketClient
|
client: SocketClient
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
if isUUID(handle) {
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
|
let payload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
|
||||||
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
|
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
|
||||||
for surface in surfaces {
|
for surface in surfaces {
|
||||||
|
|
@ -9040,7 +9054,7 @@ struct CMUXCLI {
|
||||||
let paneId: String
|
let paneId: String
|
||||||
if let paneSelector {
|
if let paneSelector {
|
||||||
paneId = try tmuxCanonicalPaneId(paneSelector, workspaceId: workspaceId, client: client)
|
paneId = try tmuxCanonicalPaneId(paneSelector, workspaceId: workspaceId, client: client)
|
||||||
} else if tmuxCallerWorkspaceHandle() == workspaceId,
|
} else if tmuxResolvedCallerWorkspaceId(client: client) == workspaceId,
|
||||||
let callerPane = tmuxCallerPaneHandle(),
|
let callerPane = tmuxCallerPaneHandle(),
|
||||||
let callerPaneId = try? tmuxCanonicalPaneId(callerPane, workspaceId: workspaceId, client: client) {
|
let callerPaneId = try? tmuxCanonicalPaneId(callerPane, workspaceId: workspaceId, client: client) {
|
||||||
paneId = callerPaneId
|
paneId = callerPaneId
|
||||||
|
|
@ -9084,8 +9098,13 @@ struct CMUXCLI {
|
||||||
let callerSurface = tmuxCallerSurfaceHandle()
|
let callerSurface = tmuxCallerSurfaceHandle()
|
||||||
let canonicalCallerPane = callerPane.flatMap { try? tmuxCanonicalPaneId($0, workspaceId: resolved.workspaceId, client: client) }
|
let canonicalCallerPane = callerPane.flatMap { try? tmuxCanonicalPaneId($0, workspaceId: resolved.workspaceId, client: client) }
|
||||||
let paneMatch = callerPane != nil && (resolved.paneId == callerPane! || resolved.paneId == canonicalCallerPane)
|
let paneMatch = callerPane != nil && (resolved.paneId == callerPane! || resolved.paneId == canonicalCallerPane)
|
||||||
let canonicalSurface = callerSurface.flatMap { try? tmuxCanonicalSurfaceId($0, workspaceId: resolved.workspaceId, client: client) }
|
if paneMatch,
|
||||||
if paneMatch, let surfaceId = canonicalSurface {
|
let callerSurface,
|
||||||
|
let surfaceId = try? tmuxCanonicalSurfaceId(
|
||||||
|
callerSurface,
|
||||||
|
workspaceId: resolved.workspaceId,
|
||||||
|
client: client
|
||||||
|
) {
|
||||||
return (resolved.workspaceId, resolved.paneId, surfaceId)
|
return (resolved.workspaceId, resolved.paneId, surfaceId)
|
||||||
}
|
}
|
||||||
let surfaceId = try tmuxSelectedSurfaceId(
|
let surfaceId = try tmuxSelectedSurfaceId(
|
||||||
|
|
@ -9098,15 +9117,64 @@ struct CMUXCLI {
|
||||||
|
|
||||||
let workspaceId = try tmuxResolveWorkspaceTarget(tmuxWindowSelector(from: raw), client: client)
|
let workspaceId = try tmuxResolveWorkspaceTarget(tmuxWindowSelector(from: raw), client: client)
|
||||||
if tmuxWindowSelector(from: raw) == nil,
|
if tmuxWindowSelector(from: raw) == nil,
|
||||||
tmuxCallerWorkspaceHandle() == workspaceId,
|
tmuxResolvedCallerWorkspaceId(client: client) == workspaceId,
|
||||||
let callerSurface = tmuxCallerSurfaceHandle(),
|
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)
|
return (workspaceId, nil, surfaceId)
|
||||||
}
|
}
|
||||||
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
|
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
|
||||||
return (workspaceId, nil, surfaceId)
|
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(
|
private func tmuxRenderFormat(
|
||||||
_ format: String?,
|
_ format: String?,
|
||||||
context: [String: String],
|
context: [String: String],
|
||||||
|
|
@ -10205,6 +10273,7 @@ struct CMUXCLI {
|
||||||
)
|
)
|
||||||
var target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
|
var target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
|
||||||
var direction: String
|
var direction: String
|
||||||
|
var anchoredCallerSurfaceId: String?
|
||||||
if parsed.hasFlag("-h") {
|
if parsed.hasFlag("-h") {
|
||||||
direction = parsed.hasFlag("-b") ? "left" : "right"
|
direction = parsed.hasFlag("-b") ? "left" : "right"
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -10218,20 +10287,12 @@ struct CMUXCLI {
|
||||||
// successfully. Falling back to target.workspaceId would pair
|
// successfully. Falling back to target.workspaceId would pair
|
||||||
// the caller's surface with a different workspace, creating an
|
// the caller's surface with a different workspace, creating an
|
||||||
// invalid cross-workspace split.
|
// invalid cross-workspace split.
|
||||||
if let callerSurface = tmuxCallerSurfaceHandle(),
|
if let callerWorkspace = tmuxCallerWorkspaceHandle(),
|
||||||
let callerWorkspace = tmuxCallerWorkspaceHandle(),
|
let wsId = try? resolveWorkspaceId(callerWorkspace, client: client),
|
||||||
let wsId = try? resolveWorkspaceId(callerWorkspace, client: client) {
|
let anchoredTarget = tmuxAnchoredSplitTarget(workspaceId: wsId, client: client) {
|
||||||
let store = loadTmuxCompatStore()
|
target = (wsId, nil, anchoredTarget.targetSurfaceId)
|
||||||
if let mvState = store.mainVerticalLayouts[wsId],
|
direction = anchoredTarget.direction
|
||||||
let lastColumn = mvState.lastColumnSurfaceId {
|
anchoredCallerSurfaceId = anchoredTarget.callerSurfaceId
|
||||||
// Main-vertical active: stack in right column.
|
|
||||||
target = (wsId, nil, lastColumn)
|
|
||||||
direction = "down"
|
|
||||||
} else {
|
|
||||||
// First teammate: split the leader surface to the right.
|
|
||||||
target = (wsId, nil, callerSurface)
|
|
||||||
direction = "right"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the leader pane focused while agents spawn beside it.
|
// Keep the leader pane focused while agents spawn beside it.
|
||||||
|
|
@ -10254,11 +10315,11 @@ struct CMUXCLI {
|
||||||
updatedStore.lastSplitSurface[target.workspaceId] = surfaceId
|
updatedStore.lastSplitSurface[target.workspaceId] = surfaceId
|
||||||
if updatedStore.mainVerticalLayouts[target.workspaceId] != nil {
|
if updatedStore.mainVerticalLayouts[target.workspaceId] != nil {
|
||||||
updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId
|
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
|
// First right split created the column; seed main-vertical
|
||||||
// state so subsequent splits stack downward.
|
// state so subsequent splits stack downward.
|
||||||
updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState(
|
updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState(
|
||||||
mainSurfaceId: callerSurface,
|
mainSurfaceId: anchoredCallerSurfaceId,
|
||||||
lastColumnSurfaceId: surfaceId
|
lastColumnSurfaceId: surfaceId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -10311,6 +10372,7 @@ struct CMUXCLI {
|
||||||
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
|
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
|
||||||
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
|
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
|
||||||
_ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId])
|
_ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId])
|
||||||
|
try? tmuxPruneCompatWorkspaceState(workspaceId: workspaceId)
|
||||||
|
|
||||||
case "kill-pane", "killp":
|
case "kill-pane", "killp":
|
||||||
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
|
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
|
||||||
|
|
@ -10319,6 +10381,11 @@ struct CMUXCLI {
|
||||||
"workspace_id": target.workspaceId,
|
"workspace_id": target.workspaceId,
|
||||||
"surface_id": target.surfaceId
|
"surface_id": target.surfaceId
|
||||||
])
|
])
|
||||||
|
try? tmuxPruneCompatSurfaceState(
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
surfaceId: target.surfaceId,
|
||||||
|
client: client
|
||||||
|
)
|
||||||
// Re-equalize the agent column after removing a pane
|
// Re-equalize the agent column after removing a pane
|
||||||
_ = try? client.sendV2(method: "workspace.equalize_splits", params: [
|
_ = try? client.sendV2(method: "workspace.equalize_splits", params: [
|
||||||
"workspace_id": target.workspaceId,
|
"workspace_id": target.workspaceId,
|
||||||
|
|
@ -10582,12 +10649,7 @@ struct CMUXCLI {
|
||||||
} else if !layoutName.isEmpty {
|
} else if !layoutName.isEmpty {
|
||||||
// Non-main-vertical layout selected: clear stale state so
|
// Non-main-vertical layout selected: clear stale state so
|
||||||
// future splits don't incorrectly redirect to the old column.
|
// future splits don't incorrectly redirect to the old column.
|
||||||
var store = loadTmuxCompatStore()
|
try tmuxPruneCompatWorkspaceState(workspaceId: workspaceId)
|
||||||
let removedLayout = store.mainVerticalLayouts.removeValue(forKey: workspaceId) != nil
|
|
||||||
let removedSplit = store.lastSplitSurface.removeValue(forKey: workspaceId) != nil
|
|
||||||
if removedLayout || removedSplit {
|
|
||||||
try saveTmuxCompatStore(store)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
|
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)
|
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) {
|
private func runShellCommand(_ command: String, stdinText: String) throws -> (status: Int32, stdout: String, stderr: String) {
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,18 @@ func tmuxCallerSurfaceHandle() string {
|
||||||
return strings.TrimSpace(os.Getenv("CMUX_SURFACE_ID"))
|
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 {
|
func tmuxCallerPaneHandle() string {
|
||||||
for _, key := range []string{"TMUX_PANE", "CMUX_PANE_ID"} {
|
for _, key := range []string{"TMUX_PANE", "CMUX_PANE_ID"} {
|
||||||
v := strings.TrimSpace(os.Getenv(key))
|
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)
|
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) {
|
func tmuxFocusedPaneId(rc *rpcContext, workspaceId string) (string, error) {
|
||||||
payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId})
|
payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -648,7 +683,7 @@ func tmuxResolvePaneTarget(rc *rpcContext, raw string) (workspaceId string, pane
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
} else if callerWs := tmuxCallerWorkspaceHandle(); callerWs == workspaceId {
|
} else if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId {
|
||||||
if callerPane := tmuxCallerPaneHandle(); callerPane != "" {
|
if callerPane := tmuxCallerPaneHandle(); callerPane != "" {
|
||||||
if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil {
|
if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil {
|
||||||
paneId = pid
|
paneId = pid
|
||||||
|
|
@ -707,10 +742,12 @@ func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, p
|
||||||
if callerPane != "" && callerSurface != "" {
|
if callerPane != "" && callerSurface != "" {
|
||||||
canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId)
|
canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId)
|
||||||
if paneId == callerPane || paneId == canonicalCallerPane {
|
if paneId == callerPane || paneId == canonicalCallerPane {
|
||||||
surfaceId = callerSurface
|
surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId)
|
||||||
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId)
|
surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId)
|
||||||
return
|
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
|
// When no explicit target and caller workspace matches, use caller's surface
|
||||||
if winSel == "" {
|
if winSel == "" {
|
||||||
if callerWs := tmuxCallerWorkspaceHandle(); callerWs == workspaceId {
|
if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId {
|
||||||
if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" {
|
if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" {
|
||||||
surfaceId = callerSurface
|
surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId)
|
||||||
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to focused surface
|
// Fall back to focused surface
|
||||||
payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId})
|
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")
|
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) ---
|
// --- TmuxCompatStore (local JSON state) ---
|
||||||
|
|
||||||
type mainVerticalState struct {
|
type mainVerticalState struct {
|
||||||
|
|
@ -834,6 +925,47 @@ func saveTmuxCompatStore(store tmuxCompatStore) error {
|
||||||
return os.WriteFile(path, data, 0644)
|
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 ---
|
// --- Special key translation ---
|
||||||
|
|
||||||
func tmuxSpecialKeyText(token string) string {
|
func tmuxSpecialKeyText(token string) string {
|
||||||
|
|
@ -1069,20 +1201,16 @@ func tmuxSplitWindow(rc *rpcContext, args []string) error {
|
||||||
direction = "up"
|
direction = "up"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchor splits to the leader surface for agent teams
|
// Anchor splits to the leader surface for agent teams.
|
||||||
callerSurface := tmuxCallerSurfaceHandle()
|
|
||||||
callerWorkspace := tmuxCallerWorkspaceHandle()
|
callerWorkspace := tmuxCallerWorkspaceHandle()
|
||||||
if callerSurface != "" && callerWorkspace != "" {
|
anchoredCallerSurface := ""
|
||||||
|
if callerWorkspace != "" {
|
||||||
if wsId, err := tmuxResolveWorkspaceId(rc, callerWorkspace); err == nil {
|
if wsId, err := tmuxResolveWorkspaceId(rc, callerWorkspace); err == nil {
|
||||||
store := loadTmuxCompatStore()
|
if anchored := tmuxAnchoredSplitTarget(rc, wsId); anchored != nil {
|
||||||
if mvState, ok := store.MainVerticalLayouts[wsId]; ok && mvState.LastColumnSurfaceId != "" {
|
|
||||||
targetWs = wsId
|
targetWs = wsId
|
||||||
targetSurface = mvState.LastColumnSurfaceId
|
targetSurface = anchored.targetSurfaceId
|
||||||
direction = "down"
|
direction = anchored.direction
|
||||||
} else {
|
anchoredCallerSurface = anchored.callerSurfaceId
|
||||||
targetWs = wsId
|
|
||||||
targetSurface = callerSurface
|
|
||||||
direction = "right"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1110,9 +1238,9 @@ func tmuxSplitWindow(rc *rpcContext, args []string) error {
|
||||||
mvs := store.MainVerticalLayouts[targetWs]
|
mvs := store.MainVerticalLayouts[targetWs]
|
||||||
mvs.LastColumnSurfaceId = surfaceId
|
mvs.LastColumnSurfaceId = surfaceId
|
||||||
store.MainVerticalLayouts[targetWs] = mvs
|
store.MainVerticalLayouts[targetWs] = mvs
|
||||||
} else if direction == "right" && callerSurface != "" {
|
} else if direction == "right" && anchoredCallerSurface != "" {
|
||||||
store.MainVerticalLayouts[targetWs] = mainVerticalState{
|
store.MainVerticalLayouts[targetWs] = mainVerticalState{
|
||||||
MainSurfaceId: callerSurface,
|
MainSurfaceId: anchoredCallerSurface,
|
||||||
LastColumnSurfaceId: surfaceId,
|
LastColumnSurfaceId: surfaceId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1178,8 +1306,12 @@ func tmuxKillWindow(rc *rpcContext, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId})
|
_, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId})
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
_ = tmuxPruneCompatWorkspaceState(wsId)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func tmuxKillPane(rc *rpcContext, args []string) error {
|
func tmuxKillPane(rc *rpcContext, args []string) error {
|
||||||
p := parseTmuxArgs(args, []string{"-t"}, nil)
|
p := parseTmuxArgs(args, []string{"-t"}, nil)
|
||||||
|
|
@ -1191,6 +1323,7 @@ func tmuxKillPane(rc *rpcContext, args []string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
_ = tmuxPruneCompatSurfaceState(wsId, surfId)
|
||||||
// Re-equalize after removal
|
// Re-equalize after removal
|
||||||
rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId, "orientation": "vertical"})
|
rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId, "orientation": "vertical"})
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -1576,19 +1709,7 @@ func tmuxSelectLayout(rc *rpcContext, args []string) error {
|
||||||
saveTmuxCompatStore(store)
|
saveTmuxCompatStore(store)
|
||||||
}
|
}
|
||||||
} else if layoutName != "" {
|
} else if layoutName != "" {
|
||||||
store := loadTmuxCompatStore()
|
_ = tmuxPruneCompatWorkspaceState(wsId)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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