Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions rootshell/App/AppCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(_:)),
Expand Down
11 changes: 10 additions & 1 deletion rootshell/App/CatalystAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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(_:)),
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions rootshell/App/UIApplication+CommandFallback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
8 changes: 6 additions & 2 deletions rootshell/Core/Keybinds/KeybindAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion rootshell/Core/Keybinds/KeybindCommandGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions rootshell/Core/Terminal/TerminalSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1101,15 +1101,15 @@ 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()
historyRecorder.cancel()

if let trzszSession = session as? TrzszSession {
switch reason {
case .userClose:
case .userClose, .muxDetach:
trzszSession.terminate()
case .sceneTeardown:
trzszSession.stopForReconnect()
Expand All @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion rootshell/Features/Herdr/HerdrController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions rootshell/Features/Multiplexer/MuxDetachBannerState.swift
Original file line number Diff line number Diff line change
@@ -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?
}
Loading