diff --git a/rootshell/App/AppCommands.swift b/rootshell/App/AppCommands.swift index 2942a6023..4dc972f3d 100644 --- a/rootshell/App/AppCommands.swift +++ b/rootshell/App/AppCommands.swift @@ -661,6 +661,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 f340bfa1b..9dff64daa 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`); on a herdr tab, take the whole session instead case detach_other_clients = "detach_other_clients" @@ -303,7 +305,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 @@ -362,6 +364,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") @@ -462,6 +465,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: @@ -618,7 +622,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 90a087487..96581d48a 100644 --- a/rootshell/Features/Herdr/HerdrController.swift +++ b/rootshell/Features/Herdr/HerdrController.swift @@ -780,9 +780,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 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..037dd01f7 --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxSessionResume.swift @@ -0,0 +1,272 @@ +// +// 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 && 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, + 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: config.herdrControlModeEnabled + ) + } + 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)”" + ) + } + + /// 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, + 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..4491e8979 100644 --- a/rootshell/Features/Tmux/TmuxController.swift +++ b/rootshell/Features/Tmux/TmuxController.swift @@ -546,6 +546,16 @@ final class TmuxController { /// (id=tmux-gateway-surface-freed) private(set) var ownerSurfaceFreed = false + /// Window ids the user asked to kill, with the time the kill was requested. + /// `ensureWindow` suppresses self-heal for a short window so an in-flight + /// kill isn't undone by a stale topology; after the grace expires (or the + /// window is confirmed gone in prune) the tombstone clears. + /// ROOTSHELL-TMUX (id=tmux-window-tab-close-server) + private var pendingKilledWindowIds: [Int: Date] = [:] + + /// How long an optimistic kill suppresses `ensureWindow` recreation. + private static let pendingKillGrace: TimeInterval = 2.0 + /// True while control mode has live windows. The gateway view reads this to /// decide whether ESC should detach (only on the gateway, and only while a /// tmux session is actually attached) versus passing through to a pane app. @@ -1063,8 +1073,32 @@ final class TmuxController { } } + /// True while an optimistic kill should suppress projection for `windowId`. + /// Expired tombstones are cleared so a failed kill can recreate the tab. + private func isPendingKill(_ windowId: Int) -> Bool { + guard let killedAt = pendingKilledWindowIds[windowId] else { return false } + if Date().timeIntervalSince(killedAt) < Self.pendingKillGrace { + return true + } + pendingKilledWindowIds.removeValue(forKey: windowId) + return false + } + private func ensureWindow(_ windowId: Int, index: Int) { let hostModel = hostTabsModel(forWindowId: windowId) + + // Optimistic kill: keep the tab gone while kill-window is in flight so a + // stale topology can't self-heal it back. After the grace period, assume + // the kill failed and allow recreation. ensurePane/setLayout no-op for + // the same window so a suppressed ensureWindow doesn't fail the batch. + if isPendingKill(windowId) { + TmuxDebugLogger.shared.event( + "CLOSE", + "ensureWindow suppressed for pending kill @\(windowId)" + ) + return + } + // Refresh the display index on every reconcile so move-window / swap-window // re-order the tabs (the reorder runs at syncEnd). (id=tmux-window-order) if let existing = windowTabs[windowId] { @@ -1192,6 +1226,10 @@ final class TmuxController { viewerTerminal: UnsafeMutableRawPointer?, viewerPane: UnsafeMutableRawPointer? ) -> Bool { + // Window was optimistically killed; skip projection so the batch does + // not fail while ensureWindow is suppressed. (id=tmux-window-tab-close-server) + if isPendingKill(windowId) { return true } + if let existing = paneViews[paneId] { // move-pane / break-pane keep the pane id but change its WINDOW. The // full-topology reconcile re-emits ensure_pane under the NEW window; @@ -1321,6 +1359,10 @@ final class TmuxController { /// re-emits the same topology and the retry is what heals the desync. /// ROOTSHELL-TMUX (id=tmux-reconcile-dedup-failure) private func setLayout(windowId: Int, layout: TmuxLayoutNode, zoomedPaneId: Int?) -> Bool { + // Same pending-kill no-op as ensurePane — missing windowTabs must not + // mark the reconcile batch failed. (id=tmux-window-tab-close-server) + if isPendingKill(windowId) { return true } + guard let tab = windowTabs[windowId] else { TmuxDebugLogger.shared.event("LAYOUT", "setLayout FAILED (no tab) win=\(windowId)") return false @@ -1918,6 +1960,11 @@ final class TmuxController { let priorWindowCount = windowTabs.count let hostIdsBeforePrune = Set(windowHostIds.values + [baseWindowId]) + // Successful kills: window absent from topology → drop the tombstone. + // Failed/in-flight kills that are still listed stay pending so + // ensureWindow keeps suppressing self-heal for the grace period. + pendingKilledWindowIds = pendingKilledWindowIds.filter { windowIds.contains($0.key) } + // Snapshot display order + selection BEFORE removing anything so that, if // the user closed the tmux window tab they were viewing, we can land on // its nearest surviving neighbor (the same rule a regular tab close uses) @@ -2341,6 +2388,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 @@ -2700,13 +2759,35 @@ final class TmuxController { windowTabs[windowId]?.splitTree.zoomed != nil } - /// Resolve the controller projecting a tmux window TAB: any of the tab's - /// pane views carries the gateway binding. Nil for non-tmux tabs and for - /// placeholders that have no live panes yet. + /// Resolve the controller projecting a tmux window TAB. Prefer a live pane + /// binding whose parent surface still maps to an active controller; when the + /// tree is empty or bindings are stale (common for background windows right + /// after detach → reattach), fall back to `owningGatewayTerminalUUID`, then + /// to whichever active controller still projects this tab. static func controller(forWindowTab tab: TabModel) -> TmuxController? { for view in tab.splitTree.terminalLeaves { - if let binding = view.tmuxPaneBinding { - return controller(forOwnerSurface: binding.parentSurface) + if let binding = view.tmuxPaneBinding, + let controller = controller(forOwnerSurface: binding.parentSurface), + controller.isActive { + return controller + } + } + if let owner = tab.owningGatewayTerminalUUID { + for (_, weak) in controllersByOwnerSurface { + guard let controller = weak.controller, + controller.ownerTerminalUUIDForNotifications == owner, + controller.isActive else { continue } + return controller + } + } + // Last resort: identity in windowTabs (UUID may be missing after a + // reconnect race, but the controller still owns the projection). + if let windowId = tab.tmuxWindowId { + for (_, weak) in controllersByOwnerSurface { + guard let controller = weak.controller, controller.isActive else { continue } + if controller.windowTabs[windowId] === tab { + return controller + } } } return nil @@ -2724,6 +2805,114 @@ final class TmuxController { // MARK: - Hidden-window bridges (state is private; the logic lives in // TmuxController+HiddenWindows.swift) (id=tmux-hidden-windows) + /// Kill a tmux window by id via this gateway's command channel, then + /// optimistically remove the projected tab so ✕ feels immediate. A + /// pending-kill tombstone stops `ensureWindow` from self-healing the tab + /// back while kill-window is in flight (or if the C command is dropped). + /// Returns true when the close was handled locally (caller must not also + /// tear the tab down). ROOTSHELL-TMUX (id=tmux-window-tab-close-server) + @discardableResult + func requestKillWindow(windowId: Int) -> Bool { + guard !didEnd, !isDetaching, !ownerSurfaceFreed else { + TmuxDebugLogger.shared.event( + "CLOSE", + "kill-window skipped win=@\(windowId) didEnd=\(didEnd) detaching=\(isDetaching) freed=\(ownerSurfaceFreed)" + ) + return false + } + + pendingKilledWindowIds[windowId] = Date() + + // Prefer a validated gateway surface (same checks as `sendTmuxCommand`). + // Even when identity lookup fails, still remove the tab locally — a + // silent no-op kill was leaving ✕ looking like it did nothing after + // detach→reattach. ROOTSHELL-TMUX (id=tmux-window-tab-close-server) + let surfaceOK = ghosttyApp?.surfaceView(for: ownerSurface)?.uuid == ownerTerminalUUID + if surfaceOK { + let cmd = "kill-window -t @\(windowId)\n" + let data = Data(cmd.utf8) + if !data.isEmpty { + lastCommandAt = Date() + TmuxDebugLogger.shared.command(kind: "kill-window", target: "@\(windowId)", bytes: data.count) + data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + ghostty_surface_tmux_command( + ownerSurface, + base.assumingMemoryBound(to: CChar.self), + UInt(data.count)) + } + } + } else { + TmuxDebugLogger.shared.event( + "CLOSE", + "kill-window @\(windowId): gateway surface identity mismatch; removing tab locally only" + ) + } + + removeProjectedWindowLocally(windowId: windowId) + return true + } + + /// Drop one projected window from the tab model and controller maps (the + /// optimistic half of a user kill-window). Selection moves to a neighbor + /// when the closed tab was selected. + private func removeProjectedWindowLocally(windowId: Int) { + let tab = windowTabs[windowId] ?? hostTabsModel(forWindowId: windowId).tabs.first(where: { + $0.tmuxWindowId == windowId && $0.owningGatewayTerminalUUID == ownerTerminalUUID + }) + guard let tab else { + TmuxDebugLogger.shared.event( + "CLOSE", + "optimistic remove: no tab for win=@\(windowId)" + ) + return + } + + let hostModel = hostTabsModel(forWindowId: windowId) + let hostId = hostWindowId(forWindowId: windowId) + let priorOrder = hostModel.tabs.map(\.id) + let selectedID = hostModel.selectedTabID + let groupedNeighborID = selectedID.flatMap { hostModel.groupedCloseNeighbor(for: $0) } + + let panesToRemove = paneViews.filter { $0.value.tmuxPaneBinding?.windowId == windowId } + for (paneId, view) in panesToRemove { + view.isLogicallyFocused = false + view.shouldBecomeFirstResponderWhenReady = false + view.tmuxPaneRetired = true + view.cleanup(reason: .userClose) + paneViews.removeValue(forKey: paneId) + } + + hostModel.tabs.removeAll { $0.id == tab.id } + windowTabs.removeValue(forKey: windowId) + windowHostIds.removeValue(forKey: windowId) + windowFontSize.removeValue(forKey: windowId) + lastPushedWindowSize.removeValue(forKey: windowId) + lastLayoutPaneCount.removeValue(forKey: windowId) + reportedWindowCellsByWindow.removeValue(forKey: windowId) + clearForeignConstraint(windowId: windowId) + clearPendingSplitFocus(windowId: windowId) + + if let selectedID, selectedID == tab.id, + let neighborID = survivingGroupedOrNearestNeighbor( + in: hostModel, + groupedCandidateID: groupedNeighborID, + priorOrder: priorOrder, + removedID: tab.id + ) { + hostModel.selectedTabID = neighborID + hostModel.pendingScrollToTabID = neighborID + TerminalWindowRegistry.refreshSelectionAfterMutation(in: hostId, allowFocus: true) + } else { + hostModel.repairSelectionIfNeeded() + } + + TmuxDebugLogger.shared.event( + "CLOSE", + "optimistic remove win=@\(windowId) tab=\(tab.id.uuidString.prefix(8))" + ) + } + /// The tab projecting a tmux window, if any. func windowTab(forWindowId windowId: Int) -> TabModel? { windowTabs[windowId] 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 ad3921db4..73f82ea3b 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..7deabbe5d 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) } @@ -814,42 +817,49 @@ extension MainView { extension MainView { - /// Dispatch the user-configured close action for a tmux -CC window tab - /// (the tab's ✕ button, or ⌘W on a single-pane tmux window). Returns true - /// when it handled the close — the caller must NOT tear the tab down - /// locally: the server reconcile (or the chosen action) drives teardown. - /// Returns false when the tab isn't a live tmux window tab so the caller - /// falls back to a normal local close. (id=tmux-tab-close-action) + /// Route a tmux -CC window-tab close to the server (or the configured + /// close action). Must NOT require a live pane in the tab's split tree — + /// after detach → reattach, background windows often have empty or stale + /// trees; a local close then self-heals the tab back into existence. + /// Always resolve the gateway via `controller(forWindowTab:)` and kill by + /// `tmuxWindowId` on that controller. (id=tmux-tab-close-action) @MainActor func handleTmuxWindowTabClose(_ tab: TerminalTab) -> Bool { - guard tab.isTmuxWindow, let windowId = tab.tmuxWindowId, - let pane = tab.splitTree.terminalLeaves.first(where: { $0.isTmuxPane }), - let binding = pane.tmuxPaneBinding, - let controller = TmuxController.controller(forOwnerSurface: binding.parentSurface), - controller.isActive else { return false } + guard tab.isTmuxWindow, let windowId = tab.tmuxWindowId else { return false } + + guard let controller = TmuxController.controller(forWindowTab: tab), + !controller.didEnd, !controller.isDetaching else { + TmuxDebugLogger.shared.event( + "CLOSE", + "window-tab close: no live controller win=\(windowId) owner=\(tab.owningGatewayTerminalUUID?.uuidString.prefix(8) ?? "nil")" + ) + return false + } let action = TmuxTabCloseAction.current if action == .ask { pendingTmuxCloseTabID = tab.id return true } - return performTmuxClose(action, tab: tab, pane: pane, - controller: controller, windowId: windowId) + return performTmuxClose(action, controller: controller, windowId: windowId) } /// Perform a concrete tmux tab-close action (never resolves `.ask`). - /// Factored out so the "Ask Each Time" action sheet can invoke each branch - /// directly. (id=tmux-tab-close-action) + /// Kill/hide/detach always run via the gateway controller so empty or + /// stale background window trees still close after detach → reattach. + /// (id=tmux-tab-close-action) @MainActor @discardableResult func performTmuxClose(_ action: TmuxTabCloseAction, - tab: TerminalTab, - pane: Ghostty.TerminalView, controller: TmuxController, windowId: Int) -> Bool { switch action { case .closeWindow: - return pane.requestTmuxKillWindow() + // Always kill through the live gateway controller. Pane-view + // `requestTmuxKillWindow` can return true while silently dropping + // on a stale parent surface after detach→reattach, leaving the + // tab stuck; or return false without this fallback. + return controller.requestKillWindow(windowId: windowId) case .detachSession: controller.requestGracefulDetach(source: "tab-close") return true @@ -867,10 +877,12 @@ extension MainView { if controller.hideWindow(windowId: windowId) { return true } - return pane.requestTmuxKillWindow() + return controller.requestKillWindow(windowId: windowId) case .ask: // Safety net: a re-prompt instead of silently dropping the close. - pendingTmuxCloseTabID = tab.id + if let tab = controller.windowTab(forWindowId: windowId) { + pendingTmuxCloseTabID = tab.id + } return true } } @@ -883,12 +895,10 @@ extension MainView { defer { pendingTmuxCloseTabID = nil } guard let id = pendingTmuxCloseTabID, let tab = terminals.first(where: { $0.id == id }), - tab.isTmuxWindow, let windowId = tab.tmuxWindowId, - let pane = tab.splitTree.terminalLeaves.first(where: { $0.isTmuxPane }), - let binding = pane.tmuxPaneBinding, - let controller = TmuxController.controller(forOwnerSurface: binding.parentSurface), - controller.isActive else { return } - performTmuxClose(action, tab: tab, pane: pane, controller: controller, windowId: windowId) + tab.isTmuxWindow, let windowId = tab.tmuxWindowId else { return } + guard let controller = TmuxController.controller(forWindowTab: tab), + !controller.didEnd, !controller.isDetaching else { return } + performTmuxClose(action, controller: controller, windowId: windowId) } /// The tmux close-action setting applies to herdr tabs too: close on the diff --git a/rootshell/UI/Shell/MainView+TabSidebar.swift b/rootshell/UI/Shell/MainView+TabSidebar.swift index 1a2ca868e..cfe000b66 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 3b822cad7..2e240379b 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 74374848e..f0f07fdc0 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? @@ -508,23 +511,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 2583809aa..4ec8ec220 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 24681f3d5..71853f974 100644 --- a/rootshell/UI/Terminal/TerminalSplitTreeView.swift +++ b/rootshell/UI/Terminal/TerminalSplitTreeView.swift @@ -1431,7 +1431,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 3246a5a75..c0bd5bcd3 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 58de66e9b..c5e681df9 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 { @@ -1575,13 +1576,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 @@ -1632,10 +1639,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 @@ -4569,6 +4583,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)