Address ssh review feedback and CI blockers

This commit is contained in:
Lawrence Chen 2026-03-11 23:03:53 -07:00
parent 3c2de584ba
commit de47345538
8 changed files with 884 additions and 42 deletions

View file

@ -1276,6 +1276,8 @@ struct CMUXCLI {
case "ssh":
try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "ssh-session-end":
try runSSHSessionEnd(commandArgs: commandArgs, client: client)
case "new-workspace":
let (commandOpt, rem0) = parseOption(commandArgs, name: "--command")
@ -2883,8 +2885,12 @@ struct CMUXCLI {
prepareSSHTerminfoIfNeeded(sshOptions)
let sshCommand = buildSSHCommandText(sshOptions)
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
let sshStartupCommand = buildSSHStartupCommand(sshCommand: sshCommand, shellFeatures: shellFeaturesValue)
let remoteSSHOptions = sshOptionsWithControlSocketDefaults(
let sshStartupCommand = buildSSHStartupCommand(
sshCommand: sshCommand,
shellFeatures: shellFeaturesValue,
remoteRelayPort: sshOptions.remoteRelayPort
)
let remoteSSHOptions = effectiveSSHOptions(
sshOptions.sshOptions,
remoteRelayPort: sshOptions.remoteRelayPort
)
@ -2939,6 +2945,7 @@ struct CMUXCLI {
configureParams["relay_port"] = sshOptions.remoteRelayPort
configureParams["local_socket_path"] = sshOptions.localSocketPath
}
configureParams["terminal_startup_command"] = sshStartupCommand
cliDebugLog(
"cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " +
@ -2960,7 +2967,12 @@ struct CMUXCLI {
cliDebugLog(
"cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))"
)
_ = try? client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId])
do {
_ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId])
} catch {
let warning = "Warning: failed to rollback workspace \(workspaceId): \(error)\n"
FileHandle.standardError.write(Data(warning.utf8))
}
throw error
}
@ -3055,12 +3067,6 @@ struct CMUXCLI {
guard let destination else {
throw CLIError(message: "ssh requires a destination (example: cmux ssh user@host)")
}
if destination.hasPrefix("-") {
throw CLIError(
message: "ssh: destination must be <user@host>. Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args."
)
}
return SSHCommandOptions(
destination: destination,
port: port,
@ -3075,6 +3081,7 @@ struct CMUXCLI {
private func buildSSHCommandText(_ options: SSHCommandOptions) -> String {
var parts = baseSSHArguments(options)
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
if options.extraArguments.isEmpty {
// No explicit remote command provided. Use RemoteCommand to bootstrap
@ -3085,7 +3092,7 @@ struct CMUXCLI {
if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") {
parts += [
"-o",
"RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort))",
"RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))",
]
}
parts.append(options.destination)
@ -3096,11 +3103,21 @@ struct CMUXCLI {
return parts.map(shellQuote).joined(separator: " ")
}
private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int) -> String {
private func effectiveSSHOptions(_ options: [String], remoteRelayPort: Int? = nil) -> [String] {
var merged = sshOptionsWithControlSocketDefaults(options, remoteRelayPort: remoteRelayPort)
if !hasSSHOptionKey(merged, key: "StrictHostKeyChecking") {
merged.append("StrictHostKeyChecking=accept-new")
}
return merged
}
private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String {
let relayExport = remoteRelayPort > 0
? "export CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort)"
: nil
let remoteEnvExports = interactiveRemoteShellExports(shellFeatures: shellFeatures)
let innerCommand = [
remoteEnvExports,
"export PATH=\"$HOME/.cmux/bin:$PATH\"",
relayExport,
"exec \"${SHELL:-/bin/zsh}\" -i",
@ -3115,6 +3132,7 @@ struct CMUXCLI {
" exec \"$CMUX_LOGIN_SHELL\" -lc \(shellQuote(innerCommand))",
" ;;",
" *)",
remoteEnvExports,
" export PATH=\"$HOME/.cmux/bin:$PATH\"",
relayExport,
" exec \"$CMUX_LOGIN_SHELL\" -i",
@ -3127,15 +3145,36 @@ struct CMUXCLI {
return outerCommand
}
private func interactiveRemoteShellExports(shellFeatures: String) -> String {
let environment = ProcessInfo.processInfo.environment
let term = Self.normalizedEnvValue(environment["TERM"]) ?? "xterm-ghostty"
let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor"
let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty"
let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"])
?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)
?? ""
let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
var exports: [String] = [
"export TERM=\(shellQuote(term))",
"export COLORTERM=\(shellQuote(colorTerm))",
"export TERM_PROGRAM=\(shellQuote(termProgram))",
]
if !termProgramVersion.isEmpty {
exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))")
}
if !trimmedShellFeatures.isEmpty {
exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))")
}
return exports.joined(separator: "; ")
}
private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] {
let effectiveSSHOptions = sshOptionsWithControlSocketDefaults(
let effectiveSSHOptions = effectiveSSHOptions(
options.sshOptions,
remoteRelayPort: options.remoteRelayPort
)
var parts: [String] = ["ssh"]
if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") {
parts += ["-o", "StrictHostKeyChecking=accept-new"]
}
if !hasSSHOptionKey(effectiveSSHOptions, key: "SetEnv") {
parts += ["-o", "SetEnv COLORTERM=truecolor"]
}
@ -3228,17 +3267,68 @@ struct CMUXCLI {
return merged.joined(separator: ",")
}
private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String) -> String {
private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String, remoteRelayPort: Int) -> String {
let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty
? ""
: "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))"
let script = [shellFeaturesBootstrap, "command \(sshCommand); exec ${SHELL:-/bin/zsh} -l"]
let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort)
let script = [
shellFeaturesBootstrap,
"CMUX_SSH_SESSION_ENDED=0",
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }",
"trap 'cmux_ssh_session_end' EXIT HUP INT TERM",
"command \(sshCommand)",
"trap - EXIT HUP INT TERM",
"cmux_ssh_session_end",
"exec ${SHELL:-/bin/zsh} -l",
]
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.joined(separator: "\n")
return "/bin/zsh -ilc \(shellQuote(script))"
}
private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String {
[
"if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]",
"&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]",
"&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;",
"elif command -v cmux >/dev/null 2>&1",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;",
"fi",
].joined(separator: " ")
}
private func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws {
guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"),
let relayPort = Int(relayPortRaw),
relayPort > 0 else {
throw CLIError(message: "ssh-session-end requires --relay-port <port>")
}
let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]
let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
guard let workspaceRaw,
let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client),
!workspaceId.isEmpty else {
throw CLIError(message: "ssh-session-end requires --workspace or CMUX_WORKSPACE_ID")
}
guard let surfaceRaw,
let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId),
!surfaceId.isEmpty else {
throw CLIError(message: "ssh-session-end requires --surface or CMUX_SURFACE_ID")
}
_ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [
"workspace_id": workspaceId,
"surface_id": surfaceId,
"relay_port": relayPort,
])
}
private func hasSSHOptionKey(_ options: [String], key: String) -> Bool {
let loweredKey = key.lowercased()
for option in options {

View file

@ -25452,6 +25452,57 @@
}
}
},
"contextMenu.copyError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy Error"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "エラーをコピー"
}
}
}
},
"contextMenu.copyErrors": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy Errors"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "エラーをコピー"
}
}
}
},
"contextMenu.copySshError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy SSH Error"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSHエラーをコピー"
}
}
}
},
"contextMenu.moveDown": {
"extractionState": "manual",
"localizations": {
@ -42792,6 +42843,40 @@
}
}
},
"settings.app.showSSH": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show SSH in Sidebar"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サイドバーにSSHを表示"
}
}
}
},
"settings.app.showSSH.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Display the SSH target for remote workspaces in its own row."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リモートワークスペースのSSHターゲットを専用の行に表示します。"
}
}
}
},
"settings.app.showPorts.subtitle": {
"extractionState": "manual",
"localizations": {
@ -61336,6 +61421,125 @@
}
}
},
"sidebar.remote.badge": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH"
}
}
}
},
"remote.status.connected": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Connected"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "接続済み"
}
}
}
},
"remote.status.connecting": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Connecting"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "接続中"
}
}
}
},
"remote.status.disconnected": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Disconnected"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "切断済み"
}
}
}
},
"remote.status.error": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Error"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "エラー"
}
}
}
},
"sidebar.remote.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH • %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH • %@"
}
}
}
},
"sidebar.remote.subtitleFallback": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH workspace"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH ワークスペース"
}
}
}
},
"sidebar.workspace.moveDownAction": {
"extractionState": "manual",
"localizations": {

View file

@ -74,6 +74,47 @@ func cmuxAccentColor() -> Color {
Color(nsColor: cmuxAccentNSColor())
}
struct SidebarRemoteErrorCopyEntry: Equatable {
let workspaceTitle: String
let target: String
let detail: String
}
enum SidebarRemoteErrorCopySupport {
static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? {
guard !entries.isEmpty else { return nil }
if entries.count == 1 {
return String(localized: "contextMenu.copyError", defaultValue: "Copy Error")
}
return String(localized: "contextMenu.copyErrors", defaultValue: "Copy Errors")
}
static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? {
guard !entries.isEmpty else { return nil }
if entries.count == 1, let entry = entries.first {
return "SSH error (\(entry.target)): \(entry.detail)"
}
return entries.enumerated().map { index, entry in
"\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)"
}.joined(separator: "\n")
}
static func parsedTargetAndDetail(from value: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("SSH error") else { return nil }
if let match = trimmed.firstMatch(of: /^SSH error \((.+?)\):\s*(.+)$/) {
return (String(match.1), String(match.2))
}
if let match = trimmed.firstMatch(of: /^SSH error:\s*(.+)$/) {
guard let fallbackTarget, !fallbackTarget.isEmpty else { return nil }
return (fallbackTarget, String(match.1))
}
return nil
}
}
func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor {
cmuxAccentNSColor(for: colorScheme)
}
@ -1929,6 +1970,7 @@ struct ContentView: View {
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.frame(width: sidebarWidth)
.frame(maxHeight: .infinity, alignment: .topLeading)
}
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
@ -7292,6 +7334,7 @@ struct VerticalTabsSidebar: View {
#endif
draggedTabId = nil
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func debugShortSidebarTabId(_ id: UUID?) -> String {
@ -9485,6 +9528,7 @@ private struct TabItemView: View, Equatable {
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@AppStorage("sidebarShowSSH") private var sidebarShowSSH = true
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@ -9587,6 +9631,15 @@ private struct TabItemView: View, Equatable {
)
}
private var remoteWorkspaceSidebarText: String? {
guard tab.hasActiveRemoteTerminalSessions else { return nil }
let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmedTarget, !trimmedTarget.isEmpty {
return trimmedTarget
}
return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace")
}
private var copyableSidebarSSHError: String? {
let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty {
@ -9601,6 +9654,48 @@ private struct TabItemView: View, Equatable {
return nil
}
private var remoteConnectionStatusText: String {
switch tab.remoteConnectionState {
case .connected:
return String(localized: "remote.status.connected", defaultValue: "Connected")
case .connecting:
return String(localized: "remote.status.connecting", defaultValue: "Connecting")
case .error:
return String(localized: "remote.status.error", defaultValue: "Error")
case .disconnected:
return String(localized: "remote.status.disconnected", defaultValue: "Disconnected")
}
}
@ViewBuilder
private var remoteWorkspaceSection: some View {
if sidebarShowSSH, let remoteWorkspaceSidebarText {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "sidebar.remote.badge", defaultValue: "SSH"))
.font(.system(size: 9, weight: .semibold))
.foregroundColor(activeSecondaryColor(0.62))
.textCase(.uppercase)
HStack(spacing: 6) {
Text(remoteWorkspaceSidebarText)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(activeSecondaryColor(0.8))
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Text(remoteConnectionStatusText)
.font(.system(size: 9, weight: .medium))
.foregroundColor(activeSecondaryColor(0.58))
.lineLimit(1)
}
}
.padding(.top, latestNotificationText == nil ? 1 : 2)
.safeHelp(remoteStateHelpText)
}
}
private func copyTextToPasteboard(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
@ -9613,6 +9708,7 @@ private struct TabItemView: View, Equatable {
let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up")
let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down")
let latestNotificationSubtitle = latestNotificationText
let effectiveSubtitle = latestNotificationSubtitle
let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest)
? tab.sidebarOrderedPanelIds()
: nil
@ -9716,7 +9812,7 @@ private struct TabItemView: View, Equatable {
.frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing)
}
if let subtitle = latestNotificationSubtitle {
if let subtitle = effectiveSubtitle {
Text(subtitle)
.font(.system(size: 10))
.foregroundColor(activeSecondaryColor(0.8))
@ -9725,6 +9821,8 @@ private struct TabItemView: View, Equatable {
.multilineTextAlignment(.leading)
}
remoteWorkspaceSection
if sidebarShowMetadata {
let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder()
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()

View file

@ -2811,6 +2811,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
env["CMUX_PANEL_ID"] = id.uuidString
env["CMUX_TAB_ID"] = tabId.uuidString
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
if let bundledCLIPath = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux").path,
!bundledCLIPath.isEmpty {
env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIPath
}
if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
env["CMUX_BUNDLE_ID"] = bundleId
}

View file

@ -1741,6 +1741,8 @@ class TerminalController {
return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params))
case "workspace.remote.status":
return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params))
case "workspace.remote.terminal_session_end":
return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params))
// Settings
case "settings.open":
@ -2103,6 +2105,7 @@ class TerminalController {
"workspace.remote.reconnect",
"workspace.remote.disconnect",
"workspace.remote.status",
"workspace.remote.terminal_session_end",
"settings.open",
"feedback.open",
"feedback.submit",
@ -3339,6 +3342,8 @@ class TerminalController {
let autoConnect = v2Bool(params, "auto_connect") ?? true
let relayPort = v2Int(params, "relay_port")
let localSocketPath = v2RawString(params, "local_socket_path")
let terminalStartupCommand = v2RawString(params, "terminal_startup_command")?
.trimmingCharacters(in: .whitespacesAndNewlines)
#if DEBUG
dlog(
@ -3368,7 +3373,8 @@ class TerminalController {
sshOptions: sshOptions,
localProxyPort: localProxyPort,
relayPort: relayPort,
localSocketPath: localSocketPath
localSocketPath: localSocketPath,
terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand
)
workspace.configureRemoteConnection(config, autoConnect: autoConnect)
@ -3503,6 +3509,48 @@ class TerminalController {
return result
}
private func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult {
guard let workspaceId = v2UUID(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
guard let relayPort = v2Int(params, "relay_port"),
relayPort > 0 else {
return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"relay_port": relayPort,
])
v2MainSync {
guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId),
let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else {
return
}
workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort)
let windowId = v2ResolveWindowId(tabManager: owner)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": workspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"relay_port": relayPort,
"remote": workspace.remoteStatusPayload(),
])
}
return result
}
private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)

