From 518733299dd2541d1001d943998d2bccbdb83e66 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 02:42:01 -0700 Subject: [PATCH 01/23] test: preserve remote tmux pane identity across layout changes --- cmux.xcodeproj/project.pbxproj | 4 + .../RemoteTmuxMirrorLayoutIdentityTests.swift | 201 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index a06ba8aa886f..ba312f0f2938 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1065,6 +1065,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 7E110877D4049839B248D5B2 /* RemoteTmuxMirrorFrames.swift in Sources */ = {isa = PBXBuildFile; fileRef = 609C73EBCC5E35DDC4E76978 /* RemoteTmuxMirrorFrames.swift */; }; A11FBDE1CC12989770B70EBE /* RemoteTmuxMirrorGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32650AC37D12294A52E4D63 /* RemoteTmuxMirrorGeometry.swift */; }; BC22F721323079468ED57528 /* RemoteTmuxMirrorGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD61EDB9377BE136E564F02B /* RemoteTmuxMirrorGeometryTests.swift */; }; + 783300000000000000000002 /* RemoteTmuxMirrorLayoutIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 783300000000000000000001 /* RemoteTmuxMirrorLayoutIdentityTests.swift */; }; 74060000000000000000000A /* RemoteTmuxMirrorLayoutMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740600000000000000000009 /* RemoteTmuxMirrorLayoutMathTests.swift */; }; 736200000000000000000006 /* RemoteTmuxMirrorLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736200000000000000000005 /* RemoteTmuxMirrorLifecycleTests.swift */; }; D77330010000000000000004 /* RemoteTmuxMirrorMutationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77330010000000000000003 /* RemoteTmuxMirrorMutationCoordinator.swift */; }; @@ -2751,6 +2752,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 609C73EBCC5E35DDC4E76978 /* RemoteTmuxMirrorFrames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorFrames.swift; sourceTree = ""; }; D32650AC37D12294A52E4D63 /* RemoteTmuxMirrorGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorGeometry.swift; sourceTree = ""; }; AD61EDB9377BE136E564F02B /* RemoteTmuxMirrorGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorGeometryTests.swift; sourceTree = ""; }; + 783300000000000000000001 /* RemoteTmuxMirrorLayoutIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorLayoutIdentityTests.swift; sourceTree = ""; }; 740600000000000000000009 /* RemoteTmuxMirrorLayoutMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorLayoutMathTests.swift; sourceTree = ""; }; 736200000000000000000005 /* RemoteTmuxMirrorLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorLifecycleTests.swift; sourceTree = ""; }; D77330010000000000000003 /* RemoteTmuxMirrorMutationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorMutationCoordinator.swift; sourceTree = ""; }; @@ -5035,6 +5037,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 740600000000000000000009 /* RemoteTmuxMirrorLayoutMathTests.swift */, 74060000000000000000000B /* RemoteTmuxMirrorPlacementTests.swift */, 74060000000000000000000D /* RemoteTmuxWindowMirrorFocusSeedTests.swift */, + 783300000000000000000001 /* RemoteTmuxMirrorLayoutIdentityTests.swift */, 5C7032605BF360630E6E207A /* RemoteTmuxConnectionWindowSizingTests.swift */, 4D8BD9A2D8B7F7A27B77B289 /* RemoteTmuxRectPublicationTests.swift */, A7C9D1E3F5B7A9C1D3E5F701 /* RemoteTmuxWindowReorderTests.swift */, @@ -7048,6 +7051,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7738A0017738A0017738A001 /* RemoteTmuxMirrorCLIObservabilityTests.swift in Sources */, D4F8A2E61C5B39707A8E6F13 /* RemoteTmuxMirrorFeedForwardTests.swift in Sources */, BC22F721323079468ED57528 /* RemoteTmuxMirrorGeometryTests.swift in Sources */, + 783300000000000000000002 /* RemoteTmuxMirrorLayoutIdentityTests.swift in Sources */, 74060000000000000000000A /* RemoteTmuxMirrorLayoutMathTests.swift in Sources */, 736200000000000000000006 /* RemoteTmuxMirrorLifecycleTests.swift in Sources */, F11ED7AB0001000100010001 /* RemoteTmuxMirrorNewTabPlacementTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift new file mode 100644 index 000000000000..4fee6c42051b --- /dev/null +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -0,0 +1,201 @@ +import Bonsplit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct RemoteTmuxMirrorLayoutIdentityTests { + @Test("remote layout changes reconcile pane identities incrementally") + func remoteLayoutChangesReconcilePaneIdentitiesIncrementally() throws { + let harness = try Harness() + defer { harness.tearDown() } + + let originalPanel = try #require(harness.singlePanePanel(tmuxPaneID: 11)) + let originalSurfaceID = originalPanel.id + let originalPaneID = try #require( + harness.workspace.paneId(forPanelId: originalSurfaceID) + ) + + try harness.publishLayout( + "abcd,80x24,0,0[80x12,0,0,11,80x11,0,13,22]", + rects: [ + "%11 0 0 80 12 1 off :zsh", + "%22 0 13 80 11 0 off :zsh", + ] + ) + + let mirror = try #require(harness.windowMirror) + #expect(mirror.panel(forPane: 11) === originalPanel) + #expect(mirror.panel(forPane: 11)?.id == originalSurfaceID) + #expect(mirror.syntheticPaneID(forPane: 11) == originalPaneID) + #expect(Set(mirror.controlPanes().map(\.panel.id)).count == 2) + #expect(Set(mirror.controlPanes().map(\.panel.id)).contains(originalSurfaceID)) + + let secondSurfaceID = try #require(mirror.panel(forPane: 22)?.id) + let secondPaneID = try #require(mirror.syntheticPaneID(forPane: 22)) + try harness.publishLayout( + "abcd,80x24,0,0[80x12,0,0,11,80x11,0,13{40x11,0,13,22,39x11,41,13,33}]", + rects: [ + "%11 0 0 80 12 1 off :zsh", + "%22 0 13 40 11 0 off :zsh", + "%33 41 13 39 11 0 off :zsh", + ] + ) + + #expect(harness.windowMirror === mirror) + #expect(mirror.panel(forPane: 11) === originalPanel) + #expect(mirror.panel(forPane: 11)?.id == originalSurfaceID) + #expect(mirror.syntheticPaneID(forPane: 11) == originalPaneID) + #expect(mirror.panel(forPane: 22)?.id == secondSurfaceID) + #expect(mirror.syntheticPaneID(forPane: 22) == secondPaneID) + #expect(Set(mirror.controlPanes().map(\.panel.id)).count == 3) + + weak var removedSecondPanel: TerminalPanel? + removedSecondPanel = mirror.panel(forPane: 22) + try harness.publishLayout( + "abcd,80x24,0,0[80x12,0,0,11,80x11,0,13,33]", + rects: [ + "%11 0 0 80 12 1 off :zsh", + "%33 0 13 80 11 0 off :zsh", + ] + ) + + #expect(mirror.panel(forPane: 11) === originalPanel) + #expect(mirror.panel(forPane: 11)?.id == originalSurfaceID) + #expect(mirror.syntheticPaneID(forPane: 11) == originalPaneID) + #expect(mirror.panel(forPane: 22) == nil) + #expect(mirror.controlPane(surfaceID: secondSurfaceID) == nil) + #expect(harness.sessionMirror.paneId(forSurfaceId: secondSurfaceID) == nil) + #expect(removedSecondPanel == nil) + #expect(Set(mirror.controlPanes().map(\.tmuxPaneID)) == [11, 33]) + + let thirdSurfaceID = try #require(mirror.panel(forPane: 33)?.id) + weak var removedThirdPanel: TerminalPanel? + removedThirdPanel = mirror.panel(forPane: 33) + try harness.publishLayout( + "abcd,80x24,0,0,11", + rects: ["%11 0 0 80 24 1 off :zsh"] + ) + + #expect(harness.windowMirror === mirror) + #expect(mirror.panel(forPane: 11) === originalPanel) + #expect(mirror.panel(forPane: 11)?.id == originalSurfaceID) + #expect(mirror.syntheticPaneID(forPane: 11) == originalPaneID) + #expect(mirror.panel(forPane: 33) == nil) + #expect(mirror.controlPane(surfaceID: thirdSurfaceID) == nil) + #expect(harness.sessionMirror.paneId(forSurfaceId: thirdSurfaceID) == nil) + #expect(removedThirdPanel == nil) + #expect(mirror.controlPanes().map(\.tmuxPaneID) == [11]) + } +} + +@MainActor +private final class Harness { + let connection: RemoteTmuxControlConnection + let writer: RemoteTmuxControlPipeWriter + let pipe: Pipe + let manager: TabManager + let workspace: Workspace + let sessionMirror: RemoteTmuxSessionMirror + + init() throws { + connection = RemoteTmuxControlConnection( + host: RemoteTmuxHost(destination: "user@host"), + sessionName: "work" + ) + pipe = Pipe() + writer = RemoteTmuxControlPipeWriter( + handle: pipe.fileHandleForWriting, + label: "remote-tmux-layout-identity-test", + maxPendingBytes: 1 << 16, + onFailure: {} + ) + connection.installStdinWriterForTesting(writer) + connection.handleMessageForTesting(.enter) + connection.handleMessageForTesting( + .commandResult(commandNumber: 0, lines: [], isError: false) + ) + connection.handleMessageForTesting(.commandResult( + commandNumber: 1, + lines: ["@1 f92f,80x24,0,0,11 f92f,80x24,0,0,11 [] editor"], + isError: false + )) + connection.handleMessageForTesting(.commandResult( + commandNumber: 2, + lines: ["%11 0 0 80 24 1 off :zsh"], + isError: false + )) + + manager = TabManager(autoWelcomeIfNeeded: false) + workspace = try #require(manager.selectedWorkspace) + workspace.isRemoteTmuxMirror = true + sessionMirror = RemoteTmuxSessionMirror( + host: connection.host, + sessionName: "work", + connection: connection, + tabManager: manager, + workspace: workspace + ) + drainCommandsBeforeLayout() + } + + var windowMirror: RemoteTmuxWindowMirror? { + workspace.panels.keys.lazy.compactMap { + workspace.remoteTmuxWindowMirror(forPanelId: $0) + }.first + } + + func singlePanePanel(tmuxPaneID: Int) -> TerminalPanel? { + workspace.panels.values.compactMap { $0 as? TerminalPanel }.first { + sessionMirror.paneId(forSurfaceId: $0.id) == tmuxPaneID + } + } + + func publishLayout(_ layout: String, rects: [String]) throws { + drainCommandsBeforeLayout() + var parser = RemoteTmuxControlStreamParser() + let messages = parser.feed(Data("%layout-change @1 \(layout) \(layout) *\r\n".utf8)) + let message = try #require(messages.only) + connection.handleMessageForTesting(message) + + while let first = connection.pendingCommandKindsForTesting.first { + if case .paneRects = first { break } + connection.handleMessageForTesting( + .commandResult(commandNumber: 0, lines: [], isError: false) + ) + } + guard let first = connection.pendingCommandKindsForTesting.first, + case .paneRects = first else { + Issue.record("expected a pane rects command for layout \(layout)") + return + } + connection.handleMessageForTesting( + .commandResult(commandNumber: 0, lines: rects, isError: false) + ) + } + + func tearDown() { + sessionMirror.detachObserver() + workspace.isRemoteTmuxMirror = false + manager.tabs.forEach { $0.teardownAllPanels() } + writer.close() + try? pipe.fileHandleForReading.close() + } + + private func drainCommandsBeforeLayout() { + while !connection.pendingCommandKindsForTesting.isEmpty { + connection.handleMessageForTesting( + .commandResult(commandNumber: 0, lines: [], isError: false) + ) + } + } +} + +private extension Collection { + var only: Element? { count == 1 ? first : nil } +} From 0dc8aab1e64b09fb080023e1110fbac9dfbc4376 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 02:45:40 -0700 Subject: [PATCH 02/23] Fix remote tmux layout reconciliation identity --- Sources/RemoteTmuxSessionMirror.swift | 59 +++++++++---------- Sources/RemoteTmuxWindowMirror+Bonsplit.swift | 4 ++ Sources/RemoteTmuxWindowMirror.swift | 40 +++++++++---- 3 files changed, 60 insertions(+), 43 deletions(-) diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index d1946c88863b..cffbc9f08d4e 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -4,12 +4,8 @@ import CmuxRemoteSession /// Mirrors one remote tmux session into a dedicated cmux sidebar workspace. /// /// Owns the binding between a ``RemoteTmuxControlConnection`` and a ``Workspace``: -/// each tmux window becomes a tab (rendering that window's first pane via a -/// MANUAL-I/O display surface), pane output is routed to the right tab, and the -/// workspace's default local terminal tab is closed once remote tabs exist. -/// -/// Full pane→split mapping and window-close handling build on this first -/// session→workspace increment. +/// each tmux window becomes a tab, pane output is routed to its stable local +/// surface, and the workspace's default local tab is closed once mirrors exist. @MainActor final class RemoteTmuxSessionMirror { let host: RemoteTmuxHost @@ -196,10 +192,12 @@ final class RemoteTmuxSessionMirror { for paneId in window.paneIDsInOrder { windowIdByPane[paneId] = windowId } let title = Self.tabTitle(for: window) let panelId: UUID + let displayPanelWasCreated: Bool if let existing = panelIdByWindow[windowId] { // Existing tab — refresh its title if tmux renamed the window. workspace.updateRemoteTmuxTabTitle(panelId: existing, title: title) panelId = existing + displayPanelWasCreated = false } else { guard let panel = workspace.addRemoteTmuxDisplayPane( remotePaneId: firstPaneId, @@ -208,28 +206,16 @@ final class RemoteTmuxSessionMirror { onInput: { [weak connection] data in Task { @MainActor in connection?.sendKeys(paneId: firstPaneId, data: data) } }, - // Size THIS tmux window to the rendered grid so a single- - // pane window (the common case — where a claude / claude agents - // TUI runs) doesn't stay at ssh's default 80×24 and render - // mangled. Per-window, so it never fights the multi-pane - // mirrors' sizes. Acyclic: a display pane's grid follows its - // tab's local pixels only — tmux's layout never shapes its frame. + // A single-pane display drives this window from its rendered + // grid; multi-pane sizing transfers to the window mirror below. onResize: { [weak connection] columns, rows in connection?.setWindowSize(windowId: windowId, columns: columns, rows: rows) } ) else { continue } panelIdByWindow[windowId] = panel.id panelIdByPane[firstPaneId] = panel.id - // Initial claim, event-driven, two guaranteed paths and no - // polling: `onRuntimeReady` covers the surface that spawns - // already AT its final grid (it never applies a resize, so a - // report-based hook alone deadlocks the claim — the bug the - // old sleep-retry papered over), and `onManualSizeApplied` - // covers every later grid change, including the off-window - // apply a background workspace's surface flushes when it - // finally enters a window. Echo-safe: the connection dedups - // per-window sizes, and the multi-pane path clears both hooks - // when a window mirror takes ownership (reconcileWindowMirror). + // Claim from either runtime readiness or a later manual resize; + // adoption below replaces both hooks at the ownership boundary. if let terminalPanel = workspace.panels[panel.id] as? TerminalPanel { let surface = terminalPanel.surface surface.onRuntimeReady = { [weak connection, weak surface] in @@ -248,8 +234,15 @@ final class RemoteTmuxSessionMirror { connection.seedPane(paneId: firstPaneId) } panelId = panel.id + displayPanelWasCreated = true } - reconcileWindowMirror(windowId: windowId, panelId: panelId, window: window, in: workspace) + reconcileWindowMirror( + windowId: windowId, + panelId: panelId, + window: window, + displayPanelWasCreated: displayPanelWasCreated, + in: workspace + ) } // Close tabs for windows tmux removed, so a closed remote window doesn't // leave a frozen tab behind. @@ -267,6 +260,7 @@ final class RemoteTmuxSessionMirror { // Drop cached directories for panes tmux no longer reports, so the cache // stays bounded across window/pane churn (tmux pane ids never recur). let livePanes = Set(connection.windowsByID.values.flatMap { $0.paneIDsInOrder }) + panelIdByPane = panelIdByPane.filter { livePanes.contains($0.key) } cwdByPane = cwdByPane.filter { livePanes.contains($0.key) } titleFilters = titleFilters.filter { livePanes.contains($0.key) } closeDefaultTabsIfNeeded() @@ -302,6 +296,7 @@ final class RemoteTmuxSessionMirror { windowId: Int, panelId: UUID, window: RemoteTmuxWindow, + displayPanelWasCreated: Bool, in workspace: Workspace ) { if let mirror = windowMirrorByWindowId[windowId] { @@ -312,6 +307,15 @@ final class RemoteTmuxSessionMirror { return } guard window.paneIDsInOrder.count > 1 else { return } + let adoptedPanes: [RemoteTmuxWindowMirror.AdoptedPane] = panelIdByPane.compactMap { + tmuxPaneId, mappedPanelId in + guard !displayPanelWasCreated, + mappedPanelId == panelId, + window.paneIDsInOrder.contains(tmuxPaneId), + let panel = workspace.panels[panelId] as? TerminalPanel, + let paneId = workspace.paneId(forPanelId: panelId) else { return nil } + return (tmuxPaneId, panel, paneId) + } let mirror = RemoteTmuxWindowMirror( windowId: windowId, panelId: panelId, @@ -320,6 +324,7 @@ final class RemoteTmuxSessionMirror { appearance: workspace.bonsplitController.configuration.appearance, workspaceBonsplitController: workspace.bonsplitController, onControlPaneRemoved: TerminalController.remoteTmuxControlPaneRemovalHandler(), + adoptedPanes: adoptedPanes, makePanel: { [weak workspace, weak connection] tmuxPaneId in workspace?.makeRemoteTmuxPanePanel(onInput: { data in Task { @MainActor in connection?.sendKeys(paneId: tmuxPaneId, data: data) } @@ -338,14 +343,6 @@ final class RemoteTmuxSessionMirror { mirror.apply(window: window) windowMirrorByWindowId[windowId] = mirror workspace.setRemoteTmuxWindowMirror(mirror, forPanelId: panelId) - // The window mirror now owns client sizing for this window (it sends - // refresh-client -C for the whole multi-pane area). Clear the original - // single-pane display surface's resize hook so both paths don't drive the - // same connection with differently-computed sizes. - if let panel = workspace.panels[panelId] as? TerminalPanel { - panel.surface.onManualSizeApplied = nil - panel.surface.onRuntimeReady = nil - } } private func closeDefaultTabsIfNeeded() { diff --git a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift index 614b25a59618..e7ac08552a93 100644 --- a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift +++ b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift @@ -69,6 +69,7 @@ extension RemoteTmuxWindowMirror { ) else { return nil } tabIdByPaneId[paneId] = tabId paneIdByPaneId[paneId] = pane + if controlPaneIdByPaneId[paneId] == nil { controlPaneIdByPaneId[paneId] = pane } paneIdByBonsplitPane[pane] = paneId paneIdByTabId[tabId] = paneId return pane @@ -190,6 +191,9 @@ extension RemoteTmuxWindowMirror { guard let newPane else { return false } tabIdByPaneId[expansion.newPaneId] = tab.id paneIdByPaneId[expansion.newPaneId] = newPane + if controlPaneIdByPaneId[expansion.newPaneId] == nil { + controlPaneIdByPaneId[expansion.newPaneId] = newPane + } paneIdByBonsplitPane[newPane] = expansion.newPaneId paneIdByTabId[tab.id] = expansion.newPaneId return bonsplitTreeMatches(layout: desiredLayout) diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 4ab233151af2..12de196ddc7b 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -27,6 +27,8 @@ import Observation @MainActor @Observable final class RemoteTmuxWindowMirror { + typealias AdoptedPane = (tmuxPaneId: Int, panel: TerminalPanel, controlPaneId: PaneID) + /// tmux window id (the `@N` without the sigil). let windowId: Int /// The bonsplit tab's panel id this window renders into. @@ -74,6 +76,10 @@ final class RemoteTmuxWindowMirror { /// are always updated together in ``reconcile(layout:)``. @ObservationIgnored var panelsByPaneId: [Int: TerminalPanel] = [:] @ObservationIgnored var tabIdByPaneId: [Int: TabID] = [:] + /// Stable control-plane identities keyed by tmux pane id. These are not + /// Bonsplit node ids: the render tree may be rebuilt without changing the + /// identity automation holds for a surviving remote pane. + @ObservationIgnored var controlPaneIdByPaneId: [Int: PaneID] = [:] @ObservationIgnored var paneIdByPaneId: [Int: PaneID] = [:] @ObservationIgnored var paneIdByBonsplitPane: [PaneID: Int] = [:] @ObservationIgnored var paneIdByTabId: [TabID: Int] = [:] @@ -131,6 +137,7 @@ final class RemoteTmuxWindowMirror { workspaceBonsplitController: BonsplitController? = nil, geometrySource: (() -> RemoteTmuxMirrorGeometry?)? = nil, onControlPaneRemoved: ((PaneID, UUID) -> Void)? = nil, + adoptedPanes: [AdoptedPane] = [], makePanel: @escaping (_ tmuxPaneId: Int) -> TerminalPanel? ) { self.windowId = windowId @@ -146,6 +153,11 @@ final class RemoteTmuxWindowMirror { self.bonsplitController = Self.makeController(configuration: initialConfiguration) configureBonsplitController() observeWorkspaceBonsplitConfiguration() + for pane in adoptedPanes where layout.paneIDsInOrder.contains(pane.tmuxPaneId) { + panelsByPaneId[pane.tmuxPaneId] = pane.panel + controlPaneIdByPaneId[pane.tmuxPaneId] = pane.controlPaneId + configurePanePanel(pane.panel, paneId: pane.tmuxPaneId, needsSeed: false) + } reconcile(layout: layout) } @@ -160,7 +172,7 @@ final class RemoteTmuxWindowMirror { /// The stable native Bonsplit pane id for `tmuxPaneId`, if currently rendered. func syntheticPaneID(forPane tmuxPaneId: Int) -> PaneID? { - paneIdByPaneId[tmuxPaneId] + controlPaneIdByPaneId[tmuxPaneId] } /// Applies a full window update: panel lifecycle + sizing structure from @@ -189,17 +201,7 @@ final class RemoteTmuxWindowMirror { for paneId in newLayout.paneIDsInOrder where panelsByPaneId[paneId] == nil { guard let panel = makePanel(paneId) else { continue } panelsByPaneId[paneId] = panel - let surface = panel.surface - surface.onManualSizeApplied = { [weak self] in self?.handleSizingSample($0) } - surface.onRuntimeReady = { [weak self, weak surface] in - guard let sample = surface?.rawSizingSample() else { return } - self?.handleSizingSample(sample) - } - surface.flushPendingManualSizeReportIfAttached() - if let sample = surface.rawSizingSample() { handleSizingSample(sample) } - // Canonical seed (reflow classification → capture → cwd). The session - // mirror's cwd observer maps the pane back to this window's tab. - connection?.seedPane(paneId: paneId) + configurePanePanel(panel, paneId: paneId, needsSeed: true) } for (paneId, panel) in panelsByPaneId where !livePaneIds.contains(paneId) { // Use the full panel close (detaches the portal from the registry @@ -208,6 +210,7 @@ final class RemoteTmuxWindowMirror { panel.surface.onManualSizeApplied = nil panel.surface.onRuntimeReady = nil cleanupControlPane(tmuxPaneID: paneId) + controlPaneIdByPaneId[paneId] = nil panel.close() connection?.unsubscribePanePath(paneId: paneId) connection?.unsubscribePaneReflow(paneId: paneId) @@ -255,6 +258,18 @@ final class RemoteTmuxWindowMirror { } } + private func configurePanePanel(_ panel: TerminalPanel, paneId: Int, needsSeed: Bool) { + let surface = panel.surface + surface.onManualSizeApplied = { [weak self] in self?.handleSizingSample($0) } + surface.onRuntimeReady = { [weak self, weak surface] in + guard let sample = surface?.rawSizingSample() else { return } + self?.handleSizingSample(sample) + } + surface.flushPendingManualSizeReportIfAttached() + if let sample = surface.rawSizingSample() { handleSizingSample(sample) } + if needsSeed { connection?.seedPane(paneId: paneId) } + } + /// Routes a tmux `%output` to the surface for `paneId` (no-op if unknown). func routeOutput(paneId: Int, data: Data) { panelsByPaneId[paneId]?.surface.processRemoteOutput(data) @@ -449,6 +464,7 @@ final class RemoteTmuxWindowMirror { } panelsByPaneId.removeAll() tabIdByPaneId.removeAll() + controlPaneIdByPaneId.removeAll() paneIdByPaneId.removeAll() paneIdByBonsplitPane.removeAll() paneIdByTabId.removeAll() From 80a3273815d38daef4f352e30f9aa776fbea0122 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 03:00:40 -0700 Subject: [PATCH 03/23] Preserve initial multi-pane sizing ownership --- Sources/RemoteTmuxSessionMirror.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index cffbc9f08d4e..e656db9b4c45 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -343,8 +343,11 @@ final class RemoteTmuxSessionMirror { mirror.apply(window: window) windowMirrorByWindowId[windowId] = mirror workspace.setRemoteTmuxWindowMirror(mirror, forPanelId: panelId) + if adoptedPanes.isEmpty, let panel = workspace.panels[panelId] as? TerminalPanel { + panel.surface.onManualSizeApplied = nil + panel.surface.onRuntimeReady = nil + } } - private func closeDefaultTabsIfNeeded() { guard !defaultClosed, !panelIdByWindow.isEmpty, let workspace else { return } for panelId in defaultPanelIds where workspace.panels[panelId] != nil { From d78c79461f61706b42fe1dff56276305e4b4b351 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 03:19:54 -0700 Subject: [PATCH 04/23] Keep remote tmux control identities render-independent --- Sources/RemoteTmuxWindowMirror+Bonsplit.swift | 4 -- Sources/RemoteTmuxWindowMirror.swift | 1 + .../RemoteTmuxMirrorLayoutIdentityTests.swift | 48 +++++++++++++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift index e7ac08552a93..614b25a59618 100644 --- a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift +++ b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift @@ -69,7 +69,6 @@ extension RemoteTmuxWindowMirror { ) else { return nil } tabIdByPaneId[paneId] = tabId paneIdByPaneId[paneId] = pane - if controlPaneIdByPaneId[paneId] == nil { controlPaneIdByPaneId[paneId] = pane } paneIdByBonsplitPane[pane] = paneId paneIdByTabId[tabId] = paneId return pane @@ -191,9 +190,6 @@ extension RemoteTmuxWindowMirror { guard let newPane else { return false } tabIdByPaneId[expansion.newPaneId] = tab.id paneIdByPaneId[expansion.newPaneId] = newPane - if controlPaneIdByPaneId[expansion.newPaneId] == nil { - controlPaneIdByPaneId[expansion.newPaneId] = newPane - } paneIdByBonsplitPane[newPane] = expansion.newPaneId paneIdByTabId[tab.id] = expansion.newPaneId return bonsplitTreeMatches(layout: desiredLayout) diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 12de196ddc7b..9fab0cc1828b 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -201,6 +201,7 @@ final class RemoteTmuxWindowMirror { for paneId in newLayout.paneIDsInOrder where panelsByPaneId[paneId] == nil { guard let panel = makePanel(paneId) else { continue } panelsByPaneId[paneId] = panel + controlPaneIdByPaneId[paneId] = PaneID() configurePanePanel(panel, paneId: paneId, needsSeed: true) } for (paneId, panel) in panelsByPaneId where !livePaneIds.contains(paneId) { diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index 4fee6c42051b..4b9aec54e135 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -92,6 +92,45 @@ import Testing #expect(removedThirdPanel == nil) #expect(mirror.controlPanes().map(\.tmuxPaneID) == [11]) } + + @Test("fallback rebuild keeps control identities unique and stable") + func fallbackRebuildKeepsControlIdentitiesUniqueAndStable() throws { + let harness = try Harness( + initialLayout: "f92f,80x24,0,0[80x12,0,0,11,80x11,0,13,22]", + initialRects: [ + "%11 0 0 80 12 1 off :zsh", + "%22 0 13 80 11 0 off :zsh", + ] + ) + defer { harness.tearDown() } + + let mirror = try #require(harness.windowMirror) + let firstPanel = try #require(mirror.panel(forPane: 11)) + let secondPanel = try #require(mirror.panel(forPane: 22)) + let firstControlID = try #require(mirror.syntheticPaneID(forPane: 11)) + let secondControlID = try #require(mirror.syntheticPaneID(forPane: 22)) + + // Two coalesced additions cannot use the targeted single-leaf path. Put + // a new pane first so Bonsplit reuses its retained root node for it. + try harness.publishLayout( + "abcd,80x24,0,0[80x6,0,0,33,80x5,0,7,11,80x5,0,13,22,80x5,0,19,44]", + rects: [ + "%33 0 0 80 6 0 off :zsh", + "%11 0 7 80 5 1 off :zsh", + "%22 0 13 80 5 0 off :zsh", + "%44 0 19 80 5 0 off :zsh", + ] + ) + + #expect(harness.windowMirror === mirror) + #expect(mirror.panel(forPane: 11) === firstPanel) + #expect(mirror.panel(forPane: 22) === secondPanel) + #expect(mirror.syntheticPaneID(forPane: 11) == firstControlID) + #expect(mirror.syntheticPaneID(forPane: 22) == secondControlID) + let controlIDs = mirror.controlPanes().map(\.paneID) + #expect(controlIDs.count == 4) + #expect(Set(controlIDs).count == controlIDs.count) + } } @MainActor @@ -103,7 +142,10 @@ private final class Harness { let workspace: Workspace let sessionMirror: RemoteTmuxSessionMirror - init() throws { + init( + initialLayout: String = "f92f,80x24,0,0,11", + initialRects: [String] = ["%11 0 0 80 24 1 off :zsh"] + ) throws { connection = RemoteTmuxControlConnection( host: RemoteTmuxHost(destination: "user@host"), sessionName: "work" @@ -122,12 +164,12 @@ private final class Harness { ) connection.handleMessageForTesting(.commandResult( commandNumber: 1, - lines: ["@1 f92f,80x24,0,0,11 f92f,80x24,0,0,11 [] editor"], + lines: ["@1 \(initialLayout) \(initialLayout) [] editor"], isError: false )) connection.handleMessageForTesting(.commandResult( commandNumber: 2, - lines: ["%11 0 0 80 24 1 off :zsh"], + lines: initialRects, isError: false )) From baf237e7f780cc4741d3144b4fd6a020f4b1eaa7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 03:32:09 -0700 Subject: [PATCH 05/23] test: keep remote session pane identities distinct --- .../RemoteTmuxMirrorLayoutIdentityTests.swift | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index 4b9aec54e135..7ca51f7804c4 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -131,6 +131,50 @@ import Testing #expect(controlIDs.count == 4) #expect(Set(controlIDs).count == controlIDs.count) } + + @Test("session pane identities stay unique and stable across a window split") + func sessionPaneIdentitiesStayUniqueAndStableAcrossAWindowSplit() throws { + let harness = try Harness( + initialWindowLines: [ + "@1 f92f,80x24,0,0,11 f92f,80x24,0,0,11 [] editor", + "@2 abcd,80x24,0,0,44 abcd,80x24,0,0,44 [] logs", + ], + initialRects: [ + "%11 0 0 80 24 1 off :zsh", + "%44 0 0 80 24 0 off :zsh", + ] + ) + defer { harness.tearDown() } + + let editorPanel = try #require(harness.singlePanePanel(tmuxPaneID: 11)) + let logsPanel = try #require(harness.singlePanePanel(tmuxPaneID: 44)) + let editorSurfaceID = editorPanel.id + let logsSurfaceID = logsPanel.id + let editorControlID = try #require(harness.controlPaneID(surfaceID: editorSurfaceID)) + let logsControlID = try #require(harness.controlPaneID(surfaceID: logsSurfaceID)) + #expect(editorControlID != logsControlID) + + try harness.publishLayout( + "beef,80x24,0,0[80x12,0,0,11,80x11,0,13,22]", + rects: [ + "%11 0 0 80 12 1 off :zsh", + "%22 0 13 80 11 0 off :zsh", + "%44 0 0 80 24 0 off :zsh", + ] + ) + + let mirror = try #require(harness.windowMirror(windowID: 1)) + #expect(mirror.panel(forPane: 11) === editorPanel) + #expect(mirror.panel(forPane: 11)?.id == editorSurfaceID) + #expect(harness.singlePanePanel(tmuxPaneID: 44) === logsPanel) + #expect(harness.controlPaneID(surfaceID: editorSurfaceID) == editorControlID) + #expect(harness.controlPaneID(surfaceID: logsSurfaceID) == logsControlID) + let addedControlID = try #require( + mirror.panel(forPane: 22).flatMap { harness.controlPaneID(surfaceID: $0.id) } + ) + #expect(addedControlID != editorControlID) + #expect(addedControlID != logsControlID) + } } @MainActor @@ -144,6 +188,7 @@ private final class Harness { init( initialLayout: String = "f92f,80x24,0,0,11", + initialWindowLines: [String]? = nil, initialRects: [String] = ["%11 0 0 80 24 1 off :zsh"] ) throws { connection = RemoteTmuxControlConnection( @@ -164,7 +209,7 @@ private final class Harness { ) connection.handleMessageForTesting(.commandResult( commandNumber: 1, - lines: ["@1 \(initialLayout) \(initialLayout) [] editor"], + lines: initialWindowLines ?? ["@1 \(initialLayout) \(initialLayout) [] editor"], isError: false )) connection.handleMessageForTesting(.commandResult( @@ -192,6 +237,17 @@ private final class Harness { }.first } + func windowMirror(windowID: Int) -> RemoteTmuxWindowMirror? { + workspace.panels.keys.lazy.compactMap { + workspace.remoteTmuxWindowMirror(forPanelId: $0) + }.first(where: { $0.windowId == windowID }) + } + + func controlPaneID(surfaceID: UUID) -> UUID? { + TerminalController.shared.controlPaneList(workspace: workspace, tabManager: manager) + .panes.first(where: { $0.surfaceIDs.contains(surfaceID) })?.paneID + } + func singlePanePanel(tmuxPaneID: Int) -> TerminalPanel? { workspace.panels.values.compactMap { $0 as? TerminalPanel }.first { sessionMirror.paneId(forSurfaceId: $0.id) == tmuxPaneID From cce364999e577f2499e1d8c928975732f3578e34 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 03:33:27 -0700 Subject: [PATCH 06/23] test: publish initial remote windows atomically --- .../RemoteTmuxMirrorLayoutIdentityTests.swift | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index 7ca51f7804c4..e97314a999da 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -139,9 +139,9 @@ import Testing "@1 f92f,80x24,0,0,11 f92f,80x24,0,0,11 [] editor", "@2 abcd,80x24,0,0,44 abcd,80x24,0,0,44 [] logs", ], - initialRects: [ - "%11 0 0 80 24 1 off :zsh", - "%44 0 0 80 24 0 off :zsh", + initialRectsByWindow: [ + 1: ["%11 0 0 80 24 1 off :zsh"], + 2: ["%44 0 0 80 24 0 off :zsh"], ] ) defer { harness.tearDown() } @@ -189,7 +189,8 @@ private final class Harness { init( initialLayout: String = "f92f,80x24,0,0,11", initialWindowLines: [String]? = nil, - initialRects: [String] = ["%11 0 0 80 24 1 off :zsh"] + initialRects: [String] = ["%11 0 0 80 24 1 off :zsh"], + initialRectsByWindow: [Int: [String]]? = nil ) throws { connection = RemoteTmuxControlConnection( host: RemoteTmuxHost(destination: "user@host"), @@ -212,11 +213,15 @@ private final class Harness { lines: initialWindowLines ?? ["@1 \(initialLayout) \(initialLayout) [] editor"], isError: false )) - connection.handleMessageForTesting(.commandResult( - commandNumber: 2, - lines: initialRects, - isError: false - )) + let rectsByWindow = initialRectsByWindow ?? [1: initialRects] + while let kind = connection.pendingCommandKindsForTesting.first { + guard case .paneRects(let windowID, _) = kind else { break } + connection.handleMessageForTesting(.commandResult( + commandNumber: 2, + lines: rectsByWindow[windowID] ?? [], + isError: false + )) + } manager = TabManager(autoWelcomeIfNeeded: false) workspace = try #require(manager.selectedWorkspace) From 9f4c728ca0217a4e8d6b98bb2e8bb00ad9241ff1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 03:42:26 -0700 Subject: [PATCH 07/23] test: make mirror lookup capture explicit --- cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index e97314a999da..9c43f03f6092 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -238,13 +238,13 @@ private final class Harness { var windowMirror: RemoteTmuxWindowMirror? { workspace.panels.keys.lazy.compactMap { - workspace.remoteTmuxWindowMirror(forPanelId: $0) + self.workspace.remoteTmuxWindowMirror(forPanelId: $0) }.first } func windowMirror(windowID: Int) -> RemoteTmuxWindowMirror? { workspace.panels.keys.lazy.compactMap { - workspace.remoteTmuxWindowMirror(forPanelId: $0) + self.workspace.remoteTmuxWindowMirror(forPanelId: $0) }.first(where: { $0.windowId == windowID }) } From fae63cd0953e75f9541c91866340f163ff00fa1a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 04:05:21 -0700 Subject: [PATCH 08/23] ci: route helper build to SDK 15 runner --- .github/workflows/ci.yml | 5 ++++- tests/test_ci_release_sdk_lane.sh | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5517db17e4f1..fb6a50c91bb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -826,7 +826,10 @@ jobs: # transitive skip otherwise marks every macOS job skipped even when # linux-preflight itself succeeds. Require the direct needs explicitly. if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} - runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} + # This lane must have both the SDK-15 helper Xcode and the SDK-26 app + # Xcode. The general macOS 15 pool may point at lean Tart images that only + # carry Xcode 26, so keep helper production on its capability-specific pool. + runs-on: ${{ vars.MACOS_RUNNER_HELPER_15 || 'warp-macos-15-arm64-6x' }} timeout-minutes: 40 env: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }} diff --git a/tests/test_ci_release_sdk_lane.sh b/tests/test_ci_release_sdk_lane.sh index 99be8b32e0fa..6887d47a3b59 100755 --- a/tests/test_ci_release_sdk_lane.sh +++ b/tests/test_ci_release_sdk_lane.sh @@ -75,6 +75,11 @@ if [[ "$swift_package_section" != *"timeout-minutes: 40"* ]]; then exit 1 fi +if [[ "$swift_package_section" != *"runs-on: \${{ vars.MACOS_RUNNER_HELPER_15 || 'warp-macos-15-arm64-6x' }}"* ]]; then + echo "FAIL: CI swift-package-tests must use the SDK-15-helper-capable runner pool" >&2 + exit 1 +fi + if [[ "$swift_package_section" != *"CMUX_CI_HELPER_XCODE_APP"* ]]; then echo "FAIL: CI swift-package-tests must use a helper-specific Xcode pin" >&2 exit 1 From 24265abe75d7a0fe178fb8a1557a4d62a50c5c9b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 04:31:01 -0700 Subject: [PATCH 09/23] test: gate remote tmux identity regression directly --- .github/workflows/ci.yml | 19 +++++++++++++++++++ scripts/ci/cmux_unit_test_shard.py | 1 + tests/test_ci_change_areas.py | 10 ++++++++++ tests/test_ci_cmux_unit_test_shard.py | 1 + 4 files changed, 31 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb6a50c91bb5..be732555949c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -618,6 +618,25 @@ jobs: -only-testing:cmuxTests/RemoteTmuxWindowMirrorFocusSeedTests \ test + - name: Run remote tmux mirror layout identity regression + if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} + run: | + # The tolerant full-suite step may accept expected failures or stop + # after an app-host crash. Keep #7833's identity suite non-tolerant + # so a missing incremental-reconcile regression cannot pass the shard. + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + scripts/ci/run-in-console-session.sh \ + scripts/ci/run-app-host-xcodebuild.sh \ + -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + CMUX_SKIP_ZIG_BUILD=1 \ + -only-testing:cmuxTests/RemoteTmuxMirrorLayoutIdentityTests \ + test + - name: Run unit tests run: | set -euo pipefail diff --git a/scripts/ci/cmux_unit_test_shard.py b/scripts/ci/cmux_unit_test_shard.py index 9cde428d2616..ba8aa2bdf991 100755 --- a/scripts/ci/cmux_unit_test_shard.py +++ b/scripts/ci/cmux_unit_test_shard.py @@ -37,6 +37,7 @@ FOCUSED_GATE_SELECTORS = { "cmuxTests/BrowserSystemProxyMirrorTests", "cmuxTests/GhosttyOptionAsAltModsTests", + "cmuxTests/RemoteTmuxMirrorLayoutIdentityTests", } # BrowserDeveloperToolsVisibilityPersistenceTests reliably crash-restarts the # app host on CI runners (its detached-inspector tests kill the host mid-run; diff --git a/tests/test_ci_change_areas.py b/tests/test_ci_change_areas.py index 4e2f86643c39..79ed3424ccbf 100644 --- a/tests/test_ci_change_areas.py +++ b/tests/test_ci_change_areas.py @@ -616,6 +616,16 @@ def test_required_macos_topology_collapses_display_and_release_helper_jobs() -> assert "./scripts/build-ghostty-cli-helper.sh --universal --output ghostty-cli-helper/ghostty" not in release_block +def test_remote_tmux_layout_identity_uses_a_nontolerant_focused_gate() -> None: + block = workflow_job_block("app-host-unit-tests") + step = "Run remote tmux mirror layout identity regression" + selector = "-only-testing:cmuxTests/RemoteTmuxMirrorLayoutIdentityTests" + + assert step in block + assert selector in block + assert block.index(step) < block.index("- name: Run unit tests") + + def test_agent_session_web_resources_runs_only_for_agent_session_web_area() -> None: block = workflow_job_block("agent-session-web-resources") diff --git a/tests/test_ci_cmux_unit_test_shard.py b/tests/test_ci_cmux_unit_test_shard.py index 8683e030a44d..c8c2bf948324 100644 --- a/tests/test_ci_cmux_unit_test_shard.py +++ b/tests/test_ci_cmux_unit_test_shard.py @@ -296,6 +296,7 @@ def main() -> int: for focused_selector in ( "-only-testing:cmuxTests/BrowserSystemProxyMirrorTests", "-only-testing:cmuxTests/GhosttyOptionAsAltModsTests", + "-only-testing:cmuxTests/RemoteTmuxMirrorLayoutIdentityTests", ): if focused_selector in shard_selectors: print(f"FAIL: focused gate selector should not be folded into shard: {focused_selector}") From fb3553a35cae71222854c085429b4c3508c7484c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Jul 2026 04:55:49 -0700 Subject: [PATCH 10/23] Fix remote tmux session pane identity ownership --- ...TmuxControlConnection+CommandResults.swift | 1 + ...xControlConnection+LayoutPublication.swift | 31 ++++ Sources/RemoteTmuxControlConnection.swift | 2 + Sources/RemoteTmuxControlPane.swift | 3 +- Sources/RemoteTmuxControlPaneLocation.swift | 39 +++++ .../RemoteTmuxControlPaneMutationOwner.swift | 20 +++ Sources/RemoteTmuxController.swift | 4 +- ...oteTmuxSessionMirror+ControlTopology.swift | 148 ++++++++++++++++++ ...uxSessionMirror+WindowReconciliation.swift | 68 ++++++++ Sources/RemoteTmuxSessionMirror.swift | 125 +++++++-------- ...oteTmuxWindowMirror+ControlMutations.swift | 2 +- ...moteTmuxWindowMirror+ControlTopology.swift | 8 - Sources/RemoteTmuxWindowMirror.swift | 33 ++-- ...erminalController+ControlPaneContext.swift | 7 +- ...ontroller+RemoteTmuxControlMutations.swift | 24 ++- ...inalController+RemoteTmuxControlRefs.swift | 23 ++- ...Controller+RemoteTmuxControlTopology.swift | 35 +++-- .../Workspace+RemoteTmuxControlTopology.swift | 63 ++++++-- Sources/Workspace.swift | 3 + cmux.xcodeproj/project.pbxproj | 16 ++ ...emoteTmuxMirrorCLIObservabilityTests.swift | 22 ++- .../RemoteTmuxMirrorLayoutIdentityTests.swift | 82 +++++++++- 22 files changed, 593 insertions(+), 166 deletions(-) create mode 100644 Sources/RemoteTmuxControlPaneLocation.swift create mode 100644 Sources/RemoteTmuxControlPaneMutationOwner.swift create mode 100644 Sources/RemoteTmuxSessionMirror+ControlTopology.swift create mode 100644 Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift diff --git a/Sources/RemoteTmuxControlConnection+CommandResults.swift b/Sources/RemoteTmuxControlConnection+CommandResults.swift index 576f1e37da85..dc1faf815000 100644 --- a/Sources/RemoteTmuxControlConnection+CommandResults.swift +++ b/Sources/RemoteTmuxControlConnection+CommandResults.swift @@ -124,6 +124,7 @@ extension RemoteTmuxControlConnection { // only by its rects reply. Verified entries for surviving // windows stay as-is until then. windowsByID = windowsByID.filter { liveIDs.contains($0.key) } + prunePublishedPaneOwnership(liveWindowIds: liveIDs) pendingLayouts = pendingLayouts.filter { liveIDs.contains($0.key) } // A population that starts from an empty table (first attach, // reconnect reseed after every window closed) publishes diff --git a/Sources/RemoteTmuxControlConnection+LayoutPublication.swift b/Sources/RemoteTmuxControlConnection+LayoutPublication.swift index 338a6a7f84c1..a0a586676f42 100644 --- a/Sources/RemoteTmuxControlConnection+LayoutPublication.swift +++ b/Sources/RemoteTmuxControlConnection+LayoutPublication.swift @@ -109,6 +109,7 @@ extension RemoteTmuxControlConnection { func flushInitialBatchIfDrained() { guard let awaiting = initialBatchAwaiting, awaiting.isEmpty else { return } for (id, window) in initialBatchStaged { windowsByID[id] = window } + rebuildPublishedPaneOwnership() initialBatchStaged = [:] initialBatchAwaiting = nil prunePaneState(keeping: Set(windowsByID.values.flatMap { $0.paneIDsInOrder })) @@ -235,6 +236,10 @@ extension RemoteTmuxControlConnection { return } windowsByID[windowId] = published + recordPublishedPaneOwnership( + windowId: windowId, + paneIds: published.paneIDsInOrder + ) if !windowOrder.contains(windowId) { windowOrder.append(windowId) } prunePaneState(keeping: Set(windowsByID.values.flatMap { $0.paneIDsInOrder })) observers.notifyTopologyChanged() @@ -249,6 +254,32 @@ extension RemoteTmuxControlConnection { scheduleAttachRedrawKickIfNeeded() } + func recordPublishedPaneOwnership(windowId: Int, paneIds: [Int]) { + let livePaneIds = Set(paneIds) + publishedWindowIdByPane = publishedWindowIdByPane.filter { + $0.value != windowId || livePaneIds.contains($0.key) + } + for paneId in paneIds { publishedWindowIdByPane[paneId] = windowId } + } + + func removePublishedPaneOwnership(windowId: Int) { + publishedWindowIdByPane = publishedWindowIdByPane.filter { $0.value != windowId } + } + + func prunePublishedPaneOwnership(liveWindowIds: Set) { + publishedWindowIdByPane = publishedWindowIdByPane.filter { + liveWindowIds.contains($0.value) + } + } + + func rebuildPublishedPaneOwnership() { + publishedWindowIdByPane.removeAll(keepingCapacity: true) + for windowId in windowOrder { + guard let window = windowsByID[windowId] else { continue } + for paneId in window.paneIDsInOrder { publishedWindowIdByPane[paneId] = windowId } + } + } + /// Retry-or-drop for a rects fetch that errored: the pending layout must /// never be published raw, and must not dangle in-flight forever. diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index e0dd3e27c8df..36f3fbd649a7 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -48,6 +48,7 @@ final class RemoteTmuxControlConnection { private(set) var sessionId: Int? var windowsByID: [Int: RemoteTmuxWindow] = [:] var windowOrder: [Int] = [] + var publishedWindowIdByPane: [Int: Int] = [:] var activePaneByWindow: [Int: Int] = [:] var paneOutputByteCounts: [Int: Int] = [:] var totalOutputBytes = 0 @@ -733,6 +734,7 @@ final class RemoteTmuxControlConnection { } } activePaneByWindow[id] = nil + removePublishedPaneOwnership(windowId: id) windowsByID[id] = nil windowTitleRowsVisible[id] = nil windowOrder.removeAll { $0 == id } diff --git a/Sources/RemoteTmuxControlPane.swift b/Sources/RemoteTmuxControlPane.swift index f0b42f071b13..188fc0f996a3 100644 --- a/Sources/RemoteTmuxControlPane.swift +++ b/Sources/RemoteTmuxControlPane.swift @@ -1,8 +1,7 @@ import Bonsplit import Foundation -/// A read-only control-plane projection of one pane rendered inside a -/// multi-pane remote-tmux window mirror. +/// A read-only control-plane projection of one pane in a mirrored tmux session. @MainActor struct RemoteTmuxControlPane { let tmuxPaneID: Int diff --git a/Sources/RemoteTmuxControlPaneLocation.swift b/Sources/RemoteTmuxControlPaneLocation.swift new file mode 100644 index 000000000000..81199b517894 --- /dev/null +++ b/Sources/RemoteTmuxControlPaneLocation.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Resolves one projected tmux pane to its sole mutation owner. Production +/// session workspaces always use their session mirror; a standalone window +/// mirror can own mutations only when no session is bound to the workspace. +@MainActor +struct RemoteTmuxControlPaneLocation { + let containerPanelID: UUID + let owner: any RemoteTmuxControlPaneMutationOwner + let pane: RemoteTmuxControlPane + + func controlFocus() -> Bool { + owner.controlFocus(pane: pane.tmuxPaneID) + } + + func sendInput(_ text: String) -> Bool { + owner.sendInput(toPane: pane.tmuxPaneID, text: text) + } + + func sendKey(_ name: String) -> RemoteTmuxWindowMirror.ControlKeySendResult { + owner.sendKey(toPane: pane.tmuxPaneID, name: name) + } + + func requestSplit(vertical: Bool) -> Bool { + owner.requestSplit(fromPane: pane.tmuxPaneID, vertical: vertical) + } + + func requestRespawn(command: String, workingDirectory: String?) -> Bool { + owner.requestRespawnPane( + pane.tmuxPaneID, + command: command, + workingDirectory: workingDirectory + ) + } + + func requestKill() -> Bool { + owner.requestKillPane(pane.tmuxPaneID) + } +} diff --git a/Sources/RemoteTmuxControlPaneMutationOwner.swift b/Sources/RemoteTmuxControlPaneMutationOwner.swift new file mode 100644 index 000000000000..397d0b9e0c22 --- /dev/null +++ b/Sources/RemoteTmuxControlPaneMutationOwner.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Mutation boundary shared by session-owned pane projections and deliberately +/// standalone window-mirror fixtures. +@MainActor +protocol RemoteTmuxControlPaneMutationOwner: AnyObject { + func controlFocus(pane tmuxPaneID: Int) -> Bool + func sendInput(toPane tmuxPaneID: Int, text: String) -> Bool + func sendKey( + toPane tmuxPaneID: Int, + name: String + ) -> RemoteTmuxWindowMirror.ControlKeySendResult + func requestSplit(fromPane tmuxPaneID: Int, vertical: Bool) -> Bool + func requestRespawnPane( + _ tmuxPaneID: Int, + command: String, + workingDirectory: String? + ) -> Bool + func requestKillPane(_ tmuxPaneID: Int) -> Bool +} diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 1bb4b409566f..34c002e3ec08 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -320,7 +320,9 @@ final class RemoteTmuxController { seededSessionId: sessionId, connection: connection, tabManager: tabManager, - workspace: workspace + workspace: workspace, + onControlPaneRemoved: TerminalController.remoteTmuxControlPaneRemovalHandler(), + onControlSurfaceRemoved: TerminalController.remoteTmuxControlSurfaceRemovalHandler() ) return true } diff --git a/Sources/RemoteTmuxSessionMirror+ControlTopology.swift b/Sources/RemoteTmuxSessionMirror+ControlTopology.swift new file mode 100644 index 000000000000..eb23f065f32f --- /dev/null +++ b/Sources/RemoteTmuxSessionMirror+ControlTopology.swift @@ -0,0 +1,148 @@ +import Bonsplit +import Foundation + +@MainActor +extension RemoteTmuxSessionMirror { + func controlPaneID(forPane tmuxPaneID: Int) -> PaneID? { + controlPaneIdByPane[tmuxPaneID] + } + + func reconcileControlPaneIdentities(livePaneIDs: Set) { + let removedPaneIDs = controlPaneIdByPane.keys.filter { !livePaneIDs.contains($0) } + for tmuxPaneID in removedPaneIDs { + cleanupControlPaneIdentity(tmuxPaneID: tmuxPaneID) + controlPaneIdByPane[tmuxPaneID] = nil + } + for tmuxPaneID in livePaneIDs where controlPaneIdByPane[tmuxPaneID] == nil { + controlPaneIdByPane[tmuxPaneID] = PaneID() + } + } + + func teardownControlPaneIdentities() { + for tmuxPaneID in controlPaneIdByPane.keys { + cleanupControlPaneIdentity(tmuxPaneID: tmuxPaneID) + } + controlPaneIdByPane.removeAll() + controlSurfaceIdByPane.removeAll() + } + + func updateControlSurface(tmuxPaneID: Int, surfaceID: UUID?, windowID: Int?) { + guard controlPaneIdByPane[tmuxPaneID] != nil else { return } + if let ownerWindowID = windowIdByPane[tmuxPaneID] { + guard ownerWindowID == windowID else { return } + } else if surfaceID != nil { + return + } + let previousSurfaceID = controlSurfaceIdByPane[tmuxPaneID] + guard previousSurfaceID != surfaceID else { return } + if let previousSurfaceID { onControlSurfaceRemoved(previousSurfaceID) } + controlSurfaceIdByPane[tmuxPaneID] = surfaceID + } + + func controlPaneLocations( + containerPanelID requestedContainerPanelID: UUID? = nil + ) -> [RemoteTmuxControlPaneLocation] { + guard let workspace else { return [] } + let windowIDs: [Int] + if let requestedContainerPanelID { + guard let windowID = windowIdByPanel[requestedContainerPanelID] else { return [] } + windowIDs = [windowID] + } else { + windowIDs = connection.windowOrder + } + return windowIDs.flatMap { windowID -> [RemoteTmuxControlPaneLocation] in + guard let containerPanelID = self.panelIdByWindow[windowID], + let window = self.connection.windowsByID[windowID] else { return [] } + let windowMirror = self.windowMirrorByWindowId[windowID] + if let windowMirror { + return windowMirror.controlPanes().compactMap { + guard self.windowIdByPane[$0.tmuxPaneID] == windowID else { return nil } + return RemoteTmuxControlPaneLocation( + containerPanelID: containerPanelID, + owner: self, + pane: $0 + ) + } + } + guard let tmuxPaneID = window.paneIDsInOrder.first, + self.windowIdByPane[tmuxPaneID] == windowID, + let paneID = self.controlPaneIdByPane[tmuxPaneID], + let panelID = self.panelIdByPane[tmuxPaneID], + let panel = workspace.panels[panelID] as? TerminalPanel else { return [] } + let pane = RemoteTmuxControlPane( + tmuxPaneID: tmuxPaneID, + paneID: paneID, + panel: panel, + title: workspace.panelTitle(panelId: panelID) ?? panel.displayTitle, + isFocused: true + ) + return [RemoteTmuxControlPaneLocation( + containerPanelID: containerPanelID, + owner: self, + pane: pane + )] + } + } + + func controlPaneLocation(paneID: UUID) -> RemoteTmuxControlPaneLocation? { + controlPaneLocations().first(where: { $0.pane.paneID.id == paneID }) + } + + func controlPaneLocation(surfaceID: UUID) -> RemoteTmuxControlPaneLocation? { + controlPaneLocations().first(where: { $0.pane.panel.id == surfaceID }) + } + + func controlFocus(pane tmuxPaneID: Int) -> Bool { + guard let windowID = windowIdByPane[tmuxPaneID] else { return false } + return connection.send("select-pane -t @\(windowID).%\(tmuxPaneID)") + } + + func sendInput(toPane tmuxPaneID: Int, text: String) -> Bool { + guard controlPaneIdByPane[tmuxPaneID] != nil, + let data = text.data(using: .utf8) else { return false } + return connection.sendKeys(paneId: tmuxPaneID, data: data) + } + + func sendKey( + toPane tmuxPaneID: Int, + name: String + ) -> RemoteTmuxWindowMirror.ControlKeySendResult { + guard controlPaneIdByPane[tmuxPaneID] != nil else { return .rejected } + guard let key = RemoteTmuxWindowMirror.tmuxKeyName(name) else { return .unknownKey } + return connection.send("send-keys -t %\(tmuxPaneID) \(key)") ? .sent : .rejected + } + + func requestSplit(fromPane tmuxPaneID: Int, vertical: Bool) -> Bool { + guard let windowID = windowIdByPane[tmuxPaneID] else { return false } + return connection.send( + "split-window \(vertical ? "-v" : "-h") -t @\(windowID).%\(tmuxPaneID)" + ) + } + + func requestRespawnPane( + _ tmuxPaneID: Int, + command shellCommand: String, + workingDirectory: String? + ) -> Bool { + guard let windowID = windowIdByPane[tmuxPaneID], + RemoteTmuxHost.controlModeLineSafeName(shellCommand) != nil else { return false } + var command = "respawn-pane -k -t @\(windowID).%\(tmuxPaneID)" + if let directory = workingDirectory { + guard RemoteTmuxHost.controlModeLineSafeName(directory) != nil else { return false } + command += " -c \(RemoteTmuxHost.shellSingleQuoted(directory))" + } + command += " \(RemoteTmuxHost.shellSingleQuoted(shellCommand))" + return connection.send(command) + } + + func requestKillPane(_ tmuxPaneID: Int) -> Bool { + guard let windowID = windowIdByPane[tmuxPaneID] else { return false } + return connection.send("kill-pane -t @\(windowID).%\(tmuxPaneID)") + } + + private func cleanupControlPaneIdentity(tmuxPaneID: Int) { + guard let paneID = controlPaneIdByPane[tmuxPaneID] else { return } + let surfaceID = controlSurfaceIdByPane.removeValue(forKey: tmuxPaneID) + onControlPaneRemoved(paneID, surfaceID) + } +} diff --git a/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift b/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift new file mode 100644 index 000000000000..5ef55da947d0 --- /dev/null +++ b/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift @@ -0,0 +1,68 @@ +import Foundation +import CmuxRemoteSession + +@MainActor +extension RemoteTmuxSessionMirror { + /// Creates or reconciles the in-tab multi-pane renderer. A surviving + /// single-pane display transfers only its panel; control identity remains + /// owned by the session-wide pane ledger. + func reconcileWindowMirror( + windowId: Int, + panelId: UUID, + window: RemoteTmuxWindow, + displayPanelWasCreated: Bool, + in workspace: Workspace + ) { + if let mirror = windowMirrorByWindowId[windowId] { + mirror.apply(window: window) + for paneId in window.paneIDsInOrder { + if let cwd = cwdByPane[paneId] { mirror.updatePaneCwd(paneId: paneId, path: cwd) } + } + return + } + guard window.paneIDsInOrder.count > 1 else { return } + let adoptedPanes: [RemoteTmuxWindowMirror.AdoptedPane] = panelIdByPane.compactMap { + tmuxPaneId, mappedPanelId in + guard !displayPanelWasCreated, + mappedPanelId == panelId, + window.paneIDsInOrder.contains(tmuxPaneId), + let panel = workspace.panels[panelId] as? TerminalPanel else { return nil } + return (tmuxPaneId, panel) + } + let mirror = RemoteTmuxWindowMirror( + windowId: windowId, + panelId: panelId, + connection: connection, + layout: window.layout, + appearance: workspace.bonsplitController.configuration.appearance, + workspaceBonsplitController: workspace.bonsplitController, + controlPaneID: { [weak self] in self?.controlPaneID(forPane: $0) }, + onControlSurfaceChanged: { [weak self] tmuxPaneID, surfaceID in + self?.updateControlSurface( + tmuxPaneID: tmuxPaneID, + surfaceID: surfaceID, + windowID: windowId + ) + }, + adoptedPanes: adoptedPanes, + makePanel: { [weak workspace, weak connection] tmuxPaneId in + workspace?.makeRemoteTmuxPanePanel(onInput: { data in + Task { @MainActor in connection?.sendKeys(paneId: tmuxPaneId, data: data) } + }) + } + ) + mirror.onClosePaneRequest = { [weak workspace, weak mirror] tmuxPaneId in + guard let mirror else { return } + workspace?.requestRemoteTmuxPaneClose(windowMirror: mirror, tmuxPaneId: tmuxPaneId) + } + // The window can already be zoomed when its first topology publish + // arrives; apply the full update after seeding the base tree. + mirror.apply(window: window) + windowMirrorByWindowId[windowId] = mirror + workspace.setRemoteTmuxWindowMirror(mirror, forPanelId: panelId) + if adoptedPanes.isEmpty, let panel = workspace.panels[panelId] as? TerminalPanel { + panel.surface.onManualSizeApplied = nil + panel.surface.onRuntimeReady = nil + } + } +} diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index e656db9b4c45..55a36def70cd 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -1,3 +1,4 @@ +import Bonsplit import Foundation import CmuxRemoteSession @@ -7,13 +8,15 @@ import CmuxRemoteSession /// each tmux window becomes a tab, pane output is routed to its stable local /// surface, and the workspace's default local tab is closed once mirrors exist. @MainActor -final class RemoteTmuxSessionMirror { +final class RemoteTmuxSessionMirror: RemoteTmuxControlPaneMutationOwner { let host: RemoteTmuxHost private(set) var sessionName: String /// Discovery's stable tmux session id (`$N`), seeded at creation so id-based /// de-dup works before the control stream reports `connection.sessionId`. let seededSessionId: Int? let connection: RemoteTmuxControlConnection + let onControlPaneRemoved: (PaneID, UUID?) -> Void + let onControlSurfaceRemoved: (UUID) -> Void /// Updates the tracked session name after a `rename-session`. func setSessionName(_ name: String) { sessionName = name } @@ -49,22 +52,25 @@ final class RemoteTmuxSessionMirror { } private weak var tabManager: TabManager? - private weak var workspace: Workspace? + weak var workspace: Workspace? /// The workspace currently backing this mirror, if it has not been released. var mirroredWorkspace: Workspace? { workspace } private let defaultPanelIds: [UUID] private var defaultClosed = false - private var panelIdByWindow: [Int: UUID] = [:] - private var panelIdByPane: [Int: UUID] = [:] - private var windowIdByPane: [Int: Int] = [:] + var panelIdByWindow: [Int: UUID] = [:] + var windowIdByPanel: [UUID: Int] = [:] + var panelIdByPane: [Int: UUID] = [:] + var windowIdByPane: [Int: Int] = [:] + var controlPaneIdByPane: [Int: PaneID] = [:] + var controlSurfaceIdByPane: [Int: UUID] = [:] /// Last-known working directory per tmux pane, so switching the active pane of /// a multi-pane window can re-project that pane's directory onto the tab. - private var cwdByPane: [Int: String] = [:] + var cwdByPane: [Int: String] = [:] /// Per-pane filter that strips the screen/tmux `ESC k ST` window-title /// escape from `%output` (stateful across chunk boundaries). private var titleFilters: [Int: RemoteTmuxScreenTitleFilter] = [:] /// Per-window multi-pane renderers (present once a window has >1 pane). - private var windowMirrorByWindowId: [Int: RemoteTmuxWindowMirror] = [:] + var windowMirrorByWindowId: [Int: RemoteTmuxWindowMirror] = [:] private var pendingExplicitFocusWindowId: Int? private var observerToken: RemoteTmuxControlConnection.ObserverToken? @@ -74,15 +80,20 @@ final class RemoteTmuxSessionMirror { seededSessionId: Int? = nil, connection: RemoteTmuxControlConnection, tabManager: TabManager, - workspace: Workspace + workspace: Workspace, + onControlPaneRemoved: @escaping (PaneID, UUID?) -> Void = { _, _ in }, + onControlSurfaceRemoved: @escaping (UUID) -> Void = { _ in } ) { self.host = host self.sessionName = sessionName self.seededSessionId = seededSessionId self.connection = connection + self.onControlPaneRemoved = onControlPaneRemoved + self.onControlSurfaceRemoved = onControlSurfaceRemoved self.tabManager = tabManager self.workspace = workspace self.defaultPanelIds = Array(workspace.panels.keys) + workspace.remoteTmuxSessionMirror = self // Register as one of possibly several observers — never overwrite a // single shared closure on the connection. @@ -147,7 +158,7 @@ final class RemoteTmuxSessionMirror { /// The tmux window id whose mirrored tab is backed by `panelId`, if any. func windowId(forPanel panelId: UUID) -> Int? { - panelIdByWindow.first(where: { $0.value == panelId })?.key + windowIdByPanel[panelId] } /// Deregisters this mirror's connection observer and tears down all per-window @@ -158,12 +169,17 @@ final class RemoteTmuxSessionMirror { connection.removeObserver(observerToken) self.observerToken = nil } + teardownControlPaneIdentities() workspace?.remoteTmuxWindowOrderSync = nil + if workspace?.remoteTmuxSessionMirror === self { + workspace?.remoteTmuxSessionMirror = nil + } for mirror in windowMirrorByWindowId.values { workspace?.setRemoteTmuxWindowMirror(nil, forPanelId: mirror.panelId) mirror.teardown() } windowMirrorByWindowId.removeAll() + windowIdByPanel.removeAll() windowIdByPane.removeAll() } @@ -185,11 +201,29 @@ final class RemoteTmuxSessionMirror { } private func rebuildTopology(in workspace: Workspace) { - windowIdByPane.removeAll(keepingCapacity: true) + let livePanes = Set(connection.windowsByID.values.flatMap { $0.paneIDsInOrder }) + let pendingPanes = Set(connection.pendingLayouts.values.flatMap { $0.node.paneIDsInOrder }) + reconcileControlPaneIdentities(livePaneIDs: livePanes.union(pendingPanes)) + windowIdByPane = connection.publishedWindowIdByPane + let previousPanelIdByPane = panelIdByPane + var nextPanelIdByPane: [Int: UUID] = [:] + for (windowId, panelId) in panelIdByWindow { + guard let firstPaneId = connection.windowsByID[windowId]?.paneIDsInOrder.first, + windowIdByPane[firstPaneId] == windowId else { continue } + nextPanelIdByPane[firstPaneId] = panelId + } + panelIdByPane = nextPanelIdByPane + for (paneId, previousPanelId) in previousPanelIdByPane + where nextPanelIdByPane[paneId] != previousPanelId { + updateControlSurface( + tmuxPaneID: paneId, + surfaceID: nil, + windowID: windowIdByPanel[previousPanelId] + ) + } for windowId in connection.windowOrder { guard let window = connection.windowsByID[windowId], let firstPaneId = window.paneIDsInOrder.first else { continue } - for paneId in window.paneIDsInOrder { windowIdByPane[paneId] = windowId } let title = Self.tabTitle(for: window) let panelId: UUID let displayPanelWasCreated: Bool @@ -213,6 +247,7 @@ final class RemoteTmuxSessionMirror { } ) else { continue } panelIdByWindow[windowId] = panel.id + windowIdByPanel[panel.id] = windowId panelIdByPane[firstPaneId] = panel.id // Claim from either runtime readiness or a later manual resize; // adoption below replaces both hooks at the ownership boundary. @@ -236,6 +271,15 @@ final class RemoteTmuxSessionMirror { panelId = panel.id displayPanelWasCreated = true } + if window.paneIDsInOrder.count == 1, + windowMirrorByWindowId[windowId] == nil, + let panel = workspace.panels[panelId] as? TerminalPanel { + updateControlSurface( + tmuxPaneID: firstPaneId, + surfaceID: panel.id, + windowID: windowId + ) + } reconcileWindowMirror( windowId: windowId, panelId: panelId, @@ -255,11 +299,11 @@ final class RemoteTmuxSessionMirror { } _ = workspace.removeRemoteTmuxDisplayPane(panelId) panelIdByWindow[windowId] = nil + windowIdByPanel[panelId] = nil panelIdByPane = panelIdByPane.filter { $0.value != panelId } } // Drop cached directories for panes tmux no longer reports, so the cache // stays bounded across window/pane churn (tmux pane ids never recur). - let livePanes = Set(connection.windowsByID.values.flatMap { $0.paneIDsInOrder }) panelIdByPane = panelIdByPane.filter { livePanes.contains($0.key) } cwdByPane = cwdByPane.filter { livePanes.contains($0.key) } titleFilters = titleFilters.filter { livePanes.contains($0.key) } @@ -291,63 +335,6 @@ final class RemoteTmuxSessionMirror { workspace.focusPanel(panelId) } - /// Creates or reconciles the in-tab multi-pane renderer. - private func reconcileWindowMirror( - windowId: Int, - panelId: UUID, - window: RemoteTmuxWindow, - displayPanelWasCreated: Bool, - in workspace: Workspace - ) { - if let mirror = windowMirrorByWindowId[windowId] { - mirror.apply(window: window) - for paneId in window.paneIDsInOrder { - if let cwd = cwdByPane[paneId] { mirror.updatePaneCwd(paneId: paneId, path: cwd) } - } - return - } - guard window.paneIDsInOrder.count > 1 else { return } - let adoptedPanes: [RemoteTmuxWindowMirror.AdoptedPane] = panelIdByPane.compactMap { - tmuxPaneId, mappedPanelId in - guard !displayPanelWasCreated, - mappedPanelId == panelId, - window.paneIDsInOrder.contains(tmuxPaneId), - let panel = workspace.panels[panelId] as? TerminalPanel, - let paneId = workspace.paneId(forPanelId: panelId) else { return nil } - return (tmuxPaneId, panel, paneId) - } - let mirror = RemoteTmuxWindowMirror( - windowId: windowId, - panelId: panelId, - connection: connection, - layout: window.layout, - appearance: workspace.bonsplitController.configuration.appearance, - workspaceBonsplitController: workspace.bonsplitController, - onControlPaneRemoved: TerminalController.remoteTmuxControlPaneRemovalHandler(), - adoptedPanes: adoptedPanes, - makePanel: { [weak workspace, weak connection] tmuxPaneId in - workspace?.makeRemoteTmuxPanePanel(onInput: { data in - Task { @MainActor in connection?.sendKeys(paneId: tmuxPaneId, data: data) } - }) - } - ) - mirror.onClosePaneRequest = { [weak workspace, weak mirror] tmuxPaneId in - guard let mirror else { return } - workspace?.requestRemoteTmuxPaneClose(windowMirror: mirror, tmuxPaneId: tmuxPaneId) - } - // The window can already be zoomed when its first topology publish - // arrives (attached to a session zoomed before connect): init seeds - // only the base tree, so apply the full update to adopt - // visibleLayout/zoomed too. Reconciling the identical base layout - // again is a no-op (same structure signature, equal tree). - mirror.apply(window: window) - windowMirrorByWindowId[windowId] = mirror - workspace.setRemoteTmuxWindowMirror(mirror, forPanelId: panelId) - if adoptedPanes.isEmpty, let panel = workspace.panels[panelId] as? TerminalPanel { - panel.surface.onManualSizeApplied = nil - panel.surface.onRuntimeReady = nil - } - } private func closeDefaultTabsIfNeeded() { guard !defaultClosed, !panelIdByWindow.isEmpty, let workspace else { return } for panelId in defaultPanelIds where workspace.panels[panelId] != nil { diff --git a/Sources/RemoteTmuxWindowMirror+ControlMutations.swift b/Sources/RemoteTmuxWindowMirror+ControlMutations.swift index bfe31b8f056b..1aa274e5cf08 100644 --- a/Sources/RemoteTmuxWindowMirror+ControlMutations.swift +++ b/Sources/RemoteTmuxWindowMirror+ControlMutations.swift @@ -18,7 +18,7 @@ extension RemoteTmuxWindowMirror { return sendControlCommand("send-keys -t %\(tmuxPaneID) \(key)") ? .sent : .rejected } - private static func tmuxKeyName(_ raw: String) -> String? { + static func tmuxKeyName(_ raw: String) -> String? { let normalized = raw.lowercased().replacingOccurrences(of: "+", with: "-") let aliases: [String: String] = [ "enter": "Enter", "return": "Enter", "tab": "Tab", diff --git a/Sources/RemoteTmuxWindowMirror+ControlTopology.swift b/Sources/RemoteTmuxWindowMirror+ControlTopology.swift index eda9ca568f63..250d95cbf6a5 100644 --- a/Sources/RemoteTmuxWindowMirror+ControlTopology.swift +++ b/Sources/RemoteTmuxWindowMirror+ControlTopology.swift @@ -34,12 +34,4 @@ extension RemoteTmuxWindowMirror { guard let activePaneId else { return nil } return controlPanes().first(where: { $0.tmuxPaneID == activePaneId }) } - - /// Drops app-lifetime control refs before a projected pane leaves the - /// mirror-owned topology; these panels bypass Workspace panel lifecycle. - func cleanupControlPane(tmuxPaneID: Int) { - guard let paneID = syntheticPaneID(forPane: tmuxPaneID), - let surfaceID = panel(forPane: tmuxPaneID)?.id else { return } - onControlPaneRemoved?(paneID, surfaceID) - } } diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 9fab0cc1828b..787c858585fc 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -26,8 +26,8 @@ import Observation /// user state and are never written. @MainActor @Observable -final class RemoteTmuxWindowMirror { - typealias AdoptedPane = (tmuxPaneId: Int, panel: TerminalPanel, controlPaneId: PaneID) +final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { + typealias AdoptedPane = (tmuxPaneId: Int, panel: TerminalPanel) /// tmux window id (the `@N` without the sigil). let windowId: Int @@ -42,8 +42,9 @@ final class RemoteTmuxWindowMirror { /// Creates a configured manual-I/O pane panel whose input goes to `tmuxPaneId`. @ObservationIgnored let makePanel: (_ tmuxPaneId: Int) -> TerminalPanel? @ObservationIgnored var onClosePaneRequest: ((Int) -> Void)? - /// Removes projected control refs when a mirror-owned panel leaves topology. - @ObservationIgnored let onControlPaneRemoved: ((PaneID, UUID) -> Void)? + /// Session-owned control identity lookup. Render nodes are replaceable. + @ObservationIgnored private let controlPaneID: (Int) -> PaneID? + @ObservationIgnored private let onControlSurfaceChanged: ((Int, UUID?) -> Void)? /// The window's BASE pane layout (tmux's full tree even while a pane is /// zoomed). Drives panel lifecycle and the sizing structure fold. @@ -76,10 +77,6 @@ final class RemoteTmuxWindowMirror { /// are always updated together in ``reconcile(layout:)``. @ObservationIgnored var panelsByPaneId: [Int: TerminalPanel] = [:] @ObservationIgnored var tabIdByPaneId: [Int: TabID] = [:] - /// Stable control-plane identities keyed by tmux pane id. These are not - /// Bonsplit node ids: the render tree may be rebuilt without changing the - /// identity automation holds for a surviving remote pane. - @ObservationIgnored var controlPaneIdByPaneId: [Int: PaneID] = [:] @ObservationIgnored var paneIdByPaneId: [Int: PaneID] = [:] @ObservationIgnored var paneIdByBonsplitPane: [PaneID: Int] = [:] @ObservationIgnored var paneIdByTabId: [TabID: Int] = [:] @@ -136,7 +133,8 @@ final class RemoteTmuxWindowMirror { appearance: BonsplitConfiguration.Appearance = .init(), workspaceBonsplitController: BonsplitController? = nil, geometrySource: (() -> RemoteTmuxMirrorGeometry?)? = nil, - onControlPaneRemoved: ((PaneID, UUID) -> Void)? = nil, + controlPaneID: @escaping (Int) -> PaneID? = { _ in nil }, + onControlSurfaceChanged: ((Int, UUID?) -> Void)? = nil, adoptedPanes: [AdoptedPane] = [], makePanel: @escaping (_ tmuxPaneId: Int) -> TerminalPanel? ) { @@ -146,7 +144,8 @@ final class RemoteTmuxWindowMirror { self.workspaceBonsplitController = workspaceBonsplitController self.makePanel = makePanel self.geometrySource = geometrySource - self.onControlPaneRemoved = onControlPaneRemoved + self.controlPaneID = controlPaneID + self.onControlSurfaceChanged = onControlSurfaceChanged self.layout = layout let initialConfiguration = workspaceBonsplitController?.configuration ?? BonsplitConfiguration(appearance: appearance) @@ -155,7 +154,7 @@ final class RemoteTmuxWindowMirror { observeWorkspaceBonsplitConfiguration() for pane in adoptedPanes where layout.paneIDsInOrder.contains(pane.tmuxPaneId) { panelsByPaneId[pane.tmuxPaneId] = pane.panel - controlPaneIdByPaneId[pane.tmuxPaneId] = pane.controlPaneId + onControlSurfaceChanged?(pane.tmuxPaneId, pane.panel.id) configurePanePanel(pane.panel, paneId: pane.tmuxPaneId, needsSeed: false) } reconcile(layout: layout) @@ -170,9 +169,9 @@ final class RemoteTmuxWindowMirror { /// The surface rendering `tmuxPaneId`, if it exists. func surface(forPane tmuxPaneId: Int) -> TerminalSurface? { panelsByPaneId[tmuxPaneId]?.surface } - /// The stable native Bonsplit pane id for `tmuxPaneId`, if currently rendered. + /// The session-owned stable control pane id for `tmuxPaneId`. func syntheticPaneID(forPane tmuxPaneId: Int) -> PaneID? { - controlPaneIdByPaneId[tmuxPaneId] + controlPaneID(tmuxPaneId) } /// Applies a full window update: panel lifecycle + sizing structure from @@ -201,7 +200,7 @@ final class RemoteTmuxWindowMirror { for paneId in newLayout.paneIDsInOrder where panelsByPaneId[paneId] == nil { guard let panel = makePanel(paneId) else { continue } panelsByPaneId[paneId] = panel - controlPaneIdByPaneId[paneId] = PaneID() + onControlSurfaceChanged?(paneId, panel.id) configurePanePanel(panel, paneId: paneId, needsSeed: true) } for (paneId, panel) in panelsByPaneId where !livePaneIds.contains(paneId) { @@ -210,8 +209,7 @@ final class RemoteTmuxWindowMirror { // dereferenced by a later Core Animation commit. panel.surface.onManualSizeApplied = nil panel.surface.onRuntimeReady = nil - cleanupControlPane(tmuxPaneID: paneId) - controlPaneIdByPaneId[paneId] = nil + onControlSurfaceChanged?(paneId, nil) panel.close() connection?.unsubscribePanePath(paneId: paneId) connection?.unsubscribePaneReflow(paneId: paneId) @@ -460,12 +458,11 @@ final class RemoteTmuxWindowMirror { for (paneId, panel) in panelsByPaneId { panel.surface.onManualSizeApplied = nil panel.surface.onRuntimeReady = nil - cleanupControlPane(tmuxPaneID: paneId) + onControlSurfaceChanged?(paneId, nil) panel.close() } panelsByPaneId.removeAll() tabIdByPaneId.removeAll() - controlPaneIdByPaneId.removeAll() paneIdByPaneId.removeAll() paneIdByBonsplitPane.removeAll() paneIdByTabId.removeAll() diff --git a/Sources/TerminalController+ControlPaneContext.swift b/Sources/TerminalController+ControlPaneContext.swift index 5eac81e688c3..6080747ad01f 100644 --- a/Sources/TerminalController+ControlPaneContext.swift +++ b/Sources/TerminalController+ControlPaneContext.swift @@ -258,7 +258,7 @@ extension TerminalController: ControlPaneContext { } if panelType == .terminal { - let remoteTarget: Workspace.RemoteTmuxControlPaneLocation? + let remoteTarget: RemoteTmuxControlPaneLocation? if let requestedSurfaceID = inputs.requestedSourceSurfaceID { switch ws.remoteTmuxControlSurfaceTarget(surfaceID: requestedSurfaceID) { case .pane(let location): @@ -281,10 +281,7 @@ extension TerminalController: ControlPaneContext { initialDividerPosition: initialDividerPosition ) guard unsupported.isEmpty else { return .mirrorUnsupportedOptions(unsupported) } - guard remoteTarget.mirror.requestSplit( - fromPane: remoteTarget.pane.tmuxPaneID, - vertical: orientation == .vertical - ) else { + guard remoteTarget.requestSplit(vertical: orientation == .vertical) else { return .createFailed } v2MaybeFocusWindow(for: tabManager) diff --git a/Sources/TerminalController+RemoteTmuxControlMutations.swift b/Sources/TerminalController+RemoteTmuxControlMutations.swift index c2355c917ce7..320389b8c218 100644 --- a/Sources/TerminalController+RemoteTmuxControlMutations.swift +++ b/Sources/TerminalController+RemoteTmuxControlMutations.swift @@ -27,11 +27,11 @@ extension TerminalController { } func focusRemoteTmuxControlPane( - _ location: Workspace.RemoteTmuxControlPaneLocation, + _ location: RemoteTmuxControlPaneLocation, workspace: Workspace, tabManager: TabManager ) -> Bool { - guard location.mirror.controlFocus(pane: location.pane.tmuxPaneID) else { return false } + guard location.controlFocus() else { return false } if let windowID = v2ResolveWindowId(tabManager: tabManager) { _ = AppDelegate.shared?.focusMainWindow(windowId: windowID) setActiveTabManager(tabManager) @@ -53,7 +53,7 @@ extension TerminalController { text: String ) -> ControlSurfaceSendResolution? { guard let remote = workspace.remoteTmuxControlPane(surfaceID: surfaceID) else { return nil } - guard remote.mirror.sendInput(toPane: remote.pane.tmuxPaneID, text: text) else { + guard remote.sendInput(text) else { return .surfaceUnavailable(surfaceID) } return .sent( @@ -71,7 +71,7 @@ extension TerminalController { key: String ) -> ControlSurfaceSendResolution? { guard let remote = workspace.remoteTmuxControlPane(surfaceID: surfaceID) else { return nil } - switch remote.mirror.sendKey(toPane: remote.pane.tmuxPaneID, name: key) { + switch remote.sendKey(key) { case .sent: return .sent( windowID: v2ResolveWindowId(tabManager: tabManager), @@ -97,7 +97,7 @@ extension TerminalController { guard panelType == .terminal else { return nil } - let location: Workspace.RemoteTmuxControlPaneLocation + let location: RemoteTmuxControlPaneLocation if inputs.requestedSourceSurfaceID == nil, let routedPaneID, let routed = workspace.remoteTmuxControlPane(paneID: routedPaneID) { @@ -127,10 +127,7 @@ extension TerminalController { remotePTYSessionID: inputs.remotePTYSessionID ) + inputs.clientUnsupportedRemoteTmuxOptions guard unsupported.isEmpty else { return .mirrorUnsupportedOptions(unsupported) } - guard location.mirror.requestSplit( - fromPane: location.pane.tmuxPaneID, - vertical: direction.orientation == .vertical - ) else { + guard location.requestSplit(vertical: direction.orientation == .vertical) else { return .createFailed } v2MaybeFocusWindow(for: tabManager) @@ -148,7 +145,7 @@ extension TerminalController { inputs: ControlSurfaceRespawnInputs, routedPaneID: UUID? ) -> ControlSurfaceRespawnResolution? { - let location: Workspace.RemoteTmuxControlPaneLocation + let location: RemoteTmuxControlPaneLocation if !inputs.hasSurfaceIDParam, let routedPaneID, let routed = workspace.remoteTmuxControlPane(paneID: routedPaneID) { @@ -171,8 +168,7 @@ extension TerminalController { } } let targetSurfaceID = location.pane.panel.id - guard location.mirror.requestRespawnPane( - location.pane.tmuxPaneID, + guard location.requestRespawn( command: inputs.command, workingDirectory: inputs.workingDirectory ) else { @@ -196,7 +192,7 @@ extension TerminalController { isImplicitTarget: Bool, routedPaneID: UUID? ) -> ControlSurfaceCloseResolution? { - let location: Workspace.RemoteTmuxControlPaneLocation + let location: RemoteTmuxControlPaneLocation if isImplicitTarget, let routedPaneID, let routed = workspace.remoteTmuxControlPane(paneID: routedPaneID) { @@ -211,7 +207,7 @@ extension TerminalController { return nil } } - guard location.mirror.requestKillPane(location.pane.tmuxPaneID) else { + guard location.requestKill() else { return .closeFailed(location.pane.panel.id) } return .closed( diff --git a/Sources/TerminalController+RemoteTmuxControlRefs.swift b/Sources/TerminalController+RemoteTmuxControlRefs.swift index 5c43393575ca..528018451634 100644 --- a/Sources/TerminalController+RemoteTmuxControlRefs.swift +++ b/Sources/TerminalController+RemoteTmuxControlRefs.swift @@ -3,11 +3,18 @@ import Foundation @MainActor extension TerminalController { - /// Drops a projected pane's control refs when it leaves mirror topology. - /// Installed as `RemoteTmuxWindowMirror.onControlPaneRemoved`. - static func remoteTmuxControlPaneRemovalHandler() -> (PaneID, UUID) -> Void { + static func remoteTmuxControlPaneRemovalHandler() -> (PaneID, UUID?) -> Void { { [weak controller = TerminalController.shared] paneID, surfaceID in - controller?.cleanupSurfaceState(surfaceIds: [surfaceID], paneIds: [paneID.id]) + controller?.cleanupSurfaceState( + surfaceIds: surfaceID.map { [$0] } ?? [], + paneIds: [paneID.id] + ) + } + } + + static func remoteTmuxControlSurfaceRemovalHandler() -> (UUID) -> Void { + { [weak controller = TerminalController.shared] surfaceID in + controller?.cleanupSurfaceState(surfaceIds: [surfaceID]) } } @@ -18,10 +25,10 @@ extension TerminalController { } var hasOrdinarySurface = false for panelID in panelIDs { - if let mirror = workspace.remoteTmuxWindowMirror(forPanelId: panelID) { - for pane in mirror.controlPanes() { - _ = v2Ref(kind: .pane, uuid: pane.paneID.id) - _ = v2Ref(kind: .surface, uuid: pane.panel.id) + if workspace.isRemoteTmuxControlContainer(panelID) { + for location in workspace.remoteTmuxControlPanes(containerPanelID: panelID) { + _ = v2Ref(kind: .pane, uuid: location.pane.paneID.id) + _ = v2Ref(kind: .surface, uuid: location.pane.panel.id) } } else { hasOrdinarySurface = true diff --git a/Sources/TerminalController+RemoteTmuxControlTopology.swift b/Sources/TerminalController+RemoteTmuxControlTopology.swift index 06e0e5fbc96e..073ee0660827 100644 --- a/Sources/TerminalController+RemoteTmuxControlTopology.swift +++ b/Sources/TerminalController+RemoteTmuxControlTopology.swift @@ -54,7 +54,7 @@ extension TerminalController { for summary in app.listMainWindowSummaries() { guard let tabManager = app.tabManagerFor(windowId: summary.windowId) else { continue } if let workspace = tabManager.tabs.first(where: { - $0.remoteTmuxWindowMirror(forPanelId: surfaceID) != nil + $0.isRemoteTmuxControlContainer(surfaceID) }) { return (summary.windowId, tabManager, workspace) } @@ -105,11 +105,9 @@ extension TerminalController { } var summaries = panelIDs.flatMap { containerPanelID -> [ControlPaneSummary] in - guard let mirror = workspace.remoteTmuxWindowMirror(forPanelId: containerPanelID) else { - return [] - } - return mirror.controlPanes().map { pane in - ControlPaneSummary( + workspace.remoteTmuxControlPanes(containerPanelID: containerPanelID).map { location in + let pane = location.pane + return ControlPaneSummary( paneID: pane.paneID.id, isFocused: workspace.focusedPanelId == containerPanelID && pane.isFocused, surfaceIDs: [pane.panel.id], @@ -121,11 +119,11 @@ extension TerminalController { } let standardSurfaceIDs = panelIDs.filter { - workspace.remoteTmuxWindowMirror(forPanelId: $0) == nil + !workspace.isRemoteTmuxControlContainer($0) } guard !standardSurfaceIDs.isEmpty else { return summaries } let selectedStandardSurfaceID = selectedPanelID.flatMap { panelID in - workspace.remoteTmuxWindowMirror(forPanelId: panelID) == nil ? panelID : nil + workspace.isRemoteTmuxControlContainer(panelID) ? nil : panelID } summaries.append(ControlPaneSummary( paneID: paneID.id, @@ -164,9 +162,11 @@ extension TerminalController { remotePane = remoteLocation.pane } else if requestedPaneID == nil, let focusedPanelID = workspace.focusedPanelId, - let mirror = workspace.remoteTmuxWindowMirror(forPanelId: focusedPanelID) { - guard let activePane = mirror.activeControlPane() else { return nil } - remotePane = activePane + workspace.isRemoteTmuxControlContainer(focusedPanelID) { + guard let activePane = workspace.activeRemoteTmuxControlPane( + containerPanelID: focusedPanelID + ) else { return nil } + remotePane = activePane.pane } else { remotePane = nil } @@ -197,7 +197,7 @@ extension TerminalController { let surfaces = workspace.bonsplitController.tabs(inPane: paneID).compactMap { tab -> ControlPaneSurfaceSummary? in guard let panelID = workspace.panelIdFromSurfaceId(tab.id), - workspace.remoteTmuxWindowMirror(forPanelId: panelID) == nil else { + !workspace.isRemoteTmuxControlContainer(panelID) else { return nil } let panel = workspace.panels[panelID] @@ -272,9 +272,10 @@ extension TerminalController { } return orderedPanels(in: workspace).flatMap { panel -> [ControlSurfaceSummary] in - if let mirror = workspace.remoteTmuxWindowMirror(forPanelId: panel.id) { - return mirror.controlPanes().map { remotePane in - ControlSurfaceSummary( + if workspace.isRemoteTmuxControlContainer(panel.id) { + return workspace.remoteTmuxControlPanes(containerPanelID: panel.id).map { location in + let remotePane = location.pane + return ControlSurfaceSummary( surfaceID: remotePane.panel.id, typeRawValue: remotePane.panel.panelType.rawValue, title: remotePane.title, @@ -320,8 +321,8 @@ extension TerminalController { func controlSurfacePanels(workspace: Workspace) -> [any Panel] { orderedPanels(in: workspace).flatMap { panel -> [any Panel] in - if let mirror = workspace.remoteTmuxWindowMirror(forPanelId: panel.id) { - return mirror.controlPanes().map { $0.panel } + if workspace.isRemoteTmuxControlContainer(panel.id) { + return workspace.remoteTmuxControlPanes(containerPanelID: panel.id).map { $0.pane.panel } } return [panel] } diff --git a/Sources/Workspace+RemoteTmuxControlTopology.swift b/Sources/Workspace+RemoteTmuxControlTopology.swift index 34fcb951aba3..4c2070c03095 100644 --- a/Sources/Workspace+RemoteTmuxControlTopology.swift +++ b/Sources/Workspace+RemoteTmuxControlTopology.swift @@ -3,11 +3,6 @@ import Foundation @MainActor extension Workspace { - typealias RemoteTmuxControlPaneLocation = ( - containerPanelID: UUID, - mirror: RemoteTmuxWindowMirror, - pane: RemoteTmuxControlPane - ) typealias ControlSurfaceProjection = ( surfaceID: UUID, paneID: UUID?, @@ -20,30 +15,72 @@ extension Workspace { } func remoteTmuxControlPane(paneID: UUID) -> RemoteTmuxControlPaneLocation? { + if let sessionMirror = remoteTmuxSessionMirror { + return sessionMirror.controlPaneLocation(paneID: paneID) + } for (containerPanelID, mirror) in remoteTmuxWindowMirrors { if let pane = mirror.controlPane(paneID: paneID) { - return (containerPanelID, mirror, pane) + return RemoteTmuxControlPaneLocation( + containerPanelID: containerPanelID, + owner: mirror, + pane: pane + ) } } return nil } func remoteTmuxControlPane(surfaceID: UUID) -> RemoteTmuxControlPaneLocation? { + if let sessionMirror = remoteTmuxSessionMirror { + return sessionMirror.controlPaneLocation(surfaceID: surfaceID) + } for (containerPanelID, mirror) in remoteTmuxWindowMirrors { if let pane = mirror.controlPane(surfaceID: surfaceID) { - return (containerPanelID, mirror, pane) + return RemoteTmuxControlPaneLocation( + containerPanelID: containerPanelID, + owner: mirror, + pane: pane + ) } } return nil } + func remoteTmuxControlPanes( + containerPanelID: UUID + ) -> [RemoteTmuxControlPaneLocation] { + if let sessionMirror = remoteTmuxSessionMirror { + return sessionMirror.controlPaneLocations(containerPanelID: containerPanelID) + } + guard let mirror = remoteTmuxWindowMirrors[containerPanelID] else { return [] } + return mirror.controlPanes().map { + RemoteTmuxControlPaneLocation( + containerPanelID: containerPanelID, + owner: mirror, + pane: $0 + ) + } + } + + func isRemoteTmuxControlContainer(_ panelID: UUID) -> Bool { + remoteTmuxSessionMirror?.windowId(forPanel: panelID) != nil + || remoteTmuxWindowMirrors[panelID] != nil + } + + func activeRemoteTmuxControlPane( + containerPanelID: UUID + ) -> RemoteTmuxControlPaneLocation? { + let locations = remoteTmuxControlPanes(containerPanelID: containerPanelID) + return locations.first(where: { $0.pane.isFocused }) ?? locations.first + } + /// Resolves every mirror-owned surface identity without conflating an /// unresolved mirror with an ordinary workspace surface. func remoteTmuxControlSurfaceTarget(surfaceID: UUID) -> RemoteTmuxControlSurfaceTarget { if let location = remoteTmuxControlPane(surfaceID: surfaceID) { return .pane(location) } - guard remoteTmuxWindowMirror(forPanelId: surfaceID) != nil else { + guard isRemoteTmuxControlContainer(surfaceID) else { return .notRemote } // The wrapper UUID identifies the mirror container, not a tmux pane. @@ -82,9 +119,11 @@ extension Workspace { func controlSurfaceProjection( forContainerPanelID containerPanelID: UUID ) -> ControlSurfaceProjection? { - if let mirror = remoteTmuxWindowMirror(forPanelId: containerPanelID) { - guard let active = mirror.activeControlPane() else { return nil } - return (active.panel.id, active.paneID.id, active.panel) + if isRemoteTmuxControlContainer(containerPanelID) { + guard let active = activeRemoteTmuxControlPane(containerPanelID: containerPanelID) else { + return nil + } + return (active.pane.panel.id, active.pane.paneID.id, active.pane.panel) } guard let panel = panels[containerPanelID] else { return nil } return (containerPanelID, paneId(forPanelId: containerPanelID)?.id, panel) @@ -102,7 +141,7 @@ extension Workspace { if let paneID = bonsplitController.allPaneIds.first(where: { $0.id == requestedPaneID }), let tab = bonsplitController.selectedTab(inPane: paneID), let panelID = panelIdFromSurfaceId(tab.id), - remoteTmuxWindowMirror(forPanelId: panelID) == nil, + !isRemoteTmuxControlContainer(panelID), let panel = terminalPanel(for: panelID) { return (panelID, panel) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e6a2068b0d57..bbf59e413790 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5050,6 +5050,9 @@ final class Workspace: Identifiable, ObservableObject { /// Ephemeral remote tmux mirror; excluded from cmux session restore. var isRemoteTmuxMirror: Bool = false + /// Session-wide owner of remote pane control identities and projections. + weak var remoteTmuxSessionMirror: RemoteTmuxSessionMirror? + /// Bound action for this mirror's outbound window-order mutation boundary. var remoteTmuxWindowOrderSync: (([UUID], ((Bool) -> Void)?) -> Bool)? diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 585cc3109da6..bb2f438f664f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1049,6 +1049,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources DCCD118CAFF7B3B9C6F184B4 /* RemoteTmuxControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */; }; 0B17C0DE0B17C0DE0B17C002 /* RemoteTmuxControlMessageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B17C0DE0B17C0DE0B17C001 /* RemoteTmuxControlMessageDecoding.swift */; }; 7738C0017738C0017738C001 /* RemoteTmuxControlPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738D0017738D0017738D001 /* RemoteTmuxControlPane.swift */; }; + 7833C0037833C0037833C003 /* RemoteTmuxControlPaneLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7833D0037833D0037833D003 /* RemoteTmuxControlPaneLocation.swift */; }; + 7833C0047833C0047833C004 /* RemoteTmuxControlPaneMutationOwner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7833D0047833D0047833D004 /* RemoteTmuxControlPaneMutationOwner.swift */; }; B87A510D6D6000FCCBFEA02C /* RemoteTmuxControlParserLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C3345C0EC55F1C725B3A4E3 /* RemoteTmuxControlParserLayoutTests.swift */; }; B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */; }; AFEC676A8FB901E750BA5641 /* RemoteTmuxControlPipeWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9420A497CBC16357FF3F2C9A /* RemoteTmuxControlPipeWriter.swift */; }; @@ -1094,7 +1096,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A42DAD21BD502098E446999C /* RemoteTmuxSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */; }; 008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */; }; 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */; }; + 7833C0017833C0017833C001 /* RemoteTmuxSessionMirror+ControlTopology.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7833D0017833D0017833D001 /* RemoteTmuxSessionMirror+ControlTopology.swift */; }; 7F023A000000000000007024 /* RemoteTmuxSessionMirror+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F023A000000000000007023 /* RemoteTmuxSessionMirror+Helpers.swift */; }; + 7833C0027833C0027833C002 /* RemoteTmuxSessionMirror+WindowReconciliation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7833D0027833D0027833D002 /* RemoteTmuxSessionMirror+WindowReconciliation.swift */; }; 1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */; }; 3AC9AB9046E742B93726A501 /* RemoteTmuxSessionRenameTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F704ED4F177122D7DCD0B01 /* RemoteTmuxSessionRenameTitleTests.swift */; }; 9A5CF3AA2E6462E77144F9E6 /* RemoteTmuxSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */; }; @@ -2737,6 +2741,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlMessage.swift; sourceTree = "<group>"; }; 0B17C0DE0B17C0DE0B17C001 /* RemoteTmuxControlMessageDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlMessageDecoding.swift; sourceTree = "<group>"; }; 7738D0017738D0017738D001 /* RemoteTmuxControlPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlPane.swift; sourceTree = "<group>"; }; + 7833D0037833D0037833D003 /* RemoteTmuxControlPaneLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlPaneLocation.swift; sourceTree = "<group>"; }; + 7833D0047833D0047833D004 /* RemoteTmuxControlPaneMutationOwner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlPaneMutationOwner.swift; sourceTree = "<group>"; }; 9C3345C0EC55F1C725B3A4E3 /* RemoteTmuxControlParserLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlParserLayoutTests.swift; sourceTree = "<group>"; }; C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlParserTests.swift; sourceTree = "<group>"; }; 9420A497CBC16357FF3F2C9A /* RemoteTmuxControlPipeWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlPipeWriter.swift; sourceTree = "<group>"; }; @@ -2782,7 +2788,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSession.swift; sourceTree = "<group>"; }; E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParser.swift; sourceTree = "<group>"; }; 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParserTests.swift; sourceTree = "<group>"; }; + 7833D0017833D0017833D001 /* RemoteTmuxSessionMirror+ControlTopology.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxSessionMirror+ControlTopology.swift"; sourceTree = "<group>"; }; 7F023A000000000000007023 /* RemoteTmuxSessionMirror+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxSessionMirror+Helpers.swift"; sourceTree = "<group>"; }; + 7833D0027833D0027833D002 /* RemoteTmuxSessionMirror+WindowReconciliation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxSessionMirror+WindowReconciliation.swift"; sourceTree = "<group>"; }; D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionMirror.swift; sourceTree = "<group>"; }; 3F704ED4F177122D7DCD0B01 /* RemoteTmuxSessionRenameTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionRenameTitleTests.swift; sourceTree = "<group>"; }; 31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionSnapshotTests.swift; sourceTree = "<group>"; }; @@ -4470,6 +4478,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F2A6C8E40D5B17293C8F6A42 /* TerminalPanelCreationOutcome.swift */, D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */, 7F023A000000000000007023 /* RemoteTmuxSessionMirror+Helpers.swift */, + 7833D0037833D0037833D003 /* RemoteTmuxControlPaneLocation.swift */, + 7833D0047833D0047833D004 /* RemoteTmuxControlPaneMutationOwner.swift */, + 7833D0017833D0017833D001 /* RemoteTmuxSessionMirror+ControlTopology.swift */, + 7833D0027833D0027833D002 /* RemoteTmuxSessionMirror+WindowReconciliation.swift */, 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */, 919E2D0DAC80E2DAB13BD782 /* RemoteTmuxControlConnection+CommandResults.swift */, D77330020000000000000001 /* RemoteTmuxControlConnection+Commands.swift */, @@ -6208,6 +6220,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = DCCD118CAFF7B3B9C6F184B4 /* RemoteTmuxControlMessage.swift in Sources */, 0B17C0DE0B17C0DE0B17C002 /* RemoteTmuxControlMessageDecoding.swift in Sources */, 7738C0017738C0017738C001 /* RemoteTmuxControlPane.swift in Sources */, + 7833C0037833C0037833C003 /* RemoteTmuxControlPaneLocation.swift in Sources */, + 7833C0047833C0047833C004 /* RemoteTmuxControlPaneMutationOwner.swift in Sources */, AFEC676A8FB901E750BA5641 /* RemoteTmuxControlPipeWriter.swift in Sources */, E1B13CF7FE1A77324A142521 /* RemoteTmuxControlStreamParser.swift in Sources */, 2FAA10BC0D7B50DE57F507A4 /* RemoteTmuxError.swift in Sources */, @@ -6232,7 +6246,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9BA962E6CB31C9FD62505BF /* RemoteTmuxRawLayoutParser.swift in Sources */, A42DAD21BD502098E446999C /* RemoteTmuxSession.swift in Sources */, 008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */, + 7833C0017833C0017833C001 /* RemoteTmuxSessionMirror+ControlTopology.swift in Sources */, 7F023A000000000000007024 /* RemoteTmuxSessionMirror+Helpers.swift in Sources */, + 7833C0027833C0027833C002 /* RemoteTmuxSessionMirror+WindowReconciliation.swift in Sources */, 1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */, 7020C0DE7020C0DE7020B002 /* RemoteTmuxSSHTransport+InteractiveRetry.swift in Sources */, 7368BEEF7368BEEF7368B004 /* RemoteTmuxSSHTransport+MissingTmux.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxMirrorCLIObservabilityTests.swift b/cmuxTests/RemoteTmuxMirrorCLIObservabilityTests.swift index b25514795d0a..cf20316731f6 100644 --- a/cmuxTests/RemoteTmuxMirrorCLIObservabilityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorCLIObservabilityTests.swift @@ -320,7 +320,7 @@ struct RemoteTmuxMirrorCLIObservabilityTests { #expect(TerminalController.shared.v2ResolveHandleRef(paneRef) == paneID) #expect(TerminalController.shared.v2ResolveHandleRef(surfaceRef) == surfaceID) - harness.mirror.teardown() + harness.teardownMirror() #expect(TerminalController.shared.v2ResolveHandleRef(paneRef) == nil) #expect(TerminalController.shared.v2ResolveHandleRef(surfaceRef) == nil) @@ -334,6 +334,7 @@ struct RemoteTmuxMirrorCLIObservabilityTests { let outerPanelID: UUID let nonMirrorPanelID: UUID? let peerSurfaceID: UUID? + let controlPaneIDs: [Int: PaneID] let mirror: RemoteTmuxWindowMirror init( @@ -379,17 +380,14 @@ struct RemoteTmuxMirrorCLIObservabilityTests { RemoteTmuxLayoutNode(width: 39, height: 24, x: 41, y: 0, content: .pane(22)), ]) ) + let paneIDs = [11: PaneID(), 22: PaneID()] + controlPaneIDs = paneIDs mirror = RemoteTmuxWindowMirror( windowId: 3, panelId: outerPanelID, connection: connection, layout: layout, - onControlPaneRemoved: { paneID, surfaceID in - TerminalController.shared.cleanupSurfaceState( - surfaceIds: [surfaceID], - paneIds: [paneID.id] - ) - }, + controlPaneID: { [paneIDs] in paneIDs[$0] }, makePanel: { [workspace] _ in workspace.makeRemoteTmuxPanePanel(onInput: { _ in }) } @@ -415,13 +413,21 @@ struct RemoteTmuxMirrorCLIObservabilityTests { func tearDown() { workspace.setRemoteTmuxWindowMirror(nil, forPanelId: outerPanelID) workspace.isRemoteTmuxMirror = false - mirror.teardown() + teardownMirror() let identifier = "cmux.main.\(windowID.uuidString)" if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) { window.performClose(nil) RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) } } + + func teardownMirror() { + TerminalController.shared.cleanupSurfaceState( + surfaceIds: mirror.controlPanes().map(\.panel.id), + paneIds: controlPaneIDs.values.map(\.id) + ) + mirror.teardown() + } } } diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index 9c43f03f6092..1c69c3a66239 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -17,9 +17,9 @@ import Testing let originalPanel = try #require(harness.singlePanePanel(tmuxPaneID: 11)) let originalSurfaceID = originalPanel.id - let originalPaneID = try #require( - harness.workspace.paneId(forPanelId: originalSurfaceID) - ) + let originalPaneID = PaneID(id: try #require( + harness.controlPaneID(surfaceID: originalSurfaceID) + )) try harness.publishLayout( "abcd,80x24,0,0[80x12,0,0,11,80x11,0,13,22]", @@ -175,10 +175,64 @@ import Testing #expect(addedControlID != editorControlID) #expect(addedControlID != logsControlID) } + + @Test( + "cross-window pane moves preserve identity in either publication order", + arguments: [false, true] + ) + func crossWindowPaneMovesPreserveIdentity(destinationFirst: Bool) throws { + let harness = try Harness( + initialWindowLines: [ + "@1 f92f,80x24,0,0[80x12,0,0,11,80x11,0,13,22] " + + "f92f,80x24,0,0[80x12,0,0,11,80x11,0,13,22] [] editor", + "@2 abcd,80x24,0,0,44 abcd,80x24,0,0,44 [] logs", + ], + initialRectsByWindow: [ + 1: ["%11 0 0 80 12 1 off :zsh", "%22 0 13 80 11 0 off :zsh"], + 2: ["%44 0 0 80 24 1 off :zsh"], + ] + ) + defer { harness.tearDown() } + + let sourceMirror = try #require(harness.windowMirror(windowID: 1)) + let oldSurfaceID = try #require(sourceMirror.panel(forPane: 22)?.id) + let stablePaneID = try #require(harness.controlPaneID(surfaceID: oldSurfaceID)) + weak var oldPanel = sourceMirror.panel(forPane: 22) + + let source = Harness.LayoutUpdate( + windowID: 1, + layout: "beef,80x24,0,0,11", + rects: ["%11 0 0 80 24 1 off :zsh"] + ) + let destination = Harness.LayoutUpdate( + windowID: 2, + layout: "cafe,80x24,0,0[80x12,0,0,44,80x11,0,13,22]", + rects: ["%44 0 0 80 12 1 off :zsh", "%22 0 13 80 11 0 off :zsh"] + ) + try harness.publishLayouts(destinationFirst ? [destination, source] : [source, destination]) + + let destinationMirror = try #require(harness.windowMirror(windowID: 2)) + let newSurfaceID = try #require(destinationMirror.panel(forPane: 22)?.id) + #expect(newSurfaceID != oldSurfaceID) + #expect(harness.controlPaneID(surfaceID: oldSurfaceID) == nil) + #expect(harness.controlPaneID(surfaceID: newSurfaceID) == stablePaneID) + #expect(harness.sessionMirror.controlPaneID(forPane: 22)?.id == stablePaneID) + #expect(oldPanel == nil) + let paneIDs = TerminalController.shared + .controlPaneList(workspace: harness.workspace, tabManager: harness.manager) + .panes.map(\.paneID) + #expect(Set(paneIDs).count == paneIDs.count) + } } @MainActor private final class Harness { + struct LayoutUpdate { + let windowID: Int + let layout: String + let rects: [String] + } + let connection: RemoteTmuxControlConnection let writer: RemoteTmuxControlPipeWriter let pipe: Pipe @@ -282,6 +336,28 @@ private final class Harness { ) } + func publishLayouts(_ updates: [LayoutUpdate]) throws { + drainCommandsBeforeLayout() + var parser = RemoteTmuxControlStreamParser() + for update in updates { + let line = "%layout-change @\(update.windowID) \(update.layout) \(update.layout) *\r\n" + let message = try #require(parser.feed(Data(line.utf8)).only) + connection.handleMessageForTesting(message) + } + let rectsByWindow = Dictionary(uniqueKeysWithValues: updates.map { ($0.windowID, $0.rects) }) + while let first = connection.pendingCommandKindsForTesting.first { + let lines: [String] + if case .paneRects(let windowID, _) = first { + lines = rectsByWindow[windowID] ?? [] + } else { + lines = [] + } + connection.handleMessageForTesting( + .commandResult(commandNumber: 0, lines: lines, isError: false) + ) + } + } + func tearDown() { sessionMirror.detachObserver() workspace.isRemoteTmuxMirror = false From 1171508bcbfc90c0f2269bf4adc52e53c203da9c Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Jul 2026 04:56:50 -0700 Subject: [PATCH 11/23] Deduplicate remote tmux mirror test lookup --- cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index 1c69c3a66239..d052160d5fe1 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -291,15 +291,13 @@ private final class Harness { } var windowMirror: RemoteTmuxWindowMirror? { - workspace.panels.keys.lazy.compactMap { - self.workspace.remoteTmuxWindowMirror(forPanelId: $0) - }.first + windowMirror(windowID: nil) } - func windowMirror(windowID: Int) -> RemoteTmuxWindowMirror? { + func windowMirror(windowID: Int?) -> RemoteTmuxWindowMirror? { workspace.panels.keys.lazy.compactMap { self.workspace.remoteTmuxWindowMirror(forPanelId: $0) - }.first(where: { $0.windowId == windowID }) + }.first(where: { windowID == nil || $0.windowId == windowID }) } func controlPaneID(surfaceID: UUID) -> UUID? { From f686623b128ea7c3919084e28000da7a6986429a Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Jul 2026 05:01:46 -0700 Subject: [PATCH 12/23] Keep workspace file within hard cap --- Sources/Workspace.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bbf59e413790..44c44492a6cc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5049,10 +5049,7 @@ final class Workspace: Identifiable, ObservableObject { /// Ephemeral remote tmux mirror; excluded from cmux session restore. var isRemoteTmuxMirror: Bool = false - - /// Session-wide owner of remote pane control identities and projections. weak var remoteTmuxSessionMirror: RemoteTmuxSessionMirror? - /// Bound action for this mirror's outbound window-order mutation boundary. var remoteTmuxWindowOrderSync: (([UUID], ((Bool) -> Void)?) -> Bool)? From 19f418f87c775be5602fe045540a5a7bb7fa5839 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Jul 2026 05:09:41 -0700 Subject: [PATCH 13/23] Preserve pane ownership through layout reorders --- ...uxSessionMirror+WindowReconciliation.swift | 1 + Sources/RemoteTmuxSessionMirror.swift | 16 ----- .../RemoteTmuxMirrorLayoutIdentityTests.swift | 59 ++++++++++++++++++- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift b/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift index 5ef55da947d0..4e0188add484 100644 --- a/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift +++ b/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift @@ -60,6 +60,7 @@ extension RemoteTmuxSessionMirror { mirror.apply(window: window) windowMirrorByWindowId[windowId] = mirror workspace.setRemoteTmuxWindowMirror(mirror, forPanelId: panelId) + panelIdByPane = panelIdByPane.filter { $0.value != panelId } if adoptedPanes.isEmpty, let panel = workspace.panels[panelId] as? TerminalPanel { panel.surface.onManualSizeApplied = nil panel.surface.onRuntimeReady = nil diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 55a36def70cd..7aec770b74f8 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -205,22 +205,6 @@ final class RemoteTmuxSessionMirror: RemoteTmuxControlPaneMutationOwner { let pendingPanes = Set(connection.pendingLayouts.values.flatMap { $0.node.paneIDsInOrder }) reconcileControlPaneIdentities(livePaneIDs: livePanes.union(pendingPanes)) windowIdByPane = connection.publishedWindowIdByPane - let previousPanelIdByPane = panelIdByPane - var nextPanelIdByPane: [Int: UUID] = [:] - for (windowId, panelId) in panelIdByWindow { - guard let firstPaneId = connection.windowsByID[windowId]?.paneIDsInOrder.first, - windowIdByPane[firstPaneId] == windowId else { continue } - nextPanelIdByPane[firstPaneId] = panelId - } - panelIdByPane = nextPanelIdByPane - for (paneId, previousPanelId) in previousPanelIdByPane - where nextPanelIdByPane[paneId] != previousPanelId { - updateControlSurface( - tmuxPaneID: paneId, - surfaceID: nil, - windowID: windowIdByPanel[previousPanelId] - ) - } for windowId in connection.windowOrder { guard let window = connection.windowsByID[windowId], let firstPaneId = window.paneIDsInOrder.first else { continue } diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index d052160d5fe1..09eb1a31c851 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -176,6 +176,61 @@ import Testing #expect(addedControlID != logsControlID) } + @Test("leading-edge split adopts the panel for its original tmux pane") + func leadingEdgeSplitAdoptsOriginalPanePanel() throws { + let harness = try Harness() + defer { harness.tearDown() } + + let originalPanel = try #require(harness.singlePanePanel(tmuxPaneID: 11)) + let originalSurfaceID = originalPanel.id + let originalControlID = try #require(harness.controlPaneID(surfaceID: originalSurfaceID)) + + try harness.publishLayout( + "beef,80x24,0,0[80x12,0,0,22,80x11,0,13,11]", + rects: [ + "%22 0 0 80 12 1 off :zsh", + "%11 0 13 80 11 0 off :zsh", + ] + ) + + let mirror = try #require(harness.windowMirror) + #expect(mirror.panel(forPane: 11) === originalPanel) + #expect(mirror.panel(forPane: 11)?.id == originalSurfaceID) + #expect(mirror.panel(forPane: 22) !== originalPanel) + #expect(harness.controlPaneID(surfaceID: originalSurfaceID) == originalControlID) + } + + @Test("pane reorder keeps live panels and surface refs") + func paneReorderKeepsLivePanelsAndSurfaceRefs() throws { + let harness = try Harness( + initialLayout: "f92f,80x24,0,0[80x12,0,0,11,80x11,0,13,22]", + initialRects: [ + "%11 0 0 80 12 1 off :zsh", + "%22 0 13 80 11 0 off :zsh", + ] + ) + defer { harness.tearDown() } + + let mirror = try #require(harness.windowMirror) + let firstPanel = try #require(mirror.panel(forPane: 11)) + let secondPanel = try #require(mirror.panel(forPane: 22)) + let firstRef = TerminalController.shared.v2Ref(kind: .surface, uuid: firstPanel.id) + let secondRef = TerminalController.shared.v2Ref(kind: .surface, uuid: secondPanel.id) + + try harness.publishLayout( + "beef,80x24,0,0[80x12,0,0,22,80x11,0,13,11]", + rects: [ + "%22 0 0 80 12 1 off :zsh", + "%11 0 13 80 11 0 off :zsh", + ] + ) + + #expect(mirror.panel(forPane: 11) === firstPanel) + #expect(mirror.panel(forPane: 22) === secondPanel) + #expect(TerminalController.shared.v2ResolveHandleRef(firstRef) == firstPanel.id) + #expect(TerminalController.shared.v2ResolveHandleRef(secondRef) == secondPanel.id) + } + @Test( "cross-window pane moves preserve identity in either publication order", arguments: [false, true] @@ -285,7 +340,9 @@ private final class Harness { sessionName: "work", connection: connection, tabManager: manager, - workspace: workspace + workspace: workspace, + onControlPaneRemoved: TerminalController.remoteTmuxControlPaneRemovalHandler(), + onControlSurfaceRemoved: TerminalController.remoteTmuxControlSurfaceRemovalHandler() ) drainCommandsBeforeLayout() } From b530462065a44e40943c9e834f084b94fdd18ece Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Jul 2026 05:19:59 -0700 Subject: [PATCH 14/23] Import Bonsplit in remote tmux observability tests --- cmuxTests/RemoteTmuxMirrorCLIObservabilityTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/RemoteTmuxMirrorCLIObservabilityTests.swift b/cmuxTests/RemoteTmuxMirrorCLIObservabilityTests.swift index cf20316731f6..32889c8e8359 100644 --- a/cmuxTests/RemoteTmuxMirrorCLIObservabilityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorCLIObservabilityTests.swift @@ -1,4 +1,5 @@ import AppKit +import Bonsplit import CmuxControlSocket import Foundation import Testing From 0c53eabbe3cf2792f30abf94aa30b8312fa04b33 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Jul 2026 05:28:51 -0700 Subject: [PATCH 15/23] Reconcile final-pane moves atomically --- Sources/RemoteTmuxControlConnection.swift | 6 ++- .../RemoteTmuxMirrorLayoutIdentityTests.swift | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 36f3fbd649a7..181351fbd2da 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -742,7 +742,11 @@ final class RemoteTmuxControlConnection { initialBatchStaged[id] = nil finishInitialBatchMember(id) record("window-close @\(id)") - observers.notifyTopologyChanged() + // A move of the window's final pane reports the source close before + // the destination layout. Re-list atomically so observers reconcile + // against the destination's pending tree instead of pruning the + // surviving pane during that event gap. + requestWindows() case let .windowRenamed(id, name): record("window-renamed @\(id)") // Update published AND quarantined topology. A rename racing a diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index 09eb1a31c851..d8853664e8cc 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -278,6 +278,53 @@ import Testing .panes.map(\.paneID) #expect(Set(paneIDs).count == paneIDs.count) } + + @Test("moving a window's only pane preserves its control identity") + func movingOnlyPanePreservesControlIdentity() throws { + let harness = try Harness( + initialWindowLines: [ + "@1 f92f,80x24,0,0,11 f92f,80x24,0,0,11 [] editor", + "@2 abcd,80x24,0,0,44 abcd,80x24,0,0,44 [] logs", + ], + initialRectsByWindow: [ + 1: ["%11 0 0 80 24 1 off :zsh"], + 2: ["%44 0 0 80 24 1 off :zsh"], + ] + ) + defer { harness.tearDown() } + + let oldPanel = try #require(harness.singlePanePanel(tmuxPaneID: 11)) + let oldSurfaceID = oldPanel.id + let stablePaneID = try #require(harness.controlPaneID(surfaceID: oldSurfaceID)) + + harness.connection.handleMessageForTesting(.windowClose(windowId: 1)) + let layout = "cafe,80x24,0,0[80x12,0,0,44,80x11,0,13,11]" + harness.connection.handleMessageForTesting(.layoutChange( + windowId: 2, layout: layout, visibleLayout: layout, zoomed: false + )) + harness.connection.handleMessageForTesting(.commandResult( + commandNumber: 0, + lines: ["@2 \(layout) \(layout) [] logs"], + isError: false + )) + while let command = harness.connection.pendingCommandKindsForTesting.first { + let lines: [String] + if case .paneRects(let windowID, _) = command, windowID == 2 { + lines = ["%44 0 0 80 12 1 off :zsh", "%11 0 13 80 11 0 off :zsh"] + } else { + lines = [] + } + harness.connection.handleMessageForTesting( + .commandResult(commandNumber: 0, lines: lines, isError: false) + ) + } + + let destination = try #require(harness.windowMirror(windowID: 2)) + let newSurfaceID = try #require(destination.panel(forPane: 11)?.id) + #expect(newSurfaceID != oldSurfaceID) + #expect(harness.controlPaneID(surfaceID: newSurfaceID) == stablePaneID) + #expect(harness.sessionMirror.controlPaneID(forPane: 11)?.id == stablePaneID) + } } @MainActor From 60fa525231ed7a64c8d7778cc7d6f2466bc0d1c6 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Jul 2026 06:04:38 -0700 Subject: [PATCH 16/23] Fix remote tmux identity test references --- cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index d8853664e8cc..56a26afb78d7 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -214,8 +214,12 @@ import Testing let mirror = try #require(harness.windowMirror) let firstPanel = try #require(mirror.panel(forPane: 11)) let secondPanel = try #require(mirror.panel(forPane: 22)) - let firstRef = TerminalController.shared.v2Ref(kind: .surface, uuid: firstPanel.id) - let secondRef = TerminalController.shared.v2Ref(kind: .surface, uuid: secondPanel.id) + let firstRef = try #require( + TerminalController.shared.v2Ref(kind: .surface, uuid: firstPanel.id) as? String + ) + let secondRef = try #require( + TerminalController.shared.v2Ref(kind: .surface, uuid: secondPanel.id) as? String + ) try harness.publishLayout( "beef,80x24,0,0[80x12,0,0,22,80x11,0,13,11]", From 5de8edcc82f5307b9a27a748870fc3900bcae211 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Jul 2026 06:09:27 -0700 Subject: [PATCH 17/23] Retain pane identity through topology refresh --- Sources/RemoteTmuxControlConnection+CommandResults.swift | 3 +++ Sources/RemoteTmuxControlConnection.swift | 6 ++++++ Sources/RemoteTmuxSessionMirror.swift | 1 + cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift | 2 ++ 4 files changed, 12 insertions(+) diff --git a/Sources/RemoteTmuxControlConnection+CommandResults.swift b/Sources/RemoteTmuxControlConnection+CommandResults.swift index dc1faf815000..7a49fb1d11ad 100644 --- a/Sources/RemoteTmuxControlConnection+CommandResults.swift +++ b/Sources/RemoteTmuxControlConnection+CommandResults.swift @@ -151,6 +151,9 @@ extension RemoteTmuxControlConnection { zoomed: window.zoomed, name: window.name ) } + // The complete snapshot decides whether panes from a recently + // closed window moved elsewhere or were actually destroyed. + paneIDsRetainedUntilWindowList.removeAll() // Per-window sizing state must not outlive the topology: a // stale pin would be replayed by the reconnect reseed, and a // pending debounce could fire at a dead @id. diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 181351fbd2da..1374a77cd221 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -49,6 +49,9 @@ final class RemoteTmuxControlConnection { var windowsByID: [Int: RemoteTmuxWindow] = [:] var windowOrder: [Int] = [] var publishedWindowIdByPane: [Int: Int] = [:] + /// Pane identities whose ownership is temporarily undecidable after their + /// source window closes, retained until `list-windows` supplies a complete snapshot. + var paneIDsRetainedUntilWindowList: Set<Int> = [] var activePaneByWindow: [Int: Int] = [:] var paneOutputByteCounts: [Int: Int] = [:] var totalOutputBytes = 0 @@ -718,6 +721,9 @@ final class RemoteTmuxControlConnection { record("window-add @\(id)") requestWindows() case let .windowClose(id): + let closingPaneIDs = Set(windowsByID[id]?.paneIDsInOrder ?? []) + .union(pendingLayouts[id]?.node.paneIDsInOrder ?? []) + paneIDsRetainedUntilWindowList.formUnion(closingPaneIDs) // Release the closed window's per-window sizing state: a stale // entry would be replayed by the reconnect reseed, and a pending // debounce could still fire at a dead @id target. diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 7aec770b74f8..5ca1e6e1b007 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -202,6 +202,7 @@ final class RemoteTmuxSessionMirror: RemoteTmuxControlPaneMutationOwner { private func rebuildTopology(in workspace: Workspace) { let livePanes = Set(connection.windowsByID.values.flatMap { $0.paneIDsInOrder }) + .union(connection.paneIDsRetainedUntilWindowList) let pendingPanes = Set(connection.pendingLayouts.values.flatMap { $0.node.paneIDsInOrder }) reconcileControlPaneIdentities(livePaneIDs: livePanes.union(pendingPanes)) windowIdByPane = connection.publishedWindowIdByPane diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index 56a26afb78d7..6f827e1cf9d3 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -302,6 +302,8 @@ import Testing let stablePaneID = try #require(harness.controlPaneID(surfaceID: oldSurfaceID)) harness.connection.handleMessageForTesting(.windowClose(windowId: 1)) + harness.connection.handleMessageForTesting(.windowRenamed(windowId: 2, name: "renamed")) + #expect(harness.sessionMirror.controlPaneID(forPane: 11)?.id == stablePaneID) let layout = "cafe,80x24,0,0[80x12,0,0,44,80x11,0,13,11]" harness.connection.handleMessageForTesting(.layoutChange( windowId: 2, layout: layout, visibleLayout: layout, zoomed: false From 09af1abd77eacb66d3732d969653653947b92a66 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Jul 2026 06:38:39 -0700 Subject: [PATCH 18/23] Scope remote tmux topology refresh ownership --- Sources/RemoteTmuxControlCommandKind.swift | 5 ++- ...TmuxControlConnection+CommandResults.swift | 11 ++--- ...RemoteTmuxControlConnection+Commands.swift | 5 ++- Sources/RemoteTmuxControlKeySendResult.swift | 6 +++ Sources/RemoteTmuxControlPaneLocation.swift | 2 +- .../RemoteTmuxControlPaneMutationOwner.swift | 2 +- ...oteTmuxSessionMirror+ControlTopology.swift | 2 +- ...uxSessionMirror+WindowReconciliation.swift | 14 +++--- ...oteTmuxWindowMirror+ControlMutations.swift | 8 +--- cmux.xcodeproj/project.pbxproj | 4 ++ cmuxTests/RemoteTmuxAuthTests.swift | 4 +- cmuxTests/RemoteTmuxWindowReorderTests.swift | 43 ++++++++++++++++--- 12 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 Sources/RemoteTmuxControlKeySendResult.swift diff --git a/Sources/RemoteTmuxControlCommandKind.swift b/Sources/RemoteTmuxControlCommandKind.swift index b0f3c5421700..4c49e9e02f2e 100644 --- a/Sources/RemoteTmuxControlCommandKind.swift +++ b/Sources/RemoteTmuxControlCommandKind.swift @@ -1,8 +1,9 @@ import Foundation enum RemoteTmuxControlCommandKind: Equatable { - /// A topology snapshot tagged with the accepted reorder generation at send time. - case listWindows(reorderGeneration: UInt64) + /// A topology snapshot tagged with the accepted reorder generation and the + /// exact close-gap pane identities it may release when the reply succeeds. + case listWindows(reorderGeneration: UInt64, retainedPaneIDs: Set<Int>) /// An order-only snapshot used to verify a successful swap batch cheaply. case listWindowOrder(reorderGeneration: UInt64) case capturePane(Int) diff --git a/Sources/RemoteTmuxControlConnection+CommandResults.swift b/Sources/RemoteTmuxControlConnection+CommandResults.swift index 7a49fb1d11ad..2d230015a354 100644 --- a/Sources/RemoteTmuxControlConnection+CommandResults.swift +++ b/Sources/RemoteTmuxControlConnection+CommandResults.swift @@ -41,7 +41,7 @@ extension RemoteTmuxControlConnection { if case let .windowReorder(isLast) = kind { completeWindowReorderCommand(isLast: isLast, failed: true) } - if case let .listWindows(requestGeneration) = kind, + if case let .listWindows(requestGeneration, _) = kind, windowReorderRecoveryGeneration == requestGeneration { restartAfterWindowReorderRecoveryFailure() } @@ -67,7 +67,7 @@ extension RemoteTmuxControlConnection { completion(windowId) case let .paneRects(windowId, generation): handlePaneRectsReply(windowId: windowId, generation: generation, lines: lines) - case let .listWindows(requestGeneration): + case let .listWindows(requestGeneration, retainedPaneIDs): // A pending order verification owns the window-order ledger: an // incidental topology refetch (e.g. a %window-add landing mid-batch) // shares the current generation tag, and letting it replace the @@ -151,9 +151,10 @@ extension RemoteTmuxControlConnection { zoomed: window.zoomed, name: window.name ) } - // The complete snapshot decides whether panes from a recently - // closed window moved elsewhere or were actually destroyed. - paneIDsRetainedUntilWindowList.removeAll() + // This complete snapshot decides only the close gaps already + // represented when its request was sent. A later overlapping + // close remains retained for its own snapshot. + paneIDsRetainedUntilWindowList.subtract(retainedPaneIDs) // Per-window sizing state must not outlive the topology: a // stale pin would be replayed by the reconnect reseed, and a // pending debounce could fire at a dead @id. diff --git a/Sources/RemoteTmuxControlConnection+Commands.swift b/Sources/RemoteTmuxControlConnection+Commands.swift index 628452c38690..c37a50a50d76 100644 --- a/Sources/RemoteTmuxControlConnection+Commands.swift +++ b/Sources/RemoteTmuxControlConnection+Commands.swift @@ -52,7 +52,10 @@ extension RemoteTmuxControlConnection { func requestWindows() { sendInternal( "list-windows -F \"#{window_id} #{window_layout} #{window_visible_layout} [#{window_flags}] #{window_name}\"", - kind: .listWindows(reorderGeneration: windowReorderGeneration) + kind: .listWindows( + reorderGeneration: windowReorderGeneration, + retainedPaneIDs: paneIDsRetainedUntilWindowList + ) ) } diff --git a/Sources/RemoteTmuxControlKeySendResult.swift b/Sources/RemoteTmuxControlKeySendResult.swift new file mode 100644 index 000000000000..c5fad2d808aa --- /dev/null +++ b/Sources/RemoteTmuxControlKeySendResult.swift @@ -0,0 +1,6 @@ +/// Outcome of translating and forwarding one control-plane key to tmux. +enum RemoteTmuxControlKeySendResult { + case sent + case rejected + case unknownKey +} diff --git a/Sources/RemoteTmuxControlPaneLocation.swift b/Sources/RemoteTmuxControlPaneLocation.swift index 81199b517894..c2c6aaf18c58 100644 --- a/Sources/RemoteTmuxControlPaneLocation.swift +++ b/Sources/RemoteTmuxControlPaneLocation.swift @@ -17,7 +17,7 @@ struct RemoteTmuxControlPaneLocation { owner.sendInput(toPane: pane.tmuxPaneID, text: text) } - func sendKey(_ name: String) -> RemoteTmuxWindowMirror.ControlKeySendResult { + func sendKey(_ name: String) -> RemoteTmuxControlKeySendResult { owner.sendKey(toPane: pane.tmuxPaneID, name: name) } diff --git a/Sources/RemoteTmuxControlPaneMutationOwner.swift b/Sources/RemoteTmuxControlPaneMutationOwner.swift index 397d0b9e0c22..9ae6ab81929a 100644 --- a/Sources/RemoteTmuxControlPaneMutationOwner.swift +++ b/Sources/RemoteTmuxControlPaneMutationOwner.swift @@ -9,7 +9,7 @@ protocol RemoteTmuxControlPaneMutationOwner: AnyObject { func sendKey( toPane tmuxPaneID: Int, name: String - ) -> RemoteTmuxWindowMirror.ControlKeySendResult + ) -> RemoteTmuxControlKeySendResult func requestSplit(fromPane tmuxPaneID: Int, vertical: Bool) -> Bool func requestRespawnPane( _ tmuxPaneID: Int, diff --git a/Sources/RemoteTmuxSessionMirror+ControlTopology.swift b/Sources/RemoteTmuxSessionMirror+ControlTopology.swift index eb23f065f32f..dbf9b5ba1d2c 100644 --- a/Sources/RemoteTmuxSessionMirror+ControlTopology.swift +++ b/Sources/RemoteTmuxSessionMirror+ControlTopology.swift @@ -106,7 +106,7 @@ extension RemoteTmuxSessionMirror { func sendKey( toPane tmuxPaneID: Int, name: String - ) -> RemoteTmuxWindowMirror.ControlKeySendResult { + ) -> RemoteTmuxControlKeySendResult { guard controlPaneIdByPane[tmuxPaneID] != nil else { return .rejected } guard let key = RemoteTmuxWindowMirror.tmuxKeyName(name) else { return .unknownKey } return connection.send("send-keys -t %\(tmuxPaneID) \(key)") ? .sent : .rejected diff --git a/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift b/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift index 4e0188add484..e1d8ba3c1745 100644 --- a/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift +++ b/Sources/RemoteTmuxSessionMirror+WindowReconciliation.swift @@ -21,11 +21,10 @@ extension RemoteTmuxSessionMirror { return } guard window.paneIDsInOrder.count > 1 else { return } - let adoptedPanes: [RemoteTmuxWindowMirror.AdoptedPane] = panelIdByPane.compactMap { - tmuxPaneId, mappedPanelId in + let adoptedPanes: [RemoteTmuxWindowMirror.AdoptedPane] = window.paneIDsInOrder.compactMap { + tmuxPaneId in guard !displayPanelWasCreated, - mappedPanelId == panelId, - window.paneIDsInOrder.contains(tmuxPaneId), + panelIdByPane[tmuxPaneId] == panelId, let panel = workspace.panels[panelId] as? TerminalPanel else { return nil } return (tmuxPaneId, panel) } @@ -60,7 +59,12 @@ extension RemoteTmuxSessionMirror { mirror.apply(window: window) windowMirrorByWindowId[windowId] = mirror workspace.setRemoteTmuxWindowMirror(mirror, forPanelId: panelId) - panelIdByPane = panelIdByPane.filter { $0.value != panelId } + for adoptedPane in adoptedPanes { + panelIdByPane[adoptedPane.tmuxPaneId] = nil + } + if displayPanelWasCreated, let firstPaneID = window.paneIDsInOrder.first { + panelIdByPane[firstPaneID] = nil + } if adoptedPanes.isEmpty, let panel = workspace.panels[panelId] as? TerminalPanel { panel.surface.onManualSizeApplied = nil panel.surface.onRuntimeReady = nil diff --git a/Sources/RemoteTmuxWindowMirror+ControlMutations.swift b/Sources/RemoteTmuxWindowMirror+ControlMutations.swift index 1aa274e5cf08..1d169a481b9d 100644 --- a/Sources/RemoteTmuxWindowMirror+ControlMutations.swift +++ b/Sources/RemoteTmuxWindowMirror+ControlMutations.swift @@ -2,18 +2,12 @@ import Foundation @MainActor extension RemoteTmuxWindowMirror { - enum ControlKeySendResult { - case sent - case rejected - case unknownKey - } - func sendInput(toPane tmuxPaneID: Int, text: String) -> Bool { guard let data = text.data(using: .utf8) else { return false } return connectionSendKeys(paneID: tmuxPaneID, data: data) } - func sendKey(toPane tmuxPaneID: Int, name: String) -> ControlKeySendResult { + func sendKey(toPane tmuxPaneID: Int, name: String) -> RemoteTmuxControlKeySendResult { guard let key = Self.tmuxKeyName(name) else { return .unknownKey } return sendControlCommand("send-keys -t %\(tmuxPaneID) \(key)") ? .sent : .rejected } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 46440924c04e..28021a365651 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1051,6 +1051,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 47390CF61FE52509FE53FE75 /* RemoteTmuxControlConnection+Sizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05663199C0335662D0736AFA /* RemoteTmuxControlConnection+Sizing.swift */; }; 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */; }; 42FBA382CDEAEC146FA5A895 /* RemoteTmuxControlConnectionSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2587F4E278EFECB84E2ED34B /* RemoteTmuxControlConnectionSnapshot.swift */; }; + 7833C0057833C0057833C005 /* RemoteTmuxControlKeySendResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7833D0057833D0057833D005 /* RemoteTmuxControlKeySendResult.swift */; }; 740600000000000000000002 /* RemoteTmuxController+Attach.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740600000000000000000001 /* RemoteTmuxController+Attach.swift */; }; 736200000000000000000002 /* RemoteTmuxController+Decisions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736200000000000000000001 /* RemoteTmuxController+Decisions.swift */; }; F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */; }; @@ -2755,6 +2756,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 05663199C0335662D0736AFA /* RemoteTmuxControlConnection+Sizing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxControlConnection+Sizing.swift"; sourceTree = "<group>"; }; 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlConnection.swift; sourceTree = "<group>"; }; 2587F4E278EFECB84E2ED34B /* RemoteTmuxControlConnectionSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlConnectionSnapshot.swift; sourceTree = "<group>"; }; + 7833D0057833D0057833D005 /* RemoteTmuxControlKeySendResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlKeySendResult.swift; sourceTree = "<group>"; }; 740600000000000000000001 /* RemoteTmuxController+Attach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxController+Attach.swift"; sourceTree = "<group>"; }; 736200000000000000000001 /* RemoteTmuxController+Decisions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxController+Decisions.swift"; sourceTree = "<group>"; }; 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxController.swift; sourceTree = "<group>"; }; @@ -4505,6 +4507,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7F023A000000000000007023 /* RemoteTmuxSessionMirror+Helpers.swift */, 7833D0037833D0037833D003 /* RemoteTmuxControlPaneLocation.swift */, 7833D0047833D0047833D004 /* RemoteTmuxControlPaneMutationOwner.swift */, + 7833D0057833D0057833D005 /* RemoteTmuxControlKeySendResult.swift */, 7833D0017833D0017833D001 /* RemoteTmuxSessionMirror+ControlTopology.swift */, 7833D0027833D0027833D002 /* RemoteTmuxSessionMirror+WindowReconciliation.swift */, 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */, @@ -6251,6 +6254,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 47390CF61FE52509FE53FE75 /* RemoteTmuxControlConnection+Sizing.swift in Sources */, 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */, 42FBA382CDEAEC146FA5A895 /* RemoteTmuxControlConnectionSnapshot.swift in Sources */, + 7833C0057833C0057833C005 /* RemoteTmuxControlKeySendResult.swift in Sources */, 740600000000000000000002 /* RemoteTmuxController+Attach.swift in Sources */, 736200000000000000000002 /* RemoteTmuxController+Decisions.swift in Sources */, F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index 6a3f27b3418e..5f942ed6d669 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -371,7 +371,9 @@ import Testing connection.handleMessageForTesting(.commandResult(commandNumber: 1, lines: [], isError: false)) - #expect(connection.pendingCommandKindsForTesting == [.listWindows(reorderGeneration: 0)]) + #expect(connection.pendingCommandKindsForTesting == [ + .listWindows(reorderGeneration: 0, retainedPaneIDs: []) + ]) } @Test func pastePaneCommandsProtectOptionLookingText() throws { diff --git a/cmuxTests/RemoteTmuxWindowReorderTests.swift b/cmuxTests/RemoteTmuxWindowReorderTests.swift index 3c27182427ba..88adb4227420 100644 --- a/cmuxTests/RemoteTmuxWindowReorderTests.swift +++ b/cmuxTests/RemoteTmuxWindowReorderTests.swift @@ -127,7 +127,9 @@ import Testing #expect(connection.pendingCommandKindsForTesting == [.listWindowOrder(reorderGeneration: 1)]) reply(connection, lines: windowOrderLines([1, 2])) - #expect(connection.pendingCommandKindsForTesting == [.listWindows(reorderGeneration: 1)]) + #expect(connection.pendingCommandKindsForTesting == [ + .listWindows(reorderGeneration: 1, retainedPaneIDs: []) + ]) #expect(!connection.sendWindowReorder(["swap-window -d -s @1 -t @2"])) } @@ -140,7 +142,9 @@ import Testing reply(connection, lines: []) reply(connection, lines: ["garbled order"]) - #expect(connection.pendingCommandKindsForTesting == [.listWindows(reorderGeneration: 1)]) + #expect(connection.pendingCommandKindsForTesting == [ + .listWindows(reorderGeneration: 1, retainedPaneIDs: []) + ]) #expect(!connection.sendWindowReorder(["swap-window -d -s @1 -t @2"])) } @@ -153,10 +157,14 @@ import Testing #expect(connection.sendWindowReorder(["swap-window -d -s @1 -t @2"])) connection.applyWindowReorder([2, 1]) reply(connection, lines: ["can't find window: @2"], isError: true) - #expect(connection.pendingCommandKindsForTesting == [.listWindows(reorderGeneration: 1)]) + #expect(connection.pendingCommandKindsForTesting == [ + .listWindows(reorderGeneration: 1, retainedPaneIDs: []) + ]) #expect(!connection.sendWindowReorder(["swap-window -d -s @2 -t @1"])) - #expect(connection.pendingCommandKindsForTesting == [.listWindows(reorderGeneration: 1)]) + #expect(connection.pendingCommandKindsForTesting == [ + .listWindows(reorderGeneration: 1, retainedPaneIDs: []) + ]) #expect(connection.windowOrder == [2, 1]) reply(connection, lines: windowLines([2, 3, 1])) @@ -171,7 +179,9 @@ import Testing publishSinglePaneWindow(connection) #expect(connection.sendWindowReorder(["swap-window -d -s @1 -t @2"])) reply(connection, lines: ["can't find window: @2"], isError: true) - #expect(connection.pendingCommandKindsForTesting == [.listWindows(reorderGeneration: 1)]) + #expect(connection.pendingCommandKindsForTesting == [ + .listWindows(reorderGeneration: 1, retainedPaneIDs: []) + ]) reply( connection, @@ -208,6 +218,29 @@ import Testing #expect(connection.windowOrder == [2, 1, 3]) } + @Test func overlappingWindowClosesReleaseOnlyTheirOwnRetainedPaneIDs() { + let (connection, writer, pipe) = attachedConnection() + defer { writer.close(); try? pipe.fileHandleForReading.close() } + publishWindows(connection, order: [1, 2, 3]) + + connection.handleMessageForTesting(.windowClose(windowId: 1)) + connection.handleMessageForTesting(.windowClose(windowId: 2)) + #expect(connection.pendingCommandKindsForTesting == [ + .listWindows(reorderGeneration: 0, retainedPaneIDs: [10]), + .listWindows(reorderGeneration: 0, retainedPaneIDs: [10, 20]), + ]) + + // The first close's snapshot must not release pane 20, whose own close + // and authoritative refresh overlapped the earlier request. + reply(connection, lines: windowLines([2, 3])) + #expect(connection.paneIDsRetainedUntilWindowList == [20]) + #expect(connection.pendingCommandKindsForTesting.first == + .listWindows(reorderGeneration: 0, retainedPaneIDs: [10, 20])) + + reply(connection, lines: windowLines([3])) + #expect(connection.paneIDsRetainedUntilWindowList.isEmpty) + } + @Test func recoveryEscalationVerifiesAgainstAuthoritativeOrder() { let (connection, writer, pipe) = attachedConnection() defer { writer.close(); try? pipe.fileHandleForReading.close() } From f7381cbb705cb0fc288676455d9765a854f26808 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Jul 2026 07:34:56 -0700 Subject: [PATCH 19/23] Install Node for CLI wrapper CI regressions --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3a45177eb7d..2ab136b1bc75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -728,6 +728,12 @@ jobs: CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \ ./tests/test_bundled_ghostty_theme_picker_helper.sh + - name: Set up Node for CLI no-socket regressions + if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + - name: Run CLI no-socket regressions if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} run: | From a9998ef46478b8388dc5c38734f083c41054ccc5 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sat, 11 Jul 2026 01:05:39 -0700 Subject: [PATCH 20/23] Recover retained pane identities after topology errors --- ...teTmuxControlConnection+CommandResults.swift | 13 ++++++++++--- cmuxTests/RemoteTmuxWindowReorderTests.swift | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection+CommandResults.swift b/Sources/RemoteTmuxControlConnection+CommandResults.swift index 2d230015a354..d016366c2131 100644 --- a/Sources/RemoteTmuxControlConnection+CommandResults.swift +++ b/Sources/RemoteTmuxControlConnection+CommandResults.swift @@ -41,9 +41,13 @@ extension RemoteTmuxControlConnection { if case let .windowReorder(isLast) = kind { completeWindowReorderCommand(isLast: isLast, failed: true) } - if case let .listWindows(requestGeneration, _) = kind, - windowReorderRecoveryGeneration == requestGeneration { - restartAfterWindowReorderRecoveryFailure() + if case let .listWindows(requestGeneration, retainedPaneIDs) = kind { + if windowReorderRecoveryGeneration == requestGeneration { + restartAfterWindowReorderRecoveryFailure() + } else if !retainedPaneIDs.isEmpty { + record("window-list-retention-reconnect") + beginReconnecting() + } } if case .listWindowOrder = kind { requestFullWindowOrderRecovery() @@ -214,6 +218,9 @@ extension RemoteTmuxControlConnection { // publishes only when the rects replies land. } else if completesReorderRecovery { restartAfterWindowReorderRecoveryFailure() + } else if !retainedPaneIDs.isEmpty { + record("window-list-retention-reconnect") + beginReconnecting() } case let .listWindowOrder(requestGeneration): let order = lines.compactMap { line in diff --git a/cmuxTests/RemoteTmuxWindowReorderTests.swift b/cmuxTests/RemoteTmuxWindowReorderTests.swift index 88adb4227420..75652270ab67 100644 --- a/cmuxTests/RemoteTmuxWindowReorderTests.swift +++ b/cmuxTests/RemoteTmuxWindowReorderTests.swift @@ -241,6 +241,23 @@ import Testing #expect(connection.paneIDsRetainedUntilWindowList.isEmpty) } + @Test(arguments: [true, false]) + func unusableCloseRefreshReconnectsWithoutReleasingIdentity(isError: Bool) { + let (connection, writer, pipe) = attachedConnection() + defer { writer.close(); try? pipe.fileHandleForReading.close() } + publishWindows(connection, order: [1, 2]) + + connection.handleMessageForTesting(.windowClose(windowId: 1)) + reply( + connection, + lines: [isError ? "refresh rejected" : "garbled topology"], + isError: isError + ) + + #expect(connection.connectionState == .reconnecting) + #expect(connection.paneIDsRetainedUntilWindowList == [10]) + } + @Test func recoveryEscalationVerifiesAgainstAuthoritativeOrder() { let (connection, writer, pipe) = attachedConnection() defer { writer.close(); try? pipe.fileHandleForReading.close() } From 264dc7c2ff78362a008f6e80169aa54cbe51f81c Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sat, 11 Jul 2026 01:15:48 -0700 Subject: [PATCH 21/23] Integrate remote tmux resize ownership --- Sources/RemoteTmuxControlPaneLocation.swift | 21 +++++++++++ .../RemoteTmuxControlPaneMutationOwner.swift | 7 ++++ ...oteTmuxSessionMirror+ControlTopology.swift | 36 +++++++++++++++++++ ...ontroller+RemoteTmuxControlMutations.swift | 32 +++++++++-------- .../Workspace+RemoteTmuxControlTopology.swift | 3 ++ 5 files changed, 85 insertions(+), 14 deletions(-) diff --git a/Sources/RemoteTmuxControlPaneLocation.swift b/Sources/RemoteTmuxControlPaneLocation.swift index c2c6aaf18c58..c8b2cb754b24 100644 --- a/Sources/RemoteTmuxControlPaneLocation.swift +++ b/Sources/RemoteTmuxControlPaneLocation.swift @@ -7,6 +7,7 @@ import Foundation struct RemoteTmuxControlPaneLocation { let containerPanelID: UUID let owner: any RemoteTmuxControlPaneMutationOwner + let windowMirror: RemoteTmuxWindowMirror? let pane: RemoteTmuxControlPane func controlFocus() -> Bool { @@ -25,6 +26,26 @@ struct RemoteTmuxControlPaneLocation { owner.requestSplit(fromPane: pane.tmuxPaneID, vertical: vertical) } + func requestResizePane(_ tmuxPaneID: Int, direction: String, amountCells: Int) -> Bool { + owner.requestResizePane(tmuxPaneID, direction: direction, amountCells: amountCells) + } + + func requestResizePane(_ tmuxPaneID: Int, absoluteAxis: String, targetCells: Int) -> Bool { + owner.requestResizePane(tmuxPaneID, absoluteAxis: absoluteAxis, targetCells: targetCells) + } + + func requestResizePane( + _ tmuxPaneID: Int, + absoluteAxis: String, + targetPercentage: Int + ) -> Bool { + owner.requestResizePane( + tmuxPaneID, + absoluteAxis: absoluteAxis, + targetPercentage: targetPercentage + ) + } + func requestRespawn(command: String, workingDirectory: String?) -> Bool { owner.requestRespawnPane( pane.tmuxPaneID, diff --git a/Sources/RemoteTmuxControlPaneMutationOwner.swift b/Sources/RemoteTmuxControlPaneMutationOwner.swift index 9ae6ab81929a..95de1c7cd4fe 100644 --- a/Sources/RemoteTmuxControlPaneMutationOwner.swift +++ b/Sources/RemoteTmuxControlPaneMutationOwner.swift @@ -11,6 +11,13 @@ protocol RemoteTmuxControlPaneMutationOwner: AnyObject { name: String ) -> RemoteTmuxControlKeySendResult func requestSplit(fromPane tmuxPaneID: Int, vertical: Bool) -> Bool + func requestResizePane(_ tmuxPaneID: Int, direction: String, amountCells: Int) -> Bool + func requestResizePane(_ tmuxPaneID: Int, absoluteAxis: String, targetCells: Int) -> Bool + func requestResizePane( + _ tmuxPaneID: Int, + absoluteAxis: String, + targetPercentage: Int + ) -> Bool func requestRespawnPane( _ tmuxPaneID: Int, command: String, diff --git a/Sources/RemoteTmuxSessionMirror+ControlTopology.swift b/Sources/RemoteTmuxSessionMirror+ControlTopology.swift index dbf9b5ba1d2c..fc1241f8d39c 100644 --- a/Sources/RemoteTmuxSessionMirror+ControlTopology.swift +++ b/Sources/RemoteTmuxSessionMirror+ControlTopology.swift @@ -60,6 +60,7 @@ extension RemoteTmuxSessionMirror { return RemoteTmuxControlPaneLocation( containerPanelID: containerPanelID, owner: self, + windowMirror: windowMirror, pane: $0 ) } @@ -79,6 +80,7 @@ extension RemoteTmuxSessionMirror { return [RemoteTmuxControlPaneLocation( containerPanelID: containerPanelID, owner: self, + windowMirror: nil, pane: pane )] } @@ -119,6 +121,40 @@ extension RemoteTmuxSessionMirror { ) } + func requestResizePane(_ tmuxPaneID: Int, direction: String, amountCells: Int) -> Bool { + guard let windowID = windowIdByPane[tmuxPaneID], + let windowMirror = windowMirrorByWindowId[windowID] else { return false } + return windowMirror.requestResizePane( + tmuxPaneID, + direction: direction, + amountCells: amountCells + ) + } + + func requestResizePane(_ tmuxPaneID: Int, absoluteAxis: String, targetCells: Int) -> Bool { + guard let windowID = windowIdByPane[tmuxPaneID], + let windowMirror = windowMirrorByWindowId[windowID] else { return false } + return windowMirror.requestResizePane( + tmuxPaneID, + absoluteAxis: absoluteAxis, + targetCells: targetCells + ) + } + + func requestResizePane( + _ tmuxPaneID: Int, + absoluteAxis: String, + targetPercentage: Int + ) -> Bool { + guard let windowID = windowIdByPane[tmuxPaneID], + let windowMirror = windowMirrorByWindowId[windowID] else { return false } + return windowMirror.requestResizePane( + tmuxPaneID, + absoluteAxis: absoluteAxis, + targetPercentage: targetPercentage + ) + } + func requestRespawnPane( _ tmuxPaneID: Int, command shellCommand: String, diff --git a/Sources/TerminalController+RemoteTmuxControlMutations.swift b/Sources/TerminalController+RemoteTmuxControlMutations.swift index cedc9700066f..b85bcdedd265 100644 --- a/Sources/TerminalController+RemoteTmuxControlMutations.swift +++ b/Sources/TerminalController+RemoteTmuxControlMutations.swift @@ -225,14 +225,16 @@ extension TerminalController { tabManager: TabManager, inputs: ControlPaneResizeInputs ) -> ControlPaneResizeResolution? { - let location: Workspace.RemoteTmuxControlPaneLocation + let location: RemoteTmuxControlPaneLocation if let paneID = inputs.paneID { guard let remote = workspace.remoteTmuxControlPane(paneID: paneID) else { return nil } location = remote } else if let focusedPanelID = workspace.focusedPanelId, - let mirror = workspace.remoteTmuxWindowMirror(forPanelId: focusedPanelID) { - guard let pane = mirror.activeControlPane() else { return .noFocusedPane } - location = (focusedPanelID, mirror, pane) + workspace.isRemoteTmuxControlContainer(focusedPanelID) { + guard let focused = workspace.activeRemoteTmuxControlPane( + containerPanelID: focusedPanelID + ) else { return .noFocusedPane } + location = focused } else { return nil } @@ -247,7 +249,7 @@ extension TerminalController { ) switch inputs.intent { case .tmuxAbsoluteCells(let axis, let targetCells, let fallbackPoints): - guard location.mirror.requestResizePane( + guard location.requestResizePane( location.pane.tmuxPaneID, absoluteAxis: axis, targetCells: targetCells @@ -263,7 +265,7 @@ extension TerminalController { ) case .tmuxAbsolutePercentage(let axis, let percentage, let fallbackPoints): - guard location.mirror.requestResizePane( + guard location.requestResizePane( location.pane.tmuxPaneID, absoluteAxis: axis, targetPercentage: percentage @@ -279,7 +281,7 @@ extension TerminalController { ) case .tmuxRelative(let direction, let amountCells, let fallbackPoints): - guard location.mirror.requestResizePane( + guard location.requestResizePane( location.pane.tmuxPaneID, direction: direction, amountCells: amountCells @@ -296,17 +298,18 @@ extension TerminalController { case .outerAbsolute(let axis, let targetPoints): guard targetPoints.isFinite else { return unavailable } + guard let windowMirror = location.windowMirror else { return unavailable } let orientation: SplitOrientation switch axis { case "horizontal": orientation = .horizontal case "vertical": orientation = .vertical default: return unavailable } - guard let context = RemoteTmuxNativeSplitTree(layout: location.mirror.layout) + guard let context = RemoteTmuxNativeSplitTree(layout: windowMirror.layout) .paneResizeContext( paneID: location.pane.tmuxPaneID, orientation: orientation - ), let metrics = location.mirror.nativeLayoutMetrics() else { + ), let metrics = windowMirror.nativeLayoutMetrics() else { return unavailable } guard context.hasSplitAncestor else { @@ -317,7 +320,7 @@ extension TerminalController { orientation: orientation, outerExtent: CGFloat(targetPoints) ) - guard location.mirror.requestResizePane( + guard location.requestResizePane( location.pane.tmuxPaneID, absoluteAxis: axis, targetCells: targetCells @@ -333,14 +336,15 @@ extension TerminalController { ) case .borderRelative(let directionRaw, let amountPoints): - guard let direction = V2PaneResizeDirection(rawValue: directionRaw), - let metrics = location.mirror.nativeLayoutMetrics() else { + guard let windowMirror = location.windowMirror, + let direction = V2PaneResizeDirection(rawValue: directionRaw), + let metrics = windowMirror.nativeLayoutMetrics() else { return unavailable } let orientation: SplitOrientation = direction.splitOrientation == "horizontal" ? .horizontal : .vertical - guard let context = RemoteTmuxNativeSplitTree(layout: location.mirror.layout) + guard let context = RemoteTmuxNativeSplitTree(layout: windowMirror.layout) .paneResizeContext( paneID: location.pane.tmuxPaneID, orientation: orientation @@ -376,7 +380,7 @@ extension TerminalController { pointDelta: CGFloat(amountPoints), orientation: orientation ) - guard location.mirror.requestResizePane( + guard location.requestResizePane( commandPaneID, direction: directionRaw, amountCells: amountCells diff --git a/Sources/Workspace+RemoteTmuxControlTopology.swift b/Sources/Workspace+RemoteTmuxControlTopology.swift index 4c2070c03095..d76b2b22440f 100644 --- a/Sources/Workspace+RemoteTmuxControlTopology.swift +++ b/Sources/Workspace+RemoteTmuxControlTopology.swift @@ -23,6 +23,7 @@ extension Workspace { return RemoteTmuxControlPaneLocation( containerPanelID: containerPanelID, owner: mirror, + windowMirror: mirror, pane: pane ) } @@ -39,6 +40,7 @@ extension Workspace { return RemoteTmuxControlPaneLocation( containerPanelID: containerPanelID, owner: mirror, + windowMirror: mirror, pane: pane ) } @@ -57,6 +59,7 @@ extension Workspace { RemoteTmuxControlPaneLocation( containerPanelID: containerPanelID, owner: mirror, + windowMirror: mirror, pane: $0 ) } From 6c4558a74af37430981d0d909b10486ac3d3a76c Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sat, 11 Jul 2026 01:35:09 -0700 Subject: [PATCH 22/23] Close remote tmux tabs before topology refresh --- Sources/RemoteTmuxControlConnection.swift | 4 ++++ cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift | 2 ++ 2 files changed, 6 insertions(+) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 1374a77cd221..40ad91600f0d 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -753,6 +753,10 @@ final class RemoteTmuxControlConnection { // against the destination's pending tree instead of pruning the // surviving pane during that event gap. requestWindows() + // Remove the closed window's tab immediately. The retained-pane + // ledger above keeps any moved pane's control identity alive until + // the authoritative window snapshot publishes its destination. + observers.notifyTopologyChanged() case let .windowRenamed(id, name): record("window-renamed @\(id)") // Update published AND quarantined topology. A rename racing a diff --git a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift index 6f827e1cf9d3..cd537e219466 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutIdentityTests.swift @@ -302,6 +302,8 @@ import Testing let stablePaneID = try #require(harness.controlPaneID(surfaceID: oldSurfaceID)) harness.connection.handleMessageForTesting(.windowClose(windowId: 1)) + #expect(harness.workspace.panels[oldSurfaceID] == nil) + #expect(harness.sessionMirror.controlPaneID(forPane: 11)?.id == stablePaneID) harness.connection.handleMessageForTesting(.windowRenamed(windowId: 2, name: "renamed")) #expect(harness.sessionMirror.controlPaneID(forPane: 11)?.id == stablePaneID) let layout = "cafe,80x24,0,0[80x12,0,0,44,80x11,0,13,11]" From bdcc92ff2f1f6398cfd8bef13071d7014592a1fc Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sat, 11 Jul 2026 01:53:47 -0700 Subject: [PATCH 23/23] Coalesce remote tmux topology refreshes --- ...TmuxControlConnection+CommandResults.swift | 5 +++++ ...RemoteTmuxControlConnection+Commands.swift | 21 +++++++++++++++++-- Sources/RemoteTmuxControlConnection.swift | 5 +++++ cmuxTests/RemoteTmuxWindowReorderTests.swift | 10 ++++----- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection+CommandResults.swift b/Sources/RemoteTmuxControlConnection+CommandResults.swift index d016366c2131..61f4f6c1cb8c 100644 --- a/Sources/RemoteTmuxControlConnection+CommandResults.swift +++ b/Sources/RemoteTmuxControlConnection+CommandResults.swift @@ -9,6 +9,11 @@ extension RemoteTmuxControlConnection { // misalign the positional correlation. guard !pendingCommands.isEmpty else { return } let kind = pendingCommands.removeFirst() + defer { + if case .listWindows = kind { + completeWindowListRequest() + } + } guard !isError else { // An errored activity query must still complete (with nil) — a close // decision is waiting on it and falls back to the cached state. diff --git a/Sources/RemoteTmuxControlConnection+Commands.swift b/Sources/RemoteTmuxControlConnection+Commands.swift index c37a50a50d76..827fea07ce46 100644 --- a/Sources/RemoteTmuxControlConnection+Commands.swift +++ b/Sources/RemoteTmuxControlConnection+Commands.swift @@ -50,13 +50,30 @@ extension RemoteTmuxControlConnection { /// id and layout tokens never do — so the result parses as /// `@id <layout> <name with spaces…>`. func requestWindows() { - sendInternal( + guard !windowListRequestInFlight else { + windowListRequestDirty = true + return + } + guard sendInternal( "list-windows -F \"#{window_id} #{window_layout} #{window_visible_layout} [#{window_flags}] #{window_name}\"", kind: .listWindows( reorderGeneration: windowReorderGeneration, retainedPaneIDs: paneIDsRetainedUntilWindowList ) - ) + ) else { return } + windowListRequestInFlight = true + } + + func completeWindowListRequest() { + windowListRequestInFlight = false + guard windowListRequestDirty else { return } + windowListRequestDirty = false + requestWindows() + } + + func resetWindowListRequestCoalescing() { + windowListRequestInFlight = false + windowListRequestDirty = false } func restartAfterWindowReorderRecoveryFailure() { diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 40ad91600f0d..130f488043cb 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -102,6 +102,8 @@ final class RemoteTmuxControlConnection { private var ingestTask: Task<Void, Never>? private var processGeneration: UInt64 = 0 var pendingCommands: [CommandKind] = [] + var windowListRequestInFlight = false + var windowListRequestDirty = false var windowReorderBatchFailed = false var windowReorderGeneration: UInt64 = 0 var windowReorderRecoveryGeneration: UInt64? @@ -311,6 +313,7 @@ final class RemoteTmuxControlConnection { // A fresh control stream cannot retain the prior parser or command FIFO. parser = RemoteTmuxControlStreamParser() pendingCommands.removeAll() + resetWindowListRequestCoalescing() windowReorderBatchFailed = false windowReorderRecoveryGeneration = nil pendingLayouts.removeAll() @@ -453,6 +456,7 @@ final class RemoteTmuxControlConnection { failPendingWindowReorderVerifications() reconnectTask?.cancel() reconnectTask = nil + resetWindowListRequestCoalescing() cancelSizingFollowUps() pendingPostAttachAction = nil } @@ -612,6 +616,7 @@ final class RemoteTmuxControlConnection { failPendingActivityQueries() failPendingNewWindowRequests() failPendingWindowReorderVerifications() + resetWindowListRequestCoalescing() cancelSizingFollowUps() pendingPostAttachAction = nil teardownProcessHandles() diff --git a/cmuxTests/RemoteTmuxWindowReorderTests.swift b/cmuxTests/RemoteTmuxWindowReorderTests.swift index 75652270ab67..efbeafab8469 100644 --- a/cmuxTests/RemoteTmuxWindowReorderTests.swift +++ b/cmuxTests/RemoteTmuxWindowReorderTests.swift @@ -227,15 +227,15 @@ import Testing connection.handleMessageForTesting(.windowClose(windowId: 2)) #expect(connection.pendingCommandKindsForTesting == [ .listWindows(reorderGeneration: 0, retainedPaneIDs: [10]), - .listWindows(reorderGeneration: 0, retainedPaneIDs: [10, 20]), ]) - // The first close's snapshot must not release pane 20, whose own close - // and authoritative refresh overlapped the earlier request. + // Burst closes share one in-flight snapshot. Its completion releases only + // pane 10 and queues one follow-up containing the later close's pane 20. reply(connection, lines: windowLines([2, 3])) #expect(connection.paneIDsRetainedUntilWindowList == [20]) - #expect(connection.pendingCommandKindsForTesting.first == - .listWindows(reorderGeneration: 0, retainedPaneIDs: [10, 20])) + #expect(connection.pendingCommandKindsForTesting == [ + .listWindows(reorderGeneration: 0, retainedPaneIDs: [20]), + ]) reply(connection, lines: windowLines([3])) #expect(connection.paneIDsRetainedUntilWindowList.isEmpty)