Repository navigation
Fix claude-teams pane anchoring with main-vertical layout #2119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d140f29
9973bde
af0290c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9015,6 +9015,18 @@ struct CMUXCLI { | |
| ) throws -> (workspaceId: String, paneId: String?, surfaceId: String) { | ||
| if tmuxPaneSelector(from: raw) != nil { | ||
| let resolved = try tmuxResolvePaneTarget(raw, client: client) | ||
| // When the target pane matches the caller's pane, prefer the caller's | ||
| // exact surface (CMUX_SURFACE_ID) over the pane's currently selected | ||
| // surface. The selected surface can change (e.g. tab switches) after | ||
| // claude-teams started, but the caller surface stays fixed. | ||
| let callerPane = tmuxCallerPaneHandle() | ||
| let callerSurface = tmuxCallerSurfaceHandle() | ||
| let canonicalCallerPane = callerPane.flatMap { try? tmuxCanonicalPaneId($0, workspaceId: resolved.workspaceId, client: client) } | ||
| let paneMatch = callerPane != nil && (resolved.paneId == callerPane! || resolved.paneId == canonicalCallerPane) | ||
| let canonicalSurface = callerSurface.flatMap { try? tmuxCanonicalSurfaceId($0, workspaceId: resolved.workspaceId, client: client) } | ||
| if paneMatch, let surfaceId = canonicalSurface { | ||
| return (resolved.workspaceId, resolved.paneId, surfaceId) | ||
| } | ||
| let surfaceId = try tmuxSelectedSurfaceId( | ||
| workspaceId: resolved.workspaceId, | ||
| paneId: resolved.paneId, | ||
|
|
@@ -9548,23 +9560,62 @@ struct CMUXCLI { | |
| valueFlags: ["-c", "-F", "-l", "-t"], | ||
| boolFlags: ["-P", "-b", "-d", "-h", "-v"] | ||
| ) | ||
| let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) | ||
| let direction: String | ||
| var target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) | ||
| var direction: String | ||
| if parsed.hasFlag("-h") { | ||
| direction = parsed.hasFlag("-b") ? "left" : "right" | ||
| } else { | ||
| direction = parsed.hasFlag("-b") ? "up" : "down" | ||
| } | ||
|
|
||
| // Claude's agent teams targets arbitrary panes (from list-panes), | ||
| // not necessarily the leader pane from TMUX_PANE. Override the | ||
| // target to anchor all teammate splits to the leader surface. | ||
| let store = loadTmuxCompatStore() | ||
| if let callerSurface = tmuxCallerSurfaceHandle(), | ||
| let callerWorkspace = tmuxCallerWorkspaceHandle() { | ||
| let wsId = (try? resolveWorkspaceId(callerWorkspace, client: client)) ?? target.workspaceId | ||
| if let mvState = store.mainVerticalLayouts[wsId], | ||
| let lastColumn = mvState.lastColumnSurfaceId { | ||
| // Main-vertical active: stack in right column. | ||
| target = (wsId, nil, lastColumn) | ||
| direction = "down" | ||
| } else { | ||
| // First teammate: split the leader surface to the right. | ||
| target = (wsId, nil, callerSurface) | ||
| direction = "right" | ||
| } | ||
| } | ||
|
|
||
| // Keep the leader pane focused while Claude starts teammates beside it. | ||
| let created = try client.sendV2(method: "surface.split", params: [ | ||
| "workspace_id": target.workspaceId, | ||
| "surface_id": target.surfaceId, | ||
| "direction": direction | ||
| "direction": direction, | ||
| "focus": false | ||
| ]) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| guard let surfaceId = created["surface_id"] as? String else { | ||
| throw CLIError(message: "surface.split did not return surface_id") | ||
| } | ||
| let paneId = created["pane_id"] as? String | ||
| // Keep the leader pane focused while Claude starts teammates beside it. | ||
|
|
||
| // Track the newly created pane for main-vertical layout. | ||
| do { | ||
| var updatedStore = loadTmuxCompatStore() | ||
| updatedStore.lastSplitSurface[target.workspaceId] = surfaceId | ||
| if updatedStore.mainVerticalLayouts[target.workspaceId] != nil { | ||
| updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId | ||
| } else if direction == "right", let callerSurface = tmuxCallerSurfaceHandle() { | ||
| // First right split created the column; seed main-vertical | ||
| // state so subsequent splits stack downward. | ||
| updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState( | ||
| mainSurfaceId: callerSurface, | ||
|
Comment on lines
+9608
to
+9612
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This block creates Useful? React with 👍 / 👎. |
||
| lastColumnSurfaceId: surfaceId | ||
| ) | ||
| } | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| try saveTmuxCompatStore(updatedStore) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+9603
to
+9616
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Protect tmux-compat store updates with a lock. These are now cross-process Also applies to: 9845-9855 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { | ||
| _ = try client.sendV2(method: "surface.send_text", params: [ | ||
| "workspace_id": target.workspaceId, | ||
|
|
@@ -9785,22 +9836,59 @@ struct CMUXCLI { | |
| let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) | ||
| _ = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) | ||
|
|
||
| case "select-layout", "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client": | ||
| case "select-layout": | ||
| let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) | ||
| let layoutName = parsed.positional.first ?? "" | ||
| if layoutName == "main-vertical" { | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) | ||
|
Comment on lines
+9842
to
+9843
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This branch only updates state when Useful? React with 👍 / 👎. |
||
| if let callerSurface = tmuxCallerSurfaceHandle() { | ||
| var store = loadTmuxCompatStore() | ||
| // Seed lastColumnSurfaceId from the most recent split if | ||
| // this is the first time main-vertical is set and a split | ||
| // already happened (the normal flow: split then layout). | ||
| let existingColumn = store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId | ||
| let seedColumn = existingColumn ?? store.lastSplitSurface[workspaceId] | ||
| store.mainVerticalLayouts[workspaceId] = MainVerticalState( | ||
| mainSurfaceId: callerSurface, | ||
| lastColumnSurfaceId: seedColumn | ||
| ) | ||
| try saveTmuxCompatStore(store) | ||
| } | ||
|
Comment on lines
+9839
to
+9856
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clear persisted main-vertical state when another layout is selected. This branch only mutates state for 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| case "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client": | ||
| return | ||
|
|
||
| default: | ||
| throw CLIError(message: "Unsupported tmux compatibility command: \(command)") | ||
| } | ||
| } | ||
|
|
||
| private struct MainVerticalState: Codable { | ||
| /// The surface ID of the "main" (leader) pane on the left side. | ||
| var mainSurfaceId: String | ||
| /// The surface ID of the bottom-most pane in the right column. | ||
| /// Subsequent teammate splits target this pane with direction "down". | ||
| var lastColumnSurfaceId: String? | ||
| } | ||
|
|
||
| private struct TmuxCompatStore: Codable { | ||
| var buffers: [String: String] = [:] | ||
| var hooks: [String: String] = [:] | ||
| /// Tracks main-vertical layout state per workspace, keyed by workspace ID. | ||
| var mainVerticalLayouts: [String: MainVerticalState] = [:] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Add backward-compatible decoding defaults for the new tmux-compat store fields. Older store files that do not contain these keys should still decode instead of resetting persisted state. Prompt for AI agents |
||
| /// Tracks the last surface created by split-window per workspace. | ||
| /// Used to seed lastColumnSurfaceId when select-layout main-vertical | ||
| /// is called after the first split. | ||
| var lastSplitSurface: [String: String] = [:] | ||
|
Comment on lines
+9879
to
+9883
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The two new non-optional Useful? React with 👍 / 👎. |
||
| } | ||
|
Comment on lines
9877
to
9884
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consider pruning the stale entries inside the existing |
||
|
|
||
| private func tmuxCompatStoreURL() -> URL { | ||
| let root = NSString(string: "~/.cmuxterm").expandingTildeInPath | ||
| return URL(fileURLWithPath: root).appendingPathComponent("tmux-compat-store.json") | ||
| let homePath = ProcessInfo.processInfo.environment["HOME"] | ||
| ?? NSString(string: "~").expandingTildeInPath | ||
| return URL(fileURLWithPath: homePath) | ||
| .appendingPathComponent(".cmuxterm") | ||
| .appendingPathComponent("tmux-compat-store.json") | ||
| } | ||
|
|
||
| private func loadTmuxCompatStore() -> TmuxCompatStore { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4565,7 +4565,8 @@ class TerminalController { | |||||||||
| v2MaybeFocusWindow(for: tabManager) | ||||||||||
| v2MaybeSelectWorkspace(tabManager, workspace: ws) | ||||||||||
|
|
||||||||||
| if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) { | ||||||||||
| let focus = v2Bool(params, "focus") ?? true | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Prompt for AI agents
Suggested change
|
||||||||||
| if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) { | ||||||||||
|
Comment on lines
+4568
to
+4569
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gate split focus through Line 4569 forwards the raw Suggested fix- let focus = v2Bool(params, "focus") ?? true
+ let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {Based on learnings: Socket/CLI commands must not steal macOS app focus. Only explicit focus-intent commands may mutate in-app focus/selection. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| let paneUUID = ws.paneId(forPanelId: newId)?.id | ||||||||||
| let windowId = v2ResolveWindowId(tabManager: tabManager) | ||||||||||
| result = .ok([ | ||||||||||
|
|
||||||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The new workspace fallback can pair
target.workspaceIdwithcallerSurface, creating an invalid cross-workspacesurface.splitrequest.Prompt for AI agents