From 84633437b5d43f8379a30d8a000dc90f2323efe2 Mon Sep 17 00:00:00 2001 From: Joshua Van Deren Date: Sun, 13 Sep 2026 12:12:24 -0600 Subject: [PATCH 1/4] Add multiplexer detach/resume Leave a mux session running from Tabs / the tab menu, focus a live auto-start attachment instead of opening a second client, and offer Reconnect on a short banner. Raw-pane chords use swipe discovery when present, otherwise the usual defaults. --- rootshell/App/AppCommands.swift | 8 + rootshell/App/CatalystAppDelegate.swift | 11 +- .../App/UIApplication+CommandFallback.swift | 4 + rootshell/Core/Keybinds/KeybindAction.swift | 8 +- .../Keybinds/KeybindCommandGenerator.swift | 2 +- .../Terminal/TerminalSessionController.swift | 6 +- .../Features/Herdr/HerdrController.swift | 14 +- .../Multiplexer/MuxDetachBannerState.swift | 14 + .../Multiplexer/MuxSessionDetach.swift | 386 ++++++++++++++++++ .../Multiplexer/MuxSessionResume.swift | 211 ++++++++++ .../Discovery/SessionDiscoveryRunner.swift | 2 + .../Session/MultiplexerSwipeBindings.swift | 52 ++- rootshell/Features/Tmux/TmuxController.swift | 12 + .../Tmux/Views/TmuxSessionDashboardView.swift | 8 +- .../Features/Tmux/Views/TmuxTabMenu.swift | 46 ++- .../UI/Shell/MainView+ConnectionSheet.swift | 13 + .../UI/Shell/MainView+Notifications.swift | 32 +- rootshell/UI/Shell/MainView+Splits.swift | 9 +- .../UI/Shell/MainView+TabManagement.swift | 3 + rootshell/UI/Shell/MainView+TabSidebar.swift | 60 +++ .../UI/Shell/MainView+TerminalContent.swift | 51 +++ rootshell/UI/Shell/MainView.swift | 43 +- rootshell/UI/Sidebar/VerticalTabSidebar.swift | 10 +- rootshell/UI/Tabs/TabBar.swift | 3 + .../UI/Terminal/TerminalSplitTreeView.swift | 2 + .../UI/Terminal/TerminalView+Keyboard.swift | 4 + rootshell/UI/Terminal/TerminalView.swift | 29 +- 27 files changed, 986 insertions(+), 57 deletions(-) create mode 100644 rootshell/Features/Multiplexer/MuxDetachBannerState.swift create mode 100644 rootshell/Features/Multiplexer/MuxSessionDetach.swift create mode 100644 rootshell/Features/Multiplexer/MuxSessionResume.swift diff --git a/rootshell/App/AppCommands.swift b/rootshell/App/AppCommands.swift index 61289d1e0..636654e8e 100644 --- a/rootshell/App/AppCommands.swift +++ b/rootshell/App/AppCommands.swift @@ -633,6 +633,14 @@ struct WindowCommands: Commands { } .modifier(DynamicShortcut(action: .discover_sessions, shortcuts: shortcutState.shortcuts)) + Button("Detach Session") { + UIApplication.shared.sendAction( + #selector(Ghostty.TerminalView.menuDetachSession(_:)), + to: nil, from: nil, for: nil + ) + } + .modifier(DynamicShortcut(action: .detach_session, shortcuts: shortcutState.shortcuts)) + Button("Detach Other Clients") { UIApplication.shared.sendAction( #selector(Ghostty.TerminalView.menuDetachOtherClients(_:)), diff --git a/rootshell/App/CatalystAppDelegate.swift b/rootshell/App/CatalystAppDelegate.swift index 4aaa02d00..3072e8871 100644 --- a/rootshell/App/CatalystAppDelegate.swift +++ b/rootshell/App/CatalystAppDelegate.swift @@ -348,6 +348,10 @@ extension UIApplication { sendAction(#selector(Ghostty.TerminalView.menuDiscoverSessions(_:)), to: nil, from: sender, for: nil) } + @objc func ghostty_detachSession(_ sender: Any?) { + sendAction(#selector(Ghostty.TerminalView.menuDetachSession(_:)), to: nil, from: sender, for: nil) + } + @objc func ghostty_detachOtherClients(_ sender: Any?) { sendAction(#selector(Ghostty.TerminalView.menuDetachOtherClients(_:)), to: nil, from: sender, for: nil) } @@ -1481,6 +1485,11 @@ class CatalystAppDelegate: AppDelegate { modifierFlags: [.command, .control] ) + let detachSession = UICommand( + title: String(localized: "Detach Session"), + action: #selector(UIApplication.ghostty_detachSession(_:)) + ) + let detachOtherClients = UIKeyCommand( title: String(localized: "Detach Other Clients"), action: #selector(UIApplication.ghostty_detachOtherClients(_:)), @@ -1511,7 +1520,7 @@ class CatalystAppDelegate: AppDelegate { let navGroup = UIMenu(title: "", options: .displayInline, children: [ toggleTabSwitcher, toggleTabExpose, previousTab, nextTab, previousGroup, nextGroup, tmuxSessions, - discoverSessions, detachOtherClients + discoverSessions, detachSession, detachOtherClients ]) // Tab selection (1-9), each with its own action (see ghostty_selectTabN). diff --git a/rootshell/App/UIApplication+CommandFallback.swift b/rootshell/App/UIApplication+CommandFallback.swift index 9e1e9d500..6ef7e6e7e 100644 --- a/rootshell/App/UIApplication+CommandFallback.swift +++ b/rootshell/App/UIApplication+CommandFallback.swift @@ -250,6 +250,10 @@ extension UIApplication { ghostty_postNotification(.discoverSessions) } + @objc func menuDetachSession(_ sender: Any?) { + ghostty_postNotification(.detachSession) + } + @objc func menuDetachOtherClients(_ sender: Any?) { ghostty_postNotification(.detachOtherClients) } diff --git a/rootshell/Core/Keybinds/KeybindAction.swift b/rootshell/Core/Keybinds/KeybindAction.swift index 043a01d53..3fbe62798 100644 --- a/rootshell/Core/Keybinds/KeybindAction.swift +++ b/rootshell/Core/Keybinds/KeybindAction.swift @@ -102,6 +102,8 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case show_tmux_sessions = "show_tmux_sessions" /// Re-run multiplexer session discovery on the focused terminal case discover_sessions = "discover_sessions" + /// Detach the current tab from its multiplexer (tmux / zellij / herdr / zmx) + case detach_session = "detach_session" /// Detach all OTHER tmux clients from the current gateway (`detach-client -a`) case detach_other_clients = "detach_other_clients" @@ -302,7 +304,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .new_local_shell, .new_tab, .new_window, .close_tab, .duplicate_ssh_tab, .previous_tab, .next_tab, .show_tmux_sessions, .discover_sessions, - .detach_other_clients, .toggle_tab_switcher, + .detach_session, .detach_other_clients, .toggle_tab_switcher, .toggle_tab_expose, .previous_group, .next_group, .select_tab_1, .select_tab_2, .select_tab_3, .select_tab_4, .select_tab_5, .select_tab_6, .select_tab_7, .select_tab_8, .select_tab_9: return .tabs @@ -361,6 +363,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .next_tab: return String(localized: "Next Tab", comment: "Keybind action") case .show_tmux_sessions: return String(localized: "Sessions & Workspaces", comment: "Keybind action") case .discover_sessions: return String(localized: "Discover Sessions", comment: "Keybind action") + case .detach_session: return String(localized: "Detach Session", comment: "Keybind action: leave multiplexer, keep session") case .detach_other_clients: return String(localized: "Detach Other Clients", comment: "Keybind action") case .select_tab_1: return String(localized: "Select Tab 1", comment: "Keybind action") @@ -461,6 +464,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .next_tab: return .nextTab case .show_tmux_sessions: return .showTmuxSessions case .discover_sessions: return .discoverSessions + case .detach_session: return .detachSession case .detach_other_clients: return .detachOtherClients case .select_tab_1, .select_tab_2, .select_tab_3, .select_tab_4, .select_tab_5, .select_tab_6, .select_tab_7, .select_tab_8, .select_tab_9: @@ -617,7 +621,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { .browse_profiles, .toggle_ai_agent, .toggle_voice_agent, .toggle_tab_bar, .toggle_group_mode, .toggle_transparency, .toggle_titlebar, .toggle_auto_redact, .toggle_background_effect, .toggle_tab_switcher, .toggle_tab_expose, .show_tmux_sessions, - .discover_sessions, .detach_other_clients, + .discover_sessions, .detach_session, .detach_other_clients, .increase_font_size, .decrease_font_size, .reset_font_size, .start_search: return true diff --git a/rootshell/Core/Keybinds/KeybindCommandGenerator.swift b/rootshell/Core/Keybinds/KeybindCommandGenerator.swift index 34b7d5e58..33856bd90 100644 --- a/rootshell/Core/Keybinds/KeybindCommandGenerator.swift +++ b/rootshell/Core/Keybinds/KeybindCommandGenerator.swift @@ -129,7 +129,7 @@ final class KeybindCommandGenerator: ObservableObject { .toggle_split_zoom, .equalize_splits, .open_settings, .toggle_quick_settings, .browse_hosts, .browse_profiles, .toggle_ai_agent, .toggle_voice_agent, .toggle_tab_bar, .toggle_group_mode, .toggle_tab_switcher, .toggle_tab_expose, .previous_group, .next_group, .show_tmux_sessions, .discover_sessions, - .detach_other_clients, + .detach_session, .detach_other_clients, .toggle_transparency, .toggle_titlebar, .toggle_auto_redact, .toggle_background_effect, .toggle_compose, .toggle_full_screen, .toggle_mouse_capture, .cycle_input_source, .increase_font_size, .decrease_font_size, diff --git a/rootshell/Core/Terminal/TerminalSessionController.swift b/rootshell/Core/Terminal/TerminalSessionController.swift index 57a686b58..71c89e653 100644 --- a/rootshell/Core/Terminal/TerminalSessionController.swift +++ b/rootshell/Core/Terminal/TerminalSessionController.swift @@ -1101,7 +1101,7 @@ final class TerminalSessionController { /// For resumable sessions (Trzsz/Mosh, and local sessions with an active /// embedded session) the `reason` drives whether we tell the server to /// close: `.sceneTeardown` keeps the server-side session alive so resume - /// can pick it back up; `.userClose`/`.transferOut` terminate it. + /// can pick it back up; `.userClose`/`.muxDetach`/`.transferOut` terminate it. func teardown(reason: Ghostty.TerminalView.CleanupReason) { localSessionCreateGeneration &+= 1 responsePipeline.cancel() @@ -1109,7 +1109,7 @@ final class TerminalSessionController { if let trzszSession = session as? TrzszSession { switch reason { - case .userClose: + case .userClose, .muxDetach: trzszSession.terminate() case .sceneTeardown: trzszSession.stopForReconnect() @@ -1118,7 +1118,7 @@ final class TerminalSessionController { } } else if let moshSession = session as? MoshSession { switch reason { - case .userClose, .transferOut: + case .userClose, .muxDetach, .transferOut: // Mosh has no peer-attach concept, so a transferOut on a // mosh session would be a logic bug; treat it as a user // close so we don't leave a zombie server session. diff --git a/rootshell/Features/Herdr/HerdrController.swift b/rootshell/Features/Herdr/HerdrController.swift index a7ca38df2..4ecaa01c5 100644 --- a/rootshell/Features/Herdr/HerdrController.swift +++ b/rootshell/Features/Herdr/HerdrController.swift @@ -681,9 +681,19 @@ final class HerdrController { /// User-initiated detach: ends control mode, optionally closing the /// gateway tab too. The herdr session keeps running on the host. - func detach(closeGateway: Bool) { + func detach(closeGateway: Bool, announce: Bool = true) { let gatewayView = gateway let windowId = hostWindowId + // Same choke point as tmux `requestGracefulDetach`: menu, ESC, and + // Detach Session all show the reconnect banner. + if announce, isActive || !didEnd { + MuxSessionDetach.notifyControlModeDetached( + type: .herdr, + sessionName: sessionName, + windowId: windowId, + terminal: gatewayView + ) + } stop() guard closeGateway, let gatewayView else { return } // Same routing a dying tab uses: the .closeSplit observer resolves @@ -704,7 +714,7 @@ final class HerdrController { let gatewayView = gateway gatewayView?.herdrAutoAttachSuppressed = true Self.logger.info("herdr control mode detached after another client took control") - detach(closeGateway: false) + detach(closeGateway: false, announce: false) let message = String(localized: "Detached from herdr because another client took control. The session is still running.") gatewayView?.writeToGhostty(string: "\r\n\(message)\r\n") } diff --git a/rootshell/Features/Multiplexer/MuxDetachBannerState.swift b/rootshell/Features/Multiplexer/MuxDetachBannerState.swift new file mode 100644 index 000000000..4573eeffd --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxDetachBannerState.swift @@ -0,0 +1,14 @@ +// +// MuxDetachBannerState.swift +// rootshell +// +// Transient banner after mux detach (or when focusing an already-live +// attachment) with an optional Reconnect action. +// + +import Foundation + +struct MuxDetachBannerState: Equatable { + let message: String + let offer: MuxSessionResume.ReconnectOffer? +} diff --git a/rootshell/Features/Multiplexer/MuxSessionDetach.swift b/rootshell/Features/Multiplexer/MuxSessionDetach.swift new file mode 100644 index 000000000..ede07ce94 --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxSessionDetach.swift @@ -0,0 +1,386 @@ +// +// MuxSessionDetach.swift +// rootshell +// +// Unified "leave the multiplexer, keep the session" detach for every +// supported multiplexer. tmux -CC uses the existing graceful control-mode +// detach; raw / passthrough attachments type each multiplexer’s native +// detach chord into the pane PTY (same idea as iTerm2’s detach: leave +// cleanly and reattach later via session discovery). +// + +import Foundation +import UIKit +import os + +/// Classifies and performs a session detach for a terminal pane or tmux +/// control-mode gateway. +@MainActor +enum MuxSessionDetach { + /// Posted on `.closeSplit` so MainView tears the pane down with + /// `.muxDetach`. Close Tab also leaves zmx running (native zmx semantics). + static let leaveMuxSessionUserInfoKey = "leaveMuxSession" + + /// How this attachment should leave the multiplexer. + enum Kind: Equatable { + /// Graceful `detach-client` through the tmux -CC viewer. + case tmuxControlMode + /// `HerdrController.detach` — ends control mode, session stays up. + case herdrControlMode + /// Native key chord typed into the pane (raw / passthrough). + case keySequence(MultiplexerType) + } + + struct Attachment: Equatable { + let kind: Kind + let sessionName: String? + let displayName: String + } + + /// Result of attempting a detach. + enum Outcome: Equatable { + case detached(Attachment) + case none + } + + /// Native detach chords. Prefix-based tools need a short settle so the + /// multiplexer sees the follow-up key as a command, not literal input. + private static let prefixSettleDelay: TimeInterval = 0.08 + + /// After the detach chord, wait for the remote client to exit before + /// tearing down the local tab (mirrors MultiplexerExposeFeed’s zmx timing). + private static func postDetachSettleDelay(for type: MultiplexerType) -> TimeInterval { + switch type { + case .zmx: + // zmx’s detach (Ctrl-\) is asynchronous; closing too early HUP’s the + // still-attached client instead of a clean leave. + return 0.45 + case .tmux, .zellij, .herdr: + return 0.2 + } + } + + /// Inspect a terminal for a detachable multiplexer attachment. + static func attachment(on terminal: Ghostty.TerminalView) -> Attachment? { + if terminal.tmuxController?.isActive == true || terminal.isTmuxGatewaySurfaceActive { + let name = terminal.tmuxController?.currentSessionName + return Attachment( + kind: .tmuxControlMode, + sessionName: name, + displayName: displayName(for: .tmux, sessionName: name) + ) + } + if let controller = HerdrController.controller(for: terminal), controller.isActive { + let name = controller.sessionName + return Attachment( + kind: .herdrControlMode, + sessionName: name, + displayName: displayName(for: .herdr, sessionName: name) + ) + } + if let binding = terminal.passthroughMultiplexer { + return Attachment( + kind: .keySequence(binding.type), + sessionName: binding.sessionName, + displayName: displayName(for: binding.type, sessionName: binding.sessionName) + ) + } + if let binding = terminal.rawMultiplexer { + return Attachment( + kind: .keySequence(binding.type), + sessionName: binding.sessionName, + displayName: displayName(for: binding.type, sessionName: binding.sessionName) + ) + } + return nil + } + + /// Resolve a detach target for a tab: prefer the tab’s own panes, then a + /// tmux -CC controller reachable from a window tab (so Detach works even + /// when the gateway tab is auto-hidden). + static func attachment( + for tab: TabModel, + tmuxController: (TabModel) -> TmuxController? + ) -> Attachment? { + for view in tab.splitTree.terminalLeaves { + if let attachment = attachment(on: view), + attachment.kind != .tmuxControlMode, + attachment.kind != .herdrControlMode { + return attachment + } + } + if let controller = HerdrController.controller(forAnyTab: tab), controller.isActive { + let name = controller.sessionName + return Attachment( + kind: .herdrControlMode, + sessionName: name, + displayName: displayName(for: .herdr, sessionName: name) + ) + } + if let controller = tmuxController(tab), controller.isActive { + let name = controller.currentSessionName + return Attachment( + kind: .tmuxControlMode, + sessionName: name, + displayName: displayName(for: .tmux, sessionName: name) + ) + } + for view in tab.splitTree.terminalLeaves { + if let attachment = attachment(on: view) { + return attachment + } + } + return nil + } + + /// Detach the multiplexer on `terminal`. For tmux -CC this routes through + /// the controller; otherwise the native detach chord is typed into the PTY. + @discardableResult + static func detach(on terminal: Ghostty.TerminalView) -> Outcome { + guard let attachment = attachment(on: terminal) else { return .none } + switch attachment.kind { + case .tmuxControlMode: + // Banner is posted inside requestGracefulDetach (via sendTmuxDetach). + terminal.sendTmuxDetach() + return .detached(attachment) + case .herdrControlMode: + // Banner is posted inside HerdrController.detach. + HerdrController.controller(for: terminal)?.detach(closeGateway: false) + return .detached(attachment) + case .keySequence(let type): + performKeySequenceDetach(on: terminal, type: type) + announce(attachment, reconnectFrom: terminal) + return .detached(attachment) + } + } + + /// Detach whatever multiplexer backs `tab` (window tabs resolve to their + /// gateway controller for tmux -CC). + @discardableResult + static func detach( + tab: TabModel, + tmuxController: (TabModel) -> TmuxController? + ) -> Outcome { + // Prefer an in-pane raw/passthrough binding on this tab before falling + // through to the tmux gateway — a split that hosts zmx beside a tmux + // pane should detach the focused attachment, not the whole gateway. + if let focused = tab.focusedTerminal, + let attachment = attachment(on: focused), + attachment.kind != .tmuxControlMode, + attachment.kind != .herdrControlMode { + return detach(on: focused) + } + + if let controller = HerdrController.controller(forAnyTab: tab), controller.isActive { + let name = controller.sessionName + let attachment = Attachment( + kind: .herdrControlMode, + sessionName: name, + displayName: displayName(for: .herdr, sessionName: name) + ) + controller.detach(closeGateway: false) + return .detached(attachment) + } + + if let controller = tmuxController(tab), controller.isActive { + let name = controller.currentSessionName + let attachment = Attachment( + kind: .tmuxControlMode, + sessionName: name, + displayName: displayName(for: .tmux, sessionName: name) + ) + // Banner is posted inside requestGracefulDetach. + controller.requestGracefulDetach(source: "keybind") + return .detached(attachment) + } + + for view in tab.splitTree.terminalLeaves { + if attachment(on: view) != nil { + return detach(on: view) + } + } + return .none + } + + // MARK: - Key sequences + + /// Type the multiplexer’s native detach chord (when needed), drop local + /// bindings, then close the pane/tab — same journey as iTerm2’s Shell → + /// tmux → Detach (remote session keeps running; local UI goes away). + static func performKeySequenceDetach( + on terminal: Ghostty.TerminalView, + type: MultiplexerType + ) { + let steps = keySteps(for: type, on: terminal) + let settle = postDetachSettleDelay(for: type) + Task { @MainActor [weak terminal] in + guard let terminal else { return } + + // zmx: closing the client is the supported detach. Do not type + // Ctrl-\ first — pairing that with an immediate SSH teardown races + // the client exit and can destroy the session instead of leaving + // it for `zmx attach ` on reconnect. + if steps.isEmpty { + clearLocalBinding(on: terminal) + postCloseLeavingMuxSession(terminal) + return + } + + for (index, step) in steps.enumerated() { + terminal.sendUserInput(step) + if index < steps.count - 1 { + try? await Task.sleep(for: .seconds(Self.prefixSettleDelay)) + } + } + try? await Task.sleep(for: .seconds(settle)) + clearLocalBinding(on: terminal) + // Close this attachment’s UI (split or whole tab). closeSplit routes + // by the posted view, so a background-tab detach still targets the + // right pane even if the user switched away during the settle. + postCloseLeavingMuxSession(terminal) + } + } + + /// Close the local pane without destroying a zmx session. + private static func postCloseLeavingMuxSession(_ terminal: Ghostty.TerminalView) { + NotificationCenter.default.post( + name: .closeSplit, + object: terminal, + userInfo: [leaveMuxSessionUserInfoKey: true] + ) + } + + /// Bytes to type, in order. Prefers swipe-discovery bindings, then + /// built-in defaults. Empty means “close the local client only” (zmx). + static func keySteps(for type: MultiplexerType, on terminal: Ghostty.TerminalView? = nil) -> [Data] { + if let discovered = discoveredSteps(for: type, on: terminal) { + return dataSteps(from: discovered) + } + return defaultKeySteps(for: type) + } + + /// Built-in detach chords used when swipe discovery has not learned + /// the host's bindings. + static func defaultKeySteps(for type: MultiplexerType) -> [Data] { + switch type { + case .tmux: + // Default prefix Ctrl-b, then d. + return [Data([0x02]), Data("d".utf8)] + case .zellij: + // Session mode Ctrl-o, then d. + return [Data([0x0F]), Data("d".utf8)] + case .herdr: + // Prefix Ctrl-b, then q. + return [Data([0x02]), Data("q".utf8)] + case .zmx: + // No chord: zmx treats closing the client window as detach + // (https://zmx.sh/). Ctrl-\ remains available for manual use. + return [] + } + } + + private static func discoveredSteps( + for type: MultiplexerType, + on terminal: Ghostty.TerminalView? + ) -> [SequenceStep]? { + guard let bindings = terminal?.discoveredMultiplexerSwipeBindings else { return nil } + switch type { + case .tmux: + return bindings.tmuxDetachClient + case .zellij: + return bindings.zellijDetach + case .herdr, .zmx: + return nil + } + } + + private static func dataSteps(from steps: [SequenceStep]) -> [Data] { + steps.map { $0.terminalData() }.filter { !$0.isEmpty } + } + + private static func clearLocalBinding(on terminal: Ghostty.TerminalView) { + var changed = false + if terminal.rawMultiplexer != nil { + terminal.rawMultiplexer = nil + changed = true + } + if terminal.passthroughMultiplexer != nil { + terminal.passthroughMultiplexer = nil + changed = true + } + if changed { + AgentAttentionCenter.shared.topologyDidChange() + } + } + + private static func displayName(for type: MultiplexerType, sessionName: String?) -> String { + if let sessionName, !sessionName.isEmpty { + return "\(type.rawValue) “\(sessionName)”" + } + return type.rawValue + } + + /// Posted by `TmuxController.requestGracefulDetach` so every tmux -CC leave + /// path (context-menu confirm, dashboard, ESC, keybind, tab-close) shows the + /// same reconnect banner zmx already got via `detach(on:)`. + static func notifyControlModeDetached( + type: MultiplexerType = .tmux, + sessionName: String?, + windowId: String, + terminal: Ghostty.TerminalView? + ) { + let kind: Kind = type == .herdr ? .herdrControlMode : .tmuxControlMode + let attachment = Attachment( + kind: kind, + sessionName: sessionName, + displayName: displayName(for: type, sessionName: sessionName) + ) + announce(attachment, reconnectFrom: terminal, windowId: windowId) + } + + private static func announce( + _ attachment: Attachment, + reconnectFrom terminal: Ghostty.TerminalView? = nil, + windowId: String? = nil + ) { + let message = String( + localized: "Detached from \(attachment.displayName). Session keeps running.", + comment: "Accessibility announcement after detaching a multiplexer" + ) + UIAccessibility.post(notification: .announcement, argument: message) + postReconnectOffer(attachment: attachment, terminal: terminal, windowId: windowId) + } + + private static func postReconnectOffer( + attachment: Attachment, + terminal: Ghostty.TerminalView?, + windowId: String? = nil + ) { + var userInfo: [AnyHashable: Any] = ["displayName": attachment.displayName] + if let windowId = windowId ?? terminal?.windowId { + userInfo["windowId"] = windowId + } + if let terminal, + let ssh = terminal.connectionConfig.sshConfigForHistory + ?? terminal.connectionConfig.underlyingSSHConfig { + let proto: ConnectionProtocol + switch terminal.connectionConfig { + case .mosh, .shellLaunchedMosh: + proto = .mosh + case .trzsz, .shellLaunchedTrzsz: + proto = .trzsz + default: + proto = .ssh + } + userInfo["offer"] = MuxSessionResume.ReconnectOffer( + displayName: attachment.displayName, + sshConfig: ssh, + connectionProtocol: proto, + profileID: terminal.sourceProfileID + ) + } + // object: nil — do not require the pane to still be in the tab tree. + // tmux -CC prune tears windows down as soon as control mode ends. + NotificationCenter.default.post(name: .muxSessionDidDetach, object: nil, userInfo: userInfo) + } +} diff --git a/rootshell/Features/Multiplexer/MuxSessionResume.swift b/rootshell/Features/Multiplexer/MuxSessionResume.swift new file mode 100644 index 000000000..e3b2c6272 --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxSessionResume.swift @@ -0,0 +1,211 @@ +// +// MuxSessionResume.swift +// rootshell +// +// Resume-or-focus: when a mux auto-start profile is opened while a live +// attachment to the same host/session already exists, focus that UI instead +// of spawning a second unrelated-looking control client. +// + +import Foundation +import UIKit + +@MainActor +enum MuxSessionResume { + struct Match: Equatable { + let windowId: String + let tabID: UUID + let displayName: String + } + + /// Payload for the post-detach reconnect banner. + struct ReconnectOffer: Equatable { + let displayName: String + let sshConfig: SSHConfig + let connectionProtocol: ConnectionProtocol + let profileID: UUID? + } + + /// Find a live multiplexer attachment that matches this profile's auto-start + /// target. Returns nil when the profile does not auto-start a mux, or when + /// no matching attachment is open. + static func findLiveAttachment(for config: SSHConfig) -> Match? { + guard let target = autoStartTarget(for: config) else { return nil } + let gatewayKey = TmuxGatewaySessionStore.connectionKey( + host: config.host, + port: config.port, + username: config.username + ) + + for (windowId, model) in TmuxWindowRegistry.allWindows() { + for tab in model.tabs { + if let match = matchTmuxControl( + tab: tab, + model: model, + windowId: windowId, + gatewayKey: gatewayKey, + sessionName: target.sessionName, + wantsControl: target.wantsControlMode + ) { + return match + } + if let match = matchRawOrPassthrough( + tab: tab, + windowId: windowId, + config: config, + type: target.type, + sessionName: target.sessionName + ) { + return match + } + } + } + return nil + } + + /// Focus an existing attachment: activate its window scene if needed, then + /// select the tab. Returns true when focus was requested. + @discardableResult + static func focus( + _ match: Match, + in currentWindowId: String, + selectTab: (UUID) -> Void + ) -> Bool { + if match.windowId != currentWindowId, + let sceneSessionId = TerminalWindowRegistry.sceneSessionId(for: match.windowId), + let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.session.persistentIdentifier == sceneSessionId }) { + UIApplication.shared.requestSceneSessionActivation( + scene.session, + userActivity: nil, + options: nil, + errorHandler: nil + ) + // Selecting across windows: ask the owning TabsModel directly. + if let model = TmuxWindowRegistry.tabsModel(for: match.windowId) { + model.selectedTabID = match.tabID + return true + } + } + selectTab(match.tabID) + return true + } + + // MARK: - Private + + private struct AutoStartTarget { + let type: MultiplexerType + let sessionName: String? + let wantsControlMode: Bool + } + + private static func autoStartTarget(for config: SSHConfig) -> AutoStartTarget? { + if config.tmuxAutoEnable { + return AutoStartTarget( + type: .tmux, + sessionName: config.tmuxSessionNameForConnection, + wantsControlMode: config.tmuxAutoMode == .control + ) + } + if config.herdrAutoEnable { + return AutoStartTarget( + type: .herdr, + sessionName: config.herdrSessionNameForConnection, + wantsControlMode: false + ) + } + if config.zmxAutoEnable { + return AutoStartTarget( + type: .zmx, + sessionName: config.zmxSessionNameForConnection, + wantsControlMode: false + ) + } + return nil + } + + private static func matchTmuxControl( + tab: TabModel, + model: TabsModel, + windowId: String, + gatewayKey: String, + sessionName: String?, + wantsControl: Bool + ) -> Match? { + guard wantsControl else { return nil } + let controller = + TmuxController.controller(forWindowTab: tab) + ?? TmuxController.controller(forGatewayTab: tab) + ?? tab.splitTree.terminalLeaves.first(where: { $0.tmuxController != nil })?.tmuxController + guard let controller, controller.isActive else { return nil } + if let key = controller.connectionKey, key != gatewayKey { return nil } + if let sessionName, + let current = controller.currentSessionName, + current != sessionName { + return nil + } + let ownerID = controller.ownerTerminalUUIDForNotifications + let focusTab = model.tabs.first(where: { + $0.isTmuxWindow + && !$0.isHiddenTmuxWindow + && $0.owningGatewayTerminalUUID == ownerID + }) ?? model.tabs.first(where: { + $0.isTmuxGateway + && $0.splitTree.terminalLeaves.contains(where: { $0.tmuxController === controller }) + }) ?? tab + let name = controller.currentSessionName ?? sessionName ?? "tmux" + return Match( + windowId: windowId, + tabID: focusTab.id, + displayName: "tmux “\(name)”" + ) + } + + private static func matchRawOrPassthrough( + tab: TabModel, + windowId: String, + config: SSHConfig, + type: MultiplexerType, + sessionName: String? + ) -> Match? { + for view in tab.splitTree.terminalLeaves { + guard let ssh = view.connectionConfig.sshConfigForHistory, + ssh.host == config.host, + ssh.port == config.port, + ssh.username == config.username else { continue } + + let bindingType: MultiplexerType? + let bindingSession: String? + if let raw = view.rawMultiplexer { + bindingType = raw.type + bindingSession = raw.sessionName + } else if let pass = view.passthroughMultiplexer { + bindingType = pass.type + bindingSession = pass.sessionName + } else if type == .zmx, ssh.zmxAutoEnable { + // Binding may not be applied yet on a just-opened pane; still + // treat an in-flight zmx auto-start as the live attachment. + bindingType = .zmx + bindingSession = ssh.zmxSessionNameForConnection + } else if type == .herdr, ssh.herdrAutoEnable { + bindingType = .herdr + bindingSession = ssh.herdrSessionNameForConnection + } else if type == .tmux, ssh.tmuxAutoEnable, ssh.tmuxAutoMode == .regular { + bindingType = .tmux + bindingSession = ssh.tmuxSessionNameForConnection + } else { + bindingType = nil + bindingSession = nil + } + guard bindingType == type else { continue } + if let sessionName, let bindingSession, sessionName != bindingSession { + continue + } + let label = bindingSession.map { "\(type.rawValue) “\($0)”" } ?? type.rawValue + return Match(windowId: windowId, tabID: tab.id, displayName: label) + } + return nil + } +} + diff --git a/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift b/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift index 9084bdf6e..4aa5a4826 100644 --- a/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift +++ b/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift @@ -397,6 +397,8 @@ enum SessionDiscoveryParser { swipeBindings.tmuxPreviousSession, swipeBindings.zellijNextTab, swipeBindings.zellijPreviousTab, + swipeBindings.tmuxDetachClient, + swipeBindings.zellijDetach, ].compactMap { $0 }.count logger.info("Session discovery complete: \(count) sessions (\(typeNames)), \(bindingCount) resolved bindings") diff --git a/rootshell/Features/SSH/Session/MultiplexerSwipeBindings.swift b/rootshell/Features/SSH/Session/MultiplexerSwipeBindings.swift index 5fd6acf4d..f1cb63a0f 100644 --- a/rootshell/Features/SSH/Session/MultiplexerSwipeBindings.swift +++ b/rootshell/Features/SSH/Session/MultiplexerSwipeBindings.swift @@ -24,6 +24,8 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { var tmuxPreviousSession: [SequenceStep]? var zellijNextTab: [SequenceStep]? var zellijPreviousTab: [SequenceStep]? + var tmuxDetachClient: [SequenceStep]? + var zellijDetach: [SequenceStep]? init( tmuxNextWindow: [SequenceStep]? = nil, @@ -31,7 +33,9 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { tmuxNextSession: [SequenceStep]? = nil, tmuxPreviousSession: [SequenceStep]? = nil, zellijNextTab: [SequenceStep]? = nil, - zellijPreviousTab: [SequenceStep]? = nil + zellijPreviousTab: [SequenceStep]? = nil, + tmuxDetachClient: [SequenceStep]? = nil, + zellijDetach: [SequenceStep]? = nil ) { self.tmuxNextWindow = tmuxNextWindow self.tmuxPreviousWindow = tmuxPreviousWindow @@ -39,6 +43,8 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { self.tmuxPreviousSession = tmuxPreviousSession self.zellijNextTab = zellijNextTab self.zellijPreviousTab = zellijPreviousTab + self.tmuxDetachClient = tmuxDetachClient + self.zellijDetach = zellijDetach } var hasResolvedBindings: Bool { @@ -48,6 +54,8 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { || tmuxPreviousSession != nil || zellijNextTab != nil || zellijPreviousTab != nil + || tmuxDetachClient != nil + || zellijDetach != nil } func sequence(for preset: SwipeGesturePreset) -> [SequenceStep]? { @@ -76,7 +84,9 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { tmuxNextSession: other.tmuxNextSession ?? tmuxNextSession, tmuxPreviousSession: other.tmuxPreviousSession ?? tmuxPreviousSession, zellijNextTab: other.zellijNextTab ?? zellijNextTab, - zellijPreviousTab: other.zellijPreviousTab ?? zellijPreviousTab + zellijPreviousTab: other.zellijPreviousTab ?? zellijPreviousTab, + tmuxDetachClient: other.tmuxDetachClient ?? tmuxDetachClient, + zellijDetach: other.zellijDetach ?? zellijDetach ) } } @@ -88,6 +98,7 @@ enum TmuxSwipeBindingParser { case previousWindow case nextSession case previousSession + case detachClient } private struct BindingLine { @@ -151,6 +162,9 @@ enum TmuxSwipeBindingParser { default: return false } + case .detachClient: + guard command == "detach-client" || command == "detach" else { return false } + return !tokens.dropFirst().contains("-a") } } } @@ -217,7 +231,8 @@ enum TmuxSwipeBindingParser { tmuxNextWindow: resolve(action: .nextWindow, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos), tmuxPreviousWindow: resolve(action: .previousWindow, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos), tmuxNextSession: resolve(action: .nextSession, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos), - tmuxPreviousSession: resolve(action: .previousSession, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos) + tmuxPreviousSession: resolve(action: .previousSession, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos), + tmuxDetachClient: resolve(action: .detachClient, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos) ) } @@ -334,6 +349,7 @@ enum ZellijSwipeBindingParser { private enum Mode: String, CaseIterable { case normal case tab + case session } private struct Semantic { @@ -341,9 +357,12 @@ enum ZellijSwipeBindingParser { var exitsToNormal = false var goesToNextTab = false var goesToPreviousTab = false + var entersSessionMode = false + var detaches = false var isRelevant: Bool { entersTabMode || exitsToNormal || goesToNextTab || goesToPreviousTab + || entersSessionMode || detaches } } @@ -415,6 +434,8 @@ enum ZellijSwipeBindingParser { bind(keys: ["Ctrl t", "Enter", "Esc"], semantic: Semantic(exitsToNormal: true), to: [.tab]) bind(keys: ["h", "Left", "Up", "k"], semantic: Semantic(goesToPreviousTab: true), to: [.tab]) bind(keys: ["l", "Right", "Down", "j"], semantic: Semantic(goesToNextTab: true), to: [.tab]) + bind(keys: ["Ctrl o"], semantic: Semantic(entersSessionMode: true), to: [.normal]) + bind(keys: ["d"], semantic: Semantic(detaches: true), to: [.session]) } mutating func clear(_ targetModes: Set) { @@ -456,10 +477,12 @@ enum ZellijSwipeBindingParser { let nextTab = resolveTabSequence(in: state, next: true) let previousTab = resolveTabSequence(in: state, next: false) + let detach = resolveDetachSequence(in: state) return MultiplexerSwipeBindings( zellijNextTab: nextTab, - zellijPreviousTab: previousTab + zellijPreviousTab: previousTab, + zellijDetach: detach ) } @@ -488,6 +511,21 @@ enum ZellijSwipeBindingParser { return steps } + private static func resolveDetachSequence(in state: State) -> [SequenceStep]? { + let normalBindings = state.modes[.normal] ?? ModeBindings() + let sessionBindings = state.modes[.session] ?? ModeBindings() + + if let direct = normalBindings.firstMatchingCombo(where: { $0.detaches }) { + return [.keyCombo(direct)] + } + + guard let enterSession = normalBindings.firstMatchingCombo(where: { $0.entersSessionMode }), + let action = sessionBindings.firstMatchingCombo(where: { $0.detaches }) else { + return nil + } + return [.keyCombo(enterSession), .keyCombo(action)] + } + private static func preferredExitCombo(in bindings: ModeBindings) -> SequenceStep.KeyCombo? { let allExits = bindings.matchingCombos(where: { $0.exitsToNormal }) if let escape = allExits.first(where: isPlainEscape) { @@ -564,6 +602,8 @@ enum ZellijSwipeBindingParser { return [.normal] case Mode.tab.rawValue: return [.tab] + case Mode.session.rawValue: + return [.session] case "shared": return Set(Mode.allCases) case "shared_except": @@ -587,7 +627,9 @@ enum ZellijSwipeBindingParser { entersTabMode: compact.contains("switchtomode\"tab\""), exitsToNormal: compact.contains("switchtomode\"normal\""), goesToNextTab: compact.contains("gotonexttab"), - goesToPreviousTab: compact.contains("gotoprevioustab") + goesToPreviousTab: compact.contains("gotoprevioustab"), + entersSessionMode: compact.contains("switchtomode\"session\""), + detaches: compact.contains("detach") ) } diff --git a/rootshell/Features/Tmux/TmuxController.swift b/rootshell/Features/Tmux/TmuxController.swift index 717f36d36..839e2a2fa 100644 --- a/rootshell/Features/Tmux/TmuxController.swift +++ b/rootshell/Features/Tmux/TmuxController.swift @@ -2341,6 +2341,18 @@ final class TmuxController { } let uuidPrefix = ownerTerminalUUID.uuidString.prefix(8) TmuxDebugLogger.shared.event("DETACH", "requested \(source) gw=\(uuidPrefix)") + + // Context-menu / dashboard / ESC / tab-close all enter here without + // going through MuxSessionDetach.detach — post the reconnect banner + // from this choke point so tmux -CC matches zmx. + let bannerTerminal = ownGatewayView() + ?? windowTabs.values.lazy.compactMap { $0.splitTree.terminalLeaves.first }.first + MuxSessionDetach.notifyControlModeDetached( + sessionName: currentSessionName, + windowId: baseWindowId, + terminal: bannerTerminal + ) + // Re-validate at EXECUTION time, not enqueue time. The entry guard above // only proves the surface was live when the detach was requested; the // detach is dispatched across two async hops (ghosttyAPIQueue → main) and diff --git a/rootshell/Features/Tmux/Views/TmuxSessionDashboardView.swift b/rootshell/Features/Tmux/Views/TmuxSessionDashboardView.swift index dc9051761..e529646ac 100644 --- a/rootshell/Features/Tmux/Views/TmuxSessionDashboardView.swift +++ b/rootshell/Features/Tmux/Views/TmuxSessionDashboardView.swift @@ -280,17 +280,17 @@ struct TmuxSessionDashboardView: View { private var detachGatewayDialog: some View { Color.clear.confirmationDialog( - "Detach Gateway?", + "Detach Session?", isPresented: $showingDetachConfirmation, titleVisibility: .visible ) { - Button("Detach Gateway", role: .destructive) { + Button("Detach Session", role: .destructive) { detachGateway() } .keyboardShortcut(.defaultAction) Button("Cancel", role: .cancel) {} } message: { - Text("Leaves tmux control mode for this tab. The tmux session keeps running on the server.") + Text("Leaves tmux control mode for this connection. Window tabs close; sessions keep running on the server.") } } @@ -440,7 +440,7 @@ struct TmuxSessionDashboardView: View { } .buttonStyle(.borderless) .disabled(controller.didEnd) - .accessibilityLabel("Detach Gateway") + .accessibilityLabel("Detach Session") // Evict every OTHER client (e.g. a small-screen device left // attached, clamping the shared window). Shown only while other diff --git a/rootshell/Features/Tmux/Views/TmuxTabMenu.swift b/rootshell/Features/Tmux/Views/TmuxTabMenu.swift index 3ba198f7a..9ec171dfc 100644 --- a/rootshell/Features/Tmux/Views/TmuxTabMenu.swift +++ b/rootshell/Features/Tmux/Views/TmuxTabMenu.swift @@ -187,20 +187,43 @@ struct TmuxTabMenuItems: View { } } -/// Destructive "Detach" item for a tmux gateway tab. Separate from -/// `TmuxTabMenuItems` so each surface can place it in its destructive -/// section (next to Close), keeping menu ordering idiomatic per surface. +/// Destructive "Detach" item for a tmux control-mode attachment. Shown on both +/// gateway and window tabs so Detach stays reachable when the gateway is +/// auto-hidden — the action always leaves the whole control client (all +/// windows), matching iTerm2-style detach. Separate from `TmuxTabMenuItems` +/// so each surface can place it in its destructive section (next to Close). struct TmuxGatewayDetachMenuItem: View { let tab: TabModel let controller: TmuxController? let dialogs: TmuxTabDialogCoordinator var body: some View { - if tab.isTmuxGateway, let controller, controller.isActive { + if (tab.isTmuxGateway || tab.isTmuxWindow), + let controller, controller.isActive { Button(role: .destructive) { dialogs.requestDetach(tab) } label: { - Label("Detach", systemImage: "eject") + Label("Detach Session", systemImage: "eject") + } + } + } +} + +/// Destructive Detach for a raw / passthrough multiplexer tab (zellij, herdr, +/// zmx, plain tmux). Hidden when the tab is already covered by the tmux -CC +/// detach item above. +struct MultiplexerDetachMenuItem: View { + let tab: TabModel + let onDetach: (TabModel) -> Void + + var body: some View { + if !tab.isTmuxGateway, !tab.isTmuxWindow, + HerdrController.controller(forAnyTab: tab)?.isActive != true, + MuxSessionDetach.attachment(for: tab, tmuxController: { _ in nil }) != nil { + Button(role: .destructive) { + onDetach(tab) + } label: { + Label("Detach Session", systemImage: "eject") } } } @@ -290,21 +313,22 @@ private struct TmuxTabDialogsModifier: ViewModifier { private var detachGatewayDialog: some View { Color.clear.confirmationDialog( - "Detach Gateway?", + "Detach Session?", isPresented: Binding( get: { dialogs.detachConfirmGatewayTab != nil }, set: { if !$0 { dialogs.detachConfirmGatewayTab = nil } } ), titleVisibility: .visible ) { - Button("Detach Gateway", role: .destructive) { - guard let tab = dialogs.detachConfirmGatewayTab, - let controller = controller(tab) else { return } - controller.detachGatewayClient() + Button("Detach Session", role: .destructive) { + guard let tab = dialogs.detachConfirmGatewayTab else { return } + // Same funnel as Tabs → Detach Session / zmx: graceful detach + // posts the reconnect banner from TmuxController. + _ = MuxSessionDetach.detach(tab: tab, tmuxController: controller) } Button("Cancel", role: .cancel) {} } message: { - Text("Leaves tmux control mode for this tab. The tmux session keeps running on the server.") + Text("Leaves tmux control mode for this connection. All window tabs for this attachment close; the tmux sessions keep running on the server.") } } } diff --git a/rootshell/UI/Shell/MainView+ConnectionSheet.swift b/rootshell/UI/Shell/MainView+ConnectionSheet.swift index b9faef399..b8b1e87c4 100644 --- a/rootshell/UI/Shell/MainView+ConnectionSheet.swift +++ b/rootshell/UI/Shell/MainView+ConnectionSheet.swift @@ -271,6 +271,13 @@ extension MainView { var config = profile.sshConfig let connectionProtocol = profile.connectionProtocol + // Resume-or-focus: opening a mux auto-start profile while already + // attached to that host/session focuses the live UI instead of a + // second client (especially important for zmx — one PTY per name). + if focusLiveMuxAttachmentIfPresent(for: config) { + return + } + let transportMode = profile.trzszTransportMode let profileMTU = profile.trzszMTU let profilePortMin = profile.trzszPortMin @@ -418,6 +425,12 @@ extension MainView { #endif func connectWithConfig(_ config: SSHConfig, connectionProtocol: ConnectionProtocol = .ssh, splitOption: SSHConnectionView.SplitOption, trzszTransportMode: ProfileTransportMode = .default, trzszMTU: Int? = nil, trzszPortMin: Int? = nil, trzszPortMax: Int? = nil, trzszServerPath: String? = nil, sourceProfileID: UUID? = nil) { + // History / duplicate / deep-link paths also land here — resume before + // spawning another client to the same mux session. + if focusLiveMuxAttachmentIfPresent(for: config) { + return + } + // Safety net: verify key is still resolvable before creating session if case .key(let keyID) = config.authMethod, SSHKeyManager.shared.findKey(id: keyID) == nil { let resolution = ConnectionKeyResolver.resolve(config: config) diff --git a/rootshell/UI/Shell/MainView+Notifications.swift b/rootshell/UI/Shell/MainView+Notifications.swift index 41db7830a..7d78a6065 100644 --- a/rootshell/UI/Shell/MainView+Notifications.swift +++ b/rootshell/UI/Shell/MainView+Notifications.swift @@ -163,7 +163,8 @@ extension MainView { // events close the dying tab, not whichever tab the user has since // switched to. nil object → fall back to the focused split. let target = notification.object as? SplitPaneView - self.closeSplit(targeting: target) + let leaveMux = notification.userInfo?[MuxSessionDetach.leaveMuxSessionUserInfoKey] as? Bool ?? false + self.closeSplit(targeting: target, leaveMuxSession: leaveMux) } observerBag.observeOnMainActor(.vncToggleFullScreen) { [self] notification in @@ -307,11 +308,40 @@ extension MainView { self.discoverSessionsForSelectedTab(origin: notification.object as? Ghostty.TerminalView) } + observerBag.observeOnMainActor(.detachSession) { [self] notification in + guard self.shouldHandleNotification(notification) else { return } + self.detachSessionForSelectedTab() + } + observerBag.observeOnMainActor(.detachOtherClients) { [self] notification in guard self.shouldHandleNotification(notification) else { return } self.detachOtherClientsForSelectedTab() } + observerBag.observeOnMainActor(.muxSessionDidDetach) { [self] notification in + // Prefer windowId: tmux -CC prune can remove the notifying pane + // from the tab tree before (or as) this handler runs. + if let targetWindow = notification.userInfo?["windowId"] as? String { + guard targetWindow == self.windowId else { return } + } else if !self.shouldHandleNotification(notification) { + // No windowId and no pane object: only the focused window + // accepts the banner (Mac Catalyst often has >1 scene). + guard self.isWindowFocused || self.windowIsKeyWindow else { return } + } + let offer = notification.userInfo?["offer"] as? MuxSessionResume.ReconnectOffer + let name = offer?.displayName + ?? (notification.userInfo?["displayName"] as? String) + ?? String(localized: "session", comment: "Generic mux session label in detach banner") + self.muxDetachBanner = MuxDetachBannerState( + message: String( + localized: "Detached from \(name). Session keeps running.", + comment: "Post-detach banner message" + ), + offer: offer + ) + self.scheduleMuxDetachBannerDismiss() + } + observerBag.observeOnMainActor(.increaseFontSize) { [self] notification in guard self.shouldHandleNotification(notification) else { return } guard terminals.indices.contains(selectedTabIndex), diff --git a/rootshell/UI/Shell/MainView+Splits.swift b/rootshell/UI/Shell/MainView+Splits.swift index 797c1396b..c2c2bf0e1 100644 --- a/rootshell/UI/Shell/MainView+Splits.swift +++ b/rootshell/UI/Shell/MainView+Splits.swift @@ -287,8 +287,8 @@ extension MainView { terminals[selectedTabIndex].splitTree = terminals[selectedTabIndex].splitTree.toggleZoom(for: currentNode) } - func closeSplit(targeting targetPane: SplitPaneView? = nil) { - Ghostty.logger.info("closeSplit() called (target=\(targetPane?.uuid.uuidString.prefix(8).description ?? "nil"))") + func closeSplit(targeting targetPane: SplitPaneView? = nil, leaveMuxSession: Bool = false) { + Ghostty.logger.info("closeSplit() called (target=\(targetPane?.uuid.uuidString.prefix(8).description ?? "nil"), leaveMux=\(leaveMuxSession))") // Resolve which tab + pane to close. // When a specific pane is provided (e.g. from `.closeSplit` posted by an @@ -409,9 +409,12 @@ extension MainView { // keep their user-close semantics (plus withdrawing any pending // keyboard-interactive prompt so its auth future doesn't park until // the login timeout); other panes take the generic close funnel. + // Mux detach posts leaveMuxSession so cleanup uses `.muxDetach`. + // Close Tab also leaves a zmx session running (closing the client + // is zmx’s detach path). if let terminalToClose = paneToClose.asTerminal { withdrawKeyboardInteractive(for: terminalToClose) - terminalToClose.cleanup(reason: .userClose) + terminalToClose.cleanup(reason: leaveMuxSession ? .muxDetach : .userClose) } else { paneToClose.prepareForClose() } diff --git a/rootshell/UI/Shell/MainView+TabManagement.swift b/rootshell/UI/Shell/MainView+TabManagement.swift index 1525b2ba3..d17d118d0 100644 --- a/rootshell/UI/Shell/MainView+TabManagement.swift +++ b/rootshell/UI/Shell/MainView+TabManagement.swift @@ -324,10 +324,12 @@ extension MainView { // MARK: - Per-Protocol Creators func createSSHTab(with config: SSHConfig, sourceProfileID: UUID? = nil) { + if focusLiveMuxAttachmentIfPresent(for: config) { return } openTerminalTab(config: .ssh(config), title: config.displayName, sourceProfileID: sourceProfileID) } func createMoshTab(with config: MoshConfig, sourceProfileID: UUID? = nil) { + if focusLiveMuxAttachmentIfPresent(for: config.sshConfig) { return } openTerminalTab(config: .mosh(config), title: config.sshConfig.displayName, sourceProfileID: sourceProfileID) } @@ -342,6 +344,7 @@ extension MainView { } func createTrzszTab(with config: TrzszConfig, sourceProfileID: UUID? = nil) { + if focusLiveMuxAttachmentIfPresent(for: config.sshConfig) { return } openTerminalTab(config: .trzsz(config), title: config.sshConfig.displayName, sourceProfileID: sourceProfileID) } diff --git a/rootshell/UI/Shell/MainView+TabSidebar.swift b/rootshell/UI/Shell/MainView+TabSidebar.swift index a1183b974..4162fa3e6 100644 --- a/rootshell/UI/Shell/MainView+TabSidebar.swift +++ b/rootshell/UI/Shell/MainView+TabSidebar.swift @@ -191,6 +191,66 @@ extension MainView { target.discoverSessionsIfConfigured(manual: true) } + /// Leave the multiplexer on the selected tab. Works for tmux -CC (including + /// window tabs when the gateway is auto-hidden), raw tmux / zellij / herdr, + /// and zmx. Sessions keep running for later reattach. + func detachSessionForSelectedTab() { + guard terminals.indices.contains(selectedTabIndex) else { return } + let tab = terminals[selectedTabIndex] + _ = MuxSessionDetach.detach(tab: tab, tmuxController: tmuxControllerForTab) + } + + func scheduleMuxDetachBannerDismiss() { + muxDetachBannerDismissTask?.cancel() + muxDetachBannerDismissTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(8)) + guard !Task.isCancelled else { return } + dismissMuxDetachBanner() + } + } + + func dismissMuxDetachBanner() { + muxDetachBannerDismissTask?.cancel() + muxDetachBannerDismissTask = nil + muxDetachBanner = nil + } + + /// If a live mux auto-start attachment already matches `config`, focus it + /// and show a short banner. Returns true when a new connection was skipped. + @discardableResult + func focusLiveMuxAttachmentIfPresent(for config: SSHConfig) -> Bool { + guard let match = MuxSessionResume.findLiveAttachment(for: config) else { + return false + } + _ = MuxSessionResume.focus(match, in: windowId) { id in + selectTab(id: id) + } + muxDetachBanner = MuxDetachBannerState( + message: String( + localized: "Already attached to \(match.displayName)", + comment: "Banner when opening a mux profile that is already live" + ), + offer: nil + ) + scheduleMuxDetachBannerDismiss() + return true + } + + func reconnectFromMuxDetachBanner() { + guard let offer = muxDetachBanner?.offer else { return } + dismissMuxDetachBanner() + if let profileID = offer.profileID, + let profile = ConnectionProfileManager.shared.profiles.first(where: { $0.id == profileID }) { + connectToProfile(profile, splitOption: .newTab) + return + } + connectWithConfig( + offer.sshConfig, + connectionProtocol: offer.connectionProtocol, + splitOption: .newTab + ) + } + /// Evict every OTHER tmux client (`detach-client -a`) for the selected /// tab's gateway, keeping this client attached. Works from ANY tmux CC tab: /// `tmuxControllerForTab` resolves a window tab through its pane binding to diff --git a/rootshell/UI/Shell/MainView+TerminalContent.swift b/rootshell/UI/Shell/MainView+TerminalContent.swift index b21ae7b46..6d844803e 100644 --- a/rootshell/UI/Shell/MainView+TerminalContent.swift +++ b/rootshell/UI/Shell/MainView+TerminalContent.swift @@ -314,6 +314,9 @@ extension MainView { // tmux -CC window placeholder restored from disk, awaiting reconcile tmuxReconnectingOverlay + // Detach banner lives on MainView’s content area (not here) so it + // still shows after tmux -CC prune empties every tab. + if showQuickSettingsOverlay { QuickSettingsHUD(isPresented: $showQuickSettingsOverlay) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -396,6 +399,54 @@ extension MainView { .bannerBackground() } + /// Post-detach / already-attached / missing-mux banner. Hosted above both + /// the terminal stack and the empty state — tmux -CC detach prunes every + /// tab immediately, so a terminal-only overlay never paints. + /// + /// Sized to the card only (no full-bleed VStack). A max-size container in + /// `.overlay` steals Catalyst hits from the dismiss control even when the + /// spacer disables hit testing. + @ViewBuilder + var muxDetachBannerOverlay: some View { + if let banner = muxDetachBanner { + HStack(spacing: 10) { + Image(systemName: banner.offer == nil ? "exclamationmark.triangle.fill" : "eject.circle.fill") + .foregroundStyle(.secondary) + Text(banner.message) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(2) + Spacer(minLength: 8) + if banner.offer != nil { + Button("Reconnect") { + reconnectFromMuxDetachBanner() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + Button { + dismissMuxDetachBanner() + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss") + } + .padding(.leading, 12) + .padding(.trailing, 8) + .padding(.vertical, 10) + .frame(maxWidth: 480) + .bannerBackground() + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + .animation(.easeInOut(duration: 0.2), value: muxDetachBanner) + } + } + private var tmuxReconnectStatusRow: some View { HStack(spacing: 8) { ProgressView() diff --git a/rootshell/UI/Shell/MainView.swift b/rootshell/UI/Shell/MainView.swift index eba63a8e9..c6c00d897 100644 --- a/rootshell/UI/Shell/MainView.swift +++ b/rootshell/UI/Shell/MainView.swift @@ -139,6 +139,9 @@ struct MainView: View { /// "Ask Each Time" close of a herdr control-mode tab. @State var pendingHerdrCloseTabID: UUID? @State var pendingNewTabRequest: NewTabRequest? + /// Transient post-detach / already-attached banner. + @State var muxDetachBanner: MuxDetachBannerState? + @State var muxDetachBannerDismissTask: Task? @State var unavailableNewTabRequest: NewTabRequest? @State var authenticationRetryRequest: SSHAuthenticationRetryRequest? @State var reconnectConfig: SSHConfig? @@ -506,23 +509,31 @@ struct MainView: View { } } - // Terminal view - if ghosttyApp.readiness == .ready, !terminals.isEmpty { - terminalAndSidebarContent(geometry: geometry) - } else if ghosttyApp.readiness == .ready, terminals.isEmpty, !windowClosingAfterTabTransfer { - // Empty state - shown when all tabs are closed - EmptyStateResponder( - onNewTab: addNewTab, - onNewLocalShell: handleNewTabCommand - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if ghosttyApp.readiness == .ready, terminals.isEmpty { - Color.clear + // Terminal view (detach banner overlays empty state too — + // tmux -CC prune removes every tab in one go). + Group { + if ghosttyApp.readiness == .ready, !terminals.isEmpty { + terminalAndSidebarContent(geometry: geometry) + } else if ghosttyApp.readiness == .ready, terminals.isEmpty, !windowClosingAfterTabTransfer { + // Empty state - shown when all tabs are closed + EmptyStateResponder( + onNewTab: addNewTab, + onNewLocalShell: handleNewTabCommand + ) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if ghosttyApp.readiness == .loading { - loadingView - } else if ghosttyApp.readiness == .error { - errorView + } else if ghosttyApp.readiness == .ready, terminals.isEmpty { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if ghosttyApp.readiness == .loading { + loadingView + } else if ghosttyApp.readiness == .error { + errorView + } + } + .overlay(alignment: .top) { + if ghosttyApp.readiness == .ready { + muxDetachBannerOverlay + } } } .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) diff --git a/rootshell/UI/Sidebar/VerticalTabSidebar.swift b/rootshell/UI/Sidebar/VerticalTabSidebar.swift index f08869902..b9cbd1b49 100644 --- a/rootshell/UI/Sidebar/VerticalTabSidebar.swift +++ b/rootshell/UI/Sidebar/VerticalTabSidebar.swift @@ -1981,6 +1981,9 @@ struct VerticalTabSidebar: View { groupOverrideMenuItem(for: tab) Divider() HerdrGatewayDetachMenuItem(tab: tab, dialogs: herdrDialogs) + MultiplexerDetachMenuItem(tab: tab) { tab in + _ = MuxSessionDetach.detach(tab: tab, tmuxController: tmuxController) + } Button(role: .destructive) { onCloseTab(tab.id) } label: { @@ -1990,7 +1993,7 @@ struct VerticalTabSidebar: View { /// Context menu for a VISIBLE tmux window row: connection info, the /// shared tmux admin section (rename, move to session, new tab, - /// sessions, hide), close (configurable tmux tab-close action). + /// sessions, hide), detach (whole control client), close. @ViewBuilder private func windowRowMenu(for tab: TabModel) -> some View { connectionAddressCopyItems(for: tab) @@ -2006,6 +2009,11 @@ struct VerticalTabSidebar: View { moveToWindowItems(for: tab) groupOverrideMenuItem(for: tab) Divider() + TmuxGatewayDetachMenuItem( + tab: tab, + controller: tmuxController(tab), + dialogs: tmuxDialogs + ) Button(role: .destructive) { onCloseTab(tab.id) } label: { diff --git a/rootshell/UI/Tabs/TabBar.swift b/rootshell/UI/Tabs/TabBar.swift index dc552523d..c518f19ca 100644 --- a/rootshell/UI/Tabs/TabBar.swift +++ b/rootshell/UI/Tabs/TabBar.swift @@ -584,6 +584,9 @@ struct TabBar: View { dialogs: tmuxDialogs ) HerdrGatewayDetachMenuItem(tab: tab, dialogs: herdrDialogs) + MultiplexerDetachMenuItem(tab: tab) { tab in + _ = MuxSessionDetach.detach(tab: tab, tmuxController: tmuxController) + } Button(role: .destructive) { onCloseTab(index) } label: { diff --git a/rootshell/UI/Terminal/TerminalSplitTreeView.swift b/rootshell/UI/Terminal/TerminalSplitTreeView.swift index 08718844e..a925bd535 100644 --- a/rootshell/UI/Terminal/TerminalSplitTreeView.swift +++ b/rootshell/UI/Terminal/TerminalSplitTreeView.swift @@ -1374,7 +1374,9 @@ extension Notification.Name { static let toggleFullScreen = Notification.Name("com.rootshell.toggleFullScreen") static let showTmuxSessions = Notification.Name("com.rootshell.showTmuxSessions") static let discoverSessions = Notification.Name("com.rootshell.discoverSessions") + static let detachSession = Notification.Name("com.rootshell.detachSession") static let detachOtherClients = Notification.Name("com.rootshell.detachOtherClients") + static let muxSessionDidDetach = Notification.Name("com.rootshell.muxSessionDidDetach") static let showToolbarSettings = Notification.Name("com.rootshell.showToolbarSettings") static let forceASCIIKeyboardChanged = Notification.Name("com.rootshell.forceASCIIKeyboardChanged") static let ghosttySessionDiscoveryChanged = Notification.Name("com.rootshell.sessionDiscoveryChanged") diff --git a/rootshell/UI/Terminal/TerminalView+Keyboard.swift b/rootshell/UI/Terminal/TerminalView+Keyboard.swift index 43a6e1b66..9c874a90b 100644 --- a/rootshell/UI/Terminal/TerminalView+Keyboard.swift +++ b/rootshell/UI/Terminal/TerminalView+Keyboard.swift @@ -2333,6 +2333,10 @@ extension Ghostty.TerminalView { NotificationCenter.default.post(name: .discoverSessions, object: self) } + @objc func menuDetachSession(_ sender: Any?) { + NotificationCenter.default.post(name: .detachSession, object: self) + } + @objc func menuDetachOtherClients(_ sender: Any?) { NotificationCenter.default.post(name: .detachOtherClients, object: self) } diff --git a/rootshell/UI/Terminal/TerminalView.swift b/rootshell/UI/Terminal/TerminalView.swift index 6e0bf3d87..00963d727 100644 --- a/rootshell/UI/Terminal/TerminalView.swift +++ b/rootshell/UI/Terminal/TerminalView.swift @@ -508,6 +508,7 @@ extension Ghostty { /// A transparent multiplexer identity that does not suppress agent /// attention or depend on alternate-screen ownership. var passthroughMultiplexer: RawMultiplexerBinding? + nonisolated(unsafe) var tmuxDetachInProgressAtomic: Bool = false var isTmuxDetachInProgress: Bool { @@ -1572,13 +1573,19 @@ extension Ghostty { /// Why the terminal is being torn down. Drives the resumable-session /// branch in `cleanup` — the wrong choice here either kills a server - /// session the user wants preserved (.userClose during a scene - /// teardown) or strands a server session the user wanted closed - /// (.sceneTeardown for an explicit tab close). + /// session the user wants preserved (`.userClose` / `.muxDetach` during + /// a scene teardown) or strands a server session the user wanted + /// closed (`.sceneTeardown` for an explicit tab close). Detach and + /// Close Tab both leave a zmx session running (closing the client is + /// zmx’s supported detach path). enum CleanupReason { /// User tapped Close Tab / closed the split. Send "close" to - /// tsshd/mosh-server and delete local credentials. + /// tsshd/mosh-server and delete local credentials. A zmx session + /// stays alive on the host. case userClose + /// Detaching from a multiplexer: tear down the local client like + /// `.userClose` and leave a zmx session running. + case muxDetach /// Scene/window is being torn down (rotation, app exit). Keep /// server-side session alive so resume can pick it back up. case sceneTeardown @@ -1629,10 +1636,17 @@ extension Ghostty { TrzszTransferInbox.shared.cancel(ticketID) } - // 1-2. Stop the session and close the PTY. The per-session-type + completeCleanupAfterSessionStop(reason: reason) + } + + /// Remainder of ``cleanup(reason:)`` after session-stop work that + /// used to run asynchronously (zmx kill). Close Tab now leaves zmx + /// running, same as Detach. + private func completeCleanupAfterSessionStop(reason: CleanupReason) { + // 2. Stop the session and close the PTY. The per-session-type // teardown semantics (resumable Trzsz/Mosh keep the server session - // alive for .sceneTeardown; .userClose terminates) live on the - // owning controller now. See TerminalSessionController.teardown. + // alive for .sceneTeardown; .userClose / .muxDetach terminate) + // live on the owning controller now. See TerminalSessionController.teardown. sessionController.teardown(reason: reason) // 3. Cancel async tasks and timers @@ -4565,6 +4579,7 @@ extension Ghostty { #if os(iOS) && !targetEnvironment(macCatalyst) if focused && !iPadVisorController.permitsFocus(self) { return false } #endif + invalidateWritingAssistance(resetDocument: true) // Update mouse capture state when focus changes to ensure scroll handling // has accurate state for this terminal (fixes split view mouse capture scrolling) From 7d8eff0d471b14625715bf87896cfec422152bc0 Mon Sep 17 00:00:00 2001 From: Joshua Van Deren Date: Tue, 15 Sep 2026 16:40:39 -0600 Subject: [PATCH 2/4] Focus live herdr control-mode attachments on resume herdr (control) is a gateway plus projected tabs, not a raw pane binding. Match HerdrController the same way tmux -CC matches TmuxController, and do not treat a leftover gateway shell as still attached. --- .../Multiplexer/MuxSessionResume.swift | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/rootshell/Features/Multiplexer/MuxSessionResume.swift b/rootshell/Features/Multiplexer/MuxSessionResume.swift index e3b2c6272..037dd01f7 100644 --- a/rootshell/Features/Multiplexer/MuxSessionResume.swift +++ b/rootshell/Features/Multiplexer/MuxSessionResume.swift @@ -45,10 +45,26 @@ enum MuxSessionResume { windowId: windowId, gatewayKey: gatewayKey, sessionName: target.sessionName, - wantsControl: target.wantsControlMode + wantsControl: target.wantsControlMode && target.type == .tmux ) { return match } + if let match = matchHerdrControl( + tab: tab, + model: model, + windowId: windowId, + config: config, + sessionName: target.sessionName, + wantsControl: target.wantsControlMode && target.type == .herdr + ) { + return match + } + // herdr (control) is a gateway + projected tabs, not a raw + // binding. Skip the herdrAutoEnable shell fallback so a + // leftover gateway after detach is not treated as still live. + if target.type == .herdr, target.wantsControlMode { + continue + } if let match = matchRawOrPassthrough( tab: tab, windowId: windowId, @@ -112,7 +128,7 @@ enum MuxSessionResume { return AutoStartTarget( type: .herdr, sessionName: config.herdrSessionNameForConnection, - wantsControlMode: false + wantsControlMode: config.herdrControlModeEnabled ) } if config.zmxAutoEnable { @@ -162,6 +178,51 @@ enum MuxSessionResume { ) } + /// Live herdr control-mode family (gateway + projected tabs), analogous + /// to `matchTmuxControl`. The gateway pty is a normal shell, so a raw + /// herdr binding is not present. + private static func matchHerdrControl( + tab: TabModel, + model: TabsModel, + windowId: String, + config: SSHConfig, + sessionName: String?, + wantsControl: Bool + ) -> Match? { + guard wantsControl else { return nil } + guard let controller = HerdrController.controller(forAnyTab: tab), + controller.isActive else { + return nil + } + let ssh = controller.gateway?.connectionConfig.sshConfigForHistory + ?? controller.gateway?.connectionConfig.underlyingSSHConfig + if let ssh { + guard ssh.host == config.host, + ssh.port == config.port, + ssh.username == config.username else { + return nil + } + } + if let sessionName { + let current = controller.sessionName ?? "default" + if current != sessionName { return nil } + } + let focusTab = model.tabs.first(where: { + $0.isHerdrWindow + && !$0.isHiddenTmuxWindow + && $0.owningGatewayTerminalUUID == controller.gatewayUUID + }) ?? model.tabs.first(where: { + $0.isHerdrGateway + && $0.splitTree.terminalLeaves.contains(where: { $0.herdrController === controller }) + }) ?? tab + let name = controller.sessionName ?? sessionName ?? "herdr" + return Match( + windowId: windowId, + tabID: focusTab.id, + displayName: "herdr “\(name)”" + ) + } + private static func matchRawOrPassthrough( tab: TabModel, windowId: String, From 589990f92eec25477771b2d862823bbb154beab2 Mon Sep 17 00:00:00 2001 From: Kit Knox Date: Wed, 23 Sep 2026 17:42:47 -0700 Subject: [PATCH 3/4] Fix multiplexer resume identity and reconnect targeting --- .../Tests/LocalMultiplexerRecoveryTests.swift | 6 +- rootshell.xcodeproj/project.pbxproj | 4 + .../Multiplexer/MuxSessionDetach.swift | 15 +- .../Multiplexer/MuxSessionResume.swift | 139 ++++++------------ .../Multiplexer/MuxSessionTarget.swift | 68 +++++++++ rootshell/Features/SSH/Config/SSHConfig.swift | 26 +++- .../SSH/Session/MultiplexerSession.swift | 28 ---- .../UI/Shell/MainView+ConnectionSheet.swift | 4 +- rootshell/UI/Shell/MainView+TabSidebar.swift | 9 +- .../UI/Terminal/TerminalView+Session.swift | 14 +- rootshellTests/MuxSessionTargetTests.swift | 82 +++++++++++ 11 files changed, 268 insertions(+), 127 deletions(-) create mode 100644 rootshell/Features/Multiplexer/MuxSessionTarget.swift create mode 100644 rootshellTests/MuxSessionTargetTests.swift diff --git a/rootshell-helper/Tests/LocalMultiplexerRecoveryTests.swift b/rootshell-helper/Tests/LocalMultiplexerRecoveryTests.swift index 9fe045f4a..add91e365 100644 --- a/rootshell-helper/Tests/LocalMultiplexerRecoveryTests.swift +++ b/rootshell-helper/Tests/LocalMultiplexerRecoveryTests.swift @@ -183,7 +183,11 @@ final class LocalMultiplexerRecoveryTests: XCTestCase { XCTAssertTrue(LocalMultiplexerRecovery.isAvailable(saved)) let target = LocalHerdrControlTarget(sessionName: "ignored", attachment: saved) let observed = LocalMultiplexerRecovery.inspectHerdrControl(target, records: LocalMultiplexerRecovery.processes(), deadline: Date().addingTimeInterval(3)) - XCTAssertEqual(observed, saved) + // Recovery prefers the installed rootshell client, while retaining the + // verified server/socket/session identity from the original client. + var expected = saved + expected.executable = saved.launchExecutable + XCTAssertEqual(observed, expected) // A raw-capable server accepts two fresh pipe clients with the same // boot identity. EOF closes each bridge; the original server survives. let status = try XCTUnwrap(LocalMultiplexerRecovery.run(executable, ["status", "--json"], diff --git a/rootshell.xcodeproj/project.pbxproj b/rootshell.xcodeproj/project.pbxproj index 5553d9ded..fbabd69e1 100644 --- a/rootshell.xcodeproj/project.pbxproj +++ b/rootshell.xcodeproj/project.pbxproj @@ -280,6 +280,7 @@ F95F66F45A2A58F968B83428 /* VimRuntime.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 47A51A922EEDC704000D76E0 /* VimRuntime.bundle */; platformFilters = (ios, xros, ); }; F9641A2175775B2B2284EFF2 /* AppIconRadicalDracula.icon in Resources */ = {isa = PBXBuildFile; fileRef = 478D16DE2F9FC42B007B574B /* AppIconRadicalDracula.icon */; }; FA804455DA96B007ACED884C /* network_ios in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, xros, ); productRef = 47NWPKG12FF10000AABB0001 /* network_ios */; }; + 47AF04300000000000000A01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04300000000000000B01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -650,6 +651,7 @@ 47WASM00000000000000001 /* Wasm */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Wasm; path = Resources/Wasm; sourceTree = ""; }; 992A6D1173F54B70A4EDBA3E /* joe */ = {isa = PBXFileReference; lastKnownFileType = folder; name = joe; path = Resources/joe; sourceTree = ""; }; DD1249A5776E91521A4BEA8A /* rootshell.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = rootshell.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 47AF04300000000000000B01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = rootshell/Features/Multiplexer/MuxSessionTarget.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -1223,6 +1225,7 @@ 47ABC10D2FF50000AABB000D /* rootshell-helper/Sources/PTYManagerImpl.m */, 47ABC10E2FF50000AABB000E /* rootshell-helper/Sources/ProcessSpawner.m */, 47AEC0020000000000000010 /* rootshell/Core/Shell/LoginShellCommand.swift */, + 47AF04300000000000000B01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift */, 47AEC0010000000000000010 /* rootshell/Core/Persistence/LocalMultiplexerAttachment.swift */, 47AF04710000000000000004 /* rootshell/Core/Keybinds/KeyTrigger.swift */, 47AF04710000000000000006 /* rootshell/Core/Keybinds/HardwareKeyboardText.swift */, @@ -2251,6 +2254,7 @@ 47AF04960000000000000A01 /* rootshell/Core/Terminal/RemoteSessionBackgroundGracePolicy.swift in Sources */, 47AF04850000000000000A01 /* rootshell/Core/Terminal/TerminalScrollbarAvailabilityPolicy.swift in Sources */, 47AEC0020000000000000003 /* rootshell/Core/Shell/LoginShellCommand.swift in Sources */, + 47AF04300000000000000A01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift in Sources */, 47AF0F1D0000000000000B01 /* rootshell/Core/Shell/InitialDirectoryCommand.swift in Sources */, 47F11E0000000000000000B1 /* rootshell/Features/FileManager/Transfers/FileTransferLogic.swift in Sources */, 47F11E0000000000000000B2 /* rootshell/Features/FileManager/Model/FileListSelection.swift in Sources */, diff --git a/rootshell/Features/Multiplexer/MuxSessionDetach.swift b/rootshell/Features/Multiplexer/MuxSessionDetach.swift index ede07ce94..27b2c0428 100644 --- a/rootshell/Features/Multiplexer/MuxSessionDetach.swift +++ b/rootshell/Features/Multiplexer/MuxSessionDetach.swift @@ -35,6 +35,17 @@ enum MuxSessionDetach { let kind: Kind let sessionName: String? let displayName: String + + var resumeTarget: MuxSessionTarget? { + switch kind { + case .tmuxControlMode: + return MuxSessionTarget(type: .tmux, sessionName: sessionName, controlMode: true) + case .herdrControlMode: + return MuxSessionTarget(type: .herdr, sessionName: sessionName ?? "default", controlMode: true) + case .keySequence(let type): + return MuxSessionTarget(type: type, sessionName: sessionName) + } + } } /// Result of attempting a detach. @@ -361,6 +372,7 @@ enum MuxSessionDetach { userInfo["windowId"] = windowId } if let terminal, + let target = attachment.resumeTarget, let ssh = terminal.connectionConfig.sshConfigForHistory ?? terminal.connectionConfig.underlyingSSHConfig { let proto: ConnectionProtocol @@ -376,7 +388,8 @@ enum MuxSessionDetach { displayName: attachment.displayName, sshConfig: ssh, connectionProtocol: proto, - profileID: terminal.sourceProfileID + profileID: terminal.sourceProfileID, + target: target ) } // object: nil — do not require the pane to still be in the tab tree. diff --git a/rootshell/Features/Multiplexer/MuxSessionResume.swift b/rootshell/Features/Multiplexer/MuxSessionResume.swift index 037dd01f7..3417ee8ac 100644 --- a/rootshell/Features/Multiplexer/MuxSessionResume.swift +++ b/rootshell/Features/Multiplexer/MuxSessionResume.swift @@ -24,6 +24,7 @@ enum MuxSessionResume { let sshConfig: SSHConfig let connectionProtocol: ConnectionProtocol let profileID: UUID? + let target: MuxSessionTarget } /// Find a live multiplexer attachment that matches this profile's auto-start @@ -44,8 +45,7 @@ enum MuxSessionResume { model: model, windowId: windowId, gatewayKey: gatewayKey, - sessionName: target.sessionName, - wantsControl: target.wantsControlMode && target.type == .tmux + target: target ) { return match } @@ -53,24 +53,20 @@ enum MuxSessionResume { tab: tab, model: model, windowId: windowId, - config: config, - sessionName: target.sessionName, - wantsControl: target.wantsControlMode && target.type == .herdr + gatewayKey: gatewayKey, + target: target ) { return match } - // herdr (control) is a gateway + projected tabs, not a raw - // binding. Skip the herdrAutoEnable shell fallback so a - // leftover gateway after detach is not treated as still live. - if target.type == .herdr, target.wantsControlMode { + // A raw attachment cannot satisfy a request for native panes. + if target.controlMode { continue } if let match = matchRawOrPassthrough( tab: tab, windowId: windowId, - config: config, - type: target.type, - sessionName: target.sessionName + gatewayKey: gatewayKey, + target: target ) { return match } @@ -110,32 +106,27 @@ enum MuxSessionResume { // MARK: - Private - private struct AutoStartTarget { - let type: MultiplexerType - let sessionName: String? - let wantsControlMode: Bool - } - - private static func autoStartTarget(for config: SSHConfig) -> AutoStartTarget? { + private static func autoStartTarget(for config: SSHConfig) -> MuxSessionTarget? { + if let target = config.muxResumeTarget { return target } if config.tmuxAutoEnable { - return AutoStartTarget( + return MuxSessionTarget( type: .tmux, sessionName: config.tmuxSessionNameForConnection, - wantsControlMode: config.tmuxAutoMode == .control + controlMode: config.tmuxAutoMode == .control ) } if config.herdrAutoEnable { - return AutoStartTarget( + return MuxSessionTarget( type: .herdr, sessionName: config.herdrSessionNameForConnection, - wantsControlMode: config.herdrControlModeEnabled + controlMode: config.herdrControlModeEnabled ) } if config.zmxAutoEnable { - return AutoStartTarget( + return MuxSessionTarget( type: .zmx, sessionName: config.zmxSessionNameForConnection, - wantsControlMode: false + controlMode: false ) } return nil @@ -146,21 +137,17 @@ enum MuxSessionResume { model: TabsModel, windowId: String, gatewayKey: String, - sessionName: String?, - wantsControl: Bool + target: MuxSessionTarget ) -> Match? { - guard wantsControl else { return nil } + guard target.type == .tmux, target.controlMode else { return nil } let controller = TmuxController.controller(forWindowTab: tab) ?? TmuxController.controller(forGatewayTab: tab) ?? tab.splitTree.terminalLeaves.first(where: { $0.tmuxController != nil })?.tmuxController - guard let controller, controller.isActive else { return nil } - if let key = controller.connectionKey, key != gatewayKey { return nil } - if let sessionName, - let current = controller.currentSessionName, - current != sessionName { - return nil - } + guard let controller else { return nil } + let attached = MuxSessionTarget(type: .tmux, sessionName: controller.currentSessionName, controlMode: true) + guard target.matchesLiveAttachment(attached, connectionKey: controller.connectionKey, + requestedConnectionKey: gatewayKey, isActive: controller.isActive) else { return nil } let ownerID = controller.ownerTerminalUUIDForNotifications let focusTab = model.tabs.first(where: { $0.isTmuxWindow @@ -170,7 +157,7 @@ enum MuxSessionResume { $0.isTmuxGateway && $0.splitTree.terminalLeaves.contains(where: { $0.tmuxController === controller }) }) ?? tab - let name = controller.currentSessionName ?? sessionName ?? "tmux" + let name = target.sessionName return Match( windowId: windowId, tabID: focusTab.id, @@ -185,28 +172,19 @@ enum MuxSessionResume { tab: TabModel, model: TabsModel, windowId: String, - config: SSHConfig, - sessionName: String?, - wantsControl: Bool + gatewayKey: String, + target: MuxSessionTarget ) -> Match? { - guard wantsControl else { return nil } - guard let controller = HerdrController.controller(forAnyTab: tab), - controller.isActive else { - return nil - } + guard target.type == .herdr, target.controlMode, + let controller = HerdrController.controller(forAnyTab: tab) else { return nil } let ssh = controller.gateway?.connectionConfig.sshConfigForHistory ?? controller.gateway?.connectionConfig.underlyingSSHConfig - if let ssh { - guard ssh.host == config.host, - ssh.port == config.port, - ssh.username == config.username else { - return nil - } - } - if let sessionName { - let current = controller.sessionName ?? "default" - if current != sessionName { return nil } + let connectionKey = ssh.map { + TmuxGatewaySessionStore.connectionKey(host: $0.host, port: $0.port, username: $0.username) } + let attached = MuxSessionTarget(type: .herdr, sessionName: controller.sessionName ?? "default", controlMode: true) + guard target.matchesLiveAttachment(attached, connectionKey: connectionKey, + requestedConnectionKey: gatewayKey, isActive: controller.isActive) else { return nil } let focusTab = model.tabs.first(where: { $0.isHerdrWindow && !$0.isHiddenTmuxWindow @@ -215,7 +193,7 @@ enum MuxSessionResume { $0.isHerdrGateway && $0.splitTree.terminalLeaves.contains(where: { $0.herdrController === controller }) }) ?? tab - let name = controller.sessionName ?? sessionName ?? "herdr" + let name = target.sessionName return Match( windowId: windowId, tabID: focusTab.id, @@ -226,47 +204,24 @@ enum MuxSessionResume { private static func matchRawOrPassthrough( tab: TabModel, windowId: String, - config: SSHConfig, - type: MultiplexerType, - sessionName: String? + gatewayKey: String, + target: MuxSessionTarget ) -> Match? { for view in tab.splitTree.terminalLeaves { - guard let ssh = view.connectionConfig.sshConfigForHistory, - ssh.host == config.host, - ssh.port == config.port, - ssh.username == config.username else { continue } - - let bindingType: MultiplexerType? - let bindingSession: String? - if let raw = view.rawMultiplexer { - bindingType = raw.type - bindingSession = raw.sessionName - } else if let pass = view.passthroughMultiplexer { - bindingType = pass.type - bindingSession = pass.sessionName - } else if type == .zmx, ssh.zmxAutoEnable { - // Binding may not be applied yet on a just-opened pane; still - // treat an in-flight zmx auto-start as the live attachment. - bindingType = .zmx - bindingSession = ssh.zmxSessionNameForConnection - } else if type == .herdr, ssh.herdrAutoEnable { - bindingType = .herdr - bindingSession = ssh.herdrSessionNameForConnection - } else if type == .tmux, ssh.tmuxAutoEnable, ssh.tmuxAutoMode == .regular { - bindingType = .tmux - bindingSession = ssh.tmuxSessionNameForConnection - } else { - bindingType = nil - bindingSession = nil - } - guard bindingType == type else { continue } - if let sessionName, let bindingSession, sessionName != bindingSession { - continue + // Configuration alone is not proof of an attachment: failed + // connections and shells left after detach retain that config. + guard view.herdrController == nil, view.tmuxController == nil, + let binding = view.rawMultiplexer ?? view.passthroughMultiplexer else { continue } + let connectionKey = view.connectionConfig.sshConfigForHistory.map { + TmuxGatewaySessionStore.connectionKey(host: $0.host, port: $0.port, username: $0.username) } - let label = bindingSession.map { "\(type.rawValue) “\($0)”" } ?? type.rawValue - return Match(windowId: windowId, tabID: tab.id, displayName: label) + let attached = MuxSessionTarget(type: binding.type, sessionName: binding.sessionName) + guard target.matchesLiveAttachment(attached, connectionKey: connectionKey, + requestedConnectionKey: gatewayKey, + isActive: view.session?.isRunning == true) else { continue } + return Match(windowId: windowId, tabID: tab.id, + displayName: "\(target.type.rawValue) “\(target.sessionName)”") } return nil } } - diff --git a/rootshell/Features/Multiplexer/MuxSessionTarget.swift b/rootshell/Features/Multiplexer/MuxSessionTarget.swift new file mode 100644 index 000000000..e44b3e10a --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxSessionTarget.swift @@ -0,0 +1,68 @@ +import Foundation + +// MARK: - Multiplexer Type + +nonisolated enum MultiplexerType: String, Sendable, Equatable, Hashable { + case tmux + case zellij + case herdr + case zmx + + /// Whether the multiplexer, rather than its inner program, owns the screen. + var ownsAlternateScreen: Bool { + switch self { + case .tmux, .zellij, .herdr: return true + case .zmx: return false + } + } + + /// SF Symbol representing this multiplexer. + /// + var iconName: String { + switch self { + case .tmux: return "rectangle.split.2x1" + case .zellij: return "rectangle.split.3x1" + case .herdr: return "square.grid.2x2" + case .zmx: return "rectangle" + } + } +} + +/// The actual attachment to resume, independent of profile and global defaults. +nonisolated struct MuxSessionTarget: Equatable, Hashable, Sendable { + let type: MultiplexerType + let sessionName: String + let controlMode: Bool + + init?(type: MultiplexerType, sessionName: String?, controlMode: Bool = false) { + guard let sessionName, !sessionName.isEmpty else { return nil } + self.type = type + self.sessionName = sessionName + self.controlMode = controlMode && (type == .tmux || type == .herdr) + } + + /// Missing remote identity (including a local gateway) is never a match. + func matchesLiveAttachment(_ attachment: Self?, connectionKey: String?, + requestedConnectionKey: String, isActive: Bool) -> Bool { + isActive && connectionKey == requestedConnectionKey && attachment == self + } + + /// herdr control mode opens its own channel beside the gateway shell. + var execCommand: String? { + let name = LoginShellCommand.doubleQuoted(sessionName) + let command: String + switch type { + case .tmux: + let target = LoginShellCommand.doubleQuoted("=" + sessionName) + command = "exec tmux \(controlMode ? "-CC " : "")attach-session -t \(target)" + case .herdr: + guard !controlMode else { return nil } + command = "exec herdr session attach \(name)" + case .zellij: + command = "exec zellij attach \(name)" + case .zmx: + command = "ZMX_SESSION_PREFIX= exec zmx attach \(name)" + } + return LoginShellCommand.runInPOSIXShell(LoginShellCommand.pathPrefix + command) + } +} diff --git a/rootshell/Features/SSH/Config/SSHConfig.swift b/rootshell/Features/SSH/Config/SSHConfig.swift index 51e0a5398..10f992732 100644 --- a/rootshell/Features/SSH/Config/SSHConfig.swift +++ b/rootshell/Features/SSH/Config/SSHConfig.swift @@ -177,6 +177,9 @@ struct SSHConfig: Codable, Hashable { /// existing profile decodes. var multiplexerSessionName: String? = nil + /// One-shot reconnect target captured at detach, never saved to a profile. + var muxResumeTarget: MuxSessionTarget? = nil + /// Command to run when the session starts. The mode controls whether this is /// sent as terminal input or used as the initial PTY exec command. var launchCommand: String? = nil @@ -976,7 +979,7 @@ struct SSHConfig: Codable, Hashable { /// Whether the channel replaced the interactive shell with a command. var hasExecTakeoverCommand: Bool { !MuxDetachGate.hasFallbackShell( - hasRemoteCommand: !(remoteCommand?.isEmpty ?? true), + hasRemoteCommand: !(remoteCommand?.isEmpty ?? true) || muxResumeTarget?.execCommand != nil, hasInitialCommandLaunch: initialLaunchCommand != nil, tmuxAutoEnable: tmuxAutoEnable, // Control mode keeps the interactive shell; herdr runs out of band. @@ -1006,6 +1009,7 @@ struct SSHConfig: Codable, Hashable { /// multiplexer auto-start; precedence among multiplexers is tmux, then /// herdr, then zmx. private var baseExecCommand: String? { + if let muxResumeTarget { return muxResumeTarget.execCommand } if let remoteCommand, !remoteCommand.isEmpty { return Self.command(remoteCommand, applying: remoteCommandPolicy) } @@ -1030,6 +1034,7 @@ struct SSHConfig: Codable, Hashable { } private var baseMoshSessionCommand: String { + if let muxResumeTarget { return muxResumeTarget.execCommand ?? "$SHELL -l" } if let remoteCommand, !remoteCommand.isEmpty { let script = remoteCommandPolicy == .prependPATH ? Self.remoteExecPathPrefix + remoteCommand : remoteCommand return LoginShellCommand.runInPOSIXShell(script, login: true) @@ -1055,3 +1060,22 @@ struct SSHConfig: Codable, Hashable { return "$SHELL -l" } } + +extension SSHConfig { + /// Keep connection/authentication settings while replacing startup behavior + /// with the attachment the user actually detached from. + func resumingMultiplexer(_ target: MuxSessionTarget) -> SSHConfig { + var config = self + config.muxResumeTarget = target + config.tmuxAutoEnable = target.type == .tmux + config.tmuxAutoMode = target.controlMode ? .control : .regular + config.herdrAutoEnable = target.type == .herdr + config.herdrAutoMode = target.controlMode ? .control : .regular + config.zmxAutoEnable = target.type == .zmx + config.multiplexerSessionName = target.sessionName + config.remoteCommand = nil + config.launchCommand = nil + config.launchCommandMode = .afterConnect + return config + } +} diff --git a/rootshell/Features/SSH/Session/MultiplexerSession.swift b/rootshell/Features/SSH/Session/MultiplexerSession.swift index fb318eb76..42dc3390f 100644 --- a/rootshell/Features/SSH/Session/MultiplexerSession.swift +++ b/rootshell/Features/SSH/Session/MultiplexerSession.swift @@ -7,34 +7,6 @@ import Foundation -// MARK: - Multiplexer Type - -enum MultiplexerType: String, Sendable, Equatable, Hashable { - case tmux - case zellij - case herdr - case zmx - - /// Whether the multiplexer, rather than its inner program, owns the screen. - var ownsAlternateScreen: Bool { - switch self { - case .tmux, .zellij, .herdr: return true - case .zmx: return false - } - } - - /// SF Symbol representing this multiplexer. - /// - var iconName: String { - switch self { - case .tmux: return "rectangle.split.2x1" - case .zellij: return "rectangle.split.3x1" - case .herdr: return "square.grid.2x2" - case .zmx: return "rectangle" - } - } -} - // MARK: - Unified Session Model struct MultiplexerSession: Identifiable, Equatable, Sendable { diff --git a/rootshell/UI/Shell/MainView+ConnectionSheet.swift b/rootshell/UI/Shell/MainView+ConnectionSheet.swift index 6deb7a60b..a4fccbbc4 100644 --- a/rootshell/UI/Shell/MainView+ConnectionSheet.swift +++ b/rootshell/UI/Shell/MainView+ConnectionSheet.swift @@ -582,7 +582,9 @@ extension MainView { } // Update profile auth method to reflect saved password preference - var updatedProfile = profile + // Reconnect can carry a temporary startup override. Save only the auth + // change to the stored profile, not that one-shot attachment target. + var updatedProfile = ConnectionProfileManager.shared.profile(for: profile.id) ?? profile updatedProfile.sshConfig.authMethod = savedSuccessfully ? .savedPassword : .password("") try? ConnectionProfileManager.shared.updateProfile(updatedProfile) diff --git a/rootshell/UI/Shell/MainView+TabSidebar.swift b/rootshell/UI/Shell/MainView+TabSidebar.swift index cfe000b66..60c09234a 100644 --- a/rootshell/UI/Shell/MainView+TabSidebar.swift +++ b/rootshell/UI/Shell/MainView+TabSidebar.swift @@ -239,13 +239,18 @@ extension MainView { func reconnectFromMuxDetachBanner() { guard let offer = muxDetachBanner?.offer else { return } dismissMuxDetachBanner() + let config = offer.sshConfig.resumingMultiplexer(offer.target) if let profileID = offer.profileID, - let profile = ConnectionProfileManager.shared.profiles.first(where: { $0.id == profileID }) { + var profile = ConnectionProfileManager.shared.profiles.first(where: { $0.id == profileID }) { + // A temporary copy keeps the profile's authentication prompts and + // transport options without reopening its original mux/default. + profile.sshConfig = config + profile.connectionProtocol = offer.connectionProtocol connectToProfile(profile, splitOption: .newTab) return } connectWithConfig( - offer.sshConfig, + config, connectionProtocol: offer.connectionProtocol, splitOption: .newTab ) diff --git a/rootshell/UI/Terminal/TerminalView+Session.swift b/rootshell/UI/Terminal/TerminalView+Session.swift index 5a028174c..04dc3648f 100644 --- a/rootshell/UI/Terminal/TerminalView+Session.swift +++ b/rootshell/UI/Terminal/TerminalView+Session.swift @@ -535,6 +535,17 @@ extension Ghostty.TerminalView { func applyConfiguredMultiplexerBinding() { guard let sshConfig = connectionConfig.sshConfigForHistory else { return } + if let target = sshConfig.muxResumeTarget { + if !target.controlMode { + if target.type.ownsAlternateScreen { + bindRawMultiplexer(target.type, sessionName: target.sessionName) + } else { + bindPassthroughMultiplexer(target.type, sessionName: target.sessionName, canDetachSwitch: false) + } + } + return + } + // Keep zmx's transparent identity separate from raw multiplexer // bindings, since raw bindings also suppress agent attention. if sshConfig.zmxAutoEnable, let name = sshConfig.zmxSessionNameForConnection { @@ -826,7 +837,8 @@ extension Ghostty.TerminalView { // // A manual run overrides all of it: the user asked for the picker, so an // already-started multiplexer or launch command must not swallow it. - let multiplexerAutoStart = sshConfig.tmuxAutoEnable || sshConfig.herdrAutoEnable || sshConfig.zmxAutoEnable + let multiplexerAutoStart = sshConfig.muxResumeTarget != nil + || sshConfig.tmuxAutoEnable || sshConfig.herdrAutoEnable || sshConfig.zmxAutoEnable let allowSessionPickerOverlay = manual || (!hasLaunchCommand && !hasRemoteCommand && !wasResumed && !multiplexerAutoStart) let skipTmuxSessions = !tmuxEnabled || !allowSessionPickerOverlay diff --git a/rootshellTests/MuxSessionTargetTests.swift b/rootshellTests/MuxSessionTargetTests.swift new file mode 100644 index 000000000..cf07d8d43 --- /dev/null +++ b/rootshellTests/MuxSessionTargetTests.swift @@ -0,0 +1,82 @@ +import XCTest + +final class MuxSessionTargetTests: XCTestCase { + func testLocalControlSessionCannotInterceptRemoteProfile() throws { + for type in [MultiplexerType.tmux, .herdr] { + let target = try XCTUnwrap(MuxSessionTarget(type: type, sessionName: "default", controlMode: true)) + XCTAssertFalse(target.matchesLiveAttachment(target, connectionKey: nil, + requestedConnectionKey: "user@remote:22", isActive: true)) + XCTAssertFalse(target.matchesLiveAttachment(target, connectionKey: "user@other:22", + requestedConnectionKey: "user@remote:22", isActive: true)) + XCTAssertTrue(target.matchesLiveAttachment(target, connectionKey: "user@remote:22", + requestedConnectionKey: "user@remote:22", isActive: true)) + } + } + + func testFailedConnectionsAndUnboundShellsDoNotBlockReconnect() throws { + for type in [MultiplexerType.tmux, .herdr, .zmx, .zellij] { + let target = try XCTUnwrap(MuxSessionTarget(type: type, sessionName: "work")) + XCTAssertFalse(target.matchesLiveAttachment(target, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: false)) + XCTAssertFalse(target.matchesLiveAttachment(nil, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + XCTAssertTrue(target.matchesLiveAttachment(target, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + } + } + + func testDifferentSessionsAndModesAreNotInterchangeable() throws { + let target = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "work", controlMode: true)) + for other in [ + MuxSessionTarget(type: .tmux, sessionName: "work"), + MuxSessionTarget(type: .tmux, sessionName: "other", controlMode: true), + MuxSessionTarget(type: .herdr, sessionName: "work", controlMode: true) + ] { + XCTAssertFalse(target.matchesLiveAttachment(other, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + } + } + + func testUnknownSessionDoesNotOfferAnUnrelatedReconnect() { + for type in [MultiplexerType.tmux, .herdr, .zellij, .zmx] { + XCTAssertNil(MuxSessionTarget(type: type, sessionName: nil)) + XCTAssertNil(MuxSessionTarget(type: type, sessionName: "")) + } + } + + func testTmuxReconnectPreservesControlModeAndUsesExactSessionTarget() throws { + for control in [false, true] { + let target = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "work", controlMode: control)) + XCTAssertEqual(target.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec tmux \(control ? "-CC " : "")attach-session -t \"=work\"")) + } + } + + func testHerdrControlReconnectLeavesGatewayOnShell() throws { + let target = try XCTUnwrap(MuxSessionTarget(type: .herdr, sessionName: "default", controlMode: true)) + XCTAssertEqual(target.sessionName, "default") + XCTAssertTrue(target.controlMode) + XCTAssertNil(target.execCommand) + let raw = try XCTUnwrap(MuxSessionTarget(type: .herdr, sessionName: "project")) + XCTAssertEqual(raw.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec herdr session attach \"project\"")) + } + + func testZellijAndZmxReconnectToDiscoveredName() throws { + let zellij = try XCTUnwrap(MuxSessionTarget(type: .zellij, sessionName: "project", controlMode: true)) + XCTAssertFalse(zellij.controlMode) + XCTAssertEqual(zellij.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec zellij attach \"project\"")) + let zmx = try XCTUnwrap(MuxSessionTarget(type: .zmx, sessionName: "prefix-project")) + XCTAssertEqual(zmx.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "ZMX_SESSION_PREFIX= exec zmx attach \"prefix-project\"")) + } + + func testSessionNamesAreQuotedAcrossBothShellLayers() throws { + let name = "work's \"tab\"; $HOME `id` \\ end" + let target = try XCTUnwrap(MuxSessionTarget(type: .zellij, sessionName: name)) + let quoted = "\"work's \\\"tab\\\"; \\$HOME \\`id\\` \\\\ end\"" + XCTAssertEqual(target.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec zellij attach " + quoted)) + } +} From b33ff817854a0b47b8755ea7ac715f87afa4d56b Mon Sep 17 00:00:00 2001 From: Kit Knox Date: Wed, 23 Sep 2026 18:44:24 -0700 Subject: [PATCH 4/4] Preserve multiplexer targets and tmux socket identity on reconnect Retain original tmux socket selectors alongside resolved paths through reconnect and tab restoration. Treat unquoted tilde expansion as unknown. Persist multiplexer resume targets in tab state without saving them to profiles, preserving Zellij restoration. --- .../SerializableConnectionConfig.swift | 4 + .../Multiplexer/MuxSessionDetach.swift | 36 ++++-- .../Multiplexer/MuxSessionResume.swift | 10 +- .../Multiplexer/MuxSessionTarget.swift | 112 +++++++++++++++++- rootshell/Features/SSH/Config/SSHConfig.swift | 19 ++- rootshell/Features/Tmux/TmuxController.swift | 11 +- .../UI/Terminal/TerminalView+Session.swift | 14 ++- rootshell/UI/Terminal/TerminalView.swift | 2 + rootshellTests/MuxSessionTargetTests.swift | 98 +++++++++++++++ 9 files changed, 282 insertions(+), 24 deletions(-) diff --git a/rootshell/Core/Persistence/SerializableConnectionConfig.swift b/rootshell/Core/Persistence/SerializableConnectionConfig.swift index 38f87e39a..3e29a9021 100644 --- a/rootshell/Core/Persistence/SerializableConnectionConfig.swift +++ b/rootshell/Core/Persistence/SerializableConnectionConfig.swift @@ -172,6 +172,8 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable { /// Per-profile multiplexer session name. Optional for backward compat — /// older serialized sessions decode as nil and use the global default. let multiplexerSessionName: String? + /// Captured attachment target belongs to tab restoration, not profiles. + let muxResumeTarget: MuxSessionTarget? /// Folder the pane was opened in (Open in Folder). Optional for /// backward compat; nil restores in the login directory. let initialDirectory: String? @@ -211,6 +213,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable { self.launchCommandMode = config.launchCommandMode self.terminalType = config.terminalType self.multiplexerSessionName = config.multiplexerSessionName + self.muxResumeTarget = config.muxResumeTarget self.initialDirectory = config.initialDirectory // Convert auth method, stripping passwords @@ -296,6 +299,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable { config.launchCommandMode = launchCommandMode ?? .afterConnect config.terminalType = terminalType config.multiplexerSessionName = multiplexerSessionName + config.muxResumeTarget = muxResumeTarget config.initialDirectory = initialDirectory if let jump = jumpHost { diff --git a/rootshell/Features/Multiplexer/MuxSessionDetach.swift b/rootshell/Features/Multiplexer/MuxSessionDetach.swift index 27b2c0428..d68f0903d 100644 --- a/rootshell/Features/Multiplexer/MuxSessionDetach.swift +++ b/rootshell/Features/Multiplexer/MuxSessionDetach.swift @@ -35,15 +35,19 @@ enum MuxSessionDetach { let kind: Kind let sessionName: String? let displayName: String + var tmuxSocket: TmuxSocketIdentity? = .defaultServer + var tmuxSocketSelector: TmuxSocketIdentity? var resumeTarget: MuxSessionTarget? { switch kind { case .tmuxControlMode: - return MuxSessionTarget(type: .tmux, sessionName: sessionName, controlMode: true) + return MuxSessionTarget(type: .tmux, sessionName: sessionName, controlMode: true, tmuxSocket: tmuxSocket, + tmuxSocketSelector: tmuxSocketSelector) case .herdrControlMode: return MuxSessionTarget(type: .herdr, sessionName: sessionName ?? "default", controlMode: true) case .keySequence(let type): - return MuxSessionTarget(type: type, sessionName: sessionName) + return MuxSessionTarget(type: type, sessionName: sessionName, tmuxSocket: tmuxSocket, + tmuxSocketSelector: tmuxSocketSelector) } } } @@ -78,7 +82,9 @@ enum MuxSessionDetach { return Attachment( kind: .tmuxControlMode, sessionName: name, - displayName: displayName(for: .tmux, sessionName: name) + displayName: displayName(for: .tmux, sessionName: name), + tmuxSocket: terminal.tmuxController?.resumeSocket, + tmuxSocketSelector: terminal.tmuxController?.configuredResumeSocket ) } if let controller = HerdrController.controller(for: terminal), controller.isActive { @@ -93,14 +99,18 @@ enum MuxSessionDetach { return Attachment( kind: .keySequence(binding.type), sessionName: binding.sessionName, - displayName: displayName(for: binding.type, sessionName: binding.sessionName) + displayName: displayName(for: binding.type, sessionName: binding.sessionName), + tmuxSocket: binding.tmuxSocket, + tmuxSocketSelector: binding.tmuxSocketSelector ) } if let binding = terminal.rawMultiplexer { return Attachment( kind: .keySequence(binding.type), sessionName: binding.sessionName, - displayName: displayName(for: binding.type, sessionName: binding.sessionName) + displayName: displayName(for: binding.type, sessionName: binding.sessionName), + tmuxSocket: binding.tmuxSocket, + tmuxSocketSelector: binding.tmuxSocketSelector ) } return nil @@ -133,7 +143,9 @@ enum MuxSessionDetach { return Attachment( kind: .tmuxControlMode, sessionName: name, - displayName: displayName(for: .tmux, sessionName: name) + displayName: displayName(for: .tmux, sessionName: name), + tmuxSocket: controller.resumeSocket, + tmuxSocketSelector: controller.configuredResumeSocket ) } for view in tab.splitTree.terminalLeaves { @@ -198,7 +210,9 @@ enum MuxSessionDetach { let attachment = Attachment( kind: .tmuxControlMode, sessionName: name, - displayName: displayName(for: .tmux, sessionName: name) + displayName: displayName(for: .tmux, sessionName: name), + tmuxSocket: controller.resumeSocket, + tmuxSocketSelector: controller.configuredResumeSocket ) // Banner is posted inside requestGracefulDetach. controller.requestGracefulDetach(source: "keybind") @@ -338,13 +352,17 @@ enum MuxSessionDetach { type: MultiplexerType = .tmux, sessionName: String?, windowId: String, - terminal: Ghostty.TerminalView? + terminal: Ghostty.TerminalView?, + tmuxSocket: TmuxSocketIdentity? = nil, + tmuxSocketSelector: TmuxSocketIdentity? = nil ) { let kind: Kind = type == .herdr ? .herdrControlMode : .tmuxControlMode let attachment = Attachment( kind: kind, sessionName: sessionName, - displayName: displayName(for: type, sessionName: sessionName) + displayName: displayName(for: type, sessionName: sessionName), + tmuxSocket: tmuxSocket, + tmuxSocketSelector: tmuxSocketSelector ) announce(attachment, reconnectFrom: terminal, windowId: windowId) } diff --git a/rootshell/Features/Multiplexer/MuxSessionResume.swift b/rootshell/Features/Multiplexer/MuxSessionResume.swift index 3417ee8ac..746ab9ba1 100644 --- a/rootshell/Features/Multiplexer/MuxSessionResume.swift +++ b/rootshell/Features/Multiplexer/MuxSessionResume.swift @@ -112,7 +112,8 @@ enum MuxSessionResume { return MuxSessionTarget( type: .tmux, sessionName: config.tmuxSessionNameForConnection, - controlMode: config.tmuxAutoMode == .control + controlMode: config.tmuxAutoMode == .control, + tmuxSocket: config.tmuxSocketForResume ) } if config.herdrAutoEnable { @@ -145,7 +146,9 @@ enum MuxSessionResume { ?? TmuxController.controller(forGatewayTab: tab) ?? tab.splitTree.terminalLeaves.first(where: { $0.tmuxController != nil })?.tmuxController guard let controller else { return nil } - let attached = MuxSessionTarget(type: .tmux, sessionName: controller.currentSessionName, controlMode: true) + let attached = MuxSessionTarget(type: .tmux, sessionName: controller.currentSessionName, + controlMode: true, tmuxSocket: controller.resumeSocket, + tmuxSocketSelector: controller.configuredResumeSocket) guard target.matchesLiveAttachment(attached, connectionKey: controller.connectionKey, requestedConnectionKey: gatewayKey, isActive: controller.isActive) else { return nil } let ownerID = controller.ownerTerminalUUIDForNotifications @@ -215,7 +218,8 @@ enum MuxSessionResume { let connectionKey = view.connectionConfig.sshConfigForHistory.map { TmuxGatewaySessionStore.connectionKey(host: $0.host, port: $0.port, username: $0.username) } - let attached = MuxSessionTarget(type: binding.type, sessionName: binding.sessionName) + let attached = MuxSessionTarget(type: binding.type, sessionName: binding.sessionName, tmuxSocket: binding.tmuxSocket, + tmuxSocketSelector: binding.tmuxSocketSelector) guard target.matchesLiveAttachment(attached, connectionKey: connectionKey, requestedConnectionKey: gatewayKey, isActive: view.session?.isRunning == true) else { continue } diff --git a/rootshell/Features/Multiplexer/MuxSessionTarget.swift b/rootshell/Features/Multiplexer/MuxSessionTarget.swift index e44b3e10a..52b14a29c 100644 --- a/rootshell/Features/Multiplexer/MuxSessionTarget.swift +++ b/rootshell/Features/Multiplexer/MuxSessionTarget.swift @@ -2,7 +2,7 @@ import Foundation // MARK: - Multiplexer Type -nonisolated enum MultiplexerType: String, Sendable, Equatable, Hashable { +nonisolated enum MultiplexerType: String, Codable, Sendable, Equatable, Hashable { case tmux case zellij case herdr @@ -29,22 +29,38 @@ nonisolated enum MultiplexerType: String, Sendable, Equatable, Hashable { } /// The actual attachment to resume, independent of profile and global defaults. -nonisolated struct MuxSessionTarget: Equatable, Hashable, Sendable { +nonisolated struct MuxSessionTarget: Codable, Equatable, Hashable, Sendable { let type: MultiplexerType let sessionName: String let controlMode: Bool + let tmuxSocket: TmuxSocketIdentity? + /// Original profile selector, retained when tmux reports its resolved path. + let tmuxSocketSelector: TmuxSocketIdentity? - init?(type: MultiplexerType, sessionName: String?, controlMode: Bool = false) { + var configuredTmuxSocket: TmuxSocketIdentity? { tmuxSocketSelector ?? tmuxSocket } + + init?(type: MultiplexerType, sessionName: String?, controlMode: Bool = false, + tmuxSocket: TmuxSocketIdentity? = .defaultServer, + tmuxSocketSelector: TmuxSocketIdentity? = nil) { guard let sessionName, !sessionName.isEmpty else { return nil } + guard type != .tmux || tmuxSocket != nil else { return nil } self.type = type self.sessionName = sessionName self.controlMode = controlMode && (type == .tmux || type == .herdr) + self.tmuxSocket = type == .tmux ? tmuxSocket : nil + self.tmuxSocketSelector = type == .tmux ? tmuxSocketSelector : nil } /// Missing remote identity (including a local gateway) is never a match. func matchesLiveAttachment(_ attachment: Self?, connectionKey: String?, requestedConnectionKey: String, isActive: Bool) -> Bool { - isActive && connectionKey == requestedConnectionKey && attachment == self + guard isActive, connectionKey == requestedConnectionKey, let attachment, + type == attachment.type, sessionName == attachment.sessionName, + controlMode == attachment.controlMode else { return false } + // The control client reports a resolved -S path. Its original -L name + // (or default-server selection) is also a valid way to address it. + return tmuxSocket == attachment.tmuxSocket + || (type == .tmux && tmuxSocket != nil && tmuxSocket == attachment.configuredTmuxSocket) } /// herdr control mode opens its own channel beside the gateway shell. @@ -54,7 +70,7 @@ nonisolated struct MuxSessionTarget: Equatable, Hashable, Sendable { switch type { case .tmux: let target = LoginShellCommand.doubleQuoted("=" + sessionName) - command = "exec tmux \(controlMode ? "-CC " : "")attach-session -t \(target)" + command = "exec tmux \(tmuxSocket?.arguments ?? "")\(controlMode ? "-CC " : "")attach-session -t \(target)" case .herdr: guard !controlMode else { return nil } command = "exec herdr session attach \(name)" @@ -66,3 +82,89 @@ nonisolated struct MuxSessionTarget: Equatable, Hashable, Sendable { return LoginShellCommand.runInPOSIXShell(LoginShellCommand.pathPrefix + command) } } + +nonisolated enum TmuxSocketIdentity: Codable, Equatable, Hashable, Sendable { + case defaultServer + case name(String) + case path(String) + + var arguments: String { + switch self { + case .defaultServer: return "" + case .name(let name): return "-L \(LoginShellCommand.doubleQuoted(name)) " + case .path(let path): return "-S \(LoginShellCommand.doubleQuoted(path)) " + } + } + + /// The in-band identity is host:socket,pid,start_time. Split the numeric + /// suffix from the right so commas and colons in socket paths survive. + static func fromServerIdentity(_ identity: String) -> Self? { + guard let started = identity.lastIndex(of: ","), + let pid = identity[.. Self? { + guard var words = literalWords(command) else { return nil } + if words.first == "exec" { words.removeFirst() } + guard let program = words.first.map({ ($0 as NSString).lastPathComponent }) else { return nil } + if ["sh", "bash", "zsh"].contains(program), words.count == 3, + ["-c", "-lc"].contains(words[1]) { + return fromStartupCommand(words[2]) + } + guard program == "tmux" else { return nil } + var socket: Self = .defaultServer + var explicitPath: Self? + var index = 1 + while index < words.count, words[index].hasPrefix("-") { + let flag = words[index] + if flag == "-L" || flag == "-S" || flag == "-f" { + index += 1 + guard index < words.count, !words[index].isEmpty else { return nil } + if flag == "-L" { socket = .name(words[index]) } + if flag == "-S" { explicitPath = .path(words[index]) } + } else if flag.hasPrefix("-L"), flag.count > 2 { + socket = .name(String(flag.dropFirst(2))) + } else if flag.hasPrefix("-S"), flag.count > 2 { + explicitPath = .path(String(flag.dropFirst(2))) + } + index += 1 + } + return explicitPath ?? socket + } + + private static func literalWords(_ command: String) -> [String]? { + var words: [String] = [] + var word = "" + var quote: Character? + var escaped = false + for character in command { + if escaped { + if quote == "\"", !"$`\"\\\n".contains(character) { word.append("\\") } + if character != "\n" { word.append(character) } + escaped = false + } else if character == "\\", quote != "'" { + escaped = true + } else if character == quote { + quote = nil + } else if quote == nil, character == "'" || character == "\"" { + quote = character + } else if character.isWhitespace, quote == nil { + if !word.isEmpty { words.append(word); word = "" } + } else { + // Never treat an unevaluated expansion or shell operator as a + // literal socket selector; the live control query resolves it. + if quote != "'", "$`".contains(character) { return nil } + if quote == nil, ";|&()<>{}*?[]".contains(character) { return nil } + if quote == nil, word.isEmpty, character == "~" { return nil } + word.append(character) + } + } + guard quote == nil, !escaped else { return nil } + if !word.isEmpty { words.append(word) } + return words + } +} diff --git a/rootshell/Features/SSH/Config/SSHConfig.swift b/rootshell/Features/SSH/Config/SSHConfig.swift index 10f992732..503ebdc1b 100644 --- a/rootshell/Features/SSH/Config/SSHConfig.swift +++ b/rootshell/Features/SSH/Config/SSHConfig.swift @@ -177,7 +177,7 @@ struct SSHConfig: Codable, Hashable { /// existing profile decodes. var multiplexerSessionName: String? = nil - /// One-shot reconnect target captured at detach, never saved to a profile. + /// Captured at detach and saved with tab state, never with a profile. var muxResumeTarget: MuxSessionTarget? = nil /// Command to run when the session starts. The mode controls whether this is @@ -1062,6 +1062,23 @@ struct SSHConfig: Codable, Hashable { } extension SSHConfig { + var tmuxSocketForResume: TmuxSocketIdentity? { + if let target = muxResumeTarget { return target.tmuxSocket } + if let remoteCommand, !remoteCommand.isEmpty { + return TmuxSocketIdentity.fromStartupCommand(remoteCommand) + } + if let initialLaunchCommand { + return TmuxSocketIdentity.fromStartupCommand(initialLaunchCommand) + } + if tmuxAutoEnable { + if let custom = Self.tmuxGlobalCustomCommand { + return TmuxSocketIdentity.fromStartupCommand(custom) + } + return .defaultServer + } + return launchCommand.flatMap(TmuxSocketIdentity.fromStartupCommand) + } + /// Keep connection/authentication settings while replacing startup behavior /// with the attachment the user actually detached from. func resumingMultiplexer(_ target: MuxSessionTarget) -> SSHConfig { diff --git a/rootshell/Features/Tmux/TmuxController.swift b/rootshell/Features/Tmux/TmuxController.swift index 930cb20a2..d6f78cb2b 100644 --- a/rootshell/Features/Tmux/TmuxController.swift +++ b/rootshell/Features/Tmux/TmuxController.swift @@ -275,6 +275,11 @@ final class TmuxController { private(set) var gatewaySourceSystemImage = "terminal" /// Identifies this tmux server lifetime; combined with pane IDs for push routing. var pushRouteServerIdentity: String? + var configuredResumeSocket: TmuxSocketIdentity? + var startupResumeSocket: TmuxSocketIdentity? + var resumeSocket: TmuxSocketIdentity? { + pushRouteServerIdentity.flatMap(TmuxSocketIdentity.fromServerIdentity) ?? startupResumeSocket + } var pushRouteServerIdentityTask: Task? /// Tag 0 is never used. var nextReplyTag: UInt32 = 1 @@ -1823,7 +1828,9 @@ final class TmuxController { MuxSessionDetach.notifyControlModeDetached( sessionName: currentSessionName, windowId: baseWindowId, - terminal: bannerTerminal + terminal: bannerTerminal, + tmuxSocket: resumeSocket, + tmuxSocketSelector: configuredResumeSocket ) // Recheck after the async hops; the surface can be freed in between. @@ -2828,6 +2835,8 @@ extension Ghostty.TerminalView { // Flush session info that arrived before the controller existed. // ROOTSHELL-TMUX (id=tmux-session-info-stash) if let ssh = connectionConfig.underlyingSSHConfig { + controller.startupResumeSocket = ssh.tmuxSocketForResume + controller.configuredResumeSocket = ssh.muxResumeTarget?.configuredTmuxSocket ?? ssh.tmuxSocketForResume controller.connectionKey = TmuxGatewaySessionStore.connectionKey( host: ssh.host, port: ssh.port, username: ssh.username) } diff --git a/rootshell/UI/Terminal/TerminalView+Session.swift b/rootshell/UI/Terminal/TerminalView+Session.swift index 04dc3648f..84646728e 100644 --- a/rootshell/UI/Terminal/TerminalView+Session.swift +++ b/rootshell/UI/Terminal/TerminalView+Session.swift @@ -538,7 +538,8 @@ extension Ghostty.TerminalView { if let target = sshConfig.muxResumeTarget { if !target.controlMode { if target.type.ownsAlternateScreen { - bindRawMultiplexer(target.type, sessionName: target.sessionName) + bindRawMultiplexer(target.type, sessionName: target.sessionName, tmuxSocket: target.tmuxSocket, + tmuxSocketSelector: target.configuredTmuxSocket) } else { bindPassthroughMultiplexer(target.type, sessionName: target.sessionName, canDetachSwitch: false) } @@ -557,7 +558,8 @@ extension Ghostty.TerminalView { // Auto-connect. Control mode gets its own surface per pane, so only // the plain mode collapses a whole session onto this one. if sshConfig.tmuxAutoEnable, sshConfig.tmuxAutoMode == .regular { - bindRawMultiplexer(.tmux, sessionName: sshConfig.tmuxSessionNameForConnection) + bindRawMultiplexer(.tmux, sessionName: sshConfig.tmuxSessionNameForConnection, + tmuxSocket: sshConfig.tmuxSocketForResume) return } @@ -571,7 +573,7 @@ extension Ghostty.TerminalView { .compactMap { $0 } for command in configured { if let type = Self.rawMultiplexerType(launching: command) { - bindRawMultiplexer(type, sessionName: nil) + bindRawMultiplexer(type, sessionName: nil, tmuxSocket: TmuxSocketIdentity.fromStartupCommand(command)) return } } @@ -582,10 +584,12 @@ extension Ghostty.TerminalView { /// /// Binds only multiplexers that own the alternate screen; raw bindings /// suppress agent attention until ownership is released. - func bindRawMultiplexer(_ type: MultiplexerType, sessionName: String?) { + func bindRawMultiplexer(_ type: MultiplexerType, sessionName: String?, tmuxSocket: TmuxSocketIdentity? = .defaultServer, + tmuxSocketSelector: TmuxSocketIdentity? = nil) { guard type.ownsAlternateScreen else { return } guard rawMultiplexer == nil else { return } - rawMultiplexer = .init(type: type, sessionName: sessionName) + rawMultiplexer = .init(type: type, sessionName: sessionName, tmuxSocket: tmuxSocket, + tmuxSocketSelector: tmuxSocketSelector) AgentAttentionCenter.shared.topologyDidChange() } diff --git a/rootshell/UI/Terminal/TerminalView.swift b/rootshell/UI/Terminal/TerminalView.swift index 0b5d08dd7..0a2736d4f 100644 --- a/rootshell/UI/Terminal/TerminalView.swift +++ b/rootshell/UI/Terminal/TerminalView.swift @@ -480,6 +480,8 @@ extension Ghostty { /// Known when the app issued the attach; nil when the binding came /// from a corroborating screen signal. let sessionName: String? + var tmuxSocket: TmuxSocketIdentity? = .defaultServer + var tmuxSocketSelector: TmuxSocketIdentity? /// True once the multiplexer has actually taken the alternate /// screen. The binding is recorded at session-ready, before the /// remote command has run, so the surface is legitimately on the diff --git a/rootshellTests/MuxSessionTargetTests.swift b/rootshellTests/MuxSessionTargetTests.swift index cf07d8d43..e358804ec 100644 --- a/rootshellTests/MuxSessionTargetTests.swift +++ b/rootshellTests/MuxSessionTargetTests.swift @@ -1,6 +1,104 @@ import XCTest final class MuxSessionTargetTests: XCTestCase { + func testCustomTmuxServersSurviveReconnect() throws { + for (command, socket) in [ + ("tmux -L work -CC new-session -A -s main", TmuxSocketIdentity.name("work")), + ("exec /usr/bin/tmux -S '/tmp/work socket' attach -t main", .path("/tmp/work socket")), + ("sh -c 'exec tmux -Lwork -CC attach -t main'", .name("work")), + ("tmux -S/tmp/socket -Lignored attach -t main", .path("/tmp/socket")), + ("tmux -CC new-session -A -s main", .defaultServer) + ] { + XCTAssertEqual(TmuxSocketIdentity.fromStartupCommand(command), socket) + let target = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", controlMode: true, + tmuxSocket: socket)) + XCTAssertEqual(target.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec tmux \(socket.arguments)-CC attach-session -t \"=main\"")) + } + XCTAssertNil(TmuxSocketIdentity.fromStartupCommand("tmux -S \"$SOCKET\" attach")) + XCTAssertNil(TmuxSocketIdentity.fromStartupCommand("tmux -S ~/socket attach")) + XCTAssertNil(TmuxSocketIdentity.fromStartupCommand("tmux -S /tmp/socket-* attach")) + XCTAssertNil(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: nil)) + } + + func testControlServerIdentityCapturesResolvedNamedSocketAndQuotesIt() throws { + let path = "/tmp/tmux-501/work,team:one ' $HOME `id`" + let socket = try XCTUnwrap(TmuxSocketIdentity.fromServerIdentity("host:\(path),123,456")) + XCTAssertEqual(socket, .path(path)) + let target = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", controlMode: true, tmuxSocket: socket)) + XCTAssertEqual(target.execCommand, LoginShellCommand.runInPOSIXShell(LoginShellCommand.pathPrefix + + "exec tmux -S \"/tmp/tmux-501/work,team:one ' \\$HOME \\`id\\`\" -CC attach-session -t \"=main\"")) + XCTAssertNil(TmuxSocketIdentity.fromServerIdentity("incomplete")) + } + + func testSameNamedSessionsOnDifferentTmuxServersDoNotMatch() throws { + let work = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: .name("work"))) + let other = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: .name("other"))) + XCTAssertFalse(work.matchesLiveAttachment(other, connectionKey: "remote", requestedConnectionKey: "remote", isActive: true)) + let resolved = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: .path("/tmp/tmux-501/work"), + tmuxSocketSelector: .name("work"))) + XCTAssertTrue(work.matchesLiveAttachment(resolved, connectionKey: "remote", requestedConnectionKey: "remote", + isActive: true)) + XCTAssertFalse(other.matchesLiveAttachment(resolved, connectionKey: "remote", requestedConnectionKey: "remote", + isActive: true)) + } + + func testOriginalProfileMatchesAfterRepeatedReconnectAndRestoration() throws { + for selector in [TmuxSocketIdentity.defaultServer, .name("work")] { + for controlMode in [false, true] { + let profile = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", + controlMode: controlMode, tmuxSocket: selector)) + let path = TmuxSocketIdentity.path("/tmp/tmux-501/resolved") + var attachment = profile + for _ in 0..<2 { + let detached = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: attachment.sessionName, + controlMode: attachment.controlMode, tmuxSocket: path, + tmuxSocketSelector: attachment.configuredTmuxSocket)) + attachment = try JSONDecoder().decode(MuxSessionTarget.self, from: JSONEncoder().encode(detached)) + XCTAssertEqual(attachment.configuredTmuxSocket, selector) + XCTAssertEqual(attachment.tmuxSocket, path) + XCTAssertEqual(attachment.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec tmux -S \"/tmp/tmux-501/resolved\" " + + "\(controlMode ? "-CC " : "")attach-session -t \"=main\"")) + XCTAssertTrue(profile.matchesLiveAttachment(attachment, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + let other = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", + controlMode: controlMode, tmuxSocket: .name("other"))) + XCTAssertFalse(other.matchesLiveAttachment(attachment, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + } + } + } + } + + func testTildeExpansionIsUnknownWhileQuotedTildesRemainLiteral() { + for command in ["tmux -S ~/work.sock attach", "tmux -S ~user/work.sock attach", + "sh -c 'exec tmux -S ~/work.sock attach'"] { + let socket = TmuxSocketIdentity.fromStartupCommand(command) + XCTAssertNil(socket) + XCTAssertNil(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: socket)) + } + for command in ["tmux -S '~/work.sock' attach", "tmux -S \"~/work.sock\" attach", + "tmux -S \\~/work.sock attach"] { + XCTAssertEqual(TmuxSocketIdentity.fromStartupCommand(command), .path("~/work.sock")) + } + } + + func testZellijAndCustomTmuxTargetsRoundTripForTabRestoration() throws { + for target in [ + MuxSessionTarget(type: .zellij, sessionName: "restored project"), + MuxSessionTarget(type: .tmux, sessionName: "main", controlMode: true, tmuxSocket: .path("/tmp/custom socket")), + MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: .name("work")) + ] { + let original = try XCTUnwrap(target) + let data = try JSONEncoder().encode(original) + let restored = try JSONDecoder().decode(MuxSessionTarget.self, from: data) + XCTAssertEqual(restored, original) + XCTAssertEqual(restored.execCommand, original.execCommand) + XCTAssertNotNil(restored.execCommand) + } + } + func testLocalControlSessionCannotInterceptRemoteProfile() throws { for type in [MultiplexerType.tmux, .herdr] { let target = try XCTUnwrap(MuxSessionTarget(type: type, sessionName: "default", controlMode: true))