From b949cd81cb7a1eddf78768b3f7caefa601408e7c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 18:59:26 -0400 Subject: [PATCH 1/5] fix(cloud): keep active pane focus with manual input --- .../GhosttyNSView+PointerFocusActivation.swift | 10 +--------- .../CmuxTuiSurfaceProvider+ManualMirror.swift | 12 +++++++++++- Sources/Workspace.swift | 16 ++++++++++++++++ cmuxTests/SurfacePaneFactoryFocusTests.swift | 16 ++++++++++++++++ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/Sources/GhosttyNSView+PointerFocusActivation.swift b/Sources/GhosttyNSView+PointerFocusActivation.swift index 54a69b2c4d34..5b7f0a74e76f 100644 --- a/Sources/GhosttyNSView+PointerFocusActivation.swift +++ b/Sources/GhosttyNSView+PointerFocusActivation.swift @@ -22,15 +22,7 @@ extension GhosttyNSView { // A remote-tmux mirror pane surface is not a workspace panel, so // without it every pointer-down in one of those would ask the // container to select a pane it does not own. - if let workspace = terminalSurface.owningWorkspace(), - workspace.panels[terminalSurface.id] != nil, - (workspace.owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: workspace.id))?.selectedTabId == workspace.id, - workspace.focusedPanelId != terminalSurface.id { - workspace.focusPanel( - terminalSurface.id, - trigger: .terminalFirstResponder - ) - } + terminalSurface.owningWorkspace()?.focusPanelFromTerminalInput(terminalSurface.id) case .rightSidebarDock: DockSplitStore.focusPanelFromDockPointer(terminalSurface.id, window: window) } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index 3478702c1b2e..b3434fdfa0af 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -132,8 +132,18 @@ extension CmuxTuiSurfaceProvider { // input. A cloud terminal can have more than one local projection; // the pane the user is typing in must be the authoritative owner. let existingExplicitInput = created.surface.onExplicitInput - created.surface.onExplicitInput = { [weak session] in + let createdWorkspaceID = created.workspaceID + let createdSurface = created.surface + created.surface.onExplicitInput = { [weak session, weak createdSurface] in existingExplicitInput?() + // A manual-mirror portal can accept a key or paste while its + // AppKit focus callback is one turn behind. Reassert the + // selected workspace pane at the input boundary so the active + // pane border cannot remain on an adjacent local terminal. + if let createdSurface, + let workspace = Workspace.liveWorkspace(id: createdWorkspaceID) { + workspace.focusPanelFromTerminalInput(createdSurface.id) + } session?.noteExplicitInput() } manualMirrorSessions[created.panelID] = session diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c6fb86fed9f3..575fbdc258d9 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -11678,6 +11678,22 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } // MARK: - Focus Management + /// Converges the workspace pane selection when a mounted terminal accepts + /// user input. Cloud manual-mirror surfaces can receive an input event + /// while their portal focus callback is still being rebound; keeping this + /// guard at the workspace boundary makes the active pane border and the + /// keyboard target follow the same selected workspace. + @discardableResult + func focusPanelFromTerminalInput(_ panelId: UUID) -> Bool { + guard panels[panelId] != nil, + (owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id))?.selectedTabId == id, + focusedPanelId != panelId else { + return false + } + focusPanel(panelId, trigger: .terminalFirstResponder) + return true + } + /// Focuses a panel a split just created. `previousHostedView` is the terminal that /// had focus before Bonsplit moved it. SwiftUI reparents that view into the new /// split, which would trigger onFocus + ghostty_surface_set_focus on it and steal diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index 580f891e2538..b63ad10395e8 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -83,6 +83,22 @@ import SwiftUI #expect(workspace.panelIdFromSurfaceId(selectedSurface) == created.panelID) } + @Test("Cloud terminal input reasserts the active pane") + func cloudTerminalInputReassertsActivePane() throws { + let harness = try Harness() + defer { harness.tearDown() } + let workspace = harness.workspace + let paneID = try #require(workspace.bonsplitController.focusedPaneId) + let localPanelID = try #require(workspace.focusedPanelId) + let cloudPanel = try #require(workspace.newTerminalSurface(inPane: paneID, focus: false)) + cloudPanel.cloudAttachment = CloudTerminalAttachmentStatus(machineID: "focus-ring-test") + + #expect(workspace.focusedPanelId == localPanelID) + #expect(workspace.focusPanelFromTerminalInput(cloudPanel.id)) + #expect(workspace.focusedPanelId == cloudPanel.id) + #expect(!workspace.focusPanelFromTerminalInput(cloudPanel.id)) + } + @Test("Cloud shortcut inheritance uses the live remote foreground cwd") func cloudShortcutInheritanceUsesLiveRemoteForegroundCwd() async throws { let harness = try Harness() From ff85d255b906474a9b50c9c13be0607379a61cb7 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 20:34:54 -0400 Subject: [PATCH 2/5] test(cloud): cover split pane focus convergence --- cmuxTests/SurfacePaneFactoryFocusTests.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index b63ad10395e8..081d9aea88d5 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -88,9 +88,12 @@ import SwiftUI let harness = try Harness() defer { harness.tearDown() } let workspace = harness.workspace - let paneID = try #require(workspace.bonsplitController.focusedPaneId) let localPanelID = try #require(workspace.focusedPanelId) - let cloudPanel = try #require(workspace.newTerminalSurface(inPane: paneID, focus: false)) + let cloudPanel = try #require(workspace.newTerminalSplit( + from: localPanelID, + orientation: .horizontal, + focus: false + )) cloudPanel.cloudAttachment = CloudTerminalAttachmentStatus(machineID: "focus-ring-test") #expect(workspace.focusedPanelId == localPanelID) From c3cf1d70c317f8d5cc7c9f02c4fd6986d221dc6b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 20:59:28 -0400 Subject: [PATCH 3/5] fix(cloud): preserve input focus convergence across moves --- Sources/Panels/TerminalPanel.swift | 4 +++ .../CmuxTuiSurfaceProvider+ManualMirror.swift | 26 +++++----------- .../Workspace+CloudManualMirror.swift | 15 +++++++++ Sources/Workspace+AttentionFlashRouting.swift | 1 + cmuxTests/SurfacePaneFactoryFocusTests.swift | 31 ++++++++++++++----- 5 files changed, 52 insertions(+), 25 deletions(-) diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index a132806c6157..8d757c4e264b 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -122,6 +122,10 @@ final class TerminalPanel: Panel, ObservableObject { /// Optional owner hook for a manual mirror that becomes the active pane. /// Ordinary terminals leave this unset. var onTerminalFocus: (() -> Void)? + /// Optional input hook owned by a manual-mirror session. Workspace routing + /// composes this hook into its current owner callback so a panel that moves + /// between workspaces keeps its Cloud input behavior. + var onManualMirrorExplicitInput: (() -> Void)? private var cancellables = Set() /// Shared monotonic gate for AppKit and workspace-overlay flash renderers. diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index b3434fdfa0af..1313e4354172 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -127,24 +127,14 @@ extension CmuxTuiSurfaceProvider { ) } session.bind(surface: created.surface) - // Preserve the workspace's existing notification-dismissal hook - // while re-claiming geometry when this pane receives explicit - // input. A cloud terminal can have more than one local projection; - // the pane the user is typing in must be the authoritative owner. - let existingExplicitInput = created.surface.onExplicitInput - let createdWorkspaceID = created.workspaceID - let createdSurface = created.surface - created.surface.onExplicitInput = { [weak session, weak createdSurface] in - existingExplicitInput?() - // A manual-mirror portal can accept a key or paste while its - // AppKit focus callback is one turn behind. Reassert the - // selected workspace pane at the input boundary so the active - // pane border cannot remain on an adjacent local terminal. - if let createdSurface, - let workspace = Workspace.liveWorkspace(id: createdWorkspaceID) { - workspace.focusPanelFromTerminalInput(createdSurface.id) - } - session?.noteExplicitInput() + // Keep Cloud input convergence on the panel. Workspace transfer + // rebinds the ordinary terminal callback, while this hook resolves + // the panel's current owner after a move. + if let createdPanel = Workspace.liveWorkspace(id: created.workspaceID)?.panels[created.panelID] as? TerminalPanel { + Workspace.bindCloudManualMirrorInputConvergence( + panel: createdPanel, + onExplicitInput: { [weak session] in session?.noteExplicitInput() } + ) } manualMirrorSessions[created.panelID] = session session.reconnect(socketPath: connected.socketPath) diff --git a/Sources/Surfaces/Workspace+CloudManualMirror.swift b/Sources/Surfaces/Workspace+CloudManualMirror.swift index 112f3d93ad71..2096efb80d12 100644 --- a/Sources/Surfaces/Workspace+CloudManualMirror.swift +++ b/Sources/Surfaces/Workspace+CloudManualMirror.swift @@ -115,6 +115,21 @@ extension Workspace { panel.cloudAttachment = attachment } + /// Keeps Cloud input convergence attached to the panel rather than to the + /// workspace that happened to create it. Workspace transfer rebinds the + /// ordinary terminal callback, while this hook follows the panel and + /// resolves its current workspace at input time. + static func bindCloudManualMirrorInputConvergence( + panel: TerminalPanel, + onExplicitInput: @escaping @MainActor () -> Void + ) { + panel.onManualMirrorExplicitInput = { [weak panel] in + guard let panel else { return } + onExplicitInput() + Workspace.liveWorkspace(id: panel.workspaceId)?.focusPanelFromTerminalInput(panel.id) + } + } + /// Places an already-built manual-mirror panel at `destination` and returns its id. /// `isLoading` marks the tab strip while an optimistic pane waits for its terminal. func insertCloudManualMirrorPanel( diff --git a/Sources/Workspace+AttentionFlashRouting.swift b/Sources/Workspace+AttentionFlashRouting.swift index 916712a2e7f8..89885f367e07 100644 --- a/Sources/Workspace+AttentionFlashRouting.swift +++ b/Sources/Workspace+AttentionFlashRouting.swift @@ -20,6 +20,7 @@ extension Workspace { func installTerminalVisualBellRouting(for terminalPanel: TerminalPanel) { terminalPanel.surface.onExplicitInput = { [weak self, weak terminalPanel] in guard let self, let terminalPanel else { return } + terminalPanel.onManualMirrorExplicitInput?() terminalPanel.recordExplicitInput() // Explicit input is shared-sizing activity for this Mac pane. TerminalController.shared.noteLocalTerminalSizingActivity(surfaceID: terminalPanel.id) diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index 081d9aea88d5..b3dcb86db7c6 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -88,18 +88,35 @@ import SwiftUI let harness = try Harness() defer { harness.tearDown() } let workspace = harness.workspace + let sourcePane = try #require(workspace.bonsplitController.focusedPaneId) let localPanelID = try #require(workspace.focusedPanelId) - let cloudPanel = try #require(workspace.newTerminalSplit( - from: localPanelID, - orientation: .horizontal, - focus: false - )) + let cloudPanel = try #require(workspace.makeRemoteTmuxPanePanel(onInput: { _ in })) cloudPanel.cloudAttachment = CloudTerminalAttachmentStatus(machineID: "focus-ring-test") + _ = try workspace.insertCloudManualMirrorPanel( + cloudPanel, + at: .split(workspaceID: workspace.id, paneID: sourcePane.id.uuidString, direction: .right), + focus: false, + isLoading: false + ) + Workspace.bindCloudManualMirrorInputConvergence(panel: cloudPanel, onExplicitInput: {}) #expect(workspace.focusedPanelId == localPanelID) - #expect(workspace.focusPanelFromTerminalInput(cloudPanel.id)) + cloudPanel.surface.onExplicitInput?() #expect(workspace.focusedPanelId == cloudPanel.id) - #expect(!workspace.focusPanelFromTerminalInput(cloudPanel.id)) + cloudPanel.surface.onExplicitInput?() + #expect(workspace.focusedPanelId == cloudPanel.id) + + // Moving the same panel to another workspace must keep the input hook + // attached to its new owner after attach rebinds ordinary callbacks. + let manager = try #require(harness.appDelegate.tabManagerFor(windowId: harness.windowId)) + let destination = manager.addWorkspace(select: false, eagerLoadTerminal: false) + let detached = try #require(workspace.detachSurface(panelId: cloudPanel.id)) + let destinationPane = try #require(destination.bonsplitController.allPaneIds.first) + #expect(destination.attachDetachedSurface(detached, inPane: destinationPane, focus: false) == cloudPanel.id) + manager.selectWorkspace(destination) + #expect(destination.focusedPanelId != cloudPanel.id) + cloudPanel.surface.onExplicitInput?() + #expect(destination.focusedPanelId == cloudPanel.id) } @Test("Cloud shortcut inheritance uses the live remote foreground cwd") From 924ae51aa990ce0316a4bf4a77d70b6900b39780 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 23:10:00 -0400 Subject: [PATCH 4/5] Fence Cloud input focus to active mirror sessions --- .../CmuxTuiSurfaceProvider+ManualMirror.swift | 4 ++++ .../Surfaces/Workspace+CloudManualMirror.swift | 5 +++-- cmuxTests/SurfacePaneFactoryFocusTests.swift | 16 +++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index 1313e4354172..af7cd872401f 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -133,6 +133,10 @@ extension CmuxTuiSurfaceProvider { if let createdPanel = Workspace.liveWorkspace(id: created.workspaceID)?.panels[created.panelID] as? TerminalPanel { Workspace.bindCloudManualMirrorInputConvergence( panel: createdPanel, + isActive: { [weak session, weak createdPanel] in + guard let session, let createdPanel else { return false } + return session.phase != .stopped && session.surface === createdPanel.surface + }, onExplicitInput: { [weak session] in session?.noteExplicitInput() } ) } diff --git a/Sources/Surfaces/Workspace+CloudManualMirror.swift b/Sources/Surfaces/Workspace+CloudManualMirror.swift index 2096efb80d12..020cd33efc57 100644 --- a/Sources/Surfaces/Workspace+CloudManualMirror.swift +++ b/Sources/Surfaces/Workspace+CloudManualMirror.swift @@ -121,12 +121,13 @@ extension Workspace { /// resolves its current workspace at input time. static func bindCloudManualMirrorInputConvergence( panel: TerminalPanel, + isActive: @escaping @MainActor () -> Bool = { true }, onExplicitInput: @escaping @MainActor () -> Void ) { panel.onManualMirrorExplicitInput = { [weak panel] in - guard let panel else { return } - onExplicitInput() + guard let panel, isActive() else { return } Workspace.liveWorkspace(id: panel.workspaceId)?.focusPanelFromTerminalInput(panel.id) + onExplicitInput() } } diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index b3dcb86db7c6..2332b90554ab 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -98,7 +98,14 @@ import SwiftUI focus: false, isLoading: false ) - Workspace.bindCloudManualMirrorInputConvergence(panel: cloudPanel, onExplicitInput: {}) + var sessionActive = true + Workspace.bindCloudManualMirrorInputConvergence( + panel: cloudPanel, + isActive: { sessionActive }, + onExplicitInput: { + #expect(Workspace.liveWorkspace(id: cloudPanel.workspaceId)?.focusedPanelId == cloudPanel.id) + } + ) #expect(workspace.focusedPanelId == localPanelID) cloudPanel.surface.onExplicitInput?() @@ -117,6 +124,13 @@ import SwiftUI #expect(destination.focusedPanelId != cloudPanel.id) cloudPanel.surface.onExplicitInput?() #expect(destination.focusedPanelId == cloudPanel.id) + + // A stopped session must not steal focus if its panel is later reused. + sessionActive = false + let localDestinationPanel = try #require(destination.panels.keys.first { $0 != cloudPanel.id }) + destination.focusPanelFromTerminalInput(localDestinationPanel) + cloudPanel.surface.onExplicitInput?() + #expect(destination.focusedPanelId == localDestinationPanel) } @Test("Cloud shortcut inheritance uses the live remote foreground cwd") From 6bf8a1a6e42f7ad182bc0f246ae5f4f3719261b0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 02:25:33 -0400 Subject: [PATCH 5/5] test(cloud): cover pointer focus convergence --- cmuxTests/SurfacePaneFactoryFocusTests.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index 2332b90554ab..e123dc8c34aa 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -107,6 +107,17 @@ import SwiftUI } ) + #expect(workspace.focusedPanelId == localPanelID) + + // Pointer activation must converge the visible pane selection before + // the portal's asynchronous callback gets a chance to run. + let pointerView = GhosttyNSView(frame: .zero) + pointerView.terminalSurface = cloudPanel.surface + pointerView.activateContainerFocusFromPointerDown() + #expect(workspace.focusedPanelId == cloudPanel.id) + #expect(workspace.isFocusedTerminalInputSurface(cloudPanel.id)) + + workspace.focusPanel(localPanelID) #expect(workspace.focusedPanelId == localPanelID) cloudPanel.surface.onExplicitInput?() #expect(workspace.focusedPanelId == cloudPanel.id)