View file

@ -1469,11 +1469,7 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
if let streamID {
rpcClient.closeStream(streamID: streamID)
}
if reason != nil {
connection.cancel()
} else {
connection.cancel()
}
connection.cancel()
onClose(id)
}
@ -1959,6 +1955,10 @@ private final class WorkspaceRemoteSessionController {
private var daemonReady = false
private var daemonBootstrapVersion: String?
private var daemonRemotePath: String?
private var reverseRelayProcess: Process?
private var reverseRelayStderrPipe: Pipe?
private var reverseRelayRestartWorkItem: DispatchWorkItem?
private var reverseRelayStderrBuffer = ""
private var reconnectRetryCount = 0
private var reconnectWorkItem: DispatchWorkItem?
private var heartbeatWorkItem: DispatchWorkItem?
@ -1998,7 +1998,10 @@ private final class WorkspaceRemoteSessionController {
reconnectWorkItem?.cancel()
reconnectWorkItem = nil
reconnectRetryCount = 0
reverseRelayRestartWorkItem?.cancel()
reverseRelayRestartWorkItem = nil
stopHeartbeatLocked(reset: true)
stopReverseRelayLocked()
proxyLease?.release()
proxyLease = nil
@ -2045,6 +2048,8 @@ private final class WorkspaceRemoteSessionController {
capabilities: hello.capabilities,
remotePath: hello.remotePath
)
prepareRemoteCLISessionLocked(remotePath: hello.remotePath)
startReverseRelayLocked(remotePath: hello.remotePath)
startProxyLocked()
} catch {
daemonReady = false
@ -2083,6 +2088,129 @@ private final class WorkspaceRemoteSessionController {
proxyLease = lease
}
private func prepareRemoteCLISessionLocked(remotePath: String) {
createRemoteCLISymlinkLocked(daemonRemotePath: remotePath)
writeRemoteRelayDaemonPathLocked(remotePath: remotePath)
}
private func startReverseRelayLocked(remotePath: String) {
guard !isStopping else { return }
guard daemonReady else { return }
guard let relayPort = configuration.relayPort, relayPort > 0,
let localSocketPath = configuration.localSocketPath?
.trimmingCharacters(in: .whitespacesAndNewlines),
!localSocketPath.isEmpty else {
return
}
guard reverseRelayProcess == nil else { return }
reverseRelayRestartWorkItem?.cancel()
reverseRelayRestartWorkItem = nil
Self.killOrphanedRelayProcesses(
relayPort: relayPort,
socketPath: localSocketPath,
destination: configuration.destination
)
let process = Process()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = reverseRelayArguments(relayPort: relayPort, localSocketPath: localSocketPath)
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = stderrPipe
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else {
handle.readabilityHandler = nil
return
}
self?.queue.async {
guard let self else { return }
if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty {
self.reverseRelayStderrBuffer.append(chunk)
if self.reverseRelayStderrBuffer.count > 8192 {
self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192)
}
}
}
}
process.terminationHandler = { [weak self] terminated in
self?.queue.async {
self?.handleReverseRelayTerminationLocked(process: terminated)
}
}
do {
try process.run()
reverseRelayProcess = process
reverseRelayStderrPipe = stderrPipe
reverseRelayStderrBuffer = ""
debugLog(
"remote.relay.start relayPort=\(relayPort) localSocket=\(localSocketPath) " +
"target=\(configuration.displayTarget)"
)
queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in
guard let self else { return }
guard !self.isStopping else { return }
guard self.reverseRelayProcess === process, process.isRunning else { return }
self.writeRemoteSocketAddrLocked(relayPort: relayPort)
self.writeRemoteRelayDaemonPathLocked(remotePath: remotePath)
}
} catch {
debugLog(
"remote.relay.startFailed relayPort=\(relayPort) localSocket=\(localSocketPath) " +
"error=\(error.localizedDescription)"
)
scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0)
}
}
private func handleReverseRelayTerminationLocked(process: Process) {
guard reverseRelayProcess === process else { return }
let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer)
reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil
reverseRelayProcess = nil
reverseRelayStderrPipe = nil
guard !isStopping else { return }
guard let remotePath = daemonRemotePath,
!remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
let detail = stderrDetail ?? "status=\(process.terminationStatus)"
debugLog("remote.relay.exit \(detail)")
scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0)
}
private func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) {
guard !isStopping else { return }
reverseRelayRestartWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.reverseRelayRestartWorkItem = nil
guard !self.isStopping else { return }
guard self.reverseRelayProcess == nil else { return }
guard self.daemonReady else { return }
self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath)
}
reverseRelayRestartWorkItem = workItem
queue.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func stopReverseRelayLocked() {
reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil
if let reverseRelayProcess, reverseRelayProcess.isRunning {
reverseRelayProcess.terminate()
}
reverseRelayProcess = nil
reverseRelayStderrPipe = nil
reverseRelayStderrBuffer = ""
}
private func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) {
guard !isStopping else { return }
switch update {
@ -2262,6 +2390,21 @@ private final class WorkspaceRemoteSessionController {
}
}
private func reverseRelayArguments(relayPort: Int, localSocketPath: String) -> [String] {
// `-o ControlPath=none` is not enough on macOS OpenSSH, the client can still
// attach to an existing master and exit immediately with its status.
// `-S none` forces a standalone transport for the reverse relay.
var args: [String] = ["-N", "-T", "-S", "none"]
args += sshCommonArguments(batchMode: true)
args += [
"-o", "ExitOnForwardFailure=no",
"-o", "RequestTTY=no",
"-R", "127.0.0.1:\(relayPort):\(localSocketPath)",
configuration.destination,
]
return args
}
private func sshCommonArguments(batchMode: Bool) -> [String] {
let effectiveSSHOptions: [String] = {
if batchMode {
@ -2385,9 +2528,38 @@ private final class WorkspaceRemoteSessionController {
process.standardInput = FileHandle.nullDevice
}
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
let captureQueue = DispatchQueue(label: "cmux.remote.process.capture")
var stdoutData = Data()
var stderrData = Data()
stdoutHandle.readabilityHandler = { handle in
let data = handle.availableData
captureQueue.sync {
if data.isEmpty {
handle.readabilityHandler = nil
} else {
stdoutData.append(data)
}
}
}
stderrHandle.readabilityHandler = { handle in
let data = handle.availableData
captureQueue.sync {
if data.isEmpty {
handle.readabilityHandler = nil
} else {
stderrData.append(data)
}
}
}
do {
try process.run()
} catch {
stdoutHandle.readabilityHandler = nil
stderrHandle.readabilityHandler = nil
debugLog(
"remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
"error=\(error.localizedDescription)"
@ -2425,8 +2597,14 @@ private final class WorkspaceRemoteSessionController {
])
}
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
stdoutHandle.readabilityHandler = nil
stderrHandle.readabilityHandler = nil
captureQueue.sync {
stdoutData.append(stdoutHandle.readDataToEndOfFile())
stderrData.append(stderrHandle.readDataToEndOfFile())
}
try? stdoutHandle.close()
try? stderrHandle.close()
let stdout = String(data: stdoutData, encoding: .utf8) ?? ""
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
debugLog(
@ -2469,6 +2647,68 @@ private final class WorkspaceRemoteSessionController {
return hello
}
private func createRemoteCLISymlinkLocked(daemonRemotePath: String) {
let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedRemotePath.isEmpty else { return }
let script = """
mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay"
ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current"
ln -sf "$HOME/.cmux/bin/cmuxd-remote-current" "$HOME/.cmux/bin/cmux"
"""
let command = "sh -c \(Self.shellSingleQuoted(script))"
do {
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8)
if result.status != 0 {
debugLog(
"remote.relay.wrapper.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")"
)
}
} catch {
debugLog("remote.relay.wrapper.error \(error.localizedDescription)")
}
}
private func writeRemoteSocketAddrLocked(relayPort: Int) {
let script = """
mkdir -p "$HOME/.cmux"
printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr"
"""
let command = "sh -c \(Self.shellSingleQuoted(script))"
do {
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8)
if result.status != 0 {
debugLog(
"remote.relay.socketAddr.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")"
)
}
} catch {
debugLog("remote.relay.socketAddr.error \(error.localizedDescription)")
}
}
private func writeRemoteRelayDaemonPathLocked(remotePath: String) {
guard let relayPort = configuration.relayPort, relayPort > 0 else { return }
let trimmedRemotePath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedRemotePath.isEmpty else { return }
let script = """
mkdir -p "$HOME/.cmux/relay"
printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path"
"""
let command = "sh -c \(Self.shellSingleQuoted(script))"
do {
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8)
if result.status != 0 {
debugLog(
"remote.relay.daemonPath.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")"
)
}
} catch {
debugLog("remote.relay.daemonPath.error \(error.localizedDescription)")
}
}
private func resolveRemotePlatformLocked() throws -> RemotePlatform {
let script = "uname -s; uname -m"
let command = "sh -c \(Self.shellSingleQuoted(script))"
@ -2784,6 +3024,20 @@ private final class WorkspaceRemoteSessionController {
".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote"
}
private static func killOrphanedRelayProcesses(relayPort: Int, socketPath: String, destination: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:\(relayPort):\(socketPath).*\(destination)"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
} catch {
// Best effort cleanup only.
}
}
private static func which(_ executable: String) -> String? {
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
for component in path.split(separator: ":") {
@ -2950,6 +3204,7 @@ struct WorkspaceRemoteConfiguration: Equatable {
let localProxyPort: Int?
let relayPort: Int?
let localSocketPath: String?
let terminalStartupCommand: String?
var displayTarget: String {
guard let port else { return destination }
@ -3326,12 +3581,14 @@ final class Workspace: Identifiable, ObservableObject {
@Published var remoteHeartbeatCount: Int = 0
@Published var remoteLastHeartbeatAt: Date?
@Published var listeningPorts: [Int] = []
@Published private(set) var activeRemoteTerminalSessionCount: Int = 0
var surfaceTTYNames: [UUID: String] = [:]
private var remoteSessionController: WorkspaceRemoteSessionController?
fileprivate var activeRemoteSessionControllerID: UUID?
private var remoteLastErrorFingerprint: String?
private var remoteLastDaemonErrorFingerprint: String?
private var remoteLastPortConflictFingerprint: String?
private var activeRemoteTerminalSurfaceIds: Set<UUID> = []
private static let remoteErrorStatusKey = "remote.error"
private static let remotePortConflictStatusKey = "remote.port_conflicts"
@ -3377,7 +3634,19 @@ final class Workspace: Identifiable, ObservableObject {
}
private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance {
bonsplitAppearance(from: config.backgroundColor)
bonsplitAppearance(
from: config.backgroundColor,
backgroundOpacity: config.backgroundOpacity
)
}
static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String {
let themedColor = GhosttyBackgroundTheme.color(
backgroundColor: backgroundColor,
opacity: backgroundOpacity
)
let includeAlpha = themedColor.alphaComponent < 0.999
return themedColor.hexString(includeAlpha: includeAlpha)
}
nonisolated static func resolvedChromeColors(
@ -3386,40 +3655,52 @@ final class Workspace: Identifiable, ObservableObject {
.init(backgroundHex: backgroundColor.hexString())
}
private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance {
let chromeColors = resolvedChromeColors(from: backgroundColor)
return BonsplitConfiguration.Appearance(
private static func bonsplitAppearance(
from backgroundColor: NSColor,
backgroundOpacity: Double
) -> BonsplitConfiguration.Appearance {
BonsplitConfiguration.Appearance(
splitButtonTooltips: Self.currentSplitButtonTooltips(),
enableAnimations: false,
chromeColors: chromeColors
chromeColors: .init(
backgroundHex: Self.bonsplitChromeHex(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundOpacity
)
)
)
}
func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") {
applyGhosttyChrome(backgroundColor: config.backgroundColor, reason: reason)
applyGhosttyChrome(
backgroundColor: config.backgroundColor,
backgroundOpacity: config.backgroundOpacity,
reason: reason
)
}
func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") {
func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") {
let nextHex = Self.bonsplitChromeHex(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundOpacity
)
let currentChromeColors = bonsplitController.configuration.appearance.chromeColors
let nextChromeColors = Self.resolvedChromeColors(from: backgroundColor)
let isNoOp = currentChromeColors.backgroundHex == nextChromeColors.backgroundHex &&
currentChromeColors.borderHex == nextChromeColors.borderHex
let isNoOp = currentChromeColors.backgroundHex == nextHex
if GhosttyApp.shared.backgroundLogEnabled {
let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil"
let nextBackgroundHex = nextChromeColors.backgroundHex ?? "nil"
GhosttyApp.shared.logBackground(
"theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)"
"theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)"
)
}
if isNoOp {
return
}
bonsplitController.configuration.appearance.chromeColors = nextChromeColors
bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
"theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")"
"theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")"
)
}
}
@ -3448,7 +3729,10 @@ final class Workspace: Identifiable, ObservableObject {
// and keep split entry instantaneous.
// Avoid re-reading/parsing Ghostty config on every new workspace; this hot path
// runs for socket/CLI workspace creation and can cause visible typing lag.
let appearance = Self.bonsplitAppearance(from: GhosttyApp.shared.defaultBackgroundColor)
let appearance = Self.bonsplitAppearance(
from: GhosttyApp.shared.defaultBackgroundColor,
backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity
)
let config = BonsplitConfiguration(
allowSplits: true,
allowCloseTabs: true,
@ -4225,6 +4509,10 @@ final class Workspace: Identifiable, ObservableObject {
remoteConfiguration?.displayTarget
}
var hasActiveRemoteTerminalSessions: Bool {
activeRemoteTerminalSessionCount > 0
}
func remoteStatusPayload() -> [String: Any] {
let heartbeatAgeSeconds: Any = {
guard let last = remoteLastHeartbeatAt else { return NSNull() }
@ -4238,6 +4526,7 @@ final class Workspace: Identifiable, ObservableObject {
"enabled": remoteConfiguration != nil,
"state": remoteConnectionState.rawValue,
"connected": remoteConnectionState == .connected,
"active_terminal_sessions": activeRemoteTerminalSessionCount,
"daemon": remoteDaemonStatus.payload(),
"detected_ports": remoteDetectedPorts,
"forwarded_ports": remoteForwardedPorts,
@ -4294,6 +4583,7 @@ final class Workspace: Identifiable, ObservableObject {
func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) {
remoteConfiguration = configuration
seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration)
remoteDetectedPorts = []
remoteForwardedPorts = []
remotePortConflicts = []
@ -4345,6 +4635,8 @@ final class Workspace: Identifiable, ObservableObject {
activeRemoteSessionControllerID = nil
remoteSessionController = nil
previousController?.stop()
activeRemoteTerminalSurfaceIds.removeAll()
activeRemoteTerminalSessionCount = 0
remoteDetectedPorts = []
remoteForwardedPorts = []
remotePortConflicts = []
@ -4367,6 +4659,51 @@ final class Workspace: Identifiable, ObservableObject {
recomputeListeningPorts()
}
private func clearRemoteConfigurationIfWorkspaceBecameLocal() {
guard panels.isEmpty, remoteConfiguration != nil else { return }
disconnectRemoteConnection(clearConfiguration: true)
}
private func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) {
guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
return
}
guard activeRemoteTerminalSurfaceIds.isEmpty else { return }
let terminalIds = panels.compactMap { panelId, panel in
panel is TerminalPanel ? panelId : nil
}
guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return }
trackRemoteTerminalSurface(initialPanelId)
}
private func trackRemoteTerminalSurface(_ panelId: UUID) {
guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return }
activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count
}
private func untrackRemoteTerminalSurface(_ panelId: UUID) {
guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return }
activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count
maybeDemoteRemoteWorkspaceAfterSSHSessionEnded()
}
private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() {
guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return }
let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel }
if !hasBrowserPanels {
disconnectRemoteConnection(clearConfiguration: true)
}
}
func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) {
guard let relayPort,
relayPort > 0,
remoteConfiguration?.relayPort == relayPort else {
return
}
untrackRemoteTerminalSurface(surfaceId)
}
func teardownRemoteConnection() {
disconnectRemoteConnection(clearConfiguration: true)
}
@ -4681,16 +5018,21 @@ final class Workspace: Identifiable, ObservableObject {
guard let paneId = sourcePaneId else { return nil }
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
let remoteTerminalStartupCommand = remoteTerminalStartupCommand()
// Create the new terminal panel.
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig,
portOrdinal: portOrdinal
portOrdinal: portOrdinal,
initialCommand: remoteTerminalStartupCommand
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
if remoteTerminalStartupCommand != nil {
trackRemoteTerminalSurface(newPanel.id)
}
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit
@ -4716,6 +5058,9 @@ final class Workspace: Identifiable, ObservableObject {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
surfaceIdToPanelId.removeValue(forKey: newTab.id)
if remoteTerminalStartupCommand != nil {
untrackRemoteTerminalSurface(newPanel.id)
}
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
@ -4758,6 +5103,7 @@ final class Workspace: Identifiable, ObservableObject {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let inheritedConfig = inheritedTerminalConfig(inPane: paneId)
let remoteTerminalStartupCommand = remoteTerminalStartupCommand()
// Create new terminal panel
let newPanel = TerminalPanel(
@ -4766,10 +5112,14 @@ final class Workspace: Identifiable, ObservableObject {
configTemplate: inheritedConfig,
workingDirectory: workingDirectory,
portOrdinal: portOrdinal,
initialCommand: remoteTerminalStartupCommand,
additionalEnvironment: startupEnvironment
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
if remoteTerminalStartupCommand != nil {
trackRemoteTerminalSurface(newPanel.id)
}
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Create tab in bonsplit
@ -4783,6 +5133,9 @@ final class Workspace: Identifiable, ObservableObject {
) else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
if remoteTerminalStartupCommand != nil {
untrackRemoteTerminalSurface(newPanel.id)
}
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
@ -4801,6 +5154,12 @@ final class Workspace: Identifiable, ObservableObject {
return newPanel
}
private func remoteTerminalStartupCommand() -> String? {
guard hasActiveRemoteTerminalSessions else { return nil }
return remoteConfiguration?.terminalStartupCommand?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Create a new browser panel split
@discardableResult
func newBrowserSplit(
@ -5037,6 +5396,27 @@ final class Workspace: Identifiable, ObservableObject {
return markdownPanel
}
/// Tear down all panels in this workspace, freeing their Ghostty surfaces.
/// Called before the workspace is removed from TabManager to ensure child
/// processes receive SIGHUP even if ARC deallocation is delayed.
func teardownAllPanels() {
let panelEntries = Array(panels)
for (panelId, panel) in panelEntries {
panelSubscriptions.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
panel.close()
}
panels.removeAll(keepingCapacity: false)
surfaceIdToPanelId.removeAll(keepingCapacity: false)
panelSubscriptions.removeAll(keepingCapacity: false)
pruneSurfaceMetadata(validSurfaceIds: [])
restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false)
lastTerminalConfigInheritancePanelId = nil
lastTerminalConfigInheritanceFontPoints = nil
}
/// Close a panel.
/// Returns true when a bonsplit tab close request was issued.
func closePanel(_ panelId: UUID, force: Bool = false) -> Bool {
@ -7163,6 +7543,7 @@ extension Workspace: BonsplitDelegate {
}
panels.removeValue(forKey: panelId)
untrackRemoteTerminalSurface(panelId)
surfaceIdToPanelId.removeValue(forKey: tabId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
@ -7180,6 +7561,7 @@ extension Workspace: BonsplitDelegate {
if lastTerminalConfigInheritancePanelId == panelId {
lastTerminalConfigInheritancePanelId = nil
}
clearRemoteConfigurationIfWorkspaceBecameLocal()
// Keep the workspace invariant for normal close paths.
// Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can
@ -7309,6 +7691,7 @@ extension Workspace: BonsplitDelegate {
for panelId in closedPanelIds {
panels[panelId]?.close()
panels.removeValue(forKey: panelId)
untrackRemoteTerminalSurface(panelId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
@ -7326,6 +7709,7 @@ extension Workspace: BonsplitDelegate {
let closedSet = Set(closedPanelIds)
surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) }
recomputeListeningPorts()
clearRemoteConfigurationIfWorkspaceBecameLocal()
if let focusedPane = bonsplitController.focusedPaneId,
let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id {

View file

@ -3086,6 +3086,7 @@ struct SettingsView: View {
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
@AppStorage("sidebarShowSSH") private var sidebarShowSSH = true
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@ -3697,6 +3698,17 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"),
subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.")
) {
Toggle("", isOn: $sidebarShowSSH)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"),
subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.")
@ -4382,6 +4394,7 @@ struct SettingsView: View {
sidebarShowPullRequest = true
openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
sidebarShowSSH = true
sidebarShowPorts = true
sidebarShowLog = true
sidebarShowProgress = true

View file

@ -3,3 +3,4 @@
# Format: <ghostty_sha> <sha256>
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df