From f07dc8af87d1731b016e006fc63c648403e9c152 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Fri, 5 Jun 2026 21:52:25 +0300 Subject: [PATCH 01/73] Add remote tmux (-CC over SSH) mirroring (beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror a remote host's tmux server in cmux over `ssh -tt … tmux -CC` (iTerm2-style control mode), behind the `remoteTmux` beta flag. The whole control protocol is parsed in Swift (no dependency on ghostty's built-in viewer): sessions become sidebar workspaces, tmux windows become tabs, and a window's panes render as native cmux splits inside the tab. cmux actions propagate to tmux and remote changes flow back via the control stream: attach/create (new-session)/kill/rename/reorder sessions; create (new-window)/kill/rename/reorder windows; render + input + split-window / kill-pane / refresh-client sizing for panes. Closing the dedicated window or quitting cmux only detaches (the remote server stays alive for resume); closing a session/window/pane propagates the matching tmux kill. Entry points (beta-gated): a "Attach Remote tmux…" Command Palette command and File-menu item open a dedicated window mirroring that host. Includes a Settings beta toggle, en+ja localization, and parser unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitmodules | 2 +- .../Keys/BetaFeaturesCatalogSection.swift | 13 + .../CuratedSettingEntry+Default.swift | 1 + .../Sections/BetaFeaturesSection.swift | 27 +- Resources/Localizable.xcstrings | 306 ++++++++ Sources/AppDelegate+RemoteTmux.swift | 86 +++ Sources/AppDelegate.swift | 47 +- Sources/ContentView.swift | 36 +- Sources/GhosttyTerminalView.swift | 117 +++- Sources/RemoteTmuxCommandResult.swift | 16 + Sources/RemoteTmuxControlConnection.swift | 425 +++++++++++ Sources/RemoteTmuxControlMessage.swift | 52 ++ Sources/RemoteTmuxControlStreamParser.swift | 212 ++++++ Sources/RemoteTmuxController.swift | 660 ++++++++++++++++++ Sources/RemoteTmuxError.swift | 43 ++ Sources/RemoteTmuxHost.swift | 142 ++++ Sources/RemoteTmuxLayoutNode.swift | 102 +++ Sources/RemoteTmuxManualIOWrite.swift | 40 ++ Sources/RemoteTmuxRawLayoutParser.swift | 84 +++ Sources/RemoteTmuxSSHTransport.swift | 174 +++++ Sources/RemoteTmuxSession.swift | 31 + Sources/RemoteTmuxSessionListParser.swift | 51 ++ Sources/RemoteTmuxSessionMirror.swift | 233 +++++++ Sources/RemoteTmuxWindow.swift | 32 + Sources/RemoteTmuxWindowMirror.swift | 143 ++++ Sources/RemoteTmuxWindowMirrorView.swift | 206 ++++++ Sources/TabManager.swift | 21 +- Sources/TerminalController+RemoteTmux.swift | 218 ++++++ Sources/TerminalController.swift | 18 + Sources/Workspace.swift | 192 +++++ Sources/WorkspaceContentView.swift | 15 + Sources/cmuxApp.swift | 14 + cmux.xcodeproj/project.pbxproj | 84 +++ cmuxTests/RemoteTmuxControlParserTests.swift | 180 +++++ .../RemoteTmuxSessionListParserTests.swift | 55 ++ vendor/bonsplit | 2 +- 36 files changed, 4068 insertions(+), 12 deletions(-) create mode 100644 Sources/AppDelegate+RemoteTmux.swift create mode 100644 Sources/RemoteTmuxCommandResult.swift create mode 100644 Sources/RemoteTmuxControlConnection.swift create mode 100644 Sources/RemoteTmuxControlMessage.swift create mode 100644 Sources/RemoteTmuxControlStreamParser.swift create mode 100644 Sources/RemoteTmuxController.swift create mode 100644 Sources/RemoteTmuxError.swift create mode 100644 Sources/RemoteTmuxHost.swift create mode 100644 Sources/RemoteTmuxLayoutNode.swift create mode 100644 Sources/RemoteTmuxManualIOWrite.swift create mode 100644 Sources/RemoteTmuxRawLayoutParser.swift create mode 100644 Sources/RemoteTmuxSSHTransport.swift create mode 100644 Sources/RemoteTmuxSession.swift create mode 100644 Sources/RemoteTmuxSessionListParser.swift create mode 100644 Sources/RemoteTmuxSessionMirror.swift create mode 100644 Sources/RemoteTmuxWindow.swift create mode 100644 Sources/RemoteTmuxWindowMirror.swift create mode 100644 Sources/RemoteTmuxWindowMirrorView.swift create mode 100644 Sources/TerminalController+RemoteTmux.swift create mode 100644 cmuxTests/RemoteTmuxControlParserTests.swift create mode 100644 cmuxTests/RemoteTmuxSessionListParserTests.swift diff --git a/.gitmodules b/.gitmodules index 51853e856536..73dc3bd1c7df 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,4 +7,4 @@ url = https://github.com/manaflow-ai/homebrew-cmux.git [submodule "vendor/bonsplit"] path = vendor/bonsplit - url = https://github.com/manaflow-ai/bonsplit.git + url = https://github.com/robertnisipeanu/bonsplit.git diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift index d680c8c585e9..24bd9a82a3fd 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift @@ -47,5 +47,18 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection { userDefaultsKey: "customSidebars.beta.enabled" ) + /// Remote tmux: mirror a remote host's tmux sessions in the cmux sidebar + /// over `ssh … tmux -CC` (iTerm2-style control mode). Sessions appear as + /// sidebar workspaces, tmux windows as tabs, and tmux panes as splits; + /// create/close propagate to the remote, while quitting cmux leaves the + /// remote tmux server running for resume. Defaults off; while off, every + /// remote-tmux entry point and socket command is gated out so the local + /// terminal path is unaffected. + public let remoteTmux = DefaultsKey( + id: "remoteTmux.beta.enabled", + defaultValue: false, + userDefaultsKey: "remoteTmux.beta.enabled" + ) + public init() {} } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index f53a51aeab1d..ade6bdcb605c 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -100,6 +100,7 @@ extension Array where Element == CuratedSettingEntry { .init(section: .betaFeatures, id: "feed", title: "Feed", synonyms: "feed right sidebar agent decisions permissions questions approval beta unstable"), .init(section: .betaFeatures, id: "dock", title: "Dock", synonyms: "dock right sidebar terminal controls tui beta unstable"), .init(section: .betaFeatures, id: "customSidebars", title: "Custom Sidebars", synonyms: "custom sidebars swift json interpreted vibe beta unstable"), + .init(section: .betaFeatures, id: "remoteTmux", title: "Remote tmux", synonyms: "remote tmux ssh control mode -CC mirror session window pane sidebar workspace beta unstable"), // Automation .init(section: .automation, id: "socket-mode", title: "Socket Control Mode", synonyms: "automation.socketControlMode api socket unix domain control server auth allow password disabled"), diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift index 434e1e48bf75..fa2698fc40a2 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift @@ -2,21 +2,23 @@ import CmuxSettings import SwiftUI /// **Beta Features** section — a warning note followed by the -/// experimental toggles: `Feed`, `Dock`, `Extensions`, and -/// `Custom Sidebars`. Each toggle gates an unstable feature that is off -/// by default. +/// experimental toggles: `Feed`, `Dock`, `Extensions`, +/// `Custom Sidebars`, and `Remote tmux`. Each toggle gates an unstable +/// feature that is off by default. @MainActor public struct BetaFeaturesSection: View { @State private var feed: DefaultsValueModel @State private var dock: DefaultsValueModel @State private var extensions: DefaultsValueModel @State private var customSidebars: DefaultsValueModel + @State private var remoteTmux: DefaultsValueModel public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog) { _feed = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.rightSidebarFeed)) _dock = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.rightSidebarDock)) _extensions = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.extensions)) _customSidebars = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.customSidebars)) + _remoteTmux = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.remoteTmux)) } public var body: some View { @@ -34,6 +36,8 @@ public struct BetaFeaturesSection: View { extensionsRow SettingsCardDivider() customSidebarsRow + SettingsCardDivider() + remoteTmuxRow } } } @@ -105,6 +109,23 @@ public struct BetaFeaturesSection: View { .accessibilityIdentifier("SettingsBetaCustomSidebarsToggle") } } + + @ViewBuilder + private var remoteTmuxRow: some View { + SettingsCardRow( + configurationReview: .settingsOnly, + searchAnchorID: "setting:betaFeatures:remoteTmux", + String(localized: "settings.betaFeatures.remoteTmux", defaultValue: "Remote tmux"), + subtitle: remoteTmux.current + ? String(localized: "settings.betaFeatures.remoteTmux.subtitleOn", defaultValue: "Mirrors a remote host's tmux sessions in the sidebar over ssh tmux -CC; sessions become workspaces and windows become tabs. Quitting cmux leaves the remote tmux server running.") + : String(localized: "settings.betaFeatures.remoteTmux.subtitleOff", defaultValue: "Hides remote tmux mirroring until you enable it here.") + ) { + Toggle("", isOn: Binding(get: { remoteTmux.current }, set: { remoteTmux.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsBetaRemoteTmuxToggle") + } + } } /// Small warning callout with a yellow triangle, used at the top of diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 054bedafae30..1c4d52dae2a7 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -101128,6 +101128,312 @@ } } }, + "settings.betaFeatures.remoteTmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote tmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux" + } + } + } + }, + "settings.betaFeatures.remoteTmux.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hides remote tmux mirroring until you enable it here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここで有効にするまで、リモート tmux のミラーリングを表示しません。" + } + } + } + }, + "settings.betaFeatures.remoteTmux.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mirrors a remote host's tmux sessions in the sidebar over ssh tmux -CC; sessions become workspaces and windows become tabs. Quitting cmux leaves the remote tmux server running." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ssh tmux -CC でリモートホストの tmux セッションをサイドバーにミラーリングします。セッションはワークスペースに、ウィンドウはタブになります。cmux を終了してもリモートの tmux サーバーは動作し続けます。" + } + } + } + }, + "remoteTmux.tab.pane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "tmux pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "tmux ペイン" + } + } + } + }, + "remoteTmux.tab.window": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "tmux window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "tmux ウィンドウ" + } + } + } + }, + "remoteTmux.pane.splitRight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右に分割" + } + } + } + }, + "remoteTmux.pane.splitDown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に分割" + } + } + } + }, + "remoteTmux.pane.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインを閉じる" + } + } + } + }, + "sidebar.extensions.action.remoteTmuxWindowRequested": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote tmux window requested" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux ウィンドウを要求しました" + } + } + } + }, + "menu.file.attachRemoteTmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach Remote tmux…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux に接続…" + } + } + } + }, + "command.attachRemoteTmux.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach Remote tmux…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux に接続…" + } + } + } + }, + "command.attachRemoteTmux.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open a new window mirroring a remote server's tmux sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートサーバーの tmux セッションをミラーリングする新しいウィンドウを開きます" + } + } + } + }, + "remoteTmux.attach.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach Remote tmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux に接続" + } + } + } + }, + "remoteTmux.attach.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter an SSH destination (a ~/.ssh/config alias or user@host). cmux opens a new window mirroring that server's tmux sessions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH 接続先(~/.ssh/config のエイリアスまたは user@host)を入力してください。cmux はそのサーバーの tmux セッションをミラーリングする新しいウィンドウを開きます。" + } + } + } + }, + "remoteTmux.attach.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "user@host or ssh alias" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "user@host または ssh エイリアス" + } + } + } + }, + "remoteTmux.attach.confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続" + } + } + } + }, + "remoteTmux.attach.invalidDestination": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An SSH destination cannot start with “-”." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH 接続先は「-」で始めることはできません。" + } + } + } + }, + "remoteTmux.attach.failed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t attach to remote tmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux に接続できませんでした" + } + } + } + }, "settings.betaFeatures.dock": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+RemoteTmux.swift b/Sources/AppDelegate+RemoteTmux.swift new file mode 100644 index 000000000000..946d615b6a6d --- /dev/null +++ b/Sources/AppDelegate+RemoteTmux.swift @@ -0,0 +1,86 @@ +import AppKit + +/// User-facing entry points for attaching a remote tmux server (the beta +/// `remoteTmux` feature). The command palette and the menu bar item both funnel +/// through ``promptAttachRemoteTmuxHost(preferredWindow:)`` so the prompt and +/// attach flow live in one place. +extension AppDelegate { + /// Prompts for an SSH destination and, on confirm, opens a new cmux window + /// mirroring that server's tmux sessions 1:1 (see + /// ``RemoteTmuxController/mirrorHostInNewWindow(host:)``). + /// + /// No-ops (with a beep) when the `remoteTmux` beta flag is off. + @MainActor + func promptAttachRemoteTmuxHost(preferredWindow: NSWindow? = nil) { + guard RemoteTmuxController.isEnabled else { + NSSound.beep() + return + } + + let alert = NSAlert() + alert.messageText = String( + localized: "remoteTmux.attach.title", + defaultValue: "Attach Remote tmux" + ) + alert.informativeText = String( + localized: "remoteTmux.attach.message", + defaultValue: "Enter an SSH destination (a ~/.ssh/config alias or user@host). cmux opens a new window mirroring that server's tmux sessions." + ) + let input = NSTextField(string: "") + input.placeholderString = String( + localized: "remoteTmux.attach.placeholder", + defaultValue: "user@host or ssh alias" + ) + input.frame = NSRect(x: 0, y: 0, width: 280, height: 22) + alert.accessoryView = input + alert.window.initialFirstResponder = input + alert.addButton(withTitle: String( + localized: "remoteTmux.attach.confirm", + defaultValue: "Attach" + )) + alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) + + guard alert.runModal() == .alertFirstButtonReturn else { return } + + let destination = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !destination.isEmpty else { return } + // Reject a dash-prefixed destination — it is never a valid SSH + // alias/user@host and refusing it guards against ssh option injection. + guard !destination.hasPrefix("-") else { + presentRemoteTmuxAttachError( + destination: destination, + detail: String( + localized: "remoteTmux.attach.invalidDestination", + defaultValue: "An SSH destination cannot start with “-”." + ) + ) + return + } + + let host = RemoteTmuxHost(destination: destination) + Task { @MainActor in + do { + _ = try await self.remoteTmuxController.mirrorHostInNewWindow(host: host) + } catch { + self.presentRemoteTmuxAttachError( + destination: destination, + detail: String(describing: error) + ) + } + } + } + + /// Shows a warning alert when attaching to a remote tmux server fails. + @MainActor + private func presentRemoteTmuxAttachError(destination: String, detail: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "remoteTmux.attach.failed.title", + defaultValue: "Couldn’t attach to remote tmux" + ) + alert.informativeText = "\(destination): \(detail)" + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5243d4b0f089..e9b411c27fa0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -670,6 +670,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent nonisolated(unsafe) static var shared: AppDelegate? /// Stateless control-socket syscall layer (CmuxControlSocket); composition-root owned. nonisolated let socketTransport = SocketTransport() + /// Coordinates remote tmux (`ssh … tmux -CC`) mirroring; composition-root owned. + let remoteTmuxController = RemoteTmuxController() + /// One-shot guard so remote-tmux hosts are reconnected only once per launch. + private var didTriggerRemoteTmuxRestore = false private static let reloadConfigurationMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.cmux.reloadConfiguration") private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) @@ -1708,6 +1712,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillBecomeActive(_ notification: Notification) { if !hasVisibleMainTerminalWindow() { _ = mainWindowVisibilityController.orderFrontApplicationWindowsBeforeActivation(windows: mainWindowsForVisibilityController(), reason: .applicationWillBecomeActive) } } func applicationDidBecomeActive(_ notification: Notification) { + // One-shot per launch: reconnect to remote tmux hosts that were mirrored + // before quit and re-mirror their still-running sessions (remoteTmux beta). + if !didTriggerRemoteTmuxRestore { + didTriggerRemoteTmuxRestore = true + remoteTmuxController.restoreMirroredHostsOnLaunch() + } let activationWindows = mainWindowsForVisibilityController() if mainWindowVisibilityController.finishPendingApplicationActivationRestore(windows: activationWindows, reason: .applicationDidBecomeActive) == nil, !hasVisibleMainTerminalWindow() { _ = mainWindowVisibilityController.restoreApplicationWindowsAfterActivation(windows: activationWindows, reason: .applicationDidBecomeActive) @@ -1842,6 +1852,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillTerminate(_ notification: Notification) { StartupBreadcrumbLog.append("appDelegate.willTerminate.begin") isTerminatingApp = true + // Detach remote tmux control connections (kills the local ssh clients); + // the remote tmux server + sessions stay alive for resume next launch. + remoteTmuxController.detachAll() closeAllWebInspectorsBeforeAppTeardown() _ = saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) ClosedItemHistoryStore.shared.flushPendingSaves() @@ -4157,7 +4170,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func sortedMainWindowContextsForSessionSnapshot() -> [MainWindowContext] { - mainWindowContexts.values.sorted { lhs, rhs in + mainWindowContexts.values + // Exclude dedicated remote-tmux mirror windows: their workspaces are + // non-restorable, so snapshotting one yields an empty window that + // would restore as a leftover, duplicating the window that + // restoreMirroredHostsOnLaunch rebuilds. + .filter { !remoteTmuxController.isDedicatedRemoteWindow($0.windowId) } + .sorted { lhs, rhs in let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) let lhsIsKey = lhsWindow?.isKeyWindow ?? false @@ -6980,6 +6999,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let context = livePreferredContext ?? preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) + // In a dedicated remote-tmux window, a new workspace means "create a new + // tmux session on that host" — route it to the remote and mirror it into + // this window instead of creating a local workspace. + if let context, + remoteTmuxController.handleRemoteWindowNewWorkspaceRequested(windowId: context.windowId) { + return true + } + let workspaceGroupTarget = context.flatMap { workspaceGroupNewWorkspaceTarget(in: $0) } if let context, executeConfiguredNewWorkspaceActionIfAvailable( @@ -7510,8 +7537,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let workspace = context.tabManager.selectedWorkspace ?? context.tabManager.addWorkspace(select: shouldBringToFront, autoWelcomeIfNeeded: false) + // In a remote tmux mirror workspace, paste targets the existing focused + // pane. Do NOT fall back to creating a new surface there: that would + // route to a remote `new-window` (a surprising side effect) yet still + // have no local pane to deliver the text to. let terminalPanel = workspace.focusedTerminalPanel - ?? workspace.newTerminalSurfaceInFocusedPane(focus: shouldBringToFront) + ?? (workspace.isRemoteTmuxMirror ? nil : workspace.newTerminalSurfaceInFocusedPane(focus: shouldBringToFront)) guard let terminalPanel else { return false } #if DEBUG @@ -8099,6 +8130,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let controller = MainWindowController(window: window) controller.onClose = { [weak self, weak controller] in guard let self, let controller else { return } + // If this was a dedicated remote-tmux window, detach its host's + // control connections (the remote tmux server stays alive for resume; + // closing the window must not kill remote sessions). + self.remoteTmuxController.handleRemoteWindowClosed(windowId: windowId) + // Also detach any per-workspace mirrors in this window (covers the + // socket `remote.tmux.mirror` path into a non-dedicated window), so + // their pane surfaces / ssh connections don't leak on window close. + if let manager = self.tabManagerFor(windowId: windowId) { + self.remoteTmuxController.handleWindowWorkspacesClosed( + workspaceIds: manager.tabs.map { $0.id } + ) + } self.mainWindowControllers.removeAll(where: { $0 === controller }) } controller.shouldClose = { [weak self] in diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3ae43ffc6f86..b3b4f941522c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7225,6 +7225,17 @@ struct ContentView: View { panelSubtitle: panelSubtitle ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.attachRemoteTmux", + title: constant(String(localized: "command.attachRemoteTmux.title", defaultValue: "Attach Remote tmux…")), + subtitle: constant(String(localized: "command.attachRemoteTmux.subtitle", defaultValue: "Open a new window mirroring a remote server's tmux sessions")), + keywords: ["remote", "tmux", "ssh", "attach", "mirror", "session"], + dismissOnRun: true, + when: { _ in RemoteTmuxController.isEnabled } + ) + ) + contributions.append( CommandPaletteCommandContribution( commandId: "palette.renameTab", @@ -8187,6 +8198,11 @@ struct ContentView: View { } registerIdentifierCopyCommandHandlers(®istry) + registry.register(commandId: "palette.attachRemoteTmux") { + AppDelegate.shared?.promptAttachRemoteTmuxHost( + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) + } registry.register(commandId: "palette.renameTab") { beginRenameTabFlow() } @@ -11573,6 +11589,14 @@ struct VerticalTabsSidebar: View { tabManager.selectWorkspace(workspace) } let panel = workspace.newTerminalSurfaceInFocusedPane(focus: true, initialInput: nil) + if panel == nil, workspace.isRemoteTmuxMirror { + // Routed to the remote as a tmux `new-window`; the tab arrives + // asynchronously via the mirror, so this is success, not failure. + return CmuxSidebarActionResult( + accepted: true, + message: String(localized: "sidebar.extensions.action.remoteTmuxWindowRequested", defaultValue: "Remote tmux window requested") + ) + } return panel.map { CmuxSidebarActionResult(accepted: true, message: $0.id.uuidString) } ?? .rejected(String(localized: "sidebar.extensions.action.surfaceCreateRejected", defaultValue: "Surface could not be created")) @@ -14849,7 +14873,17 @@ private struct SidebarEmptyArea: View { .contentShape(Rectangle()) .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture(count: 2) { - tabManager.addWorkspace(placementOverride: .end) + // In a dedicated remote-tmux window, route through + // performNewWorkspaceAction so a new workspace becomes a new tmux + // session instead of a local (orphan) workspace. + if tabManager.tabs.contains(where: { $0.isRemoteTmuxMirror }) { + _ = AppDelegate.shared?.performNewWorkspaceAction( + tabManager: tabManager, + debugSource: "sidebar.emptyArea.remoteTmux" + ) + } else { + tabManager.addWorkspace(placementOverride: .end) + } if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3ab921d593ba..2635812e7474 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5338,6 +5338,20 @@ final class TerminalSurface: Identifiable, ObservableObject { var requestedWorkingDirectory: String? { workingDirectory } let focusPlacement: TerminalSurfaceFocusPlacement private var additionalEnvironment: [String: String] + /// When true, the surface is created in libghostty MANUAL I/O mode: no + /// process is spawned, output is injected via ``processRemoteOutput(_:)``, + /// and typed input is delivered to ``manualInputHandler``. Used for + /// remote-tmux pane display surfaces. + let manualIO: Bool + private let manualInputHandler: (@Sendable (Data) -> Void)? + /// Retained userdata for the MANUAL-mode `io_write_cb`; released alongside + /// the surface (see ``teardownSurface()``). + private var manualIOContext: Unmanaged? + /// Output delivered via ``processRemoteOutput(_:)`` before the runtime + /// surface exists (e.g. a background remote-tmux workspace not yet hosted). + /// Flushed into the surface once it is created so content isn't lost. + private var pendingRemoteOutput = Data() + private let maxPendingRemoteOutputBytes = 4 * 1_048_576 let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 @@ -5493,12 +5507,16 @@ final class TerminalSurface: Identifiable, ObservableObject { initialInput: String? = nil, initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:], - focusPlacement: TerminalSurfaceFocusPlacement = .workspace + focusPlacement: TerminalSurfaceFocusPlacement = .workspace, + manualIO: Bool = false, + manualInputHandler: (@Sendable (Data) -> Void)? = nil ) { #if DEBUG dispatchPrecondition(condition: .onQueue(.main)) #endif + self.manualIO = manualIO + self.manualInputHandler = manualInputHandler self.id = UUID() self.tabId = tabId self.surfaceContext = context @@ -5530,6 +5548,10 @@ final class TerminalSurface: Identifiable, ObservableObject { || trimmedInput != nil || inheritedCommand?.isEmpty == false || inheritedInput?.isEmpty == false + // MANUAL-I/O (remote-tmux display) surfaces have no command but must + // start eagerly so they can receive injected output even while their + // workspace is in the background (not yet hosted in a visible view). + || manualIO // Surfaces with startup work must spawn before the user focuses their workspace. // Ghostty's embedded surface creation still expects a view with a window, so use @@ -5773,6 +5795,21 @@ final class TerminalSurface: Identifiable, ObservableObject { ghostty_surface_mouse_scroll(surface, 0, deltaLines, 0) } + /// The current monospace cell size in points, or `nil` if the runtime + /// surface isn't ready. Used to derive a tmux client size from a cmux pixel + /// area (remote tmux mirror sizing). + @MainActor + func cellSizePoints() -> CGSize? { + guard let surface = liveSurfaceForGhosttyAccess(reason: "cellSize") else { return nil } + let size = ghostty_surface_size(surface) + guard size.cell_width_px > 0, size.cell_height_px > 0 else { return nil } + let scale = max(Double(lastXScale), 1) + return CGSize( + width: Double(size.cell_width_px) / scale, + height: Double(size.cell_height_px) / scale + ) + } + /// Forward a mobile tap to this real surface as a left mouse click at the /// given grid cell. libghostty does the mode-correct thing: a program with /// mouse reporting (alt-screen TUIs like lazygit/htop/fzf) gets an encoded @@ -6002,6 +6039,10 @@ final class TerminalSurface: Identifiable, ObservableObject { let teeContext = mobileByteTeeContext mobileByteTeeContext = nil MobileTerminalByteTee.shared.dropSurface(surfaceID: id) + // Release the MANUAL-I/O write box: input only fires this callback while + // the surface is focused, which a surface being torn down is not. + manualIOContext?.release() + manualIOContext = nil let surfaceToFree = surface if let surfaceToFree { @@ -6060,6 +6101,10 @@ final class TerminalSurface: Identifiable, ObservableObject { let teeContext = mobileByteTeeContext mobileByteTeeContext = nil MobileTerminalByteTee.shared.dropSurface(surfaceID: id) + // Release the MANUAL-I/O write box: input only fires this callback while + // the surface is focused, which a surface being torn down is not. + manualIOContext?.release() + manualIOContext = nil let surfaceToFree = surface if let surfaceToFree { @@ -6336,6 +6381,19 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer surfaceConfig.context = surfaceContext + if manualIO { + // MANUAL I/O: ghostty spawns no process; typed input is delivered to + // our callback (→ tmux send-keys) and output is injected via + // ghostty_surface_process_output (← tmux %output). + manualIOContext?.release() + let box = Unmanaged.passRetained( + RemoteTmuxManualIOWriteBox(onWrite: manualInputHandler ?? { _ in }) + ) + manualIOContext = box + surfaceConfig.io_mode = GHOSTTY_SURFACE_IO_MANUAL + surfaceConfig.io_write_cb = cmuxRemoteTmuxManualIOWriteCallback + surfaceConfig.io_write_userdata = box.toOpaque() + } #if DEBUG let templateFontText = String(format: "%.2f", surfaceConfig.font_size) cmuxDebugLog( @@ -6616,6 +6674,8 @@ final class TerminalSurface: Identifiable, ObservableObject { guard let createdSurface = surface else { return } TerminalSurfaceRegistry.shared.registerRuntimeSurface(createdSurface, ownerId: id) recordRuntimeSurfaceCreation() + // Flush any remote-tmux output that arrived before the surface existed. + flushPendingRemoteOutput(to: createdSurface) // Install the PTY tee so MobileTerminalByteTee receives every byte // the read thread produces, in order, before the VT parser runs. // Paired iPhones consume these bytes via `terminal.bytes` events @@ -7506,6 +7566,40 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + /// Injects raw terminal output bytes into this surface's VT parser, as if + /// they came from a PTY. Used by MANUAL-I/O remote-tmux display surfaces to + /// render a remote pane's `%output`. If the runtime surface doesn't exist + /// yet (e.g. a background workspace not yet hosted) the bytes are buffered + /// and flushed on creation. Must be called on the main thread. + func processRemoteOutput(_ data: Data) { + guard !data.isEmpty else { return } + guard let surface, cmuxSurfacePointerAppearsLive(surface) else { + // Keep the NEWEST bytes (a terminal's recent output is what matters): + // append, then trim from the front if over the cap. + pendingRemoteOutput.append(data) + if pendingRemoteOutput.count > maxPendingRemoteOutputBytes { + pendingRemoteOutput.removeFirst(pendingRemoteOutput.count - maxPendingRemoteOutputBytes) + } + return + } + flushPendingRemoteOutput(to: surface) + writeProcessOutput(data, to: surface) + } + + private func flushPendingRemoteOutput(to surface: ghostty_surface_t) { + guard !pendingRemoteOutput.isEmpty else { return } + let buffered = pendingRemoteOutput + pendingRemoteOutput = Data() + writeProcessOutput(buffered, to: surface) + } + + private func writeProcessOutput(_ data: Data, to surface: ghostty_surface_t) { + data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } + ghostty_surface_process_output(surface, baseAddress, UInt(rawBuffer.count)) + } + } + private static func readText( surface: ghostty_surface_t, pointTag: ghostty_point_tag_e @@ -7894,6 +7988,11 @@ final class TerminalSurface: Identifiable, ObservableObject { // mobileByteTeeContext, so teeContext is nil here and ?.release() no-ops. let teeContext = mobileByteTeeContext mobileByteTeeContext = nil + // Release the MANUAL-I/O write box here too — deinit may be the only + // teardown path for a surface never explicitly torn down, and input + // can't fire on a deallocating surface, so an immediate release is safe. + manualIOContext?.release() + manualIOContext = nil // `dropSurface` is @MainActor but `deinit` is nonisolated, so hop to the // main actor with the surface id captured by value (no self capture). // Dropping by id only clears the registry/replay state; releasing @@ -11418,8 +11517,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } private func canSplitCurrentSurface() -> Bool { + guard let surfaceId = terminalSurface?.id else { return false } + // Mirror panes aren't in workspace.panels but can still split (→ tmux). + if AppDelegate.shared?.remoteTmuxController.isMirrorPaneSurface(surfaceId) == true { + return true + } guard let tabId, - let surfaceId = terminalSurface?.id, let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == tabId }) else { @@ -11438,8 +11541,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { @discardableResult private func splitCurrentSurface(direction: SplitDirection) -> Bool { + guard let surfaceId = terminalSurface?.id else { return false } + // Remote tmux mirror pane: route the split to tmux `split-window` and let + // the resulting %layout-change rebuild the in-tab splits (one source of + // truth). Local splits are unaffected (this returns false for them). + if AppDelegate.shared?.remoteTmuxController.handleMirrorSplitRequested( + surfaceId: surfaceId, vertical: !direction.isHorizontal + ) == true { + return true + } guard let tabId, - let surfaceId = terminalSurface?.id, let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false diff --git a/Sources/RemoteTmuxCommandResult.swift b/Sources/RemoteTmuxCommandResult.swift new file mode 100644 index 000000000000..fb9dcb7b4168 --- /dev/null +++ b/Sources/RemoteTmuxCommandResult.swift @@ -0,0 +1,16 @@ +import Foundation + +/// The captured result of running a single command on a remote host over SSH. +struct RemoteTmuxCommandResult: Sendable, Equatable { + /// The process exit status. `0` is success. + let exitCode: Int32 + + /// Captured standard output, decoded as UTF-8. + let stdout: String + + /// Captured standard error, decoded as UTF-8. + let stderr: String + + /// Whether the command exited cleanly. + var succeeded: Bool { exitCode == 0 } +} diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift new file mode 100644 index 000000000000..c6c13566ba84 --- /dev/null +++ b/Sources/RemoteTmuxControlConnection.swift @@ -0,0 +1,425 @@ +import Foundation + +/// A live tmux control-mode connection to one remote session. +/// +/// Spawns `ssh -tt host tmux -CC attach -t ` as a +/// `Process` with pipes, feeds its stdout through ``RemoteTmuxControlStreamParser`` +/// (in order, via an `AsyncStream`), and exposes the mirrored topology plus a +/// live output callback. cmux owns the whole protocol here — so it never depends +/// on ghostty's (tmux-3.6-fragile) built-in Viewer, and there is no +/// command-queue desync because we issue and correlate commands ourselves. +@MainActor +final class RemoteTmuxControlConnection { + /// The host this connection talks to. + let host: RemoteTmuxHost + /// The tmux session name this connection attaches to. Mutable because a + /// `rename-session` changes it (the underlying `$id` is stable). + private(set) var sessionName: String + + /// Updates the tracked session name after a `rename-session`. + func setSessionName(_ name: String) { sessionName = name } + + /// Opaque token identifying a registered observer (pass to ``removeObserver(_:)``). + typealias ObserverToken = UUID + + // Multicast observer registries. A single connection is shared by every + // consumer of the same host+session (``RemoteTmuxController.attach`` reuses + // it), so events MUST fan out to all consumers — a single overwritable + // closure silently cut off whichever consumer wired up first. + private var paneOutputObservers: [ObserverToken: (_ paneId: Int, _ data: Data) -> Void] = [:] + private var topologyObservers: [ObserverToken: () -> Void] = [:] + private var exitObservers: [ObserverToken: () -> Void] = [:] + + // MARK: Observed state + + private(set) var started = false + private(set) var enterReceived = false + private(set) var exited = false + private(set) var sessionId: Int? + private(set) var windowsByID: [Int: RemoteTmuxWindow] = [:] + private(set) var windowOrder: [Int] = [] + private(set) var activePaneByWindow: [Int: Int] = [:] + private(set) var paneOutputByteCounts: [Int: Int] = [:] + private(set) var totalOutputBytes = 0 + private(set) var recentEvents: [String] = [] + + private var process: Process? + private var stdinHandle: FileHandle? + private var stdoutReader: FileHandle? + private var streamContinuation: AsyncStream.Continuation? + private var parser = RemoteTmuxControlStreamParser() + private var ingestTask: Task? + private var pendingCommands: [CommandKind] = [] + private let createIfMissing: Bool + private let maxRecentEvents = 100 + + private enum CommandKind: Equatable { case listWindows, capturePane(Int), paneCursor(Int), other } + + init(host: RemoteTmuxHost, sessionName: String, createIfMissing: Bool = false) { + self.host = host + self.sessionName = sessionName + self.createIfMissing = createIfMissing + } + + // MARK: - Observers + + /// Registers a consumer's callbacks and returns a token to deregister them. + /// + /// Multiple consumers (e.g. a mirrored workspace and a single-pane display + /// tab) can observe the same shared connection concurrently; every callback + /// fires for every event. Pass the returned token to ``removeObserver(_:)`` + /// when the consumer goes away. + /// + /// - Parameters: + /// - onPaneOutput: receives every `%output` (raw, octal-unescaped bytes). + /// - onTopologyChanged: fires when the window/pane topology changes. + /// - onExit: fires once when control mode ends. + @discardableResult + func addObserver( + onPaneOutput: ((_ paneId: Int, _ data: Data) -> Void)? = nil, + onTopologyChanged: (() -> Void)? = nil, + onExit: (() -> Void)? = nil + ) -> ObserverToken { + let token = ObserverToken() + if let onPaneOutput { paneOutputObservers[token] = onPaneOutput } + if let onTopologyChanged { topologyObservers[token] = onTopologyChanged } + if let onExit { exitObservers[token] = onExit } + return token + } + + /// Deregisters the callbacks registered under `token`. + func removeObserver(_ token: ObserverToken) { + paneOutputObservers[token] = nil + topologyObservers[token] = nil + exitObservers[token] = nil + } + + private func emitPaneOutput(_ paneId: Int, _ data: Data) { + for callback in paneOutputObservers.values { callback(paneId, data) } + } + + private func notifyTopologyChanged() { + for callback in topologyObservers.values { callback() } + } + + private func notifyExit() { + for callback in exitObservers.values { callback() } + } + + /// Spawns the SSH `tmux -CC` process and begins streaming. + func start() throws { + guard !started else { return } + try host.ensureControlSocketDirectory() + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + proc.arguments = host.controlModeArguments( + sessionName: sessionName, + createIfMissing: createIfMissing + ) + let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe() + proc.standardInput = inPipe + proc.standardOutput = outPipe + proc.standardError = errPipe + stdinHandle = inPipe.fileHandleForWriting + + let (stream, continuation) = AsyncStream.makeStream() + let reader = outPipe.fileHandleForReading + reader.readabilityHandler = { handle in + let chunk = handle.availableData + if chunk.isEmpty { + handle.readabilityHandler = nil + continuation.finish() + } else { + continuation.yield(chunk) + } + } + proc.terminationHandler = { _ in continuation.finish() } + + do { + try proc.run() + } catch { + // Don't latch `started` on a failed launch, so a later attach can + // replace this connection instead of reusing a dead one. Close the + // stdin handle too, so the connection is left in a clean, retry-safe + // state instead of holding a dead pipe that silently EPIPEs on write. + reader.readabilityHandler = nil + continuation.finish() + try? stdinHandle?.close() + stdinHandle = nil + throw error + } + started = true + process = proc + stdoutReader = reader + streamContinuation = continuation + ingestTask = Task { [weak self] in + for await chunk in stream { + self?.ingest(chunk) + } + self?.handleStreamEnd() + } + } + + /// Sends a tmux command on the control stream (newline-terminated). + func send(_ command: String) { + sendInternal(command, kind: .other) + } + + /// Requests the current window list + layouts (used to (re)build topology). + /// + /// `#{window_name}` is placed last because it can contain spaces, while the + /// id and layout tokens never do — so the result parses as + /// `@id `. + func requestWindows() { + sendInternal( + "list-windows -F \"#{window_id} #{window_layout} #{window_name}\"", + kind: .listWindows + ) + } + + /// Captures a pane's current visible contents (with escapes) and delivers + /// them to the pane-output observers so a freshly-mounted display surface shows + /// the existing screen instead of starting blank. + func capturePane(paneId: Int) { + sendInternal("capture-pane -p -e -t %\(paneId)", kind: .capturePane(paneId)) + // Follow with the real cursor position so the surface cursor lines up + // with tmux's prompt (capture-pane alone doesn't carry the cursor). + sendInternal( + "display-message -p -t %\(paneId) -F \"#{cursor_x},#{cursor_y}\"", + kind: .paneCursor(paneId) + ) + } + + /// Sends literal key bytes to a pane via tmux `send-keys -H` (hex-encoded), + /// which is binary-safe and needs no shell quoting. + func sendKeys(paneId: Int, data: Data) { + guard !data.isEmpty else { return } + let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ") + sendInternal("send-keys -t %\(paneId) -H \(hex)", kind: .other) + } + + /// Detaches: terminating ssh kills the control client but leaves the remote + /// tmux session alive for resume. + func stop() { + // Mark exited FIRST so the deliberate teardown does not fire `onExit`: + // finishing the stream makes the ingest task run `handleStreamEnd`, whose + // `guard !exited` then short-circuits. Only a genuine remote end (a real + // `%exit`, an unexpected stream EOF, or a broken-pipe write) notifies exit + // observers — so detach/quit/window-close (preserve) never trigger the + // "session ended remotely" cleanup. + exited = true + ingestTask?.cancel() + ingestTask = nil + process?.terminationHandler = nil + // Tear down the stdout reader deterministically rather than waiting for + // EOF (the ingest consumer is already cancelled). + stdoutReader?.readabilityHandler = nil + stdoutReader = nil + streamContinuation?.finish() + streamContinuation = nil + try? stdinHandle?.close() + stdinHandle = nil + process?.terminate() + process = nil + } + + // MARK: - Internals + + private func sendInternal(_ command: String, kind: CommandKind) { + guard let stdinHandle else { return } + let line = command.hasSuffix("\n") ? command : command + "\n" + guard let data = line.data(using: .utf8) else { return } + do { + try stdinHandle.write(contentsOf: data) + } catch { + // The control pipe is dead (broken pipe). Crucially, do NOT enqueue + // a pending command for a write that never reached tmux: the + // %begin/%end correlation FIFO is positional, so one phantom entry + // permanently misaligns every subsequent command result. Tear the + // connection down instead and let observers reconnect. + record("stdin-write-failed") + handleWriteFailure() + return + } + // Record only after the bytes are confirmed written, so the pending + // FIFO stays in lock-step with what tmux actually received. + pendingCommands.append(kind) + } + + private func handleWriteFailure() { + guard !exited else { return } + exited = true + stop() + notifyExit() + } + + private func ingest(_ data: Data) { + for message in parser.feed(data) { + handle(message) + } + } + + private func handleStreamEnd() { + guard !exited else { return } + exited = true + record("stream-end") + notifyExit() + } + + private func handle(_ message: RemoteTmuxControlMessage) { + switch message { + case .enter: + enterReceived = true + record("enter") + case let .exit(reason): + exited = true + record("exit\(reason.map { " " + $0 } ?? "")") + notifyExit() + case let .output(paneId, data): + paneOutputByteCounts[paneId, default: 0] += data.count + totalOutputBytes += data.count + emitPaneOutput(paneId, data) + case let .sessionChanged(id, _): + sessionId = id + record("session-changed $\(id)") + requestWindows() + case .sessionsChanged: + record("sessions-changed") + case let .windowAdd(id): + record("window-add @\(id)") + requestWindows() + case let .windowClose(id): + // Release the closed window's per-pane/per-window diagnostic state so + // it doesn't accumulate across window churn. + if let closing = windowsByID[id] { + for pane in closing.paneIDsInOrder { paneOutputByteCounts[pane] = nil } + } + activePaneByWindow[id] = nil + windowsByID[id] = nil + windowOrder.removeAll { $0 == id } + record("window-close @\(id)") + notifyTopologyChanged() + case let .windowRenamed(id, name): + record("window-renamed @\(id)") + // Propagate the new name into the topology so the mirrored tab title + // refreshes. Keep the existing geometry/layout. + if let existing = windowsByID[id], existing.name != name { + windowsByID[id] = RemoteTmuxWindow( + id: id, name: name, + width: existing.width, height: existing.height, layout: existing.layout + ) + notifyTopologyChanged() + } + case let .layoutChange(id, layout): + applyLayout(windowId: id, layout: layout) + record("layout-change @\(id)") + notifyTopologyChanged() + case let .windowPaneChanged(windowId, paneId): + activePaneByWindow[windowId] = paneId + case let .sessionWindowChanged(_, windowId): + record("session-window-changed @\(windowId)") + case let .commandResult(_, lines, isError): + handleCommandResult(lines: lines, isError: isError) + case .ignoredNotification, .unparsed: + break + } + } + + private func handleCommandResult(lines: [String], isError: Bool) { + // The attach command's own block arrives before we queue anything; only + // correlate results once we have an outstanding command. + guard !pendingCommands.isEmpty else { return } + let kind = pendingCommands.removeFirst() + guard !isError else { return } + switch kind { + case .listWindows: + var order: [Int] = [] + for line in lines { + // "@ " — id and layout never + // contain spaces, so split into at most 3 fields. + let parts = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: false) + guard parts.count >= 2, + let id = RemoteTmuxControlStreamParser.id(parts[0], sigil: "@"), + let node = RemoteTmuxRawLayoutParser.parse(String(parts[1])) + else { continue } + let name = parts.count >= 3 ? String(parts[2]) : "" + windowsByID[id] = RemoteTmuxWindow( + id: id, name: name, width: node.width, height: node.height, layout: node + ) + order.append(id) + } + if !order.isEmpty { + windowOrder = order + notifyTopologyChanged() + } + case let .capturePane(paneId): + // capture-pane -e output is the pane's visible rows (with SGR + // escapes). Home + clear, paint the rows, and leave the cursor at + // the END of the last row (no trailing newline) so it lines up with + // tmux's real prompt cursor — otherwise echoed input lands a line + // below the prompt. + let painted = "\u{1b}[H\u{1b}[2J" + lines.joined(separator: "\r\n") + if let data = painted.data(using: .utf8) { + emitPaneOutput(paneId, data) + } + case let .paneCursor(paneId): + // "x,y" (0-based) → absolute cursor-position escape so the surface + // cursor matches tmux's, even though capture-pane trims trailing + // spaces from the painted prompt line. + if let line = lines.first { + let parts = line.split(separator: ",") + if parts.count == 2, let x = Int(parts[0]), let y = Int(parts[1]), + let data = "\u{1b}[\(y + 1);\(x + 1)H".data(using: .utf8) { + emitPaneOutput(paneId, data) + } + } + case .other: + break + } + } + + private func applyLayout(windowId: Int, layout: String) { + guard let node = RemoteTmuxRawLayoutParser.parse(layout) else { return } + // Preserve any name tmux already reported (a %layout-change carries no name). + let existingName = windowsByID[windowId]?.name ?? "" + windowsByID[windowId] = RemoteTmuxWindow( + id: windowId, name: existingName, width: node.width, height: node.height, layout: node + ) + if !windowOrder.contains(windowId) { windowOrder.append(windowId) } + } + + private func record(_ event: String) { + recentEvents.append(event) + if recentEvents.count > maxRecentEvents { + recentEvents.removeFirst(recentEvents.count - maxRecentEvents) + } + } + + /// An immutable, `Sendable` snapshot for diagnostics (`remote.tmux.state`). + func snapshot() -> Snapshot { + Snapshot( + started: started, + enterReceived: enterReceived, + exited: exited, + sessionId: sessionId, + windowCount: windowsByID.count, + windowIDs: windowOrder, + paneOutputByteCounts: paneOutputByteCounts, + totalOutputBytes: totalOutputBytes, + recentEvents: recentEvents + ) + } + + struct Snapshot: Sendable { + let started: Bool + let enterReceived: Bool + let exited: Bool + let sessionId: Int? + let windowCount: Int + let windowIDs: [Int] + let paneOutputByteCounts: [Int: Int] + let totalOutputBytes: Int + let recentEvents: [String] + } +} diff --git a/Sources/RemoteTmuxControlMessage.swift b/Sources/RemoteTmuxControlMessage.swift new file mode 100644 index 000000000000..ec9a7dce9271 --- /dev/null +++ b/Sources/RemoteTmuxControlMessage.swift @@ -0,0 +1,52 @@ +import Foundation + +/// A single parsed message from a remote tmux control-mode (`tmux -CC`) stream. +/// +/// Produced by ``RemoteTmuxControlStreamParser``. Command responses (the output +/// between a `%begin`/`%end` pair) are coalesced into a single ``commandResult`` +/// carrying the lines in between; everything else is an out-of-band notification. +enum RemoteTmuxControlMessage: Sendable, Equatable { + /// The `ESC P 1000 p` handshake that opens control mode. + case enter + + /// Control mode ended (`%exit`), with tmux's optional reason. + case exit(reason: String?) + + /// `%output % ` — terminal output for a pane. `data` is already + /// octal-unescaped to its raw bytes, ready to feed into a display surface. + case output(paneId: Int, data: Data) + + /// `%session-changed $ ` — the attached session changed. + case sessionChanged(sessionId: Int, name: String) + + /// `%sessions-changed` — the set of sessions changed (re-list to refresh). + case sessionsChanged + + /// `%window-add @` — a window was added to the attached session. + case windowAdd(windowId: Int) + + /// `%window-close @` / `%unlinked-window-close @` — a window closed. + case windowClose(windowId: Int) + + /// `%window-renamed @ ` — a window was renamed. + case windowRenamed(windowId: Int, name: String) + + /// `%layout-change @ …` — a window's pane layout changed. + /// `layout` is the raw tmux layout string (parse with ``RemoteTmuxRawLayoutParser``). + case layoutChange(windowId: Int, layout: String) + + /// `%window-pane-changed @ %` — the active pane in a window changed. + case windowPaneChanged(windowId: Int, paneId: Int) + + /// `%session-window-changed $ @` — the active window in a session changed. + case sessionWindowChanged(sessionId: Int, windowId: Int) + + /// The coalesced output of one command block (`%begin`…`%end`/`%error`). + case commandResult(commandNumber: Int, lines: [String], isError: Bool) + + /// A recognized notification cmux does not act on (kept for diagnostics). + case ignoredNotification(String) + + /// A line that could not be classified. + case unparsed(String) +} diff --git a/Sources/RemoteTmuxControlStreamParser.swift b/Sources/RemoteTmuxControlStreamParser.swift new file mode 100644 index 000000000000..f52215145108 --- /dev/null +++ b/Sources/RemoteTmuxControlStreamParser.swift @@ -0,0 +1,212 @@ +import Foundation + +/// Incremental parser for a tmux control-mode (`tmux -CC`) byte stream. +/// +/// Feed raw bytes as they arrive from the SSH process; the parser buffers +/// partial lines, strips the `ESC P 1000 p` / `ESC \` DCS framing and the +/// `\r` that the SSH `-tt` pty adds, coalesces `%begin`…`%end` command blocks, +/// and emits structured ``RemoteTmuxControlMessage`` values. +/// +/// The protocol is line-oriented printable ASCII (tmux octal-escapes every +/// non-printable byte in `%output`), so line-based parsing is lossless. +struct RemoteTmuxControlStreamParser { + private var buffer: [UInt8] = [] + private var inBlock = false + private var blockNumber = 0 + private var blockLines: [String] = [] + + /// The DCS sequence tmux emits to enter control mode: `ESC P 1000 p`. + private static let enterSequence: [UInt8] = [0x1b, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70] + + /// Feeds a chunk of stream bytes and returns any newly completed messages. + mutating func feed(_ data: Data) -> [RemoteTmuxControlMessage] { + var messages: [RemoteTmuxControlMessage] = [] + buffer.append(contentsOf: data) + while let newlineIndex = buffer.firstIndex(of: 0x0a) { + var lineBytes = Array(buffer[.. [RemoteTmuxControlMessage] { + var bytes = rawBytes + var prefixMessages: [RemoteTmuxControlMessage] = [] + + // Strip a leading enter DCS (it is prepended to the first %begin line). + if bytes.starts(with: Self.enterSequence) { + prefixMessages.append(.enter) + bytes.removeFirst(Self.enterSequence.count) + } + // Drop ST (ESC \) DCS-teardown framing — but ONLY on notification lines. + // Command-block content (e.g. `capture-pane -e` output) is raw terminal + // bytes that can legitimately contain ESC `\` (an OSC String Terminator), + // and stripping those would corrupt the painted pane. tmux frames the + // block, so block content is never DCS-framed. + if !inBlock { + bytes = Self.removingST(bytes) + } + if bytes.isEmpty { return prefixMessages } + + let line = String(decoding: bytes, as: UTF8.self) + + if inBlock { + // Only a %end/%error whose command number matches this block's + // %begin terminates it. tmux does NOT escape command output inside a + // block, so a captured pane line like "%end 1 0 0" must be treated + // as content, not a terminator (otherwise the block truncates and + // the command-correlation FIFO desyncs permanently). + if (line.hasPrefix("%end ") || line.hasPrefix("%error ")), + Self.field(line, 2).flatMap({ Int($0) }) == blockNumber { + let isError = line.hasPrefix("%error ") + let result = RemoteTmuxControlMessage.commandResult( + commandNumber: blockNumber, lines: blockLines, isError: isError + ) + inBlock = false + blockLines = [] + return prefixMessages + [result] + } + blockLines.append(line) + return prefixMessages + } + + if line.hasPrefix("%begin ") { + blockNumber = Self.field(line, 2).flatMap { Int($0) } ?? 0 + inBlock = true + blockLines = [] + return prefixMessages + } + + return prefixMessages + [parseNotification(line)] + } + + private func parseNotification(_ line: String) -> RemoteTmuxControlMessage { + if line == "%exit" || line.hasPrefix("%exit ") { + let reason = line == "%exit" ? nil : String(line.dropFirst("%exit ".count)) + return .exit(reason: reason) + } + if line.hasPrefix("%output ") { + // %output % + let rest = line.dropFirst("%output ".count) + guard let space = rest.firstIndex(of: " ") else { return .unparsed(line) } + let paneToken = rest[.. String? { + let parts = line.split(separator: " ", omittingEmptySubsequences: false) + guard index < parts.count else { return nil } + return String(parts[index]) + } + + /// All fields from `index` onward, rejoined with spaces (for names that may contain spaces). + private static func fieldsFrom(_ line: String, _ index: Int) -> String { + let parts = line.split(separator: " ", omittingEmptySubsequences: false) + guard index < parts.count else { return "" } + return parts[index...].joined(separator: " ") + } + + /// Parses the field at `index` as a sigil-prefixed tmux id (`$`/`@`/`%`). + private static func fieldId(_ line: String, _ index: Int, sigil: Character) -> Int? { + guard let token = field(line, index) else { return nil } + return id(Substring(token), sigil: sigil) + } + + /// Parses a sigil-prefixed tmux id token, e.g. `@4` → 4, `%8` → 8, `$2` → 2. + static func id(_ token: Substring, sigil: Character) -> Int? { + guard token.first == sigil else { return nil } + return Int(token.dropFirst()) + } + + /// Removes any `ESC \` (ST) sequences from a line's bytes. + private static func removingST(_ bytes: [UInt8]) -> [UInt8] { + guard bytes.contains(0x1b) else { return bytes } + var out: [UInt8] = [] + out.reserveCapacity(bytes.count) + var i = 0 + while i < bytes.count { + if bytes[i] == 0x1b, i + 1 < bytes.count, bytes[i + 1] == 0x5c { + i += 2 + continue + } + out.append(bytes[i]) + i += 1 + } + return out + } + + /// Octal-unescapes a `%output` data field (`\ooo` → byte) into raw bytes. + static func unescapeOutput(_ field: Substring) -> Data { + let bytes = Array(field.utf8) + var out = Data() + out.reserveCapacity(bytes.count) + var i = 0 + func isOctal(_ b: UInt8) -> Bool { b >= 0x30 && b <= 0x37 } + while i < bytes.count { + if bytes[i] == 0x5c, // backslash + i + 3 < bytes.count, + isOctal(bytes[i + 1]), isOctal(bytes[i + 2]), isOctal(bytes[i + 3]) { + // Compute in Int to avoid a UInt8 overflow trap on malformed + // escapes like \777; emit literally if out of byte range. + let value = Int(bytes[i + 1] - 0x30) * 64 + + Int(bytes[i + 2] - 0x30) * 8 + + Int(bytes[i + 3] - 0x30) + if value <= 0xFF { + out.append(UInt8(value)) + i += 4 + } else { + out.append(bytes[i]) + i += 1 + } + } else { + out.append(bytes[i]) + i += 1 + } + } + return out + } +} diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift new file mode 100644 index 000000000000..d7ffa26c9232 --- /dev/null +++ b/Sources/RemoteTmuxController.swift @@ -0,0 +1,660 @@ +import Foundation +import CmuxSettings + +/// Coordinates cmux's mirroring of remote tmux servers. +/// +/// Owns one ``RemoteTmuxSSHTransport`` per host (keyed by SSH destination) and +/// is the entry point the socket/CLI layer and (later) the UI call into. It is +/// `@MainActor` because it will own sidebar/workspace state as the feature +/// grows; today it performs discovery by delegating to the per-host transport +/// actor. +/// +/// Constructed once and held by `AppDelegate` (no global singleton), so it can +/// be reached from the v2 socket dispatcher via `AppDelegate.shared`. +@MainActor +final class RemoteTmuxController { + private var transports: [String: RemoteTmuxSSHTransport] = [:] + + /// Live `tmux -CC` control connections keyed by `destination\u{1}session`, + /// so repeated attach requests reuse the existing connection. + private var connectionsByHostSession: [String: RemoteTmuxControlConnection] = [:] + + init() {} + + /// Synchronous read of the `remoteTmux` beta flag for AppKit/socket paths + /// that run outside the SwiftUI update cycle. Resolves the same catalog key + /// the settings store persists to, so the catalog stays the single source + /// of the key, decode, and default. SwiftUI binds via + /// `@LiveSetting(\.betaFeatures.remoteTmux)`. + nonisolated static var isEnabled: Bool { + let key = SettingCatalog().betaFeatures.remoteTmux + return Bool.decodeFromUserDefaults(UserDefaults.standard.object(forKey: key.userDefaultsKey)) ?? key.defaultValue + } + + /// Returns (creating if needed) the transport for a host. + func transport(for host: RemoteTmuxHost) -> RemoteTmuxSSHTransport { + if let existing = transports[host.destination] { + return existing + } + let transport = RemoteTmuxSSHTransport(host: host) + transports[host.destination] = transport + return transport + } + + /// Discovers the tmux sessions on a host. + func listSessions(host: RemoteTmuxHost) async throws -> [RemoteTmuxSession] { + try await transport(for: host).listSessions() + } + + /// Tears down a host's shared SSH master (used when removing a host). + func disconnect(host: RemoteTmuxHost) async { + let transport = transports.removeValue(forKey: host.destination) + await transport?.shutdownMaster() + } + + // MARK: - Control connections (tmux -CC mirroring) + + /// Attaches a `tmux -CC` control connection to `sessionName` on `host`, + /// reusing an existing live connection for the same host+session. + @discardableResult + func attach( + host: RemoteTmuxHost, + sessionName: String, + createIfMissing: Bool = false + ) throws -> RemoteTmuxControlConnection { + let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + if let existing = connectionsByHostSession[key] { + if !existing.exited { return existing } + // Replace a dead connection — fully tear down the old one first so + // its ssh process, stdin fd, stream continuation and ingest task + // don't leak. + existing.stop() + connectionsByHostSession.removeValue(forKey: key) + } + let connection = RemoteTmuxControlConnection( + host: host, + sessionName: sessionName, + createIfMissing: createIfMissing + ) + // Insert only after a successful launch, so a failed `start()` never + // leaves a dead (never-started, `exited == false`) connection that a + // later attach would wrongly reuse. + try connection.start() + connectionsByHostSession[key] = connection + return connection + } + + // MARK: - Sidebar mirroring (P3, initial increment) + + /// Display panels mirroring a remote pane, keyed `dest\u{1}session\u{1}pane`. + private var displayPanels: [String: TerminalPanel] = [:] + + /// Observer tokens for the single-pane display path, keyed by connection key. + private var displayObserverTokens: [String: RemoteTmuxControlConnection.ObserverToken] = [:] + + /// Attaches a session and mirrors its active window's first pane as a live + /// display tab in a workspace. The tab renders the remote pane's output and + /// forwards keystrokes back to it. + /// + /// This is the "attach a single remote pane into a cmux tab" path; full + /// session→workspace / window→tab mirroring is ``mirrorSession(host:sessionName:)``. + /// + /// - Parameters: + /// - host: the remote SSH destination. + /// - sessionName: the tmux session to attach to. + /// - focus: when `true`, selects and focuses the created tab (user-initiated + /// attach). Socket/background callers pass `false` so they never steal the + /// user's keyboard focus, per the socket focus policy. + func openActivePane(host: RemoteTmuxHost, sessionName: String, focus: Bool = false) throws { + let connection = try attach(host: host, sessionName: sessionName) + let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + // Capture the target workspace at command time. Topology often arrives + // asynchronously (after the first %layout-change), so resolving the + // workspace inside the callback would build the tab in whichever + // workspace happens to be selected when topology lands. + guard let targetWorkspaceId = AppDelegate.shared?.tabManager?.selectedWorkspace?.id else { + throw RemoteTmuxError.unreachable("no active workspace") + } + // Register an observer (don't overwrite a single closure): the connection + // is shared with any concurrent mirror of the same session. + if displayObserverTokens[key] == nil { + displayObserverTokens[key] = connection.addObserver( + onPaneOutput: { [weak self] paneId, data in + self?.displayPanels["\(key)\u{1}\(paneId)"]?.surface.processRemoteOutput(data) + }, + onTopologyChanged: { [weak self, weak connection] in + guard let self, let connection else { return } + self.buildDisplayIfNeeded(connection: connection, key: key, workspaceId: targetWorkspaceId, focus: focus) + } + ) + } + buildDisplayIfNeeded(connection: connection, key: key, workspaceId: targetWorkspaceId, focus: focus) + } + + private func buildDisplayIfNeeded( + connection: RemoteTmuxControlConnection, + key: String, + workspaceId: UUID, + focus: Bool + ) { + guard let firstWindowId = connection.windowOrder.first, + let window = connection.windowsByID[firstWindowId], + let paneId = window.paneIDsInOrder.first else { return } + let panelKey = "\(key)\u{1}\(paneId)" + guard displayPanels[panelKey] == nil else { return } + guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == workspaceId }) + else { return } + guard let panel = workspace.addRemoteTmuxDisplayPane( + remotePaneId: paneId, + focus: focus, + onInput: { [weak connection] data in + Task { @MainActor in connection?.sendKeys(paneId: paneId, data: data) } + } + ) else { return } + displayPanels[panelKey] = panel + // Prime the pane with its current contents so it isn't blank on open. + connection.capturePane(paneId: paneId) + } + + /// Active session→workspace mirrors keyed `dest\u{1}session`. + private var sessionMirrors: [String: RemoteTmuxSessionMirror] = [:] + + /// SSH destination → the dedicated cmux window mirroring that host (Option 1). + private var windowIdByHost: [String: UUID] = [:] + /// Reverse map: cmux window id → the host it mirrors (for window-close detach). + private var hostByWindowId: [UUID: String] = [:] + /// Destinations with an in-flight ``mirrorHostInNewWindow(host:activateWindow:)``, + /// so a re-entrant call across the `await` gap can't open a second window. + private var pendingHostAttaches: Set = [] + + /// Returns `true` if `windowId` is a dedicated remote-tmux mirror window. + /// Used by the session-snapshot path to exclude these windows (they are + /// rebuilt by ``restoreMirroredHostsOnLaunch()``, not the generic restore). + func isDedicatedRemoteWindow(_ windowId: UUID) -> Bool { + hostByWindowId[windowId] != nil + } + + /// Opens a NEW cmux window dedicated to `host` and mirrors every tmux session + /// on it 1:1 (each session a workspace, each window a tab). This keeps remote + /// work in its own window so the user's local windows are untouched. + /// + /// Closing that window only *detaches* (the remote tmux server stays alive + /// for resume); closing an individual session workspace kills that session. + /// Reuses (and focuses) the existing dedicated window if one is already open + /// for the host. + /// + /// - Parameters: + /// - host: the remote SSH destination. + /// - activateWindow: when `true` (user-initiated attach), the new window is + /// activated/focused. Restore-on-launch passes `false` so a relaunch + /// doesn't steal focus. + /// - Returns: the cmux window id hosting the mirror. + /// - Throws: ``RemoteTmuxError`` if the host is unreachable or has no tmux + /// sessions (no empty dedicated window is created in that case). + @discardableResult + func mirrorHostInNewWindow(host: RemoteTmuxHost, activateWindow: Bool = true) async throws -> UUID { + guard let appDelegate = AppDelegate.shared else { + throw RemoteTmuxError.unreachable("app not ready") + } + // Reuse the dedicated window if this host is already mirrored. + if let existing = windowIdByHost[host.destination], + let window = appDelegate.windowForMainWindowId(existing) { + if activateWindow { window.makeKeyAndOrderFront(nil) } + return existing + } + // Guard the await gap: a second concurrent attach for the same host must + // not open a second window. + guard !pendingHostAttaches.contains(host.destination) else { + throw RemoteTmuxError.unreachable("already attaching \(host.destination)") + } + pendingHostAttaches.insert(host.destination) + defer { pendingHostAttaches.remove(host.destination) } + + var sessions = try await listSessions(host: host) + if sessions.isEmpty { + // A reachable server with zero sessions: create one so the window is + // useful. (An unreachable host throws from listSessions above.) + _ = try? await transport(for: host).runTmux(["new-session", "-d"]) + sessions = try await listSessions(host: host) + } + // Never open an empty dedicated window. + guard !sessions.isEmpty else { + throw RemoteTmuxError.unreachable("no tmux sessions on \(host.destination)") + } + // Re-check reuse: a concurrent caller may have finished while we awaited. + if let existing = windowIdByHost[host.destination], + let window = appDelegate.windowForMainWindowId(existing) { + if activateWindow { window.makeKeyAndOrderFront(nil) } + return existing + } + + let windowId = appDelegate.createMainWindow(shouldActivate: activateWindow) + guard let manager = appDelegate.tabManagerFor(windowId: windowId) else { + throw RemoteTmuxError.unreachable("could not create window") + } + windowIdByHost[host.destination] = windowId + hostByWindowId[windowId] = host.destination + + let bootstrapWorkspaceId = manager.tabs.first?.id + for session in sessions { + do { + try mirrorSession(host: host, sessionName: session.name, into: manager) + } catch { + #if DEBUG + cmuxDebugLog("remote-tmux: mirror session \(session.name) on \(host.destination) failed: \(error)") + #endif + } + } + // Remove the window's bootstrap (local welcome) workspace once at least + // one remote workspace exists, so the window is a clean 1:1 mirror. + if let bootstrapWorkspaceId, + manager.tabs.count > 1, + let bootstrap = manager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), + !bootstrap.isRemoteTmuxMirror { + manager.closeWorkspace(bootstrap, recordHistory: false) + } + if sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) { + addPersistedMirroredHost(host) + } + return windowId + } + + /// Discovers every tmux session on `host` and mirrors each as its own + /// workspace in the active window's sidebar (Option 2 — used by the + /// `remote.tmux.mirror` socket command). Prefer + /// ``mirrorHostInNewWindow(host:)`` for the user-facing attach. + func mirrorHost(host: RemoteTmuxHost) async throws { + guard let tabManager = AppDelegate.shared?.tabManager else { + throw RemoteTmuxError.unreachable("app not ready") + } + let sessions = try await listSessions(host: host) + for session in sessions { + // One session failing to attach must not abort mirroring the rest. + do { + try mirrorSession(host: host, sessionName: session.name, into: tabManager) + } catch { + #if DEBUG + cmuxDebugLog("remote-tmux: mirror session \(session.name) on \(host.destination) failed: \(error)") + #endif + } + } + // Remember the host for relaunch only once it actually has a live mirror, + // so an unreachable / sessionless host isn't persisted forever with no + // way to forget it through the UI. + if sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) { + addPersistedMirroredHost(host) + } + } + + /// Mirrors a single tmux session into a new workspace in `tabManager` (idempotent). + @discardableResult + func mirrorSession( + host: RemoteTmuxHost, + sessionName: String, + into tabManager: TabManager + ) throws -> Bool { + let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + guard sessionMirrors[key] == nil else { return false } + // Attach (and start the ssh process) BEFORE creating the workspace, so a + // failed connection doesn't leave an orphaned empty mirror workspace in + // the sidebar. + let connection = try attach(host: host, sessionName: sessionName) + let workspace = tabManager.addWorkspace( + title: sessionName, + select: false, + autoWelcomeIfNeeded: false + ) + workspace.isRemoteTmuxMirror = true + sessionMirrors[key] = RemoteTmuxSessionMirror( + host: host, + sessionName: sessionName, + connection: connection, + workspace: workspace + ) + return true + } + + // MARK: - Create / destroy propagation (P5) + + /// A new tab was requested in a mirrored workspace → create a tmux window in + /// that session. The new tab arrives via the `%window-add` notification (one + /// source of truth), so the caller must NOT also create a local tab. + /// + /// - Returns: `true` if routed to the remote (caller suppresses the local + /// tab); `false` if there is no live mirror/connection (caller proceeds + /// with normal local behavior). + func handleMirrorNewTabRequested(workspaceId: UUID) -> Bool { + guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), + !mirror.connection.exited else { return false } + mirror.connection.send("new-window") + return true + } + + /// A mirrored workspace was renamed → `rename-session` on the remote so the + /// tmux session name tracks the cmux workspace title. + func handleMirrorWorkspaceRenamed(workspaceId: UUID, title: String?) { + let name = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, + let entry = sessionMirrors.first(where: { $0.value.mirroredWorkspaceId == workspaceId }) + else { return } + let mirror = entry.value + let oldName = mirror.sessionName + guard name != oldName, !mirror.connection.exited else { return } + let host = mirror.host + // Target by the stable session id when known, so the rename can't race a + // prior rename's name. + let target = mirror.connection.sessionId.map { "$\($0)" } + ?? RemoteTmuxHost.shellSingleQuoted(oldName) + mirror.connection.send("rename-session -t \(target) \(RemoteTmuxHost.shellSingleQuoted(name))") + // Re-key all per-session state from the old name to the new one so + // detach / kill / attach-reuse keep working after the rename. + let oldKey = Self.connectionKey(destination: host.destination, sessionName: oldName) + let newKey = Self.connectionKey(destination: host.destination, sessionName: name) + mirror.setSessionName(name) + mirror.connection.setSessionName(name) + if oldKey != newKey { + if let m = sessionMirrors.removeValue(forKey: oldKey) { sessionMirrors[newKey] = m } + if let c = connectionsByHostSession.removeValue(forKey: oldKey) { connectionsByHostSession[newKey] = c } + if let t = displayObserverTokens.removeValue(forKey: oldKey) { displayObserverTokens[newKey] = t } + } + } + + /// Mirror tabs were drag-reordered → reorder the tmux windows to match. + /// + /// Uses `swap-window` (selection-sort over the current order), NOT + /// `move-window`: `move-window` unlinks+relinks a window, which in control + /// mode emits `%window-close`/`%window-add` and transiently empties the + /// mirror workspace — causing cmux to auto-seed a stray local terminal tab. + /// `swap-window` only swaps two windows' indices (no unlink), so there is no + /// churn. `-d` keeps the active window unchanged. + func handleMirrorWindowsReordered(workspaceId: UUID, orderedPanelIds: [UUID]) { + guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), + !mirror.connection.exited else { return } + let desired = orderedPanelIds.compactMap { mirror.windowId(forPanel: $0) } + guard desired.count >= 2 else { return } + // Current tmux window order (as last reported by list-windows), restricted + // to the windows we're reordering. Bail if the sets diverge, so we never + // issue a swap against a window the mirror doesn't currently track. + let desiredSet = Set(desired) + var current = mirror.connection.windowOrder.filter { desiredSet.contains($0) } + guard current.count == desired.count, Set(current) == desiredSet else { return } + for index in desired.indices where current[index] != desired[index] { + guard let swapFrom = current.firstIndex(of: desired[index]) else { continue } + mirror.connection.send("swap-window -d -s @\(current[index]) -t @\(current[swapFrom])") + current.swapAt(index, swapFrom) + } + } + + /// A split was requested from a mirrored multi-pane surface → propagate to + /// tmux `split-window`. The new pane arrives via the resulting + /// `%layout-change`. Returns `true` if `surfaceId` is a mirror pane (the + /// caller suppresses the local split). + func handleMirrorSplitRequested(surfaceId: UUID, vertical: Bool) -> Bool { + for sessionMirror in sessionMirrors.values where !sessionMirror.connection.exited { + if let match = sessionMirror.windowMirror(forSurfaceId: surfaceId) { + match.mirror.requestSplit(fromPane: match.tmuxPaneId, vertical: vertical) + return true + } + } + return false + } + + /// Whether `surfaceId` is a pane of a mirrored multi-pane tmux window (used + /// to keep the context-menu Split items enabled for mirror panes). + func isMirrorPaneSurface(_ surfaceId: UUID) -> Bool { + for sessionMirror in sessionMirrors.values { + if sessionMirror.windowMirror(forSurfaceId: surfaceId) != nil { return true } + } + return false + } + + /// A split was requested on a mirror window-tab (the split button / any + /// bonsplit-level split) → propagate to tmux `split-window`. Covers both + /// single-pane mirror windows and multi-pane ones. Returns `true` if handled. + func handleMirrorTabSplitRequested(workspaceId: UUID, panelId: UUID, vertical: Bool) -> Bool { + guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }) + else { return false } + return mirror.requestSplit(windowPanelId: panelId, vertical: vertical) + } + + /// A mirrored window's tab was renamed → `rename-window` on the remote. + func handleMirrorWindowRenamed(workspaceId: UUID, panelId: UUID, title: String?) { + let name = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, + let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), + !mirror.connection.exited, + let windowId = mirror.windowId(forPanel: panelId) else { return } + mirror.connection.send("rename-window -t @\(windowId) \(RemoteTmuxHost.shellSingleQuoted(name))") + } + + /// A tab close was requested in a mirrored workspace → kill that tmux window + /// on the remote. The local tab is removed when tmux reports `%window-close`, + /// so the caller should VETO the immediate local close. + /// + /// - Returns: `true` if routed to the remote (caller vetoes the local close); + /// `false` if there is no live mirror/connection or the panel isn't a + /// mirrored window (caller proceeds with the normal local close). + func handleMirrorTabCloseRequested(workspaceId: UUID, panelId: UUID) -> Bool { + guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), + !mirror.connection.exited, + let windowId = mirror.windowId(forPanel: panelId) else { return false } + mirror.connection.send("kill-window -t @\(windowId)") + return true + } + + /// A new workspace was requested while a dedicated remote window was active → + /// create a new tmux session on that host and mirror it into the same window. + /// + /// - Returns: `true` if `windowId` is a dedicated remote window (the caller + /// suppresses the local workspace creation); `false` otherwise. + func handleRemoteWindowNewWorkspaceRequested(windowId: UUID) -> Bool { + guard let destination = hostByWindowId[windowId] else { return false } + // Recover the full host (port/identity) from an existing mirror so the + // new session reuses the same connection details. + let host = sessionMirrors.values.first(where: { $0.host.destination == destination })?.host + ?? RemoteTmuxHost(destination: destination) + guard let manager = AppDelegate.shared?.tabManagerFor(windowId: windowId) else { return true } + Task { @MainActor in + do { + // Create a detached session and read back its (auto-assigned) name. + let result = try await self.transport(for: host).runTmux( + ["new-session", "-d", "-P", "-F", "#{session_name}"] + ) + let name = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard result.succeeded, !name.isEmpty else { return } + try self.mirrorSession(host: host, sessionName: name, into: manager) + } catch { + #if DEBUG + cmuxDebugLog("remote-tmux: new-session on \(destination) failed: \(error)") + #endif + } + } + return true + } + + /// The remote tmux session ended on its own (its last window was killed, or + /// it was killed out-of-band) — remove the mirror + connection and close the + /// now-dead workspace WITHOUT issuing a kill (the session is already gone). + func handleSessionEndedRemotely(host: RemoteTmuxHost, sessionName: String, workspaceId: UUID) { + let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + if let mirror = sessionMirrors.removeValue(forKey: key) { + mirror.detachObserver() + } + displayObserverTokens.removeValue(forKey: key) + connectionsByHostSession.removeValue(forKey: key)?.stop() + if !sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) { + forgetMirroredHost(host.destination) + if let windowId = windowIdByHost.removeValue(forKey: host.destination) { + hostByWindowId.removeValue(forKey: windowId) + } + } + // Close the dead mirror workspace. The mirror was already removed above, + // so TabManager.closeWorkspace's kill hook finds no entry and won't + // re-issue a kill. (closeWorkspace leaves the last workspace in a window + // for the window-close path.) + if let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = manager.tabs.first(where: { $0.id == workspaceId }) { + manager.closeWorkspace(workspace) + } + } + + /// Detaches any session mirrors whose workspace is in a closing window + /// (covers the `remote.tmux.mirror` socket path that mirrors into a + /// non-dedicated window, whose generic close doesn't run handleWorkspaceClosed). + /// Window close = detach + preserve remote (no kill); pane surfaces are torn + /// down via `detachObserver`. + func handleWindowWorkspacesClosed(workspaceIds: [UUID]) { + guard !workspaceIds.isEmpty else { return } + let ids = Set(workspaceIds) + for (key, mirror) in sessionMirrors { + guard let workspaceId = mirror.mirroredWorkspaceId, ids.contains(workspaceId) else { continue } + mirror.detachObserver() + displayObserverTokens.removeValue(forKey: key) + sessionMirrors.removeValue(forKey: key) + connectionsByHostSession.removeValue(forKey: key)?.stop() + } + } + + /// Handles close of a dedicated remote window (Option 1): detaches every + /// control connection for that host so the ssh clients shut down, but does + /// NOT kill any remote session — closing the window only detaches, leaving + /// the remote tmux server alive for resume on the next launch. + func handleRemoteWindowClosed(windowId: UUID) { + guard let destination = hostByWindowId[windowId] else { return } + hostByWindowId.removeValue(forKey: windowId) + windowIdByHost.removeValue(forKey: destination) + for (key, mirror) in sessionMirrors where mirror.host.destination == destination { + mirror.detachObserver() + displayObserverTokens.removeValue(forKey: key) + sessionMirrors.removeValue(forKey: key) + } + for (key, connection) in connectionsByHostSession where connection.host.destination == destination { + connection.stop() + connectionsByHostSession.removeValue(forKey: key) + } + // The host stays persisted on purpose: it re-mirrors into a fresh + // dedicated window on the next launch. + } + + /// Handles user-initiated close of a mirrored session workspace: detaches + /// the control connection and kills the session on the remote. + func handleWorkspaceClosed(workspaceId: UUID) { + guard let entry = sessionMirrors.first(where: { $0.value.mirroredWorkspaceId == workspaceId }) + else { return } + let mirror = entry.value + let host = mirror.host + let sessionName = mirror.sessionName + sessionMirrors.removeValue(forKey: entry.key) + mirror.detachObserver() + displayObserverTokens.removeValue(forKey: entry.key) + detach(host: host, sessionName: sessionName) + // If this was the host's last mirrored session, stop auto-re-mirroring it + // on the next launch and forget its dedicated-window binding. + if !sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) { + forgetMirroredHost(host.destination) + if let windowId = windowIdByHost.removeValue(forKey: host.destination) { + hostByWindowId.removeValue(forKey: windowId) + } + } + // Kill by the stable session id when known, so a prior rename-session + // can't leave us targeting a stale name. + let killTarget = mirror.connection.sessionId.map { "$\($0)" } ?? sessionName + let transport = transport(for: host) + Task { _ = try? await transport.runTmux(["kill-session", "-t", killTarget]) } + } + + /// Returns the control connection for a host+session, if attached. + func connection(host: RemoteTmuxHost, sessionName: String) -> RemoteTmuxControlConnection? { + connectionsByHostSession[Self.connectionKey( + destination: host.destination, + sessionName: sessionName + )] + } + + /// Detaches and forgets a control connection (leaves the remote session alive). + func detach(host: RemoteTmuxHost, sessionName: String) { + let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + connectionsByHostSession.removeValue(forKey: key)?.stop() + } + + /// Detaches every control connection (used on app quit so remote sessions + /// survive). Does NOT kill any remote tmux server/session. + func detachAll() { + let connections = Array(connectionsByHostSession.values) + connectionsByHostSession.removeAll() + for connection in connections { connection.stop() } + } + + private static func connectionKey(destination: String, sessionName: String) -> String { + "\(destination)\u{1}\(sessionName)" + } + + // MARK: - Persistence / reconnect on relaunch (P5) + + /// JSON-encoded `[RemoteTmuxHost]` — preserves port/identityFile across launches. + private static let mirroredHostsDefaultsKey = "remoteTmux.mirroredHostsV2" + /// Legacy key: a plain `[String]` of destinations (destination-only). Read for + /// one-time migration into the V2 store, then removed. + private static let legacyMirroredHostsKey = "remoteTmux.mirroredHosts" + + /// The persisted hosts to re-mirror on launch, decoding the full host + /// (destination + port + identityFile) or migrating the legacy + /// destination-only array. + private func persistedMirroredHosts() -> [RemoteTmuxHost] { + let defaults = UserDefaults.standard + if let data = defaults.data(forKey: Self.mirroredHostsDefaultsKey), + let hosts = try? JSONDecoder().decode([RemoteTmuxHost].self, from: data) { + return hosts + } + if let legacy = defaults.stringArray(forKey: Self.legacyMirroredHostsKey) { + return legacy.map { RemoteTmuxHost(destination: $0) } + } + return [] + } + + private func writePersistedMirroredHosts(_ hosts: [RemoteTmuxHost]) { + let defaults = UserDefaults.standard + // Dedupe by destination, keep a stable order. + var seen = Set() + let unique = hosts + .filter { seen.insert($0.destination).inserted } + .sorted { $0.destination < $1.destination } + if let data = try? JSONEncoder().encode(unique) { + defaults.set(data, forKey: Self.mirroredHostsDefaultsKey) + } + // The legacy store has been folded into V2; drop it so it can't shadow. + defaults.removeObject(forKey: Self.legacyMirroredHostsKey) + } + + /// Remembers a host (full connection details) for re-mirroring on relaunch, + /// updating in place if its port/identity changed. + private func addPersistedMirroredHost(_ host: RemoteTmuxHost) { + var hosts = persistedMirroredHosts().filter { $0.destination != host.destination } + hosts.append(host) + writePersistedMirroredHosts(hosts) + } + + /// Stops remembering a host so it is not re-mirrored on the next launch. + func forgetMirroredHost(_ destination: String) { + let hosts = persistedMirroredHosts().filter { $0.destination != destination } + writePersistedMirroredHosts(hosts) + } + + /// Reconnects to every persisted mirrored host and re-mirrors its + /// still-running sessions. Quitting cmux only detaches (the remote tmux + /// server stays alive), so this restores the sidebar after relaunch. Called + /// once per launch from the app delegate. + func restoreMirroredHostsOnLaunch() { + guard Self.isEnabled else { return } + let hosts = persistedMirroredHosts() + guard !hosts.isEmpty else { return } + Task { @MainActor in + for host in hosts { + // Restore each host into its own dedicated window (Option 1), so + // the relaunched sidebar matches how the user attached it. Don't + // activate — a relaunch must not steal focus. + _ = try? await self.mirrorHostInNewWindow(host: host, activateWindow: false) + } + } + } +} diff --git a/Sources/RemoteTmuxError.swift b/Sources/RemoteTmuxError.swift new file mode 100644 index 000000000000..749a83cac343 --- /dev/null +++ b/Sources/RemoteTmuxError.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Errors raised while talking to a remote tmux server over SSH. +enum RemoteTmuxError: Error, Sendable, Equatable { + /// The `ssh` (or remote) command exited non-zero for a reason cmux does + /// not treat as benign. Carries the exit code and captured stderr. + case commandFailed(exitCode: Int32, stderr: String) + + /// The local `ssh` binary could not be launched at all. + case launchFailed(String) + + /// The remote host is not reachable / the SSH master could not be opened. + case unreachable(String) +} + +extension RemoteTmuxError { + /// A short, user-presentable description. + var message: String { + switch self { + case let .commandFailed(exitCode, stderr): + let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty + ? "remote command failed (exit \(exitCode))" + : "remote command failed (exit \(exitCode)): \(trimmed)" + case let .launchFailed(detail): + return "failed to launch ssh: \(detail)" + case let .unreachable(detail): + return "host unreachable: \(detail)" + } + } +} + +// `String(describing:)` and `error.localizedDescription` both surface the +// crafted ``message`` instead of the default enum reflection dump, so the +// socket/CLI error path (which maps thrown errors via `String(describing:)`) +// returns the readable form rather than `commandFailed(exitCode: 1, …)`. +extension RemoteTmuxError: CustomStringConvertible { + var description: String { message } +} + +extension RemoteTmuxError: LocalizedError { + var errorDescription: String? { message } +} diff --git a/Sources/RemoteTmuxHost.swift b/Sources/RemoteTmuxHost.swift new file mode 100644 index 000000000000..fda708cfe012 --- /dev/null +++ b/Sources/RemoteTmuxHost.swift @@ -0,0 +1,142 @@ +import Foundation + +/// Identifies a remote host whose tmux server cmux mirrors over SSH. +/// +/// A host is addressed by its SSH `destination` — either a `~/.ssh/config` +/// alias (e.g. `claude-box`) or an explicit `user@host`. cmux multiplexes +/// every operation against the host (discovery commands, the `tmux -CC` +/// control client, and one-shot mutations) over a single SSH ControlMaster +/// socket derived from the destination, so authentication happens once. +struct RemoteTmuxHost: Sendable, Equatable, Codable, Identifiable { + /// The SSH destination: a `~/.ssh/config` alias or `user@host`. + let destination: String + + /// Optional explicit port (`-p`). `nil` defers to `~/.ssh/config`. + let port: Int? + + /// Optional explicit identity file (`-i`). `nil` defers to `~/.ssh/config`. + let identityFile: String? + + /// Stable identity for UI/persistence: the destination string. + var id: String { destination } + + init(destination: String, port: Int? = nil, identityFile: String? = nil) { + self.destination = destination + self.port = port + self.identityFile = identityFile + } + + /// A human-readable (but lossy) slug for the destination, used only for + /// debuggability in the control socket filename. It lowercases and maps + /// every non-alphanumeric character to `-`, so distinct destinations can + /// collapse to the same slug — uniqueness comes from ``destinationHash``, + /// never from the slug alone. + var slug: String { + let lowered = destination.lowercased() + let mapped = lowered.map { ch -> Character in + ch.isLetter || ch.isNumber ? ch : "-" + } + let collapsed = String(mapped.prefix(40)) + return collapsed.isEmpty ? "host" : collapsed + } + + /// A stable, deterministic, collision-resistant hex digest of the exact, + /// case-sensitive ``destination`` (FNV-1a/64). Two destinations that share a + /// lossy ``slug`` (e.g. `alice@host` vs `alice.host`, or `Host` vs `host`) + /// still get different digests, so they never share a ControlMaster socket. + var destinationHash: String { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 // FNV offset basis + for byte in destination.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 // FNV prime + } + return String(format: "%016llx", hash) + } + + /// The SSH ControlMaster socket path shared by every operation against this host. + /// + /// Kept short (well under the AF_UNIX 104-byte limit) and namespaced under + /// `~/.cmux/ssh/`. The filename combines the lossy human-readable ``slug`` + /// with the collision-resistant ``destinationHash`` of the exact + /// destination, so two distinct destinations never collide on one socket + /// (which would otherwise route commands — including the destructive + /// `kill-session` — to the wrong host through a shared master). + var controlSocketPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/.cmux/ssh/tmux-\(slug)-\(destinationHash).sock" + } + + /// Ensures the directory that holds the control socket exists. + func ensureControlSocketDirectory() throws { + let dir = (controlSocketPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory( + atPath: dir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } + + /// SSH options that reuse (or open) the shared ControlMaster. + /// + /// - Parameter controlPersistSeconds: how long the master lingers idle + /// after the last client detaches, so back-to-back commands stay fast. + /// - Parameter batchMode: when `true`, ssh never prompts interactively + /// (correct for non-interactive discovery/mutation commands; the + /// `tmux -CC` control client runs under a PTY and must NOT set this). + func sshControlArguments(controlPersistSeconds: Int, batchMode: Bool) -> [String] { + var args = [ + "-o", "ControlMaster=auto", + "-o", "ControlPath=\(controlSocketPath)", + "-o", "ControlPersist=\(controlPersistSeconds)", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=3", + ] + if batchMode { + args.append(contentsOf: ["-o", "BatchMode=yes"]) + } + if let port { + args.append(contentsOf: ["-p", String(port)]) + } + if let identityFile, !identityFile.isEmpty { + args.append(contentsOf: ["-i", identityFile]) + } + return args + } + + /// Single-quotes a value for safe interpolation into a `/bin/sh` command. + static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Builds the `ssh` argv (for direct `Process` execution, no shell) that + /// runs `tmux -CC` control mode for `sessionName` on this host. + /// + /// Uses `ssh -tt` to force a remote PTY (the remote `tmux attach` needs a + /// tty); the local side is plain pipes. The remote command is one argument + /// that the remote login shell parses, so the session name is single-quoted. + /// A `--` end-of-options marker precedes the destination so a destination + /// that begins with `-` can never be parsed by `ssh` as an option (which + /// would allow `-oProxyCommand=…` local command injection). + /// + /// - Parameters: + /// - sessionName: the tmux session to attach to (or create). + /// - createIfMissing: `new-session -A -s` (attach or create) vs `attach-session -t`. + func controlModeArguments( + sessionName: String, + createIfMissing: Bool, + controlPersistSeconds: Int = 180 + ) -> [String] { + var args = ["-tt"] + args.append(contentsOf: sshControlArguments( + controlPersistSeconds: controlPersistSeconds, + batchMode: false + )) + let quotedName = Self.shellSingleQuoted(sessionName) + let remoteCommand = createIfMissing + ? "tmux -CC new-session -A -s \(quotedName)" + : "tmux -CC attach-session -t \(quotedName)" + args.append(contentsOf: ["--", destination, remoteCommand]) + return args + } +} diff --git a/Sources/RemoteTmuxLayoutNode.swift b/Sources/RemoteTmuxLayoutNode.swift new file mode 100644 index 000000000000..a37a79071c82 --- /dev/null +++ b/Sources/RemoteTmuxLayoutNode.swift @@ -0,0 +1,102 @@ +import Foundation + +/// A node in a tmux window's pane-layout tree, parsed from a tmux +/// `#{window_layout}` / `%layout-change` string by ``RemoteTmuxRawLayoutParser``. +/// +/// Each node carries its geometry (`width`/`height`/`x`/`y`, in terminal cells) +/// and is either a leaf pane or a split containing child nodes, mirroring tmux's +/// layout semantics: `horizontal` children are arranged left→right, `vertical` +/// children top→bottom. +/// +/// The JSON shape (one of `pane`/`horizontal`/`vertical` is present): +/// ```json +/// { "width": 80, "height": 24, "x": 0, "y": 0, +/// "horizontal": [ { …, "pane": 1 }, { …, "pane": 2 } ] } +/// ``` +struct RemoteTmuxLayoutNode: Sendable, Equatable, Codable { + /// Width of the node in terminal cells. + let width: Int + /// Height of the node in terminal cells. + let height: Int + /// X offset from the window's top-left, in cells. + let x: Int + /// Y offset from the window's top-left, in cells. + let y: Int + /// The node's content: a leaf pane or a split. + let content: Content + + /// A layout node is either a leaf pane or a directional split. + enum Content: Sendable, Equatable { + /// A leaf pane, identified by its numeric tmux pane id (the `%N` + /// without the leading `%`). + case pane(Int) + /// A left→right split of child nodes. + case horizontal([RemoteTmuxLayoutNode]) + /// A top→bottom split of child nodes. + case vertical([RemoteTmuxLayoutNode]) + } + + init(width: Int, height: Int, x: Int, y: Int, content: Content) { + self.width = width + self.height = height + self.x = x + self.y = y + self.content = content + } + + private enum CodingKeys: String, CodingKey { + case width, height, x, y, pane, horizontal, vertical + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + width = try container.decode(Int.self, forKey: .width) + height = try container.decode(Int.self, forKey: .height) + x = try container.decode(Int.self, forKey: .x) + y = try container.decode(Int.self, forKey: .y) + if let paneId = try container.decodeIfPresent(Int.self, forKey: .pane) { + content = .pane(paneId) + } else if let children = try container.decodeIfPresent([RemoteTmuxLayoutNode].self, forKey: .horizontal) { + content = .horizontal(children) + } else if let children = try container.decodeIfPresent([RemoteTmuxLayoutNode].self, forKey: .vertical) { + content = .vertical(children) + } else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "layout node missing pane/horizontal/vertical" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(width, forKey: .width) + try container.encode(height, forKey: .height) + try container.encode(x, forKey: .x) + try container.encode(y, forKey: .y) + switch content { + case let .pane(id): try container.encode(id, forKey: .pane) + case let .horizontal(children): try container.encode(children, forKey: .horizontal) + case let .vertical(children): try container.encode(children, forKey: .vertical) + } + } + + /// `true` when this node is a single pane (no split). + var isLeaf: Bool { + if case .pane = content { return true } + return false + } + + /// All pane ids in this subtree, in depth-first left-to-right order — the + /// natural order to create matching cmux splits. + var paneIDsInOrder: [Int] { + switch content { + case let .pane(id): + return [id] + case let .horizontal(children), let .vertical(children): + return children.flatMap { $0.paneIDsInOrder } + } + } +} diff --git a/Sources/RemoteTmuxManualIOWrite.swift b/Sources/RemoteTmuxManualIOWrite.swift new file mode 100644 index 000000000000..26060f0e09c1 --- /dev/null +++ b/Sources/RemoteTmuxManualIOWrite.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Heap-allocated userdata box for a ``TerminalSurface`` created in libghostty +/// MANUAL I/O mode. +/// +/// In MANUAL mode ghostty spawns no process and owns no PTY: bytes the user +/// types are delivered to ``onWrite`` (on ghostty's I/O thread) instead of a +/// PTY, and output is injected with `ghostty_surface_process_output`. cmux uses +/// this for remote-tmux pane display surfaces — keystrokes route to the tmux +/// `-CC` connection (`send-keys`), output comes from `%output`. +/// +/// The box's lifetime is tied to the surface via a retained `Unmanaged` +/// reference; it is released when the surface is freed. +final class RemoteTmuxManualIOWriteBox { + /// Invoked with bytes the user typed into the surface. Runs on ghostty's + /// I/O thread, so the closure must be `Sendable` and hop to the main actor + /// itself before touching `@MainActor` state. + let onWrite: @Sendable (Data) -> Void + + init(onWrite: @escaping @Sendable (Data) -> Void) { + self.onWrite = onWrite + } +} + +/// C trampoline matching `ghostty_io_write_cb` for MANUAL-mode surfaces. +/// +/// Declared as a file-scope `let` (not a `func`) so the symbol stays private to +/// this translation unit and the linker sees no duplicate when referenced via +/// function pointer — mirroring ``cmuxMobileTerminalByteTeeCallback``. +let cmuxRemoteTmuxManualIOWriteCallback: @convention(c) ( + UnsafeMutableRawPointer?, UnsafePointer?, UInt +) -> Void = { userdata, bytes, len in + guard let userdata, let bytes, len > 0 else { return } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + let count = Int(len) + let data = bytes.withMemoryRebound(to: UInt8.self, capacity: count) { rebound in + Data(buffer: UnsafeBufferPointer(start: rebound, count: count)) + } + box.onWrite(data) +} diff --git a/Sources/RemoteTmuxRawLayoutParser.swift b/Sources/RemoteTmuxRawLayoutParser.swift new file mode 100644 index 000000000000..0d267045404f --- /dev/null +++ b/Sources/RemoteTmuxRawLayoutParser.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Parses a raw tmux window-layout string into a ``RemoteTmuxLayoutNode`` tree. +/// +/// The format (from `#{window_layout}` / `%layout-change`) is a 4-hex-char +/// checksum, a comma, then a recursive node: +/// ``` +/// f92f,120x40,0,0{60x40,0,0,4,59x40,61,0[59x20,61,0,5,59x19,61,21,8]} +/// ``` +/// where each node is `WxH,X,Y` followed by one of: +/// - `,` — a leaf pane, +/// - `{ … }` — a left-right (horizontal) split of comma-separated child nodes, +/// - `[ … ]` — a top-bottom (vertical) split of comma-separated child nodes. +enum RemoteTmuxRawLayoutParser { + /// Parses a window-layout string (with or without the leading checksum). + /// + /// - Returns: the root layout node, or `nil` if the string is malformed. + static func parse(_ raw: String) -> RemoteTmuxLayoutNode? { + var chars = Array(raw) + // Strip a leading 4-hex-char checksum followed by a comma, if present. + if chars.count > 5, + chars[4] == ",", + chars[0..<4].allSatisfy(\.isHexDigit) { + chars.removeFirst(5) + } + var cursor = 0 + guard let node = parseNode(chars, &cursor), cursor == chars.count else { return nil } + return node + } + + private static func parseNode(_ chars: [Character], _ cursor: inout Int) -> RemoteTmuxLayoutNode? { + guard let width = parseInt(chars, &cursor), consume(chars, &cursor, "x"), + let height = parseInt(chars, &cursor), consume(chars, &cursor, ","), + let x = parseInt(chars, &cursor), consume(chars, &cursor, ","), + let y = parseInt(chars, &cursor) else { return nil } + + guard cursor < chars.count else { return nil } + let content: RemoteTmuxLayoutNode.Content + switch chars[cursor] { + case ",": + cursor += 1 + guard let paneId = parseInt(chars, &cursor) else { return nil } + content = .pane(paneId) + case "{": + guard let children = parseChildren(chars, &cursor, open: "{", close: "}") else { return nil } + content = .horizontal(children) + case "[": + guard let children = parseChildren(chars, &cursor, open: "[", close: "]") else { return nil } + content = .vertical(children) + default: + return nil + } + return RemoteTmuxLayoutNode(width: width, height: height, x: x, y: y, content: content) + } + + private static func parseChildren( + _ chars: [Character], _ cursor: inout Int, open: Character, close: Character + ) -> [RemoteTmuxLayoutNode]? { + guard consume(chars, &cursor, open) else { return nil } + var children: [RemoteTmuxLayoutNode] = [] + while true { + guard let child = parseNode(chars, &cursor) else { return nil } + children.append(child) + guard cursor < chars.count else { return nil } + if chars[cursor] == close { cursor += 1; break } + if chars[cursor] == "," { cursor += 1; continue } + return nil + } + return children.count >= 2 ? children : nil + } + + private static func parseInt(_ chars: [Character], _ cursor: inout Int) -> Int? { + let start = cursor + while cursor < chars.count, chars[cursor].isNumber { cursor += 1 } + guard cursor > start else { return nil } + return Int(String(chars[start.. Bool { + guard cursor < chars.count, chars[cursor] == expected else { return false } + cursor += 1 + return true + } +} diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift new file mode 100644 index 000000000000..68c66a70e55b --- /dev/null +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -0,0 +1,174 @@ +import Foundation + +/// Runs commands against a remote host's tmux server over a shared SSH +/// ControlMaster connection. +/// +/// This is the non-interactive half of the remote-tmux feature: session +/// discovery (`tmux list-sessions`) and one-shot mutations (`new-session`, +/// `new-window`, `split-window`, `kill-*`, `send-keys`). The latency-sensitive +/// `tmux -CC` control stream is NOT run here — it runs in a ghostty surface so +/// it gets a PTY. Both share the same ControlMaster socket +/// (``RemoteTmuxHost/controlSocketPath``), so the first to connect authenticates +/// and the rest are subsecond. +/// +/// Modeled as an `actor` because it owns the per-host connection lifecycle and +/// serializes process launches; reads/writes are `async`. +actor RemoteTmuxSSHTransport { + /// The host this transport talks to. + let host: RemoteTmuxHost + + private let sshExecutablePath: String + private let controlPersistSeconds: Int + + /// - Parameters: + /// - host: the remote destination. + /// - sshExecutablePath: the local `ssh` binary (overridable for tests). + /// - controlPersistSeconds: idle lifetime of the shared master. + init( + host: RemoteTmuxHost, + sshExecutablePath: String = "/usr/bin/ssh", + controlPersistSeconds: Int = 180 + ) { + self.host = host + self.sshExecutablePath = sshExecutablePath + self.controlPersistSeconds = controlPersistSeconds + } + + // MARK: - High-level tmux operations + + /// Lists the tmux sessions on the remote server. + /// + /// Returns an empty array when the remote tmux server is not running yet + /// (cmux treats "no server running" / "no sessions" as zero sessions, not + /// an error, so the sidebar can still offer to create one). + func listSessions() async throws -> [RemoteTmuxSession] { + let result = try await runTmux([ + "list-sessions", "-F", RemoteTmuxSessionListParser.formatString, + ]) + if !result.succeeded { + if Self.indicatesNoServer(result.stderr) { return [] } + throw RemoteTmuxError.commandFailed(exitCode: result.exitCode, stderr: result.stderr) + } + return RemoteTmuxSessionListParser.parse(result.stdout) + } + + /// Runs a `tmux ` command on the remote host and returns its result. + @discardableResult + func runTmux(_ args: [String]) async throws -> RemoteTmuxCommandResult { + try await run(["tmux"] + args) + } + + /// Runs an arbitrary remote command over the shared SSH master. + /// + /// `ssh` concatenates the post-destination argv with spaces and the remote + /// login shell re-splits the result, so each remote token is single-quoted + /// here; otherwise whitespace inside an argument (e.g. the tabs in a + /// `list-sessions -F` format string) would be word-split on the remote. + @discardableResult + func run(_ remoteArgs: [String]) async throws -> RemoteTmuxCommandResult { + try host.ensureControlSocketDirectory() + let remoteCommand = remoteArgs + .map { RemoteTmuxHost.shellSingleQuoted($0) } + .joined(separator: " ") + // `--` ends ssh option parsing so a destination beginning with `-` + // (e.g. `-oProxyCommand=…`) can never be consumed as an ssh option. + let sshArgs = + host.sshControlArguments(controlPersistSeconds: controlPersistSeconds, batchMode: true) + + ["--", host.destination, remoteCommand] + return try await Self.runProcess(executable: sshExecutablePath, arguments: sshArgs) + } + + /// Tears down the shared SSH master (e.g. when the user removes a host). + func shutdownMaster() async { + _ = try? await Self.runProcess( + executable: sshExecutablePath, + arguments: ["-O", "exit", "-o", "ControlPath=\(host.controlSocketPath)", "--", host.destination] + ) + } + + // MARK: - Heuristics + + /// Whether stderr indicates the remote tmux server simply isn't running. + static func indicatesNoServer(_ stderr: String) -> Bool { + let lowered = stderr.lowercased() + return lowered.contains("no server running") + || lowered.contains("no sessions") + || lowered.contains("error connecting to") + } + + // MARK: - Process plumbing + + /// Launches a process and captures stdout/stderr without blocking the actor. + /// + /// Each pipe is drained to EOF on a detached task so a chatty command can't + /// deadlock against a full 64 KiB pipe buffer while we await termination. + /// We capture only the raw fds (`Int32`, `Sendable`) across the task + /// boundary — never the non-`Sendable` `FileHandle` — and the `Pipe`s stay + /// alive because `process` retains them until this function returns. + private static func runProcess( + executable: String, + arguments: [String] + ) async throws -> RemoteTmuxCommandResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + process.standardInput = FileHandle.nullDevice + + let outFD = outPipe.fileHandleForReading.fileDescriptor + let errFD = errPipe.fileHandleForReading.fileDescriptor + let outRead = Task.detached { Self.drain(fd: outFD) } + let errRead = Task.detached { Self.drain(fd: errFD) } + + do { + try process.run() + } catch { + outRead.cancel() + errRead.cancel() + throw RemoteTmuxError.launchFailed(error.localizedDescription) + } + + let exitCode: Int32 = await withCheckedContinuation { continuation in + process.terminationHandler = { proc in + continuation.resume(returning: proc.terminationStatus) + } + } + + let outData = await outRead.value + let errData = await errRead.value + return RemoteTmuxCommandResult( + exitCode: exitCode, + stdout: String(decoding: outData, as: UTF8.self), + stderr: String(decoding: errData, as: UTF8.self) + ) + } + + /// Reads a file descriptor to EOF, returning everything read. + /// + /// Uses the raw `read(2)` so nothing non-`Sendable` crosses the task + /// boundary; the owning `Pipe` keeps `fd` open for the duration. + private static func drain(fd: Int32) -> Data { + var data = Data() + let bufferSize = 65_536 + var buffer = [UInt8](repeating: 0, count: bufferSize) + while true { + let count = buffer.withUnsafeMutableBytes { ptr -> Int in + read(fd, ptr.baseAddress, bufferSize) + } + if count > 0 { + data.append(contentsOf: buffer[0.. [RemoteTmuxSession] { + var sessions: [RemoteTmuxSession] = [] + for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty { continue } + let fields = line.components(separatedBy: "\t") + // Need at least id + name + windows; attached/created are optional. + guard fields.count >= 3 else { continue } + let id = fields[0].trimmingCharacters(in: .whitespaces) + let name = fields[1] + guard !id.isEmpty else { continue } + let windowCount = Int(fields[2].trimmingCharacters(in: .whitespaces)) ?? 0 + let attached: Bool = { + guard fields.count >= 4 else { return false } + return (Int(fields[3].trimmingCharacters(in: .whitespaces)) ?? 0) > 0 + }() + let createdUnix: Int? = { + guard fields.count >= 5 else { return nil } + return Int(fields[4].trimmingCharacters(in: .whitespaces)) + }() + sessions.append( + RemoteTmuxSession( + id: id, + name: name, + windowCount: windowCount, + attached: attached, + createdUnix: createdUnix + ) + ) + } + return sessions + } +} diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift new file mode 100644 index 000000000000..3079eb87506d --- /dev/null +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -0,0 +1,233 @@ +import Foundation + +/// Mirrors one remote tmux session into a dedicated cmux sidebar workspace. +/// +/// Owns the binding between a ``RemoteTmuxControlConnection`` and a ``Workspace``: +/// each tmux window becomes a tab (rendering that window's first pane via a +/// MANUAL-I/O display surface), pane output is routed to the right tab, and the +/// workspace's default local terminal tab is closed once remote tabs exist. +/// +/// Full pane→split mapping and window-close handling build on this first +/// session→workspace increment. +@MainActor +final class RemoteTmuxSessionMirror { + let host: RemoteTmuxHost + private(set) var sessionName: String + let connection: RemoteTmuxControlConnection + + /// Updates the tracked session name after a `rename-session`. + func setSessionName(_ name: String) { sessionName = name } + + private weak var workspace: Workspace? + private let defaultPanelIds: [UUID] + private var defaultClosed = false + private var panelIdByWindow: [Int: UUID] = [:] + private var panelIdByPane: [Int: UUID] = [:] + /// Per-window multi-pane renderers (present once a window has >1 pane). + private var windowMirrorByWindowId: [Int: RemoteTmuxWindowMirror] = [:] + private var observerToken: RemoteTmuxControlConnection.ObserverToken? + + init( + host: RemoteTmuxHost, + sessionName: String, + connection: RemoteTmuxControlConnection, + workspace: Workspace + ) { + self.host = host + self.sessionName = sessionName + self.connection = connection + self.workspace = workspace + self.defaultPanelIds = Array(workspace.panels.keys) + + // Register as one of possibly several observers — never overwrite a + // single shared closure on the connection. + self.observerToken = connection.addObserver( + onPaneOutput: { [weak self] paneId, data in + self?.routeOutput(paneId: paneId, data: data) + }, + onTopologyChanged: { [weak self] in + self?.rebuild() + }, + onExit: { [weak self] in + self?.handleConnectionExited() + } + ) + rebuild() + } + + /// The remote session ended on its own (e.g. its last tmux window was killed, + /// or it was killed out-of-band) — hand off to the controller to remove the + /// mirror and close the now-dead workspace. Deliberate detach/quit/window + /// close suppress `onExit`, so this only runs for genuine remote ends. + private func handleConnectionExited() { + guard let workspaceId = mirroredWorkspaceId else { return } + AppDelegate.shared?.remoteTmuxController.handleSessionEndedRemotely( + host: host, sessionName: sessionName, workspaceId: workspaceId + ) + } + + /// The cmux workspace mirroring this session (if still alive). + var mirroredWorkspaceId: UUID? { workspace?.id } + + /// The tmux window id whose mirrored tab is backed by `panelId`, if any. + func windowId(forPanel panelId: UUID) -> Int? { + panelIdByWindow.first(where: { $0.value == panelId })?.key + } + + /// Deregisters this mirror's connection observer and tears down all per-window + /// multi-pane renderers (called when the mirror is torn down so its callbacks + /// don't linger on a shared connection and its pane surfaces don't leak). + func detachObserver() { + if let observerToken { + connection.removeObserver(observerToken) + self.observerToken = nil + } + for (windowId, mirror) in windowMirrorByWindowId { + workspace?.setRemoteTmuxWindowMirror(nil, forPanelId: mirror.panelId) + mirror.teardown() + windowMirrorByWindowId[windowId] = nil + } + } + + /// The tmux window id (if any) whose layout currently contains `paneId`. + private func windowIdContaining(pane paneId: Int) -> Int? { + connection.windowsByID.first(where: { $0.value.paneIDsInOrder.contains(paneId) })?.key + } + + /// Adds a tab for any window that doesn't yet have one, refreshes existing + /// tab titles after a tmux rename, activates/reconciles the in-tab multi-pane + /// renderer for multi-pane windows, then closes the workspace's original + /// local tab(s) once at least one remote tab exists. + func rebuild() { + guard let workspace else { return } + for windowId in connection.windowOrder { + guard let window = connection.windowsByID[windowId], + let firstPaneId = window.paneIDsInOrder.first else { continue } + let title = Self.tabTitle(for: window) + let panelId: UUID + if let existing = panelIdByWindow[windowId] { + // Existing tab — refresh its title if tmux renamed the window. + workspace.updateRemoteTmuxTabTitle(panelId: existing, title: title) + panelId = existing + } else { + guard let panel = workspace.addRemoteTmuxDisplayPane( + remotePaneId: firstPaneId, + title: title, + focus: false, + onInput: { [weak connection] data in + Task { @MainActor in connection?.sendKeys(paneId: firstPaneId, data: data) } + } + ) else { continue } + panelIdByWindow[windowId] = panel.id + panelIdByPane[firstPaneId] = panel.id + connection.capturePane(paneId: firstPaneId) + panelId = panel.id + } + reconcileWindowMirror(windowId: windowId, panelId: panelId, window: window, in: workspace) + } + // Close tabs for windows tmux removed, so a closed remote window doesn't + // leave a frozen tab behind. + let liveWindows = Set(connection.windowOrder) + for (windowId, panelId) in panelIdByWindow where !liveWindows.contains(windowId) { + if let mirror = windowMirrorByWindowId[windowId] { + workspace.setRemoteTmuxWindowMirror(nil, forPanelId: panelId) + mirror.teardown() + windowMirrorByWindowId[windowId] = nil + } + _ = workspace.closePanel(panelId, force: true) + panelIdByWindow[windowId] = nil + panelIdByPane = panelIdByPane.filter { $0.value != panelId } + } + closeDefaultTabsIfNeeded() + } + + /// Creates the in-tab multi-pane renderer the first time a window has more + /// than one pane, and reconciles it on subsequent layout changes. Once + /// created it persists for that window (rendering even a single pane), so the + /// tab never flips back and forth between the two render paths. + private func reconcileWindowMirror( + windowId: Int, + panelId: UUID, + window: RemoteTmuxWindow, + in workspace: Workspace + ) { + if let mirror = windowMirrorByWindowId[windowId] { + mirror.reconcile(layout: window.layout) + return + } + guard window.paneIDsInOrder.count > 1 else { return } + let mirror = RemoteTmuxWindowMirror( + windowId: windowId, + panelId: panelId, + connection: connection, + layout: window.layout, + makePanel: { [weak workspace, weak connection] tmuxPaneId in + workspace?.makeRemoteTmuxPanePanel(onInput: { data in + Task { @MainActor in connection?.sendKeys(paneId: tmuxPaneId, data: data) } + }) + } + ) + windowMirrorByWindowId[windowId] = mirror + workspace.setRemoteTmuxWindowMirror(mirror, forPanelId: panelId) + } + + /// The tab title for a mirrored window: the tmux window name, or a localized + /// placeholder when tmux hasn't reported one. tmux window names are + /// content-derived (like every other cmux tab title) so the name itself is + /// not translated; only the empty-name placeholder is localized. + private static func tabTitle(for window: RemoteTmuxWindow) -> String { + let trimmed = window.name.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty + ? String(localized: "remoteTmux.tab.window", defaultValue: "tmux window") + : trimmed + } + + private func closeDefaultTabsIfNeeded() { + guard !defaultClosed, !panelIdByWindow.isEmpty, let workspace else { return } + for panelId in defaultPanelIds where workspace.panels[panelId] != nil { + _ = workspace.closePanel(panelId, force: true) + } + defaultClosed = true + } + + private func routeOutput(paneId: Int, data: Data) { + // Multi-pane window: its in-tab renderer owns the pane's surface. + if let windowId = windowIdContaining(pane: paneId), + let mirror = windowMirrorByWindowId[windowId] { + mirror.routeOutput(paneId: paneId, data: data) + return + } + // Single-pane window: route to the window-tab's panel surface. + guard let workspace, + let panelId = panelIdByPane[paneId], + let panel = workspace.panels[panelId] as? TerminalPanel else { return } + panel.surface.processRemoteOutput(data) + } + + /// Routes a split of a mirror window-tab (by its panel id) to tmux + /// `split-window`, splitting the focused pane (or the window's only pane). + /// Used by the split BUTTON / `shouldSplitPane` path, which works at the + /// bonsplit-pane (tab) level rather than per mirror surface. Returns `true` + /// if handled (the caller vetoes the local split). + func requestSplit(windowPanelId panelId: UUID, vertical: Bool) -> Bool { + guard !connection.exited, let windowId = windowId(forPanel: panelId) else { return false } + let targetPane = windowMirrorByWindowId[windowId]?.activePaneId + ?? connection.windowsByID[windowId]?.paneIDsInOrder.first + guard let targetPane else { return false } + connection.send("split-window \(vertical ? "-v" : "-h") -t @\(windowId).%\(targetPane)") + return true + } + + /// The multi-pane renderer + tmux pane id for a focused mirror surface, used + /// by the split shortcut to route ⌘D to `split-window`. + func windowMirror(forSurfaceId surfaceId: UUID) -> (mirror: RemoteTmuxWindowMirror, tmuxPaneId: Int)? { + for mirror in windowMirrorByWindowId.values { + for paneId in mirror.paneIDsInOrder { + if mirror.surface(forPane: paneId)?.id == surfaceId { + return (mirror, paneId) + } + } + } + return nil + } +} diff --git a/Sources/RemoteTmuxWindow.swift b/Sources/RemoteTmuxWindow.swift new file mode 100644 index 000000000000..df5dd1bc8388 --- /dev/null +++ b/Sources/RemoteTmuxWindow.swift @@ -0,0 +1,32 @@ +import Foundation + +/// A tmux window within a mirrored session, assembled from the control-mode +/// stream (`%window-add` / `%layout-change`) by ``RemoteTmuxControlConnection``. +/// +/// Maps to a cmux tab; its ``layout`` tree (parsed by +/// ``RemoteTmuxRawLayoutParser``) maps to the tab's pane splits. +struct RemoteTmuxWindow: Sendable, Equatable, Codable { + /// tmux's numeric window id (the `@N` without the leading `@`), stable for + /// the server's lifetime. + let id: Int + /// The tmux window name (`#{window_name}`), shown as the mirrored tab's + /// title. Empty when tmux has not reported a name yet. + let name: String + /// Window width in terminal cells. + let width: Int + /// Window height in terminal cells. + let height: Int + /// The pane-layout tree for this window. + let layout: RemoteTmuxLayoutNode + + init(id: Int, name: String = "", width: Int, height: Int, layout: RemoteTmuxLayoutNode) { + self.id = id + self.name = name + self.width = width + self.height = height + self.layout = layout + } + + /// All pane ids in this window, depth-first left-to-right. + var paneIDsInOrder: [Int] { layout.paneIDsInOrder } +} diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift new file mode 100644 index 000000000000..a52a17a149fc --- /dev/null +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -0,0 +1,143 @@ +import AppKit +import Bonsplit +import Foundation +import Observation + +/// Owns the per-pane ``TerminalPanel``s and current layout for ONE mirrored tmux +/// window, so a single cmux tab can render the tmux window's full multi-pane +/// split layout side by side — with the native cmux pane chrome (each pane is a +/// real ``TerminalPanel`` rendered via ``TerminalPanelView``). +/// +/// Created lazily by ``RemoteTmuxSessionMirror`` the first time a window has more +/// than one pane; once created it owns every pane's panel for that window. The +/// remote tmux control stream is the source of truth: pane output is fed into +/// the matching surface, typed input is forwarded to that pane via `send-keys`, +/// and a user split is propagated to `split-window`. +@MainActor +@Observable +final class RemoteTmuxWindowMirror { + /// tmux window id (the `@N` without the sigil). + let windowId: Int + /// The bonsplit tab's panel id this window renders into. + let panelId: UUID + + @ObservationIgnored private weak var connection: RemoteTmuxControlConnection? + /// Creates a configured manual-I/O pane panel whose input goes to `tmuxPaneId`. + @ObservationIgnored private let makePanel: (_ tmuxPaneId: Int) -> TerminalPanel? + + /// The window's current pane layout — drives the SwiftUI split container. + private(set) var layout: RemoteTmuxLayoutNode + /// The tmux pane the user last focused (drives the focus overlay + splits). + private(set) var activePaneId: Int? + + /// ``TerminalPanel`` per tmux pane id. Not observation-tracked: the view + /// re-reads it whenever ``layout`` (which IS tracked) changes, and the two + /// are always updated together in ``reconcile(layout:)``. + @ObservationIgnored private var panelsByPaneId: [Int: TerminalPanel] = [:] + /// Stable synthetic bonsplit pane id per tmux pane (for portal hosting), + /// minted at panel-creation time so the view body is a pure read. + @ObservationIgnored private var syntheticPaneIds: [Int: PaneID] = [:] + + init( + windowId: Int, + panelId: UUID, + connection: RemoteTmuxControlConnection, + layout: RemoteTmuxLayoutNode, + makePanel: @escaping (_ tmuxPaneId: Int) -> TerminalPanel? + ) { + self.windowId = windowId + self.panelId = panelId + self.connection = connection + self.makePanel = makePanel + self.layout = layout + reconcile(layout: layout) + } + + /// All tmux pane ids currently in the window, depth-first left→right. + var paneIDsInOrder: [Int] { layout.paneIDsInOrder } + + /// The panel rendering `tmuxPaneId`, if it exists. + func panel(forPane tmuxPaneId: Int) -> TerminalPanel? { panelsByPaneId[tmuxPaneId] } + + /// The surface rendering `tmuxPaneId`, if it exists. + func surface(forPane tmuxPaneId: Int) -> TerminalSurface? { panelsByPaneId[tmuxPaneId]?.surface } + + /// The stable synthetic bonsplit pane id for `tmuxPaneId` (minted in + /// ``reconcile(layout:)``; a pure read here so it's body-safe). + func syntheticPaneID(forPane tmuxPaneId: Int) -> PaneID { + syntheticPaneIds[tmuxPaneId] ?? PaneID() + } + + /// Updates the layout, creating panels for new panes and tearing down panels + /// for panes tmux removed (surviving panes keep their panel and scrollback). + func reconcile(layout newLayout: RemoteTmuxLayoutNode) { + let livePaneIds = Set(newLayout.paneIDsInOrder) + for paneId in newLayout.paneIDsInOrder where panelsByPaneId[paneId] == nil { + guard let panel = makePanel(paneId) else { continue } + panelsByPaneId[paneId] = panel + syntheticPaneIds[paneId] = PaneID() + connection?.capturePane(paneId: paneId) + } + for (paneId, panel) in panelsByPaneId where !livePaneIds.contains(paneId) { + // Use the full panel close (detaches the portal from the registry + // BEFORE freeing the surface) so a stale portal entry can't be + // dereferenced by a later Core Animation commit. + panel.close() + panelsByPaneId[paneId] = nil + syntheticPaneIds[paneId] = nil + if activePaneId == paneId { activePaneId = nil } + } + if layout != newLayout { layout = newLayout } + } + + /// Routes a tmux `%output` to the surface for `paneId` (no-op if unknown). + func routeOutput(paneId: Int, data: Data) { + panelsByPaneId[paneId]?.surface.processRemoteOutput(data) + } + + @ObservationIgnored private var lastClientSize: (cols: Int, rows: Int)? + + /// Tells tmux to size this session's windows to the rendered cmux area, so + /// captured/live pane content matches the on-screen grid. Derives cols/rows + /// from the content pixel area and a live pane's cell size; sends + /// `refresh-client -C` only when the grid actually changes (no feedback loop: + /// the cmux area doesn't change when tmux reflows). + func updateClientSize(contentSizePoints: CGSize) { + guard contentSizePoints.width > 1, contentSizePoints.height > 1, + let cell = panelsByPaneId.values.lazy.compactMap({ $0.surface.cellSizePoints() }).first, + cell.width > 1, cell.height > 1 else { return } + let cols = max(20, Int(contentSizePoints.width / cell.width)) + let rows = max(5, Int(contentSizePoints.height / cell.height)) + guard lastClientSize?.cols != cols || lastClientSize?.rows != rows else { return } + lastClientSize = (cols, rows) + connection?.send("refresh-client -C \(cols)x\(rows)") + } + + /// Records the user-focused pane and asks tmux to make it active. + func focus(pane tmuxPaneId: Int) { + if activePaneId != tmuxPaneId { activePaneId = tmuxPaneId } + connection?.send("select-pane -t @\(windowId).%\(tmuxPaneId)") + } + + /// Propagates a user split of `tmuxPaneId` to tmux `split-window` + /// (`-h` = side-by-side, `-v` = stacked). The new pane arrives via the + /// resulting `%layout-change` → ``reconcile(layout:)``. + func requestSplit(fromPane tmuxPaneId: Int, vertical: Bool) { + connection?.send("split-window \(vertical ? "-v" : "-h") -t @\(windowId).%\(tmuxPaneId)") + } + + /// Propagates a user close of `tmuxPaneId` to tmux `kill-pane`. The pane is + /// removed via the resulting `%layout-change` (or `%window-close` if it was + /// the window's last pane). + func requestKillPane(_ tmuxPaneId: Int) { + connection?.send("kill-pane -t @\(windowId).%\(tmuxPaneId)") + } + + /// Tears down every pane panel (called when the window-tab is removed). + func teardown() { + for panel in panelsByPaneId.values { panel.close() } + panelsByPaneId.removeAll() + syntheticPaneIds.removeAll() + activePaneId = nil + } +} diff --git a/Sources/RemoteTmuxWindowMirrorView.swift b/Sources/RemoteTmuxWindowMirrorView.swift new file mode 100644 index 000000000000..cc079e396b72 --- /dev/null +++ b/Sources/RemoteTmuxWindowMirrorView.swift @@ -0,0 +1,206 @@ +import Bonsplit +import SwiftUI + +/// Renders a mirrored tmux window's multi-pane layout as nested splits inside a +/// single cmux tab. Each pane is a real ``TerminalPanel`` (rendered via +/// ``TerminalPanelView`` for native chrome) topped with a small control header +/// (split / close) that doubles as a clearly visible separator between panes. +@MainActor +struct RemoteTmuxWindowMirrorView: View { + let mirror: RemoteTmuxWindowMirror + let appearance: PanelAppearance + let isVisibleInUI: Bool + let portalPriority: Int + + var body: some View { + GeometryReader { geo in + RemoteTmuxLayoutContainer( + node: mirror.layout, + mirror: mirror, + appearance: appearance, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority + ) + .frame(width: geo.size.width, height: geo.size.height) + // Size the remote tmux window to the rendered area so pane content + // matches the on-screen grid. + .onAppear { mirror.updateClientSize(contentSizePoints: geo.size) } + .onChange(of: geo.size) { _, newSize in + mirror.updateClientSize(contentSizePoints: newSize) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + // Match the terminal background so the area never shows through as black. + .background(Color(nsColor: appearance.backgroundColor)) + } +} + +/// Recursive split container that lays out one ``RemoteTmuxLayoutNode`` subtree, +/// sizing children in proportion to their tmux cell extents. The gaps between +/// children show the divider color so both horizontal and vertical separators +/// are visible. +@MainActor +private struct RemoteTmuxLayoutContainer: View { + let node: RemoteTmuxLayoutNode + let mirror: RemoteTmuxWindowMirror + let appearance: PanelAppearance + let isVisibleInUI: Bool + let portalPriority: Int + + private let dividerThickness: CGFloat = 2 + + var body: some View { + switch node.content { + case let .pane(paneId): + leaf(paneId: paneId) + case let .horizontal(children): + splitStack(children: children, axis: .horizontal) + case let .vertical(children): + splitStack(children: children, axis: .vertical) + } + } + + @ViewBuilder + private func leaf(paneId: Int) -> some View { + if let panel = mirror.panel(forPane: paneId) { + VStack(spacing: 0) { + RemoteTmuxPaneHeader( + isActive: mirror.activePaneId == paneId, + appearance: appearance, + onFocus: { mirror.focus(pane: paneId) }, + onSplitRight: { mirror.requestSplit(fromPane: paneId, vertical: false) }, + onSplitDown: { mirror.requestSplit(fromPane: paneId, vertical: true) }, + onClose: { mirror.requestKillPane(paneId) } + ) + TerminalPanelView( + panel: panel, + paneId: mirror.syntheticPaneID(forPane: paneId), + isFocused: mirror.activePaneId == paneId, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, + isSplit: true, + appearance: appearance, + hasUnreadNotification: false, + terminalAgentContext: "", + onFocus: { mirror.focus(pane: paneId) }, + onResumeAgentHibernation: {}, + onAutoResumeAgentHibernation: {}, + onTriggerFlash: {} + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .id(paneId) + .background(Color(nsColor: appearance.backgroundColor)) + } else { + Color(nsColor: appearance.backgroundColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private func splitStack(children: [RemoteTmuxLayoutNode], axis: Axis) -> some View { + let weights = children.map { CGFloat(axis == .horizontal ? $0.width : $0.height) } + let total = max(1, weights.reduce(0, +)) + GeometryReader { geo in + let span = axis == .horizontal ? geo.size.width : geo.size.height + let usable = max(1, span - dividerThickness * CGFloat(max(0, children.count - 1))) + if axis == .horizontal { + HStack(spacing: dividerThickness) { + childViews(children, weights: weights, total: total, usable: usable, axis: axis) + } + .frame(width: geo.size.width, height: geo.size.height) + } else { + VStack(spacing: dividerThickness) { + childViews(children, weights: weights, total: total, usable: usable, axis: axis) + } + .frame(width: geo.size.width, height: geo.size.height) + } + } + // The inter-child gaps reveal this as the split divider. + .background(appearance.dividerColor) + } + + @ViewBuilder + private func childViews( + _ children: [RemoteTmuxLayoutNode], + weights: [CGFloat], + total: CGFloat, + usable: CGFloat, + axis: Axis + ) -> some View { + ForEach(children.indices, id: \.self) { index in + let dimension = usable * weights[index] / total + RemoteTmuxLayoutContainer( + node: children[index], + mirror: mirror, + appearance: appearance, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority + ) + .frame( + width: axis == .horizontal ? dimension : nil, + height: axis == .vertical ? dimension : nil + ) + } + } +} + +/// A compact per-pane control bar shown above each mirrored tmux pane: a focus +/// indicator plus split-right / split-down / close buttons (which drive tmux +/// `split-window` / `kill-pane`). Gives mirrored panes native-feeling chrome and +/// a clearly visible separator. +@MainActor +private struct RemoteTmuxPaneHeader: View { + let isActive: Bool + let appearance: PanelAppearance + let onFocus: () -> Void + let onSplitRight: () -> Void + let onSplitDown: () -> Void + let onClose: () -> Void + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(isActive ? Color.accentColor : Color.secondary.opacity(0.35)) + .frame(width: 6, height: 6) + Spacer(minLength: 0) + button( + system: "square.split.2x1", + label: String(localized: "remoteTmux.pane.splitRight", defaultValue: "Split Right"), + action: onSplitRight + ) + button( + system: "square.split.1x2", + label: String(localized: "remoteTmux.pane.splitDown", defaultValue: "Split Down"), + action: onSplitDown + ) + button( + system: "xmark", + label: String(localized: "remoteTmux.pane.close", defaultValue: "Close Pane"), + action: onClose + ) + } + .padding(.horizontal, 8) + .frame(height: 24) + .frame(maxWidth: .infinity) + .background(Color(nsColor: appearance.backgroundColor)) + .overlay(alignment: .bottom) { + Rectangle().fill(appearance.dividerColor).frame(height: 1) + } + .contentShape(Rectangle()) + .onTapGesture(perform: onFocus) + } + + private func button(system: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: system) + .font(.system(size: 11, weight: .medium)) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(label) + .accessibilityLabel(label) + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index cc5d6231c78c..63aeafc4b6c1 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3966,6 +3966,12 @@ class TabManager: ObservableObject { if selectedTabId == tabId { updateWindowTitle(for: tabs[index]) } + // A remote tmux mirror workspace rename propagates to `rename-session`. + if tabs[index].isRemoteTmuxMirror { + AppDelegate.shared?.remoteTmuxController.handleMirrorWorkspaceRenamed( + workspaceId: tabId, title: title + ) + } } func clearCustomTitle(tabId: UUID) { @@ -5205,6 +5211,12 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace, recordHistory: Bool = true) { guard tabs.count > 1 else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) + // User-initiated close of a mirrored remote tmux session kills it on the + // remote. (App quit tears down windows without calling closeWorkspace, so + // quitting still leaves remote sessions alive.) + if workspace.isRemoteTmuxMirror { + AppDelegate.shared?.remoteTmuxController.handleWorkspaceClosed(workspaceId: workspace.id) + } if recordHistory, workspace.isRestorableInSessionSnapshot, let index = tabs.firstIndex(where: { $0.id == workspace.id }) { @@ -5734,7 +5746,14 @@ class TabManager: ObservableObject { return } if tabs.count <= 1 { - // Last workspace in this window: match Close Workspace shortcut behavior. + // Last workspace in this window: it closes via the window-close path. + // For a remote-tmux mirror we deliberately do NOT kill the session + // here — closing the (last) workspace closes the window, and a window + // close only DETACHES (the remote tmux server stays alive for resume). + // Killing here would be premature: the window close can still be + // vetoed (single-window quit warning), which would destroy the remote + // session on a close the user then cancelled. Non-last session + // workspaces still kill via the closeWorkspace path below. if let window { window.performClose(nil) } else { diff --git a/Sources/TerminalController+RemoteTmux.swift b/Sources/TerminalController+RemoteTmux.swift new file mode 100644 index 000000000000..143732b17c7e --- /dev/null +++ b/Sources/TerminalController+RemoteTmux.swift @@ -0,0 +1,218 @@ +import Foundation + +/// Socket/CLI handlers for the remote-tmux (`ssh … tmux -CC`) beta feature. +/// +/// These run on the socket worker (registered in `socketWorkerV2Methods`) so +/// the SSH round-trips never block the main actor. Each handler gates on the +/// `remoteTmux` beta flag and delegates to `AppDelegate`'s +/// ``RemoteTmuxController``. +extension TerminalController { + /// `remote.tmux.sessions` — list the tmux sessions on a host. + /// + /// Params: `host` (required SSH destination/alias), optional `port` (Int), + /// optional `identity_file` (String). + nonisolated func v2RemoteTmuxSessions(id: Any?, params: [String: Any]) -> String { + guard RemoteTmuxController.isEnabled else { + return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + } + guard let host = Self.remoteTmuxHost(from: params) else { + return v2Error(id: id, code: "invalid_params", message: "host is required") + } + return v2VmCall(id: id, timeoutSeconds: 30) { + guard let controller = await MainActor.run(body: { AppDelegate.shared?.remoteTmuxController }) + else { + throw RemoteTmuxError.unreachable("app not ready") + } + let sessions = try await controller.listSessions(host: host) + return [ + "host": host.destination, + "sessions": sessions.map { Self.sessionPayload($0) }, + ] + } + } + + /// Builds a ``RemoteTmuxHost`` from socket params (`host`, `port`, `identity_file`). + /// + /// Rejects a destination (or identity file) beginning with `-`: even with the + /// `--` end-of-options guard in the argv builders, a dash-prefixed + /// destination is never a legitimate SSH alias/`user@host`, and refusing it + /// at the trust boundary is defense in depth against ssh option injection + /// (`-oProxyCommand=…` → local command execution). + nonisolated static func remoteTmuxHost(from params: [String: Any]) -> RemoteTmuxHost? { + guard let destination = (params["host"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !destination.isEmpty, + !destination.hasPrefix("-") + else { return nil } + let port = params["port"] as? Int + let identityFile = (params["identity_file"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let identityFile, identityFile.hasPrefix("-") { return nil } + return RemoteTmuxHost( + destination: destination, + port: port, + identityFile: (identityFile?.isEmpty == false) ? identityFile : nil + ) + } + + /// `remote.tmux.attach` — attach a `tmux -CC` control client to a session. + /// + /// Params: `host` (required), `session` (required tmux session name), + /// optional `create` (Bool — attach-or-create). Returns the control surface id. + nonisolated func v2RemoteTmuxAttach(id: Any?, params: [String: Any]) -> String { + guard RemoteTmuxController.isEnabled else { + return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + } + guard let host = Self.remoteTmuxHost(from: params) else { + return v2Error(id: id, code: "invalid_params", message: "host is required") + } + guard let session = Self.remoteTmuxSessionName(from: params) else { + return v2Error(id: id, code: "invalid_params", message: "session is required") + } + let createIfMissing = (params["create"] as? Bool) ?? false + return v2VmCall(id: id, timeoutSeconds: 20) { + try await MainActor.run { + guard let controller = AppDelegate.shared?.remoteTmuxController else { + throw RemoteTmuxError.unreachable("app not ready") + } + _ = try controller.attach( + host: host, + sessionName: session, + createIfMissing: createIfMissing + ) + } + return [ + "host": host.destination, + "session": session, + "attached": true, + ] + } + } + + /// `remote.tmux.open` — attach a session and mirror its active pane as a + /// live display tab in the current workspace (first sidebar-mirroring step). + nonisolated func v2RemoteTmuxOpen(id: Any?, params: [String: Any]) -> String { + guard RemoteTmuxController.isEnabled else { + return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + } + guard let host = Self.remoteTmuxHost(from: params), + let session = Self.remoteTmuxSessionName(from: params) + else { + return v2Error(id: id, code: "invalid_params", message: "host and session are required") + } + return v2VmCall(id: id, timeoutSeconds: 20) { + try await MainActor.run { + guard let controller = AppDelegate.shared?.remoteTmuxController else { + throw RemoteTmuxError.unreachable("app not ready") + } + try controller.openActivePane(host: host, sessionName: session) + } + return ["host": host.destination, "session": session, "opened": true] + } + } + + /// `remote.tmux.mirror` — mirror every tmux session on a host as its own + /// sidebar workspace (windows become tabs). Params: `host` (required). + nonisolated func v2RemoteTmuxMirror(id: Any?, params: [String: Any]) -> String { + guard RemoteTmuxController.isEnabled else { + return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + } + guard let host = Self.remoteTmuxHost(from: params) else { + return v2Error(id: id, code: "invalid_params", message: "host is required") + } + return v2VmCall(id: id, timeoutSeconds: 30) { + guard let controller = await MainActor.run(body: { AppDelegate.shared?.remoteTmuxController }) + else { + throw RemoteTmuxError.unreachable("app not ready") + } + try await controller.mirrorHost(host: host) + return ["host": host.destination, "mirrored": true] + } + } + + /// `remote.tmux.detach` — detach a control client (leaves the remote session alive). + nonisolated func v2RemoteTmuxDetach(id: Any?, params: [String: Any]) -> String { + guard RemoteTmuxController.isEnabled else { + return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + } + guard let host = Self.remoteTmuxHost(from: params), + let session = Self.remoteTmuxSessionName(from: params) + else { + return v2Error(id: id, code: "invalid_params", message: "host and session are required") + } + return v2VmCall(id: id, timeoutSeconds: 10) { + await MainActor.run { + AppDelegate.shared?.remoteTmuxController.detach(host: host, sessionName: session) + } + return ["host": host.destination, "session": session, "detached": true] + } + } + + /// `remote.tmux.state` — report a control client's observed control-mode state. + /// + /// Diagnostics surface for verifying the ghostty → cmux event pipe end to end. + nonisolated func v2RemoteTmuxState(id: Any?, params: [String: Any]) -> String { + guard RemoteTmuxController.isEnabled else { + return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + } + guard let host = Self.remoteTmuxHost(from: params), + let session = Self.remoteTmuxSessionName(from: params) + else { + return v2Error(id: id, code: "invalid_params", message: "host and session are required") + } + return v2VmCall(id: id, timeoutSeconds: 10) { + let snapshot: RemoteTmuxControlConnection.Snapshot? = await MainActor.run { + AppDelegate.shared?.remoteTmuxController + .connection(host: host, sessionName: session)? + .snapshot() + } + guard let snapshot else { + return ["host": host.destination, "session": session, "attached": false] + } + var paneBytes: [String: Int] = [:] + for (paneId, count) in snapshot.paneOutputByteCounts { + paneBytes["%\(paneId)"] = count + } + var payload: [String: Any] = [ + "host": host.destination, + "session": session, + "attached": true, + "started": snapshot.started, + "enter_received": snapshot.enterReceived, + "exited": snapshot.exited, + "window_count": snapshot.windowCount, + "window_ids": snapshot.windowIDs, + "total_output_bytes": snapshot.totalOutputBytes, + "pane_output_bytes": paneBytes, + "recent_events": snapshot.recentEvents, + ] + if let sessionId = snapshot.sessionId { + payload["session_id"] = sessionId + } + return payload + } + } + + /// Extracts a required tmux session name from socket params. + nonisolated static func remoteTmuxSessionName(from params: [String: Any]) -> String? { + guard let session = (params["session"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !session.isEmpty + else { return nil } + return session + } + + /// Serializes a session for the socket response. + nonisolated static func sessionPayload(_ session: RemoteTmuxSession) -> [String: Any] { + var dict: [String: Any] = [ + "id": session.id, + "name": session.name, + "windows": session.windowCount, + "attached": session.attached, + ] + if let created = session.createdUnix { + dict["created"] = created + } + return dict + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 220b6f4ba252..3ee8ace7f2ff 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1535,6 +1535,12 @@ class TerminalController { "workspace.remote.pty_detach", "workspace.remote.pty_bridge", "workspace.remote.pty_resize", + "remote.tmux.sessions", + "remote.tmux.attach", + "remote.tmux.detach", + "remote.tmux.state", + "remote.tmux.open", + "remote.tmux.mirror", "sidebar.custom.validate", "sidebar.custom.reload", "sidebar.custom.select", @@ -1719,6 +1725,18 @@ class TerminalController { return v2Result(id: request.id, v2WorkspaceRemotePTYBridge(params: request.params)) case "workspace.remote.pty_resize": return v2Result(id: request.id, v2WorkspaceRemotePTYResize(params: request.params)) + case "remote.tmux.sessions": + return v2RemoteTmuxSessions(id: request.id, params: request.params) + case "remote.tmux.attach": + return v2RemoteTmuxAttach(id: request.id, params: request.params) + case "remote.tmux.detach": + return v2RemoteTmuxDetach(id: request.id, params: request.params) + case "remote.tmux.state": + return v2RemoteTmuxState(id: request.id, params: request.params) + case "remote.tmux.open": + return v2RemoteTmuxOpen(id: request.id, params: request.params) + case "remote.tmux.mirror": + return v2RemoteTmuxMirror(id: request.id, params: request.params) case "sidebar.custom.validate": return v2Result(id: request.id, v2CustomSidebarValidate(params: request.params)) case "sidebar.custom.reload": diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 53f2a478e585..656fb18a69f4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -11809,6 +11809,12 @@ final class Workspace: Identifiable, ObservableObject { title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle), hasCustomTitle: panelCustomTitles[panelId] != nil ) + // A remote tmux mirror tab rename propagates to `rename-window`. + if isRemoteTmuxMirror { + AppDelegate.shared?.remoteTmuxController.handleMirrorWindowRenamed( + workspaceId: id, panelId: panelId, title: trimmed + ) + } } func isPanelPinned(_ panelId: UUID) -> Bool { @@ -12964,7 +12970,36 @@ final class Workspace: Identifiable, ObservableObject { remoteConfiguration != nil } + /// True when this workspace is an ephemeral mirror of a remote tmux session + /// (created by ``RemoteTmuxController``). Such workspaces are rebuilt from + /// the remote on each launch, so they are excluded from cmux's own session + /// snapshot/restore to avoid resurrecting stale, disconnected copies. + var isRemoteTmuxMirror: Bool = false + + /// Per-window multi-pane renderers, keyed by the window-tab's panel id. When + /// a mirrored tmux window has more than one pane, its tab renders this + /// in-tab split container (``RemoteTmuxWindowMirrorView``) instead of the + /// single-surface ``PanelContentView``. Owned by ``RemoteTmuxSessionMirror``; + /// the view layer only reads it. + @Published private(set) var remoteTmuxWindowMirrors: [UUID: RemoteTmuxWindowMirror] = [:] + + /// The multi-pane renderer for a window-tab's panel, if that window is + /// currently multi-pane. + func remoteTmuxWindowMirror(forPanelId panelId: UUID) -> RemoteTmuxWindowMirror? { + remoteTmuxWindowMirrors[panelId] + } + + /// Registers (or replaces) a window's multi-pane renderer. + func setRemoteTmuxWindowMirror(_ mirror: RemoteTmuxWindowMirror?, forPanelId panelId: UUID) { + if let mirror { + remoteTmuxWindowMirrors[panelId] = mirror + } else { + remoteTmuxWindowMirrors.removeValue(forKey: panelId) + } + } + var isRestorableInSessionSnapshot: Bool { + if isRemoteTmuxMirror { return false } guard let remoteConfiguration else { return true } return remoteConfiguration.sessionSnapshot() != nil } @@ -14497,6 +14532,17 @@ final class Workspace: Identifiable, ObservableObject { remotePTYSessionID: String? = nil, suppressWorkspaceRemoteStartupCommand: Bool = false ) -> TerminalPanel? { + // In a remote tmux mirror workspace, a new tab means "create a tmux + // window" — route it to the remote and let the resulting %window-add + // notification add the tab (one source of truth). NEVER create a local + // terminal here, even when the remote route can't be taken (dead/missing + // connection): a local tab would be an orphan the mirror can't reconcile, + // breaking the 1:1 invariant (symmetric with newBrowserSurface). A dead + // mirror workspace is torn down separately via handleSessionEndedRemotely. + if isRemoteTmuxMirror { + _ = AppDelegate.shared?.remoteTmuxController.handleMirrorNewTabRequested(workspaceId: id) + return nil + } let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView @@ -14597,6 +14643,92 @@ final class Workspace: Identifiable, ObservableObject { return newPanel } + /// Creates a configured MANUAL-I/O ``TerminalPanel`` for one remote tmux pane, + /// WITHOUT inserting it into the workspace's bonsplit/`panels` (the + /// ``RemoteTmuxWindowMirror`` owns it and renders it via ``TerminalPanelView`` + /// inside a single tab, so the pane gets the full native cmux pane chrome — + /// background, focus overlay, dividers). + func makeRemoteTmuxPanePanel(onInput: @escaping @Sendable (Data) -> Void) -> TerminalPanel { + let surface = TerminalSurface( + tabId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + manualIO: true, + manualInputHandler: onInput + ) + let panel = TerminalPanel(workspaceId: id, surface: surface) + configureNewTerminalPanel(panel) + return panel + } + + /// Mounts a remote tmux pane as a live display tab in this workspace. + /// + /// The tab is backed by a MANUAL-I/O ``TerminalSurface`` (no local process): + /// the caller feeds `%output` via ``TerminalSurface/processRemoteOutput(_:)`` + /// and receives typed input through `onInput` (→ tmux `send-keys`). Used by + /// ``RemoteTmuxController`` to render a mirrored remote tmux pane. + /// + /// - Parameter focus: when `true`, selects and reasserts AppKit keyboard + /// focus onto the created tab (a user-initiated attach). When `false` + /// (socket/background mirroring), the tab is created and selected within + /// its pane but the user's keyboard focus is left untouched, per the + /// socket focus policy. + @discardableResult + func addRemoteTmuxDisplayPane( + remotePaneId: Int, + title customTitle: String? = nil, + focus: Bool = false, + onInput: @escaping @Sendable (Data) -> Void + ) -> TerminalPanel? { + guard let paneId = bonsplitController.focusedPaneId ?? bonsplitController.allPaneIds.first + else { return nil } + + let title = customTitle ?? String(localized: "remoteTmux.tab.pane", defaultValue: "tmux pane") + let surface = TerminalSurface( + tabId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + manualIO: true, + manualInputHandler: onInput + ) + let newPanel = TerminalPanel(workspaceId: id, surface: surface) + configureNewTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = title + + guard let newTabId = bonsplitController.createTab( + title: title, + icon: "rectangle.connected.to.line.below", + kind: SurfaceKind.terminal, + inPane: paneId + ) else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + return nil + } + surfaceIdToPanelId[newTabId] = newPanel.id + if focus { + bonsplitController.focusPane(paneId) + } + bonsplitController.selectTab(newTabId) + if focus { + newPanel.focus() + } + // Reassert AppKit first-responder (keyboard focus) only on a user-initiated + // attach; a background/socket mirror must not steal focus. + applyTabSelection(tabId: newTabId, inPane: paneId, reassertAppKitFocus: focus) + return newPanel + } + + /// Updates a mirrored remote tmux tab's title (e.g. after a tmux + /// `%window-renamed`). No-ops if the panel is no longer mounted. + func updateRemoteTmuxTabTitle(panelId: UUID, title: String) { + guard let tabId = surfaceIdFromPanelId(panelId) else { return } + panelTitles[panelId] = title + guard let existing = bonsplitController.tab(tabId), existing.title != title else { return } + bonsplitController.updateTab(tabId, title: title, icon: nil, isDirty: nil) + } + private func remoteTerminalStartupCommand() -> String? { guard !suppressRemoteTerminalStartupForSessionRestoreScaffold else { return nil @@ -14624,6 +14756,9 @@ final class Workspace: Identifiable, ObservableObject { bypassRemoteProxy: Bool = false, initialDividerPosition: CGFloat? = nil ) -> BrowserPanel? { + // No local browser surfaces in a remote tmux mirror workspace (it is a + // 1:1 view of a tmux session). See ``newBrowserSurface(inPane:)``. + if isRemoteTmuxMirror { return nil } let browserEnabled = BrowserAvailabilitySettings.isEnabled() guard browserEnabled || creationPolicy.permitsCreationWhenBrowserDisabled else { if let url { @@ -14734,6 +14869,11 @@ final class Workspace: Identifiable, ObservableObject { transparentBackground: Bool = false, bypassRemoteProxy: Bool = false ) -> BrowserPanel? { + // A remote tmux mirror workspace is a 1:1 view of a tmux session (which + // has no browser concept). A local browser tab here would be an orphan + // that the mirror's rebuild() never reconciles, breaking the 1:1 + // invariant — so refuse browser creation in a mirror workspace. + if isRemoteTmuxMirror { return nil } let browserEnabled = BrowserAvailabilitySettings.isEnabled() guard browserEnabled || creationPolicy.permitsCreationWhenBrowserDisabled else { if let externalURL = url ?? initialRequest?.url { @@ -17116,6 +17256,11 @@ final class Workspace: Identifiable, ObservableObject { for panel in panels.values { guard let terminalPanel = panel as? TerminalPanel else { continue } + // Mirror-rendered window-tab panels are driven by the in-tab mirror + // view, not the workspace; never reattach/refresh their dismantled + // hostedView here (matches the visibility/follow-up skips, and avoids + // a non-converging layout follow-up loop during zoom). + if remoteTmuxWindowMirrors[terminalPanel.id] != nil { continue } guard visiblePanelIds.contains(terminalPanel.id) else { continue } let hostedView = terminalPanel.hostedView let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 @@ -17213,6 +17358,10 @@ final class Workspace: Identifiable, ObservableObject { for panel in panels.values { guard let terminalPanel = panel as? TerminalPanel else { continue } + // A multi-pane remote-tmux window-tab is rendered by its + // RemoteTmuxWindowMirrorView (its own panel's surface is not mounted), + // so the workspace must not drive that panel's portal here. + if remoteTmuxWindowMirrors[terminalPanel.id] != nil { continue } let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) if terminalPanel.hostedView.debugPortalVisibleInUI != shouldBeVisible { terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) @@ -17237,6 +17386,8 @@ final class Workspace: Identifiable, ObservableObject { for panel in panels.values { guard let terminalPanel = panel as? TerminalPanel else { continue } + // Skip mirror-rendered window-tab panels (see reconcile above). + if remoteTmuxWindowMirrors[terminalPanel.id] != nil { continue } let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) let hostedView = terminalPanel.hostedView @@ -18466,6 +18617,24 @@ extension Workspace: BonsplitDelegate { let tabCloseButtonClose = tabCloseButtonCloseTabIds.remove(tab.id) != nil let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil || tabCloseButtonClose + // Remote tmux mirror: closing a window tab means "kill that tmux window". + // Route ANY non-programmatic close (close button, ⌘W, and batch closes + // like "close others / close to the left/right") to the remote and veto + // the immediate local close — the tab is removed when tmux reports + // %window-close, which also tears the window mirror down (so a batch + // close can't abandon the mirror's pane surfaces, and the window doesn't + // reappear on the next rebuild). Programmatic closes (forceCloseTabIds, + // used by the mirror's own rebuild) are excluded — they do the actual + // removal. Falls through to the normal local close when there is no live + // mirror connection. + if isRemoteTmuxMirror, !forceCloseTabIds.contains(tab.id), + let panelId = panelIdFromSurfaceId(tab.id), + AppDelegate.shared?.remoteTmuxController.handleMirrorTabCloseRequested( + workspaceId: id, panelId: panelId + ) == true { + return false + } + if forceCloseTabIds.contains(tab.id) { if !pushClosedPanelHistoryIfEligible(for: tab, inPane: pane) { stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) @@ -18730,6 +18899,29 @@ extension Workspace: BonsplitDelegate { applyTabSelection(tabId: tab.id, inPane: pane) } + func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { + // In a remote tmux mirror, a split (button or any bonsplit-level split) + // becomes a tmux `split-window`; the new pane arrives via %layout-change. + // Veto the local split when handled. Local workspaces split normally. + guard isRemoteTmuxMirror, + let tabId = bonsplitController.selectedTab(inPane: pane)?.id, + let panelId = panelIdFromSurfaceId(tabId) else { return true } + let handled = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested( + workspaceId: id, panelId: panelId, vertical: orientation == .vertical + ) ?? false + return !handled + } + + func splitTabBar(_ controller: BonsplitController, didReorderTabsInPane pane: PaneID, orderedTabIds: [TabID]) { + // A remote tmux mirror tab reorder propagates to tmux window order. + guard isRemoteTmuxMirror else { return } + let orderedPanelIds = orderedTabIds.compactMap { panelIdFromSurfaceId($0) } + guard !orderedPanelIds.isEmpty else { return } + AppDelegate.shared?.remoteTmuxController.handleMirrorWindowsReordered( + workspaceId: id, orderedPanelIds: orderedPanelIds + ) + } + func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { #if DEBUG let now = ProcessInfo.processInfo.systemUptime diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 128777f7a43e..1c9ed10d029b 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -237,6 +237,20 @@ struct WorkspaceContentView: View { isWorkspaceManuallyUnread: isWorkspaceManuallyUnread, isWorkspaceManualUnreadRepresentative: workspaceManualUnreadPanelId == panel.id ) + if let windowMirror = workspace.remoteTmuxWindowMirror(forPanelId: panel.id) { + // Multi-pane tmux window: render its pane layout as splits + // inside this single tab. Single-pane windows keep the + // standard PanelContentView path below. + RemoteTmuxWindowMirrorView( + mirror: windowMirror, + appearance: appearance, + isVisibleInUI: isVisibleInUI, + portalPriority: workspacePortalPriority + ) + .onTapGesture { + workspace.bonsplitController.focusPane(paneId) + } + } else { PanelContentView( panel: panel, workspaceId: workspace.id, @@ -282,6 +296,7 @@ struct WorkspaceContentView: View { .onTapGesture { workspace.bonsplitController.focusPane(paneId) } + } } else { // Fallback for tabs without panels (shouldn't happen normally) EmptyPanelView(workspace: workspace, paneId: paneId) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 987b462d82da..1487c1d9332e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -691,6 +691,20 @@ struct cmuxApp: App { AppDelegate.shared?.showOpenFolderInInlineVSCodePanel() } .disabled(!TerminalDirectoryOpenTarget.vscodeInline.isAvailable()) + + if RemoteTmuxController.isEnabled { + Divider() + Button( + String( + localized: "menu.file.attachRemoteTmux", + defaultValue: "Attach Remote tmux…" + ) + ) { + AppDelegate.shared?.promptAttachRemoteTmuxHost( + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) + } + } } // Close tab/workspace diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d00c0cf5d9ae..6b11b112a8e8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 3865A0063865A0063865A006 /* AppDelegate+GlobalSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0063865B0063865B006 /* AppDelegate+GlobalSearch.swift */; }; D7AB00000000000000000001 /* AppDelegate+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */; }; 2907A0012907A0012907A001 /* AppDelegate+RecoverableMainWindowRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */; }; + 120313FA0CB27B88C13D50F7 /* AppDelegate+RemoteTmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB6146C0EDA948444A60842 /* AppDelegate+RemoteTmux.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; 725746692D9647948561044D /* AppDelegateBareSpaceShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */; }; E3309A09 /* AppDelegateEqualizeSplitsShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */; }; @@ -412,6 +413,25 @@ A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */; }; + 9C988B800BBFB3CFFD46E611 /* RemoteTmuxCommandResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */; }; + 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */; }; + F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */; }; + DCCD118CAFF7B3B9C6F184B4 /* RemoteTmuxControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */; }; + B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */; }; + E1B13CF7FE1A77324A142521 /* RemoteTmuxControlStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */; }; + 2FAA10BC0D7B50DE57F507A4 /* RemoteTmuxError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */; }; + 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */; }; + E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */; }; + 7DA09B544027E3F73AA09518 /* RemoteTmuxManualIOWrite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */; }; + C9BA962E6CB31C9FD62505BF /* RemoteTmuxRawLayoutParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */; }; + A42DAD21BD502098E446999C /* RemoteTmuxSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */; }; + 008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */; }; + 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */; }; + 1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */; }; + 6D3C19C2014FF9C754358EFF /* RemoteTmuxSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */; }; + A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */; }; + 826F6597BF05F1242E17F0C3 /* RemoteTmuxWindowMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */; }; + 063516351A6B6F642928B37D /* RemoteTmuxWindowMirrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */; }; D5037010000000000000003 /* RenderableSystemSymbol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5037010000000000000002 /* RenderableSystemSymbol.swift */; }; F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */; }; F5410000A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */; }; @@ -529,6 +549,7 @@ 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */; }; + A31B24551C6E01E86D76B07E /* TerminalController+RemoteTmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B11974CF8937E165A3FAEE /* TerminalController+RemoteTmux.swift */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; C7A50A000000000000000002 /* TerminalControllerPaneResizeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50A000000000000000001 /* TerminalControllerPaneResizeSupport.swift */; }; 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; @@ -722,6 +743,7 @@ 3865B0063865B0063865B006 /* AppDelegate+GlobalSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Search/AppDelegate+GlobalSearch.swift"; sourceTree = ""; }; D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+RecoverableMainWindowRoutes.swift"; sourceTree = ""; }; + 7AB6146C0EDA948444A60842 /* AppDelegate+RemoteTmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+RemoteTmux.swift"; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateBareSpaceShortcutRoutingTests.swift; sourceTree = ""; }; E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateEqualizeSplitsShortcutTests.swift; sourceTree = ""; }; @@ -1073,6 +1095,25 @@ D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackRuntimeBridge.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteShellSessionParsing.swift; sourceTree = ""; }; + 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxCommandResult.swift; sourceTree = ""; }; + 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlConnection.swift; sourceTree = ""; }; + 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxController.swift; sourceTree = ""; }; + 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlMessage.swift; sourceTree = ""; }; + C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlParserTests.swift; sourceTree = ""; }; + FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlStreamParser.swift; sourceTree = ""; }; + AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxError.swift; sourceTree = ""; }; + 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxHost.swift; sourceTree = ""; }; + 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutNode.swift; sourceTree = ""; }; + 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxManualIOWrite.swift; sourceTree = ""; }; + 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxRawLayoutParser.swift; sourceTree = ""; }; + 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSession.swift; sourceTree = ""; }; + E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParser.swift; sourceTree = ""; }; + 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParserTests.swift; sourceTree = ""; }; + D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionMirror.swift; sourceTree = ""; }; + E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSSHTransport.swift; sourceTree = ""; }; + AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindow.swift; sourceTree = ""; }; + FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowMirror.swift; sourceTree = ""; }; + E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowMirrorView.swift; sourceTree = ""; }; D5037010000000000000002 /* RenderableSystemSymbol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderableSystemSymbol.swift; sourceTree = ""; }; F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderHermesTests.swift; sourceTree = ""; }; F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderResumeTests.swift; sourceTree = ""; }; @@ -1186,6 +1227,7 @@ 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; + 95B11974CF8937E165A3FAEE /* TerminalController+RemoteTmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+RemoteTmux.swift"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; C7A50A000000000000000001 /* TerminalControllerPaneResizeSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerPaneResizeSupport.swift; sourceTree = ""; }; 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; @@ -1789,6 +1831,25 @@ A5001222 /* WindowAccessor.swift */, CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */, A5001611 /* SessionPersistence.swift */, + E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */, + FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */, + 7AB6146C0EDA948444A60842 /* AppDelegate+RemoteTmux.swift */, + D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */, + 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */, + 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */, + 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */, + FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */, + 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */, + AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */, + 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */, + 95B11974CF8937E165A3FAEE /* TerminalController+RemoteTmux.swift */, + 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */, + E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */, + AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */, + E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */, + 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */, + 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */, + 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */, A5001671 /* SessionRestoredTerminalCommandStore.swift */, B35751000000000000000001 /* TmuxResumeParser.swift */, A5001661 /* RestorableAgentSession.swift */, @@ -1991,6 +2052,8 @@ B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, + C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */, + 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */, C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */, FEED49850000000000000002 /* FeedEventClassificationTests.swift */, 42D69572C8D276745E502B94 /* SessionIndexViewTests.swift */, @@ -2425,6 +2488,7 @@ 3865A0063865A0063865A006 /* AppDelegate+GlobalSearch.swift in Sources */, D7AB00000000000000000001 /* AppDelegate+MoveTabToNewWorkspace.swift in Sources */, 2907A0012907A0012907A001 /* AppDelegate+RecoverableMainWindowRoutes.swift in Sources */, + 120313FA0CB27B88C13D50F7 /* AppDelegate+RemoteTmux.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A11EAB000000000000000000 /* AppearanceSettings.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, @@ -2628,6 +2692,23 @@ D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */, + 9C988B800BBFB3CFFD46E611 /* RemoteTmuxCommandResult.swift in Sources */, + 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */, + F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */, + DCCD118CAFF7B3B9C6F184B4 /* RemoteTmuxControlMessage.swift in Sources */, + E1B13CF7FE1A77324A142521 /* RemoteTmuxControlStreamParser.swift in Sources */, + 2FAA10BC0D7B50DE57F507A4 /* RemoteTmuxError.swift in Sources */, + 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */, + E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */, + 7DA09B544027E3F73AA09518 /* RemoteTmuxManualIOWrite.swift in Sources */, + C9BA962E6CB31C9FD62505BF /* RemoteTmuxRawLayoutParser.swift in Sources */, + A42DAD21BD502098E446999C /* RemoteTmuxSession.swift in Sources */, + 008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */, + 1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */, + 6D3C19C2014FF9C754358EFF /* RemoteTmuxSSHTransport.swift in Sources */, + A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */, + 826F6597BF05F1242E17F0C3 /* RemoteTmuxWindowMirror.swift in Sources */, + 063516351A6B6F642928B37D /* RemoteTmuxWindowMirrorView.swift in Sources */, D5037010000000000000003 /* RenderableSystemSymbol.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, C13519000000000000000005 /* RestorableAgentTypes.swift in Sources */, @@ -2693,6 +2774,7 @@ C7A506000000000000000002 /* TaskManagerView.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */, + A31B24551C6E01E86D76B07E /* TerminalController+RemoteTmux.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, C7A50A000000000000000002 /* TerminalControllerPaneResizeSupport.swift in Sources */, C7A505000000000000000002 /* TerminalControllerTopSupport.swift in Sources */, @@ -2952,6 +3034,8 @@ F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */, C135190000000000000000A1 /* PreferredEditorSettingsTests.swift in Sources */, C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */, + B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, + 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */, F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */, F5410000A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift in Sources */, F5410002A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift new file mode 100644 index 000000000000..94fb70115610 --- /dev/null +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Behavior tests for the incremental `tmux -CC` control-mode stream parser and +/// the raw window-layout parser. These exercise the real parsers on byte input +/// and assert the emitted messages / nodes — not source text. +@Suite struct RemoteTmuxControlParserTests { + /// Feeds a control-mode protocol string (lines are `\r\n`-terminated as the + /// SSH `-tt` pty delivers them) and collects the emitted messages. + private func parse(_ protocolText: String) -> [RemoteTmuxControlMessage] { + var parser = RemoteTmuxControlStreamParser() + return parser.feed(Data(protocolText.utf8)) + } + + // MARK: - Command-block framing (the FIFO-desync fix) + + @Test func blockTerminatedOnlyByMatchingCommandNumber() { + // The captured output inside command #7's block itself contains a line + // that looks like a terminator for a *different* command (#9). tmux does + // not escape command output, so that inner line must be treated as + // content; only `%end … 7 …` (matching the %begin's command number) + // closes the block. Matching on prefix alone truncates the block and + // permanently desyncs the command-correlation FIFO. + let messages = parse( + "%begin 1700000000 7 1\r\n" + + "captured pane line one\r\n" + + "%end 1700000000 9 1\r\n" + + "captured pane line two\r\n" + + "%end 1700000000 7 1\r\n" + ) + #expect(messages == [ + .commandResult( + commandNumber: 7, + lines: [ + "captured pane line one", + "%end 1700000000 9 1", + "captured pane line two", + ], + isError: false + ) + ]) + } + + @Test func errorBlockTerminatesAndIsFlagged() { + let messages = parse( + "%begin 1700000000 3 1\r\n" + + "no such window\r\n" + + "%error 1700000000 3 1\r\n" + ) + #expect(messages == [ + .commandResult(commandNumber: 3, lines: ["no such window"], isError: true) + ]) + } + + @Test func blockContentPreservesEscapeBackslash() { + // `capture-pane -e` output can contain ESC `\` (an OSC String Terminator). + // ST stripping is scoped to notification lines, so block content must + // survive verbatim — otherwise the painted pane loses bytes. + let esc = "\u{1b}\\" // ESC backslash (ST) + let messages = parse( + "%begin 1700000000 4 0\r\n" + + "title\(esc)tail\r\n" + + "%end 1700000000 4 0\r\n" + ) + #expect(messages == [ + .commandResult(commandNumber: 4, lines: ["title\(esc)tail"], isError: false) + ]) + } + + @Test func enterDCSIsStrippedAndEmittedBeforeFirstBlock() { + // The real stream prepends the `ESC P 1000 p` enter sequence to the + // first %begin line; the parser emits `.enter` and strips the framing. + let enter = "\u{1b}P1000p" + let messages = parse( + enter + "%begin 1700000000 1 0\r\n" + + "ok\r\n" + + "%end 1700000000 1 0\r\n" + ) + #expect(messages == [ + .enter, + .commandResult(commandNumber: 1, lines: ["ok"], isError: false), + ]) + } + + @Test func partialLinesBufferAcrossFeeds() { + var parser = RemoteTmuxControlStreamParser() + // A notification split mid-line across two chunks must not emit early. + #expect(parser.feed(Data("%window-ad".utf8)).isEmpty) + let messages = parser.feed(Data("d @4\r\n".utf8)) + #expect(messages == [.windowAdd(windowId: 4)]) + } + + // MARK: - %output octal unescaping (the overflow-trap fix) + + @Test func outputUnescapesValidOctal() { + // \033 = ESC, \012 = newline. + let messages = parse("%output %2 hi\\012\\033[1m\r\n") + #expect(messages == [ + .output(paneId: 2, data: Data([0x68, 0x69, 0x0a, 0x1b, 0x5b, 0x31, 0x6d])) + ]) + } + + @Test func outputDoesNotTrapOnOutOfRangeOctal() { + // \777 = 511, outside a byte: must be emitted literally, never trapped. + let messages = parse("%output %5 \\777x\r\n") + #expect(messages == [ + .output(paneId: 5, data: Data("\\777x".utf8)) + ]) + } + + @Test func sessionChangedKeepsMultiWordName() { + let messages = parse("%session-changed $1 my session name\r\n") + #expect(messages == [.sessionChanged(sessionId: 1, name: "my session name")]) + } + + @Test func layoutChangeCarriesRawLayoutString() { + let messages = parse("%layout-change @4 f92f,80x24,0,0,1 @4 1\r\n") + #expect(messages == [.layoutChange(windowId: 4, layout: "f92f,80x24,0,0,1")]) + } + + // MARK: - Raw layout parser + + @Test func parsesLeafLayoutWithChecksum() { + let node = RemoteTmuxRawLayoutParser.parse("f92f,80x24,0,0,1") + #expect(node == RemoteTmuxLayoutNode( + width: 80, height: 24, x: 0, y: 0, content: .pane(1) + )) + } + + @Test func parsesLeafLayoutWithoutChecksum() { + let node = RemoteTmuxRawLayoutParser.parse("80x24,0,0,7") + #expect(node?.content == .pane(7)) + } + + @Test func parsesHorizontalSplit() { + let node = RemoteTmuxRawLayoutParser.parse("abcd,120x40,0,0{60x40,0,0,4,59x40,61,0,5}") + #expect(node == RemoteTmuxLayoutNode( + width: 120, height: 40, x: 0, y: 0, + content: .horizontal([ + RemoteTmuxLayoutNode(width: 60, height: 40, x: 0, y: 0, content: .pane(4)), + RemoteTmuxLayoutNode(width: 59, height: 40, x: 61, y: 0, content: .pane(5)), + ]) + )) + #expect(node?.paneIDsInOrder == [4, 5]) + } + + @Test func parsesVerticalSplit() { + let node = RemoteTmuxRawLayoutParser.parse("abcd,80x40,0,0[80x20,0,0,1,80x19,0,21,2]") + #expect(node?.content == .vertical([ + RemoteTmuxLayoutNode(width: 80, height: 20, x: 0, y: 0, content: .pane(1)), + RemoteTmuxLayoutNode(width: 80, height: 19, x: 0, y: 21, content: .pane(2)), + ])) + } + + @Test func parsesNestedSplit() { + // A horizontal split whose right child is itself a vertical split. + let node = RemoteTmuxRawLayoutParser.parse( + "abcd,120x40,0,0{60x40,0,0,4,59x40,61,0[59x20,61,0,5,59x19,61,21,8]}" + ) + #expect(node?.paneIDsInOrder == [4, 5, 8]) + } + + @Test func rejectsSingleChildSplit() { + // A split must have at least two children; one child is malformed. + #expect(RemoteTmuxRawLayoutParser.parse("abcd,60x40,0,0{60x40,0,0,4}") == nil) + } + + @Test func rejectsGarbageLayout() { + #expect(RemoteTmuxRawLayoutParser.parse("not-a-layout") == nil) + #expect(RemoteTmuxRawLayoutParser.parse("") == nil) + // Trailing junk after a valid node fails (cursor must reach the end). + #expect(RemoteTmuxRawLayoutParser.parse("80x24,0,0,1xyz") == nil) + } +} diff --git a/cmuxTests/RemoteTmuxSessionListParserTests.swift b/cmuxTests/RemoteTmuxSessionListParserTests.swift new file mode 100644 index 000000000000..74ecdcc2cc08 --- /dev/null +++ b/cmuxTests/RemoteTmuxSessionListParserTests.swift @@ -0,0 +1,55 @@ +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Behavior tests for parsing remote `tmux list-sessions -F` output into +/// ``RemoteTmuxSession`` values. Exercises the real parser, not source text. +@Suite struct RemoteTmuxSessionListParserTests { + @Test func parsesTabSeparatedSessions() { + let output = "$0\tmain\t3\t1\t1780000000\n$1\tscratch\t2\t0\t1780000001\n" + let sessions = RemoteTmuxSessionListParser.parse(output) + #expect(sessions.count == 2) + #expect(sessions[0] == RemoteTmuxSession( + id: "$0", name: "main", windowCount: 3, attached: true, createdUnix: 1780000000 + )) + #expect(sessions[1] == RemoteTmuxSession( + id: "$1", name: "scratch", windowCount: 2, attached: false, createdUnix: 1780000001 + )) + } + + @Test func attachedReflectsNonZeroCount() { + // tmux reports session_attached as a client count, not a 0/1 boolean. + let output = "$5\twork\t1\t2\t1780000002" + let sessions = RemoteTmuxSessionListParser.parse(output) + #expect(sessions.count == 1) + #expect(sessions[0].attached == true) + } + + @Test func emptyOutputYieldsNoSessions() { + #expect(RemoteTmuxSessionListParser.parse("").isEmpty) + #expect(RemoteTmuxSessionListParser.parse("\n\n").isEmpty) + } + + @Test func skipsMalformedLinesButKeepsValidOnes() { + // A short line (missing window count) is skipped; the valid line remains. + let output = "garbage-without-tabs\n$2\tcmux-probe\t1\t0\t1780000003\n" + let sessions = RemoteTmuxSessionListParser.parse(output) + #expect(sessions.count == 1) + #expect(sessions[0].id == "$2") + #expect(sessions[0].name == "cmux-probe") + } + + @Test func toleratesMissingTrailingFields() { + // Only id+name+windows present; attached defaults false, created nil. + let output = "$9\tminimal\t4" + let sessions = RemoteTmuxSessionListParser.parse(output) + #expect(sessions.count == 1) + #expect(sessions[0] == RemoteTmuxSession( + id: "$9", name: "minimal", windowCount: 4, attached: false, createdUnix: nil + )) + } +} diff --git a/vendor/bonsplit b/vendor/bonsplit index ddb46fe94fbb..aa66547ecd49 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit ddb46fe94fbbd1efd062d80371ca2455c4296eb5 +Subproject commit aa66547ecd4978ee477f0b57e993b515b5596b4c From 6b4d06d7e458bc0866403ae766a4945102446373 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Fri, 5 Jun 2026 23:46:21 +0300 Subject: [PATCH 02/73] Fix remote tmux TUI rendering: sync client size for single-pane windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `ssh -tt … tmux -CC attach`, ssh's stdio are pipes owned by cmux (no local TTY), so the remote tmux control client defaults to 80x24. The multi-pane mirror already sent `refresh-client -C`, but the single-pane display path (the common case where a claude / claude agents TUI runs) and the openActivePane attach path never reported a size — so a freshly attached session stayed at 80x24 while cmux rendered a larger surface, and TUIs painted into a mismatched grid (doubled borders, overlapping output). - TerminalSurface gains `onManualGridResize`, fired from `updateSize` on a real cell-grid change while the surface is on screen (exact cols/rows from libghostty, deduped). - New `RemoteTmuxControlConnection.setClientSize(columns:rows:)` is the single sizing entrypoint; the single-pane display path, the openActivePane attach path, and the multi-pane window mirror all route through it. - `reconcileWindowMirror` clears the single-pane hook when a window escalates to multi-pane, so the two sizing paths can't both drive one connection. Co-Authored-By: Claude Opus 4.8 --- Sources/GhosttyTerminalView.swift | 26 +++++++++++++++++++++++ Sources/RemoteTmuxControlConnection.swift | 14 ++++++++++++ Sources/RemoteTmuxController.swift | 6 ++++++ Sources/RemoteTmuxSessionMirror.swift | 15 +++++++++++++ Sources/RemoteTmuxWindowMirror.swift | 2 +- Sources/Workspace.swift | 4 +++- 6 files changed, 65 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2635812e7474..2bff7254f509 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5344,6 +5344,16 @@ final class TerminalSurface: Identifiable, ObservableObject { /// remote-tmux pane display surfaces. let manualIO: Bool private let manualInputHandler: (@Sendable (Data) -> Void)? + /// For MANUAL-I/O remote tmux display surfaces: invoked on the main actor + /// whenever the rendered grid (columns × rows) changes, so the owner can size + /// the remote tmux client (`refresh-client -C`) to match. Without it a freshly + /// attached session stays at ssh's default 80×24 while cmux renders a much + /// larger surface, and TUIs (claude, claude agents) paint into the wrong grid — + /// doubled borders, overlapping output. + var onManualGridResize: (@MainActor (_ columns: Int, _ rows: Int) -> Void)? + /// Last grid reported through ``onManualGridResize`` (so we fire only on a real + /// column/row change, not on every sub-cell pixel nudge). + private var lastReportedManualGrid: (columns: Int, rows: Int)? /// Retained userdata for the MANUAL-mode `io_write_cb`; released alongside /// the surface (see ``teardownSurface()``). private var manualIOContext: Unmanaged? @@ -6836,6 +6846,22 @@ final class TerminalSurface: Identifiable, ObservableObject { lastPixelHeight = hpx } + // Remote tmux display surfaces: keep the remote tmux client sized to the + // rendered grid so a freshly attached session doesn't stay at ssh's + // default 80×24 (which mangles TUIs like claude / claude agents). Only + // report when actually on screen and when the cell grid — not just the + // pixel area — changed. + if manualIO, let report = onManualGridResize, attachedView?.window != nil { + let grid = ghostty_surface_size(surface) + let cols = Int(grid.columns) + let rows = Int(grid.rows) + if cols > 1, rows > 1, + lastReportedManualGrid?.columns != cols || lastReportedManualGrid?.rows != rows { + lastReportedManualGrid = (cols, rows) + report(cols, rows) + } + } + // Let Ghostty continue rendering on its own wakeups for steady-state frames. return true } diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index c6c13566ba84..3559d2da7b1a 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -166,6 +166,20 @@ final class RemoteTmuxControlConnection { sendInternal(command, kind: .other) } + /// Sizes the tmux control client to `columns`×`rows` cells (tmux + /// `refresh-client -C`) so the remote windows/panes reflow to the rendered + /// cmux grid. Without this a freshly attached session stays at ssh's default + /// 80×24 and TUIs (claude, claude agents) render mangled. No-ops once the + /// connection has exited or for a degenerate grid. + /// + /// This is the single sizing entrypoint every remote-tmux render path routes + /// through (the single-pane display surface and the multi-pane window mirror), + /// so client sizing stays one shared behavior rather than duplicated sends. + func setClientSize(columns: Int, rows: Int) { + guard !exited, columns > 0, rows > 0 else { return } + send("refresh-client -C \(columns)x\(rows)") + } + /// Requests the current window list + layouts (used to (re)build topology). /// /// `#{window_name}` is placed last because it can contain spaces, while the diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index d7ffa26c9232..5cce19d1664b 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -149,6 +149,12 @@ final class RemoteTmuxController { focus: focus, onInput: { [weak connection] data in Task { @MainActor in connection?.sendKeys(paneId: paneId, data: data) } + }, + // Size the remote tmux client to this display surface's rendered grid, + // so a single attached pane doesn't stay at ssh's default 80×24 and + // render TUIs mangled (matches the session-mirror display path). + onResize: { [weak connection] columns, rows in + connection?.setClientSize(columns: columns, rows: rows) } ) else { return } displayPanels[panelKey] = panel diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 3079eb87506d..e843df33c091 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -116,6 +116,14 @@ final class RemoteTmuxSessionMirror { focus: false, onInput: { [weak connection] data in Task { @MainActor in connection?.sendKeys(paneId: firstPaneId, data: data) } + }, + // Size the remote tmux client to the rendered grid so a single- + // pane window (the common case — where a claude / claude agents + // TUI runs) doesn't stay at ssh's default 80×24 and render + // mangled. The multi-pane path handles this via the window + // mirror's own geometry. + onResize: { [weak connection] columns, rows in + connection?.setClientSize(columns: columns, rows: rows) } ) else { continue } panelIdByWindow[windowId] = panel.id @@ -169,6 +177,13 @@ final class RemoteTmuxSessionMirror { ) windowMirrorByWindowId[windowId] = mirror workspace.setRemoteTmuxWindowMirror(mirror, forPanelId: panelId) + // The window mirror now owns client sizing for this window (it sends + // refresh-client -C for the whole multi-pane area). Clear the original + // single-pane display surface's resize hook so both paths don't drive the + // same connection with differently-computed sizes. + if let panel = workspace.panels[panelId] as? TerminalPanel { + panel.surface.onManualGridResize = nil + } } /// The tab title for a mirrored window: the tmux window name, or a localized diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index a52a17a149fc..2d9cdf1c2907 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -110,7 +110,7 @@ final class RemoteTmuxWindowMirror { let rows = max(5, Int(contentSizePoints.height / cell.height)) guard lastClientSize?.cols != cols || lastClientSize?.rows != rows else { return } lastClientSize = (cols, rows) - connection?.send("refresh-client -C \(cols)x\(rows)") + connection?.setClientSize(columns: cols, rows: rows) } /// Records the user-focused pane and asks tmux to make it active. diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 656fb18a69f4..e09e8316f537 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14678,7 +14678,8 @@ final class Workspace: Identifiable, ObservableObject { remotePaneId: Int, title customTitle: String? = nil, focus: Bool = false, - onInput: @escaping @Sendable (Data) -> Void + onInput: @escaping @Sendable (Data) -> Void, + onResize: (@MainActor (_ columns: Int, _ rows: Int) -> Void)? = nil ) -> TerminalPanel? { guard let paneId = bonsplitController.focusedPaneId ?? bonsplitController.allPaneIds.first else { return nil } @@ -14691,6 +14692,7 @@ final class Workspace: Identifiable, ObservableObject { manualIO: true, manualInputHandler: onInput ) + surface.onManualGridResize = onResize let newPanel = TerminalPanel(workspaceId: id, surface: surface) configureNewTerminalPanel(newPanel) panels[newPanel.id] = newPanel From 8c8d16ecde77005f31cbe7b628379dd7f246dc31 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 6 Jun 2026 00:27:06 +0300 Subject: [PATCH 03/73] Remote tmux: track remote working directory + paste images to the host Two polish fixes for the remote-tmux mirror. Working directory: a mirrored tab's folder was stuck at "~" because cmux never read the remote pane's cwd. Now each mirrored pane queries `pane_current_path` once (`display-message`) and subscribes for live updates (`refresh-client -B`), parsed via a new `%subscription-changed` message and a new `onPaneCwd` connection observer routed to `workspace.updatePanelDirectory`. Wired at all three pane-creation sites (session-mirror single-pane, window-mirror multi-pane, openActivePane). For a multi-pane window only the active pane's directory is projected onto the tab (per-pane cache + `onActivePaneChanged`), so a background pane can't hijack the folder; the cache is pruned to live panes on each rebuild. Image paste: pasting a screenshot into a remote tmux pane inserted a macOS-local /var/folders path the remote can't read. `resolvedImageTransferTarget()` now routes remote-tmux surfaces through `.detectedSSH(host.detectedSSHSession())`, reusing the existing scp-upload seam over the host's ControlMaster socket, so the image lands on the remote and the remote path is inserted. Co-Authored-By: Claude Opus 4.8 --- Sources/RemoteTmuxControlConnection.swift | 70 +++++++++++++++++++- Sources/RemoteTmuxControlMessage.swift | 7 ++ Sources/RemoteTmuxControlStreamParser.swift | 8 +++ Sources/RemoteTmuxController.swift | 41 ++++++++++++ Sources/RemoteTmuxHost.swift | 25 +++++++ Sources/RemoteTmuxSessionMirror.swift | 72 +++++++++++++++++++++ Sources/RemoteTmuxWindowMirror.swift | 6 ++ Sources/TerminalImageTransfer.swift | 7 ++ 8 files changed, 235 insertions(+), 1 deletion(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 3559d2da7b1a..17248f03b74f 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -27,6 +27,8 @@ final class RemoteTmuxControlConnection { // it), so events MUST fan out to all consumers — a single overwritable // closure silently cut off whichever consumer wired up first. private var paneOutputObservers: [ObserverToken: (_ paneId: Int, _ data: Data) -> Void] = [:] + private var paneCwdObservers: [ObserverToken: (_ paneId: Int, _ path: String) -> Void] = [:] + private var activePaneObservers: [ObserverToken: (_ windowId: Int, _ paneId: Int) -> Void] = [:] private var topologyObservers: [ObserverToken: () -> Void] = [:] private var exitObservers: [ObserverToken: () -> Void] = [:] @@ -53,7 +55,14 @@ final class RemoteTmuxControlConnection { private let createIfMissing: Bool private let maxRecentEvents = 100 - private enum CommandKind: Equatable { case listWindows, capturePane(Int), paneCursor(Int), other } + private enum CommandKind: Equatable { + case listWindows, capturePane(Int), paneCursor(Int), panePath(Int), other + } + + /// Subscription-name prefix for per-pane `pane_current_path` (`refresh-client -B`). + /// The tmux pane id is appended so an inbound `%subscription-changed` can be + /// routed back to its pane; defined once so the writer and reader can't drift. + private static let cwdSubscriptionPrefix = "cmux_cwd_" init(host: RemoteTmuxHost, sessionName: String, createIfMissing: Bool = false) { self.host = host @@ -72,16 +81,26 @@ final class RemoteTmuxControlConnection { /// /// - Parameters: /// - onPaneOutput: receives every `%output` (raw, octal-unescaped bytes). + /// - onPaneCwd: receives a pane's working directory (`pane_current_path`), + /// both the initial value and live changes (see ``requestPanePath(paneId:)`` + /// and ``subscribePanePath(paneId:)``). + /// - onActivePaneChanged: fires when a window's active pane changes + /// (`%window-pane-changed`), so consumers can re-project per-pane state + /// (e.g. the active pane's directory) onto the window's tab. /// - onTopologyChanged: fires when the window/pane topology changes. /// - onExit: fires once when control mode ends. @discardableResult func addObserver( onPaneOutput: ((_ paneId: Int, _ data: Data) -> Void)? = nil, + onPaneCwd: ((_ paneId: Int, _ path: String) -> Void)? = nil, + onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)? = nil, onTopologyChanged: (() -> Void)? = nil, onExit: (() -> Void)? = nil ) -> ObserverToken { let token = ObserverToken() if let onPaneOutput { paneOutputObservers[token] = onPaneOutput } + if let onPaneCwd { paneCwdObservers[token] = onPaneCwd } + if let onActivePaneChanged { activePaneObservers[token] = onActivePaneChanged } if let onTopologyChanged { topologyObservers[token] = onTopologyChanged } if let onExit { exitObservers[token] = onExit } return token @@ -90,6 +109,8 @@ final class RemoteTmuxControlConnection { /// Deregisters the callbacks registered under `token`. func removeObserver(_ token: ObserverToken) { paneOutputObservers[token] = nil + paneCwdObservers[token] = nil + activePaneObservers[token] = nil topologyObservers[token] = nil exitObservers[token] = nil } @@ -98,6 +119,14 @@ final class RemoteTmuxControlConnection { for callback in paneOutputObservers.values { callback(paneId, data) } } + private func emitPaneCwd(_ paneId: Int, _ path: String) { + for callback in paneCwdObservers.values { callback(paneId, path) } + } + + private func emitActivePaneChanged(_ windowId: Int, _ paneId: Int) { + for callback in activePaneObservers.values { callback(windowId, paneId) } + } + private func notifyTopologyChanged() { for callback in topologyObservers.values { callback() } } @@ -205,6 +234,33 @@ final class RemoteTmuxControlConnection { ) } + /// One-shot query of a pane's working directory (`pane_current_path`), + /// delivered to the cwd observers. Guarantees an initial folder for the + /// mirrored tab even on tmux builds without control-mode subscriptions. + func requestPanePath(paneId: Int) { + sendInternal( + "display-message -p -t %\(paneId) -F \"#{pane_current_path}\"", + kind: .panePath(paneId) + ) + } + + /// Subscribes to live `pane_current_path` changes for `paneId` via tmux + /// control-mode `refresh-client -B`, so a remote `cd` updates the mirrored + /// tab's folder without polling. tmux emits the value once on subscribe and + /// again on every change as `%subscription-changed cmux_cwd_ … : `. + /// Best-effort: on tmux builds that don't support subscriptions the command is + /// a no-op and ``requestPanePath(paneId:)`` still supplies the initial folder. + func subscribePanePath(paneId: Int) { + send("refresh-client -B \(Self.cwdSubscriptionPrefix)\(paneId):%\(paneId):#{pane_current_path}") + } + + /// Removes the live `pane_current_path` subscription for `paneId` (issued once + /// the pane is gone). tmux also drops a dead pane's subscriptions on its own; + /// this keeps the client's subscription set tidy across split/close churn. + func unsubscribePanePath(paneId: Int) { + send("refresh-client -B \(Self.cwdSubscriptionPrefix)\(paneId)") + } + /// Sends literal key bytes to a pane via tmux `send-keys -H` (hex-encoded), /// which is binary-safe and needs no shell quoting. func sendKeys(paneId: Int, data: Data) { @@ -331,8 +387,16 @@ final class RemoteTmuxControlConnection { notifyTopologyChanged() case let .windowPaneChanged(windowId, paneId): activePaneByWindow[windowId] = paneId + emitActivePaneChanged(windowId, paneId) case let .sessionWindowChanged(_, windowId): record("session-window-changed @\(windowId)") + case let .subscriptionChanged(name, value): + // cmux subscribes each pane's working directory as "cmux_cwd_". + if name.hasPrefix(Self.cwdSubscriptionPrefix), + let paneId = Int(name.dropFirst(Self.cwdSubscriptionPrefix.count)) { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !path.isEmpty { emitPaneCwd(paneId, path) } + } case let .commandResult(_, lines, isError): handleCommandResult(lines: lines, isError: isError) case .ignoredNotification, .unparsed: @@ -388,6 +452,10 @@ final class RemoteTmuxControlConnection { emitPaneOutput(paneId, data) } } + case let .panePath(paneId): + if let path = lines.first?.trimmingCharacters(in: .whitespaces), !path.isEmpty { + emitPaneCwd(paneId, path) + } case .other: break } diff --git a/Sources/RemoteTmuxControlMessage.swift b/Sources/RemoteTmuxControlMessage.swift index ec9a7dce9271..0cf46b96f6af 100644 --- a/Sources/RemoteTmuxControlMessage.swift +++ b/Sources/RemoteTmuxControlMessage.swift @@ -41,6 +41,13 @@ enum RemoteTmuxControlMessage: Sendable, Equatable { /// `%session-window-changed $ @` — the active window in a session changed. case sessionWindowChanged(sessionId: Int, windowId: Int) + /// `%subscription-changed … : ` — a `refresh-client -B` + /// subscription's value changed. cmux subscribes per-pane `pane_current_path` + /// for live working-directory tracking. Parsed leniently: `name` is the first + /// field and `value` is everything after the first ` : ` separator, so the + /// version-variable middle fields (session/window/pane/flags) are ignored. + case subscriptionChanged(name: String, value: String) + /// The coalesced output of one command block (`%begin`…`%end`/`%error`). case commandResult(commandNumber: Int, lines: [String], isError: Bool) diff --git a/Sources/RemoteTmuxControlStreamParser.swift b/Sources/RemoteTmuxControlStreamParser.swift index f52215145108..d8e46bb93750 100644 --- a/Sources/RemoteTmuxControlStreamParser.swift +++ b/Sources/RemoteTmuxControlStreamParser.swift @@ -130,6 +130,14 @@ struct RemoteTmuxControlStreamParser { let wid = Self.fieldId(line, 2, sigil: "@") else { return .unparsed(line) } return .sessionWindowChanged(sessionId: sid, windowId: wid) } + if line.hasPrefix("%subscription-changed ") { + guard let name = Self.field(line, 1) else { return .ignoredNotification(line) } + // The value is everything after the first " : " separator. The middle + // fields (session/window/pane/flags) vary by tmux version, so key off + // the subscription name instead of a fixed field index. + let value = line.range(of: " : ").map { String(line[$0.upperBound...]) } ?? "" + return .subscriptionChanged(name: name, value: value) + } if line.hasPrefix("%") { return .ignoredNotification(line) } return .unparsed(line) } diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 5cce19d1664b..596bf535e417 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -122,6 +122,9 @@ final class RemoteTmuxController { onPaneOutput: { [weak self] paneId, data in self?.displayPanels["\(key)\u{1}\(paneId)"]?.surface.processRemoteOutput(data) }, + onPaneCwd: { [weak self] paneId, path in + self?.applyDisplayPaneCwd(key: key, paneId: paneId, path: path) + }, onTopologyChanged: { [weak self, weak connection] in guard let self, let connection else { return } self.buildDisplayIfNeeded(connection: connection, key: key, workspaceId: targetWorkspaceId, focus: focus) @@ -160,6 +163,22 @@ final class RemoteTmuxController { displayPanels[panelKey] = panel // Prime the pane with its current contents so it isn't blank on open. connection.capturePane(paneId: paneId) + // Track the pane's working directory (initial + live) so the tab shows the + // remote cwd instead of staying at "~". + connection.requestPanePath(paneId: paneId) + connection.subscribePanePath(paneId: paneId) + } + + /// Applies a display pane's reported working directory to its tab. Resolves the + /// workspace from the panel's own surface (not a captured id) so it stays + /// correct if the tab was moved to another workspace/window after attach. + private func applyDisplayPaneCwd(key: String, paneId: Int, path: String) { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let panel = displayPanels["\(key)\u{1}\(paneId)"], + let workspace = panel.surface.owningWorkspace() + else { return } + _ = workspace.updatePanelDirectory(panelId: panel.id, directory: trimmed) } /// Active session→workspace mirrors keyed `dest\u{1}session`. @@ -414,6 +433,28 @@ final class RemoteTmuxController { return false } + /// The SSH upload target for a remote-tmux surface (a session-mirror pane or + /// an ``openActivePane`` display pane), or `nil` if `surfaceId` isn't one. + /// Lets the image-paste path upload a pasted screenshot to the remote tmux + /// host (and insert the remote path) instead of an unreadable macOS-local one. + func remoteUploadTarget(forSurfaceId surfaceId: UUID) -> TerminalRemoteUploadTarget? { + for sessionMirror in sessionMirrors.values + where !sessionMirror.connection.exited && sessionMirror.ownsSurface(surfaceId) { + return .detectedSSH(sessionMirror.host.detectedSSHSession()) + } + // openActivePane display panes: the panel key is `dest\u{1}session\u{1}pane`, + // so the connection key is its first two components. + for (panelKey, panel) in displayPanels where panel.surface.id == surfaceId { + let parts = panelKey.split(separator: "\u{1}", omittingEmptySubsequences: false) + guard parts.count >= 2 else { continue } + let connectionKey = parts[0..<2].joined(separator: "\u{1}") + if let connection = connectionsByHostSession[connectionKey], !connection.exited { + return .detectedSSH(connection.host.detectedSSHSession()) + } + } + return nil + } + /// A split was requested on a mirror window-tab (the split button / any /// bonsplit-level split) → propagate to tmux `split-window`. Covers both /// single-pane mirror windows and multi-pane ones. Returns `true` if handled. diff --git a/Sources/RemoteTmuxHost.swift b/Sources/RemoteTmuxHost.swift index fda708cfe012..4b5d959dff57 100644 --- a/Sources/RemoteTmuxHost.swift +++ b/Sources/RemoteTmuxHost.swift @@ -139,4 +139,29 @@ struct RemoteTmuxHost: Sendable, Equatable, Codable, Identifiable { args.append(contentsOf: ["--", destination, remoteCommand]) return args } + + /// Builds a ``DetectedSSHSession`` that uploads files to this host over SSH, + /// reusing the same ControlMaster socket the control connection already opened + /// (so an `scp` multiplexes over the existing authenticated master — no second + /// prompt while a mirror is live). + /// + /// Used by the image-paste path: a screenshot pasted into a mirrored remote + /// tmux pane is uploaded to the host and the remote path is inserted, so a + /// remote CLI (e.g. claude) can read it — instead of inserting a macOS-local + /// path that doesn't exist on the remote. + func detectedSSHSession() -> DetectedSSHSession { + DetectedSSHSession( + destination: destination, + port: port, + identityFile: identityFile, + configFile: nil, + jumpHost: nil, + controlPath: controlSocketPath, + useIPv4: false, + useIPv6: false, + forwardAgent: false, + compressionEnabled: false, + sshOptions: [] + ) + } } diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index e843df33c091..a8677ac514a3 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -23,6 +23,9 @@ final class RemoteTmuxSessionMirror { private var defaultClosed = false private var panelIdByWindow: [Int: UUID] = [:] private var panelIdByPane: [Int: UUID] = [:] + /// Last-known working directory per tmux pane, so switching the active pane of + /// a multi-pane window can re-project that pane's directory onto the tab. + private var cwdByPane: [Int: String] = [:] /// Per-window multi-pane renderers (present once a window has >1 pane). private var windowMirrorByWindowId: [Int: RemoteTmuxWindowMirror] = [:] private var observerToken: RemoteTmuxControlConnection.ObserverToken? @@ -45,6 +48,12 @@ final class RemoteTmuxSessionMirror { onPaneOutput: { [weak self] paneId, data in self?.routeOutput(paneId: paneId, data: data) }, + onPaneCwd: { [weak self] paneId, path in + self?.handlePaneCwd(paneId: paneId, path: path) + }, + onActivePaneChanged: { [weak self] windowId, paneId in + self?.handleActivePaneChanged(windowId: windowId, paneId: paneId) + }, onTopologyChanged: { [weak self] in self?.rebuild() }, @@ -129,6 +138,10 @@ final class RemoteTmuxSessionMirror { panelIdByWindow[windowId] = panel.id panelIdByPane[firstPaneId] = panel.id connection.capturePane(paneId: firstPaneId) + // Track the pane's working directory so the tab shows the remote + // cwd (initial value + live `cd`) instead of staying at "~". + connection.requestPanePath(paneId: firstPaneId) + connection.subscribePanePath(paneId: firstPaneId) panelId = panel.id } reconcileWindowMirror(windowId: windowId, panelId: panelId, window: window, in: workspace) @@ -146,6 +159,10 @@ final class RemoteTmuxSessionMirror { panelIdByWindow[windowId] = nil panelIdByPane = panelIdByPane.filter { $0.value != panelId } } + // Drop cached directories for panes tmux no longer reports, so the cache + // stays bounded across window/pane churn (tmux pane ids never recur). + let livePanes = Set(connection.windowsByID.values.flatMap { $0.paneIDsInOrder }) + cwdByPane = cwdByPane.filter { livePanes.contains($0.key) } closeDefaultTabsIfNeeded() } @@ -205,6 +222,50 @@ final class RemoteTmuxSessionMirror { defaultClosed = true } + /// Routes a pane's reported working directory to the tab that renders it: a + /// single-pane window updates its display tab; a multi-pane window updates its + /// window tab only when the reporting pane is the window's active pane, so a + /// background pane's `cd` can't hijack the tab's folder. No-ops for unknown panes. + private func handlePaneCwd(paneId: Int, path: String) { + guard let workspace else { return } + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + cwdByPane[paneId] = trimmed + guard let panelId = tabPanelId(forPane: paneId) else { return } + // Multi-pane window: only the active pane represents the tab. + if let windowId = windowIdContaining(pane: paneId), + windowMirrorByWindowId[windowId] != nil, + activePane(inWindow: windowId) != paneId { + return + } + _ = workspace.updatePanelDirectory(panelId: panelId, directory: trimmed) + } + + /// Re-projects the newly-active pane's cached directory onto its multi-pane + /// window tab when the active pane changes, so switching panes updates the + /// folder immediately (rather than waiting for that pane's next `cd`). + private func handleActivePaneChanged(windowId: Int, paneId: Int) { + guard let workspace, + windowMirrorByWindowId[windowId] != nil, + let panelId = panelIdByWindow[windowId], + let path = cwdByPane[paneId] else { return } + _ = workspace.updatePanelDirectory(panelId: panelId, directory: path) + } + + /// The panel id of the tab that renders `paneId`: a single-pane window's + /// display tab, or a multi-pane window's window tab. + private func tabPanelId(forPane paneId: Int) -> UUID? { + panelIdByPane[paneId] ?? windowIdContaining(pane: paneId).flatMap { panelIdByWindow[$0] } + } + + /// The pane that currently represents `windowId`'s tab: the user-focused mirror + /// pane, else tmux's active pane, else the window's first pane. + private func activePane(inWindow windowId: Int) -> Int? { + windowMirrorByWindowId[windowId]?.activePaneId + ?? connection.activePaneByWindow[windowId] + ?? connection.windowsByID[windowId]?.paneIDsInOrder.first + } + private func routeOutput(paneId: Int, data: Data) { // Multi-pane window: its in-tab renderer owns the pane's surface. if let windowId = windowIdContaining(pane: paneId), @@ -233,6 +294,17 @@ final class RemoteTmuxSessionMirror { return true } + /// Whether `surfaceId` is one of this session mirror's pane surfaces — a + /// single-pane display tab or any multi-pane window-mirror pane. Used to route + /// a pasted image to this mirror's tmux host for SSH upload. + func ownsSurface(_ surfaceId: UUID) -> Bool { + if windowMirror(forSurfaceId: surfaceId) != nil { return true } + guard let workspace else { return false } + return panelIdByPane.values.contains { + (workspace.panels[$0] as? TerminalPanel)?.surface.id == surfaceId + } + } + /// The multi-pane renderer + tmux pane id for a focused mirror surface, used /// by the split shortcut to route ⌘D to `split-window`. func windowMirror(forSurfaceId surfaceId: UUID) -> (mirror: RemoteTmuxWindowMirror, tmuxPaneId: Int)? { diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 2d9cdf1c2907..5e903c04b527 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -77,12 +77,18 @@ final class RemoteTmuxWindowMirror { panelsByPaneId[paneId] = panel syntheticPaneIds[paneId] = PaneID() connection?.capturePane(paneId: paneId) + // Track this pane's working directory (initial + live) so the window + // tab reflects the remote cwd. The session mirror's cwd observer maps + // the pane back to this window's tab. + connection?.requestPanePath(paneId: paneId) + connection?.subscribePanePath(paneId: paneId) } for (paneId, panel) in panelsByPaneId where !livePaneIds.contains(paneId) { // Use the full panel close (detaches the portal from the registry // BEFORE freeing the surface) so a stale portal entry can't be // dereferenced by a later Core Animation commit. panel.close() + connection?.unsubscribePanePath(paneId: paneId) panelsByPaneId[paneId] = nil syntheticPaneIds[paneId] = nil if activePaneId == paneId { activePaneId = nil } diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift index 12957abbade2..b2c2be20c44b 100644 --- a/Sources/TerminalImageTransfer.swift +++ b/Sources/TerminalImageTransfer.swift @@ -488,6 +488,13 @@ extension TerminalSurface { if workspace.isRemoteTerminalSurface(id) { return .remote(.workspaceRemote) } + // Remote tmux mirror surfaces have no local TTY/process, so the SSH + // detector below can't see them. Upload pasted images to the tmux host + // over SSH (where claude runs can read them) instead of inserting a + // macOS-local path the remote host has no access to. + if let target = AppDelegate.shared?.remoteTmuxController.remoteUploadTarget(forSurfaceId: id) { + return .remote(target) + } if let ttyName = workspace.surfaceTTYNames[id], let session = TerminalSSHSessionDetector.detect(forTTY: ttyName) { return .remote(.detectedSSH(session)) From 962fd1b8ff79d46ae0f6e048326895663cbb5e0c Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 6 Jun 2026 12:09:46 +0300 Subject: [PATCH 04/73] Remote tmux: paste/drop via tmux paste-buffer -p so images render natively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting/dropping an image into a mirrored remote tmux pane inserted the uploaded /tmp path as plain keystrokes (send-keys), so the remote app (claude) showed the path text instead of inlining it as [Image #N]. Native `ssh` + `tmux attach` shows [Image #N] because the live PTY carries the app's bracketed-paste mode (DECSET 2004), so the path arrives as a bracketed paste — which is what claude's image-path detector requires. The -CC mirror is a manual-I/O surface that can't know the pane's 2004 state (capture-pane never replays ESC[?2004h), so ghostty emitted the path unbracketed. Fix: deliver mirror paste/drop through tmux `paste-buffer -p`, which brackets the paste iff the REAL pane has 2004 on (tmux tracks it authoritatively on the real pty). Images now inline as [Image #N]; files paste as a bracketed path; shell panes without 2004 get plain text — matching native behavior. - RemoteTmuxControlConnection.pastePane: set-buffer + paste-buffer -p -d on a per-pane buffer (single-quoted, single-line only). - RemoteTmuxController.pasteIntoMirror/pasteTarget + RemoteTmuxSessionMirror .paneId(forSurfaceId:): resolve the tmux pane behind a cmux surface. - GhosttyTerminalView: route single-line paste (completeClipboardRequest) and drop (insertText) for mirror panes through pasteIntoMirror; multi-line and non-mirror surfaces fall back to the existing path unchanged. - Share displayPanels key parsing via displayPaneTarget (was duplicated in pasteTarget/remoteUploadTarget). Also gitignore stray tmux-*.log debug logs. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 ++ Sources/GhosttyTerminalView.swift | 27 ++++++++++- Sources/RemoteTmuxControlConnection.swift | 16 +++++++ Sources/RemoteTmuxController.swift | 57 +++++++++++++++++++---- Sources/RemoteTmuxSessionMirror.swift | 17 +++++-- 5 files changed, 105 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index e940686e28d5..f3e3b8d69f48 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ tmp-*/ # Local dogfood scratch (screenshots, recordings) — never commit artifacts/ + +# tmux verbose debug logs (tmux -v) that land in the cwd +tmux-*.log diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2bff7254f509..908ee1f5f773 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1796,7 +1796,20 @@ class GhosttyApp { func completeClipboardRequest(with text: String) { let finish = { guard callbackContext.runtimeSurface == requestSurface else { return } - text.withCString { ptr in + // Remote tmux mirror panes: deliver the paste via tmux + // paste-buffer -p so the remote app sees a real bracketed paste + // (e.g. a pasted image path → claude `[Image #N]`) instead of the + // unbracketed keystrokes the manual-IO surface would emit (its + // ghostty terminal can't know the remote pane's bracketed-paste + // mode). Release ghostty's request with empty text so it doesn't + // also insert the content. + let handledByMirror = !text.isEmpty && MainActor.assumeIsolated { + AppDelegate.shared?.remoteTmuxController.pasteIntoMirror( + surfaceId: callbackContext.surfaceId, text: text + ) ?? false + } + let completionText = handledByMirror ? "" : text + completionText.withCString { ptr in ghostty_surface_complete_clipboard_request(requestSurface, ptr, state, false) } callbackContext.terminalSurface?.noteClipboardReadCompleted() @@ -11929,10 +11942,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let operation { self?.terminalSurface?.hostedView.endImageTransferIndicator(for: operation) } + guard let surface = self?.terminalSurface else { return } + // Remote tmux mirror panes: deliver via tmux paste-buffer -p so a + // dropped image path arrives as a real bracketed paste (claude → + // `[Image #N]`); the manual-IO surface can't bracket it itself. + let handledByMirror = MainActor.assumeIsolated { + AppDelegate.shared?.remoteTmuxController.pasteIntoMirror( + surfaceId: surface.id, text: text + ) ?? false + } + if handledByMirror { return } // Use the text/paste path (ghostty_surface_text) instead of the key event // path (ghostty_surface_key) so bracketed paste mode is triggered and the // insertion is instant, matching upstream Ghostty behaviour. - self?.terminalSurface?.sendText(text) + surface.sendText(text) } if Thread.isMainThread { send() diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 17248f03b74f..8a1d3012f290 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -269,6 +269,22 @@ final class RemoteTmuxControlConnection { sendInternal("send-keys -t %\(paneId) -H \(hex)", kind: .other) } + /// Pastes `text` into `paneId` as a tmux paste (`paste-buffer -p`), which wraps + /// the content in bracketed-paste markers IFF the real pane's app has + /// bracketed-paste mode enabled — tmux tracks that on the real pty, which the + /// mirror surface can't see. This makes a pasted/dropped image path arrive as a + /// genuine paste, so the remote app recognizes it (e.g. claude → `[Image #N]`) + /// instead of seeing the plain keystrokes that ``sendKeys(paneId:data:)`` would + /// deliver. Uses a dedicated, immediately-deleted (`-d`) per-pane buffer so + /// there's no buffer-name collision. `text` must be a single line (callers route + /// only single-line content — e.g. file/image paths — here). + func pastePane(paneId: Int, text: String) { + guard !text.isEmpty else { return } + let buffer = "cmux-paste-\(paneId)" + send("set-buffer -b \(buffer) \(RemoteTmuxHost.shellSingleQuoted(text))") + send("paste-buffer -p -d -b \(buffer) -t %\(paneId)") + } + /// Detaches: terminating ssh kills the control client but leaves the remote /// tmux session alive for resume. func stop() { diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 596bf535e417..4364b528a0bf 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -433,6 +433,52 @@ final class RemoteTmuxController { return false } + /// If `surfaceId` is a remote-tmux mirror pane, delivers `text` to that pane as + /// a tmux paste (`paste-buffer -p`, bracketed iff the real pane has + /// bracketed-paste mode on) and returns `true`. Lets a pasted/dropped image + /// path be recognized by the remote app (e.g. claude → `[Image #N]`) instead of + /// arriving as plain `send-keys`. Only single-line `text` is routed (covers + /// file/image paths); callers fall back to their normal insertion for empty or + /// multi-line text, which can't be carried safely on a one-line control command. + func pasteIntoMirror(surfaceId: UUID, text: String) -> Bool { + guard !text.isEmpty, !text.contains(where: { $0 == "\n" || $0 == "\r" }) else { return false } + guard let target = pasteTarget(forSurfaceId: surfaceId) else { return false } + target.connection.pastePane(paneId: target.paneId, text: text) + return true + } + + /// The live control connection + tmux pane id behind a remote-tmux mirror + /// surface (session-mirror pane or ``openActivePane`` display pane), or `nil`. + private func pasteTarget(forSurfaceId surfaceId: UUID) + -> (connection: RemoteTmuxControlConnection, paneId: Int)? + { + for sessionMirror in sessionMirrors.values where !sessionMirror.connection.exited { + if let paneId = sessionMirror.paneId(forSurfaceId: surfaceId) { + return (sessionMirror.connection, paneId) + } + } + return displayPaneTarget(forSurfaceId: surfaceId) + } + + /// The live control connection + tmux pane id behind an ``openActivePane`` + /// display-pane surface, or `nil`. The `displayPanels` key is + /// `dest\u{1}session\u{1}pane`, so the first two components form the connection + /// key and the third is the pane id. Shared by ``pasteTarget(forSurfaceId:)`` + /// and ``remoteUploadTarget(forSurfaceId:)`` so the key format lives in one place. + private func displayPaneTarget(forSurfaceId surfaceId: UUID) + -> (connection: RemoteTmuxControlConnection, paneId: Int)? + { + for (panelKey, panel) in displayPanels where panel.surface.id == surfaceId { + let parts = panelKey.split(separator: "\u{1}", omittingEmptySubsequences: false) + guard parts.count >= 3, let paneId = Int(parts[2]) else { continue } + let connectionKey = parts[0..<2].joined(separator: "\u{1}") + if let connection = connectionsByHostSession[connectionKey], !connection.exited { + return (connection, paneId) + } + } + return nil + } + /// The SSH upload target for a remote-tmux surface (a session-mirror pane or /// an ``openActivePane`` display pane), or `nil` if `surfaceId` isn't one. /// Lets the image-paste path upload a pasted screenshot to the remote tmux @@ -442,15 +488,8 @@ final class RemoteTmuxController { where !sessionMirror.connection.exited && sessionMirror.ownsSurface(surfaceId) { return .detectedSSH(sessionMirror.host.detectedSSHSession()) } - // openActivePane display panes: the panel key is `dest\u{1}session\u{1}pane`, - // so the connection key is its first two components. - for (panelKey, panel) in displayPanels where panel.surface.id == surfaceId { - let parts = panelKey.split(separator: "\u{1}", omittingEmptySubsequences: false) - guard parts.count >= 2 else { continue } - let connectionKey = parts[0..<2].joined(separator: "\u{1}") - if let connection = connectionsByHostSession[connectionKey], !connection.exited { - return .detectedSSH(connection.host.detectedSSHSession()) - } + if let target = displayPaneTarget(forSurfaceId: surfaceId) { + return .detectedSSH(target.connection.host.detectedSSHSession()) } return nil } diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index a8677ac514a3..7b90b246a6b8 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -298,11 +298,20 @@ final class RemoteTmuxSessionMirror { /// single-pane display tab or any multi-pane window-mirror pane. Used to route /// a pasted image to this mirror's tmux host for SSH upload. func ownsSurface(_ surfaceId: UUID) -> Bool { - if windowMirror(forSurfaceId: surfaceId) != nil { return true } - guard let workspace else { return false } - return panelIdByPane.values.contains { - (workspace.panels[$0] as? TerminalPanel)?.surface.id == surfaceId + paneId(forSurfaceId: surfaceId) != nil + } + + /// The tmux pane id whose surface is `surfaceId` (single-pane display tab or + /// multi-pane window-mirror pane), or nil if this mirror doesn't render it. + /// Used to target a tmux paste at the pane behind a cmux surface. + func paneId(forSurfaceId surfaceId: UUID) -> Int? { + if let match = windowMirror(forSurfaceId: surfaceId) { return match.tmuxPaneId } + guard let workspace else { return nil } + for (paneId, panelId) in panelIdByPane + where (workspace.panels[panelId] as? TerminalPanel)?.surface.id == surfaceId { + return paneId } + return nil } /// The multi-pane renderer + tmux pane id for a focused mirror surface, used From 72297d6936fc36ed533a47d5ce6132728e843e82 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 6 Jun 2026 15:46:23 +0300 Subject: [PATCH 05/73] Remote tmux: fix mirror resize for alt-screen and inline TUIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resizing the cmux window mangled a mirrored remote tmux pane because cmux's ghostty emulator reflowed the screen independently of tmux, which is the sole authority on a pane's reflow (control mode streams only the app's incremental post-SIGWINCH redraw, never a full grid repaint). A native client never reflows locally; cmux did. - Match the remote pane's screen: capturePane now queries `#{alternate_on}` and, for an alt-screen pane, enters the alternate screen on the mirror surface (ESC[?1049h) before the captured rows. Alt-screen TUIs (vim/htop) then don't reflow on resize (the alternate screen has no reflow), matching the remote. - Suppress local reflow on the primary screen: in updateSize, disable DECAWM (ESC[?7l) across set_size + render_now for manual-I/O mirror surfaces, then restore it — ghostty reflows only when DECAWM is on at resize time. This matches iTerm2's "tmux owns the grid; don't reflow locally" approach and fixes inline TUIs (e.g. claude) rendering misaligned after a resize. render_now also flushes a GPU frame so the resized grid shows promptly. Known limitation: a rare residual remains for inline primary-screen TUIs on hard fast resizes (the deep reflow/scrollback divergence whose complete fix needs a ghostty no-reflow-surface flag, which requires a GhosttyKit rebuild). Tracked separately; debug instrumentation lives on the remote-tmux-resize-debug branch. Co-Authored-By: Claude Opus 4.8 --- Sources/GhosttyTerminalView.swift | 35 +++++++++++++++++++++++ Sources/RemoteTmuxControlConnection.swift | 31 +++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 908ee1f5f773..35abe9f256a7 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5258,6 +5258,12 @@ final class TerminalSurface: Identifiable, ObservableObject { private static let committedTextInputChunkByteLimit = 96 + /// `ESC[?7l` — disable DECAWM (autowrap). Injected around a mirror surface's + /// resize to suppress ghostty's primary-screen reflow (see ``updateSize``). + private static let decawmDisableSequence = Data("\u{1b}[?7l".utf8) + /// `ESC[?7h` — re-enable DECAWM after a mirror resize. + private static let decawmEnableSequence = Data("\u{1b}[?7h".utf8) + enum NamedKeySendResult: Equatable { case sent case queued @@ -6854,9 +6860,38 @@ final class TerminalSurface: Identifiable, ObservableObject { } if sizeChanged { + // Mirror (manual-I/O) surfaces must NOT reflow/rewrap their primary + // screen on resize. tmux is the authority on a pane's reflow and only + // streams the app's incremental post-SIGWINCH redraw (never a full grid + // repaint), so a client that reflows independently diverges from tmux — + // inline TUIs like claude then render misaligned after a resize. iTerm2 + // avoids this by never reflowing locally ("tmux owns the grid"). ghostty + // reflows iff DECAWM (wraparound) is on at resize time, so disable it + // across the resize and restore it after. No writes occur during this + // window, so autowrap is otherwise unaffected. (A ghostty "no-reflow + // surface" flag would be cleaner but needs a GhosttyKit rebuild.) + // + // Caveat: this restores DECAWM to ON unconditionally. That is correct + // for the overwhelmingly common case (DECAWM defaults to on); a remote + // app that had explicitly turned it OFF is transiently re-enabled until + // its next %output re-asserts the mode (self-healing). A faithful + // save/restore would need a ghostty read-mode API that doesn't exist. + if manualIO { + writeProcessOutput(Self.decawmDisableSequence, to: surface) + } ghostty_surface_set_size(surface, wpx, hpx) lastPixelWidth = wpx lastPixelHeight = hpx + // For manual-I/O surfaces the `.manual` termio backend applies the + // terminal resize synchronously inside set_size (with DECAWM off, so it + // does not reflow), so the buffer already matches the just-set grid here. + // render_now forces a GPU frame so the resized grid is shown promptly + // before the post-resize %output arrives; then DECAWM is restored. This + // fires only on an actual size change (never while typing). + if manualIO { + ghostty_surface_render_now(surface) + writeProcessOutput(Self.decawmEnableSequence, to: surface) + } } // Remote tmux display surfaces: keep the remote tmux client sized to the diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 8a1d3012f290..b780d820b441 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -56,7 +56,7 @@ final class RemoteTmuxControlConnection { private let maxRecentEvents = 100 private enum CommandKind: Equatable { - case listWindows, capturePane(Int), paneCursor(Int), panePath(Int), other + case listWindows, capturePane(Int), paneCursor(Int), panePath(Int), paneAltScreen(Int), other } /// Subscription-name prefix for per-pane `pane_current_path` (`refresh-client -B`). @@ -64,6 +64,10 @@ final class RemoteTmuxControlConnection { /// routed back to its pane; defined once so the writer and reader can't drift. private static let cwdSubscriptionPrefix = "cmux_cwd_" + /// `ESC[?1049h` — enter the alternate screen, emitted to a mirror surface when + /// the remote pane is on the alternate screen (see ``capturePane(paneId:)``). + private static let altScreenEnterSequence = Data("\u{1b}[?1049h".utf8) + init(host: RemoteTmuxHost, sessionName: String, createIfMissing: Bool = false) { self.host = host self.sessionName = sessionName @@ -224,7 +228,23 @@ final class RemoteTmuxControlConnection { /// Captures a pane's current visible contents (with escapes) and delivers /// them to the pane-output observers so a freshly-mounted display surface shows /// the existing screen instead of starting blank. + /// + /// First queries `#{alternate_on}` and, if the remote pane is on the alternate + /// screen, enters it on the mirror surface (emits `ESC[?1049h`) before the + /// captured rows so they land on the matching screen and resize behaves like the + /// remote (the alternate screen does not reflow). func capturePane(paneId: Int) { + // Match the remote pane's screen (primary vs alternate) BEFORE seeding the + // captured rows. An alt-screen TUI (e.g. claude) must render on the mirror's + // alternate screen so resize matches the remote (the alternate screen does + // not reflow; the primary screen reflows/scrolls and offsets rows). The + // pane was already on the alt screen before cmux attached, so its 1049h is + // not in the live %output — query `#{alternate_on}` and enter alt ourselves. + // Ordered first so the enter lands before the capture paint in the FIFO. + sendInternal( + "display-message -p -t %\(paneId) -F \"#{alternate_on}\"", + kind: .paneAltScreen(paneId) + ) sendInternal("capture-pane -p -e -t %\(paneId)", kind: .capturePane(paneId)) // Follow with the real cursor position so the surface cursor lines up // with tmux's prompt (capture-pane alone doesn't carry the cursor). @@ -472,6 +492,15 @@ final class RemoteTmuxControlConnection { if let path = lines.first?.trimmingCharacters(in: .whitespaces), !path.isEmpty { emitPaneCwd(paneId, path) } + case let .paneAltScreen(paneId): + // Enter the alternate screen on the mirror surface so it matches the + // remote pane (alt = no reflow on resize). Emitted before the capture + // paint that follows in the FIFO, so the seeded rows land on the alt + // screen. A pane on the primary screen needs no toggle (the surface + // defaults to primary, and a later live `%output` 1049l would leave alt). + if lines.first?.trimmingCharacters(in: .whitespaces) == "1" { + emitPaneOutput(paneId, Self.altScreenEnterSequence) + } case .other: break } From 0813e5335ba6ef17911a3f8eb477c8c3c2184578 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 6 Jun 2026 21:32:09 +0300 Subject: [PATCH 06/73] Remote tmux: fix mirror UTF-8 corruption and harden pane-state seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse %output payloads from raw bytes so a multi-byte UTF-8 character that tmux splits across two %output notifications survives intact (ghostty's stream parser reassembles split UTF-8 across process_output calls). The previous per-line String(decoding:as: UTF8.self) round-trip replaced each split half with U+FFFD, corrupting box-drawing-heavy TUIs (e.g. claude) at certain sizes — visible as garbled separators that overflow to the next row. Harden the on-capture terminal-state seed in RemoteTmuxControlConnection: - restrict DECSTBM seeding to non-full-window regions (a full-window region is the surface default and would go stale across a resize); - emit the cursor LAST (DECSTBM and DECOM both home the cursor) and make it region-relative when origin mode is on; - clamp untrusted numeric format fields to 0...65535 to avoid an Int overflow trap from a malicious remote; - drop mouse-mode seeding (native cmux selection/scroll is preferred, and the tmux flag->DECSET mapping is ambiguous) and the invalid bracket_paste_flag query (paste fidelity is handled by tmux paste-buffer -p). Add regression tests: a box-drawing char split across two %output notifications survives without U+FFFD, and the pane-state seed places the cursor last / region-relative and suppresses full-window scroll regions. Co-Authored-By: Claude Opus 4.8 --- Sources/RemoteTmuxControlConnection.swift | 105 ++++++++++++++++--- Sources/RemoteTmuxControlStreamParser.swift | 77 +++++++++++--- cmuxTests/RemoteTmuxControlParserTests.swift | 68 ++++++++++++ 3 files changed, 221 insertions(+), 29 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index b780d820b441..c1d3c59bbf54 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -56,7 +56,7 @@ final class RemoteTmuxControlConnection { private let maxRecentEvents = 100 private enum CommandKind: Equatable { - case listWindows, capturePane(Int), paneCursor(Int), panePath(Int), paneAltScreen(Int), other + case listWindows, capturePane(Int), paneState(Int), panePath(Int), paneAltScreen(Int), other } /// Subscription-name prefix for per-pane `pane_current_path` (`refresh-client -B`). @@ -246,11 +246,29 @@ final class RemoteTmuxControlConnection { kind: .paneAltScreen(paneId) ) sendInternal("capture-pane -p -e -t %\(paneId)", kind: .capturePane(paneId)) - // Follow with the real cursor position so the surface cursor lines up - // with tmux's prompt (capture-pane alone doesn't carry the cursor). + // After the paint, restore the pane's terminal STATE — scroll region + // (DECSTBM) + DEC private modes + cursor. The live %output only carries + // changes made AFTER cmux attached, so state the app set earlier (most + // importantly the scroll region) is otherwise missing on the mirror, and an + // inline TUI's region-relative redraws then land on the wrong rows even at a + // static size. tmux exposes all of this as formats. Sent after capture-pane + // so it applies on top of the painted rows. + // + // Mouse tracking is deliberately NOT seeded: forwarding the local surface's + // mouse events to the remote pane would capture drag-to-select (turning it + // into the remote app's OSC 52 copy) and wheel scrolling, which is the + // "VIM scrolling" feel we want to avoid — native cmux selection/scroll is + // preferred. (tmux's mouse_*_flag → DECSET mapping is also ambiguous between + // the tmux docs and ghostty's viewer, so seeding it would be a guess.) + // Faithful mouse forwarding can be a deliberate follow-up. sendInternal( - "display-message -p -t %\(paneId) -F \"#{cursor_x},#{cursor_y}\"", - kind: .paneCursor(paneId) + "display-message -p -t %\(paneId) -F \"" + + "cursor_x=#{cursor_x},cursor_y=#{cursor_y}," + + "scroll_region_upper=#{scroll_region_upper},scroll_region_lower=#{scroll_region_lower}," + + "cursor_flag=#{cursor_flag},insert_flag=#{insert_flag}," + + "keypad_cursor_flag=#{keypad_cursor_flag},keypad_flag=#{keypad_flag}," + + "wrap_flag=#{wrap_flag},origin_flag=#{origin_flag},pane_height=#{pane_height}\"", + kind: .paneState(paneId) ) } @@ -477,16 +495,13 @@ final class RemoteTmuxControlConnection { if let data = painted.data(using: .utf8) { emitPaneOutput(paneId, data) } - case let .paneCursor(paneId): - // "x,y" (0-based) → absolute cursor-position escape so the surface - // cursor matches tmux's, even though capture-pane trims trailing - // spaces from the painted prompt line. + case let .paneState(paneId): + // Restore the pane's terminal state (scroll region + DEC modes + cursor) + // onto the mirror surface, applied after the capture paint. The scroll + // region (DECSTBM) is the important one: without it an inline TUI's + // region-relative redraws land on the wrong rows even at a static size. if let line = lines.first { - let parts = line.split(separator: ",") - if parts.count == 2, let x = Int(parts[0]), let y = Int(parts[1]), - let data = "\u{1b}[\(y + 1);\(x + 1)H".data(using: .utf8) { - emitPaneOutput(paneId, data) - } + emitPaneOutput(paneId, Self.paneStateSeedSequence(from: line)) } case let .panePath(paneId): if let path = lines.first?.trimmingCharacters(in: .whitespaces), !path.isEmpty { @@ -506,6 +521,68 @@ final class RemoteTmuxControlConnection { } } + /// Builds the escape sequence that restores a pane's terminal state onto the + /// mirror surface, from a `display-message` `key=value,…` line. Sets the scroll + /// region (DECSTBM), the DEC private modes (wrap/cursor/insert/app-cursor-keys/ + /// keypad), origin mode, and finally the cursor position. + /// + /// The cursor placement is emitted LAST on purpose: setting the scroll region + /// (DECSTBM) and changing origin mode (DECOM) each move the cursor to the home + /// position, so any earlier cursor placement would be lost. When origin mode is + /// on with a restricted region, tmux's absolute cursor row is translated to the + /// region-relative row the (origin-relative) CUP then expects. + /// + /// Mouse tracking is intentionally not restored — see ``capturePane(paneId:)``. + nonisolated static func paneStateSeedSequence(from line: String) -> Data { + var fields: [String: String] = [:] + for pair in line.split(separator: ",") { + let kv = pair.split(separator: "=", maxSplits: 1) + if kv.count == 2 { fields[String(kv[0])] = String(kv[1]) } + } + let on: (String) -> Bool = { fields[$0] == "1" } + // Clamp to a plausible terminal-dimension range: the values come from an + // untrusted remote, and a crafted `Int.min`/`Int.max` would trap the later + // `+ 1` / `- 1` arithmetic (Swift overflow is a hard crash). Out-of-range or + // non-numeric values are treated as absent. + let num: (String) -> Int? = { fields[$0].flatMap { Int($0) }.flatMap { (0...65535).contains($0) ? $0 : nil } } + + var seq = "" + // Scroll region (DECSTBM) — tmux reports 0-based, DECSTBM is 1-based. Only + // seed a RESTRICTED region: a full-window region (upper 0, lower height-1) + // is the surface's default already, and pinning it to the capture-time row + // count would go stale across a later resize (the surface, left at default, + // tracks resizes on its own). A restricted region is re-asserted by the + // remote app on its next redraw, so a transiently stale one self-heals. + let regionUpper = num("scroll_region_upper") + var restrictedRegion = false + if let upper = regionUpper, let lower = num("scroll_region_lower"), lower >= upper { + let isFullWindow = upper == 0 && (num("pane_height").map { lower == $0 - 1 } ?? false) + if !isFullWindow { + seq += "\u{1b}[\(upper + 1);\(lower + 1)r" + restrictedRegion = true + } + } + seq += on("wrap_flag") ? "\u{1b}[?7h" : "\u{1b}[?7l" // DECAWM + seq += on("cursor_flag") ? "\u{1b}[?25h" : "\u{1b}[?25l" // DECTCEM (cursor visible) + seq += on("insert_flag") ? "\u{1b}[4h" : "\u{1b}[4l" // IRM + seq += on("keypad_cursor_flag") ? "\u{1b}[?1h" : "\u{1b}[?1l" // DECCKM (app cursor keys) + seq += on("keypad_flag") ? "\u{1b}=" : "\u{1b}>" // DECKPAM / DECKPNM + // (Bracketed-paste mode is intentionally not seeded: tmux exposes no + // reliable pane format for it, and paste fidelity is handled by tmux's own + // `paste-buffer -p` in ``pastePane(paneId:text:)``.) + // Origin mode (DECOM) before the cursor — changing it homes the cursor. + let originOn = on("origin_flag") + seq += originOn ? "\u{1b}[?6h" : "\u{1b}[?6l" + // Cursor LAST. tmux reports an absolute row; with origin mode on and a + // restricted region the CUP is interpreted region-relative, so subtract the + // region top. + if let cx = num("cursor_x"), let cy = num("cursor_y") { + let row = (originOn && restrictedRegion) ? max(0, cy - (regionUpper ?? 0)) : cy + seq += "\u{1b}[\(row + 1);\(cx + 1)H" + } + return Data(seq.utf8) + } + private func applyLayout(windowId: Int, layout: String) { guard let node = RemoteTmuxRawLayoutParser.parse(layout) else { return } // Preserve any name tmux already reported (a %layout-change carries no name). diff --git a/Sources/RemoteTmuxControlStreamParser.swift b/Sources/RemoteTmuxControlStreamParser.swift index d8e46bb93750..86f42cf47fad 100644 --- a/Sources/RemoteTmuxControlStreamParser.swift +++ b/Sources/RemoteTmuxControlStreamParser.swift @@ -7,8 +7,13 @@ import Foundation /// `\r` that the SSH `-tt` pty adds, coalesces `%begin`…`%end` command blocks, /// and emits structured ``RemoteTmuxControlMessage`` values. /// -/// The protocol is line-oriented printable ASCII (tmux octal-escapes every -/// non-printable byte in `%output`), so line-based parsing is lossless. +/// The protocol is line-oriented: notifications and command-block content are +/// ASCII (tmux octal-escapes control bytes), so they are decoded to `String`. The +/// exception is `%output`, whose payload carries raw pane bytes — including the +/// high bytes of multi-byte UTF-8 characters, which tmux does NOT escape and can +/// split across two notifications. `%output` is therefore parsed from raw bytes +/// (see ``parseOutput(rawLine:)``) so those characters survive for ghostty to +/// reassemble; a String round-trip would replace each split half with U+FFFD. struct RemoteTmuxControlStreamParser { private var buffer: [UInt8] = [] private var inBlock = false @@ -18,6 +23,10 @@ struct RemoteTmuxControlStreamParser { /// The DCS sequence tmux emits to enter control mode: `ESC P 1000 p`. private static let enterSequence: [UInt8] = [0x1b, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70] + /// ASCII bytes of the `%output ` notification prefix (used to detect and parse + /// `%output` lines from raw bytes, before any String decode). + private static let outputPrefix: [UInt8] = Array("%output ".utf8) + /// Feeds a chunk of stream bytes and returns any newly completed messages. mutating func feed(_ data: Data) -> [RemoteTmuxControlMessage] { var messages: [RemoteTmuxControlMessage] = [] @@ -50,6 +59,17 @@ struct RemoteTmuxControlStreamParser { } if bytes.isEmpty { return prefixMessages } + // `%output` is the only notification whose payload carries raw, possibly + // multi-byte UTF-8 pane bytes. Parse it straight from the raw bytes so a + // character that tmux split across two `%output` notifications (it sends + // pane bytes raw and chunks PTY reads mid-character) survives intact — + // ghostty's stream parser reassembles split UTF-8 across process_output + // calls, but routing each half through `String(decoding:as: UTF8.self)` + // first would replace it with U+FFFD before ghostty ever sees it. + if !inBlock, let output = Self.parseOutput(rawLine: bytes) { + return prefixMessages + [output] + } + let line = String(decoding: bytes, as: UTF8.self) if inBlock { @@ -68,6 +88,11 @@ struct RemoteTmuxControlStreamParser { blockLines = [] return prefixMessages + [result] } + // Block content is always tmux-formatted text — `capture-pane`/ + // `display-message` responses are printable/escaped, never raw PTY + // bytes split mid-character — so this String round-trip is lossless. + // Only `%output` (handled above, from raw bytes) carries raw PTY bytes + // that a String decode would corrupt. blockLines.append(line) return prefixMessages } @@ -82,20 +107,40 @@ struct RemoteTmuxControlStreamParser { return prefixMessages + [parseNotification(line)] } + /// Parses an `%output % ` line directly from its raw + /// bytes, preserving the data's multi-byte UTF-8 exactly. Returns `nil` if the + /// line is not a well-formed `%output` notification, so the caller falls back to + /// the String-based notification parser. + /// + /// Only the prefix and pane id (pure ASCII) are interpreted as text; the data + /// after the second space is unescaped from raw bytes, so a multi-byte + /// character split across two `%output` notifications is never replaced with + /// U+FFFD. + private static func parseOutput(rawLine bytes: [UInt8]) -> RemoteTmuxControlMessage? { + guard bytes.starts(with: outputPrefix) else { return nil } + var i = outputPrefix.count + guard i < bytes.count, bytes[i] == UInt8(ascii: "%") else { return nil } + i += 1 + let digitsStart = i + while i < bytes.count, bytes[i] >= UInt8(ascii: "0"), bytes[i] <= UInt8(ascii: "9") { + i += 1 + } + guard i > digitsStart, i < bytes.count, bytes[i] == UInt8(ascii: " "), + let paneId = Int(String(decoding: bytes[digitsStart.. RemoteTmuxControlMessage { if line == "%exit" || line.hasPrefix("%exit ") { let reason = line == "%exit" ? nil : String(line.dropFirst("%exit ".count)) return .exit(reason: reason) } - if line.hasPrefix("%output ") { - // %output % - let rest = line.dropFirst("%output ".count) - guard let space = rest.firstIndex(of: " ") else { return .unparsed(line) } - let paneToken = rest[.. Data { - let bytes = Array(field.utf8) + /// Octal-unescapes raw `%output` data bytes (`\ooo` → byte). Any byte that is + /// not part of a `\ooo` escape — including the raw high bytes of a multi-byte + /// UTF-8 character — passes through unchanged, so split or whole UTF-8 text + /// survives intact for ghostty to decode. + static func unescapeOutput(_ bytes: [UInt8]) -> Data { var out = Data() out.reserveCapacity(bytes.count) var i = 0 - func isOctal(_ b: UInt8) -> Bool { b >= 0x30 && b <= 0x37 } + let isOctal: (UInt8) -> Bool = { $0 >= 0x30 && $0 <= 0x37 } while i < bytes.count { if bytes[i] == 0x5c, // backslash i + 3 < bytes.count, diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift index 94fb70115610..785dd17a6f3b 100644 --- a/cmuxTests/RemoteTmuxControlParserTests.swift +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -114,6 +114,33 @@ import Testing ]) } + @Test func outputPreservesMultibyteCharSplitAcrossNotifications() { + // tmux sends pane bytes raw and can chunk a PTY read mid-character, so a + // box-drawing `─` (E2 94 80) arrives split across two %output + // notifications: "…E2 94" then "80…". Each half must be emitted as raw + // bytes — NOT run through String(decoding:as: UTF8.self), which replaces + // each incomplete half with U+FFFD (EF BF BD). ghostty's stream parser + // reassembles the split character across process_output calls, but only if + // it receives the original bytes. This reproduces the box-drawing + // corruption seen in mirrored TUIs (claude) at certain sizes. + var parser = RemoteTmuxControlStreamParser() + var stream = Data() + stream.append(Data("%output %1 ".utf8)) + stream.append(Data([0xe2, 0x94])) // first 2 bytes of `─` + stream.append(Data("\r\n".utf8)) + stream.append(Data("%output %1 ".utf8)) + stream.append(Data([0x80])) // final byte of `─` + stream.append(Data("\r\n".utf8)) + + let messages = parser.feed(stream) + let payload = messages.reduce(into: Data()) { acc, message in + if case let .output(paneId, data) = message, paneId == 1 { acc.append(data) } + } + // Intact `─`, byte-for-byte — never a U+FFFD (EF BF BD) replacement. + #expect(payload == Data([0xe2, 0x94, 0x80])) + #expect(!payload.contains(0xef)) + } + @Test func sessionChangedKeepsMultiWordName() { let messages = parse("%session-changed $1 my session name\r\n") #expect(messages == [.sessionChanged(sessionId: 1, name: "my session name")]) @@ -124,6 +151,47 @@ import Testing #expect(messages == [.layoutChange(windowId: 4, layout: "f92f,80x24,0,0,1")]) } + // MARK: - Pane state seeding (cursor / region / origin ordering) + + @Test func paneStateSeedPlacesCursorLastWithOriginRelativeRow() { + // origin mode ON + a restricted scroll region: DECSTBM and DECOM each home + // the cursor, so the CUP must be emitted LAST, and tmux's absolute row + // converted to the region-relative row the origin-relative CUP expects. + let line = "cursor_x=4,cursor_y=10," + + "scroll_region_upper=3,scroll_region_lower=20," + + "cursor_flag=1,insert_flag=0,keypad_cursor_flag=0,keypad_flag=0," + + "wrap_flag=1,origin_flag=1,pane_height=24" + let seq = String( + decoding: RemoteTmuxControlConnection.paneStateSeedSequence(from: line), + as: UTF8.self + ) + #expect(seq.contains("\u{1b}[4;21r")) // restricted region, 1-based 4..21 + #expect(seq.contains("\u{1b}[?6h")) // origin mode on + // Cursor placed LAST and region-relative: row = cy(10) - upper(3) = 7 → 8 + // (1-based), col = cx(4) + 1 = 5. + #expect(seq.hasSuffix("\u{1b}[8;5H")) + // Mouse tracking is intentionally not seeded. + for mode in ["?1003h", "?1002h", "?1000h", "?9h", "?1006h", "?1005h"] { + #expect(!seq.contains(mode)) + } + } + + @Test func paneStateSeedSuppressesFullWindowRegionWithAbsoluteCursor() { + // A full-window region (upper 0, lower height-1) is NOT seeded — it is the + // surface default and would go stale on resize. origin off → absolute cursor. + let line = "cursor_x=2,cursor_y=46," + + "scroll_region_upper=0,scroll_region_lower=51," + + "cursor_flag=1,insert_flag=0,keypad_cursor_flag=0,keypad_flag=0," + + "wrap_flag=1,origin_flag=0,pane_height=52" + let seq = String( + decoding: RemoteTmuxControlConnection.paneStateSeedSequence(from: line), + as: UTF8.self + ) + #expect(!seq.contains(";52r")) // full-window DECSTBM suppressed + #expect(seq.contains("\u{1b}[?6l")) // origin off + #expect(seq.hasSuffix("\u{1b}[47;3H")) // absolute cursor, placed last + } + // MARK: - Raw layout parser @Test func parsesLeafLayoutWithChecksum() { From fce51811fca001434a3e16cf9ebd4a39807209ce Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 6 Jun 2026 21:56:16 +0300 Subject: [PATCH 07/73] Remote tmux: forward mouse to the remote pane (seed tracking mode on capture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the remote pane's mouse tracking mode onto the mirror surface so clicks, scroll, and drag reach the remote app (e.g. claude). A TUI sets its mouse mode at startup, before cmux attaches, so it isn't in the live %output — query it from tmux on capture and seed it. tmux's concrete mouse flags map to xterm DECSET levels (verified empirically against tmux 3.6a: set the DECSET in a pane, read the flags back): mouse_standard_flag=1000, mouse_button_flag=1002, mouse_all_flag=1003; the most aggressive that is on wins. mouse_any_flag is tmux's aggregate "any mouse mode on" OR-flag, not a concrete level, so it is not used. Encoding follows mouse_sgr_flag (1006) / mouse_utf8_flag (1005). With mouse tracking on, drag-to-select becomes the remote app's own selection (OSC 52 copy); Shift+drag does a native cmux copy, exactly as a local terminal behaves with a mouse-mode app. Tests cover each concrete tracking level and that the aggregate mouse_any_flag alone enables nothing. Co-Authored-By: Claude Opus 4.8 --- Sources/RemoteTmuxControlConnection.swift | 51 ++++++++++++-------- cmuxTests/RemoteTmuxControlParserTests.swift | 48 +++++++++++++++++- 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index c1d3c59bbf54..3b12804d23d9 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -233,6 +233,13 @@ final class RemoteTmuxControlConnection { /// screen, enters it on the mirror surface (emits `ESC[?1049h`) before the /// captured rows so they land on the matching screen and resize behaves like the /// remote (the alternate screen does not reflow). + /// + /// After the paint it restores terminal state the live `%output` doesn't carry + /// (it set before cmux attached): scroll region, DEC private modes, the mouse + /// tracking mode, and the cursor. Restoring the mouse mode means clicks, scroll, + /// and drag in the mirror are forwarded to the remote app — so drag-to-select + /// becomes the app's own selection/OSC 52 copy, and **Shift+drag** does a native + /// cmux copy (exactly as a local terminal behaves with a mouse-mode app). func capturePane(paneId: Int) { // Match the remote pane's screen (primary vs alternate) BEFORE seeding the // captured rows. An alt-screen TUI (e.g. claude) must render on the mirror's @@ -246,28 +253,20 @@ final class RemoteTmuxControlConnection { kind: .paneAltScreen(paneId) ) sendInternal("capture-pane -p -e -t %\(paneId)", kind: .capturePane(paneId)) - // After the paint, restore the pane's terminal STATE — scroll region - // (DECSTBM) + DEC private modes + cursor. The live %output only carries - // changes made AFTER cmux attached, so state the app set earlier (most - // importantly the scroll region) is otherwise missing on the mirror, and an - // inline TUI's region-relative redraws then land on the wrong rows even at a - // static size. tmux exposes all of this as formats. Sent after capture-pane - // so it applies on top of the painted rows. - // - // Mouse tracking is deliberately NOT seeded: forwarding the local surface's - // mouse events to the remote pane would capture drag-to-select (turning it - // into the remote app's OSC 52 copy) and wheel scrolling, which is the - // "VIM scrolling" feel we want to avoid — native cmux selection/scroll is - // preferred. (tmux's mouse_*_flag → DECSET mapping is also ambiguous between - // the tmux docs and ghostty's viewer, so seeding it would be a guess.) - // Faithful mouse forwarding can be a deliberate follow-up. + // Query the pane's terminal STATE; tmux exposes it all as formats. Sent + // after capture-pane so it applies on top of the painted rows (the seed + // escapes are built in `paneStateSeedSequence`). See the doc comment for why + // restoring this matters. sendInternal( "display-message -p -t %\(paneId) -F \"" + "cursor_x=#{cursor_x},cursor_y=#{cursor_y}," + "scroll_region_upper=#{scroll_region_upper},scroll_region_lower=#{scroll_region_lower}," + "cursor_flag=#{cursor_flag},insert_flag=#{insert_flag}," + "keypad_cursor_flag=#{keypad_cursor_flag},keypad_flag=#{keypad_flag}," - + "wrap_flag=#{wrap_flag},origin_flag=#{origin_flag},pane_height=#{pane_height}\"", + + "wrap_flag=#{wrap_flag},origin_flag=#{origin_flag},pane_height=#{pane_height}," + + "mouse_all_flag=#{mouse_all_flag},mouse_button_flag=#{mouse_button_flag}," + + "mouse_standard_flag=#{mouse_standard_flag}," + + "mouse_sgr_flag=#{mouse_sgr_flag},mouse_utf8_flag=#{mouse_utf8_flag}\"", kind: .paneState(paneId) ) } @@ -524,15 +523,13 @@ final class RemoteTmuxControlConnection { /// Builds the escape sequence that restores a pane's terminal state onto the /// mirror surface, from a `display-message` `key=value,…` line. Sets the scroll /// region (DECSTBM), the DEC private modes (wrap/cursor/insert/app-cursor-keys/ - /// keypad), origin mode, and finally the cursor position. + /// keypad), mouse tracking, origin mode, and finally the cursor position. /// /// The cursor placement is emitted LAST on purpose: setting the scroll region /// (DECSTBM) and changing origin mode (DECOM) each move the cursor to the home /// position, so any earlier cursor placement would be lost. When origin mode is /// on with a restricted region, tmux's absolute cursor row is translated to the /// region-relative row the (origin-relative) CUP then expects. - /// - /// Mouse tracking is intentionally not restored — see ``capturePane(paneId:)``. nonisolated static func paneStateSeedSequence(from line: String) -> Data { var fields: [String: String] = [:] for pair in line.split(separator: ",") { @@ -567,6 +564,22 @@ final class RemoteTmuxControlConnection { seq += on("insert_flag") ? "\u{1b}[4h" : "\u{1b}[4l" // IRM seq += on("keypad_cursor_flag") ? "\u{1b}[?1h" : "\u{1b}[?1l" // DECCKM (app cursor keys) seq += on("keypad_flag") ? "\u{1b}=" : "\u{1b}>" // DECKPAM / DECKPNM + // Mouse: enable the active tracking mode + encoding so clicks/scroll/drag in + // the mirror reach the remote app (the surface defaults to off). The + // tmux-flag → xterm DECSET mapping below was verified empirically against + // tmux 3.6a (set the DECSET in a pane, read the flags back): + // ?1000h → mouse_standard_flag, ?1002h → mouse_button_flag, + // ?1003h → mouse_all_flag, and mouse_any_flag is set for ALL of them. + // So enable the most aggressive concrete flag that is on, plus the encoding + // (SGR/1006 preferred over UTF-8/1005). `mouse_any_flag` is deliberately NOT + // used: it is tmux's aggregate "any mouse mode on" OR-flag, not a concrete + // level. (NOTE: ghostty's vendored tmux viewer uses a different, one-slot- + // shifted mapping — do not "align" to it; the above matches real tmux.) + if on("mouse_all_flag") { seq += "\u{1b}[?1003h" } + else if on("mouse_button_flag") { seq += "\u{1b}[?1002h" } + else if on("mouse_standard_flag") { seq += "\u{1b}[?1000h" } + if on("mouse_sgr_flag") { seq += "\u{1b}[?1006h" } + else if on("mouse_utf8_flag") { seq += "\u{1b}[?1005h" } // (Bracketed-paste mode is intentionally not seeded: tmux exposes no // reliable pane format for it, and paste fidelity is handled by tmux's own // `paste-buffer -p` in ``pastePane(paneId:text:)``.) diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift index 785dd17a6f3b..1ddfbc4ec387 100644 --- a/cmuxTests/RemoteTmuxControlParserTests.swift +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -170,12 +170,58 @@ import Testing // Cursor placed LAST and region-relative: row = cy(10) - upper(3) = 7 → 8 // (1-based), col = cx(4) + 1 = 5. #expect(seq.hasSuffix("\u{1b}[8;5H")) - // Mouse tracking is intentionally not seeded. + // No mouse flags in this fixture → no mouse DECSET is emitted. for mode in ["?1003h", "?1002h", "?1000h", "?9h", "?1006h", "?1005h"] { #expect(!seq.contains(mode)) } } + /// Builds a pane-state line with one concrete mouse tracking flag set (+ SGR). + private func mouseSeedLine(flag: String) -> String { + "cursor_x=0,cursor_y=0," + + "scroll_region_upper=0,scroll_region_lower=51," + + "cursor_flag=1,insert_flag=0,keypad_cursor_flag=0,keypad_flag=0," + + "wrap_flag=1,origin_flag=0,pane_height=52," + + "\(flag)=1,mouse_sgr_flag=1" + } + + @Test(arguments: [ + ("mouse_all_flag", "\u{1b}[?1003h"), // any-event / all-motion + ("mouse_button_flag", "\u{1b}[?1002h"), // button-event + ("mouse_standard_flag", "\u{1b}[?1000h"), // normal + ]) + func paneStateSeedRestoresConcreteMouseTrackingLevel(flag: String, expected: String) { + // Each concrete tmux flag maps to its xterm DECSET tracking level, and ONLY + // that level is enabled (so the app gets exactly the mode it requested). + let seq = String( + decoding: RemoteTmuxControlConnection.paneStateSeedSequence(from: mouseSeedLine(flag: flag)), + as: UTF8.self + ) + #expect(seq.contains(expected)) + #expect(seq.contains("\u{1b}[?1006h")) // SGR encoding + for other in ["\u{1b}[?1003h", "\u{1b}[?1002h", "\u{1b}[?1000h"] where other != expected { + #expect(!seq.contains(other)) + } + } + + @Test func paneStateSeedIgnoresAggregateMouseAnyFlag() { + // `mouse_any_flag` is tmux's aggregate "any mouse mode on" OR-flag, not a + // concrete level — on its own it must NOT enable any tracking mode (else a + // pane that only requested 1000/1002 would be over-escalated). + let line = "cursor_x=0,cursor_y=0," + + "scroll_region_upper=0,scroll_region_lower=51," + + "cursor_flag=1,insert_flag=0,keypad_cursor_flag=0,keypad_flag=0," + + "wrap_flag=1,origin_flag=0,pane_height=52," + + "mouse_any_flag=1,mouse_all_flag=0,mouse_button_flag=0,mouse_standard_flag=0,mouse_sgr_flag=1" + let seq = String( + decoding: RemoteTmuxControlConnection.paneStateSeedSequence(from: line), + as: UTF8.self + ) + for mode in ["\u{1b}[?1003h", "\u{1b}[?1002h", "\u{1b}[?1000h"] { + #expect(!seq.contains(mode)) + } + } + @Test func paneStateSeedSuppressesFullWindowRegionWithAbsoluteCursor() { // A full-window region (upper 0, lower height-1) is NOT seeded — it is the // surface default and would go stale on resize. origin off → absolute cursor. From 62865f25338c43b16da354e209c54646bc3ea9ce Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sat, 6 Jun 2026 23:48:18 +0300 Subject: [PATCH 08/73] Docs: add Remote tmux (beta) page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the remote-tmux beta: how tmux maps to cmux (session→workspace, window→tab, and a window's panes→a native split inside one tab), requirements, enabling the Beta Features flag, opening a single pane vs mirroring a whole host, how it works, what it supports (sizing, splits→split-window, reorder→swap-window, cwd, paste/drop, mouse, unicode-correct output), the remote.tmux.* socket commands, and real limitations. Adds the page, the docs nav entry (after SSH), and en/ja message-catalog strings; other locales fall back to en until the translation pipeline fills them. Co-Authored-By: Claude Opus 4.8 --- web/app/[locale]/components/docs-nav-items.ts | 1 + web/app/[locale]/docs/remote-tmux/page.tsx | 103 ++++++++++++++++++ web/messages/en.json | 51 +++++++++ web/messages/ja.json | 50 +++++++++ 4 files changed, 205 insertions(+) create mode 100644 web/app/[locale]/docs/remote-tmux/page.tsx diff --git a/web/app/[locale]/components/docs-nav-items.ts b/web/app/[locale]/components/docs-nav-items.ts index 814c0ef52536..621ecc3eb169 100644 --- a/web/app/[locale]/components/docs-nav-items.ts +++ b/web/app/[locale]/components/docs-nav-items.ts @@ -25,6 +25,7 @@ export const navItems: NavEntry[] = [ { titleKey: "skills", href: "/docs/skills" }, { titleKey: "notifications", href: "/docs/notifications" }, { titleKey: "ssh", href: "/docs/ssh" }, + { titleKey: "remoteTmux", href: "/docs/remote-tmux" }, { sectionKey: "agentIntegrations", children: [ diff --git a/web/app/[locale]/docs/remote-tmux/page.tsx b/web/app/[locale]/docs/remote-tmux/page.tsx new file mode 100644 index 000000000000..e824e79300b1 --- /dev/null +++ b/web/app/[locale]/docs/remote-tmux/page.tsx @@ -0,0 +1,103 @@ +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; +import { Callout } from "../../components/callout"; +import { CodeBlock } from "../../components/code-block"; +import { DocsHeading } from "../../components/docs-heading"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "docs.remoteTmux" }); + return { + title: t("metaTitle"), + description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/remote-tmux"), + }; +} + +export default function RemoteTmuxPage() { + const t = useTranslations("docs.remoteTmux"); + + return ( + <> + {t("title")} +

{t("intro")}

+ + {t("betaNote")} + + {t("mappingTitle")} +

{t("mappingIntro")}

+ + + + + + + + + + + + +
{t("mapTmux")}{t("mapCmux")}
session{t("rowSession")}
window{t("rowWindow")}
pane{t("rowPane")}
+

{t("mappingPanes")}

+ + {t("requirementsTitle")} +

{t("requirementsDesc")}

+ + {t("enableTitle")} +

{t("enableDesc")}

+ + {t("attachTitle")} +

{t("attachIntro")}

+
    +
  • {t("attachOpen")}
  • +
  • {t("attachMirror")}
  • +
+ + {t("howTitle")} +

{t("howDesc")}

+ + {t("behaviorTitle")} +
    +
  • {t("behaviorSize")}
  • +
  • {t("behaviorSplit")}
  • +
  • {t("behaviorReorder")}
  • +
  • {t("behaviorCwd")}
  • +
  • {t("behaviorPaste")}
  • +
  • {t("behaviorMouse")}
  • +
  • {t("behaviorUnicode")}
  • +
+ + {t("socketTitle")} +

{t("socketDesc")}

+ + + + + + + + + + + + + + + + +
{t("socketMethod")}{t("socketParams")}{t("socketMeaning")}
remote.tmux.sessionshost, port?, identity_file?{t("methodSessions")}
remote.tmux.attachhost, session, create?{t("methodAttach")}
remote.tmux.openhost, session{t("methodOpen")}
remote.tmux.mirrorhost{t("methodMirror")}
remote.tmux.detachhost, session{t("methodDetach")}
remote.tmux.statehost, session{t("methodState")}
+

{t("socketSafetyDesc")}

+ {`{ "method": "remote.tmux.mirror", "params": { "host": "dev.example.com" } }`} + + {t("limitationsTitle")} +
    +
  • {t("limitReconnect")}
  • +
  • {t("limitPaste")}
  • +
  • {t("limitCwd")}
  • +
  • {t("limitReflow")}
  • +
+ + ); +} diff --git a/web/messages/en.json b/web/messages/en.json index b4dfe53c6811..b0be70352f6b 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -634,6 +634,56 @@ } }, "docs": { + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Open or mirror", + "attachIntro": "There are two ways in:", + "attachOpen": "Open a single pane — the “Attach Remote tmux…” command (command palette) or remote.tmux.open attaches a session and opens its active pane as a live tab in the current workspace.", + "attachMirror": "Mirror a whole host — remote.tmux.mirror reprojects every session on a host as its own workspace; each window becomes a tab and multi-pane windows become in-tab splits.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodOpen": "Attach and open the session's active pane as a live tab in the current workspace.", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "No live reconnect: if the SSH connection drops mid-session, the mirror closes and you re-attach. Mirrored hosts are restored on app relaunch, but a mid-session drop isn't retried with backoff the way cmux ssh is.", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." + }, "layoutTitle": "cmux docs", "search": { "placeholder": "Search docs...", @@ -1546,6 +1596,7 @@ "skills": "Skills", "notifications": "Notifications", "ssh": "SSH", + "remoteTmux": "Remote tmux", "agentIntegrations": "Agent Integrations", "claudeCodeTeams": "Claude Code Teams", "ohMyOpenCode": "oh-my-opencode", diff --git a/web/messages/ja.json b/web/messages/ja.json index 56e8e296364a..70a6b4a82c8e 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -590,6 +590,56 @@ } }, "docs": { + "remoteTmux": { + "title": "リモート tmux", + "metaTitle": "リモート tmux(ベータ)", + "metaDescription": "tmux のコントロールモードを使い、SSH 経由でリモートの tmux セッションを cmux から操作します。セッションはワークスペースに、ウィンドウはタブに、ウィンドウ内のペインはネイティブな cmux の分割になります。", + "intro": "リモート tmux は、リモートマシンで動作している tmux セッションを、tmux のコントロールモード(tmux -CC)を使って SSH 経由で cmux にミラーリングします。単なる SSH ターミナルではなく、リモートセッションを cmux のネイティブな UI(ワークスペース・タブ・分割・スクロールバック・コピー)として再投影し、その裏で cmux が実際の tmux サーバーを操作します。ベータ機能フラグであり、ローカルのターミナルには影響しません。", + "betaNote": "ベータ機能です。オプトインで、まだ安定化の途中です。下記の制限事項を参照してください。", + "mappingTitle": "tmux と cmux の対応", + "mappingIntro": "cmux と tmux はレイアウトの入れ子が逆向きです。cmux ではペインがタブの列を持ちます(各タブが 1 つのターミナル)。tmux ではウィンドウがペインの分割を持ちます。リモート tmux は、tmux の構造を cmux の構造へ投影することで両者を橋渡しします。", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "サイドバーの専用ワークスペース。", + "rowWindow": "そのワークスペース内のタブ。", + "rowPane": "そのタブ内の、ネイティブな分割の中のペイン。", + "mappingPanes": "新しく追加された機能は「タブの中のペイン」です。これまで cmux のタブは 1 つのターミナルだけを保持していましたが、複数のペインを持つ tmux ウィンドウは、1 つのタブの中でネイティブな cmux の分割レイアウトとして描画されるようになりました(各リモートペインは通常のクロムを備えたネイティブな cmux のターミナルペインです)。橋渡しは双方向で、そのタブ内でペインを分割・クローズすると tmux の split-window が実行され、タブを並べ替えると swap-window で tmux のウィンドウが並べ替えられます。", + "requirementsTitle": "必要なもの", + "requirementsDesc": "到達可能な SSH ホスト(cmux は ~/.ssh/config を読み込みます)と、tmux がインストールされていること。コントロールモード(tmux -CC)は tmux の標準機能なので、特別なビルドは不要です(全機能を使うには新しめの tmux を推奨)。cmux は SSH の ControlMaster 接続で接続し、デタッチしてもリモートの tmux サーバーは動作し続けます。", + "enableTitle": "有効にする", + "enableDesc": "設定 → ベータ機能 を開き、「リモート tmux」をオンにします。デフォルトはオフなので、オプトインするまでローカルのターミナルには何も影響しません。", + "attachTitle": "開く / ミラーリングする", + "attachIntro": "入り口は 2 通りあります。", + "attachOpen": "1 つのペインを開く — コマンドパレットの「Attach Remote tmux…」または remote.tmux.open でセッションに接続し、そのアクティブなペインを現在のワークスペースにライブのタブとして開きます。", + "attachMirror": "ホスト全体をミラーリングする — remote.tmux.mirror はホスト上のすべてのセッションをそれぞれのワークスペースとして再投影します。各ウィンドウはタブになり、複数ペインのウィンドウはタブ内の分割になります。", + "howTitle": "仕組み", + "howDesc": "cmux は ssh … tmux -CC attach を起動し、組み込みの tmux ビューアーに頼らずコントロールモードのストリームを自前で解析します。そのためプロトコルや %begin/%end のコマンド対応付けは完全に cmux が管理します。各リモートペインは tmux の %output を流し込む専用のターミナルサーフェスに描画され、キー入力やマウスなどの入力は tmux の send-keys でリモートへ送られます。ペインのサイズやリフローはリモートの tmux サーバーが管理し、cmux はそれに追従してローカルではリフローしません。", + "behaviorTitle": "サポートしている機能", + "behaviorSize": "サイズ調整: リモートクライアントを描画中のグリッドに合わせてリサイズします(refresh-client -C)。TUI が tmux のデフォルトの 80×24 のままになりません。", + "behaviorSplit": "分割: ミラーされたウィンドウでペインを分割・クローズすると tmux の split-window に伝播し、cmux と tmux のレイアウトが同期します。", + "behaviorReorder": "並べ替え: ミラーされたタブをドラッグで並べ替えると、swap-window で tmux のウィンドウが並べ替えられます。", + "behaviorCwd": "作業ディレクトリ: リモートペインの現在のフォルダーを追跡し、タブに表示します。", + "behaviorPaste": "貼り付けとドロップ: 貼り付けたテキストやドロップした画像・ファイルは tmux の paste-buffer -p 経由で渡されるため、リモートのアプリ(コーディングエージェントなど)は本物のブラケットペーストを受け取ります。画像はローカルパスではなく [Image #N] として届きます。", + "behaviorMouse": "マウス: リモートアプリがマウスモードのとき、クリック・スクロール・ドラッグがリモートアプリに届きます。ネイティブな cmux のテキスト選択・コピーには、Shift を押しながらドラッグしてください(マウスモードのアプリを実行している通常のターミナルと同じ挙動です)。", + "behaviorUnicode": "Unicode に正しい出力: tmux が更新の途中でマルチバイト文字を分割しても文字は保持されるため、罫線素片などの幅広な内容も乱れずに描画されます。", + "socketTitle": "ソケットコマンド", + "socketDesc": "この機能はソケットコマンドからも操作できます(ベータ機能フラグでゲートされます)。host は SSH の宛先または ~/.ssh/config のエイリアス、session は tmux のセッション名です。", + "socketMethod": "メソッド", + "socketParams": "パラメータ", + "socketMeaning": "説明", + "methodSessions": "ホスト上の tmux セッションを一覧表示します。", + "methodAttach": "セッションにコントロールクライアントで接続します(create で「接続または作成」)。", + "methodOpen": "接続して、セッションのアクティブなペインを現在のワークスペースにライブのタブとして開きます。", + "methodMirror": "ホスト上のすべてのセッションを、それぞれワークスペースとしてミラーリングします(ウィンドウがタブになります)。", + "methodDetach": "コントロールクライアントをデタッチします。リモートセッションは動作し続けます。", + "methodState": "コントロールクライアントの観測状態を報告します(診断用)。", + "socketSafetyDesc": "ダッシュで始まる host や identity ファイルは、SSH オプションインジェクション対策として境界で拒否されます。", + "limitationsTitle": "制限事項", + "limitReconnect": "ライブ再接続はありません: セッション中に SSH 接続が切れるとミラーは閉じ、再接続し直す必要があります。ミラーしたホストはアプリ再起動時に復元されますが、cmux ssh のようにバックオフ付きで自動再試行はしません。", + "limitPaste": "ブラケットペーストとして paste-buffer -p で渡されるのは単一行の貼り付け・ドロップ(ファイルや画像のパスなど)のみです。複数行のテキストは通常のキー入力として送られるため、リモートアプリは 1 回の貼り付けとして扱いません。", + "limitCwd": "作業ディレクトリのライブ更新はコントロールモードのサブスクリプション(tmux 3.2 以降)を使用します。それより古い tmux では初期フォルダーは表示されますが、cd しても更新されません。", + "limitReflow": "プライマリスクリーンのスクロールバックはリサイズ時に折り返し直されません。cmux はリフローを tmux に任せるため、古い行はアプリが再描画するまで以前の幅のままになることがあります。リサイズ時に再描画するフルスクリーンの TUI は影響を受けません。" + }, "layoutTitle": "cmux docs", "search": { "placeholder": "ドキュメントを検索...", From 6f7d7e011dd1a9e2c77782ef6ab997e0165cd4f4 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sun, 7 Jun 2026 01:30:08 +0300 Subject: [PATCH 09/73] Remote tmux: keep window reorder in sync across repeated drags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handleMirrorWindowsReordered` issues `swap-window` against `connection.windowOrder`, but `swap-window` changes window indices without emitting a notification cmux re-reads the order from — so `windowOrder` went stale after the first reorder and the next drag computed swaps against the pre-swap order (no-op or mis-sort). Re-fetch the authoritative order (`requestWindows`) after issuing swaps so reorders keep working across repeated drags. Co-Authored-By: Claude Opus 4.8 --- Sources/RemoteTmuxController.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 4364b528a0bf..e0f49cce31eb 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -403,11 +403,19 @@ final class RemoteTmuxController { let desiredSet = Set(desired) var current = mirror.connection.windowOrder.filter { desiredSet.contains($0) } guard current.count == desired.count, Set(current) == desiredSet else { return } + var swapped = false for index in desired.indices where current[index] != desired[index] { guard let swapFrom = current.firstIndex(of: desired[index]) else { continue } mirror.connection.send("swap-window -d -s @\(current[index]) -t @\(current[swapFrom])") current.swapAt(index, swapFrom) - } + swapped = true + } + // `swap-window` changes window indices but emits no notification cmux + // re-reads the order from, so `windowOrder` would otherwise stay stale — + // and the NEXT reorder would compute swaps against the pre-swap order and + // no-op or mis-sort. Re-fetch the authoritative order so reorders keep + // working across repeated drags. + if swapped { mirror.connection.requestWindows() } } /// A split was requested from a mirrored multi-pane surface → propagate to From 23cf7a4e2e43537c56660c5c50ad2e1174c7dbfe Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sun, 7 Jun 2026 01:35:22 +0300 Subject: [PATCH 10/73] Docs: correct remote tmux attach flow (mirrors the host) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Attach Remote tmux…" (File menu / command palette) takes an SSH destination and opens a new window mirroring the whole host's tmux sessions — it is not a single-pane open. Rewrite the Attaching section to match, and note that the remote.tmux.* socket commands (e.g. remote.tmux.open) offer the finer-grained single-pane variant. en/ja. Co-Authored-By: Claude Opus 4.8 --- web/app/[locale]/docs/remote-tmux/page.tsx | 5 +---- web/messages/en.json | 7 +++---- web/messages/ja.json | 7 +++---- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/web/app/[locale]/docs/remote-tmux/page.tsx b/web/app/[locale]/docs/remote-tmux/page.tsx index e824e79300b1..c5a166513793 100644 --- a/web/app/[locale]/docs/remote-tmux/page.tsx +++ b/web/app/[locale]/docs/remote-tmux/page.tsx @@ -50,10 +50,7 @@ export default function RemoteTmuxPage() { {t("attachTitle")}

{t("attachIntro")}

-
    -
  • {t("attachOpen")}
  • -
  • {t("attachMirror")}
  • -
+

{t("attachSockets")}

{t("howTitle")}

{t("howDesc")}

diff --git a/web/messages/en.json b/web/messages/en.json index b0be70352f6b..235cdbc8db1c 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -652,10 +652,9 @@ "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", "enableTitle": "Enable it", "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Open or mirror", - "attachIntro": "There are two ways in:", - "attachOpen": "Open a single pane — the “Attach Remote tmux…” command (command palette) or remote.tmux.open attaches a session and opens its active pane as a live tab in the current workspace.", - "attachMirror": "Mirror a whole host — remote.tmux.mirror reprojects every session on a host as its own workspace; each window becomes a tab and multi-pane windows become in-tab splits.", + "attachTitle": "Attaching", + "attachIntro": "Run File → Attach Remote tmux… (also in the command palette) and enter an SSH destination — a ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.open mirrors just one session's active pane into the current workspace instead of the whole host.", "howTitle": "How it works", "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", "behaviorTitle": "What it supports", diff --git a/web/messages/ja.json b/web/messages/ja.json index 70a6b4a82c8e..2a1a4b167362 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -608,10 +608,9 @@ "requirementsDesc": "到達可能な SSH ホスト(cmux は ~/.ssh/config を読み込みます)と、tmux がインストールされていること。コントロールモード(tmux -CC)は tmux の標準機能なので、特別なビルドは不要です(全機能を使うには新しめの tmux を推奨)。cmux は SSH の ControlMaster 接続で接続し、デタッチしてもリモートの tmux サーバーは動作し続けます。", "enableTitle": "有効にする", "enableDesc": "設定 → ベータ機能 を開き、「リモート tmux」をオンにします。デフォルトはオフなので、オプトインするまでローカルのターミナルには何も影響しません。", - "attachTitle": "開く / ミラーリングする", - "attachIntro": "入り口は 2 通りあります。", - "attachOpen": "1 つのペインを開く — コマンドパレットの「Attach Remote tmux…」または remote.tmux.open でセッションに接続し、そのアクティブなペインを現在のワークスペースにライブのタブとして開きます。", - "attachMirror": "ホスト全体をミラーリングする — remote.tmux.mirror はホスト上のすべてのセッションをそれぞれのワークスペースとして再投影します。各ウィンドウはタブになり、複数ペインのウィンドウはタブ内の分割になります。", + "attachTitle": "接続する", + "attachIntro": "File → Attach Remote tmux…(コマンドパレットからも実行できます)を実行し、SSH の宛先(~/.ssh/config のエイリアスまたは user@host)を入力します。cmux はそのホストの tmux セッションをミラーリングする新しいウィンドウを開きます。各セッションはワークスペースに、各ウィンドウはタブに、複数ペインのウィンドウはタブ内の分割になります。", + "attachSockets": "remote.tmux.* のソケットコマンド(下記)でより細かく制御できます。たとえば remote.tmux.open は、ホスト全体ではなく 1 つのセッションのアクティブなペインだけを現在のワークスペースにミラーリングします。", "howTitle": "仕組み", "howDesc": "cmux は ssh … tmux -CC attach を起動し、組み込みの tmux ビューアーに頼らずコントロールモードのストリームを自前で解析します。そのためプロトコルや %begin/%end のコマンド対応付けは完全に cmux が管理します。各リモートペインは tmux の %output を流し込む専用のターミナルサーフェスに描画され、キー入力やマウスなどの入力は tmux の send-keys でリモートへ送られます。ペインのサイズやリフローはリモートの tmux サーバーが管理し、cmux はそれに追従してローカルではリフローしません。", "behaviorTitle": "サポートしている機能", From bdd0ffe0a8994370087ecb5eeb886837c6ca5080 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sun, 7 Jun 2026 01:55:46 +0300 Subject: [PATCH 11/73] Remote tmux: keep window reorder correct across rapid drags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the tracked window order locally and synchronously after issuing `swap-window` (`applyWindowReorder`) instead of re-fetching it asynchronously. A `list-windows` re-fetch leaves a gap where a rapid follow-up drag reads the stale order — and an earlier reorder's snapshot can even land after a later one and roll the order back. The swaps achieve exactly the dragged order, so applying it locally matches tmux without a round-trip; out-of-band changes still reconcile on the topology events that already re-fetch. Adds a unit test for the pure reorder helper. Co-Authored-By: Claude Opus 4.8 --- Sources/RemoteTmuxControlConnection.swift | 21 ++++++++++++++++++++ Sources/RemoteTmuxController.swift | 12 ++++++----- cmuxTests/RemoteTmuxControlParserTests.swift | 18 +++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 3b12804d23d9..1a5269d19c0a 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -225,6 +225,27 @@ final class RemoteTmuxControlConnection { ) } + /// Rearranges the tracked window order to reflect a just-applied reorder. + /// `reordered` is the new sequence of a subset of windows (the ones the user + /// dragged); windows not in it keep their slots. This is synchronous and exact + /// — the `swap-window` commands achieve precisely this order, so it matches + /// tmux without a round-trip, and a rapid follow-up reorder reads the + /// just-applied order rather than a stale one. (A `list-windows` re-fetch would + /// reintroduce the race: an earlier reorder's async snapshot could land after a + /// later reorder and roll the order back. Out-of-band changes still reconcile + /// via the topology events that already trigger ``requestWindows()``.) + func applyWindowReorder(_ reordered: [Int]) { + windowOrder = Self.windowOrder(windowOrder, applyingReorder: reordered) + } + + /// Returns `order` with the windows in `reordered` rearranged into + /// `reordered`'s sequence, leaving windows not in that set in their positions. + nonisolated static func windowOrder(_ order: [Int], applyingReorder reordered: [Int]) -> [Int] { + let set = Set(reordered) + var iterator = reordered.makeIterator() + return order.map { set.contains($0) ? (iterator.next() ?? $0) : $0 } + } + /// Captures a pane's current visible contents (with escapes) and delivers /// them to the pane-output observers so a freshly-mounted display surface shows /// the existing screen instead of starting blank. diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index e0f49cce31eb..d611565c879a 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -411,11 +411,13 @@ final class RemoteTmuxController { swapped = true } // `swap-window` changes window indices but emits no notification cmux - // re-reads the order from, so `windowOrder` would otherwise stay stale — - // and the NEXT reorder would compute swaps against the pre-swap order and - // no-op or mis-sort. Re-fetch the authoritative order so reorders keep - // working across repeated drags. - if swapped { mirror.connection.requestWindows() } + // re-reads the order from, so update the tracked order locally. The swaps + // achieve exactly `desired`, so this matches tmux and a rapid follow-up + // drag computes against the just-applied order. (Deliberately NOT a + // `requestWindows()` re-fetch: its async snapshot could land after a later + // reorder and roll the order back, reintroducing the race; out-of-band + // changes reconcile on the topology events that re-fetch anyway.) + if swapped { mirror.connection.applyWindowReorder(desired) } } /// A split was requested from a mirrored multi-pane surface → propagate to diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift index 1ddfbc4ec387..07493fd68e0e 100644 --- a/cmuxTests/RemoteTmuxControlParserTests.swift +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -238,6 +238,24 @@ import Testing #expect(seq.hasSuffix("\u{1b}[47;3H")) // absolute cursor, placed last } + // MARK: - Optimistic window reorder (rapid-drag race fix) + + @Test func windowOrderApplyingReorderRearrangesSubsetInPlace() { + // All windows reordered → result is exactly the new sequence. + #expect( + RemoteTmuxControlConnection.windowOrder([0, 4, 6], applyingReorder: [0, 6, 4]) == [0, 6, 4] + ) + // A window not in the dragged subset keeps its slot; the dragged windows + // fill the slots they occupied, in the new order. + #expect( + RemoteTmuxControlConnection.windowOrder([0, 4, 6, 9], applyingReorder: [6, 4, 0]) == [6, 4, 0, 9] + ) + // No-op reorder leaves the order unchanged. + #expect( + RemoteTmuxControlConnection.windowOrder([0, 4, 6], applyingReorder: [0, 4, 6]) == [0, 4, 6] + ) + } + // MARK: - Raw layout parser @Test func parsesLeafLayoutWithChecksum() { From 7faf644cc1614eae96f4109e5500cdec098c31ef Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sun, 7 Jun 2026 02:05:30 +0300 Subject: [PATCH 12/73] Remote tmux: drop unused RemoteTmuxLayoutNode.isLeaf A leaf-check helper added during development whose call site never materialized (zero references in the codebase). Surfaced by a dead-code audit. Co-Authored-By: Claude Opus 4.8 --- Sources/RemoteTmuxLayoutNode.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Sources/RemoteTmuxLayoutNode.swift b/Sources/RemoteTmuxLayoutNode.swift index a37a79071c82..db7d2f923759 100644 --- a/Sources/RemoteTmuxLayoutNode.swift +++ b/Sources/RemoteTmuxLayoutNode.swift @@ -83,12 +83,6 @@ struct RemoteTmuxLayoutNode: Sendable, Equatable, Codable { } } - /// `true` when this node is a single pane (no split). - var isLeaf: Bool { - if case .pane = content { return true } - return false - } - /// All pane ids in this subtree, in depth-first left-to-right order — the /// natural order to create matching cmux splits. var paneIDsInOrder: [Int] { From ddd207da938ab44fd2b28ecb29cc446fd435cf8c Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sun, 7 Jun 2026 12:48:57 +0300 Subject: [PATCH 13/73] Remote tmux: out-of-band reorder, disconnect handling, scrollback seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rebuild() reorders mirror tabs to match the tmux window order for changes that originate remotely (move-window / mid-list new-window / a second client); focus-preserving and a no-op across split panes - on a remote session end, close the dedicated mirror window — gated so it never discards local work or another host's mirror, never leaves zero windows, and degrades a sole window to a fresh local workspace; distinguish SessionEndReason (.sessionExited vs .transportLost) - seed scrollback history on attach (capture-pane -e -S) so a (re)attached mirror tab is scrollable, not just output printed after attach - clarify the intentional empty list-windows guard - unit tests for the pure helpers (sessionEndAction, mirrorTabReorder) Co-Authored-By: Claude Opus 4.8 --- Sources/RemoteTmuxControlConnection.swift | 64 ++++++-- Sources/RemoteTmuxController.swift | 159 +++++++++++++++++-- Sources/RemoteTmuxSessionMirror.swift | 21 ++- Sources/Workspace.swift | 82 ++++++++++ cmuxTests/RemoteTmuxControlParserTests.swift | 78 +++++++++ 5 files changed, 373 insertions(+), 31 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 1a5269d19c0a..b481574c5a60 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -30,7 +30,7 @@ final class RemoteTmuxControlConnection { private var paneCwdObservers: [ObserverToken: (_ paneId: Int, _ path: String) -> Void] = [:] private var activePaneObservers: [ObserverToken: (_ windowId: Int, _ paneId: Int) -> Void] = [:] private var topologyObservers: [ObserverToken: () -> Void] = [:] - private var exitObservers: [ObserverToken: () -> Void] = [:] + private var exitObservers: [ObserverToken: (SessionEndReason) -> Void] = [:] // MARK: Observed state @@ -59,6 +59,19 @@ final class RemoteTmuxControlConnection { case listWindows, capturePane(Int), paneState(Int), panePath(Int), paneAltScreen(Int), other } + /// Why a control connection ended — distinguishes a genuine tmux end from a + /// transport drop so consumers can react differently (e.g. keep the host + /// persisted for relaunch on a transient network loss, but forget it when the + /// session was actually killed). + enum SessionEndReason: Sendable, Equatable { + /// tmux reported `%exit` — the session/server intentionally ended. + case sessionExited + /// The control stream died without `%exit` (ssh process exit, EOF, or a + /// broken-pipe write) — typically a network/transport loss, not a + /// deliberate end. + case transportLost + } + /// Subscription-name prefix for per-pane `pane_current_path` (`refresh-client -B`). /// The tmux pane id is appended so an inbound `%subscription-changed` can be /// routed back to its pane; defined once so the writer and reader can't drift. @@ -68,6 +81,13 @@ final class RemoteTmuxControlConnection { /// the remote pane is on the alternate screen (see ``capturePane(paneId:)``). private static let altScreenEnterSequence = Data("\u{1b}[?1049h".utf8) + /// How many lines of pane history `capture-pane` seeds onto a freshly mounted + /// (or reconnected) mirror surface. Capturing scrollback — not just the visible + /// screen — is what makes the mirrored tab scrollable from the start; without it + /// a fresh attach has only the current screen and nothing to scroll up into. + /// Clamped by the remote pane's `history-limit`, so short panes seed less. + private static let scrollbackCaptureLines = 5_000 + init(host: RemoteTmuxHost, sessionName: String, createIfMissing: Bool = false) { self.host = host self.sessionName = sessionName @@ -92,14 +112,14 @@ final class RemoteTmuxControlConnection { /// (`%window-pane-changed`), so consumers can re-project per-pane state /// (e.g. the active pane's directory) onto the window's tab. /// - onTopologyChanged: fires when the window/pane topology changes. - /// - onExit: fires once when control mode ends. + /// - onExit: fires once when control mode ends, with the ``SessionEndReason``. @discardableResult func addObserver( onPaneOutput: ((_ paneId: Int, _ data: Data) -> Void)? = nil, onPaneCwd: ((_ paneId: Int, _ path: String) -> Void)? = nil, onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)? = nil, onTopologyChanged: (() -> Void)? = nil, - onExit: (() -> Void)? = nil + onExit: ((SessionEndReason) -> Void)? = nil ) -> ObserverToken { let token = ObserverToken() if let onPaneOutput { paneOutputObservers[token] = onPaneOutput } @@ -135,8 +155,8 @@ final class RemoteTmuxControlConnection { for callback in topologyObservers.values { callback() } } - private func notifyExit() { - for callback in exitObservers.values { callback() } + private func notifyExit(reason: SessionEndReason) { + for callback in exitObservers.values { callback(reason) } } /// Spawns the SSH `tmux -CC` process and begins streaming. @@ -273,7 +293,11 @@ final class RemoteTmuxControlConnection { "display-message -p -t %\(paneId) -F \"#{alternate_on}\"", kind: .paneAltScreen(paneId) ) - sendInternal("capture-pane -p -e -t %\(paneId)", kind: .capturePane(paneId)) + // `-S -` seeds scrollback history (not just the visible screen) so the + // mirrored tab is scrollable immediately on attach/reconnect. On an + // alternate-screen pane there is no history, so tmux clamps to the visible + // alt screen — harmless. + sendInternal("capture-pane -p -e -S -\(Self.scrollbackCaptureLines) -t %\(paneId)", kind: .capturePane(paneId)) // Query the pane's terminal STATE; tmux exposes it all as formats. Sent // after capture-pane so it applies on top of the painted rows (the seed // escapes are built in `paneStateSeedSequence`). See the doc comment for why @@ -395,7 +419,7 @@ final class RemoteTmuxControlConnection { guard !exited else { return } exited = true stop() - notifyExit() + notifyExit(reason: .transportLost) } private func ingest(_ data: Data) { @@ -408,7 +432,7 @@ final class RemoteTmuxControlConnection { guard !exited else { return } exited = true record("stream-end") - notifyExit() + notifyExit(reason: .transportLost) } private func handle(_ message: RemoteTmuxControlMessage) { @@ -419,7 +443,7 @@ final class RemoteTmuxControlConnection { case let .exit(reason): exited = true record("exit\(reason.map { " " + $0 } ?? "")") - notifyExit() + notifyExit(reason: .sessionExited) case let .output(paneId, data): paneOutputByteCounts[paneId, default: 0] += data.count totalOutputBytes += data.count @@ -501,16 +525,28 @@ final class RemoteTmuxControlConnection { ) order.append(id) } + // Ignore an empty/garbled reply on purpose: a live tmux session always + // has ≥1 window, so a zero-window parse is a transient or malformed + // result, not a real topology. Acting on it would wipe `windowOrder` + // and tear down every mirror tab. A genuine "no windows" state means + // the session ended — that arrives as the connection's `%exit` / + // stream-end (see `handleConnectionExited` → `handleSessionEndedRemotely`), + // which is the path that closes the workspace / dedicated window. if !order.isEmpty { windowOrder = order notifyTopologyChanged() } case let .capturePane(paneId): - // capture-pane -e output is the pane's visible rows (with SGR - // escapes). Home + clear, paint the rows, and leave the cursor at - // the END of the last row (no trailing newline) so it lines up with - // tmux's real prompt cursor — otherwise echoed input lands a line - // below the prompt. + // capture-pane -e -S output is the pane's history + visible rows (with + // SGR escapes). Home + clear the VISIBLE SCREEN (ESC[2J — NOT ESC[3J, + // which would erase the scrollback we are seeding), then write every + // captured row joined by CR LF: rows that overflow the screen scroll up + // into the surface's scrollback buffer, which is what makes the mirrored + // tab scrollable from the start. The last row (the visible bottom) gets + // no trailing newline so the cursor lands at its END, lining up with + // tmux's real prompt cursor — otherwise echoed input lands a line below + // the prompt. The `.paneState` seed then repositions the cursor within + // the visible screen. let painted = "\u{1b}[H\u{1b}[2J" + lines.joined(separator: "\r\n") if let data = painted.data(using: .utf8) { emitPaneOutput(paneId, data) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index d611565c879a..8bdddc67d1c2 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -568,29 +568,164 @@ final class RemoteTmuxController { return true } + /// What to do when a mirrored session ends remotely: close the dead session's + /// workspace, or — when the host's dedicated mirror window has just lost its + /// last session — close that whole window (the disconnect UX). + enum SessionEndAction: Equatable { + /// Close only the dead session's workspace (host still has other sessions, + /// or the mirror lives in a shared/non-dedicated window). + case closeWorkspace + /// Close the dedicated remote-tmux window wholesale, because its last + /// session disconnected and `closeWorkspace` can't remove a window's last + /// workspace. + case closeDedicatedWindow(UUID) + } + + /// Decides how a remote session-end is reflected in the UI. + /// + /// - Parameters: + /// - dedicatedWindowId: the host's dedicated mirror window, or `nil` when the + /// host still has other live mirror sessions or was mirrored into a shared + /// window (the socket `remote.tmux.mirror` path). + /// - dedicatedWindowOwnedByEndingHost: whether every workspace in that window + /// belongs to the host whose session just ended (the dead workspace itself, + /// or another live mirror for the same host). `false` when the user has moved + /// a local workspace — or another host's mirror — into the dedicated window; + /// then closing the whole window would discard unrelated work, so only the + /// dead workspace is closed. + /// - otherMainWindowCount: how many OTHER main windows are open. The dedicated + /// window is only closed when at least one other window remains, so a + /// disconnect never leaves the user with zero windows. + /// - Returns: the action to apply. + nonisolated static func sessionEndAction( + dedicatedWindowId: UUID?, + dedicatedWindowOwnedByEndingHost: Bool, + otherMainWindowCount: Int + ) -> SessionEndAction { + if let dedicatedWindowId, dedicatedWindowOwnedByEndingHost, otherMainWindowCount >= 1 { + return .closeDedicatedWindow(dedicatedWindowId) + } + return .closeWorkspace + } + /// The remote tmux session ended on its own (its last window was killed, or - /// it was killed out-of-band) — remove the mirror + connection and close the - /// now-dead workspace WITHOUT issuing a kill (the session is already gone). - func handleSessionEndedRemotely(host: RemoteTmuxHost, sessionName: String, workspaceId: UUID) { + /// it was killed out-of-band) — remove the mirror + connection and either close + /// the now-dead workspace or, when the host's dedicated window just lost its + /// last session, close that whole window. Never issues a kill (the session is + /// already gone). + /// + /// - Parameter reason: distinguishes a genuine tmux `%exit` from a transport + /// drop. A transport loss (network blip) keeps the host persisted so the next + /// launch re-mirrors it; a genuine exit forgets it. + func handleSessionEndedRemotely( + host: RemoteTmuxHost, + sessionName: String, + workspaceId: UUID, + reason: RemoteTmuxControlConnection.SessionEndReason + ) { let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) if let mirror = sessionMirrors.removeValue(forKey: key) { mirror.detachObserver() } displayObserverTokens.removeValue(forKey: key) connectionsByHostSession.removeValue(forKey: key)?.stop() - if !sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) { - forgetMirroredHost(host.destination) + let hostHasOtherMirrors = sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) + // The dedicated window for this host, captured before the bindings are torn + // down. `nil` once other sessions remain — losing one of several sessions + // closes only its workspace, never the shared window. + let dedicatedWindowId = hostHasOtherMirrors ? nil : windowIdByHost[host.destination] + // Decide the UI action BEFORE tearing down persistence/bindings, so the + // persistence decision can depend on whether the dedicated window is + // actually closing. + // + // The mirror was already removed above, so any close path's kill hook finds + // no entry and won't re-issue a kill. + // + // Only close the whole dedicated window when it still exists and every + // workspace in it belongs to THIS host (the dead workspace, or another live + // mirror for the same host). The user may have moved a local workspace — or + // another host's mirror — into it (dedicated windows aren't excluded from + // move targets), and a disconnect must never discard unrelated work. + // Resolving the manager here also makes the window-count math robust to the + // window already being gone (a concurrent user close): the count then + // excludes nothing. + let dedicatedManager = dedicatedWindowId.flatMap { AppDelegate.shared?.tabManagerFor(windowId: $0) } + let dedicatedWindowIsOpen = dedicatedManager != nil + // Workspaces owned by the ending host: the just-ended one plus any other + // still-live mirrors for the same host (none once hostHasOtherMirrors is + // false, but computed generally). + let endingHostWorkspaceIds: Set = Set( + sessionMirrors.values + .filter { $0.host.destination == host.destination } + .compactMap { $0.mirroredWorkspaceId } + ).union([workspaceId]) + let ownedByEndingHost = dedicatedManager?.tabs.allSatisfy { endingHostWorkspaceIds.contains($0.id) } ?? false + let totalMainWindowCount = AppDelegate.shared?.mainWindowContexts.count ?? 0 + let otherMainWindowCount = max(0, totalMainWindowCount - (dedicatedWindowIsOpen ? 1 : 0)) + let action = Self.sessionEndAction( + dedicatedWindowId: dedicatedWindowIsOpen ? dedicatedWindowId : nil, + dedicatedWindowOwnedByEndingHost: ownedByEndingHost, + otherMainWindowCount: otherMainWindowCount + ) + if !hostHasOtherMirrors { + // Persistence (for restoreMirroredHostsOnLaunch): + // - Keep the host persisted only on a transient transport loss that + // either closes the dedicated window, or never had one (the socket + // `remote.tmux.mirror` path) — then relaunch re-mirrors into a fresh + // window with nothing left behind. + // - Forget it on a genuine `%exit`, AND on the sole-window degradation + // where the dedicated window survives as a plain local workspace: + // that window now lands in the generic session snapshot, so keeping + // the host would restore it AND re-mirror a duplicate on next launch. + let wasDedicated = dedicatedWindowId != nil + let closingDedicatedWindow: Bool = { + if case .closeDedicatedWindow = action { return true } + return false + }() + let keepPersistedForRelaunch = reason == .transportLost && (!wasDedicated || closingDedicatedWindow) + if !keepPersistedForRelaunch { + forgetMirroredHost(host.destination) + } + // Drop the dedicated-window binding (the window is either closing, or + // converting to a plain local window — either way it is no longer a + // remote mirror). Done before the switch so the window's onClose hook's + // handleRemoteWindowClosed finds the binding gone and is a no-op. if let windowId = windowIdByHost.removeValue(forKey: host.destination) { hostByWindowId.removeValue(forKey: windowId) } } - // Close the dead mirror workspace. The mirror was already removed above, - // so TabManager.closeWorkspace's kill hook finds no entry and won't - // re-issue a kill. (closeWorkspace leaves the last workspace in a window - // for the window-close path.) - if let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = manager.tabs.first(where: { $0.id == workspaceId }) { - manager.closeWorkspace(workspace) + #if DEBUG + cmuxDebugLog( + "remote-tmux: session ended host=\(host.destination) session=\(sessionName) reason=\(reason) " + + "hostHasOtherMirrors=\(hostHasOtherMirrors) dedicatedWindowOpen=\(dedicatedWindowIsOpen) " + + "ownedByEndingHost=\(ownedByEndingHost) otherWindows=\(otherMainWindowCount) action=\(action)" + ) + #endif + switch action { + case let .closeDedicatedWindow(windowId): + // Tear down the whole dedicated window (true detach UX). Uses + // `window.close()` (not `performClose`) so the disconnect never raises + // the "close window?" confirmation, and suppresses closed-window history + // (a dead-remote window isn't meaningfully restorable). The window's + // onClose hook detaches any remaining state; the mirror/connection for + // this session were already removed above. + AppDelegate.shared?.discardMainWindowWithoutClosedHistory(windowId: windowId) + case .closeWorkspace: + // Close just the dead workspace. `closeWorkspace` refuses to remove a + // window's last workspace (it would leave a windowless state), so if the + // dead mirror is the only workspace in its window, add a fresh local + // workspace first — that leaves a usable window instead of stranding a + // frozen, connection-less remote tab. `inheritWorkingDirectory: false` + // avoids inheriting the mirror's remote path; `select: false` keeps the + // disconnect from stealing focus (closeWorkspace reselects after the + // dead one is removed). + if let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = manager.tabs.first(where: { $0.id == workspaceId }) { + if manager.tabs.count == 1 { + _ = manager.addWorkspace(inheritWorkingDirectory: false, select: false) + } + manager.closeWorkspace(workspace) + } } } diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 7b90b246a6b8..332fb4b6d96a 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -57,8 +57,8 @@ final class RemoteTmuxSessionMirror { onTopologyChanged: { [weak self] in self?.rebuild() }, - onExit: { [weak self] in - self?.handleConnectionExited() + onExit: { [weak self] reason in + self?.handleConnectionExited(reason: reason) } ) rebuild() @@ -67,11 +67,12 @@ final class RemoteTmuxSessionMirror { /// The remote session ended on its own (e.g. its last tmux window was killed, /// or it was killed out-of-band) — hand off to the controller to remove the /// mirror and close the now-dead workspace. Deliberate detach/quit/window - /// close suppress `onExit`, so this only runs for genuine remote ends. - private func handleConnectionExited() { + /// close suppress `onExit`, so this only runs for genuine remote ends. `reason` + /// distinguishes a real tmux `%exit` from a transport drop (network loss). + private func handleConnectionExited(reason: RemoteTmuxControlConnection.SessionEndReason) { guard let workspaceId = mirroredWorkspaceId else { return } AppDelegate.shared?.remoteTmuxController.handleSessionEndedRemotely( - host: host, sessionName: sessionName, workspaceId: workspaceId + host: host, sessionName: sessionName, workspaceId: workspaceId, reason: reason ) } @@ -164,6 +165,16 @@ final class RemoteTmuxSessionMirror { let livePanes = Set(connection.windowsByID.values.flatMap { $0.paneIDsInOrder }) cwdByPane = cwdByPane.filter { livePanes.contains($0.key) } closeDefaultTabsIfNeeded() + // Follow out-of-band tmux window reorders (a second client, or a manual + // move-window / a new-window inserted mid-list): the cmux tabs are created + // in arrival order and appended, so a non-tail change leaves the strip + // stale. Reorder to match tmux's reported order, preserving focus. The + // cmux→tmux drag direction is handled by handleMirrorWindowsReordered and + // already matches, so this no-ops there. + let desiredPanelOrder = connection.windowOrder.compactMap { panelIdByWindow[$0] } + if desiredPanelOrder.count > 1 { + workspace.reorderRemoteTmuxMirrorTabs(toPanelOrder: desiredPanelOrder) + } } /// Creates the in-tab multi-pane renderer the first time a window has more diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c7b01278e9ab..9adec335503d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -11410,6 +11410,13 @@ final class Workspace: Identifiable, ObservableObject { private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] private var activeDetachCloseTransactions: Int = 0 private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } + /// True while ``reorderRemoteTmuxMirrorTabs(toPanelOrder:)`` is rearranging tabs. + /// bonsplit's `reorderTab`/`selectTab`/`focusPane` fire `didSelectTab` / + /// `didFocusPane`, each of which runs the full `applyTabSelection` activation + /// (focus moves, hibernation resume, focus-LRU record). A reactive tmux-driven + /// reorder must not run any of that — the user's selection/focus is unchanged — + /// so the delegate handlers short-circuit while this is set. + private var isApplyingRemoteTmuxTabReorder = false private var pendingRemoteSurfaceTTYName: String? private var pendingRemoteSurfaceTTYSurfaceId: UUID? private var pendingRemoteSurfacePortKickReason: WorkspaceRemoteSessionController.PortScanKickReason? @@ -16246,6 +16253,75 @@ final class Workspace: Identifiable, ObservableObject { return true } + /// Computes the target tab order for a remote-tmux-driven reorder, or `nil` + /// when no reorder is needed or safe. + /// + /// - Parameters: + /// - current: the workspace's current mirror-tab order (panel ids). + /// - requested: the tmux window order mapped to panel ids. + /// - Returns: the new order to apply, or `nil` when the tabs already match + /// `requested` or when `requested` (restricted to currently-present tabs) is + /// not a permutation of `current` (sets diverge — leave the tabs untouched). + nonisolated static func mirrorTabReorder(current: [UUID], requested: [UUID]) -> [UUID]? { + let present = Set(current) + let desired = requested.filter { present.contains($0) } + guard desired.count == current.count, Set(desired) == present else { return nil } + return desired == current ? nil : desired + } + + /// Reorders this workspace's remote-tmux mirror tabs so their left-to-right + /// order matches `panelOrder` (the tmux window order), preserving the user's + /// current tab selection and pane focus. + /// + /// This follows reorders that originate on the remote (a second tmux client, or + /// a manual `move-window` / a `new-window` inserted mid-list). The cmux→tmux + /// drag direction is handled by `handleMirrorWindowsReordered`. bonsplit's + /// `reorderTab` selects+focuses the moved tab (and `selectTab`/`focusPane` fire + /// the same activation), so the whole operation runs under + /// ``isApplyingRemoteTmuxTabReorder`` to suppress that churn — a reactive tmux + /// event must not steal focus or resume agents (socket focus policy). The user's + /// selection/focus are unchanged, so bonsplit's internal state is just restored + /// to match. No-ops when the tabs already match or aren't all in one pane. + /// + /// Known beta limitation: if a *remote* window reorder arrives while the user is + /// mid tab-drag, this can move tabs under the drag. The trigger is narrow (a + /// concurrent remote reorder during a ~1s local drag) and self-heals — the + /// drop's `didReorderTabsInPane` reconciles `connection.windowOrder` to the + /// final order. A drag-aware guard would need bonsplit to expose drag state. + @discardableResult + func reorderRemoteTmuxMirrorTabs(toPanelOrder panelOrder: [UUID]) -> Bool { + // All mirror tabs must live in a single pane: a global tmux window order + // can't be expressed across a user-arranged split. If the requested panels + // resolve to more than one pane (or none), skip rather than reorder a + // subset of one pane. + let presentPaneIds = Set(panelOrder.compactMap { paneId(forPanelId: $0) }) + guard presentPaneIds.count == 1, let paneId = presentPaneIds.first else { return false } + let currentPanelIds = bonsplitController.tabs(inPane: paneId).compactMap { panelIdFromSurfaceId($0.id) } + guard let desired = Self.mirrorTabReorder(current: currentPanelIds, requested: panelOrder) else { return false } +#if DEBUG + cmuxDebugLog("remote-tmux: reorder mirror tabs ws=\(id.uuidString.prefix(5)) count=\(desired.count)") +#endif + + let savedSelectedTabId = bonsplitController.selectedTab(inPane: paneId)?.id + let savedFocusedPaneId = bonsplitController.focusedPaneId + + isApplyingRemoteTmuxTabReorder = true + defer { isApplyingRemoteTmuxTabReorder = false } + for (index, panelId) in desired.enumerated() { + guard let tabId = surfaceIdFromPanelId(panelId) else { continue } + _ = bonsplitController.reorderTab(tabId, toIndex: index) + } + // Restore bonsplit's internal selection + focus (the loop moved them to the + // last-reordered tab). cmux's own focus/selection were never touched (the + // delegate handlers short-circuited), so this just realigns bonsplit with + // the user's unchanged state — no `applyTabSelection` runs. + if let savedSelectedTabId { bonsplitController.selectTab(savedSelectedTabId) } + if let savedFocusedPaneId { bonsplitController.focusPane(savedFocusedPaneId) } + + scheduleTerminalGeometryReconcile() + return true + } + func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard let sourcePanel = panels[panelId] else { return nil } @@ -19214,6 +19290,9 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { + // Suppress the per-move selection churn of a reactive mirror-tab reorder + // (the user's selection/focus is restored explicitly afterwards). + guard !isApplyingRemoteTmuxTabReorder else { return } applyTabSelection(tabId: tab.id, inPane: pane) } @@ -19295,6 +19374,9 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { + // See `isApplyingRemoteTmuxTabReorder`: a reactive reorder restores the + // prior pane focus itself, without re-running tab activation. + guard !isApplyingRemoteTmuxTabReorder else { return } // When a pane is focused, focus its selected tab's panel guard let tab = controller.selectedTab(inPane: pane) else { return } #if DEBUG diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift index 07493fd68e0e..bebae6cdc162 100644 --- a/cmuxTests/RemoteTmuxControlParserTests.swift +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -256,6 +256,84 @@ import Testing ) } + // MARK: - Session-end action (disconnect handling) + + @Test func sessionEndClosesDedicatedWindowWhenAnotherWindowRemains() { + let windowId = UUID() + // Dedicated host-owned window lost its last session and another window is + // open → close the whole window (the disconnect UX). + #expect( + RemoteTmuxController.sessionEndAction( + dedicatedWindowId: windowId, dedicatedWindowOwnedByEndingHost: true, otherMainWindowCount: 1 + ) == .closeDedicatedWindow(windowId) + ) + #expect( + RemoteTmuxController.sessionEndAction( + dedicatedWindowId: windowId, dedicatedWindowOwnedByEndingHost: true, otherMainWindowCount: 3 + ) == .closeDedicatedWindow(windowId) + ) + } + + @Test func sessionEndKeepsSoleDedicatedWindowOpen() { + // Dedicated window but it is the ONLY window → don't close it (never leave + // the user with zero windows); fall back to closing just the workspace. + #expect( + RemoteTmuxController.sessionEndAction( + dedicatedWindowId: UUID(), dedicatedWindowOwnedByEndingHost: true, otherMainWindowCount: 0 + ) == .closeWorkspace + ) + } + + @Test func sessionEndKeepsDedicatedWindowWithUnrelatedWorkspace() { + // The user moved a local workspace — or another host's mirror — into the + // dedicated window → closing the whole window would discard it. Close only + // the dead workspace instead. + #expect( + RemoteTmuxController.sessionEndAction( + dedicatedWindowId: UUID(), dedicatedWindowOwnedByEndingHost: false, otherMainWindowCount: 2 + ) == .closeWorkspace + ) + } + + @Test func sessionEndClosesWorkspaceWhenNotDedicated() { + // No dedicated window (host still has other sessions, or a shared/socket + // mirror) → only the dead workspace closes, regardless of window count. + #expect( + RemoteTmuxController.sessionEndAction( + dedicatedWindowId: nil, dedicatedWindowOwnedByEndingHost: false, otherMainWindowCount: 0 + ) == .closeWorkspace + ) + #expect( + RemoteTmuxController.sessionEndAction( + dedicatedWindowId: nil, dedicatedWindowOwnedByEndingHost: true, otherMainWindowCount: 5 + ) == .closeWorkspace + ) + } + + // MARK: - Mirror tab reorder (out-of-band tmux window reorder) + + @Test func mirrorTabReorderFollowsRemoteWindowOrder() { + let a = UUID(), b = UUID(), c = UUID() + // Remote moved the windows → cmux tabs rearrange to match. + #expect(Workspace.mirrorTabReorder(current: [a, b, c], requested: [c, a, b]) == [c, a, b]) + // A requested id that has no tab yet (filtered out) still yields the valid + // reorder of the present tabs. + #expect(Workspace.mirrorTabReorder(current: [a, b], requested: [b, a, UUID()]) == [b, a]) + } + + @Test func mirrorTabReorderNoOpsWhenAlreadyOrdered() { + let a = UUID(), b = UUID(), c = UUID() + #expect(Workspace.mirrorTabReorder(current: [a, b, c], requested: [a, b, c]) == nil) + } + + @Test func mirrorTabReorderSkipsWhenSetsDiverge() { + let a = UUID(), b = UUID(), c = UUID() + // Requested is missing a present tab → not a permutation → leave untouched. + #expect(Workspace.mirrorTabReorder(current: [a, b, c], requested: [a, b]) == nil) + // Requested drops one present tab and only reorders the rest → sets diverge. + #expect(Workspace.mirrorTabReorder(current: [a, b, c], requested: [c, b]) == nil) + } + // MARK: - Raw layout parser @Test func parsesLeafLayoutWithChecksum() { From 196caf215e3d7e5554192c5d7be206136aadcbcd Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sun, 7 Jun 2026 13:37:55 +0300 Subject: [PATCH 14/73] Remote tmux: auto-reconnect on transport loss (keep the mirror frozen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A network/ssh drop no longer closes the mirror window. The connection keeps the frozen mirror and re-attaches with capped exponential backoff (indefinitely), re-seeding each pane on reconnect (re-apply client size, clear + re-capture contents with scrollback, re-subscribe cwd). It ends only on a genuine tmux %exit, or when a reconnect reaches the host but the session is gone (classified from ssh/tmux stderr) — which auto-closes per the disconnect policy. Reconnect is attach-only, so a session killed during the outage is never silently recreated. - ConnectionState (.connecting/.connected/.reconnecting/.ended); `exited` derived - stderr consumed via AsyncStream, drained before session-gone classification - reconnect re-seed deferred to the first post-reconnect list-windows result so it can't misalign the command-result FIFO - removed the now-unreachable SessionEndReason (transport loss reconnects, so a genuine end always forgets the host) - unit tests for the session-gone stderr classifier Co-Authored-By: Claude Opus 4.8 --- Sources/RemoteTmuxControlConnection.swift | 368 ++++++++++++++++--- Sources/RemoteTmuxController.swift | 44 +-- Sources/RemoteTmuxSessionMirror.swift | 18 +- cmuxTests/RemoteTmuxControlParserTests.swift | 22 ++ 4 files changed, 370 insertions(+), 82 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index b481574c5a60..2e8c0c29d799 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -30,13 +30,26 @@ final class RemoteTmuxControlConnection { private var paneCwdObservers: [ObserverToken: (_ paneId: Int, _ path: String) -> Void] = [:] private var activePaneObservers: [ObserverToken: (_ windowId: Int, _ paneId: Int) -> Void] = [:] private var topologyObservers: [ObserverToken: () -> Void] = [:] - private var exitObservers: [ObserverToken: (SessionEndReason) -> Void] = [:] + private var exitObservers: [ObserverToken: () -> Void] = [:] + private var stateObservers: [ObserverToken: (ConnectionState) -> Void] = [:] // MARK: Observed state private(set) var started = false private(set) var enterReceived = false - private(set) var exited = false + /// The connection's lifecycle phase. Drives reconnect-on-transport-loss and the + /// disconnected UI; `exited` is derived from it. + private(set) var connectionState: ConnectionState = .connecting { + didSet { + guard oldValue != connectionState else { return } + for callback in stateObservers.values { callback(connectionState) } + } + } + /// `true` once the connection has permanently ended (genuine tmux `%exit`, a + /// session discovered gone on reconnect, or a deliberate ``stop()``). A + /// transient transport loss is `.reconnecting`, NOT ended — so callers that + /// guard on `!exited` keep treating a reconnecting connection as alive. + var exited: Bool { connectionState == .ended } private(set) var sessionId: Int? private(set) var windowsByID: [Int: RemoteTmuxWindow] = [:] private(set) var windowOrder: [Int] = [] @@ -48,28 +61,70 @@ final class RemoteTmuxControlConnection { private var process: Process? private var stdinHandle: FileHandle? private var stdoutReader: FileHandle? + private var stderrReader: FileHandle? private var streamContinuation: AsyncStream.Continuation? + private var stderrContinuation: AsyncStream.Continuation? + /// Consumes the current spawn's stderr into `stderrBuffer`. Awaited before a + /// failed reconnect attempt is classified, so the decision sees the complete + /// error rather than racing the async stderr delivery. + private var stderrTask: Task? private var parser = RemoteTmuxControlStreamParser() private var ingestTask: Task? private var pendingCommands: [CommandKind] = [] private let createIfMissing: Bool private let maxRecentEvents = 100 + // MARK: Reconnect state + + /// The current reconnect backoff task (a single sleeping `Task` between + /// attempts); cancelled on `stop()` / genuine end so a dead connection stops + /// retrying. + private var reconnectTask: Task? + /// Number of reconnect attempts since the last successful connect, driving the + /// capped exponential backoff. Reset to 0 on a successful connect. + private var reconnectAttemptCount = 0 + /// stderr text captured for the in-flight spawn, inspected when a reconnect + /// attempt's process exits to tell "session genuinely gone" from "host still + /// unreachable". Reset at the start of each spawn. + private var stderrBuffer = "" + /// Last client size applied via ``setClientSize(columns:rows:)``, re-applied + /// after a reconnect so the resumed session keeps the mirror's grid instead of + /// reverting to ssh's default 80×24. + private var lastClientSize: (columns: Int, rows: Int)? + /// Set when a reconnect reaches control mode; the actual pane re-seed is deferred + /// to the first post-reconnect `list-windows` result so it can't queue commands + /// before the attach's own `%begin`/`%end` block is consumed (which would misalign + /// the command-result FIFO). + private var pendingReconnectReseed = false + + /// Base reconnect backoff (seconds); doubled each attempt up to ``reconnectMaxDelaySeconds``. + private static let reconnectBaseDelaySeconds: Double = 1 + /// Cap on the reconnect backoff (seconds). Retries continue indefinitely at this + /// interval until the network returns or the session is found to be gone. + private static let reconnectMaxDelaySeconds: Double = 10 + /// Cap on captured stderr (bytes) so a noisy/hostile remote can't grow it unbounded. + private static let maxStderrBytes = 8 * 1024 + private enum CommandKind: Equatable { case listWindows, capturePane(Int), paneState(Int), panePath(Int), paneAltScreen(Int), other } - /// Why a control connection ended — distinguishes a genuine tmux end from a - /// transport drop so consumers can react differently (e.g. keep the host - /// persisted for relaunch on a transient network loss, but forget it when the - /// session was actually killed). - enum SessionEndReason: Sendable, Equatable { - /// tmux reported `%exit` — the session/server intentionally ended. - case sessionExited - /// The control stream died without `%exit` (ssh process exit, EOF, or a - /// broken-pipe write) — typically a network/transport loss, not a - /// deliberate end. - case transportLost + /// The lifecycle phase of a control connection. + /// + /// A transport loss moves `.connected` → `.reconnecting` (the mirror stays + /// frozen and keeps retrying); a successful re-attach returns to `.connected` + /// and re-seeds the panes. Only a genuine tmux `%exit`, a session found gone on + /// reconnect, or a deliberate ``stop()`` reaches `.ended`. + enum ConnectionState: Sendable, Equatable { + /// The initial connection is being established (before the first control-mode + /// `%enter`). + case connecting + /// Live: control mode is up and streaming. + case connected + /// The transport dropped; retrying with backoff while the mirror stays frozen. + case reconnecting + /// Permanently over (genuine `%exit`, session gone, or deliberate stop). + case ended } /// Subscription-name prefix for per-pane `pane_current_path` (`refresh-client -B`). @@ -112,14 +167,20 @@ final class RemoteTmuxControlConnection { /// (`%window-pane-changed`), so consumers can re-project per-pane state /// (e.g. the active pane's directory) onto the window's tab. /// - onTopologyChanged: fires when the window/pane topology changes. - /// - onExit: fires once when control mode ends, with the ``SessionEndReason``. + /// - onExit: fires once when the connection PERMANENTLY ends (a genuine tmux + /// `%exit`, or a session found gone on reconnect). A transient transport loss + /// does NOT fire this — the connection reconnects instead. + /// - onConnectionStateChanged: fires on every ``ConnectionState`` transition + /// (e.g. `.connected` → `.reconnecting` on a transport loss), so consumers + /// can show a disconnected/reconnecting indicator without tearing down. @discardableResult func addObserver( onPaneOutput: ((_ paneId: Int, _ data: Data) -> Void)? = nil, onPaneCwd: ((_ paneId: Int, _ path: String) -> Void)? = nil, onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)? = nil, onTopologyChanged: (() -> Void)? = nil, - onExit: ((SessionEndReason) -> Void)? = nil + onExit: (() -> Void)? = nil, + onConnectionStateChanged: ((ConnectionState) -> Void)? = nil ) -> ObserverToken { let token = ObserverToken() if let onPaneOutput { paneOutputObservers[token] = onPaneOutput } @@ -127,6 +188,7 @@ final class RemoteTmuxControlConnection { if let onActivePaneChanged { activePaneObservers[token] = onActivePaneChanged } if let onTopologyChanged { topologyObservers[token] = onTopologyChanged } if let onExit { exitObservers[token] = onExit } + if let onConnectionStateChanged { stateObservers[token] = onConnectionStateChanged } return token } @@ -137,6 +199,7 @@ final class RemoteTmuxControlConnection { activePaneObservers[token] = nil topologyObservers[token] = nil exitObservers[token] = nil + stateObservers[token] = nil } private func emitPaneOutput(_ paneId: Int, _ data: Data) { @@ -155,14 +218,34 @@ final class RemoteTmuxControlConnection { for callback in topologyObservers.values { callback() } } - private func notifyExit(reason: SessionEndReason) { - for callback in exitObservers.values { callback(reason) } + private func notifyExit() { + for callback in exitObservers.values { callback() } } /// Spawns the SSH `tmux -CC` process and begins streaming. func start() throws { guard !started else { return } try host.ensureControlSocketDirectory() + // The initial connect honors `createIfMissing`; reconnects never create. + try spawnProcess(createIfMissing: createIfMissing) + started = true + } + + /// Spawns (or re-spawns, on reconnect) the SSH `tmux -CC` process and wires its + /// stdout into the parser, consuming stderr for session-gone classification. + /// Resets the per-process state (parser, pending-command FIFO, captured stderr, + /// `enterReceived`) so a reconnect starts from a clean control stream. + /// + /// - Parameter createIfMissing: `true` only for the initial connect. Reconnect + /// attempts pass `false` (`attach-session`), so a session killed during the + /// outage fails the re-attach (→ `.ended`) instead of being silently recreated. + private func spawnProcess(createIfMissing: Bool) throws { + // Fresh control stream: the prior attempt's parser buffer and pending-command + // FIFO are stale and must not bleed into the new %begin/%end correlation. + parser = RemoteTmuxControlStreamParser() + pendingCommands.removeAll() + stderrBuffer = "" + enterReceived = false let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") @@ -187,7 +270,27 @@ final class RemoteTmuxControlConnection { continuation.yield(chunk) } } - proc.terminationHandler = { _ in continuation.finish() } + // Capture stderr via its own AsyncStream so a failed reconnect attempt can be + // classified deterministically: `handleStreamEnd` awaits `stderrTask` (which + // finishes on stderr EOF) before reading `stderrBuffer`, so the decision can't + // race a not-yet-delivered chunk. + let (errStream, errContinuation) = AsyncStream.makeStream() + let errReader = errPipe.fileHandleForReading + errReader.readabilityHandler = { handle in + let chunk = handle.availableData + if chunk.isEmpty { + handle.readabilityHandler = nil + errContinuation.finish() + } else { + errContinuation.yield(chunk) + } + } + // Finish BOTH streams on process exit so the consumers (and any awaiter) + // always complete even if a reader's EOF callback is delayed. + proc.terminationHandler = { _ in + continuation.finish() + errContinuation.finish() + } do { try proc.run() @@ -197,20 +300,39 @@ final class RemoteTmuxControlConnection { // stdin handle too, so the connection is left in a clean, retry-safe // state instead of holding a dead pipe that silently EPIPEs on write. reader.readabilityHandler = nil + errReader.readabilityHandler = nil continuation.finish() + errContinuation.finish() try? stdinHandle?.close() stdinHandle = nil throw error } - started = true process = proc stdoutReader = reader + stderrReader = errReader streamContinuation = continuation + stderrContinuation = errContinuation + stderrTask = Task { [weak self] in + for await chunk in errStream { + guard let text = String(data: chunk, encoding: .utf8), !text.isEmpty else { continue } + self?.appendStderr(text) + } + } ingestTask = Task { [weak self] in for await chunk in stream { self?.ingest(chunk) } - self?.handleStreamEnd() + await self?.handleStreamEnd() + } + } + + /// Appends captured stderr, bounded (by UTF-8 bytes) so a noisy/hostile remote + /// can't grow it without limit. Keeps the tail (the most recent, where the + /// failure reason is). + private func appendStderr(_ text: String) { + stderrBuffer += text + if stderrBuffer.utf8.count > Self.maxStderrBytes { + stderrBuffer = String(decoding: Array(stderrBuffer.utf8.suffix(Self.maxStderrBytes)), as: UTF8.self) } } @@ -222,14 +344,21 @@ final class RemoteTmuxControlConnection { /// Sizes the tmux control client to `columns`×`rows` cells (tmux /// `refresh-client -C`) so the remote windows/panes reflow to the rendered /// cmux grid. Without this a freshly attached session stays at ssh's default - /// 80×24 and TUIs (claude, claude agents) render mangled. No-ops once the - /// connection has exited or for a degenerate grid. + /// 80×24 and TUIs (claude, claude agents) render mangled. Always records the grid + /// (re-applied by ``reseedAfterReconnect()``); sends the live `refresh-client` + /// only while `.connected`. No-ops for a degenerate grid. /// /// This is the single sizing entrypoint every remote-tmux render path routes /// through (the single-pane display surface and the multi-pane window mirror), /// so client sizing stays one shared behavior rather than duplicated sends. func setClientSize(columns: Int, rows: Int) { - guard !exited, columns > 0, rows > 0 else { return } + guard columns > 0, rows > 0 else { return } + // Remember the grid so a reconnect can re-apply it (a fresh ssh client + // otherwise reverts to 80×24 and mangles TUIs). Only send now when actually + // connected — while reconnecting/ended there is no live stdin (the send would + // silently drop); `reseedAfterReconnect` re-applies the stored size. + lastClientSize = (columns, rows) + guard connectionState == .connected else { return } send("refresh-client -C \(columns)x\(rows)") } @@ -368,24 +497,37 @@ final class RemoteTmuxControlConnection { } /// Detaches: terminating ssh kills the control client but leaves the remote - /// tmux session alive for resume. + /// tmux session alive for resume. Permanently ends the connection — no reconnect. func stop() { - // Mark exited FIRST so the deliberate teardown does not fire `onExit`: - // finishing the stream makes the ingest task run `handleStreamEnd`, whose - // `guard !exited` then short-circuits. Only a genuine remote end (a real - // `%exit`, an unexpected stream EOF, or a broken-pipe write) notifies exit - // observers — so detach/quit/window-close (preserve) never trigger the - // "session ended remotely" cleanup. - exited = true + // Mark `.ended` FIRST so the deliberate teardown's stream-end is ignored and + // never fires `onExit` or a reconnect: only a genuine remote end (a real + // `%exit` or a session found gone on reconnect) notifies exit observers — so + // detach / quit / window-close (preserve) and transport drops do not. + connectionState = .ended + reconnectTask?.cancel() + reconnectTask = nil + teardownProcessHandles() + } + + /// Tears down the current spawn's process and I/O handles WITHOUT changing + /// `connectionState`, so the connection can either end (``stop()``) or re-spawn + /// (reconnect) from a clean slate. + private func teardownProcessHandles() { ingestTask?.cancel() ingestTask = nil + stderrTask?.cancel() + stderrTask = nil process?.terminationHandler = nil - // Tear down the stdout reader deterministically rather than waiting for - // EOF (the ingest consumer is already cancelled). + // Tear down the readers deterministically rather than waiting for EOF (the + // consumers are already cancelled). stdoutReader?.readabilityHandler = nil stdoutReader = nil + stderrReader?.readabilityHandler = nil + stderrReader = nil streamContinuation?.finish() streamContinuation = nil + stderrContinuation?.finish() + stderrContinuation = nil try? stdinHandle?.close() stdinHandle = nil process?.terminate() @@ -416,10 +558,10 @@ final class RemoteTmuxControlConnection { } private func handleWriteFailure() { - guard !exited else { return } - exited = true - stop() - notifyExit(reason: .transportLost) + // A broken-pipe write means the transport dropped. Keep the mirror frozen + // and reconnect (the remote tmux session survives an ssh client death) + // rather than ending. `beginReconnecting` guards the source state. + beginReconnecting() } private func ingest(_ data: Data) { @@ -428,11 +570,124 @@ final class RemoteTmuxControlConnection { } } - private func handleStreamEnd() { - guard !exited else { return } - exited = true + private func handleStreamEnd() async { record("stream-end") - notifyExit(reason: .transportLost) + switch connectionState { + case .ended: + return + case .connecting, .connected: + // The control stream died without `%exit` — a transport loss. Keep the + // mirror frozen and reconnect. + beginReconnecting() + case .reconnecting: + // A reconnect attempt's process exited before reaching control mode + // (a successful attach would have moved us to `.connected` via `.enter`). + // Drain the attempt's stderr to completion (the process has exited, so the + // stream finishes) BEFORE classifying, so the decision can't race a + // not-yet-delivered chunk and misclassify a gone session as transient. + await stderrTask?.value + // A state change may have raced the drain (e.g. a deliberate stop()). + guard connectionState == .reconnecting else { return } + // Classify: a session/server found gone is a genuine end; anything else + // (host unreachable, refused) is transient — keep retrying with backoff. + let sessionGone = Self.stderrIndicatesSessionGone(stderrBuffer) + teardownProcessHandles() + if sessionGone { + record("reconnect-session-gone") + connectionState = .ended + reconnectTask?.cancel() + reconnectTask = nil + notifyExit() + } else { + scheduleReconnectAttempt() + } + } + } + + // MARK: - Reconnect + + /// Begins reconnecting after a transport loss: tears down the dead spawn, marks + /// `.reconnecting` (consumers keep the frozen mirror), and schedules the first + /// retry. No-op unless currently connected/connecting. + private func beginReconnecting() { + guard connectionState == .connected || connectionState == .connecting else { return } + record("reconnecting") + teardownProcessHandles() + reconnectAttemptCount = 0 + connectionState = .reconnecting + scheduleReconnectAttempt() + } + + /// Schedules the next reconnect attempt after a capped exponential backoff. + private func scheduleReconnectAttempt() { + let attempt = reconnectAttemptCount + reconnectAttemptCount += 1 + let delay = min( + Self.reconnectMaxDelaySeconds, + Self.reconnectBaseDelaySeconds * pow(2, Double(attempt)) + ) + record("reconnect-scheduled attempt=\(attempt) delay=\(delay)") + reconnectTask?.cancel() + // A bounded, cancellable backoff before the next attempt (not a poll/settle): + // cancelled by stop()/genuine end, re-armed by each failed attempt. `do/catch` + // (not `try?`) so a cancelled sleep returns immediately — the previously + // scheduled task can't fall through and double-spawn a second ssh client. + reconnectTask = Task { @MainActor [weak self] in + do { + try await ContinuousClock().sleep(for: .seconds(delay)) + } catch { + return + } + guard let self, self.connectionState == .reconnecting else { return } + self.attemptReconnectSpawn() + } + } + + /// Re-spawns the ssh control client for a reconnect attempt. Always attach-only + /// (`createIfMissing: false`) so a session killed during the outage fails the + /// re-attach (→ classified `.ended`) instead of being silently recreated empty. + /// A spawn failure (e.g. control-socket dir) backs off and retries; the spawn's + /// success/failure is observed via `.enter` (connected) or `handleStreamEnd`. + private func attemptReconnectSpawn() { + record("reconnect-attempt") + do { + try spawnProcess(createIfMissing: false) + } catch { + scheduleReconnectAttempt() + } + } + + /// Re-seeds every mirrored pane after a successful reconnect: the fresh ssh + /// client lost the prior screen, cwd subscriptions, and client size, so re-apply + /// the grid, then per pane clear the stale frozen content (screen + scrollback) + /// and re-capture current contents (with history) + cwd. Called from the first + /// post-reconnect `list-windows` result, so `windowsByID` is freshly repopulated + /// and the command-result FIFO is aligned (the attach block is already drained). + private func reseedAfterReconnect() { + if let size = lastClientSize { + send("refresh-client -C \(size.columns)x\(size.rows)") + } + for window in windowsByID.values { + for paneId in window.paneIDsInOrder { + emitPaneOutput(paneId, Data("\u{1b}[H\u{1b}[2J\u{1b}[3J".utf8)) + capturePane(paneId: paneId) + requestPanePath(paneId: paneId) + subscribePanePath(paneId: paneId) + } + } + } + + /// Whether captured ssh/tmux stderr indicates the session/server is genuinely + /// gone (reconnect should stop and end) vs a transient transport failure (host + /// unreachable / connection refused — keep retrying). + nonisolated static func stderrIndicatesSessionGone(_ stderr: String) -> Bool { + let lowered = stderr.lowercased() + return lowered.contains("can't find session") + || lowered.contains("can\u{2019}t find session") + || lowered.contains("no server running") + || lowered.contains("no current session") + || lowered.contains("session not found") + || lowered.contains("lost server") } private func handle(_ message: RemoteTmuxControlMessage) { @@ -440,10 +695,30 @@ final class RemoteTmuxControlConnection { case .enter: enterReceived = true record("enter") + // First connect, or a reconnect attempt that reached control mode. + if connectionState != .connected { + let wasReconnecting = connectionState == .reconnecting + connectionState = .connected + reconnectAttemptCount = 0 + reconnectTask?.cancel() + reconnectTask = nil + // Resync the surfaces after a reconnect (a fresh client lost the + // screen/subscriptions). DON'T reseed here: the attach's own + // %begin/%end block hasn't been consumed yet, so queuing commands now + // would misalign the %end correlation FIFO. Defer until the first + // post-reconnect list-windows result (attach block drained, + // windowsByID freshly repopulated). Skipped on the first connect (the + // mirror seeds new tabs itself). + if wasReconnecting { pendingReconnectReseed = true } + } case let .exit(reason): - exited = true record("exit\(reason.map { " " + $0 } ?? "")") - notifyExit(reason: .sessionExited) + // A genuine remote end (session/server intentionally exited). No reconnect. + guard connectionState != .ended else { return } + connectionState = .ended + reconnectTask?.cancel() + reconnectTask = nil + notifyExit() case let .output(paneId, data): paneOutputByteCounts[paneId, default: 0] += data.count totalOutputBytes += data.count @@ -535,6 +810,13 @@ final class RemoteTmuxControlConnection { if !order.isEmpty { windowOrder = order notifyTopologyChanged() + // Now that the attach block is drained and the topology is fresh, run + // the deferred reconnect re-seed (re-capture each pane). Queuing the + // capture commands here keeps the result FIFO aligned. + if pendingReconnectReseed { + pendingReconnectReseed = false + reseedAfterReconnect() + } } case let .capturePane(paneId): // capture-pane -e -S output is the pane's history + visible rows (with diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 8bdddc67d1c2..038a36fed3ef 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -608,20 +608,16 @@ final class RemoteTmuxController { return .closeWorkspace } - /// The remote tmux session ended on its own (its last window was killed, or - /// it was killed out-of-band) — remove the mirror + connection and either close - /// the now-dead workspace or, when the host's dedicated window just lost its - /// last session, close that whole window. Never issues a kill (the session is - /// already gone). - /// - /// - Parameter reason: distinguishes a genuine tmux `%exit` from a transport - /// drop. A transport loss (network blip) keeps the host persisted so the next - /// launch re-mirrors it; a genuine exit forgets it. + /// The remote tmux session ended FOR GOOD (its last window was killed, it was + /// killed out-of-band, or a reconnect found it gone) — remove the mirror + + /// connection and either close the now-dead workspace or, when the host's + /// dedicated window just lost its last session, close that whole window. Never + /// issues a kill (the session is already gone). A transient transport loss does + /// NOT reach here — the connection reconnects instead. func handleSessionEndedRemotely( host: RemoteTmuxHost, sessionName: String, - workspaceId: UUID, - reason: RemoteTmuxControlConnection.SessionEndReason + workspaceId: UUID ) { let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) if let mirror = sessionMirrors.removeValue(forKey: key) { @@ -668,24 +664,12 @@ final class RemoteTmuxController { otherMainWindowCount: otherMainWindowCount ) if !hostHasOtherMirrors { - // Persistence (for restoreMirroredHostsOnLaunch): - // - Keep the host persisted only on a transient transport loss that - // either closes the dedicated window, or never had one (the socket - // `remote.tmux.mirror` path) — then relaunch re-mirrors into a fresh - // window with nothing left behind. - // - Forget it on a genuine `%exit`, AND on the sole-window degradation - // where the dedicated window survives as a plain local workspace: - // that window now lands in the generic session snapshot, so keeping - // the host would restore it AND re-mirror a duplicate on next launch. - let wasDedicated = dedicatedWindowId != nil - let closingDedicatedWindow: Bool = { - if case .closeDedicatedWindow = action { return true } - return false - }() - let keepPersistedForRelaunch = reason == .transportLost && (!wasDedicated || closingDedicatedWindow) - if !keepPersistedForRelaunch { - forgetMirroredHost(host.destination) - } + // This path runs only for a genuine remote end (a transient transport + // loss reconnects instead of reaching here), so forget the host: the + // session is truly gone and must not be re-mirrored on the next launch. + // (Quitting cmux detaches via stop(), which suppresses this, so the host + // stays persisted for relaunch in that case.) + forgetMirroredHost(host.destination) // Drop the dedicated-window binding (the window is either closing, or // converting to a plain local window — either way it is no longer a // remote mirror). Done before the switch so the window's onClose hook's @@ -696,7 +680,7 @@ final class RemoteTmuxController { } #if DEBUG cmuxDebugLog( - "remote-tmux: session ended host=\(host.destination) session=\(sessionName) reason=\(reason) " + + "remote-tmux: session ended host=\(host.destination) session=\(sessionName) " + "hostHasOtherMirrors=\(hostHasOtherMirrors) dedicatedWindowOpen=\(dedicatedWindowIsOpen) " + "ownedByEndingHost=\(ownedByEndingHost) otherWindows=\(otherMainWindowCount) action=\(action)" ) diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 332fb4b6d96a..96bc6be700cc 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -57,22 +57,22 @@ final class RemoteTmuxSessionMirror { onTopologyChanged: { [weak self] in self?.rebuild() }, - onExit: { [weak self] reason in - self?.handleConnectionExited(reason: reason) + onExit: { [weak self] in + self?.handleConnectionExited() } ) rebuild() } - /// The remote session ended on its own (e.g. its last tmux window was killed, - /// or it was killed out-of-band) — hand off to the controller to remove the - /// mirror and close the now-dead workspace. Deliberate detach/quit/window - /// close suppress `onExit`, so this only runs for genuine remote ends. `reason` - /// distinguishes a real tmux `%exit` from a transport drop (network loss). - private func handleConnectionExited(reason: RemoteTmuxControlConnection.SessionEndReason) { + /// The remote session ended for good (its last tmux window was killed, it was + /// killed out-of-band, or a reconnect found it gone) — hand off to the controller + /// to remove the mirror and close the now-dead workspace. A transient transport + /// loss does NOT reach here (the connection reconnects); deliberate detach / quit + /// / window close suppress `onExit`. So this only runs for genuine remote ends. + private func handleConnectionExited() { guard let workspaceId = mirroredWorkspaceId else { return } AppDelegate.shared?.remoteTmuxController.handleSessionEndedRemotely( - host: host, sessionName: sessionName, workspaceId: workspaceId, reason: reason + host: host, sessionName: sessionName, workspaceId: workspaceId ) } diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift index bebae6cdc162..ff2097f29516 100644 --- a/cmuxTests/RemoteTmuxControlParserTests.swift +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -334,6 +334,28 @@ import Testing #expect(Workspace.mirrorTabReorder(current: [a, b, c], requested: [c, b]) == nil) } + // MARK: - Reconnect: session-gone classification + + @Test func stderrSessionGoneIsDetected() { + // A reconnect that reaches the host but finds the session/server gone is a + // genuine end (stop retrying, close). + #expect(RemoteTmuxControlConnection.stderrIndicatesSessionGone("can't find session: work")) + #expect(RemoteTmuxControlConnection.stderrIndicatesSessionGone("no server running on /tmp/tmux-501/default")) + #expect(RemoteTmuxControlConnection.stderrIndicatesSessionGone("lost server")) + #expect(RemoteTmuxControlConnection.stderrIndicatesSessionGone("ERROR: SESSION NOT FOUND")) + } + + @Test func stderrTransientFailureIsNotSessionGone() { + // Network/transport failures must NOT be classified as session-gone — the + // reconnect loop keeps retrying through these. + #expect(!RemoteTmuxControlConnection.stderrIndicatesSessionGone( + "ssh: connect to host example.com port 22: Operation timed out")) + #expect(!RemoteTmuxControlConnection.stderrIndicatesSessionGone( + "ssh: connect to host x port 22: No route to host")) + #expect(!RemoteTmuxControlConnection.stderrIndicatesSessionGone("Connection to host closed.")) + #expect(!RemoteTmuxControlConnection.stderrIndicatesSessionGone("")) + } + // MARK: - Raw layout parser @Test func parsesLeafLayoutWithChecksum() { From d0550c6242ad4546ad6e62b91757c01063f5d977 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Sun, 7 Jun 2026 22:57:02 +0300 Subject: [PATCH 15/73] feat(remote-tmux): cmux ssh-tmux interactive SSH auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `cmux ssh-tmux [--port ] [--identity ] [--no-focus]` CLI command that opens a dedicated cmux window mirroring a remote host's tmux sessions over tmux control mode (`tmux -CC`) via SSH. Behavior: - Discovery-first auth: try session discovery over the shared SSH ControlMaster in BatchMode (no prompt). Key/agent hosts — and scripts on non-tty stdin — mirror directly with no interactive step. A host that needs interactive auth fails BatchMode discovery; the CLI then runs `ssh` inline in the user's terminal so they can authenticate (password, host-key confirmation, MFA, security-key touch), and retries over the now-open master. - The inline auth child is given the controlling terminal's foreground process group (tcsetpgrp + SIGCONT, restored on exit) so ssh's password/confirmation prompt is not stopped by SIGTTIN. - `ssh -f` backgrounds the master after auth so it does not hold the caller's pty open (no ~60s close hang), and the shared ControlMaster is torn down (`ssh -O exit`) when the mirror window closes, its last session ends, or cmux quits — so the connection never lingers. - Per-endpoint keying by `connectionHash` (destination + port + identity) across transports, connections, mirrors, and window bindings, so the same host on a different port/identity never aliases onto another endpoint's connection. - `~/.ssh/config` aliases and their IdentityFile/ProxyJump/Port are honored (no StrictHostKeyChecking pin). Dash-prefixed destinations and identity files, and hidden control characters, are rejected at the trust boundary as defense against SSH option injection. Adds the `remote.tmux.window` socket method (the CLI entry point), the `RemoteTmuxAttachOutcome` result type, the `cli.help.ssh-tmux` help string (en/ja), docs, and a Swift Testing suite covering the auth classifier, argv policy, and connection keying. Co-Authored-By: Claude Opus 4.8 --- CLI/cmux.swift | 221 ++++++++++++++++++ .../Wire/ControlCommandExecutionPolicy.swift | 1 + Resources/Localizable.xcstrings | 17 ++ Sources/RemoteTmuxAttachOutcome.swift | 24 ++ Sources/RemoteTmuxController.swift | 203 ++++++++++------ Sources/RemoteTmuxHost.swift | 93 ++++++-- Sources/RemoteTmuxSSHTransport.swift | 52 ++++- Sources/TerminalController+RemoteTmux.swift | 68 +++++- Sources/TerminalController.swift | 2 + cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/RemoteTmuxAuthTests.swift | 161 +++++++++++++ web/app/[locale]/docs/remote-tmux/page.tsx | 3 + web/messages/en.json | 2 + web/messages/ja.json | 2 + 14 files changed, 767 insertions(+), 90 deletions(-) create mode 100644 Sources/RemoteTmuxAttachOutcome.swift create mode 100644 cmuxTests/RemoteTmuxAuthTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6ff7ca7497d8..0f57f828ac2e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3819,6 +3819,12 @@ struct CMUXCLI { idFormat: idFormat, windowOverride: windowId ) + case "ssh-tmux": + try runRemoteTmux( + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput + ) case "ssh-pty-attach": try runSSHPTYAttach(commandArgs: commandArgs, client: client) case "ssh-session-list": @@ -5115,6 +5121,7 @@ struct CMUXCLI { "ssh-session-cleanup", "ssh-session-end", "ssh-session-list", + "ssh-tmux", "surface", "surface-health", "surface-resume", @@ -7580,6 +7587,193 @@ struct CMUXCLI { ) } + /// `cmux ssh-tmux ` — open a dedicated cmux window mirroring a remote + /// host's tmux sessions over `tmux -CC` (the remote-tmux beta). + /// + /// Unlike `cmux ssh`, this carries no cmuxd-remote/relay bootstrap: it only + /// drives the SSH ControlMaster the mirror multiplexes over. The app's mirror + /// control client uses plain pipes and cannot service interactive auth, so if + /// the host needs a password / host-key confirmation / MFA / FIDO touch, the + /// app returns the `ssh` argv and this CLI runs it **inline in the user's + /// terminal** (which supplies the tty), then retries the mirror over the + /// now-authenticated master. + private func runRemoteTmux( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool + ) throws { + var destination: String? + var port: Int? + var identityFile: String? + var noFocus = false + + // Intentional subset of parseSSHCommandOptions: the mirror verb has a + // different pipeline (no relay/cmuxd bootstrap, no `--` passthrough, no + // --ssh-option/--name/--window), so it parses only the flags it supports + // rather than reusing the heavier SSH-workspace parser. + var index = 0 + while index < commandArgs.count { + let arg = commandArgs[index] + switch arg { + case "--port": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh-tmux: --port requires a value") + } + guard let parsed = Int(commandArgs[index + 1]), parsed > 0, parsed <= 65535 else { + throw CLIError(message: "ssh-tmux: --port must be 1-65535") + } + port = parsed + index += 2 + case "--identity": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh-tmux: --identity requires a path") + } + identityFile = commandArgs[index + 1] + index += 2 + case "--no-focus": + noFocus = true + index += 1 + default: + if arg.hasPrefix("-") { + throw CLIError( + message: "ssh-tmux: destination must be or an ssh alias. Use --port/--identity for SSH flags." + ) + } + if destination == nil { + destination = arg + } else { + throw CLIError(message: "ssh-tmux: unexpected extra argument '\(arg)'") + } + index += 1 + } + } + + guard let destination else { + throw CLIError(message: "ssh-tmux requires a destination (example: cmux ssh-tmux user@host)") + } + + var params: [String: Any] = ["host": destination] + if let port { params["port"] = port } + if let identityFile, !identityFile.isEmpty { params["identity_file"] = identityFile } + if noFocus { params["activate"] = false } + + // The first call runs a non-interactive (BatchMode) discovery in the app, + // which can take a couple of seconds; show progress so it doesn't look idle. + if !jsonOutput { + print("Connecting to \(destination)…") + } + + // The app reports `auth_required` at most once per attempt; run the + // returned interactive ssh, then retry exactly once. `didAuthenticate` + // bounds the loop so a host that keeps reporting auth-required can't spin. + var didAuthenticate = false + while true { + let result = try client.sendV2( + method: "remote.tmux.window", + params: params, + responseTimeout: 75 // > the app-side 60s timeout, so the app's result/error always arrives first + ) + if (result["mirrored"] as? Bool) == true { + if jsonOutput { + print(jsonString(result)) + } else { + let windowId = (result["window_id"] as? String) ?? "" + print("OK host=\(destination) window=\(windowId)") + } + return + } + if (result["auth_required"] as? Bool) == true { + guard !didAuthenticate else { + throw CLIError( + message: "ssh-tmux: authentication did not open the connection to \(destination)" + ) + } + guard let sshArgv = result["ssh_argv"] as? [String], !sshArgv.isEmpty else { + throw CLIError( + message: "ssh-tmux: cmux did not return an ssh command for authentication" + ) + } + try runInteractiveAuthSSH(sshArgv: sshArgv, destination: destination) + didAuthenticate = true + // Retry immediately so the just-opened ControlMaster (ControlPersist) + // is still warm; tell the user we're proceeding. + if !jsonOutput { + print("Authenticated; opening remote tmux mirror for \(destination)…") + } + continue + } + throw CLIError(message: "ssh-tmux: unexpected response from cmux") + } + } + + /// Runs an `ssh` argv interactively in the user's terminal so password / + /// host-key / MFA / FIDO prompts work as in a normal SSH. The spawned ssh is + /// made the terminal's foreground process group (Foundation otherwise spawns it + /// backgrounded, where its tty read would be SIGTTIN-stopped and hang with no + /// prompt). + /// + /// The argv is supplied by the app over the authenticated control socket, but + /// as defense in depth the executable is required to be an `ssh` binary — the + /// CLI never execs an arbitrary command handed back from a socket response. + private func runInteractiveAuthSSH(sshArgv: [String], destination: String) throws { + // Interactive auth needs a controlling tty to prompt on. In a non-tty + // context (script, pipe, URL handler) ssh can't prompt and would hang or + // fail opaquely, so refuse early with an actionable message. + guard isatty(STDIN_FILENO) == 1 else { + throw CLIError( + message: "ssh-tmux: \(destination) needs interactive authentication, which requires a terminal. Run `cmux ssh-tmux \(destination)` directly from an interactive shell." + ) + } + // The app builds this argv with a hardcoded /usr/bin/ssh; require exactly + // that. A basename check would accept a planted /tmp/ssh — pin the full + // path so the CLI never execs an arbitrary command returned over the socket. + let allowedSSHPaths: Set = ["/usr/bin/ssh"] + guard let executable = sshArgv.first, allowedSSHPaths.contains(executable) else { + throw CLIError(message: "ssh-tmux: refusing to run a non-standard ssh path for authentication") + } + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = Array(sshArgv.dropFirst()) + process.standardInput = FileHandle.standardInput + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + // Foundation spawns the child in its OWN process group, so ssh starts as a + // BACKGROUND job of the terminal. ssh's password / host-key / MFA prompt + // reads from the controlling tty, and a background tty read raises SIGTTIN, + // which STOPS ssh — it hangs forever with no prompt (cert/agent hosts never + // read the tty, so they were unaffected). Hand the terminal's foreground + // process group to the child (and SIGCONT it in case it already stopped) so + // it can prompt, exactly as the other interactive-child CLI paths do; the + // `defer` reclaims the foreground for this CLI when ssh exits. + let originalForegroundProcessGroup = tcgetpgrp(STDIN_FILENO) + var didForegroundChild = false + do { + try process.run() + } catch { + throw CLIError(message: "ssh-tmux: failed to launch ssh: \(error.localizedDescription)") + } + if originalForegroundProcessGroup > 0 { + let childProcessGroup = getpgid(process.processIdentifier) + if childProcessGroup > 0 && childProcessGroup != originalForegroundProcessGroup { + try? setTerminalForegroundProcessGroup(childProcessGroup) + _ = Darwin.kill(-childProcessGroup, SIGCONT) + didForegroundChild = true + } + } + defer { + if didForegroundChild { + try? setTerminalForegroundProcessGroup(originalForegroundProcessGroup) + } + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw CLIError( + message: "ssh-tmux: ssh authentication to \(destination) failed (exit \(process.terminationStatus))" + ) + } + } + /// Generic "open a workspace, SSH into the remote, bootstrap cmuxd-remote, forward socket, /// drop the user in a shell" pipeline. The inner loop of `cmux ssh`; also called from /// `cmux vm new`/`shell`/`attach` so cloud VMs reuse the exact same bootstrap. @@ -13560,6 +13754,32 @@ struct CMUXCLI { cmux ssh dev@my-host --forward-agent cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no """) + case "ssh-tmux": + return String(localized: "cli.help.ssh-tmux", defaultValue: """ + Usage: cmux ssh-tmux [--port ] [--identity ] [--no-focus] + + Open a dedicated cmux window that mirrors a remote host's tmux sessions over + tmux control mode (tmux -CC) via SSH: each tmux session becomes a workspace, + each window a tab, and a multi-pane window a native split. Requires the + "Remote tmux" beta to be enabled in Settings. + + If the host needs interactive authentication (password, host-key confirmation, + MFA, or a security-key touch), cmux runs ssh inline in this terminal so you can + authenticate, then mirrors the sessions over the shared SSH connection. Hosts + that authenticate non-interactively (ssh-agent / key in ~/.ssh/config) mirror + with no prompt. ~/.ssh/config aliases and their IdentityFile/ProxyJump/Port + settings are honored. + + Flags: + --port SSH port + --identity SSH identity file path + --no-focus Open the mirror window without activating it + + Example: + cmux ssh-tmux dev@my-host + cmux ssh-tmux my-ssh-alias + cmux ssh-tmux dev@my-host --port 2222 --identity ~/.ssh/id_ed25519 + """) case "ssh-session-list": return """ Usage: cmux ssh-session-list [--workspace | --all-workspaces] @@ -31929,6 +32149,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { list-workspaces [--window ] new-workspace [--name ] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [-A|--forward-agent] [-a|--no-forward-agent] [--ssh-option <opt>] [--window <id|ref|index>] [--no-focus] [-- <remote-command-args>] + ssh-tmux <destination> [--port <n>] [--identity <path>] [--no-focus] ssh-session-list [--workspace <id|ref|index> | --all-workspaces] ssh-session-attach --session-id <id> [--workspace <id|ref|index>] [--pane <id|ref|index> | --split <left|right|up|down>] ssh-session-cleanup [--workspace <id|ref|index> | --all-workspaces] (--session-id <id> | --all) diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index ca9d1cd8452b..da9cc3382fa2 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -69,6 +69,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "remote.tmux.state", "remote.tmux.open", "remote.tmux.mirror", + "remote.tmux.window", "sidebar.custom.validate", "sidebar.custom.reload", "sidebar.custom.select", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 39d7875b4d1c..63d5a794f41a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -12818,6 +12818,23 @@ } } }, + "cli.help.ssh-tmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux ssh-tmux <destination> [--port <n>] [--identity <path>] [--no-focus]\n\nOpen a dedicated cmux window that mirrors a remote host's tmux sessions over\ntmux control mode (tmux -CC) via SSH: each tmux session becomes a workspace,\neach window a tab, and a multi-pane window a native split. Requires the\n\"Remote tmux\" beta to be enabled in Settings.\n\nIf the host needs interactive authentication (password, host-key confirmation,\nMFA, or a security-key touch), cmux runs ssh inline in this terminal so you can\nauthenticate, then mirrors the sessions over the shared SSH connection. Hosts\nthat authenticate non-interactively (ssh-agent / key in ~/.ssh/config) mirror\nwith no prompt. ~/.ssh/config aliases and their IdentityFile/ProxyJump/Port\nsettings are honored.\n\nFlags:\n --port <n> SSH port\n --identity <path> SSH identity file path\n --no-focus Open the mirror window without activating it\n\nExample:\n cmux ssh-tmux dev@my-host\n cmux ssh-tmux my-ssh-alias\n cmux ssh-tmux dev@my-host --port 2222 --identity ~/.ssh/id_ed25519" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux ssh-tmux <destination> [--port <n>] [--identity <path>] [--no-focus]\n\nSSH 経由の tmux コントロールモード(tmux -CC)でリモートホストの tmux セッションをミラーリングする専用の cmux ウィンドウを開きます。各 tmux セッションはワークスペースに、各ウィンドウはタブに、複数ペインのウィンドウはネイティブな分割になります。設定で「Remote tmux」ベータを有効にする必要があります。\n\nホストが対話型認証(パスワード、ホストキーの確認、多要素認証、セキュリティキーのタッチ)を必要とする場合、cmux はこのターミナル内で ssh を実行するため、その場で認証できます。認証後、共有 SSH 接続を介してセッションをミラーリングします。非対話で認証されるホスト(ssh-agent / ~/.ssh/config の鍵)はプロンプトなしでミラーリングされます。~/.ssh/config のエイリアスとその IdentityFile / ProxyJump / Port 設定は尊重されます。\n\nFlags:\n --port <n> SSH ポート\n --identity <path> SSH 識別ファイルのパス\n --no-focus ミラーウィンドウをアクティブにせずに開く\n\nExample:\n cmux ssh-tmux dev@my-host\n cmux ssh-tmux my-ssh-alias\n cmux ssh-tmux dev@my-host --port 2222 --identity ~/.ssh/id_ed25519" + } + } + } + }, "cli.help.ssh": { "extractionState": "manual", "localizations": { diff --git a/Sources/RemoteTmuxAttachOutcome.swift b/Sources/RemoteTmuxAttachOutcome.swift new file mode 100644 index 000000000000..0183b24038a7 --- /dev/null +++ b/Sources/RemoteTmuxAttachOutcome.swift @@ -0,0 +1,24 @@ +import Foundation + +/// The result of attempting to attach (mirror) a remote host's tmux server in a +/// dedicated cmux window. +/// +/// The remote-tmux mirror reaches the host over plain pipes with no controlling +/// tty, so it cannot service interactive SSH authentication itself. When a host +/// needs a password / host-key confirmation / MFA / FIDO touch, the attach can +/// neither succeed nor be retried in place — instead it reports +/// ``authRequired(sshArgv:)`` so the caller (the `cmux ssh-tmux` CLI, which runs in a +/// real terminal) can run that `ssh` invocation **inline in the user's tty** to +/// open the shared ControlMaster, then re-issue the attach (which now multiplexes +/// over the authenticated master and succeeds). +enum RemoteTmuxAttachOutcome: Sendable { + /// The host's sessions were mirrored into the dedicated window with the given + /// cmux window id. + case mirrored(windowId: UUID) + + /// The host needs interactive authentication first. `sshArgv` is the full + /// `ssh` argv (element 0 is the `ssh` binary) that, run under a controlling + /// tty, authenticates and opens the shared ControlMaster — after which the + /// attach should be retried. + case authRequired(sshArgv: [String]) +} diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 038a36fed3ef..6fe6a5a1f394 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -3,7 +3,8 @@ import CmuxSettings /// Coordinates cmux's mirroring of remote tmux servers. /// -/// Owns one ``RemoteTmuxSSHTransport`` per host (keyed by SSH destination) and +/// Owns one ``RemoteTmuxSSHTransport`` per endpoint (keyed by +/// ``RemoteTmuxHost/connectionHash`` — destination + port + identity) and /// is the entry point the socket/CLI layer and (later) the UI call into. It is /// `@MainActor` because it will own sidebar/workspace state as the feature /// grows; today it performs discovery by delegating to the per-host transport @@ -15,8 +16,9 @@ import CmuxSettings final class RemoteTmuxController { private var transports: [String: RemoteTmuxSSHTransport] = [:] - /// Live `tmux -CC` control connections keyed by `destination\u{1}session`, - /// so repeated attach requests reuse the existing connection. + /// Live `tmux -CC` control connections keyed by `connectionHash\u{1}session` + /// (see ``connectionKey(host:sessionName:)``), so repeated attach requests for + /// the same endpoint+session reuse the existing connection. private var connectionsByHostSession: [String: RemoteTmuxControlConnection] = [:] init() {} @@ -33,11 +35,11 @@ final class RemoteTmuxController { /// Returns (creating if needed) the transport for a host. func transport(for host: RemoteTmuxHost) -> RemoteTmuxSSHTransport { - if let existing = transports[host.destination] { + if let existing = transports[host.connectionHash] { return existing } let transport = RemoteTmuxSSHTransport(host: host) - transports[host.destination] = transport + transports[host.connectionHash] = transport return transport } @@ -48,7 +50,7 @@ final class RemoteTmuxController { /// Tears down a host's shared SSH master (used when removing a host). func disconnect(host: RemoteTmuxHost) async { - let transport = transports.removeValue(forKey: host.destination) + let transport = transports.removeValue(forKey: host.connectionHash) await transport?.shutdownMaster() } @@ -62,7 +64,7 @@ final class RemoteTmuxController { sessionName: String, createIfMissing: Bool = false ) throws -> RemoteTmuxControlConnection { - let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + let key = Self.connectionKey(host: host, sessionName: sessionName) if let existing = connectionsByHostSession[key] { if !existing.exited { return existing } // Replace a dead connection — fully tear down the old one first so @@ -86,7 +88,7 @@ final class RemoteTmuxController { // MARK: - Sidebar mirroring (P3, initial increment) - /// Display panels mirroring a remote pane, keyed `dest\u{1}session\u{1}pane`. + /// Display panels mirroring a remote pane, keyed `connectionHash\u{1}session\u{1}pane`. private var displayPanels: [String: TerminalPanel] = [:] /// Observer tokens for the single-pane display path, keyed by connection key. @@ -107,7 +109,7 @@ final class RemoteTmuxController { /// user's keyboard focus, per the socket focus policy. func openActivePane(host: RemoteTmuxHost, sessionName: String, focus: Bool = false) throws { let connection = try attach(host: host, sessionName: sessionName) - let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + let key = Self.connectionKey(host: host, sessionName: sessionName) // Capture the target workspace at command time. Topology often arrives // asynchronously (after the first %layout-change), so resolving the // workspace inside the callback would build the tab in whichever @@ -181,20 +183,24 @@ final class RemoteTmuxController { _ = workspace.updatePanelDirectory(panelId: panel.id, directory: trimmed) } - /// Active session→workspace mirrors keyed `dest\u{1}session`. + /// Active session→workspace mirrors keyed `connectionHash\u{1}session` + /// (see ``connectionKey(host:sessionName:)``). private var sessionMirrors: [String: RemoteTmuxSessionMirror] = [:] - /// SSH destination → the dedicated cmux window mirroring that host (Option 1). + /// ``RemoteTmuxHost/connectionHash`` → the dedicated cmux window mirroring that + /// endpoint (Option 1). private var windowIdByHost: [String: UUID] = [:] - /// Reverse map: cmux window id → the host it mirrors (for window-close detach). - private var hostByWindowId: [UUID: String] = [:] - /// Destinations with an in-flight ``mirrorHostInNewWindow(host:activateWindow:)``, - /// so a re-entrant call across the `await` gap can't open a second window. + /// Reverse map: cmux window id → the full host it mirrors (for window-close + /// detach and new-session-in-window, which need the endpoint's port/identity). + private var hostByWindowId: [UUID: RemoteTmuxHost] = [:] + /// Endpoint ``RemoteTmuxHost/connectionHash`` values with an in-flight + /// ``mirrorHostInNewWindow(host:activateWindow:)``, so a re-entrant call across + /// the `await` gap can't open a second window for the same endpoint. private var pendingHostAttaches: Set<String> = [] /// Returns `true` if `windowId` is a dedicated remote-tmux mirror window. - /// Used by the session-snapshot path to exclude these windows (they are - /// rebuilt by ``restoreMirroredHostsOnLaunch()``, not the generic restore). + /// Used by the session-snapshot path to exclude these windows: a mirror window + /// needs a live SSH connection and can't be restored from a generic snapshot. func isDedicatedRemoteWindow(_ windowId: UUID) -> Bool { hostByWindowId[windowId] != nil } @@ -211,54 +217,82 @@ final class RemoteTmuxController { /// - Parameters: /// - host: the remote SSH destination. /// - activateWindow: when `true` (user-initiated attach), the new window is - /// activated/focused. Restore-on-launch passes `false` so a relaunch - /// doesn't steal focus. - /// - Returns: the cmux window id hosting the mirror. + /// activated/focused. + /// - Returns: ``RemoteTmuxAttachOutcome/mirrored(windowId:)`` once the host's + /// sessions are mirrored into the dedicated (or reused) window, or + /// ``RemoteTmuxAttachOutcome/authRequired(sshArgv:)`` when the host needs + /// interactive authentication — in which case **no window is created** and + /// the caller (the `cmux ssh-tmux` CLI) runs `sshArgv` in the user's terminal to + /// open the shared master, then retries. /// - Throws: ``RemoteTmuxError`` if the host is unreachable or has no tmux /// sessions (no empty dedicated window is created in that case). @discardableResult - func mirrorHostInNewWindow(host: RemoteTmuxHost, activateWindow: Bool = true) async throws -> UUID { + func mirrorHostInNewWindow( + host: RemoteTmuxHost, + activateWindow: Bool = true + ) async throws -> RemoteTmuxAttachOutcome { guard let appDelegate = AppDelegate.shared else { throw RemoteTmuxError.unreachable("app not ready") } // Reuse the dedicated window if this host is already mirrored. - if let existing = windowIdByHost[host.destination], + if let existing = windowIdByHost[host.connectionHash], let window = appDelegate.windowForMainWindowId(existing) { if activateWindow { window.makeKeyAndOrderFront(nil) } - return existing + return .mirrored(windowId: existing) } // Guard the await gap: a second concurrent attach for the same host must // not open a second window. - guard !pendingHostAttaches.contains(host.destination) else { + guard !pendingHostAttaches.contains(host.connectionHash) else { throw RemoteTmuxError.unreachable("already attaching \(host.destination)") } - pendingHostAttaches.insert(host.destination) - defer { pendingHostAttaches.remove(host.destination) } - - var sessions = try await listSessions(host: host) - if sessions.isEmpty { - // A reachable server with zero sessions: create one so the window is - // useful. (An unreachable host throws from listSessions above.) - _ = try? await transport(for: host).runTmux(["new-session", "-d"]) - sessions = try await listSessions(host: host) + pendingHostAttaches.insert(host.connectionHash) + defer { pendingHostAttaches.remove(host.connectionHash) } + + // Discover the host's sessions over the shared ControlMaster (BatchMode, no + // prompt). A key/agent host — or one with an already-live master — succeeds + // here and mirrors directly, with no interactive step, so it also works from + // non-tty callers (scripts). A host that needs interactive auth fails here + // (BatchMode can't prompt); classify that and hand back the interactive + // `ssh` argv so the `cmux ssh-tmux` CLI authenticates in the user's terminal + // and retries — the retry then rides the now-open master. `transport.run()` + // creates the control-socket dir, so the returned auth `ssh` can open the + // master. No window has been created yet — nothing to tear down here. Both + // discovery calls (including the create-then-relist for an empty server) are + // inside the catch so an auth failure on either is classified uniformly. + let sessions: [RemoteTmuxSession] + do { + var discovered = try await listSessions(host: host) + if discovered.isEmpty { + // A reachable server with zero sessions: create one so the window + // is useful. (An unreachable host throws from listSessions.) + _ = try? await transport(for: host).runTmux(["new-session", "-d"]) + discovered = try await listSessions(host: host) + } + sessions = discovered + } catch let error as RemoteTmuxError { + if case .commandFailed(_, let stderr) = error, + RemoteTmuxSSHTransport.indicatesAuthRequired(stderr) { + return .authRequired(sshArgv: host.interactiveAuthInvocation()) + } + throw error } // Never open an empty dedicated window. guard !sessions.isEmpty else { throw RemoteTmuxError.unreachable("no tmux sessions on \(host.destination)") } // Re-check reuse: a concurrent caller may have finished while we awaited. - if let existing = windowIdByHost[host.destination], + if let existing = windowIdByHost[host.connectionHash], let window = appDelegate.windowForMainWindowId(existing) { if activateWindow { window.makeKeyAndOrderFront(nil) } - return existing + return .mirrored(windowId: existing) } let windowId = appDelegate.createMainWindow(shouldActivate: activateWindow) guard let manager = appDelegate.tabManagerFor(windowId: windowId) else { throw RemoteTmuxError.unreachable("could not create window") } - windowIdByHost[host.destination] = windowId - hostByWindowId[windowId] = host.destination + windowIdByHost[host.connectionHash] = windowId + hostByWindowId[windowId] = host let bootstrapWorkspaceId = manager.tabs.first?.id for session in sessions { @@ -278,10 +312,10 @@ final class RemoteTmuxController { !bootstrap.isRemoteTmuxMirror { manager.closeWorkspace(bootstrap, recordHistory: false) } - if sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) { + if sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) { addPersistedMirroredHost(host) } - return windowId + return .mirrored(windowId: windowId) } /// Discovers every tmux session on `host` and mirrors each as its own @@ -306,7 +340,7 @@ final class RemoteTmuxController { // Remember the host for relaunch only once it actually has a live mirror, // so an unreachable / sessionless host isn't persisted forever with no // way to forget it through the UI. - if sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) { + if sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) { addPersistedMirroredHost(host) } } @@ -318,7 +352,7 @@ final class RemoteTmuxController { sessionName: String, into tabManager: TabManager ) throws -> Bool { - let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + let key = Self.connectionKey(host: host, sessionName: sessionName) guard sessionMirrors[key] == nil else { return false } // Attach (and start the ssh process) BEFORE creating the workspace, so a // failed connection doesn't leave an orphaned empty mirror workspace in @@ -373,8 +407,8 @@ final class RemoteTmuxController { mirror.connection.send("rename-session -t \(target) \(RemoteTmuxHost.shellSingleQuoted(name))") // Re-key all per-session state from the old name to the new one so // detach / kill / attach-reuse keep working after the rename. - let oldKey = Self.connectionKey(destination: host.destination, sessionName: oldName) - let newKey = Self.connectionKey(destination: host.destination, sessionName: name) + let oldKey = Self.connectionKey(host: host, sessionName: oldName) + let newKey = Self.connectionKey(host: host, sessionName: name) mirror.setSessionName(name) mirror.connection.setSessionName(name) if oldKey != newKey { @@ -472,8 +506,8 @@ final class RemoteTmuxController { /// The live control connection + tmux pane id behind an ``openActivePane`` /// display-pane surface, or `nil`. The `displayPanels` key is - /// `dest\u{1}session\u{1}pane`, so the first two components form the connection - /// key and the third is the pane id. Shared by ``pasteTarget(forSurfaceId:)`` + /// `connectionHash\u{1}session\u{1}pane`, so the first two components form the + /// connection key and the third is the pane id. Shared by ``pasteTarget(forSurfaceId:)`` /// and ``remoteUploadTarget(forSurfaceId:)`` so the key format lives in one place. private func displayPaneTarget(forSurfaceId surfaceId: UUID) -> (connection: RemoteTmuxControlConnection, paneId: Int)? @@ -544,11 +578,9 @@ final class RemoteTmuxController { /// - Returns: `true` if `windowId` is a dedicated remote window (the caller /// suppresses the local workspace creation); `false` otherwise. func handleRemoteWindowNewWorkspaceRequested(windowId: UUID) -> Bool { - guard let destination = hostByWindowId[windowId] else { return false } - // Recover the full host (port/identity) from an existing mirror so the - // new session reuses the same connection details. - let host = sessionMirrors.values.first(where: { $0.host.destination == destination })?.host - ?? RemoteTmuxHost(destination: destination) + // `hostByWindowId` stores the full host (destination + port + identity), so + // the new session reuses the exact connection details of the window's host. + guard let host = hostByWindowId[windowId] else { return false } guard let manager = AppDelegate.shared?.tabManagerFor(windowId: windowId) else { return true } Task { @MainActor in do { @@ -561,7 +593,7 @@ final class RemoteTmuxController { try self.mirrorSession(host: host, sessionName: name, into: manager) } catch { #if DEBUG - cmuxDebugLog("remote-tmux: new-session on \(destination) failed: \(error)") + cmuxDebugLog("remote-tmux: new-session on \(host.destination) failed: \(error)") #endif } } @@ -619,17 +651,17 @@ final class RemoteTmuxController { sessionName: String, workspaceId: UUID ) { - let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + let key = Self.connectionKey(host: host, sessionName: sessionName) if let mirror = sessionMirrors.removeValue(forKey: key) { mirror.detachObserver() } displayObserverTokens.removeValue(forKey: key) connectionsByHostSession.removeValue(forKey: key)?.stop() - let hostHasOtherMirrors = sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) + let hostHasOtherMirrors = sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) // The dedicated window for this host, captured before the bindings are torn // down. `nil` once other sessions remain — losing one of several sessions // closes only its workspace, never the shared window. - let dedicatedWindowId = hostHasOtherMirrors ? nil : windowIdByHost[host.destination] + let dedicatedWindowId = hostHasOtherMirrors ? nil : windowIdByHost[host.connectionHash] // Decide the UI action BEFORE tearing down persistence/bindings, so the // persistence decision can depend on whether the dedicated window is // actually closing. @@ -652,7 +684,7 @@ final class RemoteTmuxController { // false, but computed generally). let endingHostWorkspaceIds: Set<UUID> = Set( sessionMirrors.values - .filter { $0.host.destination == host.destination } + .filter { $0.host.connectionHash == host.connectionHash } .compactMap { $0.mirroredWorkspaceId } ).union([workspaceId]) let ownedByEndingHost = dedicatedManager?.tabs.allSatisfy { endingHostWorkspaceIds.contains($0.id) } ?? false @@ -667,14 +699,20 @@ final class RemoteTmuxController { // This path runs only for a genuine remote end (a transient transport // loss reconnects instead of reaching here), so forget the host: the // session is truly gone and must not be re-mirrored on the next launch. - // (Quitting cmux detaches via stop(), which suppresses this, so the host - // stays persisted for relaunch in that case.) forgetMirroredHost(host.destination) + // The host's last session is gone, so close its shared SSH ControlMaster + // now. We must do it here rather than rely on the window's onClose hook: + // clearing the binding just below makes handleRemoteWindowClosed a no-op, + // and the dedicated window may be closed programmatically (the + // `.closeDedicatedWindow` path), so the hook can't be the one to tear the + // master down. + transports.removeValue(forKey: host.connectionHash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) // Drop the dedicated-window binding (the window is either closing, or // converting to a plain local window — either way it is no longer a // remote mirror). Done before the switch so the window's onClose hook's // handleRemoteWindowClosed finds the binding gone and is a no-op. - if let windowId = windowIdByHost.removeValue(forKey: host.destination) { + if let windowId = windowIdByHost.removeValue(forKey: host.connectionHash) { hostByWindowId.removeValue(forKey: windowId) } } @@ -735,20 +773,24 @@ final class RemoteTmuxController { /// NOT kill any remote session — closing the window only detaches, leaving /// the remote tmux server alive for resume on the next launch. func handleRemoteWindowClosed(windowId: UUID) { - guard let destination = hostByWindowId[windowId] else { return } + guard let host = hostByWindowId[windowId] else { return } hostByWindowId.removeValue(forKey: windowId) - windowIdByHost.removeValue(forKey: destination) - for (key, mirror) in sessionMirrors where mirror.host.destination == destination { + windowIdByHost.removeValue(forKey: host.connectionHash) + for (key, mirror) in sessionMirrors where mirror.host.connectionHash == host.connectionHash { mirror.detachObserver() displayObserverTokens.removeValue(forKey: key) sessionMirrors.removeValue(forKey: key) } - for (key, connection) in connectionsByHostSession where connection.host.destination == destination { + for (key, connection) in connectionsByHostSession where connection.host.connectionHash == host.connectionHash { connection.stop() connectionsByHostSession.removeValue(forKey: key) } - // The host stays persisted on purpose: it re-mirrors into a fresh - // dedicated window on the next launch. + // Close the shared SSH ControlMaster the CLI's `ssh -f` left running, so + // closing the dedicated window also closes the ssh connection instead of + // leaving it lingering for ControlPersist. Fire-and-forget so it's reliable + // even when this is the last window (→ app quit). + transports.removeValue(forKey: host.connectionHash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } /// Handles user-initiated close of a mirrored session workspace: detaches @@ -765,9 +807,9 @@ final class RemoteTmuxController { detach(host: host, sessionName: sessionName) // If this was the host's last mirrored session, stop auto-re-mirroring it // on the next launch and forget its dedicated-window binding. - if !sessionMirrors.values.contains(where: { $0.host.destination == host.destination }) { + if !sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) { forgetMirroredHost(host.destination) - if let windowId = windowIdByHost.removeValue(forKey: host.destination) { + if let windowId = windowIdByHost.removeValue(forKey: host.connectionHash) { hostByWindowId.removeValue(forKey: windowId) } } @@ -781,27 +823,40 @@ final class RemoteTmuxController { /// Returns the control connection for a host+session, if attached. func connection(host: RemoteTmuxHost, sessionName: String) -> RemoteTmuxControlConnection? { connectionsByHostSession[Self.connectionKey( - destination: host.destination, + host: host, sessionName: sessionName )] } /// Detaches and forgets a control connection (leaves the remote session alive). func detach(host: RemoteTmuxHost, sessionName: String) { - let key = Self.connectionKey(destination: host.destination, sessionName: sessionName) + let key = Self.connectionKey(host: host, sessionName: sessionName) connectionsByHostSession.removeValue(forKey: key)?.stop() } - /// Detaches every control connection (used on app quit so remote sessions - /// survive). Does NOT kill any remote tmux server/session. + /// Detaches every control connection on app quit and closes the shared SSH + /// ControlMasters, so quitting cmux closes the ssh connections it opened (the + /// CLI's `ssh -f` left them persistent). Does NOT kill any remote tmux + /// server/session — only the local control clients and masters. func detachAll() { let connections = Array(connectionsByHostSession.values) connectionsByHostSession.removeAll() for connection in connections { connection.stop() } - } - - private static func connectionKey(destination: String, sessionName: String) -> String { - "\(destination)\u{1}\(sessionName)" + // Fire-and-forget `ssh -O exit` per host: it hits the local control socket + // and runs independently of cmux, so the masters are torn down even as the + // app exits — no lingering ssh after quit. + let hosts = transports.values.map(\.host) + transports.removeAll() + for host in hosts { RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } + } + + /// The dictionary key for a control connection / session mirror, scoped to the + /// full SSH connection identity (``RemoteTmuxHost/connectionHash`` — destination + /// + port + identity), so the same destination reached on a different port or + /// with a different identity file never aliases onto another endpoint's + /// connection. + private static func connectionKey(host: RemoteTmuxHost, sessionName: String) -> String { + "\(host.connectionHash)\u{1}\(sessionName)" } // MARK: - Persistence / reconnect on relaunch (P5) diff --git a/Sources/RemoteTmuxHost.swift b/Sources/RemoteTmuxHost.swift index 4b5d959dff57..9824655a9a8a 100644 --- a/Sources/RemoteTmuxHost.swift +++ b/Sources/RemoteTmuxHost.swift @@ -17,8 +17,12 @@ struct RemoteTmuxHost: Sendable, Equatable, Codable, Identifiable { /// Optional explicit identity file (`-i`). `nil` defers to `~/.ssh/config`. let identityFile: String? - /// Stable identity for UI/persistence: the destination string. - var id: String { destination } + /// Stable identity matching the connection-uniqueness key. Two hosts with the + /// same destination but a different port/identity are distinct endpoints (see + /// ``connectionHash``), so `id` uses ``connectionHash`` rather than the + /// destination alone — keeping ``Identifiable`` identity consistent with how + /// ``RemoteTmuxController`` keys its per-endpoint state. + var id: String { connectionHash } init(destination: String, port: Int? = nil, identityFile: String? = nil) { self.destination = destination @@ -29,7 +33,7 @@ struct RemoteTmuxHost: Sendable, Equatable, Codable, Identifiable { /// A human-readable (but lossy) slug for the destination, used only for /// debuggability in the control socket filename. It lowercases and maps /// every non-alphanumeric character to `-`, so distinct destinations can - /// collapse to the same slug — uniqueness comes from ``destinationHash``, + /// collapse to the same slug — uniqueness comes from ``connectionHash``, /// never from the slug alone. var slug: String { let lowered = destination.lowercased() @@ -40,13 +44,22 @@ struct RemoteTmuxHost: Sendable, Equatable, Codable, Identifiable { return collapsed.isEmpty ? "host" : collapsed } - /// A stable, deterministic, collision-resistant hex digest of the exact, - /// case-sensitive ``destination`` (FNV-1a/64). Two destinations that share a - /// lossy ``slug`` (e.g. `alice@host` vs `alice.host`, or `Host` vs `host`) - /// still get different digests, so they never share a ControlMaster socket. - var destinationHash: String { + /// A stable, deterministic, collision-resistant hex digest of this host's full + /// **connection identity** — the case-sensitive ``destination`` plus the + /// explicit ``port`` and ``identityFile`` — over a unit-separated fingerprint + /// (FNV-1a/64). + /// + /// Two hosts that share a lossy ``slug`` (e.g. `alice@host` vs `alice.host`), + /// *or* the same destination reached on a different port or with a different + /// identity file, get different digests — so they never share a ControlMaster + /// socket. That separation is a safety property, not just hygiene: the master + /// multiplexes destructive commands (`kill-session`, `rename-window`), so two + /// distinct endpoints must never collapse onto one socket and risk routing a + /// command to the wrong server. + var connectionHash: String { + let fingerprint = "\(destination)\u{1f}\(port.map(String.init) ?? "")\u{1f}\(identityFile ?? "")" var hash: UInt64 = 0xcbf2_9ce4_8422_2325 // FNV offset basis - for byte in destination.utf8 { + for byte in fingerprint.utf8 { hash ^= UInt64(byte) hash = hash &* 0x0000_0100_0000_01b3 // FNV prime } @@ -57,13 +70,14 @@ struct RemoteTmuxHost: Sendable, Equatable, Codable, Identifiable { /// /// Kept short (well under the AF_UNIX 104-byte limit) and namespaced under /// `~/.cmux/ssh/`. The filename combines the lossy human-readable ``slug`` - /// with the collision-resistant ``destinationHash`` of the exact - /// destination, so two distinct destinations never collide on one socket - /// (which would otherwise route commands — including the destructive - /// `kill-session` — to the wrong host through a shared master). + /// with the collision-resistant ``connectionHash`` of the exact connection + /// identity (destination + port + identity file), so two distinct endpoints + /// never collide on one socket (which would otherwise route commands — + /// including the destructive `kill-session` — to the wrong host through a + /// shared master). var controlSocketPath: String { let home = FileManager.default.homeDirectoryForCurrentUser.path - return "\(home)/.cmux/ssh/tmux-\(slug)-\(destinationHash).sock" + return "\(home)/.cmux/ssh/tmux-\(slug)-\(connectionHash).sock" } /// Ensures the directory that holds the control socket exists. @@ -78,6 +92,15 @@ struct RemoteTmuxHost: Sendable, Equatable, Codable, Identifiable { /// SSH options that reuse (or open) the shared ControlMaster. /// + /// Deliberately does NOT pin `StrictHostKeyChecking`, so ssh honors the + /// user's `~/.ssh/config` host-key policy. Under `batchMode` an unknown host + /// key therefore fails fast ("Host key verification failed") instead of being + /// silently trusted; ``RemoteTmuxController`` classifies that as needing + /// interactive auth and routes the user to ``interactiveAuthInvocation()`` + /// (which likewise does not pin `StrictHostKeyChecking`, so ssh's default + /// `ask` prompts) to confirm the fingerprint in their terminal — the native + /// SSH first-contact experience. + /// /// - Parameter controlPersistSeconds: how long the master lingers idle /// after the last client detaches, so back-to-back commands stay fast. /// - Parameter batchMode: when `true`, ssh never prompts interactively @@ -104,6 +127,48 @@ struct RemoteTmuxHost: Sendable, Equatable, Codable, Identifiable { return args } + /// Builds the full `ssh` argv (executable first) for a one-shot **interactive** + /// authentication that opens the shared ControlMaster, then exits. + /// + /// Runs `ssh <control opts, no BatchMode> -T -- <destination> true`: it + /// authenticates against the host (password / host-key TOFU / + /// keyboard-interactive MFA / FIDO touch all prompt on the controlling tty), + /// runs the trivial remote `true`, and exits — leaving the master alive for + /// ``controlPersistSeconds`` so the subsequent pipe-based discovery and + /// `tmux -CC` control client multiplex over it with no further prompt. + /// + /// Intended to be run by the `cmux ssh-tmux` CLI **inside the user's terminal** + /// (which supplies the tty); the local control client itself uses plain pipes + /// and cannot prompt. It forces `BatchMode=no` so the interactive prompt always + /// works even when the user's ssh_config sets `BatchMode yes`, but it does NOT + /// pin `StrictHostKeyChecking`: the user's host-key policy is honored (a + /// configured `StrictHostKeyChecking=yes` must not be silently downgraded to a + /// TOFU prompt), and ssh's default `ask` already prompts to confirm a new + /// fingerprint on this controlling tty. + /// + /// `-f` makes ssh go to background **after authentication** (just before the + /// remote `true`): the password / host-key / MFA prompt and any auth error + /// ("Permission denied") still appear on the controlling tty first, and the + /// CLI's foreground ssh exits promptly — but the persistent ControlMaster that + /// `ControlPersist` leaves running then has its standard fds detached, so it no + /// longer holds the terminal's pty open. Without `-f` the backgrounded master + /// keeps the terminal's stdout/stderr, freezing window/app close until the + /// master gives up (~`ServerAliveInterval`×`ServerAliveCountMax` seconds). + /// + /// - Parameter sshExecutablePath: the local `ssh` binary the CLI will exec. + /// - Parameter controlPersistSeconds: idle lifetime of the opened master. + /// - Returns: argv where element 0 is `sshExecutablePath`; the `--` + /// end-of-options guard precedes the destination so a dash-prefixed + /// destination can never be parsed as an ssh option. + func interactiveAuthInvocation( + sshExecutablePath: String = "/usr/bin/ssh", + controlPersistSeconds: Int = 180 + ) -> [String] { + [sshExecutablePath] + + sshControlArguments(controlPersistSeconds: controlPersistSeconds, batchMode: false) + + ["-o", "BatchMode=no", "-f", "-T", "--", destination, "true"] + } + /// Single-quotes a value for safe interpolation into a `/bin/sh` command. static func shellSingleQuoted(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index 68c66a70e55b..e4cb50e66554 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -15,7 +15,10 @@ import Foundation /// serializes process launches; reads/writes are `async`. actor RemoteTmuxSSHTransport { /// The host this transport talks to. - let host: RemoteTmuxHost + /// + /// `nonisolated` so the controller can read it synchronously (it's an immutable + /// `Sendable` value) when tearing down masters on quit/window-close. + nonisolated let host: RemoteTmuxHost private let sshExecutablePath: String private let controlPersistSeconds: Int @@ -86,6 +89,27 @@ actor RemoteTmuxSSHTransport { ) } + /// Fire-and-forget `ssh -O exit` to close the host's shared SSH ControlMaster. + /// + /// `nonisolated` and non-`async` so it can run from the synchronous app-quit / + /// window-close paths where awaiting an actor isn't possible. `-O exit` hits the + /// LOCAL control socket (fast, no network round-trip) and the spawned process + /// runs independently of cmux, so the master is torn down even as the app exits + /// — instead of lingering for `ControlPersist` after the user closes the app or + /// the mirror window. Best-effort: a missing/dead socket just fails fast. + nonisolated static func spawnControlMasterExit( + host: RemoteTmuxHost, + sshExecutablePath: String = "/usr/bin/ssh" + ) { + let process = Process() + process.executableURL = URL(fileURLWithPath: sshExecutablePath) + process.arguments = ["-O", "exit", "-o", "ControlPath=\(host.controlSocketPath)", "--", host.destination] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try? process.run() // fire-and-forget — do not wait + } + // MARK: - Heuristics /// Whether stderr indicates the remote tmux server simply isn't running. @@ -96,6 +120,32 @@ actor RemoteTmuxSSHTransport { || lowered.contains("error connecting to") } + /// Whether a failed non-interactive (`BatchMode=yes`) connect failed because + /// the host needs **interactive** authentication or host-key confirmation + /// that batch mode cannot service — a password, an unknown/changed host key, + /// keyboard-interactive MFA, or a FIDO touch. Used to decide whether to hand + /// the user an interactive `ssh` (run in their terminal by `cmux ssh-tmux`) that + /// opens the shared ControlMaster, versus surfacing a genuine + /// unreachable/transient error. + /// + /// Matches the canonical OpenSSH failure phrases only. "Permission denied" + /// already covers `Permission denied (publickey,keyboard-interactive)`, so + /// the bare "keyboard-interactive" substring is intentionally omitted (it + /// also appears in success-time banners). A *changed* host key ("remote host + /// identification has changed") is included so the interactive terminal + /// renders ssh's actionable message rather than an opaque alert — even though + /// the user must fix `known_hosts` themselves. Algorithm-negotiation failures + /// ("no matching host key type") are deliberately NOT matched: an interactive + /// retry cannot fix them, so they surface as a normal error instead. + static func indicatesAuthRequired(_ stderr: String) -> Bool { + let lowered = stderr.lowercased() + return lowered.contains("permission denied") + || lowered.contains("host key verification failed") + || lowered.contains("remote host identification has changed") + || lowered.contains("authentication failed") + || lowered.contains("too many authentication failures") + } + // MARK: - Process plumbing /// Launches a process and captures stdout/stderr without blocking the actor. diff --git a/Sources/TerminalController+RemoteTmux.swift b/Sources/TerminalController+RemoteTmux.swift index 143732b17c7e..a217de63936f 100644 --- a/Sources/TerminalController+RemoteTmux.swift +++ b/Sources/TerminalController+RemoteTmux.swift @@ -42,12 +42,14 @@ extension TerminalController { guard let destination = (params["host"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines), !destination.isEmpty, - !destination.hasPrefix("-") + !destination.hasPrefix("-"), + !Self.remoteTmuxValueHasHiddenCharacter(destination) else { return nil } let port = params["port"] as? Int let identityFile = (params["identity_file"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) if let identityFile, identityFile.hasPrefix("-") { return nil } + if let identityFile, Self.remoteTmuxValueHasHiddenCharacter(identityFile) { return nil } return RemoteTmuxHost( destination: destination, port: port, @@ -55,6 +57,23 @@ extension TerminalController { ) } + /// Rejects control / format / separator scalars in an SSH destination or + /// identity-file path. These hidden characters never appear in a legitimate + /// `user@host` / alias / key path, and refusing them at the socket boundary + /// blocks attempts to smuggle terminal escapes or obscure the real target — + /// defense in depth alongside the dash-prefix rejection and the argv `--` + /// end-of-options guard. + nonisolated static func remoteTmuxValueHasHiddenCharacter(_ value: String) -> Bool { + value.unicodeScalars.contains { scalar in + switch scalar.properties.generalCategory { + case .control, .format, .lineSeparator, .paragraphSeparator: + return true + default: + return false + } + } + } + /// `remote.tmux.attach` — attach a `tmux -CC` control client to a session. /// /// Params: `host` (required), `session` (required tmux session name), @@ -130,6 +149,53 @@ extension TerminalController { } } + /// `remote.tmux.window` — open a dedicated cmux window mirroring every tmux + /// session on a host (the `cmux ssh-tmux` CLI entry point). + /// + /// Params: `host` (required), optional `port` (Int), optional `identity_file` + /// (String), optional `activate` (Bool, default `true`). + /// + /// Returns `{mirrored: true, window_id}` on success, or + /// `{auth_required: true, ssh_argv: […]}` when the host needs interactive + /// authentication. cmux's control client uses plain pipes and cannot prompt, + /// so the CLI runs `ssh_argv` in the user's terminal (where the tty makes + /// password / host-key / MFA / FIDO prompts work) to open the shared + /// ControlMaster, then re-issues this command — which now succeeds by + /// multiplexing over the authenticated master. + nonisolated func v2RemoteTmuxWindow(id: Any?, params: [String: Any]) -> String { + guard RemoteTmuxController.isEnabled else { + return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + } + guard let host = Self.remoteTmuxHost(from: params) else { + return v2Error(id: id, code: "invalid_params", message: "host is required") + } + let activate = (params["activate"] as? Bool) ?? true + // 60s (the CLI waits longer still) so a slow-but-valid BatchMode probe + // completes instead of the app timing out first and turning an + // auth-required result into an opaque timeout error. + return v2VmCall(id: id, timeoutSeconds: 60) { + guard let controller = await MainActor.run(body: { AppDelegate.shared?.remoteTmuxController }) + else { + throw RemoteTmuxError.unreachable("app not ready") + } + let outcome = try await controller.mirrorHostInNewWindow(host: host, activateWindow: activate) + switch outcome { + case .mirrored(let windowId): + return [ + "host": host.destination, + "mirrored": true, + "window_id": windowId.uuidString, + ] + case .authRequired(let sshArgv): + return [ + "host": host.destination, + "auth_required": true, + "ssh_argv": sshArgv, + ] + } + } + } + /// `remote.tmux.detach` — detach a control client (leaves the remote session alive). nonisolated func v2RemoteTmuxDetach(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8d42324c9d99..326620f132cb 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1043,6 +1043,8 @@ class TerminalController { return v2RemoteTmuxOpen(id: request.id, params: request.params) case "remote.tmux.mirror": return v2RemoteTmuxMirror(id: request.id, params: request.params) + case "remote.tmux.window": + return v2RemoteTmuxWindow(id: request.id, params: request.params) case "sidebar.custom.validate": return v2Result(id: request.id, v2CustomSidebarValidate(params: request.params)) case "sidebar.custom.reload": diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0c6ca27c6c39..de5ae5c5efc5 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -468,6 +468,8 @@ A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */; }; + 0A17C0DE0A17C0DE0A17C002 /* RemoteTmuxAttachOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */; }; + 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */; }; 9C988B800BBFB3CFFD46E611 /* RemoteTmuxCommandResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */; }; 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */; }; F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */; }; @@ -1207,6 +1209,8 @@ D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackRuntimeBridge.swift; sourceTree = "<group>"; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; }; E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteShellSessionParsing.swift; sourceTree = "<group>"; }; + 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxAttachOutcome.swift; sourceTree = "<group>"; }; + 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxAuthTests.swift; sourceTree = "<group>"; }; 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxCommandResult.swift; sourceTree = "<group>"; }; 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlConnection.swift; sourceTree = "<group>"; }; 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxController.swift; sourceTree = "<group>"; }; @@ -2015,6 +2019,7 @@ 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */, 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */, 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */, + 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */, A5001671 /* SessionRestoredTerminalCommandStore.swift */, B35751000000000000000001 /* TmuxResumeParser.swift */, A5001661 /* RestorableAgentSession.swift */, @@ -2227,6 +2232,7 @@ 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */, + 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */, 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */, C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */, FEED49850000000000000002 /* FeedEventClassificationTests.swift */, @@ -2936,6 +2942,7 @@ D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */, + 0A17C0DE0A17C0DE0A17C002 /* RemoteTmuxAttachOutcome.swift in Sources */, 9C988B800BBFB3CFFD46E611 /* RemoteTmuxCommandResult.swift in Sources */, 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */, F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */, @@ -3291,6 +3298,7 @@ F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */, C135190000000000000000A1 /* PreferredEditorSettingsTests.swift in Sources */, C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */, + 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */, B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */, F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift new file mode 100644 index 000000000000..c567562858e7 --- /dev/null +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Behavior tests for the remote-tmux SSH auth path that backs `cmux ssh-tmux`: +/// the stderr → "needs interactive auth" classifier, the ControlMaster host-key +/// policy baked into the standard control args, and the interactive auth `ssh` +/// argv the CLI runs in the user's terminal to open the shared master. These +/// assert produced values and decisions, never source text. +@Suite struct RemoteTmuxAuthTests { + + // MARK: - Auth-required classification + + @Test(arguments: [ + "Permission denied (publickey,password).", + "user@host: Permission denied (publickey,keyboard-interactive).", + "Host key verification failed.", + "@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@", + "Authentication failed.", + "Too many authentication failures", + ]) + func classifiesInteractiveAuthFailures(_ stderr: String) { + #expect(RemoteTmuxSSHTransport.indicatesAuthRequired(stderr)) + } + + @Test(arguments: [ + "no server running on /tmp/tmux-501/default", + "no sessions", + "error connecting to /tmp/tmux-501/default (No such file or directory)", + // Algorithm-negotiation failure: an interactive retry can't fix it, so it + // must NOT route to auth (surfaces as a normal error instead). + "no matching host key type found. their offer: ssh-rsa", + // A success-time banner that merely mentions keyboard-interactive must not + // be mistaken for an auth failure (the bare substring was dropped). + "this server offers password and keyboard-interactive methods", + "", + "some unrelated failure", + ]) + func doesNotClassifyNonAuthFailures(_ stderr: String) { + #expect(!RemoteTmuxSSHTransport.indicatesAuthRequired(stderr)) + } + + @Test func noServerIsNotTreatedAsAuthRequired() { + // A reachable host whose tmux server just isn't running must be treated as + // zero sessions, never as an auth prompt — otherwise attaching would pop an + // interactive ssh instead of offering to create a session. + let stderr = "no server running on /tmp/tmux-501/default" + #expect(RemoteTmuxSSHTransport.indicatesNoServer(stderr)) + #expect(!RemoteTmuxSSHTransport.indicatesAuthRequired(stderr)) + } + + // MARK: - Host-key policy in the standard control args + + @Test func nonInteractiveControlArgsDoNotPinHostKeyPolicy() { + // The mirror's batch path must NOT force StrictHostKeyChecking — it honors + // the user's ~/.ssh/config, and an unknown host key fails BatchMode (which + // routes to interactive auth) rather than being silently trusted. + let host = RemoteTmuxHost(destination: "user@host") + let args = host.sshControlArguments(controlPersistSeconds: 180, batchMode: true) + #expect(!args.contains(where: { $0.hasPrefix("StrictHostKeyChecking=") })) + #expect(consecutive(args, "-o", "BatchMode=yes")) + #expect(consecutive(args, "-o", "ControlPath=\(host.controlSocketPath)")) + } + + @Test func nonBatchControlArgsOmitBatchMode() { + let host = RemoteTmuxHost(destination: "user@host") + let args = host.sshControlArguments(controlPersistSeconds: 180, batchMode: false) + #expect(!args.contains("BatchMode=yes")) + } + + @Test func controlArgsAppendPortAndIdentity() { + let host = RemoteTmuxHost(destination: "user@host", port: 2222, identityFile: "/keys/id") + let args = host.sshControlArguments(controlPersistSeconds: 180, batchMode: true) + #expect(consecutive(args, "-p", "2222")) + #expect(consecutive(args, "-i", "/keys/id")) + } + + @Test func connectionHashVariesByPortAndIdentity() { + // The controller keys transports / connections / windows / persistence by + // connectionHash, so distinct endpoints must produce distinct hashes (and + // the same endpoint a stable one) — otherwise a command could be routed to + // the wrong server through a shared transport/master. + let base = RemoteTmuxHost(destination: "user@host") + #expect(base.connectionHash == RemoteTmuxHost(destination: "user@host").connectionHash) + #expect(base.connectionHash != RemoteTmuxHost(destination: "user@host", port: 2222).connectionHash) + #expect(base.connectionHash != RemoteTmuxHost(destination: "user@host", identityFile: "/keys/id").connectionHash) + #expect( + RemoteTmuxHost(destination: "user@host", port: 2222).connectionHash + != RemoteTmuxHost(destination: "user@host", identityFile: "/keys/id").connectionHash + ) + } + + @Test func controlSocketPathVariesByPortAndIdentity() { + // Distinct endpoints (same destination, different port/identity) must NOT + // share a ControlMaster socket — otherwise a destructive command could + // route to the wrong server through the shared master. + let base = RemoteTmuxHost(destination: "user@host") + let otherPort = RemoteTmuxHost(destination: "user@host", port: 2222) + let otherIdentity = RemoteTmuxHost(destination: "user@host", identityFile: "/keys/id") + #expect(base.controlSocketPath != otherPort.controlSocketPath) + #expect(base.controlSocketPath != otherIdentity.controlSocketPath) + #expect(otherPort.controlSocketPath != otherIdentity.controlSocketPath) + // Deterministic: same identity → same socket path. + #expect(base.controlSocketPath == RemoteTmuxHost(destination: "user@host").controlSocketPath) + } + + // MARK: - Interactive auth invocation (what `cmux ssh-tmux` runs in the tty) + + @Test func interactiveAuthInvocationShape() { + let host = RemoteTmuxHost(destination: "user@host") + let argv = host.interactiveAuthInvocation(sshExecutablePath: "/usr/bin/ssh") + // Executable first, so the CLI can exec argv[0] directly. + #expect(argv.first == "/usr/bin/ssh") + // Force interactive mode so the prompt works even under ssh_config BatchMode yes… + #expect(consecutive(argv, "-o", "BatchMode=no")) + #expect(!argv.contains("BatchMode=yes")) + // …and -f so ssh backgrounds AFTER auth: the persistent ControlMaster then + // detaches its fds and won't freeze the terminal on window/app close. + #expect(argv.contains("-f")) + // …but do NOT pin StrictHostKeyChecking — honor the user's host-key policy. + #expect(!argv.contains(where: { $0.hasPrefix("StrictHostKeyChecking=") })) + // Opens the SAME shared master that discovery / the -CC client multiplex over. + #expect(consecutive(argv, "-o", "ControlPath=\(host.controlSocketPath)")) + // `--` guards the destination; the remote command is the trivial `true`. + #expect(Array(argv.suffix(3)) == ["--", "user@host", "true"]) + } + + @Test func interactiveAuthInvocationGuardsDashPrefixedDestination() { + // A dash-prefixed destination must sit AFTER `--`, never be parsed as an + // ssh option (defense in depth; the dialog/socket also reject it upstream). + let host = RemoteTmuxHost(destination: "-oProxyCommand=evil") + let argv = host.interactiveAuthInvocation() + guard let dashDash = argv.firstIndex(of: "--"), + let dest = argv.firstIndex(of: "-oProxyCommand=evil") else { + Issue.record("expected both `--` and the destination in the argv") + return + } + #expect(dashDash < dest) + } + + @Test func interactiveAuthInvocationIncludesPortAndIdentity() { + let host = RemoteTmuxHost(destination: "user@host", port: 2222, identityFile: "/keys/id") + let argv = host.interactiveAuthInvocation() + #expect(consecutive(argv, "-p", "2222")) + #expect(consecutive(argv, "-i", "/keys/id")) + } + + /// True when `a` is immediately followed by `b` in `args` — i.e. an ssh + /// `-o KEY=VALUE` / `-p N` / `-i path` pair is adjacent, as ssh requires. + private func consecutive(_ args: [String], _ a: String, _ b: String) -> Bool { + for i in args.indices.dropLast() where args[i] == a && args[i + 1] == b { + return true + } + return false + } +} diff --git a/web/app/[locale]/docs/remote-tmux/page.tsx b/web/app/[locale]/docs/remote-tmux/page.tsx index c5a166513793..7c87291ed81f 100644 --- a/web/app/[locale]/docs/remote-tmux/page.tsx +++ b/web/app/[locale]/docs/remote-tmux/page.tsx @@ -50,6 +50,8 @@ export default function RemoteTmuxPage() { <DocsHeading level={2} id="attach">{t("attachTitle")}</DocsHeading> <p>{t("attachIntro")}</p> + <p>{t("attachCli")}</p> + <CodeBlock lang="bash">{`cmux ssh-tmux dev@example.com\ncmux ssh-tmux my-ssh-alias --port 2222 --identity ~/.ssh/id_ed25519`}</CodeBlock> <p>{t("attachSockets")}</p> <DocsHeading level={2} id="how-it-works">{t("howTitle")}</DocsHeading> @@ -81,6 +83,7 @@ export default function RemoteTmuxPage() { <tr><td><code>remote.tmux.attach</code></td><td><code>host</code>, <code>session</code>, <code>create?</code></td><td>{t("methodAttach")}</td></tr> <tr><td><code>remote.tmux.open</code></td><td><code>host</code>, <code>session</code></td><td>{t("methodOpen")}</td></tr> <tr><td><code>remote.tmux.mirror</code></td><td><code>host</code></td><td>{t("methodMirror")}</td></tr> + <tr><td><code>remote.tmux.window</code></td><td><code>host</code>, <code>port?</code>, <code>identity_file?</code></td><td>{t("methodWindow")}</td></tr> <tr><td><code>remote.tmux.detach</code></td><td><code>host</code>, <code>session</code></td><td>{t("methodDetach")}</td></tr> <tr><td><code>remote.tmux.state</code></td><td><code>host</code>, <code>session</code></td><td>{t("methodState")}</td></tr> </tbody> diff --git a/web/messages/en.json b/web/messages/en.json index 98ebac2b5c6d..9e352a26c1a5 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -655,6 +655,7 @@ "attachTitle": "Attaching", "attachIntro": "Run File → Attach Remote tmux… (also in the command palette) and enter an SSH destination — a ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.open mirrors just one session's active pane into the current workspace instead of the whole host.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", "howTitle": "How it works", "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", "behaviorTitle": "What it supports", @@ -674,6 +675,7 @@ "methodAttach": "Attach a control client to a session (create attaches-or-creates).", "methodOpen": "Attach and open the session's active pane as a live tab in the current workspace.", "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", "methodDetach": "Detach the control client; the remote session keeps running.", "methodState": "Report the control client's observed state (diagnostics).", "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", diff --git a/web/messages/ja.json b/web/messages/ja.json index 55eecd5d5098..ae0b8ed790d4 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -611,6 +611,7 @@ "attachTitle": "接続する", "attachIntro": "File → Attach Remote tmux…(コマンドパレットからも実行できます)を実行し、SSH の宛先(~/.ssh/config のエイリアスまたは user@host)を入力します。cmux はそのホストの tmux セッションをミラーリングする新しいウィンドウを開きます。各セッションはワークスペースに、各ウィンドウはタブに、複数ペインのウィンドウはタブ内の分割になります。", "attachSockets": "remote.tmux.* のソケットコマンド(下記)でより細かく制御できます。たとえば remote.tmux.open は、ホスト全体ではなく 1 つのセッションのアクティブなペインだけを現在のワークスペースにミラーリングします。", + "attachCli": "非対話で認証されるホスト(ssh-agent や ~/.ssh/config の鍵)はプロンプトなしで接続されます。対話的な認証(パスワード、ホストキーの確認、多要素認証)が必要なホストでは、cmux がそのターミナル内で ssh を実行するため、その場で認証でき、その後 cmux がミラーウィンドウを開きます。--port と --identity を指定できます。", "howTitle": "仕組み", "howDesc": "cmux は ssh … tmux -CC attach を起動し、組み込みの tmux ビューアーに頼らずコントロールモードのストリームを自前で解析します。そのためプロトコルや %begin/%end のコマンド対応付けは完全に cmux が管理します。各リモートペインは tmux の %output を流し込む専用のターミナルサーフェスに描画され、キー入力やマウスなどの入力は tmux の send-keys でリモートへ送られます。ペインのサイズやリフローはリモートの tmux サーバーが管理し、cmux はそれに追従してローカルではリフローしません。", "behaviorTitle": "サポートしている機能", @@ -630,6 +631,7 @@ "methodAttach": "セッションにコントロールクライアントで接続します(create で「接続または作成」)。", "methodOpen": "接続して、セッションのアクティブなペインを現在のワークスペースにライブのタブとして開きます。", "methodMirror": "ホスト上のすべてのセッションを、それぞれワークスペースとしてミラーリングします(ウィンドウがタブになります)。", + "methodWindow": "ホスト上のすべてのセッションをミラーリングする専用ウィンドウを開きます(cmux ssh-tmux のエントリポイント)。ホストが必要とする場合は、対話的認証のために実行すべき ssh コマンドを返します。", "methodDetach": "コントロールクライアントをデタッチします。リモートセッションは動作し続けます。", "methodState": "コントロールクライアントの観測状態を報告します(診断用)。", "socketSafetyDesc": "ダッシュで始まる host や identity ファイルは、SSH オプションインジェクション対策として境界で拒否されます。", From ff0479839af6aada47fb9828dcaaad584bd5aec6 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Sun, 7 Jun 2026 22:58:18 +0300 Subject: [PATCH 16/73] remote-tmux: don't auto-restore mirrors on launch Previously every host that had been mirrored before quit was reconnected and re-mirrored once per launch, so reopening cmux could spawn several dedicated mirror windows (one per remembered host) unprompted. A mirror needs a live SSH connection; it can't be meaningfully restored from a generic window snapshot, and resurrecting every past host on launch is surprising. Drop the persistence/restore path entirely: remove the `[RemoteTmuxHost]` UserDefaults store (and its legacy destination-only key + migration), the add/forget bookkeeping, and `restoreMirroredHostsOnLaunch()` plus its one-shot launch trigger in `AppDelegate`. Users re-attach explicitly with `cmux ssh-tmux`. Updates the docs limitation note accordingly (en/ja). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/AppDelegate.swift | 16 ++---- Sources/RemoteTmuxController.swift | 87 +----------------------------- web/messages/en.json | 2 +- web/messages/ja.json | 2 +- 4 files changed, 8 insertions(+), 99 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 53f4ee61f8d1..f49a9707a1d4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -673,8 +673,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent nonisolated let socketTransport = SocketTransport() /// Coordinates remote tmux (`ssh … tmux -CC`) mirroring; composition-root owned. let remoteTmuxController = RemoteTmuxController() - /// One-shot guard so remote-tmux hosts are reconnected only once per launch. - private var didTriggerRemoteTmuxRestore = false private static let reloadConfigurationMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.cmux.reloadConfiguration") private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) @@ -1727,12 +1725,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillBecomeActive(_ notification: Notification) { if !hasVisibleMainTerminalWindow() { _ = mainWindowVisibilityController.orderFrontApplicationWindowsBeforeActivation(windows: mainWindowsForVisibilityController(), reason: .applicationWillBecomeActive) } } func applicationDidBecomeActive(_ notification: Notification) { - // One-shot per launch: reconnect to remote tmux hosts that were mirrored - // before quit and re-mirror their still-running sessions (remoteTmux beta). - if !didTriggerRemoteTmuxRestore { - didTriggerRemoteTmuxRestore = true - remoteTmuxController.restoreMirroredHostsOnLaunch() - } let activationWindows = mainWindowsForVisibilityController() if mainWindowVisibilityController.finishPendingApplicationActivationRestore(windows: activationWindows, reason: .applicationDidBecomeActive) == nil, !hasVisibleMainTerminalWindow() { _ = mainWindowVisibilityController.restoreApplicationWindowsAfterActivation(windows: activationWindows, reason: .applicationDidBecomeActive) @@ -4202,10 +4194,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func sortedMainWindowContextsForSessionSnapshot() -> [MainWindowContext] { mainWindowContexts.values - // Exclude dedicated remote-tmux mirror windows: their workspaces are - // non-restorable, so snapshotting one yields an empty window that - // would restore as a leftover, duplicating the window that - // restoreMirroredHostsOnLaunch rebuilds. + // Exclude dedicated remote-tmux mirror windows: a mirror needs a live + // SSH connection, so snapshotting one yields an empty window that would + // restore as a useless leftover. Remote mirrors are not auto-restored on + // launch; the user re-attaches with `cmux ssh-tmux`. .filter { !remoteTmuxController.isDedicatedRemoteWindow($0.windowId) } .sorted { lhs, rhs in let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 6fe6a5a1f394..d814f81c3df7 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -312,9 +312,6 @@ final class RemoteTmuxController { !bootstrap.isRemoteTmuxMirror { manager.closeWorkspace(bootstrap, recordHistory: false) } - if sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) { - addPersistedMirroredHost(host) - } return .mirrored(windowId: windowId) } @@ -337,12 +334,6 @@ final class RemoteTmuxController { #endif } } - // Remember the host for relaunch only once it actually has a live mirror, - // so an unreachable / sessionless host isn't persisted forever with no - // way to forget it through the UI. - if sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) { - addPersistedMirroredHost(host) - } } /// Mirrors a single tmux session into a new workspace in `tabManager` (idempotent). @@ -696,10 +687,6 @@ final class RemoteTmuxController { otherMainWindowCount: otherMainWindowCount ) if !hostHasOtherMirrors { - // This path runs only for a genuine remote end (a transient transport - // loss reconnects instead of reaching here), so forget the host: the - // session is truly gone and must not be re-mirrored on the next launch. - forgetMirroredHost(host.destination) // The host's last session is gone, so close its shared SSH ControlMaster // now. We must do it here rather than rely on the window's onClose hook: // clearing the binding just below makes handleRemoteWindowClosed a no-op, @@ -805,10 +792,9 @@ final class RemoteTmuxController { mirror.detachObserver() displayObserverTokens.removeValue(forKey: entry.key) detach(host: host, sessionName: sessionName) - // If this was the host's last mirrored session, stop auto-re-mirroring it - // on the next launch and forget its dedicated-window binding. + // If this was the host's last mirrored session, drop its dedicated-window + // binding. if !sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) { - forgetMirroredHost(host.destination) if let windowId = windowIdByHost.removeValue(forKey: host.connectionHash) { hostByWindowId.removeValue(forKey: windowId) } @@ -858,73 +844,4 @@ final class RemoteTmuxController { private static func connectionKey(host: RemoteTmuxHost, sessionName: String) -> String { "\(host.connectionHash)\u{1}\(sessionName)" } - - // MARK: - Persistence / reconnect on relaunch (P5) - - /// JSON-encoded `[RemoteTmuxHost]` — preserves port/identityFile across launches. - private static let mirroredHostsDefaultsKey = "remoteTmux.mirroredHostsV2" - /// Legacy key: a plain `[String]` of destinations (destination-only). Read for - /// one-time migration into the V2 store, then removed. - private static let legacyMirroredHostsKey = "remoteTmux.mirroredHosts" - - /// The persisted hosts to re-mirror on launch, decoding the full host - /// (destination + port + identityFile) or migrating the legacy - /// destination-only array. - private func persistedMirroredHosts() -> [RemoteTmuxHost] { - let defaults = UserDefaults.standard - if let data = defaults.data(forKey: Self.mirroredHostsDefaultsKey), - let hosts = try? JSONDecoder().decode([RemoteTmuxHost].self, from: data) { - return hosts - } - if let legacy = defaults.stringArray(forKey: Self.legacyMirroredHostsKey) { - return legacy.map { RemoteTmuxHost(destination: $0) } - } - return [] - } - - private func writePersistedMirroredHosts(_ hosts: [RemoteTmuxHost]) { - let defaults = UserDefaults.standard - // Dedupe by destination, keep a stable order. - var seen = Set<String>() - let unique = hosts - .filter { seen.insert($0.destination).inserted } - .sorted { $0.destination < $1.destination } - if let data = try? JSONEncoder().encode(unique) { - defaults.set(data, forKey: Self.mirroredHostsDefaultsKey) - } - // The legacy store has been folded into V2; drop it so it can't shadow. - defaults.removeObject(forKey: Self.legacyMirroredHostsKey) - } - - /// Remembers a host (full connection details) for re-mirroring on relaunch, - /// updating in place if its port/identity changed. - private func addPersistedMirroredHost(_ host: RemoteTmuxHost) { - var hosts = persistedMirroredHosts().filter { $0.destination != host.destination } - hosts.append(host) - writePersistedMirroredHosts(hosts) - } - - /// Stops remembering a host so it is not re-mirrored on the next launch. - func forgetMirroredHost(_ destination: String) { - let hosts = persistedMirroredHosts().filter { $0.destination != destination } - writePersistedMirroredHosts(hosts) - } - - /// Reconnects to every persisted mirrored host and re-mirrors its - /// still-running sessions. Quitting cmux only detaches (the remote tmux - /// server stays alive), so this restores the sidebar after relaunch. Called - /// once per launch from the app delegate. - func restoreMirroredHostsOnLaunch() { - guard Self.isEnabled else { return } - let hosts = persistedMirroredHosts() - guard !hosts.isEmpty else { return } - Task { @MainActor in - for host in hosts { - // Restore each host into its own dedicated window (Option 1), so - // the relaunched sidebar matches how the user attached it. Don't - // activate — a relaunch must not steal focus. - _ = try? await self.mirrorHostInNewWindow(host: host, activateWindow: false) - } - } - } } diff --git a/web/messages/en.json b/web/messages/en.json index 9e352a26c1a5..d35572d3ba60 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -680,7 +680,7 @@ "methodState": "Report the control client's observed state (diagnostics).", "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", "limitationsTitle": "Limitations", - "limitReconnect": "No live reconnect: if the SSH connection drops mid-session, the mirror closes and you re-attach. Mirrored hosts are restored on app relaunch, but a mid-session drop isn't retried with backoff the way cmux ssh is.", + "limitReconnect": "No live reconnect: if the SSH connection drops mid-session — or you close the mirror window or quit cmux — the mirror closes and you re-attach with cmux ssh-tmux. Mirrors aren't restored on relaunch, and a mid-session drop isn't retried with backoff the way cmux ssh is.", "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." diff --git a/web/messages/ja.json b/web/messages/ja.json index ae0b8ed790d4..2121c0224b5e 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -636,7 +636,7 @@ "methodState": "コントロールクライアントの観測状態を報告します(診断用)。", "socketSafetyDesc": "ダッシュで始まる host や identity ファイルは、SSH オプションインジェクション対策として境界で拒否されます。", "limitationsTitle": "制限事項", - "limitReconnect": "ライブ再接続はありません: セッション中に SSH 接続が切れるとミラーは閉じ、再接続し直す必要があります。ミラーしたホストはアプリ再起動時に復元されますが、cmux ssh のようにバックオフ付きで自動再試行はしません。", + "limitReconnect": "ライブ再接続はありません: セッション中に SSH 接続が切れた場合、またはミラーウィンドウを閉じたり cmux を終了した場合、ミラーは閉じ、cmux ssh-tmux で再接続し直します。ミラーは再起動時に復元されず、cmux ssh のようにバックオフ付きで自動再試行もしません。", "limitPaste": "ブラケットペーストとして paste-buffer -p で渡されるのは単一行の貼り付け・ドロップ(ファイルや画像のパスなど)のみです。複数行のテキストは通常のキー入力として送られるため、リモートアプリは 1 回の貼り付けとして扱いません。", "limitCwd": "作業ディレクトリのライブ更新はコントロールモードのサブスクリプション(tmux 3.2 以降)を使用します。それより古い tmux では初期フォルダーは表示されますが、cd しても更新されません。", "limitReflow": "プライマリスクリーンのスクロールバックはリサイズ時に折り返し直されません。cmux はリフローを tmux に任せるため、古い行はアプリが再描画するまで以前の幅のままになることがあります。リサイズ時に再描画するフルスクリーンの TUI は影響を受けません。" From 422e1d3c695c6cb520b36833aaf4e6836260ed0a Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Sun, 7 Jun 2026 22:59:02 +0300 Subject: [PATCH 17/73] remote-tmux: remove File-menu/palette attach (CLI-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `cmux ssh-tmux` as the attach entry point, the "Attach Remote tmux…" File-menu item and command-palette command (and their shared `promptAttachRemoteTmuxHost` NSAlert flow in AppDelegate+RemoteTmux.swift) are redundant. Remove all three surfaces, unwire the deleted file from the Xcode project, and drop the now-unused menu/palette/alert localization keys (en/ja). Updates the docs attach intro to point at the CLI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Resources/Localizable.xcstrings | 153 --------------------------- Sources/AppDelegate+RemoteTmux.swift | 86 --------------- Sources/ContentView.swift | 16 --- Sources/cmuxApp.swift | 14 --- cmux.xcodeproj/project.pbxproj | 4 - web/messages/en.json | 2 +- web/messages/ja.json | 2 +- 7 files changed, 2 insertions(+), 275 deletions(-) delete mode 100644 Sources/AppDelegate+RemoteTmux.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 63d5a794f41a..7148995edbf0 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -102962,159 +102962,6 @@ } } }, - "menu.file.attachRemoteTmux": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Attach Remote tmux…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リモート tmux に接続…" - } - } - } - }, - "command.attachRemoteTmux.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Attach Remote tmux…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リモート tmux に接続…" - } - } - } - }, - "command.attachRemoteTmux.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open a new window mirroring a remote server's tmux sessions" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リモートサーバーの tmux セッションをミラーリングする新しいウィンドウを開きます" - } - } - } - }, - "remoteTmux.attach.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Attach Remote tmux" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リモート tmux に接続" - } - } - } - }, - "remoteTmux.attach.message": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enter an SSH destination (a ~/.ssh/config alias or user@host). cmux opens a new window mirroring that server's tmux sessions." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "SSH 接続先(~/.ssh/config のエイリアスまたは user@host)を入力してください。cmux はそのサーバーの tmux セッションをミラーリングする新しいウィンドウを開きます。" - } - } - } - }, - "remoteTmux.attach.placeholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "user@host or ssh alias" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "user@host または ssh エイリアス" - } - } - } - }, - "remoteTmux.attach.confirm": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Attach" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続" - } - } - } - }, - "remoteTmux.attach.invalidDestination": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "An SSH destination cannot start with “-”." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "SSH 接続先は「-」で始めることはできません。" - } - } - } - }, - "remoteTmux.attach.failed.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Couldn’t attach to remote tmux" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リモート tmux に接続できませんでした" - } - } - } - }, "settings.betaFeatures.dock": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+RemoteTmux.swift b/Sources/AppDelegate+RemoteTmux.swift deleted file mode 100644 index 946d615b6a6d..000000000000 --- a/Sources/AppDelegate+RemoteTmux.swift +++ /dev/null @@ -1,86 +0,0 @@ -import AppKit - -/// User-facing entry points for attaching a remote tmux server (the beta -/// `remoteTmux` feature). The command palette and the menu bar item both funnel -/// through ``promptAttachRemoteTmuxHost(preferredWindow:)`` so the prompt and -/// attach flow live in one place. -extension AppDelegate { - /// Prompts for an SSH destination and, on confirm, opens a new cmux window - /// mirroring that server's tmux sessions 1:1 (see - /// ``RemoteTmuxController/mirrorHostInNewWindow(host:)``). - /// - /// No-ops (with a beep) when the `remoteTmux` beta flag is off. - @MainActor - func promptAttachRemoteTmuxHost(preferredWindow: NSWindow? = nil) { - guard RemoteTmuxController.isEnabled else { - NSSound.beep() - return - } - - let alert = NSAlert() - alert.messageText = String( - localized: "remoteTmux.attach.title", - defaultValue: "Attach Remote tmux" - ) - alert.informativeText = String( - localized: "remoteTmux.attach.message", - defaultValue: "Enter an SSH destination (a ~/.ssh/config alias or user@host). cmux opens a new window mirroring that server's tmux sessions." - ) - let input = NSTextField(string: "") - input.placeholderString = String( - localized: "remoteTmux.attach.placeholder", - defaultValue: "user@host or ssh alias" - ) - input.frame = NSRect(x: 0, y: 0, width: 280, height: 22) - alert.accessoryView = input - alert.window.initialFirstResponder = input - alert.addButton(withTitle: String( - localized: "remoteTmux.attach.confirm", - defaultValue: "Attach" - )) - alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) - - guard alert.runModal() == .alertFirstButtonReturn else { return } - - let destination = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !destination.isEmpty else { return } - // Reject a dash-prefixed destination — it is never a valid SSH - // alias/user@host and refusing it guards against ssh option injection. - guard !destination.hasPrefix("-") else { - presentRemoteTmuxAttachError( - destination: destination, - detail: String( - localized: "remoteTmux.attach.invalidDestination", - defaultValue: "An SSH destination cannot start with “-”." - ) - ) - return - } - - let host = RemoteTmuxHost(destination: destination) - Task { @MainActor in - do { - _ = try await self.remoteTmuxController.mirrorHostInNewWindow(host: host) - } catch { - self.presentRemoteTmuxAttachError( - destination: destination, - detail: String(describing: error) - ) - } - } - } - - /// Shows a warning alert when attaching to a remote tmux server fails. - @MainActor - private func presentRemoteTmuxAttachError(destination: String, detail: String) { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String( - localized: "remoteTmux.attach.failed.title", - defaultValue: "Couldn’t attach to remote tmux" - ) - alert.informativeText = "\(destination): \(detail)" - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.runModal() - } -} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2739a7e73464..e1a108392ff2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7258,17 +7258,6 @@ struct ContentView: View { panelSubtitle: panelSubtitle ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.attachRemoteTmux", - title: constant(String(localized: "command.attachRemoteTmux.title", defaultValue: "Attach Remote tmux…")), - subtitle: constant(String(localized: "command.attachRemoteTmux.subtitle", defaultValue: "Open a new window mirroring a remote server's tmux sessions")), - keywords: ["remote", "tmux", "ssh", "attach", "mirror", "session"], - dismissOnRun: true, - when: { _ in RemoteTmuxController.isEnabled } - ) - ) - contributions.append( CommandPaletteCommandContribution( commandId: "palette.renameTab", @@ -8238,11 +8227,6 @@ struct ContentView: View { } registerIdentifierCopyCommandHandlers(®istry) - registry.register(commandId: "palette.attachRemoteTmux") { - AppDelegate.shared?.promptAttachRemoteTmuxHost( - preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow - ) - } registry.register(commandId: "palette.renameTab") { beginRenameTabFlow() } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index dabbeb9681d6..27cd0cfb5812 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -712,20 +712,6 @@ struct cmuxApp: App { AppDelegate.shared?.showOpenFolderInInlineVSCodePanel() } .disabled(!TerminalDirectoryOpenTarget.vscodeInline.isAvailable()) - - if RemoteTmuxController.isEnabled { - Divider() - Button( - String( - localized: "menu.file.attachRemoteTmux", - defaultValue: "Attach Remote tmux…" - ) - ) { - AppDelegate.shared?.promptAttachRemoteTmuxHost( - preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow - ) - } - } } // Close tab/workspace diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index de5ae5c5efc5..80dc9b93fa7f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -52,7 +52,6 @@ 3865A0063865A0063865A006 /* AppDelegate+GlobalSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0063865B0063865B006 /* AppDelegate+GlobalSearch.swift */; }; D7AB00000000000000000001 /* AppDelegate+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */; }; 2907A0012907A0012907A001 /* AppDelegate+RecoverableMainWindowRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */; }; - 120313FA0CB27B88C13D50F7 /* AppDelegate+RemoteTmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB6146C0EDA948444A60842 /* AppDelegate+RemoteTmux.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; 725746692D9647948561044D /* AppDelegateBareSpaceShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */; }; E3309A09 /* AppDelegateEqualizeSplitsShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */; }; @@ -833,7 +832,6 @@ 3865B0063865B0063865B006 /* AppDelegate+GlobalSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Search/AppDelegate+GlobalSearch.swift"; sourceTree = "<group>"; }; D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; }; 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+RecoverableMainWindowRoutes.swift"; sourceTree = "<group>"; }; - 7AB6146C0EDA948444A60842 /* AppDelegate+RemoteTmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+RemoteTmux.swift"; sourceTree = "<group>"; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateBareSpaceShortcutRoutingTests.swift; sourceTree = "<group>"; }; E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateEqualizeSplitsShortcutTests.swift; sourceTree = "<group>"; }; @@ -2002,7 +2000,6 @@ A5001611 /* SessionPersistence.swift */, E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */, FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */, - 7AB6146C0EDA948444A60842 /* AppDelegate+RemoteTmux.swift */, D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */, 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */, 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */, @@ -2721,7 +2718,6 @@ 3865A0063865A0063865A006 /* AppDelegate+GlobalSearch.swift in Sources */, D7AB00000000000000000001 /* AppDelegate+MoveTabToNewWorkspace.swift in Sources */, 2907A0012907A0012907A001 /* AppDelegate+RecoverableMainWindowRoutes.swift in Sources */, - 120313FA0CB27B88C13D50F7 /* AppDelegate+RemoteTmux.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A11EAB000000000000000000 /* AppearanceSettings.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, diff --git a/web/messages/en.json b/web/messages/en.json index d35572d3ba60..5525fd28b9d2 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -653,7 +653,7 @@ "enableTitle": "Enable it", "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", "attachTitle": "Attaching", - "attachIntro": "Run File → Attach Remote tmux… (also in the command palette) and enter an SSH destination — a ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.open mirrors just one session's active pane into the current workspace instead of the whole host.", "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", "howTitle": "How it works", diff --git a/web/messages/ja.json b/web/messages/ja.json index 2121c0224b5e..f64c63151a7d 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -609,7 +609,7 @@ "enableTitle": "有効にする", "enableDesc": "設定 → ベータ機能 を開き、「リモート tmux」をオンにします。デフォルトはオフなので、オプトインするまでローカルのターミナルには何も影響しません。", "attachTitle": "接続する", - "attachIntro": "File → Attach Remote tmux…(コマンドパレットからも実行できます)を実行し、SSH の宛先(~/.ssh/config のエイリアスまたは user@host)を入力します。cmux はそのホストの tmux セッションをミラーリングする新しいウィンドウを開きます。各セッションはワークスペースに、各ウィンドウはタブに、複数ペインのウィンドウはタブ内の分割になります。", + "attachIntro": "ターミナルで cmux ssh-tmux <宛先>(~/.ssh/config のエイリアスまたは user@host)を実行します。cmux はそのホストの tmux セッションをミラーリングする新しいウィンドウを開きます。各セッションはワークスペースに、各ウィンドウはタブに、複数ペインのウィンドウはタブ内の分割になります。", "attachSockets": "remote.tmux.* のソケットコマンド(下記)でより細かく制御できます。たとえば remote.tmux.open は、ホスト全体ではなく 1 つのセッションのアクティブなペインだけを現在のワークスペースにミラーリングします。", "attachCli": "非対話で認証されるホスト(ssh-agent や ~/.ssh/config の鍵)はプロンプトなしで接続されます。対話的な認証(パスワード、ホストキーの確認、多要素認証)が必要なホストでは、cmux がそのターミナル内で ssh を実行するため、その場で認証でき、その後 cmux がミラーウィンドウを開きます。--port と --identity を指定できます。", "howTitle": "仕組み", From 77b09ca65141187744fe26208876dfe05d133d9b Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Sun, 7 Jun 2026 23:31:31 +0300 Subject: [PATCH 18/73] fix(remote-tmux): close ControlMaster when user closes a host's last mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handleSessionWorkspaceClosedByUser` tore down the session's mirror, connection, and dedicated-window binding when the user closed a host's last mirrored workspace, but never closed the shared SSH ControlMaster — unlike the remote-end (`handleSessionEndedRemotely`) and window-close (`handleRemoteWindowClosed`) paths. Because the window binding is cleared first, the window's onClose `handleRemoteWindowClosed` is a no-op, so the master lingered for the full ControlPersist window (~180s) and the stale `transports` entry could surface an opaque failure on a later re-attach. Tear down the transport + master in this path too, sequenced after the `kill-session` command (which still needs the master alive), and drop the transport entry so a re-attach builds a fresh one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/RemoteTmuxController.swift | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index d814f81c3df7..9289000b6806 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -793,8 +793,15 @@ final class RemoteTmuxController { displayObserverTokens.removeValue(forKey: entry.key) detach(host: host, sessionName: sessionName) // If this was the host's last mirrored session, drop its dedicated-window - // binding. - if !sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) { + // binding and tear down the shared SSH ControlMaster. The remote-end path + // (handleSessionEndedRemotely) and the window-close path + // (handleRemoteWindowClosed) both close the master when a host loses its last + // mirror; a user-initiated workspace close must do the same or the master + // lingers for the full ControlPersist window. Clearing the window binding here + // makes the window's onClose handleRemoteWindowClosed a no-op, so this path has + // to own the teardown. + let isLastSession = !sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) + if isLastSession { if let windowId = windowIdByHost.removeValue(forKey: host.connectionHash) { hostByWindowId.removeValue(forKey: windowId) } @@ -803,7 +810,19 @@ final class RemoteTmuxController { // can't leave us targeting a stale name. let killTarget = mirror.connection.sessionId.map { "$\($0)" } ?? sessionName let transport = transport(for: host) - Task { _ = try? await transport.runTmux(["kill-session", "-t", killTarget]) } + if isLastSession { + // Drop the transport so a later re-attach builds a fresh one instead of + // reusing this soon-to-be-dead master. + transports.removeValue(forKey: host.connectionHash) + } + Task { + _ = try? await transport.runTmux(["kill-session", "-t", killTarget]) + // Close the master only after kill-session has used it; `ssh -O exit` + // first would tear the connection down before the session dies. + if isLastSession { + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + } + } } /// Returns the control connection for a host+session, if attached. From fc7e16a2ba5b1d695cc1398f9a315815e53e719d Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Sun, 7 Jun 2026 23:31:32 +0300 Subject: [PATCH 19/73] remote-tmux: register ssh terminationHandler before launch `RemoteTmuxSSHTransport.runProcess` installed the `Process` `terminationHandler` after `process.run()`. Foundation does not invoke a `terminationHandler` assigned after the process has already terminated, so an ssh that exits in the window between `run()` and the handler assignment would never resume the continuation and the caller would hang until its timeout. That is most likely on the fast auth-failure exits the `cmux ssh-tmux` discovery probe relies on to classify `auth_required`. Install the handler first and launch inside the continuation, resuming with `launchFailed` if `run()` itself throws. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/RemoteTmuxSSHTransport.swift | 30 ++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index e4cb50e66554..806bf03d0f34 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -174,18 +174,32 @@ actor RemoteTmuxSSHTransport { let outRead = Task.detached { Self.drain(fd: outFD) } let errRead = Task.detached { Self.drain(fd: errFD) } + // Install the termination handler BEFORE launching, then launch inside the + // continuation. If `run()` and the handler assignment were separate steps, a + // process that exits in the window between them would terminate before the + // handler is installed — and Foundation does not invoke a terminationHandler + // assigned after the process has already ended, so the continuation would + // never resume and the caller would hang until its timeout. This matters for + // the fast auth-failure exits the `cmux ssh-tmux` flow classifies. + let exitCode: Int32 do { - try process.run() + exitCode = try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { proc in + continuation.resume(returning: proc.terminationStatus) + } + do { + try process.run() + } catch { + // The process never started, so the handler will not fire; resume + // exactly once here with the launch failure. + process.terminationHandler = nil + continuation.resume(throwing: RemoteTmuxError.launchFailed(error.localizedDescription)) + } + } } catch { outRead.cancel() errRead.cancel() - throw RemoteTmuxError.launchFailed(error.localizedDescription) - } - - let exitCode: Int32 = await withCheckedContinuation { continuation in - process.terminationHandler = { proc in - continuation.resume(returning: proc.terminationStatus) - } + throw error } let outData = await outRead.value From dc2ef4a12c96fc60cdd5582f4533a3288f76676b Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Sun, 7 Jun 2026 23:45:28 +0300 Subject: [PATCH 20/73] remote-tmux: cancel-safe window creation + master teardown on mirror close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lifecycle gaps found in review: - `mirrorHostInNewWindow` runs under the `remote.tmux.window` 60s `v2VmCall` timeout, which cancels the task on expiry — but the SSH discovery awaits are not cancellation-aware and nothing checked the cancellation flag, so a slow-but-successful probe could land past the timeout and open an orphaned dedicated window after the caller already received a timeout error. Add a `Task.checkCancellation()` before `createMainWindow` so an abandoned attach creates no window. - `handleWindowWorkspacesClosed` (the non-dedicated `remote.tmux.mirror` close path) detached mirrors and stopped connections but never tore down the shared SSH ControlMaster, unlike the dedicated-window, last-session, and remote-end paths. Track the affected hosts and, for any host left with no live mirror or connection, drop the transport and `spawnControlMasterExit` so the master doesn't linger for ControlPersist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/RemoteTmuxController.swift | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 9289000b6806..a095d8a5db1a 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -287,6 +287,13 @@ final class RemoteTmuxController { return .mirrored(windowId: existing) } + // Bail before creating a window the caller has abandoned. The socket handler + // runs this under a v2VmCall timeout that cancels the task on expiry, but the + // SSH discovery awaits above are not cancellation-aware — a slow-but-successful + // probe could otherwise land here after the caller already received a timeout + // and open an orphaned dedicated window (with live SSH/tmux behind it). + try Task.checkCancellation() + let windowId = appDelegate.createMainWindow(shouldActivate: activateWindow) guard let manager = appDelegate.tabManagerFor(windowId: windowId) else { throw RemoteTmuxError.unreachable("could not create window") @@ -746,13 +753,27 @@ final class RemoteTmuxController { func handleWindowWorkspacesClosed(workspaceIds: [UUID]) { guard !workspaceIds.isEmpty else { return } let ids = Set(workspaceIds) + var affectedHosts: [String: RemoteTmuxHost] = [:] for (key, mirror) in sessionMirrors { guard let workspaceId = mirror.mirroredWorkspaceId, ids.contains(workspaceId) else { continue } + affectedHosts[mirror.host.connectionHash] = mirror.host mirror.detachObserver() displayObserverTokens.removeValue(forKey: key) sessionMirrors.removeValue(forKey: key) connectionsByHostSession.removeValue(forKey: key)?.stop() } + // For any host left with no live mirror or connection, close its shared SSH + // ControlMaster now — the dedicated-window/last-session paths already do this, + // and a non-dedicated `remote.tmux.mirror` window must too or the master + // lingers for the full ControlPersist window. + for (hash, host) in affectedHosts { + let stillUsed = sessionMirrors.values.contains { $0.host.connectionHash == hash } + || connectionsByHostSession.values.contains { $0.host.connectionHash == hash } + if !stillUsed { + transports.removeValue(forKey: hash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + } + } } /// Handles close of a dedicated remote window (Option 1): detaches every From 1c55a698fa56c1c71f0232d0c648449147640b82 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Sun, 7 Jun 2026 23:58:08 +0300 Subject: [PATCH 21/73] remote-tmux: guard async master teardown against reattach + exit connection-only masters on quit Two more lifecycle gaps found in review: - `handleWorkspaceClosed` removes the transport synchronously, then closes the ControlMaster from inside the async kill-session `Task`. A `cmux ssh-tmux` reattach to the same host during that round-trip builds a fresh transport/connection on the same ControlPath, so the stale `ssh -O exit` would drop the new mirror. Before exiting, re-check that no transport, mirror, or connection has reclaimed the endpoint (the Task is @MainActor, so the check + exit is atomic against a reattach). - `detachAll` exited masters only for hosts in `transports`, but the `remote.tmux.attach`/`open` paths open a ControlPersist master through the control connection without ever creating a transport, so those masters survived quit for the ControlPersist window. Collect endpoints from both `connectionsByHostSession` and `transports`, deduped by connectionHash. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/RemoteTmuxController.swift | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index a095d8a5db1a..7e4f637052f0 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -841,7 +841,17 @@ final class RemoteTmuxController { // Close the master only after kill-session has used it; `ssh -O exit` // first would tear the connection down before the session dies. if isLastSession { - RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + // …and only if no reattach reclaimed this endpoint during the + // kill-session round-trip: a concurrent `cmux ssh-tmux` to the same + // host builds a fresh transport/connection on the same ControlPath, + // and exiting the master here would drop that new mirror. (This Task + // is @MainActor, so the check + exit is atomic w.r.t. a reattach.) + let reclaimed = transports[host.connectionHash] != nil + || sessionMirrors.values.contains { $0.host.connectionHash == host.connectionHash } + || connectionsByHostSession.values.contains { $0.host.connectionHash == host.connectionHash } + if !reclaimed { + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + } } } } @@ -868,12 +878,17 @@ final class RemoteTmuxController { let connections = Array(connectionsByHostSession.values) connectionsByHostSession.removeAll() for connection in connections { connection.stop() } - // Fire-and-forget `ssh -O exit` per host: it hits the local control socket - // and runs independently of cmux, so the masters are torn down even as the - // app exits — no lingering ssh after quit. - let hosts = transports.values.map(\.host) + // Fire-and-forget `ssh -O exit` per endpoint: it hits the local control + // socket and runs independently of cmux, so the masters are torn down even as + // the app exits — no lingering ssh after quit. Collect endpoints from BOTH + // transports AND control connections (the remote.tmux.attach/open paths open a + // ControlPersist master via the connection without ever creating a transport), + // deduped by connectionHash. + var hostsByHash: [String: RemoteTmuxHost] = [:] + for connection in connections { hostsByHash[connection.host.connectionHash] = connection.host } + for transport in transports.values { hostsByHash[transport.host.connectionHash] = transport.host } transports.removeAll() - for host in hosts { RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } + for host in hostsByHash.values { RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } } /// The dictionary key for a control connection / session mirror, scoped to the From 2d7bdf112a42309871b8a1a30ed1acde550a69c3 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 00:10:07 +0300 Subject: [PATCH 22/73] fix(remote-tmux): fail loudly if the ssh foreground handoff fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runInteractiveAuthSSH` swallowed a `tcsetpgrp` failure with `try?` yet still set `didForegroundChild = true`. If the handoff failed, ssh stayed a background job of the tty, its password/host-key/MFA prompt SIGTTIN-stopped it, and `waitUntilExit()` blocked forever — the exact hang the foreground-process-group dance exists to prevent. Treat the handoff as required: on failure, SIGCONT the child (in case it already stopped), terminate it, and throw an actionable error instead of proceeding into a hang. `didForegroundChild` is now set only after a successful handoff (matching the sibling interactive path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- CLI/cmux.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0f57f828ac2e..f1d3da0b62b2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7756,7 +7756,19 @@ struct CMUXCLI { if originalForegroundProcessGroup > 0 { let childProcessGroup = getpgid(process.processIdentifier) if childProcessGroup > 0 && childProcessGroup != originalForegroundProcessGroup { - try? setTerminalForegroundProcessGroup(childProcessGroup) + do { + try setTerminalForegroundProcessGroup(childProcessGroup) + } catch { + // The handoff is required: without the terminal foreground, ssh's + // prompt SIGTTIN-stops and waitUntilExit() below hangs forever (the + // exact bug this dance prevents). Continue the child in case it + // already stopped, kill it, and fail loudly instead of hanging. + _ = Darwin.kill(-childProcessGroup, SIGCONT) + process.terminate() + throw CLIError( + message: "ssh-tmux: couldn't hand the terminal to ssh for \(destination); aborting to avoid a hang (\(error.localizedDescription))" + ) + } _ = Darwin.kill(-childProcessGroup, SIGCONT) didForegroundChild = true } From fc8cb842cb87b54d9f474eeb93ba512ce98f2120 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 00:10:08 +0300 Subject: [PATCH 23/73] remote-tmux: keep the ControlMaster alive on remote session-end if a connection still uses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handleSessionEndedRemotely` decided to `ssh -O exit` the shared master from `sessionMirrors` alone (`hostHasOtherMirrors`). A `remote.tmux.attach`/`open` control connection for the same endpoint multiplexes over the same ControlPath without a mirror entry, so exiting the master here would drop it. Gate the master teardown additionally on no remaining `connectionsByHostSession` entry for the endpoint (the window-binding teardown stays on `hostHasOtherMirrors`) — matching the connection-aware checks already used in `handleWindowWorkspacesClosed` and `handleWorkspaceClosed`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/RemoteTmuxController.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 7e4f637052f0..469c484823e2 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -695,13 +695,19 @@ final class RemoteTmuxController { ) if !hostHasOtherMirrors { // The host's last session is gone, so close its shared SSH ControlMaster - // now. We must do it here rather than rely on the window's onClose hook: - // clearing the binding just below makes handleRemoteWindowClosed a no-op, - // and the dedicated window may be closed programmatically (the + // now — but only if no other control connection (e.g. a + // remote.tmux.attach/open for the same endpoint) is still multiplexing + // over it. We must do it here rather than rely on the window's onClose + // hook: clearing the binding just below makes handleRemoteWindowClosed a + // no-op, and the dedicated window may be closed programmatically (the // `.closeDedicatedWindow` path), so the hook can't be the one to tear the // master down. - transports.removeValue(forKey: host.connectionHash) - RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + let hostHasOtherConnections = connectionsByHostSession.values + .contains { $0.host.connectionHash == host.connectionHash } + if !hostHasOtherConnections { + transports.removeValue(forKey: host.connectionHash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + } // Drop the dedicated-window binding (the window is either closing, or // converting to a plain local window — either way it is no longer a // remote mirror). Done before the switch so the window's onClose hook's From f7f657fb0d7043704ee6917a31a6fbb19cbd4ceb Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 00:10:08 +0300 Subject: [PATCH 24/73] remote-tmux: drop now-unused Codable from RemoteTmuxHost `Codable` existed only to serialize `[RemoteTmuxHost]` into UserDefaults for the mirror persistence layer, which was removed earlier in this branch. Nothing encodes/decodes the type anymore, so drop the conformance; it can be reintroduced deliberately if persistence ever returns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/RemoteTmuxHost.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/RemoteTmuxHost.swift b/Sources/RemoteTmuxHost.swift index 9824655a9a8a..bfd20b902336 100644 --- a/Sources/RemoteTmuxHost.swift +++ b/Sources/RemoteTmuxHost.swift @@ -7,7 +7,7 @@ import Foundation /// every operation against the host (discovery commands, the `tmux -CC` /// control client, and one-shot mutations) over a single SSH ControlMaster /// socket derived from the destination, so authentication happens once. -struct RemoteTmuxHost: Sendable, Equatable, Codable, Identifiable { +struct RemoteTmuxHost: Sendable, Equatable, Identifiable { /// The SSH destination: a `~/.ssh/config` alias or `user@host`. let destination: String From 6507065e9400b4178e390fddf96ec91190882951 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 00:19:29 +0300 Subject: [PATCH 25/73] fix(remote-tmux): don't leave a sticky empty window when every session fails to mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mirrorHostInNewWindow` created and recorded the dedicated window before the `mirrorSession` loop, which only logs per-session failures. If every session failed to attach (e.g. all were killed between discovery and attach), the function still returned `.mirrored` with an empty window — and because the window stayed keyed in `windowIdByHost`/`hostByWindowId`, the reuse check at the top handed that empty window back on every subsequent `cmux ssh-tmux` to the host, never retrying. If no session ended up mirrored, tear down the window, its bindings, and the ControlMaster, and throw so the CLI reports the failure instead of a false success. Bindings are cleared before the window close so its onClose `handleRemoteWindowClosed` is a no-op (no double teardown). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/RemoteTmuxController.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 469c484823e2..04222dadd5d1 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -311,6 +311,20 @@ final class RemoteTmuxController { #endif } } + // If every session failed to attach (e.g. all were killed between discovery + // and attach), don't leave a sticky empty dedicated window: the reuse check + // at the top would hand it back on the next attach and never retry. Tear down + // the window, bindings, and master, and surface the failure so the CLI reports + // it instead of a false success. (Bindings are cleared before the window + // close so its onClose handleRemoteWindowClosed is a no-op — no double exit.) + guard sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) else { + windowIdByHost.removeValue(forKey: host.connectionHash) + hostByWindowId.removeValue(forKey: windowId) + transports.removeValue(forKey: host.connectionHash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + appDelegate.discardMainWindowWithoutClosedHistory(windowId: windowId) + throw RemoteTmuxError.unreachable("could not mirror any tmux session on \(host.destination)") + } // Remove the window's bootstrap (local welcome) workspace once at least // one remote workspace exists, so the window is a clean 1:1 mirror. if let bootstrapWorkspaceId, From 0d4ba1169a630ae6a506176fb133e1648cdb9708 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 09:08:05 +0300 Subject: [PATCH 26/73] docs(remote-tmux): correct the reconnect limitation to match auto-reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Remote tmux docs page (added in 62865f253, before the ssh-tmux work) said "No live reconnect … the mirror closes" and "isn't retried with backoff," but a later commit in the same PR (196caf215, "auto-reconnect on transport loss") added indefinite capped-exponential-backoff reconnection that keeps the mirror frozen and re-seeds the panes on resume. Rewrite the limitation so it describes the real behavior: transient SSH drops reconnect automatically; the mirror only closes for good when the remote session ends or you close the window / quit cmux, and it isn't restored on relaunch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- web/messages/en.json | 2 +- web/messages/ja.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/messages/en.json b/web/messages/en.json index 5525fd28b9d2..57002243a554 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -680,7 +680,7 @@ "methodState": "Report the control client's observed state (diagnostics).", "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", "limitationsTitle": "Limitations", - "limitReconnect": "No live reconnect: if the SSH connection drops mid-session — or you close the mirror window or quit cmux — the mirror closes and you re-attach with cmux ssh-tmux. Mirrors aren't restored on relaunch, and a mid-session drop isn't retried with backoff the way cmux ssh is.", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." diff --git a/web/messages/ja.json b/web/messages/ja.json index f64c63151a7d..e3a506c8c477 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -636,7 +636,7 @@ "methodState": "コントロールクライアントの観測状態を報告します(診断用)。", "socketSafetyDesc": "ダッシュで始まる host や identity ファイルは、SSH オプションインジェクション対策として境界で拒否されます。", "limitationsTitle": "制限事項", - "limitReconnect": "ライブ再接続はありません: セッション中に SSH 接続が切れた場合、またはミラーウィンドウを閉じたり cmux を終了した場合、ミラーは閉じ、cmux ssh-tmux で再接続し直します。ミラーは再起動時に復元されず、cmux ssh のようにバックオフ付きで自動再試行もしません。", + "limitReconnect": "一時的な SSH の切断は自動的に再接続されます: セッション中に接続が途切れると、ミラーは固まり、cmux は上限付きの指数バックオフで再試行し、再開時にペインを再シードします。ミラーが完全に閉じるのは、リモートセッション自体が終了したとき、またはミラーウィンドウを閉じたり cmux を終了したときだけで、再起動時には復元されません(cmux ssh-tmux で再接続してください)。", "limitPaste": "ブラケットペーストとして paste-buffer -p で渡されるのは単一行の貼り付け・ドロップ(ファイルや画像のパスなど)のみです。複数行のテキストは通常のキー入力として送られるため、リモートアプリは 1 回の貼り付けとして扱いません。", "limitCwd": "作業ディレクトリのライブ更新はコントロールモードのサブスクリプション(tmux 3.2 以降)を使用します。それより古い tmux では初期フォルダーは表示されますが、cd しても更新されません。", "limitReflow": "プライマリスクリーンのスクロールバックはリサイズ時に折り返し直されません。cmux はリフローを tmux に任せるため、古い行はアプリが再描画するまで以前の幅のままになることがあります。リサイズ時に再描画するフルスクリーンの TUI は影響を受けません。" From 6315a1d3990128d5644f68a517f19be46c0f1e31 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 09:44:25 +0300 Subject: [PATCH 27/73] remote-tmux: drop the unshipped remote.tmux.open command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `remote.tmux.open` (mirror one remote pane as a tab in the current workspace) was added earlier in this PR, never reached main, and is socket-only — no CLI verb, menu, or palette entry reaches it. It also had no teardown: its display pane is a bonsplit tab with no close hook, so closing it leaked the displayPanels entry, the control-connection observer, and the underlying ssh ControlMaster (a zombie connection streaming until quit). A correct fix needs a new pane-close hook in the central tab-close path — too invasive for a dormant beta command — so pull it out rather than ship a half-working path. Removes openActivePane/buildDisplayIfNeeded/applyDisplayPaneCwd, the displayPanels/displayObserverTokens dictionaries (and their lines in the rename re-key and the four teardown handlers), displayPaneTarget and its fallbacks in pasteTarget/remoteUploadTarget, the v2RemoteTmuxOpen handler, the dispatch case, the socketWorkerMethods entry, and the docs row + methodOpen string + the attachSockets example (en/ja). The shared addRemoteTmuxDisplayPane stays — the ssh-tmux session-mirror path uses it, and its paste/upload goes through the untouched sessionMirrors branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../Wire/ControlCommandExecutionPolicy.swift | 1 - Sources/RemoteTmuxController.swift | 138 +----------------- Sources/TerminalController+RemoteTmux.swift | 22 --- Sources/TerminalController.swift | 2 - web/app/[locale]/docs/remote-tmux/page.tsx | 1 - web/messages/en.json | 3 +- web/messages/ja.json | 3 +- 7 files changed, 10 insertions(+), 160 deletions(-) diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index da9cc3382fa2..2f4016cfd684 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -67,7 +67,6 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", - "remote.tmux.open", "remote.tmux.mirror", "remote.tmux.window", "sidebar.custom.validate", diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 04222dadd5d1..bfd91696180e 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -88,101 +88,6 @@ final class RemoteTmuxController { // MARK: - Sidebar mirroring (P3, initial increment) - /// Display panels mirroring a remote pane, keyed `connectionHash\u{1}session\u{1}pane`. - private var displayPanels: [String: TerminalPanel] = [:] - - /// Observer tokens for the single-pane display path, keyed by connection key. - private var displayObserverTokens: [String: RemoteTmuxControlConnection.ObserverToken] = [:] - - /// Attaches a session and mirrors its active window's first pane as a live - /// display tab in a workspace. The tab renders the remote pane's output and - /// forwards keystrokes back to it. - /// - /// This is the "attach a single remote pane into a cmux tab" path; full - /// session→workspace / window→tab mirroring is ``mirrorSession(host:sessionName:)``. - /// - /// - Parameters: - /// - host: the remote SSH destination. - /// - sessionName: the tmux session to attach to. - /// - focus: when `true`, selects and focuses the created tab (user-initiated - /// attach). Socket/background callers pass `false` so they never steal the - /// user's keyboard focus, per the socket focus policy. - func openActivePane(host: RemoteTmuxHost, sessionName: String, focus: Bool = false) throws { - let connection = try attach(host: host, sessionName: sessionName) - let key = Self.connectionKey(host: host, sessionName: sessionName) - // Capture the target workspace at command time. Topology often arrives - // asynchronously (after the first %layout-change), so resolving the - // workspace inside the callback would build the tab in whichever - // workspace happens to be selected when topology lands. - guard let targetWorkspaceId = AppDelegate.shared?.tabManager?.selectedWorkspace?.id else { - throw RemoteTmuxError.unreachable("no active workspace") - } - // Register an observer (don't overwrite a single closure): the connection - // is shared with any concurrent mirror of the same session. - if displayObserverTokens[key] == nil { - displayObserverTokens[key] = connection.addObserver( - onPaneOutput: { [weak self] paneId, data in - self?.displayPanels["\(key)\u{1}\(paneId)"]?.surface.processRemoteOutput(data) - }, - onPaneCwd: { [weak self] paneId, path in - self?.applyDisplayPaneCwd(key: key, paneId: paneId, path: path) - }, - onTopologyChanged: { [weak self, weak connection] in - guard let self, let connection else { return } - self.buildDisplayIfNeeded(connection: connection, key: key, workspaceId: targetWorkspaceId, focus: focus) - } - ) - } - buildDisplayIfNeeded(connection: connection, key: key, workspaceId: targetWorkspaceId, focus: focus) - } - - private func buildDisplayIfNeeded( - connection: RemoteTmuxControlConnection, - key: String, - workspaceId: UUID, - focus: Bool - ) { - guard let firstWindowId = connection.windowOrder.first, - let window = connection.windowsByID[firstWindowId], - let paneId = window.paneIDsInOrder.first else { return } - let panelKey = "\(key)\u{1}\(paneId)" - guard displayPanels[panelKey] == nil else { return } - guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == workspaceId }) - else { return } - guard let panel = workspace.addRemoteTmuxDisplayPane( - remotePaneId: paneId, - focus: focus, - onInput: { [weak connection] data in - Task { @MainActor in connection?.sendKeys(paneId: paneId, data: data) } - }, - // Size the remote tmux client to this display surface's rendered grid, - // so a single attached pane doesn't stay at ssh's default 80×24 and - // render TUIs mangled (matches the session-mirror display path). - onResize: { [weak connection] columns, rows in - connection?.setClientSize(columns: columns, rows: rows) - } - ) else { return } - displayPanels[panelKey] = panel - // Prime the pane with its current contents so it isn't blank on open. - connection.capturePane(paneId: paneId) - // Track the pane's working directory (initial + live) so the tab shows the - // remote cwd instead of staying at "~". - connection.requestPanePath(paneId: paneId) - connection.subscribePanePath(paneId: paneId) - } - - /// Applies a display pane's reported working directory to its tab. Resolves the - /// workspace from the panel's own surface (not a captured id) so it stays - /// correct if the tab was moved to another workspace/window after attach. - private func applyDisplayPaneCwd(key: String, paneId: Int, path: String) { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, - let panel = displayPanels["\(key)\u{1}\(paneId)"], - let workspace = panel.surface.owningWorkspace() - else { return } - _ = workspace.updatePanelDirectory(panelId: panel.id, directory: trimmed) - } - /// Active session→workspace mirrors keyed `connectionHash\u{1}session` /// (see ``connectionKey(host:sessionName:)``). private var sessionMirrors: [String: RemoteTmuxSessionMirror] = [:] @@ -426,7 +331,6 @@ final class RemoteTmuxController { if oldKey != newKey { if let m = sessionMirrors.removeValue(forKey: oldKey) { sessionMirrors[newKey] = m } if let c = connectionsByHostSession.removeValue(forKey: oldKey) { connectionsByHostSession[newKey] = c } - if let t = displayObserverTokens.removeValue(forKey: oldKey) { displayObserverTokens[newKey] = t } } } @@ -503,8 +407,8 @@ final class RemoteTmuxController { return true } - /// The live control connection + tmux pane id behind a remote-tmux mirror - /// surface (session-mirror pane or ``openActivePane`` display pane), or `nil`. + /// The live control connection + tmux pane id behind a remote-tmux + /// session-mirror surface, or `nil`. private func pasteTarget(forSurfaceId surfaceId: UUID) -> (connection: RemoteTmuxControlConnection, paneId: Int)? { @@ -513,40 +417,18 @@ final class RemoteTmuxController { return (sessionMirror.connection, paneId) } } - return displayPaneTarget(forSurfaceId: surfaceId) - } - - /// The live control connection + tmux pane id behind an ``openActivePane`` - /// display-pane surface, or `nil`. The `displayPanels` key is - /// `connectionHash\u{1}session\u{1}pane`, so the first two components form the - /// connection key and the third is the pane id. Shared by ``pasteTarget(forSurfaceId:)`` - /// and ``remoteUploadTarget(forSurfaceId:)`` so the key format lives in one place. - private func displayPaneTarget(forSurfaceId surfaceId: UUID) - -> (connection: RemoteTmuxControlConnection, paneId: Int)? - { - for (panelKey, panel) in displayPanels where panel.surface.id == surfaceId { - let parts = panelKey.split(separator: "\u{1}", omittingEmptySubsequences: false) - guard parts.count >= 3, let paneId = Int(parts[2]) else { continue } - let connectionKey = parts[0..<2].joined(separator: "\u{1}") - if let connection = connectionsByHostSession[connectionKey], !connection.exited { - return (connection, paneId) - } - } return nil } - /// The SSH upload target for a remote-tmux surface (a session-mirror pane or - /// an ``openActivePane`` display pane), or `nil` if `surfaceId` isn't one. - /// Lets the image-paste path upload a pasted screenshot to the remote tmux - /// host (and insert the remote path) instead of an unreadable macOS-local one. + /// The SSH upload target for a remote-tmux session-mirror surface, or `nil` if + /// `surfaceId` isn't one. Lets the image-paste path upload a pasted screenshot + /// to the remote tmux host (and insert the remote path) instead of an + /// unreadable macOS-local one. func remoteUploadTarget(forSurfaceId surfaceId: UUID) -> TerminalRemoteUploadTarget? { for sessionMirror in sessionMirrors.values where !sessionMirror.connection.exited && sessionMirror.ownsSurface(surfaceId) { return .detectedSSH(sessionMirror.host.detectedSSHSession()) } - if let target = displayPaneTarget(forSurfaceId: surfaceId) { - return .detectedSSH(target.connection.host.detectedSSHSession()) - } return nil } @@ -667,7 +549,6 @@ final class RemoteTmuxController { if let mirror = sessionMirrors.removeValue(forKey: key) { mirror.detachObserver() } - displayObserverTokens.removeValue(forKey: key) connectionsByHostSession.removeValue(forKey: key)?.stop() let hostHasOtherMirrors = sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) // The dedicated window for this host, captured before the bindings are torn @@ -710,7 +591,7 @@ final class RemoteTmuxController { if !hostHasOtherMirrors { // The host's last session is gone, so close its shared SSH ControlMaster // now — but only if no other control connection (e.g. a - // remote.tmux.attach/open for the same endpoint) is still multiplexing + // remote.tmux.attach for the same endpoint) is still multiplexing // over it. We must do it here rather than rely on the window's onClose // hook: clearing the binding just below makes handleRemoteWindowClosed a // no-op, and the dedicated window may be closed programmatically (the @@ -778,7 +659,6 @@ final class RemoteTmuxController { guard let workspaceId = mirror.mirroredWorkspaceId, ids.contains(workspaceId) else { continue } affectedHosts[mirror.host.connectionHash] = mirror.host mirror.detachObserver() - displayObserverTokens.removeValue(forKey: key) sessionMirrors.removeValue(forKey: key) connectionsByHostSession.removeValue(forKey: key)?.stop() } @@ -806,7 +686,6 @@ final class RemoteTmuxController { windowIdByHost.removeValue(forKey: host.connectionHash) for (key, mirror) in sessionMirrors where mirror.host.connectionHash == host.connectionHash { mirror.detachObserver() - displayObserverTokens.removeValue(forKey: key) sessionMirrors.removeValue(forKey: key) } for (key, connection) in connectionsByHostSession where connection.host.connectionHash == host.connectionHash { @@ -831,7 +710,6 @@ final class RemoteTmuxController { let sessionName = mirror.sessionName sessionMirrors.removeValue(forKey: entry.key) mirror.detachObserver() - displayObserverTokens.removeValue(forKey: entry.key) detach(host: host, sessionName: sessionName) // If this was the host's last mirrored session, drop its dedicated-window // binding and tear down the shared SSH ControlMaster. The remote-end path @@ -901,7 +779,7 @@ final class RemoteTmuxController { // Fire-and-forget `ssh -O exit` per endpoint: it hits the local control // socket and runs independently of cmux, so the masters are torn down even as // the app exits — no lingering ssh after quit. Collect endpoints from BOTH - // transports AND control connections (the remote.tmux.attach/open paths open a + // transports AND control connections (the remote.tmux.attach path opens a // ControlPersist master via the connection without ever creating a transport), // deduped by connectionHash. var hostsByHash: [String: RemoteTmuxHost] = [:] diff --git a/Sources/TerminalController+RemoteTmux.swift b/Sources/TerminalController+RemoteTmux.swift index a217de63936f..c6b41238e036 100644 --- a/Sources/TerminalController+RemoteTmux.swift +++ b/Sources/TerminalController+RemoteTmux.swift @@ -108,28 +108,6 @@ extension TerminalController { } } - /// `remote.tmux.open` — attach a session and mirror its active pane as a - /// live display tab in the current workspace (first sidebar-mirroring step). - nonisolated func v2RemoteTmuxOpen(id: Any?, params: [String: Any]) -> String { - guard RemoteTmuxController.isEnabled else { - return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") - } - guard let host = Self.remoteTmuxHost(from: params), - let session = Self.remoteTmuxSessionName(from: params) - else { - return v2Error(id: id, code: "invalid_params", message: "host and session are required") - } - return v2VmCall(id: id, timeoutSeconds: 20) { - try await MainActor.run { - guard let controller = AppDelegate.shared?.remoteTmuxController else { - throw RemoteTmuxError.unreachable("app not ready") - } - try controller.openActivePane(host: host, sessionName: session) - } - return ["host": host.destination, "session": session, "opened": true] - } - } - /// `remote.tmux.mirror` — mirror every tmux session on a host as its own /// sidebar workspace (windows become tabs). Params: `host` (required). nonisolated func v2RemoteTmuxMirror(id: Any?, params: [String: Any]) -> String { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 326620f132cb..db889fb5ae2e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1039,8 +1039,6 @@ class TerminalController { return v2RemoteTmuxDetach(id: request.id, params: request.params) case "remote.tmux.state": return v2RemoteTmuxState(id: request.id, params: request.params) - case "remote.tmux.open": - return v2RemoteTmuxOpen(id: request.id, params: request.params) case "remote.tmux.mirror": return v2RemoteTmuxMirror(id: request.id, params: request.params) case "remote.tmux.window": diff --git a/web/app/[locale]/docs/remote-tmux/page.tsx b/web/app/[locale]/docs/remote-tmux/page.tsx index 7c87291ed81f..e0fe252b677d 100644 --- a/web/app/[locale]/docs/remote-tmux/page.tsx +++ b/web/app/[locale]/docs/remote-tmux/page.tsx @@ -81,7 +81,6 @@ export default function RemoteTmuxPage() { <tbody> <tr><td><code>remote.tmux.sessions</code></td><td><code>host</code>, <code>port?</code>, <code>identity_file?</code></td><td>{t("methodSessions")}</td></tr> <tr><td><code>remote.tmux.attach</code></td><td><code>host</code>, <code>session</code>, <code>create?</code></td><td>{t("methodAttach")}</td></tr> - <tr><td><code>remote.tmux.open</code></td><td><code>host</code>, <code>session</code></td><td>{t("methodOpen")}</td></tr> <tr><td><code>remote.tmux.mirror</code></td><td><code>host</code></td><td>{t("methodMirror")}</td></tr> <tr><td><code>remote.tmux.window</code></td><td><code>host</code>, <code>port?</code>, <code>identity_file?</code></td><td>{t("methodWindow")}</td></tr> <tr><td><code>remote.tmux.detach</code></td><td><code>host</code>, <code>session</code></td><td>{t("methodDetach")}</td></tr> diff --git a/web/messages/en.json b/web/messages/en.json index 57002243a554..e2b686c21ca7 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -654,7 +654,7 @@ "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", "attachTitle": "Attaching", "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.open mirrors just one session's active pane into the current workspace instead of the whole host.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", "howTitle": "How it works", "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", @@ -673,7 +673,6 @@ "socketMeaning": "Description", "methodSessions": "List the tmux sessions on a host.", "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodOpen": "Attach and open the session's active pane as a live tab in the current workspace.", "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", "methodDetach": "Detach the control client; the remote session keeps running.", diff --git a/web/messages/ja.json b/web/messages/ja.json index e3a506c8c477..16cb04a1ff83 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -610,7 +610,7 @@ "enableDesc": "設定 → ベータ機能 を開き、「リモート tmux」をオンにします。デフォルトはオフなので、オプトインするまでローカルのターミナルには何も影響しません。", "attachTitle": "接続する", "attachIntro": "ターミナルで cmux ssh-tmux <宛先>(~/.ssh/config のエイリアスまたは user@host)を実行します。cmux はそのホストの tmux セッションをミラーリングする新しいウィンドウを開きます。各セッションはワークスペースに、各ウィンドウはタブに、複数ペインのウィンドウはタブ内の分割になります。", - "attachSockets": "remote.tmux.* のソケットコマンド(下記)でより細かく制御できます。たとえば remote.tmux.open は、ホスト全体ではなく 1 つのセッションのアクティブなペインだけを現在のワークスペースにミラーリングします。", + "attachSockets": "remote.tmux.* のソケットコマンド(下記)でより細かく制御できます。たとえば remote.tmux.mirror は、専用ウィンドウを開く代わりに、ホストのセッションを現在のウィンドウのサイドバーにミラーリングします。", "attachCli": "非対話で認証されるホスト(ssh-agent や ~/.ssh/config の鍵)はプロンプトなしで接続されます。対話的な認証(パスワード、ホストキーの確認、多要素認証)が必要なホストでは、cmux がそのターミナル内で ssh を実行するため、その場で認証でき、その後 cmux がミラーウィンドウを開きます。--port と --identity を指定できます。", "howTitle": "仕組み", "howDesc": "cmux は ssh … tmux -CC attach を起動し、組み込みの tmux ビューアーに頼らずコントロールモードのストリームを自前で解析します。そのためプロトコルや %begin/%end のコマンド対応付けは完全に cmux が管理します。各リモートペインは tmux の %output を流し込む専用のターミナルサーフェスに描画され、キー入力やマウスなどの入力は tmux の send-keys でリモートへ送られます。ペインのサイズやリフローはリモートの tmux サーバーが管理し、cmux はそれに追従してローカルではリフローしません。", @@ -629,7 +629,6 @@ "socketMeaning": "説明", "methodSessions": "ホスト上の tmux セッションを一覧表示します。", "methodAttach": "セッションにコントロールクライアントで接続します(create で「接続または作成」)。", - "methodOpen": "接続して、セッションのアクティブなペインを現在のワークスペースにライブのタブとして開きます。", "methodMirror": "ホスト上のすべてのセッションを、それぞれワークスペースとしてミラーリングします(ウィンドウがタブになります)。", "methodWindow": "ホスト上のすべてのセッションをミラーリングする専用ウィンドウを開きます(cmux ssh-tmux のエントリポイント)。ホストが必要とする場合は、対話的認証のために実行すべき ssh コマンドを返します。", "methodDetach": "コントロールクライアントをデタッチします。リモートセッションは動作し続けます。", From 64829d1ddb39ed2506e172d03b35e22282ce7fb3 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 11:17:31 +0300 Subject: [PATCH 28/73] remote-tmux: address review-bot comments (#5553) Re-analyzed each upstream review-bot comment against the current code (analyze + adversarial verify) and applied the still-valid ones: - RemoteTmuxControlConnection: snapshot observer dictionaries before broadcasting so a callback that unregisters mid-broadcast can't trap (reachable: notifyExit -> handleSessionEndedRemotely -> removeObserver); update `sessionName` on `%session-changed` so a reconnect targets the live session name; replace (not merge) window topology on `list-windows` and prune `activePaneByWindow`/`paneOutputByteCounts`, so windows closed during a disconnect don't linger and get re-captured by reseedAfterReconnect. - RemoteTmuxControlStreamParser: reject a malformed `%begin` (missing/non-numeric command number) instead of entering block mode and wedging the parser. - RemoteTmuxError: sanitize + cap remote stderr/detail in the user-facing `message` (control/format/separator scalars -> space, 200-char cap); the stored associated values stay raw for the no-server / auth-required classifiers. - RemoteTmuxRawLayoutParser: trim trailing whitespace before the strict parse-completion check so a valid layout with a trailing newline parses. - RemoteTmuxWindowMirror: `syntheticPaneID(forPane:)` returns Optional and the caller guards it, instead of minting a throwaway `PaneID()` that churns the portal-host lease. - TerminalController+RemoteTmux: reject an out-of-range port (1...65535) at the trust boundary; `v2RemoteTmuxDetach` throws `.unreachable` instead of falsely reporting `detached:true` when the controller is unavailable. - CLI `ssh-tmux`: use `String(describing: error)` for full launch/handoff diagnostics. - vendor/bonsplit: point `.gitmodules` at the org remote (manaflow-ai/bonsplit) instead of a personal fork, and bump the pointer to include the within-pane drop-reorder delegate order-guard (bonsplit#143). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .gitmodules | 2 +- CLI/cmux.swift | 4 +-- Sources/RemoteTmuxControlConnection.swift | 36 ++++++++++++++----- Sources/RemoteTmuxControlStreamParser.swift | 9 ++++- Sources/RemoteTmuxError.swift | 38 ++++++++++++++++++--- Sources/RemoteTmuxRawLayoutParser.swift | 5 ++- Sources/RemoteTmuxWindowMirror.swift | 10 +++--- Sources/RemoteTmuxWindowMirrorView.swift | 5 +-- Sources/TerminalController+RemoteTmux.swift | 11 ++++-- vendor/bonsplit | 2 +- 10 files changed, 95 insertions(+), 27 deletions(-) diff --git a/.gitmodules b/.gitmodules index 73dc3bd1c7df..51853e856536 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,4 +7,4 @@ url = https://github.com/manaflow-ai/homebrew-cmux.git [submodule "vendor/bonsplit"] path = vendor/bonsplit - url = https://github.com/robertnisipeanu/bonsplit.git + url = https://github.com/manaflow-ai/bonsplit.git diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 33ab53871d26..590e227cd11e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7751,7 +7751,7 @@ struct CMUXCLI { do { try process.run() } catch { - throw CLIError(message: "ssh-tmux: failed to launch ssh: \(error.localizedDescription)") + throw CLIError(message: "ssh-tmux: failed to launch ssh: \(String(describing: error))") } if originalForegroundProcessGroup > 0 { let childProcessGroup = getpgid(process.processIdentifier) @@ -7766,7 +7766,7 @@ struct CMUXCLI { _ = Darwin.kill(-childProcessGroup, SIGCONT) process.terminate() throw CLIError( - message: "ssh-tmux: couldn't hand the terminal to ssh for \(destination); aborting to avoid a hang (\(error.localizedDescription))" + message: "ssh-tmux: couldn't hand the terminal to ssh for \(destination); aborting to avoid a hang (\(String(describing: error)))" ) } _ = Darwin.kill(-childProcessGroup, SIGCONT) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 2e8c0c29d799..c2632cf10c58 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -42,7 +42,7 @@ final class RemoteTmuxControlConnection { private(set) var connectionState: ConnectionState = .connecting { didSet { guard oldValue != connectionState else { return } - for callback in stateObservers.values { callback(connectionState) } + for callback in Array(stateObservers.values) { callback(connectionState) } } } /// `true` once the connection has permanently ended (genuine tmux `%exit`, a @@ -203,23 +203,27 @@ final class RemoteTmuxControlConnection { } private func emitPaneOutput(_ paneId: Int, _ data: Data) { - for callback in paneOutputObservers.values { callback(paneId, data) } + // Snapshot before iterating: a callback may unregister an observer (mutating + // the dict) synchronously, which would trap on the live collection. + for callback in Array(paneOutputObservers.values) { callback(paneId, data) } } private func emitPaneCwd(_ paneId: Int, _ path: String) { - for callback in paneCwdObservers.values { callback(paneId, path) } + for callback in Array(paneCwdObservers.values) { callback(paneId, path) } } private func emitActivePaneChanged(_ windowId: Int, _ paneId: Int) { - for callback in activePaneObservers.values { callback(windowId, paneId) } + for callback in Array(activePaneObservers.values) { callback(windowId, paneId) } } private func notifyTopologyChanged() { - for callback in topologyObservers.values { callback() } + for callback in Array(topologyObservers.values) { callback() } } private func notifyExit() { - for callback in exitObservers.values { callback() } + // Snapshot: notifyExit -> handleSessionEndedRemotely -> detachObserver -> + // removeObserver mutates exitObservers synchronously during this loop. + for callback in Array(exitObservers.values) { callback() } } /// Spawns the SSH `tmux -CC` process and begins streaming. @@ -723,8 +727,12 @@ final class RemoteTmuxControlConnection { paneOutputByteCounts[paneId, default: 0] += data.count totalOutputBytes += data.count emitPaneOutput(paneId, data) - case let .sessionChanged(id, _): + case let .sessionChanged(id, name): sessionId = id + // Track the new name too: `sessionName` is the value reused for + // attach/reconnect, so a remote rename must update it or the next + // reconnect targets a stale session and is wrongly declared gone. + sessionName = name record("session-changed $\(id)") requestWindows() case .sessionsChanged: @@ -786,6 +794,7 @@ final class RemoteTmuxControlConnection { switch kind { case .listWindows: var order: [Int] = [] + var next: [Int: RemoteTmuxWindow] = [:] for line in lines { // "@<id> <layout> <name with spaces…>" — id and layout never // contain spaces, so split into at most 3 fields. @@ -795,7 +804,7 @@ final class RemoteTmuxControlConnection { let node = RemoteTmuxRawLayoutParser.parse(String(parts[1])) else { continue } let name = parts.count >= 3 ? String(parts[2]) : "" - windowsByID[id] = RemoteTmuxWindow( + next[id] = RemoteTmuxWindow( id: id, name: name, width: node.width, height: node.height, layout: node ) order.append(id) @@ -808,6 +817,17 @@ final class RemoteTmuxControlConnection { // stream-end (see `handleConnectionExited` → `handleSessionEndedRemotely`), // which is the path that closes the workspace / dedicated window. if !order.isEmpty { + // Replace the topology, don't merge: a window closed remotely while we + // were disconnected never delivers a %window-close (it lands on the dead + // connection), so merging would leave it lingering in + // windowsByID/activePaneByWindow/paneOutputByteCounts and + // reseedAfterReconnect (which iterates windowsByID) would re-capture its + // dead panes. Prune anything not in the fresh reply. + let liveIDs = Set(order) + windowsByID = next + activePaneByWindow = activePaneByWindow.filter { liveIDs.contains($0.key) } + let livePanes = Set(next.values.flatMap { $0.paneIDsInOrder }) + paneOutputByteCounts = paneOutputByteCounts.filter { livePanes.contains($0.key) } windowOrder = order notifyTopologyChanged() // Now that the attach block is drained and the topology is fresh, run diff --git a/Sources/RemoteTmuxControlStreamParser.swift b/Sources/RemoteTmuxControlStreamParser.swift index 86f42cf47fad..ee6e70624bdd 100644 --- a/Sources/RemoteTmuxControlStreamParser.swift +++ b/Sources/RemoteTmuxControlStreamParser.swift @@ -98,7 +98,14 @@ struct RemoteTmuxControlStreamParser { } if line.hasPrefix("%begin ") { - blockNumber = Self.field(line, 2).flatMap { Int($0) } ?? 0 + guard let number = Self.field(line, 2).flatMap({ Int($0) }) else { + // Malformed `%begin` (missing/non-numeric command number): do NOT enter + // block mode — `blockNumber = 0` would swallow every later line until a + // matching `%end ... 0` and wedge the mirror until reconnect. Treat the + // bad line as a normal notification instead. + return prefixMessages + [parseNotification(line)] + } + blockNumber = number inBlock = true blockLines = [] return prefixMessages diff --git a/Sources/RemoteTmuxError.swift b/Sources/RemoteTmuxError.swift index 749a83cac343..40d393ccb370 100644 --- a/Sources/RemoteTmuxError.swift +++ b/Sources/RemoteTmuxError.swift @@ -15,19 +15,47 @@ enum RemoteTmuxError: Error, Sendable, Equatable { extension RemoteTmuxError { /// A short, user-presentable description. + /// + /// Raw remote ssh/tmux stderr (and launch/unreachable detail) is sanitized before + /// it reaches socket/CLI-facing error text: control/format/separator scalars + /// (terminal escapes, NUL, CR, …) are flattened to spaces and the result is capped, + /// so a noisy or hostile remote can't inject control bytes or unbounded output into + /// our error bodies. Only the rendered `message` is sanitized — the stored + /// associated `stderr`/`detail` are left untouched for the stderr-classification + /// paths that pattern-match them (`indicatesNoServer`, `indicatesAuthRequired`). var message: String { switch self { case let .commandFailed(exitCode, stderr): - let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty + let detail = Self.sanitizedDetail(stderr) + return detail.isEmpty ? "remote command failed (exit \(exitCode))" - : "remote command failed (exit \(exitCode)): \(trimmed)" + : "remote command failed (exit \(exitCode)): \(detail)" case let .launchFailed(detail): - return "failed to launch ssh: \(detail)" + return "failed to launch ssh: \(Self.sanitizedDetail(detail))" case let .unreachable(detail): - return "host unreachable: \(detail)" + return "host unreachable: \(Self.sanitizedDetail(detail))" } } + + /// Flattens control/format/separator scalars to spaces and caps length, so raw + /// remote diagnostics stay readable and bounded in user-facing error text. Mirrors + /// the scalar categories rejected by `TerminalController.remoteTmuxValueHasHiddenCharacter`. + private static func sanitizedDetail(_ raw: String) -> String { + let space: Unicode.Scalar = " " + var scalars = String.UnicodeScalarView() + for scalar in raw.unicodeScalars { + switch scalar.properties.generalCategory { + case .control, .format, .lineSeparator, .paragraphSeparator: + scalars.append(space) + default: + scalars.append(scalar) + } + } + let trimmed = String(scalars).trimmingCharacters(in: .whitespacesAndNewlines) + let maxLength = 200 + guard trimmed.count > maxLength else { return trimmed } + return String(trimmed.prefix(maxLength)) + "…" + } } // `String(describing:)` and `error.localizedDescription` both surface the diff --git a/Sources/RemoteTmuxRawLayoutParser.swift b/Sources/RemoteTmuxRawLayoutParser.swift index 0d267045404f..d491a73e7a3d 100644 --- a/Sources/RemoteTmuxRawLayoutParser.swift +++ b/Sources/RemoteTmuxRawLayoutParser.swift @@ -16,7 +16,10 @@ enum RemoteTmuxRawLayoutParser { /// /// - Returns: the root layout node, or `nil` if the string is malformed. static func parse(_ raw: String) -> RemoteTmuxLayoutNode? { - var chars = Array(raw) + // Normalize first: the strict `cursor == chars.count` completion check below + // would otherwise reject an otherwise-valid layout that carries a trailing + // newline/space. + var chars = Array(raw.trimmingCharacters(in: .whitespacesAndNewlines)) // Strip a leading 4-hex-char checksum followed by a comma, if present. if chars.count > 5, chars[4] == ",", diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 5e903c04b527..e69ba4b34462 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -62,10 +62,12 @@ final class RemoteTmuxWindowMirror { /// The surface rendering `tmuxPaneId`, if it exists. func surface(forPane tmuxPaneId: Int) -> TerminalSurface? { panelsByPaneId[tmuxPaneId]?.surface } - /// The stable synthetic bonsplit pane id for `tmuxPaneId` (minted in - /// ``reconcile(layout:)``; a pure read here so it's body-safe). - func syntheticPaneID(forPane tmuxPaneId: Int) -> PaneID { - syntheticPaneIds[tmuxPaneId] ?? PaneID() + /// The stable synthetic bonsplit pane id for `tmuxPaneId`, or `nil` if no panel + /// exists for it (minted in ``reconcile(layout:)``; a pure read here so it's + /// body-safe). Returns `nil` rather than minting a throwaway `PaneID()` on a miss, + /// which would churn the portal-host lease keyed off this id. + func syntheticPaneID(forPane tmuxPaneId: Int) -> PaneID? { + syntheticPaneIds[tmuxPaneId] } /// Updates the layout, creating panels for new panes and tearing down panels diff --git a/Sources/RemoteTmuxWindowMirrorView.swift b/Sources/RemoteTmuxWindowMirrorView.swift index cc079e396b72..6c1284f8c52e 100644 --- a/Sources/RemoteTmuxWindowMirrorView.swift +++ b/Sources/RemoteTmuxWindowMirrorView.swift @@ -62,7 +62,8 @@ private struct RemoteTmuxLayoutContainer: View { @ViewBuilder private func leaf(paneId: Int) -> some View { - if let panel = mirror.panel(forPane: paneId) { + if let panel = mirror.panel(forPane: paneId), + let syntheticPaneId = mirror.syntheticPaneID(forPane: paneId) { VStack(spacing: 0) { RemoteTmuxPaneHeader( isActive: mirror.activePaneId == paneId, @@ -74,7 +75,7 @@ private struct RemoteTmuxLayoutContainer: View { ) TerminalPanelView( panel: panel, - paneId: mirror.syntheticPaneID(forPane: paneId), + paneId: syntheticPaneId, isFocused: mirror.activePaneId == paneId, isVisibleInUI: isVisibleInUI, portalPriority: portalPriority, diff --git a/Sources/TerminalController+RemoteTmux.swift b/Sources/TerminalController+RemoteTmux.swift index c6b41238e036..70a250365adf 100644 --- a/Sources/TerminalController+RemoteTmux.swift +++ b/Sources/TerminalController+RemoteTmux.swift @@ -46,6 +46,10 @@ extension TerminalController { !Self.remoteTmuxValueHasHiddenCharacter(destination) else { return nil } let port = params["port"] as? Int + // Reject an out-of-range port at the trust boundary (consistent with the + // dash-prefix/hidden-char rejections above) instead of silently falling back + // to the SSH default. + if let port, !(1...65535).contains(port) { return nil } let identityFile = (params["identity_file"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) if let identityFile, identityFile.hasPrefix("-") { return nil } @@ -185,8 +189,11 @@ extension TerminalController { return v2Error(id: id, code: "invalid_params", message: "host and session are required") } return v2VmCall(id: id, timeoutSeconds: 10) { - await MainActor.run { - AppDelegate.shared?.remoteTmuxController.detach(host: host, sessionName: session) + try await MainActor.run { + guard let controller = AppDelegate.shared?.remoteTmuxController else { + throw RemoteTmuxError.unreachable("app not ready") + } + controller.detach(host: host, sessionName: session) } return ["host": host.destination, "session": session, "detached": true] } diff --git a/vendor/bonsplit b/vendor/bonsplit index aa66547ecd49..2aa97036d14f 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit aa66547ecd4978ee477f0b57e993b515b5596b4c +Subproject commit 2aa97036d14f4b4d94455ef7ebb621e08c891dcb From a230bdc613a700f4b3a97d44f1c23b04047bd785 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 11:31:31 +0300 Subject: [PATCH 29/73] remote-tmux: bump vendor/bonsplit (drop-path delegate fired outside transaction) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the bonsplit#143 follow-up that moves the within-pane drop-reorder delegate call outside `withTransaction`, matching the manual-drag path — per the re-review on bonsplit#143. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 2aa97036d14f..c209a0db3cff 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 2aa97036d14f4b4d94455ef7ebb621e08c891dcb +Subproject commit c209a0db3cffd02a9117a17ee5a5854e79a6137c From 99ef05e53ca76290e578cb4ff4019c62be5fbf27 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 16:02:37 +0300 Subject: [PATCH 30/73] remote-tmux: decompose oversized files, scope new-workspace routing, kill on tab/session close Three changes to the remote-tmux mirroring feature (PR #5553). They are committed together because they interleave within RemoteTmuxController.swift; each is described below. Decomposition (swift-file-package-boundaries): - RemoteTmuxControlConnection 1001->870: extract RemoteTmuxConnectionObservers (multicast observer registry), RemoteTmuxConnectionDiagnostics (event ring buffer), and RemoteTmuxControlMessageDecoding (stateless pure decoders). - RemoteTmuxController 922->813: extract RemoteTmuxTransportRegistry (ssh master ownership) and RemoteTmuxWindowRegistry (dedicated-window bookkeeping). - Behavior-preserving verbatim moves; the parser tests now call RemoteTmuxControlMessageDecoding directly. The connection residual (870) is one cohesive engine (process lifecycle + ingest + reconnect + command FIFO + message projection); the controller residual (813) carries the new kill feature. New-workspace routing (C5): - Gate dedicated-remote-window new-workspace creation on the ACTIVE workspace being a mirror, in the shared performNewWorkspaceAction path, so a dragged-in local tab in a remote window no longer spawns an unwanted tmux session. Applies to every entrypoint (sidebar double-tap, command-N, titlebar +, palette). Kill on tab/session close (C14): - Closing a remote-tmux tab/session kills it on the remote (synced with tmux), including the last tab of a window: AppKit deferred termination (.terminateLater) awaits the bounded kill before quitting -- "close the session, then close cmux". An app-instance/window close (Cmd-Q, red button, Cmd-Shift-W) only DETACHES, leaving the remote tmux server alive for resume. - Reply-once guard + re-entrancy guard + a watchdog keep the deferred terminate from hanging or double-replying. Adds RemoteTmuxWindowRegistryTests. Known limitations (deliberate, for now): - Kill is best-effort on a dead connection (degrades to detach; a 3.5s quit watchdog bounds the quit). You cannot kill a session over a dead link anyway. - The only-window quit-kill covers dedicated remote windows; a non-dedicated socket `remote.tmux.mirror` workspace that is the app's only window detaches instead of killing on quit (the multi-window path still kills it). - No close-confirmation dialog; the correct model (detect a running remote process, like local mode does) is deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/AppDelegate.swift | 90 +++++++- Sources/ContentView.swift | 10 +- Sources/RemoteTmuxConnectionDiagnostics.swift | 34 +++ Sources/RemoteTmuxConnectionObservers.swift | 107 +++++++++ Sources/RemoteTmuxControlConnection.swift | 207 ++++-------------- .../RemoteTmuxControlMessageDecoding.swift | 108 +++++++++ Sources/RemoteTmuxController.swift | 191 ++++++++-------- Sources/RemoteTmuxSSHTransport.swift | 32 +++ Sources/RemoteTmuxTransportRegistry.swift | 52 +++++ Sources/RemoteTmuxWindowRegistry.swift | 104 +++++++++ Sources/TabManager.swift | 35 ++- cmux.xcodeproj/project.pbxproj | 24 ++ cmuxTests/RemoteTmuxControlParserTests.swift | 30 +-- cmuxTests/RemoteTmuxWindowRegistryTests.swift | 56 +++++ 14 files changed, 789 insertions(+), 291 deletions(-) create mode 100644 Sources/RemoteTmuxConnectionDiagnostics.swift create mode 100644 Sources/RemoteTmuxConnectionObservers.swift create mode 100644 Sources/RemoteTmuxControlMessageDecoding.swift create mode 100644 Sources/RemoteTmuxTransportRegistry.swift create mode 100644 Sources/RemoteTmuxWindowRegistry.swift create mode 100644 cmuxTests/RemoteTmuxWindowRegistryTests.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c0dc771906cf..f5ae1ee49643 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1086,6 +1086,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Set to true when the user has already confirmed quit via the warning dialog, // so applicationShouldTerminate does not show a second alert. private var isQuitWarningConfirmed = false + // One-shot guard so NSApp.reply(toApplicationShouldTerminate:) is funneled through + // replyToTerminateOnce and can never be called twice (deferred kill Task + watchdog + // + a re-entrant terminate) or hang the quit. + private var didReplyToTerminate = false + // True while a deferred-kill .terminateLater is in flight, so a re-entrant + // applicationShouldTerminate doesn't spawn a second kill Task / second reply. + private var isAwaitingTerminateKills = false private var didInstallLifecycleSnapshotObservers = false private var didDisableSuddenTermination = false private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] @@ -1751,7 +1758,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } + /// Sole caller of `NSApp.reply(toApplicationShouldTerminate:)`, guarded so the + /// deferred-kill Task, its watchdog, and any re-entrant terminate can't double-reply. + private func replyToTerminateOnce(_ shouldTerminate: Bool) { + guard !didReplyToTerminate else { return } + didReplyToTerminate = true + NSApp.reply(toApplicationShouldTerminate: shouldTerminate) + // The guard only coalesces replies WITHIN a single terminate request. A + // cancelled quit (`false`) ends that request, so reset both flags — otherwise + // a later quit attempt's reply would be silently swallowed and the app could + // never terminate. + if !shouldTerminate { + didReplyToTerminate = false + isAwaitingTerminateKills = false + } + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + // A deferred kill-then-quit is already in flight (a prior terminate returned + // .terminateLater; its Task + watchdog own the single reply). A re-entrant + // terminate must defer to it WITHOUT re-evaluating — the kill Task consumes the + // marker early, so re-checking windowsMarkedForKillOnClose() here would find it + // empty and wrongly fall through to .terminateNow, quitting before the kill lands. + if isAwaitingTerminateKills { return .terminateLater } let buildFlavor = BuildFlavor.current let hasDirtyWorkspaces = hasQuitConfirmationDirtyWorkspaces() let confirmQuitMode = QuitWarningSettings.confirmQuitMode() @@ -1786,6 +1815,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { reason = "policy" } + // An explicit tab/session close of a remote window's LAST tab escalates to + // this quit. Those windows are marked for kill-on-close: defer termination, + // KILL the remote session(s) (awaited, bounded), then reply to let the app + // quit — "close the session, then close cmux". A plain quit (no marker, e.g. + // ⌘Q or a window close) takes the synchronous .terminateNow path below and + // only detaches. Gated on the marker (not `reason`) so it fires on dev + // builds too — otherwise dogfooding (always dev) never exercises the kill. + let markedForKill = remoteTmuxController.windowsMarkedForKillOnClose() + if !markedForKill.isEmpty { + if !isAwaitingTerminateKills { + isAwaitingTerminateKills = true + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.killLater", fields: ["windows": String(markedForKill.count), "reason": reason]) + Task { @MainActor in + await self.remoteTmuxController.killMarkedSessionsBeforeTerminate() + self.replyToTerminateOnce(true) + } + // Watchdog: guarantee the quit is released even if the deferred Task + // is starved (it can return .terminateLater from inside the modal's + // nested run loop). 3.5s > the 3s kill budget so it never preempts a + // completing kill; replyToTerminateOnce makes it a no-op if already replied. + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in + self?.replyToTerminateOnce(true) + } + } + return .terminateLater + } StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": reason]) return .terminateNow } @@ -1817,7 +1872,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.isTerminatingApp = false StartupBreadcrumbLog.append("appDelegate.shouldTerminate.reply", fields: ["shouldQuit": "0"]) } - NSApp.reply(toApplicationShouldTerminate: shouldQuit) + self.replyToTerminateOnce(shouldQuit) } StartupBreadcrumbLog.append("appDelegate.shouldTerminate.later") return .terminateLater @@ -1859,8 +1914,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillTerminate(_ notification: Notification) { StartupBreadcrumbLog.append("appDelegate.willTerminate.begin") isTerminatingApp = true - // Detach remote tmux control connections (kills the local ssh clients); - // the remote tmux server + sessions stay alive for resume next launch. + // Detach remote tmux control connections (kills the local ssh clients); the + // remote tmux server + sessions stay alive for resume next launch. Any explicit + // tab/session close that escalated to this quit already killed its session in + // applicationShouldTerminate's deferred path and removed that host's + // transport/connections, so detachAll finds nothing for it and can't race the kill. remoteTmuxController.detachAll() closeAllWebInspectorsBeforeAppTeardown() _ = saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) @@ -8156,14 +8214,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let controller = MainWindowController(window: window) controller.onClose = { [weak self, weak controller] in guard let self, let controller else { return } - // If this was a dedicated remote-tmux window, detach its host's - // control connections (the remote tmux server stays alive for resume; - // closing the window must not kill remote sessions). + let manager = self.tabManagerFor(windowId: windowId) + // An explicit close of the window's LAST remote workspace (a tab/session + // close) kills its remote session(s) — synced with tmux — even though it + // also closes the app window. A plain window/quit close leaves the marker + // unset and falls through to detach below (server stays alive for resume). + if self.remoteTmuxController.consumeKillSessionsOnWindowClose(windowId: windowId), + let manager { + for workspace in manager.tabs where workspace.isRemoteTmuxMirror { + self.remoteTmuxController.handleWorkspaceClosed(workspaceId: workspace.id) + } + } + // If this was a dedicated remote-tmux window, detach its host's control + // connections (no-op when the kill path above already tore them down). + // A window/quit close only detaches — the remote tmux server stays alive. self.remoteTmuxController.handleRemoteWindowClosed(windowId: windowId) // Also detach any per-workspace mirrors in this window (covers the // socket `remote.tmux.mirror` path into a non-dedicated window), so // their pane surfaces / ssh connections don't leak on window close. - if let manager = self.tabManagerFor(windowId: windowId) { + if let manager { self.remoteTmuxController.handleWindowWorkspacesClosed( workspaceIds: manager.tabs.map { $0.id } ) @@ -8174,6 +8243,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let shouldClose = self?.handleMainTerminalWindowShouldClose() ?? true if !shouldClose { self?.closedWindowHistorySuppressedWindowIds.remove(windowId) + // Close CANCELLED (a genuine veto, not a confirmed quit): clear any + // kill-on-close marker so a later window/quit close detaches. A + // CONFIRMED quit of the last tab keeps the marker set so + // applicationWillTerminate kills the session before exit. + if self?.isTerminatingApp != true { + self?.remoteTmuxController.consumeKillSessionsOnWindowClose(windowId: windowId) + } } return shouldClose } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3003d0b459d0..c7e0b5c542f3 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -14899,10 +14899,14 @@ private struct SidebarEmptyArea: View { .contentShape(Rectangle()) .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture(count: 2) { - // In a dedicated remote-tmux window, route through + // When the active workspace is a remote-tmux mirror, route through // performNewWorkspaceAction so a new workspace becomes a new tmux - // session instead of a local (orphan) workspace. - if tabManager.tabs.contains(where: { $0.isRemoteTmuxMirror }) { + // session instead of a local (orphan) workspace. Gate on the + // SELECTED tab, not `tabs.contains`: a dedicated remote window can + // be polluted with a dragged-in local workspace (move targets don't + // exclude dedicated windows), and `contains` would then misroute a + // local empty-area double-tap into spawning an unwanted tmux session. + if tabManager.selectedTab?.isRemoteTmuxMirror == true { _ = AppDelegate.shared?.performNewWorkspaceAction( tabManager: tabManager, debugSource: "sidebar.emptyArea.remoteTmux" diff --git a/Sources/RemoteTmuxConnectionDiagnostics.swift b/Sources/RemoteTmuxConnectionDiagnostics.swift new file mode 100644 index 000000000000..d9dda1652d3e --- /dev/null +++ b/Sources/RemoteTmuxConnectionDiagnostics.swift @@ -0,0 +1,34 @@ +import Foundation + +/// A bounded ring of recent lifecycle/event strings for one remote-tmux control +/// connection, surfaced through `remote.tmux.state` diagnostics. +/// +/// ``RemoteTmuxControlConnection`` records a short label for every notable event +/// (connect, exit, reconnect attempt, stream-end, write failure, …) here; the +/// buffer keeps only the most recent ``events`` up to the configured cap so a +/// long-lived connection on a chatty session can't grow it without limit. +@MainActor +final class RemoteTmuxConnectionDiagnostics { + /// The most recent recorded event labels, oldest first, capped at ``maxLines``. + private var recentEvents: [String] = [] + /// The maximum number of event labels retained; older entries are dropped. + private let maxLines: Int + + /// Creates a diagnostics buffer retaining at most `maxLines` recent events. + /// + /// - Parameter maxLines: the cap on retained event labels. Defaults to 100. + init(maxLines: Int = 100) { + self.maxLines = maxLines + } + + /// The retained event labels, oldest first. + var events: [String] { recentEvents } + + /// Appends `event`, trimming the buffer back to ``maxLines`` if it overflows. + func record(_ event: String) { + recentEvents.append(event) + if recentEvents.count > maxLines { + recentEvents.removeFirst(recentEvents.count - maxLines) + } + } +} diff --git a/Sources/RemoteTmuxConnectionObservers.swift b/Sources/RemoteTmuxConnectionObservers.swift new file mode 100644 index 000000000000..4d579abb2a8f --- /dev/null +++ b/Sources/RemoteTmuxConnectionObservers.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Multicast observer registry for one remote-tmux control connection. +/// +/// A single ``RemoteTmuxControlConnection`` is shared by every consumer of the +/// same host+session (``RemoteTmuxController.attach`` reuses it), so events MUST +/// fan out to all consumers — a single overwritable closure silently cut off +/// whichever consumer wired up first. This type owns the per-event registries and +/// emits to every registered callback, snapshotting each registry before iterating +/// so a callback that unregisters itself (mutating the dictionary) can't trap on a +/// live collection. +@MainActor +final class RemoteTmuxConnectionObservers { + /// Opaque token identifying a registered observer (pass to ``remove(_:)``). + typealias Token = UUID + + private var paneOutputObservers: [Token: (_ paneId: Int, _ data: Data) -> Void] = [:] + private var paneCwdObservers: [Token: (_ paneId: Int, _ path: String) -> Void] = [:] + private var activePaneObservers: [Token: (_ windowId: Int, _ paneId: Int) -> Void] = [:] + private var topologyObservers: [Token: () -> Void] = [:] + private var exitObservers: [Token: () -> Void] = [:] + private var stateObservers: [Token: (RemoteTmuxControlConnection.ConnectionState) -> Void] = [:] + + /// Registers a consumer's callbacks and returns a token to deregister them. + /// + /// Multiple consumers (e.g. a mirrored workspace and a single-pane display + /// tab) can observe the same shared connection concurrently; every callback + /// fires for every event. Pass the returned token to ``remove(_:)`` when the + /// consumer goes away. + /// + /// - Parameters: + /// - onPaneOutput: receives every `%output` (raw, octal-unescaped bytes). + /// - onPaneCwd: receives a pane's working directory (`pane_current_path`), + /// both the initial value and live changes. + /// - onActivePaneChanged: fires when a window's active pane changes + /// (`%window-pane-changed`), so consumers can re-project per-pane state + /// (e.g. the active pane's directory) onto the window's tab. + /// - onTopologyChanged: fires when the window/pane topology changes. + /// - onExit: fires once when the connection PERMANENTLY ends (a genuine tmux + /// `%exit`, or a session found gone on reconnect). A transient transport loss + /// does NOT fire this — the connection reconnects instead. + /// - onConnectionStateChanged: fires on every connection-state transition + /// (e.g. `.connected` → `.reconnecting` on a transport loss), so consumers + /// can show a disconnected/reconnecting indicator without tearing down. + /// - Returns: a ``Token`` to pass to ``remove(_:)``. + func add( + onPaneOutput: ((_ paneId: Int, _ data: Data) -> Void)?, + onPaneCwd: ((_ paneId: Int, _ path: String) -> Void)?, + onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)?, + onTopologyChanged: (() -> Void)?, + onExit: (() -> Void)?, + onConnectionStateChanged: ((RemoteTmuxControlConnection.ConnectionState) -> Void)? + ) -> Token { + let token = Token() + if let onPaneOutput { paneOutputObservers[token] = onPaneOutput } + if let onPaneCwd { paneCwdObservers[token] = onPaneCwd } + if let onActivePaneChanged { activePaneObservers[token] = onActivePaneChanged } + if let onTopologyChanged { topologyObservers[token] = onTopologyChanged } + if let onExit { exitObservers[token] = onExit } + if let onConnectionStateChanged { stateObservers[token] = onConnectionStateChanged } + return token + } + + /// Deregisters the callbacks registered under `token`. + func remove(_ token: Token) { + paneOutputObservers[token] = nil + paneCwdObservers[token] = nil + activePaneObservers[token] = nil + topologyObservers[token] = nil + exitObservers[token] = nil + stateObservers[token] = nil + } + + /// Fans `%output` bytes out to every pane-output observer. + func emitPaneOutput(_ paneId: Int, _ data: Data) { + // Snapshot before iterating: a callback may unregister an observer (mutating + // the dict) synchronously, which would trap on the live collection. + for callback in Array(paneOutputObservers.values) { callback(paneId, data) } + } + + /// Fans a pane's working directory out to every cwd observer. + func emitPaneCwd(_ paneId: Int, _ path: String) { + for callback in Array(paneCwdObservers.values) { callback(paneId, path) } + } + + /// Fans a window's new active pane out to every active-pane observer. + func emitActivePaneChanged(_ windowId: Int, _ paneId: Int) { + for callback in Array(activePaneObservers.values) { callback(windowId, paneId) } + } + + /// Notifies every topology observer that the window/pane layout changed. + func notifyTopologyChanged() { + for callback in Array(topologyObservers.values) { callback() } + } + + /// Notifies every exit observer that the connection permanently ended. + func notifyExit() { + // Snapshot: notifyExit -> handleSessionEndedRemotely -> detachObserver -> + // removeObserver mutates exitObservers synchronously during this loop. + for callback in Array(exitObservers.values) { callback() } + } + + /// Notifies every connection-state observer of a transition. + func notifyStateChanged(_ state: RemoteTmuxControlConnection.ConnectionState) { + for callback in Array(stateObservers.values) { callback(state) } + } +} diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index c2632cf10c58..48a34dc1b52d 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -22,16 +22,10 @@ final class RemoteTmuxControlConnection { /// Opaque token identifying a registered observer (pass to ``removeObserver(_:)``). typealias ObserverToken = UUID - // Multicast observer registries. A single connection is shared by every - // consumer of the same host+session (``RemoteTmuxController.attach`` reuses - // it), so events MUST fan out to all consumers — a single overwritable - // closure silently cut off whichever consumer wired up first. - private var paneOutputObservers: [ObserverToken: (_ paneId: Int, _ data: Data) -> Void] = [:] - private var paneCwdObservers: [ObserverToken: (_ paneId: Int, _ path: String) -> Void] = [:] - private var activePaneObservers: [ObserverToken: (_ windowId: Int, _ paneId: Int) -> Void] = [:] - private var topologyObservers: [ObserverToken: () -> Void] = [:] - private var exitObservers: [ObserverToken: () -> Void] = [:] - private var stateObservers: [ObserverToken: (ConnectionState) -> Void] = [:] + /// Multicast observer registry. A single connection is shared by every consumer + /// of the same host+session (``RemoteTmuxController.attach`` reuses it), so events + /// fan out to all consumers via this registry. + private let observers = RemoteTmuxConnectionObservers() // MARK: Observed state @@ -42,7 +36,7 @@ final class RemoteTmuxControlConnection { private(set) var connectionState: ConnectionState = .connecting { didSet { guard oldValue != connectionState else { return } - for callback in Array(stateObservers.values) { callback(connectionState) } + observers.notifyStateChanged(connectionState) } } /// `true` once the connection has permanently ended (genuine tmux `%exit`, a @@ -56,7 +50,6 @@ final class RemoteTmuxControlConnection { private(set) var activePaneByWindow: [Int: Int] = [:] private(set) var paneOutputByteCounts: [Int: Int] = [:] private(set) var totalOutputBytes = 0 - private(set) var recentEvents: [String] = [] private var process: Process? private var stdinHandle: FileHandle? @@ -72,7 +65,12 @@ final class RemoteTmuxControlConnection { private var ingestTask: Task<Void, Never>? private var pendingCommands: [CommandKind] = [] private let createIfMissing: Bool - private let maxRecentEvents = 100 + + /// Stateless pure decoders for control-mode message payloads (pane-state seed, + /// window reorder, session-gone classification). Holds no state. + private let decoding = RemoteTmuxControlMessageDecoding() + /// Bounded ring of recent event labels surfaced through `remote.tmux.state`. + private let diagnostics = RemoteTmuxConnectionDiagnostics() // MARK: Reconnect state @@ -182,48 +180,19 @@ final class RemoteTmuxControlConnection { onExit: (() -> Void)? = nil, onConnectionStateChanged: ((ConnectionState) -> Void)? = nil ) -> ObserverToken { - let token = ObserverToken() - if let onPaneOutput { paneOutputObservers[token] = onPaneOutput } - if let onPaneCwd { paneCwdObservers[token] = onPaneCwd } - if let onActivePaneChanged { activePaneObservers[token] = onActivePaneChanged } - if let onTopologyChanged { topologyObservers[token] = onTopologyChanged } - if let onExit { exitObservers[token] = onExit } - if let onConnectionStateChanged { stateObservers[token] = onConnectionStateChanged } - return token + observers.add( + onPaneOutput: onPaneOutput, + onPaneCwd: onPaneCwd, + onActivePaneChanged: onActivePaneChanged, + onTopologyChanged: onTopologyChanged, + onExit: onExit, + onConnectionStateChanged: onConnectionStateChanged + ) } /// Deregisters the callbacks registered under `token`. func removeObserver(_ token: ObserverToken) { - paneOutputObservers[token] = nil - paneCwdObservers[token] = nil - activePaneObservers[token] = nil - topologyObservers[token] = nil - exitObservers[token] = nil - stateObservers[token] = nil - } - - private func emitPaneOutput(_ paneId: Int, _ data: Data) { - // Snapshot before iterating: a callback may unregister an observer (mutating - // the dict) synchronously, which would trap on the live collection. - for callback in Array(paneOutputObservers.values) { callback(paneId, data) } - } - - private func emitPaneCwd(_ paneId: Int, _ path: String) { - for callback in Array(paneCwdObservers.values) { callback(paneId, path) } - } - - private func emitActivePaneChanged(_ windowId: Int, _ paneId: Int) { - for callback in Array(activePaneObservers.values) { callback(windowId, paneId) } - } - - private func notifyTopologyChanged() { - for callback in Array(topologyObservers.values) { callback() } - } - - private func notifyExit() { - // Snapshot: notifyExit -> handleSessionEndedRemotely -> detachObserver -> - // removeObserver mutates exitObservers synchronously during this loop. - for callback in Array(exitObservers.values) { callback() } + observers.remove(token) } /// Spawns the SSH `tmux -CC` process and begins streaming. @@ -388,15 +357,7 @@ final class RemoteTmuxControlConnection { /// later reorder and roll the order back. Out-of-band changes still reconcile /// via the topology events that already trigger ``requestWindows()``.) func applyWindowReorder(_ reordered: [Int]) { - windowOrder = Self.windowOrder(windowOrder, applyingReorder: reordered) - } - - /// Returns `order` with the windows in `reordered` rearranged into - /// `reordered`'s sequence, leaving windows not in that set in their positions. - nonisolated static func windowOrder(_ order: [Int], applyingReorder reordered: [Int]) -> [Int] { - let set = Set(reordered) - var iterator = reordered.makeIterator() - return order.map { set.contains($0) ? (iterator.next() ?? $0) : $0 } + windowOrder = decoding.windowOrder(windowOrder, applyingReorder: reordered) } /// Captures a pane's current visible contents (with escapes) and delivers @@ -594,14 +555,14 @@ final class RemoteTmuxControlConnection { guard connectionState == .reconnecting else { return } // Classify: a session/server found gone is a genuine end; anything else // (host unreachable, refused) is transient — keep retrying with backoff. - let sessionGone = Self.stderrIndicatesSessionGone(stderrBuffer) + let sessionGone = decoding.stderrIndicatesSessionGone(stderrBuffer) teardownProcessHandles() if sessionGone { record("reconnect-session-gone") connectionState = .ended reconnectTask?.cancel() reconnectTask = nil - notifyExit() + observers.notifyExit() } else { scheduleReconnectAttempt() } @@ -673,7 +634,7 @@ final class RemoteTmuxControlConnection { } for window in windowsByID.values { for paneId in window.paneIDsInOrder { - emitPaneOutput(paneId, Data("\u{1b}[H\u{1b}[2J\u{1b}[3J".utf8)) + observers.emitPaneOutput(paneId, Data("\u{1b}[H\u{1b}[2J\u{1b}[3J".utf8)) capturePane(paneId: paneId) requestPanePath(paneId: paneId) subscribePanePath(paneId: paneId) @@ -681,19 +642,6 @@ final class RemoteTmuxControlConnection { } } - /// Whether captured ssh/tmux stderr indicates the session/server is genuinely - /// gone (reconnect should stop and end) vs a transient transport failure (host - /// unreachable / connection refused — keep retrying). - nonisolated static func stderrIndicatesSessionGone(_ stderr: String) -> Bool { - let lowered = stderr.lowercased() - return lowered.contains("can't find session") - || lowered.contains("can\u{2019}t find session") - || lowered.contains("no server running") - || lowered.contains("no current session") - || lowered.contains("session not found") - || lowered.contains("lost server") - } - private func handle(_ message: RemoteTmuxControlMessage) { switch message { case .enter: @@ -722,11 +670,11 @@ final class RemoteTmuxControlConnection { connectionState = .ended reconnectTask?.cancel() reconnectTask = nil - notifyExit() + observers.notifyExit() case let .output(paneId, data): paneOutputByteCounts[paneId, default: 0] += data.count totalOutputBytes += data.count - emitPaneOutput(paneId, data) + observers.emitPaneOutput(paneId, data) case let .sessionChanged(id, name): sessionId = id // Track the new name too: `sessionName` is the value reused for @@ -750,7 +698,7 @@ final class RemoteTmuxControlConnection { windowsByID[id] = nil windowOrder.removeAll { $0 == id } record("window-close @\(id)") - notifyTopologyChanged() + observers.notifyTopologyChanged() case let .windowRenamed(id, name): record("window-renamed @\(id)") // Propagate the new name into the topology so the mirrored tab title @@ -760,15 +708,15 @@ final class RemoteTmuxControlConnection { id: id, name: name, width: existing.width, height: existing.height, layout: existing.layout ) - notifyTopologyChanged() + observers.notifyTopologyChanged() } case let .layoutChange(id, layout): applyLayout(windowId: id, layout: layout) record("layout-change @\(id)") - notifyTopologyChanged() + observers.notifyTopologyChanged() case let .windowPaneChanged(windowId, paneId): activePaneByWindow[windowId] = paneId - emitActivePaneChanged(windowId, paneId) + observers.emitActivePaneChanged(windowId, paneId) case let .sessionWindowChanged(_, windowId): record("session-window-changed @\(windowId)") case let .subscriptionChanged(name, value): @@ -776,7 +724,7 @@ final class RemoteTmuxControlConnection { if name.hasPrefix(Self.cwdSubscriptionPrefix), let paneId = Int(name.dropFirst(Self.cwdSubscriptionPrefix.count)) { let path = value.trimmingCharacters(in: .whitespacesAndNewlines) - if !path.isEmpty { emitPaneCwd(paneId, path) } + if !path.isEmpty { observers.emitPaneCwd(paneId, path) } } case let .commandResult(_, lines, isError): handleCommandResult(lines: lines, isError: isError) @@ -829,7 +777,7 @@ final class RemoteTmuxControlConnection { let livePanes = Set(next.values.flatMap { $0.paneIDsInOrder }) paneOutputByteCounts = paneOutputByteCounts.filter { livePanes.contains($0.key) } windowOrder = order - notifyTopologyChanged() + observers.notifyTopologyChanged() // Now that the attach block is drained and the topology is fresh, run // the deferred reconnect re-seed (re-capture each pane). Queuing the // capture commands here keeps the result FIFO aligned. @@ -851,7 +799,7 @@ final class RemoteTmuxControlConnection { // the visible screen. let painted = "\u{1b}[H\u{1b}[2J" + lines.joined(separator: "\r\n") if let data = painted.data(using: .utf8) { - emitPaneOutput(paneId, data) + observers.emitPaneOutput(paneId, data) } case let .paneState(paneId): // Restore the pane's terminal state (scroll region + DEC modes + cursor) @@ -859,11 +807,11 @@ final class RemoteTmuxControlConnection { // region (DECSTBM) is the important one: without it an inline TUI's // region-relative redraws land on the wrong rows even at a static size. if let line = lines.first { - emitPaneOutput(paneId, Self.paneStateSeedSequence(from: line)) + observers.emitPaneOutput(paneId, decoding.paneStateSeedSequence(from: line)) } case let .panePath(paneId): if let path = lines.first?.trimmingCharacters(in: .whitespaces), !path.isEmpty { - emitPaneCwd(paneId, path) + observers.emitPaneCwd(paneId, path) } case let .paneAltScreen(paneId): // Enter the alternate screen on the mirror surface so it matches the @@ -872,89 +820,13 @@ final class RemoteTmuxControlConnection { // screen. A pane on the primary screen needs no toggle (the surface // defaults to primary, and a later live `%output` 1049l would leave alt). if lines.first?.trimmingCharacters(in: .whitespaces) == "1" { - emitPaneOutput(paneId, Self.altScreenEnterSequence) + observers.emitPaneOutput(paneId, Self.altScreenEnterSequence) } case .other: break } } - /// Builds the escape sequence that restores a pane's terminal state onto the - /// mirror surface, from a `display-message` `key=value,…` line. Sets the scroll - /// region (DECSTBM), the DEC private modes (wrap/cursor/insert/app-cursor-keys/ - /// keypad), mouse tracking, origin mode, and finally the cursor position. - /// - /// The cursor placement is emitted LAST on purpose: setting the scroll region - /// (DECSTBM) and changing origin mode (DECOM) each move the cursor to the home - /// position, so any earlier cursor placement would be lost. When origin mode is - /// on with a restricted region, tmux's absolute cursor row is translated to the - /// region-relative row the (origin-relative) CUP then expects. - nonisolated static func paneStateSeedSequence(from line: String) -> Data { - var fields: [String: String] = [:] - for pair in line.split(separator: ",") { - let kv = pair.split(separator: "=", maxSplits: 1) - if kv.count == 2 { fields[String(kv[0])] = String(kv[1]) } - } - let on: (String) -> Bool = { fields[$0] == "1" } - // Clamp to a plausible terminal-dimension range: the values come from an - // untrusted remote, and a crafted `Int.min`/`Int.max` would trap the later - // `+ 1` / `- 1` arithmetic (Swift overflow is a hard crash). Out-of-range or - // non-numeric values are treated as absent. - let num: (String) -> Int? = { fields[$0].flatMap { Int($0) }.flatMap { (0...65535).contains($0) ? $0 : nil } } - - var seq = "" - // Scroll region (DECSTBM) — tmux reports 0-based, DECSTBM is 1-based. Only - // seed a RESTRICTED region: a full-window region (upper 0, lower height-1) - // is the surface's default already, and pinning it to the capture-time row - // count would go stale across a later resize (the surface, left at default, - // tracks resizes on its own). A restricted region is re-asserted by the - // remote app on its next redraw, so a transiently stale one self-heals. - let regionUpper = num("scroll_region_upper") - var restrictedRegion = false - if let upper = regionUpper, let lower = num("scroll_region_lower"), lower >= upper { - let isFullWindow = upper == 0 && (num("pane_height").map { lower == $0 - 1 } ?? false) - if !isFullWindow { - seq += "\u{1b}[\(upper + 1);\(lower + 1)r" - restrictedRegion = true - } - } - seq += on("wrap_flag") ? "\u{1b}[?7h" : "\u{1b}[?7l" // DECAWM - seq += on("cursor_flag") ? "\u{1b}[?25h" : "\u{1b}[?25l" // DECTCEM (cursor visible) - seq += on("insert_flag") ? "\u{1b}[4h" : "\u{1b}[4l" // IRM - seq += on("keypad_cursor_flag") ? "\u{1b}[?1h" : "\u{1b}[?1l" // DECCKM (app cursor keys) - seq += on("keypad_flag") ? "\u{1b}=" : "\u{1b}>" // DECKPAM / DECKPNM - // Mouse: enable the active tracking mode + encoding so clicks/scroll/drag in - // the mirror reach the remote app (the surface defaults to off). The - // tmux-flag → xterm DECSET mapping below was verified empirically against - // tmux 3.6a (set the DECSET in a pane, read the flags back): - // ?1000h → mouse_standard_flag, ?1002h → mouse_button_flag, - // ?1003h → mouse_all_flag, and mouse_any_flag is set for ALL of them. - // So enable the most aggressive concrete flag that is on, plus the encoding - // (SGR/1006 preferred over UTF-8/1005). `mouse_any_flag` is deliberately NOT - // used: it is tmux's aggregate "any mouse mode on" OR-flag, not a concrete - // level. (NOTE: ghostty's vendored tmux viewer uses a different, one-slot- - // shifted mapping — do not "align" to it; the above matches real tmux.) - if on("mouse_all_flag") { seq += "\u{1b}[?1003h" } - else if on("mouse_button_flag") { seq += "\u{1b}[?1002h" } - else if on("mouse_standard_flag") { seq += "\u{1b}[?1000h" } - if on("mouse_sgr_flag") { seq += "\u{1b}[?1006h" } - else if on("mouse_utf8_flag") { seq += "\u{1b}[?1005h" } - // (Bracketed-paste mode is intentionally not seeded: tmux exposes no - // reliable pane format for it, and paste fidelity is handled by tmux's own - // `paste-buffer -p` in ``pastePane(paneId:text:)``.) - // Origin mode (DECOM) before the cursor — changing it homes the cursor. - let originOn = on("origin_flag") - seq += originOn ? "\u{1b}[?6h" : "\u{1b}[?6l" - // Cursor LAST. tmux reports an absolute row; with origin mode on and a - // restricted region the CUP is interpreted region-relative, so subtract the - // region top. - if let cx = num("cursor_x"), let cy = num("cursor_y") { - let row = (originOn && restrictedRegion) ? max(0, cy - (regionUpper ?? 0)) : cy - seq += "\u{1b}[\(row + 1);\(cx + 1)H" - } - return Data(seq.utf8) - } - private func applyLayout(windowId: Int, layout: String) { guard let node = RemoteTmuxRawLayoutParser.parse(layout) else { return } // Preserve any name tmux already reported (a %layout-change carries no name). @@ -966,10 +838,7 @@ final class RemoteTmuxControlConnection { } private func record(_ event: String) { - recentEvents.append(event) - if recentEvents.count > maxRecentEvents { - recentEvents.removeFirst(recentEvents.count - maxRecentEvents) - } + diagnostics.record(event) } /// An immutable, `Sendable` snapshot for diagnostics (`remote.tmux.state`). @@ -983,7 +852,7 @@ final class RemoteTmuxControlConnection { windowIDs: windowOrder, paneOutputByteCounts: paneOutputByteCounts, totalOutputBytes: totalOutputBytes, - recentEvents: recentEvents + recentEvents: diagnostics.events ) } diff --git a/Sources/RemoteTmuxControlMessageDecoding.swift b/Sources/RemoteTmuxControlMessageDecoding.swift new file mode 100644 index 000000000000..89bb1fa5fab8 --- /dev/null +++ b/Sources/RemoteTmuxControlMessageDecoding.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Stateless pure decoders for `tmux -CC` control-mode message payloads. +/// +/// These transform untrusted remote-tmux text (a `display-message` `key=value,…` +/// line, a captured stderr string, an optimistic window reorder) into the values +/// ``RemoteTmuxControlConnection`` applies to the mirror. They hold no state and +/// touch no actor isolation, so they are `nonisolated` and safe to call from any +/// context; ``RemoteTmuxControlConnection`` owns an instance and routes its +/// internal call sites through it. +struct RemoteTmuxControlMessageDecoding { + /// Builds the escape sequence that restores a pane's terminal state onto the + /// mirror surface, from a `display-message` `key=value,…` line. Sets the scroll + /// region (DECSTBM), the DEC private modes (wrap/cursor/insert/app-cursor-keys/ + /// keypad), mouse tracking, origin mode, and finally the cursor position. + /// + /// The cursor placement is emitted LAST on purpose: setting the scroll region + /// (DECSTBM) and changing origin mode (DECOM) each move the cursor to the home + /// position, so any earlier cursor placement would be lost. When origin mode is + /// on with a restricted region, tmux's absolute cursor row is translated to the + /// region-relative row the (origin-relative) CUP then expects. + nonisolated func paneStateSeedSequence(from line: String) -> Data { + var fields: [String: String] = [:] + for pair in line.split(separator: ",") { + let kv = pair.split(separator: "=", maxSplits: 1) + if kv.count == 2 { fields[String(kv[0])] = String(kv[1]) } + } + let on: (String) -> Bool = { fields[$0] == "1" } + // Clamp to a plausible terminal-dimension range: the values come from an + // untrusted remote, and a crafted `Int.min`/`Int.max` would trap the later + // `+ 1` / `- 1` arithmetic (Swift overflow is a hard crash). Out-of-range or + // non-numeric values are treated as absent. + let num: (String) -> Int? = { fields[$0].flatMap { Int($0) }.flatMap { (0...65535).contains($0) ? $0 : nil } } + + var seq = "" + // Scroll region (DECSTBM) — tmux reports 0-based, DECSTBM is 1-based. Only + // seed a RESTRICTED region: a full-window region (upper 0, lower height-1) + // is the surface's default already, and pinning it to the capture-time row + // count would go stale across a later resize (the surface, left at default, + // tracks resizes on its own). A restricted region is re-asserted by the + // remote app on its next redraw, so a transiently stale one self-heals. + let regionUpper = num("scroll_region_upper") + var restrictedRegion = false + if let upper = regionUpper, let lower = num("scroll_region_lower"), lower >= upper { + let isFullWindow = upper == 0 && (num("pane_height").map { lower == $0 - 1 } ?? false) + if !isFullWindow { + seq += "\u{1b}[\(upper + 1);\(lower + 1)r" + restrictedRegion = true + } + } + seq += on("wrap_flag") ? "\u{1b}[?7h" : "\u{1b}[?7l" // DECAWM + seq += on("cursor_flag") ? "\u{1b}[?25h" : "\u{1b}[?25l" // DECTCEM (cursor visible) + seq += on("insert_flag") ? "\u{1b}[4h" : "\u{1b}[4l" // IRM + seq += on("keypad_cursor_flag") ? "\u{1b}[?1h" : "\u{1b}[?1l" // DECCKM (app cursor keys) + seq += on("keypad_flag") ? "\u{1b}=" : "\u{1b}>" // DECKPAM / DECKPNM + // Mouse: enable the active tracking mode + encoding so clicks/scroll/drag in + // the mirror reach the remote app (the surface defaults to off). The + // tmux-flag → xterm DECSET mapping below was verified empirically against + // tmux 3.6a (set the DECSET in a pane, read the flags back): + // ?1000h → mouse_standard_flag, ?1002h → mouse_button_flag, + // ?1003h → mouse_all_flag, and mouse_any_flag is set for ALL of them. + // So enable the most aggressive concrete flag that is on, plus the encoding + // (SGR/1006 preferred over UTF-8/1005). `mouse_any_flag` is deliberately NOT + // used: it is tmux's aggregate "any mouse mode on" OR-flag, not a concrete + // level. (NOTE: ghostty's vendored tmux viewer uses a different, one-slot- + // shifted mapping — do not "align" to it; the above matches real tmux.) + if on("mouse_all_flag") { seq += "\u{1b}[?1003h" } + else if on("mouse_button_flag") { seq += "\u{1b}[?1002h" } + else if on("mouse_standard_flag") { seq += "\u{1b}[?1000h" } + if on("mouse_sgr_flag") { seq += "\u{1b}[?1006h" } + else if on("mouse_utf8_flag") { seq += "\u{1b}[?1005h" } + // (Bracketed-paste mode is intentionally not seeded: tmux exposes no + // reliable pane format for it, and paste fidelity is handled by tmux's own + // `paste-buffer -p` in ``pastePane(paneId:text:)``.) + // Origin mode (DECOM) before the cursor — changing it homes the cursor. + let originOn = on("origin_flag") + seq += originOn ? "\u{1b}[?6h" : "\u{1b}[?6l" + // Cursor LAST. tmux reports an absolute row; with origin mode on and a + // restricted region the CUP is interpreted region-relative, so subtract the + // region top. + if let cx = num("cursor_x"), let cy = num("cursor_y") { + let row = (originOn && restrictedRegion) ? max(0, cy - (regionUpper ?? 0)) : cy + seq += "\u{1b}[\(row + 1);\(cx + 1)H" + } + return Data(seq.utf8) + } + + /// Returns `order` with the windows in `reordered` rearranged into + /// `reordered`'s sequence, leaving windows not in that set in their positions. + nonisolated func windowOrder(_ order: [Int], applyingReorder reordered: [Int]) -> [Int] { + let set = Set(reordered) + var iterator = reordered.makeIterator() + return order.map { set.contains($0) ? (iterator.next() ?? $0) : $0 } + } + + /// Whether captured ssh/tmux stderr indicates the session/server is genuinely + /// gone (reconnect should stop and end) vs a transient transport failure (host + /// unreachable / connection refused — keep retrying). + nonisolated func stderrIndicatesSessionGone(_ stderr: String) -> Bool { + let lowered = stderr.lowercased() + return lowered.contains("can't find session") + || lowered.contains("can\u{2019}t find session") + || lowered.contains("no server running") + || lowered.contains("no current session") + || lowered.contains("session not found") + || lowered.contains("lost server") + } +} diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index bfd91696180e..2221bcccf42e 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -14,7 +14,9 @@ import CmuxSettings /// be reached from the v2 socket dispatcher via `AppDelegate.shared`. @MainActor final class RemoteTmuxController { - private var transports: [String: RemoteTmuxSSHTransport] = [:] + /// Per-endpoint SSH transports (keyed by ``RemoteTmuxHost/connectionHash``), + /// owned by ``RemoteTmuxController`` and delegated to for discovery + master teardown. + private let transportRegistry = RemoteTmuxTransportRegistry() /// Live `tmux -CC` control connections keyed by `connectionHash\u{1}session` /// (see ``connectionKey(host:sessionName:)``), so repeated attach requests for @@ -35,12 +37,7 @@ final class RemoteTmuxController { /// Returns (creating if needed) the transport for a host. func transport(for host: RemoteTmuxHost) -> RemoteTmuxSSHTransport { - if let existing = transports[host.connectionHash] { - return existing - } - let transport = RemoteTmuxSSHTransport(host: host) - transports[host.connectionHash] = transport - return transport + transportRegistry.transport(for: host) } /// Discovers the tmux sessions on a host. @@ -50,8 +47,7 @@ final class RemoteTmuxController { /// Tears down a host's shared SSH master (used when removing a host). func disconnect(host: RemoteTmuxHost) async { - let transport = transports.removeValue(forKey: host.connectionHash) - await transport?.shutdownMaster() + await transportRegistry.disconnectMaster(host: host) } // MARK: - Control connections (tmux -CC mirroring) @@ -92,22 +88,16 @@ final class RemoteTmuxController { /// (see ``connectionKey(host:sessionName:)``). private var sessionMirrors: [String: RemoteTmuxSessionMirror] = [:] - /// ``RemoteTmuxHost/connectionHash`` → the dedicated cmux window mirroring that - /// endpoint (Option 1). - private var windowIdByHost: [String: UUID] = [:] - /// Reverse map: cmux window id → the full host it mirrors (for window-close - /// detach and new-session-in-window, which need the endpoint's port/identity). - private var hostByWindowId: [UUID: RemoteTmuxHost] = [:] - /// Endpoint ``RemoteTmuxHost/connectionHash`` values with an in-flight - /// ``mirrorHostInNewWindow(host:activateWindow:)``, so a re-entrant call across - /// the `await` gap can't open a second window for the same endpoint. - private var pendingHostAttaches: Set<String> = [] + /// Dedicated-window bindings (host↔window) and the in-flight-attach guard for + /// the "one cmux window per remote endpoint" mirror mode (Option 1), owned by + /// ``RemoteTmuxController`` and delegated to. + private let windowRegistry = RemoteTmuxWindowRegistry() /// Returns `true` if `windowId` is a dedicated remote-tmux mirror window. /// Used by the session-snapshot path to exclude these windows: a mirror window /// needs a live SSH connection and can't be restored from a generic snapshot. func isDedicatedRemoteWindow(_ windowId: UUID) -> Bool { - hostByWindowId[windowId] != nil + windowRegistry.isDedicatedWindow(windowId) } /// Opens a NEW cmux window dedicated to `host` and mirrors every tmux session @@ -140,18 +130,17 @@ final class RemoteTmuxController { throw RemoteTmuxError.unreachable("app not ready") } // Reuse the dedicated window if this host is already mirrored. - if let existing = windowIdByHost[host.connectionHash], + if let existing = windowRegistry.windowId(forHostHash: host.connectionHash), let window = appDelegate.windowForMainWindowId(existing) { if activateWindow { window.makeKeyAndOrderFront(nil) } return .mirrored(windowId: existing) } // Guard the await gap: a second concurrent attach for the same host must // not open a second window. - guard !pendingHostAttaches.contains(host.connectionHash) else { + guard windowRegistry.beginAttach(hostHash: host.connectionHash) else { throw RemoteTmuxError.unreachable("already attaching \(host.destination)") } - pendingHostAttaches.insert(host.connectionHash) - defer { pendingHostAttaches.remove(host.connectionHash) } + defer { windowRegistry.endAttach(hostHash: host.connectionHash) } // Discover the host's sessions over the shared ControlMaster (BatchMode, no // prompt). A key/agent host — or one with an already-live master — succeeds @@ -186,7 +175,7 @@ final class RemoteTmuxController { throw RemoteTmuxError.unreachable("no tmux sessions on \(host.destination)") } // Re-check reuse: a concurrent caller may have finished while we awaited. - if let existing = windowIdByHost[host.connectionHash], + if let existing = windowRegistry.windowId(forHostHash: host.connectionHash), let window = appDelegate.windowForMainWindowId(existing) { if activateWindow { window.makeKeyAndOrderFront(nil) } return .mirrored(windowId: existing) @@ -203,8 +192,7 @@ final class RemoteTmuxController { guard let manager = appDelegate.tabManagerFor(windowId: windowId) else { throw RemoteTmuxError.unreachable("could not create window") } - windowIdByHost[host.connectionHash] = windowId - hostByWindowId[windowId] = host + windowRegistry.bind(host: host, windowId: windowId) let bootstrapWorkspaceId = manager.tabs.first?.id for session in sessions { @@ -223,9 +211,8 @@ final class RemoteTmuxController { // it instead of a false success. (Bindings are cleared before the window // close so its onClose handleRemoteWindowClosed is a no-op — no double exit.) guard sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) else { - windowIdByHost.removeValue(forKey: host.connectionHash) - hostByWindowId.removeValue(forKey: windowId) - transports.removeValue(forKey: host.connectionHash) + windowRegistry.unbind(hostHash: host.connectionHash) + transportRegistry.remove(connectionHash: host.connectionHash) RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) appDelegate.discardMainWindowWithoutClosedHistory(windowId: windowId) throw RemoteTmuxError.unreachable("could not mirror any tmux session on \(host.destination)") @@ -466,16 +453,25 @@ final class RemoteTmuxController { return true } - /// A new workspace was requested while a dedicated remote window was active → - /// create a new tmux session on that host and mirror it into the same window. + /// Creates a new tmux session on a dedicated remote window's host (and mirrors it + /// into that window) when a new workspace is requested while a mirror tab is active. + /// The single source of truth for the remote-vs-local decision, so every + /// `performNewWorkspaceAction` entrypoint (double-tap, ⌘N, titlebar +, palette) is + /// consistent. /// - /// - Returns: `true` if `windowId` is a dedicated remote window (the caller - /// suppresses the local workspace creation); `false` otherwise. + /// - Returns: `true` only when `windowId` is dedicated AND its active workspace is a + /// mirror (caller suppresses local creation); `false` otherwise — e.g. a dedicated + /// window whose active tab is a dragged-in local one, so the caller goes local. func handleRemoteWindowNewWorkspaceRequested(windowId: UUID) -> Bool { - // `hostByWindowId` stores the full host (destination + port + identity), so + // The registry stores the full host (destination + port + identity), so // the new session reuses the exact connection details of the window's host. - guard let host = hostByWindowId[windowId] else { return false } + guard let host = windowRegistry.host(forWindowId: windowId) else { return false } guard let manager = AppDelegate.shared?.tabManagerFor(windowId: windowId) else { return true } + // Gate on the ACTIVE workspace, not just the window: a dedicated window can + // be polluted with a dragged-in local workspace (move targets don't exclude + // dedicated windows), and a new workspace requested while that local tab is + // active must stay local instead of spawning an unwanted tmux session. + guard manager.selectedTab?.isRemoteTmuxMirror == true else { return false } Task { @MainActor in do { // Create a detached session and read back its (auto-assigned) name. @@ -507,21 +503,17 @@ final class RemoteTmuxController { case closeDedicatedWindow(UUID) } - /// Decides how a remote session-end is reflected in the UI. + /// Decides how a remote session-end is reflected: close just the dead workspace, + /// or the whole dedicated window when it lost its last session. /// /// - Parameters: - /// - dedicatedWindowId: the host's dedicated mirror window, or `nil` when the - /// host still has other live mirror sessions or was mirrored into a shared - /// window (the socket `remote.tmux.mirror` path). - /// - dedicatedWindowOwnedByEndingHost: whether every workspace in that window - /// belongs to the host whose session just ended (the dead workspace itself, - /// or another live mirror for the same host). `false` when the user has moved - /// a local workspace — or another host's mirror — into the dedicated window; - /// then closing the whole window would discard unrelated work, so only the - /// dead workspace is closed. - /// - otherMainWindowCount: how many OTHER main windows are open. The dedicated - /// window is only closed when at least one other window remains, so a - /// disconnect never leaves the user with zero windows. + /// - dedicatedWindowId: the host's dedicated mirror window, or `nil` if the host + /// still has other live sessions / was mirrored into a shared window. + /// - dedicatedWindowOwnedByEndingHost: `true` only if every workspace in that + /// window belongs to the ending host (else a moved-in local/other-host + /// workspace would be discarded, so only the dead workspace closes). + /// - otherMainWindowCount: OTHER open main windows; the dedicated window closes + /// only when ≥1 remains, so a disconnect never leaves zero windows. /// - Returns: the action to apply. nonisolated static func sessionEndAction( dedicatedWindowId: UUID?, @@ -554,7 +546,7 @@ final class RemoteTmuxController { // The dedicated window for this host, captured before the bindings are torn // down. `nil` once other sessions remain — losing one of several sessions // closes only its workspace, never the shared window. - let dedicatedWindowId = hostHasOtherMirrors ? nil : windowIdByHost[host.connectionHash] + let dedicatedWindowId = hostHasOtherMirrors ? nil : windowRegistry.windowId(forHostHash: host.connectionHash) // Decide the UI action BEFORE tearing down persistence/bindings, so the // persistence decision can depend on whether the dedicated window is // actually closing. @@ -600,16 +592,14 @@ final class RemoteTmuxController { let hostHasOtherConnections = connectionsByHostSession.values .contains { $0.host.connectionHash == host.connectionHash } if !hostHasOtherConnections { - transports.removeValue(forKey: host.connectionHash) + transportRegistry.remove(connectionHash: host.connectionHash) RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } // Drop the dedicated-window binding (the window is either closing, or // converting to a plain local window — either way it is no longer a // remote mirror). Done before the switch so the window's onClose hook's // handleRemoteWindowClosed finds the binding gone and is a no-op. - if let windowId = windowIdByHost.removeValue(forKey: host.connectionHash) { - hostByWindowId.removeValue(forKey: windowId) - } + windowRegistry.unbind(hostHash: host.connectionHash) } #if DEBUG cmuxDebugLog( @@ -670,20 +660,52 @@ final class RemoteTmuxController { let stillUsed = sessionMirrors.values.contains { $0.host.connectionHash == hash } || connectionsByHostSession.values.contains { $0.host.connectionHash == hash } if !stillUsed { - transports.removeValue(forKey: hash) + transportRegistry.remove(connectionHash: hash) RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } } } - /// Handles close of a dedicated remote window (Option 1): detaches every - /// control connection for that host so the ssh clients shut down, but does - /// NOT kill any remote session — closing the window only detaches, leaving - /// the remote tmux server alive for resume on the next launch. + /// Marks a window's impending close as a tab/session close (kill on commit, not detach). + func markKillSessionsOnWindowClose(windowId: UUID) { windowRegistry.markKillSessionsOnClose(windowId: windowId) } + + /// Consumes a window's kill-on-close marker; `true` when the committed close should + /// kill its remote session(s). Also clears it on a close veto. + @discardableResult + func consumeKillSessionsOnWindowClose(windowId: UUID) -> Bool { windowRegistry.consumeKillSessionsOnClose(windowId: windowId) } + + /// Window ids marked for kill-on-close — the app-quit deferral gate in `AppDelegate`. + func windowsMarkedForKillOnClose() -> [UUID] { windowRegistry.windowsMarkedForKillOnClose() } + + /// App-quit path for a tab/session close of a remote window's LAST tab: tears down + /// each marked window's mirror sessions on the MainActor, then AWAITS killing them + /// (bounded by `timeout`) so the session is gone before cmux exits. No + /// `spawnControlMasterExit` — the kill multiplexes over the live master (ControlPersist reaps it). + func killMarkedSessionsBeforeTerminate(timeout: Duration = .seconds(3)) async { + var jobs: [(transport: RemoteTmuxSSHTransport, target: String)] = [] + for windowId in windowRegistry.windowsMarkedForKillOnClose() { + guard windowRegistry.consumeKillSessionsOnClose(windowId: windowId), + let host = windowRegistry.host(forWindowId: windowId) else { continue } + let transport = transport(for: host) + // Snapshot (filter) so removeValue doesn't mutate the iterated collection. + for (key, mirror) in sessionMirrors.filter({ $0.value.host.connectionHash == host.connectionHash }) { + sessionMirrors.removeValue(forKey: key) + mirror.detachObserver() + detach(host: host, sessionName: mirror.sessionName) // removes the connection too + jobs.append((transport, mirror.connection.sessionId.map { "$\($0)" } ?? mirror.sessionName)) + } + windowRegistry.unbind(hostHash: host.connectionHash) + transportRegistry.remove(connectionHash: host.connectionHash) + } + await RemoteTmuxSSHTransport.killSessions(jobs, timeout: timeout) + } + + /// Handles close of a dedicated remote window (Option 1): detaches every control + /// connection for that host (ssh clients shut down) but does NOT kill any remote + /// session — a window close only detaches, leaving the tmux server alive for resume. func handleRemoteWindowClosed(windowId: UUID) { - guard let host = hostByWindowId[windowId] else { return } - hostByWindowId.removeValue(forKey: windowId) - windowIdByHost.removeValue(forKey: host.connectionHash) + guard let host = windowRegistry.host(forWindowId: windowId) else { return } + windowRegistry.unbind(windowId: windowId) for (key, mirror) in sessionMirrors where mirror.host.connectionHash == host.connectionHash { mirror.detachObserver() sessionMirrors.removeValue(forKey: key) @@ -692,16 +714,16 @@ final class RemoteTmuxController { connection.stop() connectionsByHostSession.removeValue(forKey: key) } - // Close the shared SSH ControlMaster the CLI's `ssh -f` left running, so - // closing the dedicated window also closes the ssh connection instead of - // leaving it lingering for ControlPersist. Fire-and-forget so it's reliable - // even when this is the last window (→ app quit). - transports.removeValue(forKey: host.connectionHash) + // Close the shared SSH ControlMaster the CLI's `ssh -f` left running (fire- + // and-forget, reliable even when this is the last window → app quit). + transportRegistry.remove(connectionHash: host.connectionHash) RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } - /// Handles user-initiated close of a mirrored session workspace: detaches - /// the control connection and kills the session on the remote. + /// Handles user-initiated close of a mirrored session workspace: detaches the + /// control connection and kills the session on the remote. (The app-quit path + /// uses ``killMarkedSessionsBeforeTerminate(timeout:)`` instead, which awaits the + /// kill so it lands before cmux exits.) func handleWorkspaceClosed(workspaceId: UUID) { guard let entry = sessionMirrors.first(where: { $0.value.mirroredWorkspaceId == workspaceId }) else { return } @@ -711,19 +733,12 @@ final class RemoteTmuxController { sessionMirrors.removeValue(forKey: entry.key) mirror.detachObserver() detach(host: host, sessionName: sessionName) - // If this was the host's last mirrored session, drop its dedicated-window - // binding and tear down the shared SSH ControlMaster. The remote-end path - // (handleSessionEndedRemotely) and the window-close path - // (handleRemoteWindowClosed) both close the master when a host loses its last - // mirror; a user-initiated workspace close must do the same or the master - // lingers for the full ControlPersist window. Clearing the window binding here - // makes the window's onClose handleRemoteWindowClosed a no-op, so this path has - // to own the teardown. + // Last mirrored session for this host: drop the dedicated-window binding (so + // the window's onClose handleRemoteWindowClosed becomes a no-op) and tear down + // the shared SSH ControlMaster, matching the remote-end and window-close paths. let isLastSession = !sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) if isLastSession { - if let windowId = windowIdByHost.removeValue(forKey: host.connectionHash) { - hostByWindowId.removeValue(forKey: windowId) - } + windowRegistry.unbind(hostHash: host.connectionHash) } // Kill by the stable session id when known, so a prior rename-session // can't leave us targeting a stale name. @@ -732,19 +747,17 @@ final class RemoteTmuxController { if isLastSession { // Drop the transport so a later re-attach builds a fresh one instead of // reusing this soon-to-be-dead master. - transports.removeValue(forKey: host.connectionHash) + transportRegistry.remove(connectionHash: host.connectionHash) } Task { _ = try? await transport.runTmux(["kill-session", "-t", killTarget]) // Close the master only after kill-session has used it; `ssh -O exit` // first would tear the connection down before the session dies. if isLastSession { - // …and only if no reattach reclaimed this endpoint during the - // kill-session round-trip: a concurrent `cmux ssh-tmux` to the same - // host builds a fresh transport/connection on the same ControlPath, - // and exiting the master here would drop that new mirror. (This Task - // is @MainActor, so the check + exit is atomic w.r.t. a reattach.) - let reclaimed = transports[host.connectionHash] != nil + // …and only if no reattach reclaimed this endpoint during the kill + // round-trip (a concurrent `cmux ssh-tmux` rebuilds on the same + // ControlPath); this Task is @MainActor so check + exit is atomic. + let reclaimed = transportRegistry.contains(connectionHash: host.connectionHash) || sessionMirrors.values.contains { $0.host.connectionHash == host.connectionHash } || connectionsByHostSession.values.contains { $0.host.connectionHash == host.connectionHash } if !reclaimed { @@ -784,8 +797,8 @@ final class RemoteTmuxController { // deduped by connectionHash. var hostsByHash: [String: RemoteTmuxHost] = [:] for connection in connections { hostsByHash[connection.host.connectionHash] = connection.host } - for transport in transports.values { hostsByHash[transport.host.connectionHash] = transport.host } - transports.removeAll() + for host in transportRegistry.allHosts() { hostsByHash[host.connectionHash] = host } + transportRegistry.removeAll() for host in hostsByHash.values { RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } } diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index 806bf03d0f34..c8d709b7d9f9 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -110,6 +110,38 @@ actor RemoteTmuxSSHTransport { try? process.run() // fire-and-forget — do not wait } + /// Kills each `(transport, sessionTarget)` via `tmux kill-session`. Races the kill + /// round-trips against a single `Task.sleep(timeout)` and returns at the first to + /// finish (`group.next()` then `cancelAll()`) — so on a RESPONSIVE connection this + /// returns as soon as the kills land (well under `timeout`). Kills to the SAME host + /// serialize on that host's transport actor; different hosts run in parallel. + /// + /// CAVEAT: `runProcess` is not cancellation-aware, so on a HUNG connection the + /// abandoned kill child can outlive `timeout` (the structured group still awaits + /// it). The hard bound on the user-visible app-quit is therefore the CALLER's + /// watchdog (``AppDelegate``'s deferred-terminate reply fires regardless), not this + /// `timeout`. The orphaned `ssh` is reaped by the OS on app exit; the kill is + /// best-effort (it can't land on a dead connection anyway). + nonisolated static func killSessions( + _ jobs: [(transport: RemoteTmuxSSHTransport, target: String)], + timeout: Duration + ) async { + guard !jobs.isEmpty else { return } + await withTaskGroup(of: Void.self) { group in + group.addTask { + await withTaskGroup(of: Void.self) { kills in + for job in jobs { + kills.addTask { _ = try? await job.transport.runTmux(["kill-session", "-t", job.target]) } + } + await kills.waitForAll() + } + } + group.addTask { try? await Task.sleep(for: timeout) } + await group.next() + group.cancelAll() + } + } + // MARK: - Heuristics /// Whether stderr indicates the remote tmux server simply isn't running. diff --git a/Sources/RemoteTmuxTransportRegistry.swift b/Sources/RemoteTmuxTransportRegistry.swift new file mode 100644 index 000000000000..f14f2f9acc4e --- /dev/null +++ b/Sources/RemoteTmuxTransportRegistry.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Owns the per-endpoint ``RemoteTmuxSSHTransport`` instances ``RemoteTmuxController`` +/// uses for SSH discovery, keyed by ``RemoteTmuxHost/connectionHash`` (destination + +/// port + identity). +/// +/// Factored out of the controller so the get-or-create lifecycle and the scattered +/// dictionary bookkeeping live behind a small `@MainActor` surface. It only manages +/// the transport handles; it deliberately does NOT own the `ssh -O exit` +/// (``RemoteTmuxSSHTransport/spawnControlMasterExit(host:)``) teardown, which the +/// controller sequences around its own `await` gaps. +@MainActor +final class RemoteTmuxTransportRegistry { + private var transports: [String: RemoteTmuxSSHTransport] = [:] + + /// Returns (creating if needed) the transport for a host. + func transport(for host: RemoteTmuxHost) -> RemoteTmuxSSHTransport { + if let existing = transports[host.connectionHash] { + return existing + } + let transport = RemoteTmuxSSHTransport(host: host) + transports[host.connectionHash] = transport + return transport + } + + /// Tears down a host's shared SSH master (used when removing a host). + func disconnectMaster(host: RemoteTmuxHost) async { + let transport = transports.removeValue(forKey: host.connectionHash) + await transport?.shutdownMaster() + } + + /// Whether a transport already exists for `connectionHash` (the reattach-reclaim check). + func contains(connectionHash: String) -> Bool { + transports[connectionHash] != nil + } + + /// Removes and returns the transport for `connectionHash`, if any. + @discardableResult + func remove(connectionHash: String) -> RemoteTmuxSSHTransport? { + transports.removeValue(forKey: connectionHash) + } + + /// The hosts of every currently-tracked transport. + func allHosts() -> [RemoteTmuxHost] { + transports.values.map(\.host) + } + + /// Drops every tracked transport (does not exit their masters). + func removeAll() { + transports.removeAll() + } +} diff --git a/Sources/RemoteTmuxWindowRegistry.swift b/Sources/RemoteTmuxWindowRegistry.swift new file mode 100644 index 000000000000..1fcab3aee6a2 --- /dev/null +++ b/Sources/RemoteTmuxWindowRegistry.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Owns the dedicated-window bookkeeping ``RemoteTmuxController`` uses for the +/// "one cmux window per remote endpoint" mirror mode (Option 1): the host↔window +/// bindings and the in-flight-attach guard set. +/// +/// Factored out of the controller so the two-way binding (and its always-paired +/// insert/remove) plus the re-entrant-attach guard live behind one small +/// `@MainActor` surface. ``beginAttach(hostHash:)`` is a synchronous +/// check-and-insert so callers can guard an `await` gap without an extra +/// suspension point. +@MainActor +final class RemoteTmuxWindowRegistry { + /// ``RemoteTmuxHost/connectionHash`` → the dedicated cmux window mirroring that + /// endpoint (Option 1). + private var windowIdByHost: [String: UUID] = [:] + /// Reverse map: cmux window id → the full host it mirrors (for window-close + /// detach and new-session-in-window, which need the endpoint's port/identity). + private var hostByWindowId: [UUID: RemoteTmuxHost] = [:] + /// Endpoint ``RemoteTmuxHost/connectionHash`` values with an in-flight + /// `mirrorHostInNewWindow(host:activateWindow:)`, so a re-entrant call across + /// the `await` gap can't open a second window for the same endpoint. + private var pendingAttaches: Set<String> = [] + /// Window ids whose pending close was initiated by an explicit close of the + /// window's LAST remote workspace (a tab/session close), so the close-commit + /// handler kills the remote session(s) instead of merely detaching. A plain + /// app-window/quit close never sets this, so it detaches. Set just before + /// `performClose`, consumed on the (non-vetoed) close commit, and cleared if + /// the close is vetoed. + private var killSessionsOnClose: Set<UUID> = [] + + /// Returns `true` if `windowId` is a dedicated remote-tmux mirror window. + /// Used by the session-snapshot path to exclude these windows: a mirror window + /// needs a live SSH connection and can't be restored from a generic snapshot. + func isDedicatedWindow(_ windowId: UUID) -> Bool { + hostByWindowId[windowId] != nil + } + + /// Binds `host` to its dedicated `windowId` (both directions). + func bind(host: RemoteTmuxHost, windowId: UUID) { + windowIdByHost[host.connectionHash] = windowId + hostByWindowId[windowId] = host + } + + /// The dedicated window currently bound to `hostHash`, if any (the reuse check). + func windowId(forHostHash hostHash: String) -> UUID? { + windowIdByHost[hostHash] + } + + /// The full host bound to `windowId`, if any (carries port/identity). + func host(forWindowId windowId: UUID) -> RemoteTmuxHost? { + hostByWindowId[windowId] + } + + /// Atomically records an in-flight attach for `hostHash`; returns `false` if one + /// is already in flight (the re-entrant-attach guard). Synchronous and + /// non-suspending so it can guard an `await` gap. + func beginAttach(hostHash: String) -> Bool { + guard !pendingAttaches.contains(hostHash) else { return false } + pendingAttaches.insert(hostHash) + return true + } + + /// Clears the in-flight-attach marker for `hostHash` (the `defer`). + func endAttach(hostHash: String) { + pendingAttaches.remove(hostHash) + } + + /// Removes the binding for `hostHash` in BOTH directions, returning the window id + /// that was bound (if any). + @discardableResult + func unbind(hostHash: String) -> UUID? { + guard let windowId = windowIdByHost.removeValue(forKey: hostHash) else { return nil } + hostByWindowId.removeValue(forKey: windowId) + return windowId + } + + /// Removes the binding for `windowId` in BOTH directions. + func unbind(windowId: UUID) { + guard let host = hostByWindowId.removeValue(forKey: windowId) else { return } + windowIdByHost.removeValue(forKey: host.connectionHash) + } + + /// Marks `windowId`'s impending close as a tab/session close that should kill + /// the remote session(s) on commit (rather than detach). Set just before + /// `performClose`; consumed on the close commit, or on a close veto to clear it. + func markKillSessionsOnClose(windowId: UUID) { + killSessionsOnClose.insert(windowId) + } + + /// Consumes the kill-on-close marker for `windowId`, returning `true` if it was + /// set (the close-commit handler should kill the session(s), not just detach). + /// Also used on a close veto to clear the marker (the result is ignored there). + @discardableResult + func consumeKillSessionsOnClose(windowId: UUID) -> Bool { + killSessionsOnClose.remove(windowId) != nil + } + + /// All window ids currently marked for kill-on-close (for the app-terminate path + /// to honor a tab/session close of a window's last tab before the app exits). + func windowsMarkedForKillOnClose() -> [UUID] { + Array(killSessionsOnClose) + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 12e808d00852..852d8caed335 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5515,6 +5515,20 @@ class TabManager: ObservableObject { sidebarSelectedWorkspaceIds = workspaceIds.intersection(existingIds) } + /// Marks the window's pending close as a tab/session close so a remote-tmux + /// mirror among `workspaces` is KILLED (synced with tmux) on the close commit + /// rather than detached. The single decision point for every close path that + /// closes the whole window directly — the last-workspace branch of + /// ``closeWorkspaceIfRunningProcess`` and the batch/anchor paths in + /// ``closeWorkspacesWithConfirmation`` — so every explicit tab-close intent kills + /// consistently. ``AppDelegate``'s `shouldClose`/`onClose` consume or clear the + /// marker (veto vs commit). + private func markRemoteTmuxKillOnWindowCloseIfNeeded(for workspaces: [Workspace]) { + guard workspaces.contains(where: { $0.isRemoteTmuxMirror }), + let windowId = AppDelegate.shared?.windowId(for: self) else { return } + AppDelegate.shared?.remoteTmuxController.markKillSessionsOnWindowClose(windowId: windowId) + } + func closeWorkspacesWithConfirmation(_ workspaceIds: [UUID], allowPinned: Bool) { let workspaces = orderedClosableWorkspaces(workspaceIds, allowPinned: allowPinned) guard !workspaces.isEmpty else { return } @@ -5534,6 +5548,9 @@ class TabManager: ObservableObject { if plan.workspaces.count == tabs.count, let firstWorkspace = plan.workspaces.first { + // Closing every tab is still an explicit tab/session close: kill the + // remote-tmux session(s) on commit, not detach. + markRemoteTmuxKillOnWindowCloseIfNeeded(for: plan.workspaces) if let window { window.performClose(nil) return @@ -5563,6 +5580,8 @@ class TabManager: ObservableObject { // Anchor confirmed (or suppressed); skip the inner re-prompt // by closing without going through closeWorkspaceIfRunningProcess. if tabs.count <= 1 { + // Still a tab/session close → kill the remote session on commit. + markRemoteTmuxKillOnWindowCloseIfNeeded(for: [workspace]) if let window { window.performClose(nil) } else { @@ -5808,14 +5827,14 @@ class TabManager: ObservableObject { return } if tabs.count <= 1 { - // Last workspace in this window: it closes via the window-close path. - // For a remote-tmux mirror we deliberately do NOT kill the session - // here — closing the (last) workspace closes the window, and a window - // close only DETACHES (the remote tmux server stays alive for resume). - // Killing here would be premature: the window close can still be - // vetoed (single-window quit warning), which would destroy the remote - // session on a close the user then cancelled. Non-last session - // workspaces still kill via the closeWorkspace path below. + // Last workspace in this window closes via the window-close path, but it + // is still an explicit TAB/session close: for a remote-tmux mirror, mark + // the close to KILL the session on commit (synced with tmux), even though + // it also closes the app window. The marker is consumed on the (non-vetoed) + // close commit, or cleared if the close is vetoed (single-window quit + // warning) so a cancelled close never kills. A plain window/quit close + // never sets it, so it detaches. Non-last workspaces kill via closeWorkspace. + markRemoteTmuxKillOnWindowCloseIfNeeded(for: [workspace]) if let window { window.performClose(nil) } else { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 5303a9ab9d3f..acae1863e1bc 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -473,9 +473,12 @@ 0A17C0DE0A17C0DE0A17C002 /* RemoteTmuxAttachOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */; }; 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */; }; 9C988B800BBFB3CFFD46E611 /* RemoteTmuxCommandResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */; }; + 0C17C0DE0C17C0DE0C17C002 /* RemoteTmuxConnectionDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C17C0DE0C17C0DE0C17C001 /* RemoteTmuxConnectionDiagnostics.swift */; }; + 0D17C0DE0D17C0DE0D17C002 /* RemoteTmuxConnectionObservers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */; }; 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */; }; F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */; }; DCCD118CAFF7B3B9C6F184B4 /* RemoteTmuxControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */; }; + 0B17C0DE0B17C0DE0B17C002 /* RemoteTmuxControlMessageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B17C0DE0B17C0DE0B17C001 /* RemoteTmuxControlMessageDecoding.swift */; }; B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */; }; E1B13CF7FE1A77324A142521 /* RemoteTmuxControlStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */; }; 2FAA10BC0D7B50DE57F507A4 /* RemoteTmuxError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */; }; @@ -488,9 +491,12 @@ 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */; }; 1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */; }; 6D3C19C2014FF9C754358EFF /* RemoteTmuxSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */; }; + 0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */; }; A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */; }; 826F6597BF05F1242E17F0C3 /* RemoteTmuxWindowMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */; }; 063516351A6B6F642928B37D /* RemoteTmuxWindowMirrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */; }; + 0F17C0DE0F17C0DE0F17C002 /* RemoteTmuxWindowRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F17C0DE0F17C0DE0F17C001 /* RemoteTmuxWindowRegistry.swift */; }; + FA00C0DE0001BEEF0001CAFE /* RemoteTmuxWindowRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00C0DE0002BEEF0002CAFE /* RemoteTmuxWindowRegistryTests.swift */; }; D5037010000000000000003 /* RenderableSystemSymbol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5037010000000000000002 /* RenderableSystemSymbol.swift */; }; F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */; }; F5410000A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */; }; @@ -1217,9 +1223,12 @@ 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxAttachOutcome.swift; sourceTree = "<group>"; }; 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxAuthTests.swift; sourceTree = "<group>"; }; 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxCommandResult.swift; sourceTree = "<group>"; }; + 0C17C0DE0C17C0DE0C17C001 /* RemoteTmuxConnectionDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionDiagnostics.swift; sourceTree = "<group>"; }; + 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionObservers.swift; sourceTree = "<group>"; }; 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlConnection.swift; sourceTree = "<group>"; }; 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxController.swift; sourceTree = "<group>"; }; 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlMessage.swift; sourceTree = "<group>"; }; + 0B17C0DE0B17C0DE0B17C001 /* RemoteTmuxControlMessageDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlMessageDecoding.swift; sourceTree = "<group>"; }; C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlParserTests.swift; sourceTree = "<group>"; }; FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlStreamParser.swift; sourceTree = "<group>"; }; AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxError.swift; sourceTree = "<group>"; }; @@ -1232,9 +1241,12 @@ 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParserTests.swift; sourceTree = "<group>"; }; D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionMirror.swift; sourceTree = "<group>"; }; E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSSHTransport.swift; sourceTree = "<group>"; }; + 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxTransportRegistry.swift; sourceTree = "<group>"; }; AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindow.swift; sourceTree = "<group>"; }; FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowMirror.swift; sourceTree = "<group>"; }; E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowMirrorView.swift; sourceTree = "<group>"; }; + 0F17C0DE0F17C0DE0F17C001 /* RemoteTmuxWindowRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowRegistry.swift; sourceTree = "<group>"; }; + FA00C0DE0002BEEF0002CAFE /* RemoteTmuxWindowRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowRegistryTests.swift; sourceTree = "<group>"; }; D5037010000000000000002 /* RenderableSystemSymbol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderableSystemSymbol.swift; sourceTree = "<group>"; }; F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderHermesTests.swift; sourceTree = "<group>"; }; F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderResumeTests.swift; sourceTree = "<group>"; }; @@ -2012,6 +2024,9 @@ D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */, 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */, 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */, + 0B17C0DE0B17C0DE0B17C001 /* RemoteTmuxControlMessageDecoding.swift */, + 0C17C0DE0C17C0DE0C17C001 /* RemoteTmuxConnectionDiagnostics.swift */, + 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */, 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */, FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */, 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */, @@ -2019,6 +2034,8 @@ 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */, 95B11974CF8937E165A3FAEE /* TerminalController+RemoteTmux.swift */, 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */, + 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */, + 0F17C0DE0F17C0DE0F17C001 /* RemoteTmuxWindowRegistry.swift */, E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */, AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */, E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */, @@ -2241,6 +2258,7 @@ 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */, + FA00C0DE0002BEEF0002CAFE /* RemoteTmuxWindowRegistryTests.swift */, 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */, 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */, C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */, @@ -2953,9 +2971,12 @@ E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */, 0A17C0DE0A17C0DE0A17C002 /* RemoteTmuxAttachOutcome.swift in Sources */, 9C988B800BBFB3CFFD46E611 /* RemoteTmuxCommandResult.swift in Sources */, + 0C17C0DE0C17C0DE0C17C002 /* RemoteTmuxConnectionDiagnostics.swift in Sources */, + 0D17C0DE0D17C0DE0D17C002 /* RemoteTmuxConnectionObservers.swift in Sources */, 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */, F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */, DCCD118CAFF7B3B9C6F184B4 /* RemoteTmuxControlMessage.swift in Sources */, + 0B17C0DE0B17C0DE0B17C002 /* RemoteTmuxControlMessageDecoding.swift in Sources */, E1B13CF7FE1A77324A142521 /* RemoteTmuxControlStreamParser.swift in Sources */, 2FAA10BC0D7B50DE57F507A4 /* RemoteTmuxError.swift in Sources */, 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */, @@ -2966,9 +2987,11 @@ 008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */, 1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */, 6D3C19C2014FF9C754358EFF /* RemoteTmuxSSHTransport.swift in Sources */, + 0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */, A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */, 826F6597BF05F1242E17F0C3 /* RemoteTmuxWindowMirror.swift in Sources */, 063516351A6B6F642928B37D /* RemoteTmuxWindowMirrorView.swift in Sources */, + 0F17C0DE0F17C0DE0F17C002 /* RemoteTmuxWindowRegistry.swift in Sources */, D5037010000000000000003 /* RenderableSystemSymbol.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, C13519000000000000000005 /* RestorableAgentTypes.swift in Sources */, @@ -3312,6 +3335,7 @@ 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */, B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */, + FA00C0DE0001BEEF0001CAFE /* RemoteTmuxWindowRegistryTests.swift in Sources */, F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */, F5410000A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift in Sources */, F5410002A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift index ff2097f29516..92718b3b984b 100644 --- a/cmuxTests/RemoteTmuxControlParserTests.swift +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -162,7 +162,7 @@ import Testing + "cursor_flag=1,insert_flag=0,keypad_cursor_flag=0,keypad_flag=0," + "wrap_flag=1,origin_flag=1,pane_height=24" let seq = String( - decoding: RemoteTmuxControlConnection.paneStateSeedSequence(from: line), + decoding: RemoteTmuxControlMessageDecoding().paneStateSeedSequence(from: line), as: UTF8.self ) #expect(seq.contains("\u{1b}[4;21r")) // restricted region, 1-based 4..21 @@ -194,7 +194,7 @@ import Testing // Each concrete tmux flag maps to its xterm DECSET tracking level, and ONLY // that level is enabled (so the app gets exactly the mode it requested). let seq = String( - decoding: RemoteTmuxControlConnection.paneStateSeedSequence(from: mouseSeedLine(flag: flag)), + decoding: RemoteTmuxControlMessageDecoding().paneStateSeedSequence(from: mouseSeedLine(flag: flag)), as: UTF8.self ) #expect(seq.contains(expected)) @@ -214,7 +214,7 @@ import Testing + "wrap_flag=1,origin_flag=0,pane_height=52," + "mouse_any_flag=1,mouse_all_flag=0,mouse_button_flag=0,mouse_standard_flag=0,mouse_sgr_flag=1" let seq = String( - decoding: RemoteTmuxControlConnection.paneStateSeedSequence(from: line), + decoding: RemoteTmuxControlMessageDecoding().paneStateSeedSequence(from: line), as: UTF8.self ) for mode in ["\u{1b}[?1003h", "\u{1b}[?1002h", "\u{1b}[?1000h"] { @@ -230,7 +230,7 @@ import Testing + "cursor_flag=1,insert_flag=0,keypad_cursor_flag=0,keypad_flag=0," + "wrap_flag=1,origin_flag=0,pane_height=52" let seq = String( - decoding: RemoteTmuxControlConnection.paneStateSeedSequence(from: line), + decoding: RemoteTmuxControlMessageDecoding().paneStateSeedSequence(from: line), as: UTF8.self ) #expect(!seq.contains(";52r")) // full-window DECSTBM suppressed @@ -243,16 +243,16 @@ import Testing @Test func windowOrderApplyingReorderRearrangesSubsetInPlace() { // All windows reordered → result is exactly the new sequence. #expect( - RemoteTmuxControlConnection.windowOrder([0, 4, 6], applyingReorder: [0, 6, 4]) == [0, 6, 4] + RemoteTmuxControlMessageDecoding().windowOrder([0, 4, 6], applyingReorder: [0, 6, 4]) == [0, 6, 4] ) // A window not in the dragged subset keeps its slot; the dragged windows // fill the slots they occupied, in the new order. #expect( - RemoteTmuxControlConnection.windowOrder([0, 4, 6, 9], applyingReorder: [6, 4, 0]) == [6, 4, 0, 9] + RemoteTmuxControlMessageDecoding().windowOrder([0, 4, 6, 9], applyingReorder: [6, 4, 0]) == [6, 4, 0, 9] ) // No-op reorder leaves the order unchanged. #expect( - RemoteTmuxControlConnection.windowOrder([0, 4, 6], applyingReorder: [0, 4, 6]) == [0, 4, 6] + RemoteTmuxControlMessageDecoding().windowOrder([0, 4, 6], applyingReorder: [0, 4, 6]) == [0, 4, 6] ) } @@ -339,21 +339,21 @@ import Testing @Test func stderrSessionGoneIsDetected() { // A reconnect that reaches the host but finds the session/server gone is a // genuine end (stop retrying, close). - #expect(RemoteTmuxControlConnection.stderrIndicatesSessionGone("can't find session: work")) - #expect(RemoteTmuxControlConnection.stderrIndicatesSessionGone("no server running on /tmp/tmux-501/default")) - #expect(RemoteTmuxControlConnection.stderrIndicatesSessionGone("lost server")) - #expect(RemoteTmuxControlConnection.stderrIndicatesSessionGone("ERROR: SESSION NOT FOUND")) + #expect(RemoteTmuxControlMessageDecoding().stderrIndicatesSessionGone("can't find session: work")) + #expect(RemoteTmuxControlMessageDecoding().stderrIndicatesSessionGone("no server running on /tmp/tmux-501/default")) + #expect(RemoteTmuxControlMessageDecoding().stderrIndicatesSessionGone("lost server")) + #expect(RemoteTmuxControlMessageDecoding().stderrIndicatesSessionGone("ERROR: SESSION NOT FOUND")) } @Test func stderrTransientFailureIsNotSessionGone() { // Network/transport failures must NOT be classified as session-gone — the // reconnect loop keeps retrying through these. - #expect(!RemoteTmuxControlConnection.stderrIndicatesSessionGone( + #expect(!RemoteTmuxControlMessageDecoding().stderrIndicatesSessionGone( "ssh: connect to host example.com port 22: Operation timed out")) - #expect(!RemoteTmuxControlConnection.stderrIndicatesSessionGone( + #expect(!RemoteTmuxControlMessageDecoding().stderrIndicatesSessionGone( "ssh: connect to host x port 22: No route to host")) - #expect(!RemoteTmuxControlConnection.stderrIndicatesSessionGone("Connection to host closed.")) - #expect(!RemoteTmuxControlConnection.stderrIndicatesSessionGone("")) + #expect(!RemoteTmuxControlMessageDecoding().stderrIndicatesSessionGone("Connection to host closed.")) + #expect(!RemoteTmuxControlMessageDecoding().stderrIndicatesSessionGone("")) } // MARK: - Raw layout parser diff --git a/cmuxTests/RemoteTmuxWindowRegistryTests.swift b/cmuxTests/RemoteTmuxWindowRegistryTests.swift new file mode 100644 index 000000000000..40df79a5b28b --- /dev/null +++ b/cmuxTests/RemoteTmuxWindowRegistryTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Behavior tests for ``RemoteTmuxWindowRegistry``'s kill-on-close marker — the +/// seam that decides whether closing a remote-tmux window's last tab kills the +/// remote session (an explicit tab/session close) or merely detaches it (a plain +/// app-window/quit close). These exercise the mark → consume → clear state machine +/// directly, with no AppKit window involved. +@MainActor +@Suite struct RemoteTmuxWindowRegistryTests { + /// A marked window is consumed exactly once: the commit handler sees `true`, and + /// any later consume (e.g. a redundant call) sees `false`, so it can't kill twice. + @Test func markedWindowIsConsumedExactlyOnce() { + let registry = RemoteTmuxWindowRegistry() + let windowId = UUID() + registry.markKillSessionsOnClose(windowId: windowId) + #expect(registry.consumeKillSessionsOnClose(windowId: windowId) == true) + #expect(registry.consumeKillSessionsOnClose(windowId: windowId) == false) + } + + /// An unmarked window (a plain window/quit close) consumes to `false`, so the + /// close-commit handler detaches instead of killing the remote session. + @Test func unmarkedWindowConsumesToFalse() { + let registry = RemoteTmuxWindowRegistry() + #expect(registry.consumeKillSessionsOnClose(windowId: UUID()) == false) + } + + /// The marker is scoped per window id: marking one window does not make another + /// window's close kill, and the marked window still consumes to `true`. + @Test func markerIsScopedPerWindow() { + let registry = RemoteTmuxWindowRegistry() + let marked = UUID() + let other = UUID() + registry.markKillSessionsOnClose(windowId: marked) + #expect(registry.consumeKillSessionsOnClose(windowId: other) == false) + #expect(registry.consumeKillSessionsOnClose(windowId: marked) == true) + } + + /// Consuming a marked window on a close veto clears it, so a later (real) + /// window/quit close of the same window detaches rather than killing. + @Test func consumingOnVetoClearsTheMarker() { + let registry = RemoteTmuxWindowRegistry() + let windowId = UUID() + registry.markKillSessionsOnClose(windowId: windowId) + // Veto path: consume to clear (result ignored in production). + _ = registry.consumeKillSessionsOnClose(windowId: windowId) + // A subsequent close commit must not kill. + #expect(registry.consumeKillSessionsOnClose(windowId: windowId) == false) + } +} From adc1d809125bfbe0dde876666af221bc0633bed4 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 16:23:52 +0300 Subject: [PATCH 31/73] remote-tmux: add missing ja docs.navItems.remoteTmux nav label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ja docs catalog had docs.remoteTmux (page content) but was missing the docs.navItems.remoteTmux sidebar label (referenced by web/app/[locale]/components/docs-nav-items.ts). Adds "リモート tmux" to match the ja page title. en + ja now have full parity for the remote-tmux docs keys; the other locale catalogs receive them via the translation pipeline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- web/messages/ja.json | 1 + 1 file changed, 1 insertion(+) diff --git a/web/messages/ja.json b/web/messages/ja.json index 16cb04a1ff83..b7cfde1de75d 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -1491,6 +1491,7 @@ "browserAutomation": "ブラウザ自動化", "skills": "スキル", "notifications": "通知", + "remoteTmux": "リモート tmux", "agentIntegrations": "エージェント連携", "claudeCodeTeams": "Claude Code Teams", "ohMyOpenCode": "oh-my-opencode", From 49202b80b148ffe9fb10c1ebf3478deea9a18d22 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Mon, 8 Jun 2026 19:07:16 +0300 Subject: [PATCH 32/73] remote-tmux: address coderabbit review findings (reseed reset, teardown, sizing retry, localized socket errors) Fixes for the 4 outside-diff findings in coderabbit's review (pullrequestreview-4447760520), each verified against the current code: - Reset stale alt-screen + mouse modes when reseeding a REUSED surface across reconnect: the .paneAltScreen handler now emits ESC[?1049l when the pane is back on the primary screen (not just 1049h for alt), and paneStateSeedSequence resets all mouse modes + SGR (ESC[m) before re-enabling the active ones. Without this, a surface reused after reconnect could keep painting onto a stale alt screen or keep forwarding clicks to an app that no longer requested mouse mode. (The wrap/cursor/ insert/origin flags already emitted both on/off forms; mouse + alt-screen were the one-directional gap.) - RemoteTmuxWindowMirror.teardown() now unsubscribes each pane's cwd subscription (matching reconcile(layout:)), so a control connection that outlives the tab stops streaming pane_current_path into a dead mirror. - RemoteTmuxWindowMirrorView retries the client-size push until the pane surface reports its cell size, so tmux gets its initial `refresh-client -C` even when the view size never changes after attach. updateClientSize now returns readiness; the retry restarts with the latest size on resize (no stale-size clobber) and is bounded + cancelled on disappear. - Localize the remote-tmux v2Error socket bodies (4 keys: disabled / hostRequired / sessionRequired / hostAndSessionRequired), en + ja in Localizable.xcstrings, matching the existing socket.terminal.* / socket.notification.* localization. Verified: cmux-unit builds green; RemoteTmuxControlParserTests + RemoteTmuxWindowRegistryTests (35 tests) pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Resources/Localizable.xcstrings | 68 +++++++++++++++++++ Sources/RemoteTmuxControlConnection.swift | 14 ++-- .../RemoteTmuxControlMessageDecoding.swift | 12 +++- Sources/RemoteTmuxWindowMirror.swift | 17 ++++- Sources/RemoteTmuxWindowMirrorView.swift | 28 ++++++-- Sources/TerminalController+RemoteTmux.swift | 26 +++---- 6 files changed, 139 insertions(+), 26 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e7717e6364a8..648212122249 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -15112,6 +15112,74 @@ } } }, + "socket.remoteTmux.disabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote tmux beta is disabled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux ベータは無効です" + } + } + } + }, + "socket.remoteTmux.hostAndSessionRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "host and session are required" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホストとセッションが必要です" + } + } + } + }, + "socket.remoteTmux.hostRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "host is required" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホストが必要です" + } + } + } + }, + "socket.remoteTmux.sessionRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "session is required" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションが必要です" + } + } + } + }, "socket.terminal.surfaceUnavailable": { "extractionState": "manual", "localizations": { diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 48a34dc1b52d..48dc30838c57 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -133,6 +133,7 @@ final class RemoteTmuxControlConnection { /// `ESC[?1049h` — enter the alternate screen, emitted to a mirror surface when /// the remote pane is on the alternate screen (see ``capturePane(paneId:)``). private static let altScreenEnterSequence = Data("\u{1b}[?1049h".utf8) + private static let altScreenExitSequence = Data("\u{1b}[?1049l".utf8) /// How many lines of pane history `capture-pane` seeds onto a freshly mounted /// (or reconnected) mirror surface. Capturing scrollback — not just the visible @@ -814,13 +815,16 @@ final class RemoteTmuxControlConnection { observers.emitPaneCwd(paneId, path) } case let .paneAltScreen(paneId): - // Enter the alternate screen on the mirror surface so it matches the - // remote pane (alt = no reflow on resize). Emitted before the capture - // paint that follows in the FIFO, so the seeded rows land on the alt - // screen. A pane on the primary screen needs no toggle (the surface - // defaults to primary, and a later live `%output` 1049l would leave alt). + // Match the mirror surface to the remote pane's screen (alt = no reflow on + // resize). Emitted before the capture paint that follows in the FIFO, so the + // seeded rows land on the right screen. The else branch is load-bearing on a + // surface REUSED across reconnect: if it was on the alt screen before and the + // remote pane is now on primary, force it back (1049l) so the capture doesn't + // paint onto a stale alt screen. if lines.first?.trimmingCharacters(in: .whitespaces) == "1" { observers.emitPaneOutput(paneId, Self.altScreenEnterSequence) + } else { + observers.emitPaneOutput(paneId, Self.altScreenExitSequence) } case .other: break diff --git a/Sources/RemoteTmuxControlMessageDecoding.swift b/Sources/RemoteTmuxControlMessageDecoding.swift index 89bb1fa5fab8..9dd0c3842eff 100644 --- a/Sources/RemoteTmuxControlMessageDecoding.swift +++ b/Sources/RemoteTmuxControlMessageDecoding.swift @@ -32,7 +32,11 @@ struct RemoteTmuxControlMessageDecoding { // non-numeric values are treated as absent. let num: (String) -> Int? = { fields[$0].flatMap { Int($0) }.flatMap { (0...65535).contains($0) ? $0 : nil } } - var seq = "" + // Reset SGR attributes first so the cursor's style pen starts from a known + // baseline on a surface REUSED across reconnect — a prior alt-screen exit's + // restoreCursor can otherwise leave a stale style. The captured rows below + // carry their own SGR; this only affects the pen for subsequent writes. + var seq = "\u{1b}[m" // Scroll region (DECSTBM) — tmux reports 0-based, DECSTBM is 1-based. Only // seed a RESTRICTED region: a full-window region (upper 0, lower height-1) // is the surface's default already, and pinning it to the capture-time row @@ -64,6 +68,12 @@ struct RemoteTmuxControlMessageDecoding { // used: it is tmux's aggregate "any mouse mode on" OR-flag, not a concrete // level. (NOTE: ghostty's vendored tmux viewer uses a different, one-slot- // shifted mapping — do not "align" to it; the above matches real tmux.) + // Reset all mouse tracking + encoding modes FIRST, then conditionally enable + // the active one below. Unlike wrap/cursor/insert/origin above (which emit both + // on/off forms), the mouse enables are one-directional, so on a surface REUSED + // across reconnect a stale mouse mode would otherwise persist when the pane now + // has mouse off — leaving clicks/scroll forwarded to an app that no longer wants them. + seq += "\u{1b}[?1000l\u{1b}[?1002l\u{1b}[?1003l\u{1b}[?1005l\u{1b}[?1006l" if on("mouse_all_flag") { seq += "\u{1b}[?1003h" } else if on("mouse_button_flag") { seq += "\u{1b}[?1002h" } else if on("mouse_standard_flag") { seq += "\u{1b}[?1000h" } diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index e69ba4b34462..6a54968d5254 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -110,15 +110,20 @@ final class RemoteTmuxWindowMirror { /// from the content pixel area and a live pane's cell size; sends /// `refresh-client -C` only when the grid actually changes (no feedback loop: /// the cmux area doesn't change when tmux reflows). - func updateClientSize(contentSizePoints: CGSize) { + /// Returns `true` once the pane surface is live and the size was applied (sent, or + /// already current via the `lastClientSize` dedup); `false` when no pane has + /// reported its cell size yet, so the caller should retry. Idempotent. + @discardableResult + func updateClientSize(contentSizePoints: CGSize) -> Bool { guard contentSizePoints.width > 1, contentSizePoints.height > 1, let cell = panelsByPaneId.values.lazy.compactMap({ $0.surface.cellSizePoints() }).first, - cell.width > 1, cell.height > 1 else { return } + cell.width > 1, cell.height > 1 else { return false } let cols = max(20, Int(contentSizePoints.width / cell.width)) let rows = max(5, Int(contentSizePoints.height / cell.height)) - guard lastClientSize?.cols != cols || lastClientSize?.rows != rows else { return } + guard lastClientSize?.cols != cols || lastClientSize?.rows != rows else { return true } lastClientSize = (cols, rows) connection?.setClientSize(columns: cols, rows: rows) + return true } /// Records the user-focused pane and asks tmux to make it active. @@ -143,6 +148,12 @@ final class RemoteTmuxWindowMirror { /// Tears down every pane panel (called when the window-tab is removed). func teardown() { + // Unsubscribe each pane's cwd subscription first — matching reconcile(layout:), + // which unsubscribes per removed pane. Without this, a control connection that + // outlives the tab keeps streaming pane_current_path updates into a dead mirror. + for paneId in panelsByPaneId.keys { + connection?.unsubscribePanePath(paneId: paneId) + } for panel in panelsByPaneId.values { panel.close() } panelsByPaneId.removeAll() syntheticPaneIds.removeAll() diff --git a/Sources/RemoteTmuxWindowMirrorView.swift b/Sources/RemoteTmuxWindowMirrorView.swift index 6c1284f8c52e..e431d78711d0 100644 --- a/Sources/RemoteTmuxWindowMirrorView.swift +++ b/Sources/RemoteTmuxWindowMirrorView.swift @@ -11,6 +11,7 @@ struct RemoteTmuxWindowMirrorView: View { let appearance: PanelAppearance let isVisibleInUI: Bool let portalPriority: Int + @State private var sizingRetryTask: Task<Void, Never>? var body: some View { GeometryReader { geo in @@ -24,15 +25,34 @@ struct RemoteTmuxWindowMirrorView: View { .frame(width: geo.size.width, height: geo.size.height) // Size the remote tmux window to the rendered area so pane content // matches the on-screen grid. - .onAppear { mirror.updateClientSize(contentSizePoints: geo.size) } - .onChange(of: geo.size) { _, newSize in - mirror.updateClientSize(contentSizePoints: newSize) - } + .onAppear { scheduleClientSize(geo.size) } + .onChange(of: geo.size) { _, newSize in scheduleClientSize(newSize) } + .onDisappear { sizingRetryTask?.cancel() } } .frame(maxWidth: .infinity, maxHeight: .infinity) // Match the terminal background so the area never shows through as black. .background(Color(nsColor: appearance.backgroundColor)) } + + /// Pushes the client size to tmux, retrying briefly while the pane surface hasn't + /// reported its cell size yet — so the initial `refresh-client -C` lands even when + /// the view size never changes after attach. Each call restarts the retry with the + /// LATEST size, so a resize arriving before the surface is live isn't lost and can't + /// be overwritten by a stale earlier size. `updateClientSize` dedups + reports + /// readiness, so the retry stops as soon as the surface goes live. + private func scheduleClientSize(_ size: CGSize) { + sizingRetryTask?.cancel() + if mirror.updateClientSize(contentSizePoints: size) { return } + sizingRetryTask = Task { @MainActor in + // Retry until the pane surface reports its cell size (local layout timing, + // normally a frame or two; budget generously for a loaded system). do/catch + // (not try?) so a cancelled sleep returns immediately without a stale apply. + for _ in 0..<20 { + do { try await Task.sleep(for: .milliseconds(150)) } catch { return } + if mirror.updateClientSize(contentSizePoints: size) { return } + } + } + } } /// Recursive split container that lays out one ``RemoteTmuxLayoutNode`` subtree, diff --git a/Sources/TerminalController+RemoteTmux.swift b/Sources/TerminalController+RemoteTmux.swift index 70a250365adf..7c3e84fdde4a 100644 --- a/Sources/TerminalController+RemoteTmux.swift +++ b/Sources/TerminalController+RemoteTmux.swift @@ -13,10 +13,10 @@ extension TerminalController { /// optional `identity_file` (String). nonisolated func v2RemoteTmuxSessions(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { - return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) } guard let host = Self.remoteTmuxHost(from: params) else { - return v2Error(id: id, code: "invalid_params", message: "host is required") + return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.hostRequired", defaultValue: "host is required")) } return v2VmCall(id: id, timeoutSeconds: 30) { guard let controller = await MainActor.run(body: { AppDelegate.shared?.remoteTmuxController }) @@ -84,13 +84,13 @@ extension TerminalController { /// optional `create` (Bool — attach-or-create). Returns the control surface id. nonisolated func v2RemoteTmuxAttach(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { - return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) } guard let host = Self.remoteTmuxHost(from: params) else { - return v2Error(id: id, code: "invalid_params", message: "host is required") + return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.hostRequired", defaultValue: "host is required")) } guard let session = Self.remoteTmuxSessionName(from: params) else { - return v2Error(id: id, code: "invalid_params", message: "session is required") + return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.sessionRequired", defaultValue: "session is required")) } let createIfMissing = (params["create"] as? Bool) ?? false return v2VmCall(id: id, timeoutSeconds: 20) { @@ -116,10 +116,10 @@ extension TerminalController { /// sidebar workspace (windows become tabs). Params: `host` (required). nonisolated func v2RemoteTmuxMirror(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { - return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) } guard let host = Self.remoteTmuxHost(from: params) else { - return v2Error(id: id, code: "invalid_params", message: "host is required") + return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.hostRequired", defaultValue: "host is required")) } return v2VmCall(id: id, timeoutSeconds: 30) { guard let controller = await MainActor.run(body: { AppDelegate.shared?.remoteTmuxController }) @@ -146,10 +146,10 @@ extension TerminalController { /// multiplexing over the authenticated master. nonisolated func v2RemoteTmuxWindow(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { - return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) } guard let host = Self.remoteTmuxHost(from: params) else { - return v2Error(id: id, code: "invalid_params", message: "host is required") + return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.hostRequired", defaultValue: "host is required")) } let activate = (params["activate"] as? Bool) ?? true // 60s (the CLI waits longer still) so a slow-but-valid BatchMode probe @@ -181,12 +181,12 @@ extension TerminalController { /// `remote.tmux.detach` — detach a control client (leaves the remote session alive). nonisolated func v2RemoteTmuxDetach(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { - return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) } guard let host = Self.remoteTmuxHost(from: params), let session = Self.remoteTmuxSessionName(from: params) else { - return v2Error(id: id, code: "invalid_params", message: "host and session are required") + return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.hostAndSessionRequired", defaultValue: "host and session are required")) } return v2VmCall(id: id, timeoutSeconds: 10) { try await MainActor.run { @@ -204,12 +204,12 @@ extension TerminalController { /// Diagnostics surface for verifying the ghostty → cmux event pipe end to end. nonisolated func v2RemoteTmuxState(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { - return v2Error(id: id, code: "disabled", message: "remote tmux beta is disabled") + return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) } guard let host = Self.remoteTmuxHost(from: params), let session = Self.remoteTmuxSessionName(from: params) else { - return v2Error(id: id, code: "invalid_params", message: "host and session are required") + return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.hostAndSessionRequired", defaultValue: "host and session are required")) } return v2VmCall(id: id, timeoutSeconds: 10) { let snapshot: RemoteTmuxControlConnection.Snapshot? = await MainActor.run { From 4ff851e9152a8be93ce7ba5ddec32241cd974637 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Tue, 9 Jun 2026 21:26:01 +0300 Subject: [PATCH 33/73] remote-tmux: conditional shell reflow + reliable on-attach client sizing (A) Conditional reflow: classify each mirrored pane via #{alternate_on} + #{pane_current_command} (a refresh-client -B subscription plus a one-shot display-message fallback, since some tmux builds don't deliver the subscription). Plain shells reflow their primary-screen scrollback on resize (DECAWM left on), while alt-screen / inline-TUI panes (e.g. claude) keep the no-reflow DECAWM toggle so their box-drawing isn't rewrapped. Routed via a new onPaneReflow observer -> setManualIONoReflow, gated in updateSize. (B) On-attach client sizing: scheduleInitialManualGridReport + reportManualGridIfNeeded push the rendered grid to the remote even when createSurface already stamped the final grid (so updateSize sees no change); setClientSize coalesces the layout-settle size oscillation behind a trailing debounce; the .enter handler re-applies the stored size on first connect (not just reconnect). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/GhosttyTerminalView.swift | 194 +++++++++++++++----- Sources/RemoteTmuxConnectionObservers.swift | 13 ++ Sources/RemoteTmuxControlConnection.swift | 167 ++++++++++++++++- Sources/RemoteTmuxSessionMirror.swift | 25 +++ Sources/RemoteTmuxWindowMirror.swift | 6 + 5 files changed, 356 insertions(+), 49 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 93c82d398d90..d1ee5c38722f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5464,9 +5464,26 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Last grid reported through ``onManualGridResize`` (so we fire only on a real /// column/row change, not on every sub-cell pixel nudge). private var lastReportedManualGrid: (columns: Int, rows: Int)? + /// For MANUAL-I/O remote tmux display surfaces: whether this pane must NOT + /// reflow/rewrap its primary screen on resize. `true` (the safe default) = + /// suppress reflow — correct for inline TUIs like claude (whose box-drawing + /// can't rewrap) and for alt-screen apps. `false` = a plain shell on the + /// primary screen, so ghostty natively reflows its seeded scrollback on resize + /// (shrink rewraps, grow restores, scroll position preserved) — the behavior a + /// normal terminal attached to tmux has. Driven live by the remote pane's + /// classification (`#{alternate_on}` + `#{pane_current_command}`); consulted in + /// ``updateSize(...)`` to gate the DECAWM no-reflow toggle. Default `true` so a + /// pane is never reflowed before it's classified (a reattached claude can't + /// briefly rewrap during the subscription's first-emit latency). + private var manualIONoReflow: Bool = true /// Retained userdata for the MANUAL-mode `io_write_cb`; released alongside /// the surface (see ``teardownSurface()``). private var manualIOContext: Unmanaged<RemoteTmuxManualIOWriteBox>? + /// Brief retry that guarantees the rendered grid is pushed to the remote tmux + /// client on attach even when `createSurface` stamped the final grid (so the + /// post-create `updateSize` sees no size change and never reports). See + /// ``scheduleInitialManualGridReport()``. Cancelled on teardown. + private var manualGridReportRetryTask: Task<Void, Never>? /// Output delivered via ``processRemoteOutput(_:)`` before the runtime /// surface exists (e.g. a background remote-tmux workspace not yet hosted). /// Flushed into the surface once it is created so content isn't lost. @@ -6183,6 +6200,8 @@ final class TerminalSurface: Identifiable, ObservableObject { // the surface is focused, which a surface being torn down is not. manualIOContext?.release() manualIOContext = nil + manualGridReportRetryTask?.cancel() + manualGridReportRetryTask = nil let surfaceToFree = surface if let surfaceToFree { @@ -6245,6 +6264,8 @@ final class TerminalSurface: Identifiable, ObservableObject { // the surface is focused, which a surface being torn down is not. manualIOContext?.release() manualIOContext = nil + manualGridReportRetryTask?.cancel() + manualGridReportRetryTask = nil let surfaceToFree = surface if let surfaceToFree { @@ -6950,6 +6971,14 @@ final class TerminalSurface: Identifiable, ObservableObject { view.forceRefreshSurface() ghostty_surface_refresh(createdSurface) + // Push the rendered grid to the remote tmux client now. createSurface stamps + // the final grid above and (correctly) doesn't reflow, so the post-create + // updateSize sees no size change and would never report it — leaving the + // remote at ssh's 80×24 and a single-pane mirror (e.g. claude) rendered into + // a sub-region. This retrying push is the single-pane analogue of the + // multi-pane mirror's scheduleClientSize. No-op for non-manual surfaces. + scheduleInitialManualGridReport() + NotificationCenter.default.post( name: .terminalSurfaceDidBecomeReady, object: self, @@ -7026,60 +7055,90 @@ final class TerminalSurface: Identifiable, ObservableObject { } if sizeChanged { - // Mirror (manual-I/O) surfaces must NOT reflow/rewrap their primary - // screen on resize. tmux is the authority on a pane's reflow and only - // streams the app's incremental post-SIGWINCH redraw (never a full grid - // repaint), so a client that reflows independently diverges from tmux — - // inline TUIs like claude then render misaligned after a resize. iTerm2 - // avoids this by never reflowing locally ("tmux owns the grid"). ghostty - // reflows iff DECAWM (wraparound) is on at resize time, so disable it - // across the resize and restore it after. No writes occur during this - // window, so autowrap is otherwise unaffected. (A ghostty "no-reflow - // surface" flag would be cleaner but needs a GhosttyKit rebuild.) - // - // Caveat: this restores DECAWM to ON unconditionally. That is correct - // for the overwhelmingly common case (DECAWM defaults to on); a remote - // app that had explicitly turned it OFF is transiently re-enabled until - // its next %output re-asserts the mode (self-healing). A faithful - // save/restore would need a ghostty read-mode API that doesn't exist. - if manualIO { - writeProcessOutputData(Self.decawmDisableSequence, to: surface) - } - ghostty_surface_set_size(surface, wpx, hpx) + // Mirror (manual-I/O) panes reflow CONDITIONALLY on resize. A plain + // shell's primary-screen scrollback SHOULD reflow (shrink rewraps long + // lines, grow restores them, the viewport stays anchored — exactly like + // a real terminal attached to tmux); without it a shrink hard-clips the + // seeded scrollback and a grow never restores it. But an inline TUI like + // claude (and any alt-screen app) must NOT reflow, or its box-drawing + // rewraps and corrupts — tmux owns the grid and only streams the app's + // post-SIGWINCH redraw, so a client that reflows independently diverges + // (iTerm2 likewise doesn't reflow TUI panes). The per-pane choice is + // `manualIONoReflow`, driven live by the remote pane's classification + // (`#{alternate_on}` + `#{pane_current_command}`) and defaulting to + // no-reflow so a not-yet-classified (e.g. reattached) claude is never + // rewrapped. (Reflow of a shell pane works from the LIVE %output, which + // already carries real soft-wraps; the capture seed is left faithful — + // `capture-pane -J` was tried but corrupted TUI seeds, so it was dropped.) + // The DECAWM-gated sizing lives in `applyManualAwareSurfaceSize`; it is + // used only here on live resizes, never in createSurface (which must not + // inject bytes / force a render during surface init — that races the + // renderer thread). + applyManualAwareSurfaceSize(surface, width: wpx, height: hpx) lastPixelWidth = wpx lastPixelHeight = hpx - // For manual-I/O surfaces the `.manual` termio backend applies the - // terminal resize synchronously inside set_size (with DECAWM off, so it - // does not reflow), so the buffer already matches the just-set grid here. - // render_now forces a GPU frame so the resized grid is shown promptly - // before the post-resize %output arrives; then DECAWM is restored. This - // fires only on an actual size change (never while typing). - if manualIO { - ghostty_surface_render_now(surface) - writeProcessOutputData(Self.decawmEnableSequence, to: surface) - } } // Remote tmux display surfaces: keep the remote tmux client sized to the // rendered grid so a freshly attached session doesn't stay at ssh's - // default 80×24 (which mangles TUIs like claude / claude agents). Only - // report when actually on screen and when the cell grid — not just the - // pixel area — changed. - if manualIO, let report = onManualGridResize, attachedView?.window != nil { - let grid = ghostty_surface_size(surface) - let cols = Int(grid.columns) - let rows = Int(grid.rows) - if cols > 1, rows > 1, - lastReportedManualGrid?.columns != cols || lastReportedManualGrid?.rows != rows { - lastReportedManualGrid = (cols, rows) - report(cols, rows) - } - } + // default 80×24 (which mangles TUIs like claude / claude agents). Shared + // with the initial-report retry so both size the remote the same way. + reportManualGridIfNeeded() // Let Ghostty continue rendering on its own wakeups for steady-state frames. return true } + /// Reports the current rendered grid (cols×rows) to the remote tmux client via + /// ``onManualGridResize``, when this is a manual-I/O mirror surface that is + /// live, in-window, has a valid grid, and whose grid changed since the last + /// report. Returns `true` once a valid in-window grid has been reported (or was + /// already current) — the initial-report retry uses that to know it can stop. + /// + /// This is the SINGLE place a single-pane mirror sizes the remote. It is reached + /// two ways: live resizes via ``updateSize(...)``, and ``scheduleInitialManualGridReport()`` + /// — because `createSurface` stamps the final grid and bypasses this, so the + /// post-create `updateSize` sees no size change and would otherwise never report, + /// leaving the remote at ssh's 80×24 and claude rendered into a sub-region. + @discardableResult + @MainActor + func reportManualGridIfNeeded() -> Bool { + guard manualIO, let report = onManualGridResize, attachedView?.window != nil else { return false } + guard let surface = liveSurfaceForGhosttyAccess(reason: "reportManualGrid") else { return false } + let grid = ghostty_surface_size(surface) + let cols = Int(grid.columns) + let rows = Int(grid.rows) + guard cols > 1, rows > 1 else { return false } + if lastReportedManualGrid?.columns != cols || lastReportedManualGrid?.rows != rows { + lastReportedManualGrid = (cols, rows) + report(cols, rows) + } + return true + } + + /// Ensures the remote tmux client is sized to the rendered grid on attach, even + /// when `createSurface` stamped the final grid (so the post-create `updateSize` + /// sees no size change and never reports — the dominant cause of a single-pane + /// mirror rendering at the stale 80×24 width/height). Reports immediately if it + /// can, else retries briefly until the surface is live + in-window with a valid + /// grid, then reports once; later resizes flow through ``updateSize(...)``. This + /// is the single-pane analogue of the multi-pane mirror's `scheduleClientSize` + /// retry. No-op for non-manual surfaces or those without a resize hook. + @MainActor + func scheduleInitialManualGridReport() { + guard manualIO, onManualGridResize != nil else { return } + if reportManualGridIfNeeded() { return } + manualGridReportRetryTask?.cancel() + manualGridReportRetryTask = Task { @MainActor [weak self] in + // ~3s of 150ms ticks, matching RemoteTmuxWindowMirrorView.scheduleClientSize. + for _ in 0..<20 { + do { try await ContinuousClock().sleep(for: .milliseconds(150)) } catch { return } + guard let self else { return } + if self.reportManualGridIfNeeded() { return } + } + } + } + @discardableResult @MainActor func applyMobileViewportLimit(columns: Int, rows: Int, reason: String) -> Bool { @@ -7863,6 +7922,55 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + /// Sets whether this MANUAL-I/O remote-tmux surface should suppress reflow on + /// resize (see ``manualIONoReflow``). Driven by the remote pane's live + /// classification (`#{alternate_on}` + `#{pane_current_command}`). Only stores + /// the flag — it is consulted on the next resize in ``updateSize(...)``, which + /// is exactly when reflow happens, so no immediate surface work is needed. + /// Must be called on the main actor. + @MainActor + func setManualIONoReflow(_ value: Bool) { + guard manualIONoReflow != value else { return } + manualIONoReflow = value + } + + /// Applies a LIVE-resize surface pixel-size change (from ``updateSize``), + /// honoring the manual-I/O no-reflow policy. For a manual-I/O (remote tmux + /// mirror) pane classified as no-reflow (``manualIONoReflow``, the default until + /// a pane is classified), DECAWM (autowrap) is disabled across + /// `ghostty_surface_set_size` so ghostty does not reflow/rewrap the primary + /// screen — an inline TUI's box-drawing must not rewrap — then restored; a pane + /// classified as a plain shell reflows natively. `render_now` forces a prompt + /// GPU frame for manual-I/O surfaces. Non-manual surfaces just set the size + /// (no toggle, no render_now), matching prior behavior. Must be on the main actor. + /// + /// NOT used by `createSurface`: injecting process-output bytes (the DECAWM + /// toggle) and forcing a synchronous render during surface init races the + /// renderer thread (observed SIGBUS) and can mis-size the initial grid. A fresh + /// surface has no prior grid to reflow, so it just calls `ghostty_surface_set_size`. + /// + /// Caveat (no-reflow panes only): the toggle restores DECAWM to ON + /// unconditionally. Correct for the common case (DECAWM defaults on); an app + /// that had explicitly turned it OFF is transiently re-enabled until its next + /// %output re-asserts the mode (self-healing). A faithful save/restore would + /// need a ghostty read-mode API that doesn't exist. + @MainActor + private func applyManualAwareSurfaceSize( + _ surface: ghostty_surface_t, width: UInt32, height: UInt32 + ) { + let suppressReflow = manualIO && manualIONoReflow + if suppressReflow { + writeProcessOutputData(Self.decawmDisableSequence, to: surface) + } + ghostty_surface_set_size(surface, width, height) + if manualIO { + ghostty_surface_render_now(surface) + if suppressReflow { + writeProcessOutputData(Self.decawmEnableSequence, to: surface) + } + } + } + /// Injects raw terminal output bytes into this surface's VT parser, as if /// they came from a PTY. Used by MANUAL-I/O remote-tmux display surfaces to /// render a remote pane's `%output`. If the runtime surface doesn't exist diff --git a/Sources/RemoteTmuxConnectionObservers.swift b/Sources/RemoteTmuxConnectionObservers.swift index 4d579abb2a8f..1dd74131ed7b 100644 --- a/Sources/RemoteTmuxConnectionObservers.swift +++ b/Sources/RemoteTmuxConnectionObservers.swift @@ -16,6 +16,7 @@ final class RemoteTmuxConnectionObservers { private var paneOutputObservers: [Token: (_ paneId: Int, _ data: Data) -> Void] = [:] private var paneCwdObservers: [Token: (_ paneId: Int, _ path: String) -> Void] = [:] + private var paneReflowObservers: [Token: (_ paneId: Int, _ noReflow: Bool) -> Void] = [:] private var activePaneObservers: [Token: (_ windowId: Int, _ paneId: Int) -> Void] = [:] private var topologyObservers: [Token: () -> Void] = [:] private var exitObservers: [Token: () -> Void] = [:] @@ -32,6 +33,10 @@ final class RemoteTmuxConnectionObservers { /// - onPaneOutput: receives every `%output` (raw, octal-unescaped bytes). /// - onPaneCwd: receives a pane's working directory (`pane_current_path`), /// both the initial value and live changes. + /// - onPaneReflow: receives a pane's reflow classification (`true` = + /// suppress reflow on resize, for alt-screen / inline-TUI panes like + /// claude; `false` = a plain shell whose primary-screen scrollback may + /// reflow), both the initial value and live changes. /// - onActivePaneChanged: fires when a window's active pane changes /// (`%window-pane-changed`), so consumers can re-project per-pane state /// (e.g. the active pane's directory) onto the window's tab. @@ -46,6 +51,7 @@ final class RemoteTmuxConnectionObservers { func add( onPaneOutput: ((_ paneId: Int, _ data: Data) -> Void)?, onPaneCwd: ((_ paneId: Int, _ path: String) -> Void)?, + onPaneReflow: ((_ paneId: Int, _ noReflow: Bool) -> Void)?, onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)?, onTopologyChanged: (() -> Void)?, onExit: (() -> Void)?, @@ -54,6 +60,7 @@ final class RemoteTmuxConnectionObservers { let token = Token() if let onPaneOutput { paneOutputObservers[token] = onPaneOutput } if let onPaneCwd { paneCwdObservers[token] = onPaneCwd } + if let onPaneReflow { paneReflowObservers[token] = onPaneReflow } if let onActivePaneChanged { activePaneObservers[token] = onActivePaneChanged } if let onTopologyChanged { topologyObservers[token] = onTopologyChanged } if let onExit { exitObservers[token] = onExit } @@ -65,6 +72,7 @@ final class RemoteTmuxConnectionObservers { func remove(_ token: Token) { paneOutputObservers[token] = nil paneCwdObservers[token] = nil + paneReflowObservers[token] = nil activePaneObservers[token] = nil topologyObservers[token] = nil exitObservers[token] = nil @@ -83,6 +91,11 @@ final class RemoteTmuxConnectionObservers { for callback in Array(paneCwdObservers.values) { callback(paneId, path) } } + /// Fans a pane's reflow classification out to every reflow observer. + func emitPaneReflow(_ paneId: Int, _ noReflow: Bool) { + for callback in Array(paneReflowObservers.values) { callback(paneId, noReflow) } + } + /// Fans a window's new active pane out to every active-pane observer. func emitActivePaneChanged(_ windowId: Int, _ paneId: Int) { for callback in Array(activePaneObservers.values) { callback(windowId, paneId) } diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 48dc30838c57..f49a2e79041c 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -95,6 +95,14 @@ final class RemoteTmuxControlConnection { /// the command-result FIFO). private var pendingReconnectReseed = false + /// Trailing-edge debounce for `refresh-client -C`. SwiftUI layout settle makes the + /// rendered grid oscillate (e.g. cols 154→155→156→161→… in ~1s), and each distinct + /// value would otherwise send its own `refresh-client -C` → a SIGWINCH/redraw storm + /// on the remote per attach. ``setClientSize`` stores the size immediately but + /// defers the send to one shot after the size stops changing. + private var clientSizeDebounceTask: Task<Void, Never>? + private static let clientSizeDebounceMs = 180 + /// Base reconnect backoff (seconds); doubled each attempt up to ``reconnectMaxDelaySeconds``. private static let reconnectBaseDelaySeconds: Double = 1 /// Cap on the reconnect backoff (seconds). Retries continue indefinitely at this @@ -104,7 +112,7 @@ final class RemoteTmuxControlConnection { private static let maxStderrBytes = 8 * 1024 private enum CommandKind: Equatable { - case listWindows, capturePane(Int), paneState(Int), panePath(Int), paneAltScreen(Int), other + case listWindows, capturePane(Int), paneState(Int), panePath(Int), paneReflow(Int), paneAltScreen(Int), other } /// The lifecycle phase of a control connection. @@ -130,6 +138,36 @@ final class RemoteTmuxControlConnection { /// routed back to its pane; defined once so the writer and reader can't drift. private static let cwdSubscriptionPrefix = "cmux_cwd_" + /// Subscription-name prefix for per-pane reflow classification + /// (`refresh-client -B`). The subscribed format is + /// `#{alternate_on}<sep>#{pane_current_command}`; tmux emits it on subscribe + /// and on every change, so launching/exiting an app (bash → node when claude + /// starts) re-classifies the pane live. The tmux pane id is appended for + /// routing, mirroring ``cwdSubscriptionPrefix``. + private static let reflowSubscriptionPrefix = "cmux_reflow_" + + /// Field separator inside the reflow subscription value + /// (`#{alternate_on}|#{pane_current_command}`). A pipe never appears in a tmux + /// `alternate_on` flag (0/1) and is not part of a process's `comm` name. + private static let reflowFieldSeparator: Character = "|" + + /// Foreground commands (`#{pane_current_command}`) whose primary-screen + /// scrollback is safe to reflow on resize — i.e. plain interactive shells that + /// keep their history as ordinary soft-wrapped text. Anything else (node for + /// claude, python, REPLs, pagers, editors) is treated as no-reflow so an inline + /// TUI's fixed-width frame is never rewrapped. Alt-screen apps are caught + /// separately by `#{alternate_on}`. Login shells can be reported with a leading + /// dash, so the traditional/POSIX variants are listed too. Classification + /// defaults to no-reflow on anything not in this set (and on an unparseable + /// value), which is the safe direction (worst case: a shell's scrollback doesn't + /// reflow until reclassified) — so a login shell whose dash variant is missing + /// here simply doesn't reflow, never breaks. + private static let reflowSafeShellCommands: Set<String> = [ + "bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh", "ash", + "mksh", "pdksh", "elvish", "nu", "xonsh", "pwsh", "powershell", "oil", "osh", + "-bash", "-zsh", "-fish", "-sh", "-dash", "-ksh", "-tcsh", "-csh", "-ash", + ] + /// `ESC[?1049h` — enter the alternate screen, emitted to a mirror surface when /// the remote pane is on the alternate screen (see ``capturePane(paneId:)``). private static let altScreenEnterSequence = Data("\u{1b}[?1049h".utf8) @@ -162,6 +200,10 @@ final class RemoteTmuxControlConnection { /// - onPaneCwd: receives a pane's working directory (`pane_current_path`), /// both the initial value and live changes (see ``requestPanePath(paneId:)`` /// and ``subscribePanePath(paneId:)``). + /// - onPaneReflow: receives a pane's reflow classification (`true` = suppress + /// reflow on resize for alt-screen / inline-TUI panes like claude; `false` + /// = a plain shell whose primary-screen scrollback may reflow), both the + /// initial value and live changes (see ``subscribePaneReflow(paneId:)``). /// - onActivePaneChanged: fires when a window's active pane changes /// (`%window-pane-changed`), so consumers can re-project per-pane state /// (e.g. the active pane's directory) onto the window's tab. @@ -176,6 +218,7 @@ final class RemoteTmuxControlConnection { func addObserver( onPaneOutput: ((_ paneId: Int, _ data: Data) -> Void)? = nil, onPaneCwd: ((_ paneId: Int, _ path: String) -> Void)? = nil, + onPaneReflow: ((_ paneId: Int, _ noReflow: Bool) -> Void)? = nil, onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)? = nil, onTopologyChanged: (() -> Void)? = nil, onExit: (() -> Void)? = nil, @@ -184,6 +227,7 @@ final class RemoteTmuxControlConnection { observers.add( onPaneOutput: onPaneOutput, onPaneCwd: onPaneCwd, + onPaneReflow: onPaneReflow, onActivePaneChanged: onActivePaneChanged, onTopologyChanged: onTopologyChanged, onExit: onExit, @@ -333,7 +377,20 @@ final class RemoteTmuxControlConnection { // silently drop); `reseedAfterReconnect` re-applies the stored size. lastClientSize = (columns, rows) guard connectionState == .connected else { return } - send("refresh-client -C \(columns)x\(rows)") + // Coalesce the layout-settle oscillation (the rendered grid can flap through + // several distinct sizes as SwiftUI layout settles) into a single send: + // (re)arm a short trailing timer; only the last size in a burst goes out, so + // the remote sees one resize/SIGWINCH instead of a storm. + clientSizeDebounceTask?.cancel() + clientSizeDebounceTask = Task { @MainActor [weak self] in + do { + try await ContinuousClock().sleep(for: .milliseconds(Self.clientSizeDebounceMs)) + } catch { + return + } + guard let self, self.connectionState == .connected, let size = self.lastClientSize else { return } + self.send("refresh-client -C \(size.columns)x\(size.rows)") + } } /// Requests the current window list + layouts (used to (re)build topology). @@ -392,6 +449,15 @@ final class RemoteTmuxControlConnection { // mirrored tab is scrollable immediately on attach/reconnect. On an // alternate-screen pane there is no history, so tmux clamps to the visible // alt screen — harmless. + // + // NOTE: do NOT add `-J` (join wrapped lines) here. It was tried to make a + // shell pane's PRE-ATTACH scrollback rejoin cleanly on grow, but it rewrites + // an inline/alt-screen TUI's captured rows into different logical lines, so + // the seed paints shifted on reattach (claude's input line lands a row off + // and the frame doubles) and scatters on resize. The reflow win for shells + // comes from LIVE %output (which already carries real soft-wraps), not from + // the seed — so `-J`'s only upside (pre-attach rejoin-on-grow) isn't worth + // corrupting every TUI seed. Capture faithful visual rows instead. sendInternal("capture-pane -p -e -S -\(Self.scrollbackCaptureLines) -t %\(paneId)", kind: .capturePane(paneId)) // Query the pane's terminal STATE; tmux exposes it all as formats. Sent // after capture-pane so it applies on top of the painted rows (the seed @@ -438,6 +504,70 @@ final class RemoteTmuxControlConnection { send("refresh-client -B \(Self.cwdSubscriptionPrefix)\(paneId)") } + /// One-shot query of a pane's reflow classification (`#{alternate_on}` + + /// `#{pane_current_command}`), delivered to the reflow observers. This is the + /// REQUIRED initial classifier — `subscribePaneReflow` only guarantees *live* + /// updates, and on tmux builds where the `-B` subscription doesn't deliver this + /// combined value the surface would otherwise stay at its safe no-reflow default + /// forever (shells never reflow). Mirrors ``requestPanePath(paneId:)`` exactly + /// (a `display-message` always works where a subscription might not). + func requestPaneReflow(paneId: Int) { + #if DEBUG + cmuxDebugLog("remote.reflow.request pane=\(paneId)") + #endif + sendInternal( + "display-message -p -t %\(paneId) -F \"" + + "#{alternate_on}\(Self.reflowFieldSeparator)#{pane_current_command}\"", + kind: .paneReflow(paneId) + ) + } + + /// Classifies a raw `#{alternate_on}|#{pane_current_command}` value (from the + /// one-shot query or a live subscription) and emits the no-reflow decision. + /// No-reflow when on the alternate screen OR the foreground command isn't a known + /// plain shell; defaults to no-reflow on an empty/unparseable value (safe). + private func classifyAndEmitReflow(paneId: Int, rawValue: String, source: String) { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split( + separator: Self.reflowFieldSeparator, maxSplits: 1, omittingEmptySubsequences: false + ) + let altOn = parts.first.map(String.init) == "1" + let command = parts.count > 1 + ? String(parts[1]).trimmingCharacters(in: .whitespaces) + : "" + let noReflow = altOn || !Self.reflowSafeShellCommands.contains(command) + #if DEBUG + cmuxDebugLog( + "remote.reflow.classify pane=\(paneId) src=\(source) raw=\"\(trimmed)\" " + + "alt=\(altOn ? 1 : 0) cmd=\"\(command)\" noReflow=\(noReflow ? 1 : 0)" + ) + #endif + observers.emitPaneReflow(paneId, noReflow) + } + + /// Subscribes to live reflow-classification changes for `paneId` via tmux + /// control-mode `refresh-client -B`. The subscribed value is + /// `#{alternate_on}|#{pane_current_command}`; tmux emits it once on subscribe + /// and again whenever it changes, so a pane that switches between a plain shell + /// and an inline TUI (e.g. bash → node when claude launches) is reclassified + /// without polling. The mirror surface uses this to decide whether to reflow its + /// primary screen on resize (shells reflow; alt-screen / inline-TUI panes do + /// not). Best-effort: on tmux builds without subscriptions this is a no-op and + /// the surface keeps its safe no-reflow default. See ``subscriptionChanged`` + /// handling for the parse, and ``reflowSafeShellCommands`` for the policy. + func subscribePaneReflow(paneId: Int) { + send( + "refresh-client -B \(Self.reflowSubscriptionPrefix)\(paneId):%\(paneId):" + + "#{alternate_on}\(Self.reflowFieldSeparator)#{pane_current_command}" + ) + } + + /// Removes the live reflow-classification subscription for `paneId` (issued once + /// the pane is gone), mirroring ``unsubscribePanePath(paneId:)``. + func unsubscribePaneReflow(paneId: Int) { + send("refresh-client -B \(Self.reflowSubscriptionPrefix)\(paneId)") + } + /// Sends literal key bytes to a pane via tmux `send-keys -H` (hex-encoded), /// which is binary-safe and needs no shell quoting. func sendKeys(paneId: Int, data: Data) { @@ -472,6 +602,8 @@ final class RemoteTmuxControlConnection { connectionState = .ended reconnectTask?.cancel() reconnectTask = nil + clientSizeDebounceTask?.cancel() + clientSizeDebounceTask = nil teardownProcessHandles() } @@ -639,6 +771,8 @@ final class RemoteTmuxControlConnection { capturePane(paneId: paneId) requestPanePath(paneId: paneId) subscribePanePath(paneId: paneId) + requestPaneReflow(paneId: paneId) + subscribePaneReflow(paneId: paneId) } } } @@ -657,12 +791,23 @@ final class RemoteTmuxControlConnection { reconnectTask = nil // Resync the surfaces after a reconnect (a fresh client lost the // screen/subscriptions). DON'T reseed here: the attach's own - // %begin/%end block hasn't been consumed yet, so queuing commands now - // would misalign the %end correlation FIFO. Defer until the first - // post-reconnect list-windows result (attach block drained, + // %begin/%end block hasn't been consumed yet, so queuing CORRELATED + // commands now would misalign the %end correlation FIFO. Defer until + // the first post-reconnect list-windows result (attach block drained, // windowsByID freshly repopulated). Skipped on the first connect (the // mirror seeds new tabs itself). - if wasReconnecting { pendingReconnectReseed = true } + if wasReconnecting { + pendingReconnectReseed = true + } else if let size = lastClientSize { + // First connect: if a surface already computed its grid before we + // reached `.connected`, setClientSize stored it but dropped the + // send (no live stdin yet) and nothing re-applies it on first + // connect — so the remote would stay at ssh's 80×24. Re-emit it + // now. SAFE to send here (unlike the reseed): `refresh-client -C` + // is UNCORRELATED (kind .other, no %begin/%end result block), so + // it does not touch the command-result FIFO the attach block uses. + send("refresh-client -C \(size.columns)x\(size.rows)") + } } case let .exit(reason): record("exit\(reason.map { " " + $0 } ?? "")") @@ -671,6 +816,8 @@ final class RemoteTmuxControlConnection { connectionState = .ended reconnectTask?.cancel() reconnectTask = nil + clientSizeDebounceTask?.cancel() + clientSizeDebounceTask = nil observers.notifyExit() case let .output(paneId, data): paneOutputByteCounts[paneId, default: 0] += data.count @@ -726,6 +873,10 @@ final class RemoteTmuxControlConnection { let paneId = Int(name.dropFirst(Self.cwdSubscriptionPrefix.count)) { let path = value.trimmingCharacters(in: .whitespacesAndNewlines) if !path.isEmpty { observers.emitPaneCwd(paneId, path) } + } else if name.hasPrefix(Self.reflowSubscriptionPrefix), + let paneId = Int(name.dropFirst(Self.reflowSubscriptionPrefix.count)) { + // Reflow classification: "<alternate_on>|<pane_current_command>". + classifyAndEmitReflow(paneId: paneId, rawValue: value, source: "sub") } case let .commandResult(_, lines, isError): handleCommandResult(lines: lines, isError: isError) @@ -814,6 +965,10 @@ final class RemoteTmuxControlConnection { if let path = lines.first?.trimmingCharacters(in: .whitespaces), !path.isEmpty { observers.emitPaneCwd(paneId, path) } + case let .paneReflow(paneId): + // One-shot reflow classification result (see requestPaneReflow). Empty + // lines → classifyAndEmitReflow defaults to no-reflow (safe). + classifyAndEmitReflow(paneId: paneId, rawValue: lines.first ?? "", source: "oneshot") case let .paneAltScreen(paneId): // Match the mirror surface to the remote pane's screen (alt = no reflow on // resize). Emitted before the capture paint that follows in the FIFO, so the diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 96bc6be700cc..609727324984 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -51,6 +51,9 @@ final class RemoteTmuxSessionMirror { onPaneCwd: { [weak self] paneId, path in self?.handlePaneCwd(paneId: paneId, path: path) }, + onPaneReflow: { [weak self] paneId, noReflow in + self?.routeNoReflow(paneId: paneId, noReflow: noReflow) + }, onActivePaneChanged: { [weak self] windowId, paneId in self?.handleActivePaneChanged(windowId: windowId, paneId: paneId) }, @@ -139,6 +142,12 @@ final class RemoteTmuxSessionMirror { panelIdByWindow[windowId] = panel.id panelIdByPane[firstPaneId] = panel.id connection.capturePane(paneId: firstPaneId) + // Classify the pane (shell → reflow on resize; TUI/alt-screen → no + // reflow). One-shot first (always works) so a shell reflows even on + // tmux builds where the live subscription doesn't deliver, then the + // subscription for live re-classification (e.g. bash → node). + connection.requestPaneReflow(paneId: firstPaneId) + connection.subscribePaneReflow(paneId: firstPaneId) // Track the pane's working directory so the tab shows the remote // cwd (initial value + live `cd`) instead of staying at "~". connection.requestPanePath(paneId: firstPaneId) @@ -291,6 +300,22 @@ final class RemoteTmuxSessionMirror { panel.surface.processRemoteOutput(data) } + /// Applies a pane's reflow classification to its mirror surface (suppress + /// reflow on resize for alt-screen / inline-TUI panes; allow it for shells). + /// Routes exactly like ``routeOutput(paneId:data:)`` — multi-pane windows own + /// their pane surfaces, single-pane windows use the tab's panel surface. + private func routeNoReflow(paneId: Int, noReflow: Bool) { + if let windowId = windowIdContaining(pane: paneId), + let mirror = windowMirrorByWindowId[windowId] { + mirror.surface(forPane: paneId)?.setManualIONoReflow(noReflow) + return + } + guard let workspace, + let panelId = panelIdByPane[paneId], + let panel = workspace.panels[panelId] as? TerminalPanel else { return } + panel.surface.setManualIONoReflow(noReflow) + } + /// Routes a split of a mirror window-tab (by its panel id) to tmux /// `split-window`, splitting the focused pane (or the window's only pane). /// Used by the split BUTTON / `shouldSplitPane` path, which works at the diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 6a54968d5254..ad99e7af20d0 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -79,6 +79,10 @@ final class RemoteTmuxWindowMirror { panelsByPaneId[paneId] = panel syntheticPaneIds[paneId] = PaneID() connection?.capturePane(paneId: paneId) + // One-shot reflow classification (always works) + live subscription, so a + // shell pane reflows on resize even where the subscription doesn't deliver. + connection?.requestPaneReflow(paneId: paneId) + connection?.subscribePaneReflow(paneId: paneId) // Track this pane's working directory (initial + live) so the window // tab reflects the remote cwd. The session mirror's cwd observer maps // the pane back to this window's tab. @@ -91,6 +95,7 @@ final class RemoteTmuxWindowMirror { // dereferenced by a later Core Animation commit. panel.close() connection?.unsubscribePanePath(paneId: paneId) + connection?.unsubscribePaneReflow(paneId: paneId) panelsByPaneId[paneId] = nil syntheticPaneIds[paneId] = nil if activePaneId == paneId { activePaneId = nil } @@ -153,6 +158,7 @@ final class RemoteTmuxWindowMirror { // outlives the tab keeps streaming pane_current_path updates into a dead mirror. for paneId in panelsByPaneId.keys { connection?.unsubscribePanePath(paneId: paneId) + connection?.unsubscribePaneReflow(paneId: paneId) } for panel in panelsByPaneId.values { panel.close() } panelsByPaneId.removeAll() From 9cf61013f51cc1416dde0e7284a8f283619d4658 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Tue, 9 Jun 2026 22:43:29 +0300 Subject: [PATCH 34/73] remote-tmux: seed-before-size ordering + attach redraw kick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two attach-render fixes on top of the conditional-reflow/sizing work: - createSurface sizes the surface BEFORE flushing buffered remote seed output, so a seed that arrived pre-creation (background workspace) paints into the correct grid instead of wrapping at the default width. - Attach redraw kick: tmux's grid stores app output as rendered (rows drawn at an earlier width, inline streaming churn), has no redraw command for -CC clients, and capture-pane re-reads the same stale cells — only the app's own SIGWINCH repaint produces a clean current-width frame. A real-terminal attach gets that SIGWINCH because its size differs from the detached window's; the mirror's usually doesn't (the window still has the size cmux left behind). When the attach size apply matches an existing window size exactly, push rows-1 then restore after 350ms (above tmux's resize coalescing) — one-shot per connect, also on reconnect, bails if the user resized meanwhile, invisible for plain-shell panes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Sources/GhosttyTerminalView.swift | 39 ++++--- Sources/RemoteTmuxControlConnection.swift | 126 ++++++++++++++++++++-- 2 files changed, 140 insertions(+), 25 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d1ee5c38722f..c10263837d76 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6890,7 +6890,29 @@ final class TerminalSurface: Identifiable, ObservableObject { guard let createdSurface = surface else { return } TerminalSurfaceRegistry.shared.registerRuntimeSurface(createdSurface, ownerId: id) recordRuntimeSurfaceCreation() - // Flush any remote-tmux output that arrived before the surface existed. + // Size the surface to its grid BEFORE flushing the buffered seed. The + // remote-tmux capture seed is full-width (the remote pane's width); flushing + // it while the surface is still at ghostty_surface_new's default grid wraps + // the wide rows, and the later (no-reflow) set_size can't recover them — so + // a seed buffered before surface creation (e.g. a background workspace) + // would render mis-wrapped. Plain set_size only — NO render_now / DECAWM + // toggle here (that races the renderer during init and caused a SIGBUS; + // see updateSize). + ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) + let initialBackingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size + let initialWpx = pixelDimension(from: initialBackingSize.width) + let initialHpx = pixelDimension(from: initialBackingSize.height) + if initialWpx > 0, initialHpx > 0 { + ghostty_surface_set_size(createdSurface, initialWpx, initialHpx) + lastPixelWidth = initialWpx + lastPixelHeight = initialHpx + lastUncappedPixelWidth = initialWpx + lastUncappedPixelHeight = initialHpx + lastXScale = scaleFactors.x + lastYScale = scaleFactors.y + } + + // Now flush the buffered remote-tmux output into the correctly-sized grid. flushPendingRemoteOutput(to: createdSurface) // Install the PTY tee so MobileTerminalByteTee receives every byte // the read thread produces, in order, before the VT parser runs. @@ -6927,19 +6949,8 @@ final class TerminalSurface: Identifiable, ObservableObject { ghostty_surface_set_display_id(createdSurface, displayID) } - ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) - let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size - let wpx = pixelDimension(from: backingSize.width) - let hpx = pixelDimension(from: backingSize.height) - if wpx > 0, hpx > 0 { - ghostty_surface_set_size(createdSurface, wpx, hpx) - lastPixelWidth = wpx - lastPixelHeight = hpx - lastUncappedPixelWidth = wpx - lastUncappedPixelHeight = hpx - lastXScale = scaleFactors.x - lastYScale = scaleFactors.y - } + // (Surface sizing now happens BEFORE the seed flush above, so the captured + // full-width rows paint into the correctly-sized grid.) // Some GhosttyKit builds can drop inherited font_size during post-create // config/scale reconciliation. If runtime points don't match the inherited diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index f49a2e79041c..63893e6ef427 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -96,13 +96,25 @@ final class RemoteTmuxControlConnection { private var pendingReconnectReseed = false /// Trailing-edge debounce for `refresh-client -C`. SwiftUI layout settle makes the - /// rendered grid oscillate (e.g. cols 154→155→156→161→… in ~1s), and each distinct - /// value would otherwise send its own `refresh-client -C` → a SIGWINCH/redraw storm - /// on the remote per attach. ``setClientSize`` stores the size immediately but - /// defers the send to one shot after the size stops changing. + /// rendered grid oscillate (e.g. cols 154→155→156→161→…, ~15 distinct grids in + /// ~1.3s), and each previously sent its own `refresh-client -C` → ~15 SIGWINCH / + /// redraw storms on the remote per attach. We now coalesce them: ``setClientSize`` + /// stores the size immediately but defers the send to one shot after the size + /// stops changing. The fired timer is also the clean "size settled" edge that + /// consumes the one-shot attach redraw kick below. private var clientSizeDebounceTask: Task<Void, Never>? private static let clientSizeDebounceMs = 180 + /// Armed on every transition to `.connected` (first connect AND reconnect) and + /// consumed by the first size apply that follows; see + /// ``scheduleAttachRedrawKickIfNeeded()`` for why attach needs a redraw kick. + private var pendingAttachRedrawKick = false + private var attachRedrawKickTask: Task<Void, Never>? + /// Gap between the kick's shrink push and its restore push. Must exceed tmux's + /// pane-resize coalescing (~250 ms), otherwise the two pushes collapse into a + /// net-zero size change and no SIGWINCH is ever delivered. + private static let attachRedrawKickGapMs = 350 + /// Base reconnect backoff (seconds); doubled each attempt up to ``reconnectMaxDelaySeconds``. private static let reconnectBaseDelaySeconds: Double = 1 /// Cap on the reconnect backoff (seconds). Retries continue indefinitely at this @@ -377,10 +389,9 @@ final class RemoteTmuxControlConnection { // silently drop); `reseedAfterReconnect` re-applies the stored size. lastClientSize = (columns, rows) guard connectionState == .connected else { return } - // Coalesce the layout-settle oscillation (the rendered grid can flap through - // several distinct sizes as SwiftUI layout settles) into a single send: - // (re)arm a short trailing timer; only the last size in a burst goes out, so - // the remote sees one resize/SIGWINCH instead of a storm. + // Coalesce the layout-settle oscillation into a single send: (re)arm a short + // trailing timer; only the last size in a burst actually goes out. The fired + // timer is also the "settled" edge that consumes the attach redraw kick. clientSizeDebounceTask?.cancel() clientSizeDebounceTask = Task { @MainActor [weak self] in do { @@ -390,6 +401,81 @@ final class RemoteTmuxControlConnection { } guard let self, self.connectionState == .connected, let size = self.lastClientSize else { return } self.send("refresh-client -C \(size.columns)x\(size.rows)") + // Do NOT re-capture here. A re-capture would run capture-pane before the + // remote app (claude) finishes its post-SIGWINCH redraw, snapshotting the + // stale pre-resize frame and clobbering the correct redraw — the exact + // narrow/overlap/duplicate mangle. A manual resize is clean precisely + // because it issues no capture: it lets refresh-client -C → SIGWINCH → + // the app's own redraw stream back and paint. Attach does the same. + self.scheduleAttachRedrawKickIfNeeded() + } + } + + /// One-shot, attach-only: force a real SIGWINCH when the attach size push was a + /// no-op, so a running TUI re-renders the current frame at the current width. + /// + /// Why this exists: tmux's grid stores an app's output as it was RENDERED — rows + /// drawn at an earlier window width, or an inline TUI's streaming churn, stay in + /// the visible frame verbatim. tmux has no "redraw" command for a control (-CC) + /// client (`%output` is an append-only pty copy; tmux never re-streams grid + /// cells), and `capture-pane` re-reads the same stale cells, so the ONLY way to + /// get a clean current-width frame is the app's own repaint — which apps do on + /// SIGWINCH. A real-terminal attach virtually always delivers that SIGWINCH + /// because its size differs from the detached window's size. The mirror's attach + /// usually does NOT: the window still has the size cmux itself left behind, so + /// `refresh-client -C` matches it exactly, no pane resize happens, and the stale + /// frame stays until the user manually resizes. This kick closes that one gap by + /// sending the same signal a real attach sends: a genuine size change (rows-1), + /// then the true size after tmux's resize-coalescing window has passed. + /// + /// Ordering is safe vs the seed: the kick is scheduled after the capture-pane + /// commands, and the tmux server processes commands FIFO, so the app's redraw + /// `%output` always lands after (on top of) the seed paint. Skipped entirely when + /// the attach push itself changed the window size (that already SIGWINCHes), and + /// invisible for plain-shell panes (nothing re-renders, nothing is streamed). + private func scheduleAttachRedrawKickIfNeeded() { + guard pendingAttachRedrawKick else { return } + // Not ready yet (no grid computed / topology not drained): keep the one-shot + // armed for the next size apply instead of consuming it uselessly. + guard connectionState == .connected, let size = lastClientSize, !windowsByID.isEmpty else { return } + pendingAttachRedrawKick = false + guard size.rows > 2 else { return } + // Only kick when some mirrored window ALREADY has the target size — i.e. the + // size apply above cannot produce a SIGWINCH for it. (window-size latest makes + // every window track the client, so one client-level kick redraws them all.) + let windowAlreadyAtTarget = windowsByID.values.contains { + $0.width == size.columns && $0.height == size.rows + } + guard windowAlreadyAtTarget else { + #if DEBUG + cmuxDebugLog("remote.size.kick skip=windowSizeDiffers target=\(size.columns)x\(size.rows)") + #endif + return + } + #if DEBUG + cmuxDebugLog("remote.size.kick shrink to \(size.columns)x\(size.rows - 1)") + #endif + attachRedrawKickTask?.cancel() + attachRedrawKickTask = Task { @MainActor [weak self] in + guard let self, self.connectionState == .connected else { return } + // Bail if the user resized since the kick was scheduled: that resize is a + // real size change, so it already delivered the SIGWINCH this kick exists + // to force — and a shrink at the captured (now stale) size would flash + // wrong dimensions at the remote apps. + guard let current = self.lastClientSize, current == size else { return } + self.send("refresh-client -C \(size.columns)x\(size.rows - 1)") + do { + try await ContinuousClock().sleep(for: .milliseconds(Self.attachRedrawKickGapMs)) + } catch { + return + } + guard self.connectionState == .connected else { return } + // Restore the CURRENT size (the user may have resized during the gap). + let restore = self.lastClientSize ?? size + #if DEBUG + cmuxDebugLog("remote.size.kick restore to \(restore.columns)x\(restore.rows)") + #endif + self.send("refresh-client -C \(restore.columns)x\(restore.rows)") } } @@ -512,9 +598,6 @@ final class RemoteTmuxControlConnection { /// forever (shells never reflow). Mirrors ``requestPanePath(paneId:)`` exactly /// (a `display-message` always works where a subscription might not). func requestPaneReflow(paneId: Int) { - #if DEBUG - cmuxDebugLog("remote.reflow.request pane=\(paneId)") - #endif sendInternal( "display-message -p -t %\(paneId) -F \"" + "#{alternate_on}\(Self.reflowFieldSeparator)#{pane_current_command}\"", @@ -604,6 +687,8 @@ final class RemoteTmuxControlConnection { reconnectTask = nil clientSizeDebounceTask?.cancel() clientSizeDebounceTask = nil + attachRedrawKickTask?.cancel() + attachRedrawKickTask = nil teardownProcessHandles() } @@ -765,6 +850,11 @@ final class RemoteTmuxControlConnection { if let size = lastClientSize { send("refresh-client -C \(size.columns)x\(size.rows)") } + // The re-applied size is usually a no-op (the server kept the window at our + // size across the transport drop), so TUIs get no SIGWINCH — kick them so + // they repaint over the re-seeded (possibly stale) frame. FIFO-safe: the + // captures below are queued before the kick task's first push can run. + scheduleAttachRedrawKickIfNeeded() for window in windowsByID.values { for paneId in window.paneIDsInOrder { observers.emitPaneOutput(paneId, Data("\u{1b}[H\u{1b}[2J\u{1b}[3J".utf8)) @@ -786,6 +876,11 @@ final class RemoteTmuxControlConnection { if connectionState != .connected { let wasReconnecting = connectionState == .reconnecting connectionState = .connected + // Arm the one-shot attach redraw kick: if the upcoming size apply is + // a no-op (window already at our size), a running TUI gets no SIGWINCH + // and would keep showing its stale pre-attach frame. Consumed by the + // first size apply (debounced send or reconnect re-seed). + pendingAttachRedrawKick = true reconnectAttemptCount = 0 reconnectTask?.cancel() reconnectTask = nil @@ -818,6 +913,8 @@ final class RemoteTmuxControlConnection { reconnectTask = nil clientSizeDebounceTask?.cancel() clientSizeDebounceTask = nil + attachRedrawKickTask?.cancel() + attachRedrawKickTask = nil observers.notifyExit() case let .output(paneId, data): paneOutputByteCounts[paneId, default: 0] += data.count @@ -937,6 +1034,13 @@ final class RemoteTmuxControlConnection { pendingReconnectReseed = false reseedAfterReconnect() } + // First-connect coverage for the attach redraw kick: if the grid was + // computed (and size sent) before `.enter`, no post-connect + // `setClientSize` may ever fire (layout settled + same-size dedupe + // upstream), so the debounced-send consumer never runs. This is the + // earliest point where the topology is populated; a no-op when the + // kick was already consumed (or when reseedAfterReconnect just ran it). + scheduleAttachRedrawKickIfNeeded() } case let .capturePane(paneId): // capture-pane -e -S output is the pane's history + visible rows (with From 9339433de8c6fdaaf0073f422bf4e3fd17f7f48c Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Tue, 9 Jun 2026 23:17:34 +0300 Subject: [PATCH 35/73] remote-tmux: FIFO-safe first-connect sizing + attach-block consumption (review hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-pipeline findings on the assembled branch: - The first-connect refresh-client -C re-apply was sent from the .enter handler, queueing a command before the attach %begin/%end block was consumed — the empty-FIFO heuristic protecting the attach block only works when nothing is queued ahead of it, so the attach block could steal the entry and shift every later result one slot (silent topology loss). Deferred via pendingFirstConnectSizeApply to the first list-windows result, mirroring pendingReconnectReseed. Side benefit: the redraw kick's at-target check there reads the list-windows reply's pre-apply geometry, so it can tell a no-op apply from a real resize. - Root hardening for the same hazard class: the FIRST %begin/%end block per control stream is now consumed explicitly as the attach command's own (attachBlockDrained, reset per ssh spawn) instead of relying on the FIFO being empty — every cmux send has exactly one result block, the attach block has none, so the correlation stays bijective even if a debounced send races a stalled attach block. - pendingAttachRedrawKick/pendingFirstConnectSizeApply reset in stop() and on %exit; the debounced send clears the deferred first-connect apply (no duplicate refresh-client -C). - Reflow classification queued before the capture seed at all three call sites (it only matters at the next resize; arriving early shrinks the window where a resize hits the no-reflow default on a slow link). - Grid-report retry: named constants, bail when the report hook was cleared by single→multi-pane promotion; two methods made private; reflowSafeShellCommands doc comment corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Sources/GhosttyTerminalView.swift | 19 +++-- Sources/RemoteTmuxControlConnection.swift | 88 ++++++++++++++++++----- Sources/RemoteTmuxSessionMirror.swift | 5 +- Sources/RemoteTmuxWindowMirror.swift | 4 +- 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c10263837d76..c19ac82656fb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5483,7 +5483,10 @@ final class TerminalSurface: Identifiable, ObservableObject { /// client on attach even when `createSurface` stamped the final grid (so the /// post-create `updateSize` sees no size change and never reports). See /// ``scheduleInitialManualGridReport()``. Cancelled on teardown. + /// ~3s of 150ms ticks, matching `RemoteTmuxWindowMirrorView.scheduleClientSize`. private var manualGridReportRetryTask: Task<Void, Never>? + private static let manualGridReportRetryIntervalMs = 150 + private static let manualGridReportRetryCount = 20 /// Output delivered via ``processRemoteOutput(_:)`` before the runtime /// surface exists (e.g. a background remote-tmux workspace not yet hosted). /// Flushed into the surface once it is created so content isn't lost. @@ -7113,7 +7116,7 @@ final class TerminalSurface: Identifiable, ObservableObject { /// leaving the remote at ssh's 80×24 and claude rendered into a sub-region. @discardableResult @MainActor - func reportManualGridIfNeeded() -> Bool { + private func reportManualGridIfNeeded() -> Bool { guard manualIO, let report = onManualGridResize, attachedView?.window != nil else { return false } guard let surface = liveSurfaceForGhosttyAccess(reason: "reportManualGrid") else { return false } let grid = ghostty_surface_size(surface) @@ -7136,15 +7139,21 @@ final class TerminalSurface: Identifiable, ObservableObject { /// is the single-pane analogue of the multi-pane mirror's `scheduleClientSize` /// retry. No-op for non-manual surfaces or those without a resize hook. @MainActor - func scheduleInitialManualGridReport() { + private func scheduleInitialManualGridReport() { guard manualIO, onManualGridResize != nil else { return } if reportManualGridIfNeeded() { return } manualGridReportRetryTask?.cancel() manualGridReportRetryTask = Task { @MainActor [weak self] in - // ~3s of 150ms ticks, matching RemoteTmuxWindowMirrorView.scheduleClientSize. - for _ in 0..<20 { - do { try await ContinuousClock().sleep(for: .milliseconds(150)) } catch { return } + for _ in 0..<Self.manualGridReportRetryCount { + do { + try await ContinuousClock().sleep( + for: .milliseconds(Self.manualGridReportRetryIntervalMs)) + } catch { return } guard let self else { return } + // The hook is cleared when a single-pane window is promoted to a + // multi-pane mirror — the report can never succeed then, so stop + // instead of no-op-ticking out the rest of the retry budget. + guard self.onManualGridResize != nil else { return } if self.reportManualGridIfNeeded() { return } } } diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 63893e6ef427..eaada5f072b9 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -64,6 +64,14 @@ final class RemoteTmuxControlConnection { private var parser = RemoteTmuxControlStreamParser() private var ingestTask: Task<Void, Never>? private var pendingCommands: [CommandKind] = [] + /// `false` until the attach command's own `%begin`/`%end` block — always the + /// FIRST block on each control stream, preceding every notification — has been + /// consumed. That first block is matched explicitly (see the `.commandResult` + /// dispatch) rather than by "FIFO happens to be empty", so a command that races + /// in early (e.g. a debounced size send on a stalled link) can never have its + /// result slot stolen by the attach block. Reset per spawn (each ssh re-attach + /// produces a fresh attach block). + private var attachBlockDrained = false private let createIfMissing: Bool /// Stateless pure decoders for control-mode message payloads (pane-state seed, @@ -94,6 +102,12 @@ final class RemoteTmuxControlConnection { /// before the attach's own `%begin`/`%end` block is consumed (which would misalign /// the command-result FIFO). private var pendingReconnectReseed = false + /// Set on a FIRST connect whose `.enter` arrived with a grid already stored (or + /// not yet computed); the `refresh-client -C` re-apply is deferred to the first + /// `list-windows` result for the same FIFO reason as ``pendingReconnectReseed`` + /// — `.enter` precedes the attach block, and any command queued before that + /// block is consumed would shift the positional result FIFO. + private var pendingFirstConnectSizeApply = false /// Trailing-edge debounce for `refresh-client -C`. SwiftUI layout settle makes the /// rendered grid oscillate (e.g. cols 154→155→156→161→…, ~15 distinct grids in @@ -169,7 +183,8 @@ final class RemoteTmuxControlConnection { /// claude, python, REPLs, pagers, editors) is treated as no-reflow so an inline /// TUI's fixed-width frame is never rewrapped. Alt-screen apps are caught /// separately by `#{alternate_on}`. Login shells can be reported with a leading - /// dash, so the traditional/POSIX variants are listed too. Classification + /// dash, so the common traditional/POSIX dash variants are listed too (not every + /// shell has one — a missing dash variant just doesn't reflow). Classification /// defaults to no-reflow on anything not in this set (and on an unparseable /// value), which is the safe direction (worst case: a shell's scrollback doesn't /// reflow until reclassified) — so a login shell whose dash variant is missing @@ -274,6 +289,7 @@ final class RemoteTmuxControlConnection { // FIFO are stale and must not bleed into the new %begin/%end correlation. parser = RemoteTmuxControlStreamParser() pendingCommands.removeAll() + attachBlockDrained = false stderrBuffer = "" enterReceived = false @@ -401,6 +417,9 @@ final class RemoteTmuxControlConnection { } guard let self, self.connectionState == .connected, let size = self.lastClientSize else { return } self.send("refresh-client -C \(size.columns)x\(size.rows)") + // This send already applied the stored grid — the deferred first-connect + // apply would only duplicate it. + self.pendingFirstConnectSizeApply = false // Do NOT re-capture here. A re-capture would run capture-pane before the // remote app (claude) finishes its post-SIGWINCH redraw, snapshotting the // stale pre-resize frame and clobbering the correct redraw — the exact @@ -689,6 +708,8 @@ final class RemoteTmuxControlConnection { clientSizeDebounceTask = nil attachRedrawKickTask?.cancel() attachRedrawKickTask = nil + pendingAttachRedrawKick = false + pendingFirstConnectSizeApply = false teardownProcessHandles() } @@ -858,11 +879,15 @@ final class RemoteTmuxControlConnection { for window in windowsByID.values { for paneId in window.paneIDsInOrder { observers.emitPaneOutput(paneId, Data("\u{1b}[H\u{1b}[2J\u{1b}[3J".utf8)) + // Reflow classification first so it lands before the (3-command) + // capture seed; the flag only matters at the next resize, and the + // earlier it arrives the smaller the window in which a resize hits + // the conservative no-reflow default on a slow link. + requestPaneReflow(paneId: paneId) + subscribePaneReflow(paneId: paneId) capturePane(paneId: paneId) requestPanePath(paneId: paneId) subscribePanePath(paneId: paneId) - requestPaneReflow(paneId: paneId) - subscribePaneReflow(paneId: paneId) } } } @@ -879,7 +904,8 @@ final class RemoteTmuxControlConnection { // Arm the one-shot attach redraw kick: if the upcoming size apply is // a no-op (window already at our size), a running TUI gets no SIGWINCH // and would keep showing its stale pre-attach frame. Consumed by the - // first size apply (debounced send or reconnect re-seed). + // first size apply (debounced send, reconnect re-seed, or the + // first-connect list-windows result). pendingAttachRedrawKick = true reconnectAttemptCount = 0 reconnectTask?.cancel() @@ -893,15 +919,18 @@ final class RemoteTmuxControlConnection { // mirror seeds new tabs itself). if wasReconnecting { pendingReconnectReseed = true - } else if let size = lastClientSize { + } else { // First connect: if a surface already computed its grid before we // reached `.connected`, setClientSize stored it but dropped the // send (no live stdin yet) and nothing re-applies it on first - // connect — so the remote would stay at ssh's 80×24. Re-emit it - // now. SAFE to send here (unlike the reseed): `refresh-client -C` - // is UNCORRELATED (kind .other, no %begin/%end result block), so - // it does not touch the command-result FIFO the attach block uses. - send("refresh-client -C \(size.columns)x\(size.rows)") + // connect — so the remote would stay at ssh's 80×24. Do NOT send + // it from here: `.enter` is emitted BEFORE the attach's own + // %begin/%end block is consumed, and EVERY send (even kind + // `.other`) joins the positional result FIFO — a command queued + // now would be popped by the attach block and shift every later + // result one slot. Defer to the first list-windows result (attach + // block drained), exactly like the reconnect re-seed above. + pendingFirstConnectSizeApply = true } } case let .exit(reason): @@ -915,6 +944,8 @@ final class RemoteTmuxControlConnection { clientSizeDebounceTask = nil attachRedrawKickTask?.cancel() attachRedrawKickTask = nil + pendingAttachRedrawKick = false + pendingFirstConnectSizeApply = false observers.notifyExit() case let .output(paneId, data): paneOutputByteCounts[paneId, default: 0] += data.count @@ -976,15 +1007,23 @@ final class RemoteTmuxControlConnection { classifyAndEmitReflow(paneId: paneId, rawValue: value, source: "sub") } case let .commandResult(_, lines, isError): - handleCommandResult(lines: lines, isError: isError) + // The first block on each control stream is the attach command's own — + // consume it explicitly so it can never pop a queued command's slot off + // the positional FIFO (see ``attachBlockDrained``). + if !attachBlockDrained { + attachBlockDrained = true + } else { + handleCommandResult(lines: lines, isError: isError) + } case .ignoredNotification, .unparsed: break } } private func handleCommandResult(lines: [String], isError: Bool) { - // The attach command's own block arrives before we queue anything; only - // correlate results once we have an outstanding command. + // The attach block was already consumed upstream (`attachBlockDrained`); + // an empty FIFO here means an unsolicited block — drop it rather than + // misalign the positional correlation. guard !pendingCommands.isEmpty else { return } let kind = pendingCommands.removeFirst() guard !isError else { return } @@ -1034,12 +1073,25 @@ final class RemoteTmuxControlConnection { pendingReconnectReseed = false reseedAfterReconnect() } + // First connect: apply the grid that was stored before `.enter` (the + // attach block is drained here, so the send's result block correlates + // cleanly — see `pendingFirstConnectSizeApply`). A surface that hasn't + // computed a grid yet is covered by the debounced `setClientSize`. + if pendingFirstConnectSizeApply { + pendingFirstConnectSizeApply = false + if let size = lastClientSize { + send("refresh-client -C \(size.columns)x\(size.rows)") + } + } // First-connect coverage for the attach redraw kick: if the grid was - // computed (and size sent) before `.enter`, no post-connect - // `setClientSize` may ever fire (layout settled + same-size dedupe - // upstream), so the debounced-send consumer never runs. This is the - // earliest point where the topology is populated; a no-op when the - // kick was already consumed (or when reseedAfterReconnect just ran it). + // computed before `.enter`, no post-connect `setClientSize` may ever + // fire (layout settled + same-size dedupe upstream), so the + // debounced-send consumer never runs. This is the earliest point with + // populated topology — and `windowsByID` was parsed from THIS + // list-windows reply, generated before tmux processed the size apply + // just queued above, so the at-target check sees the true pre-apply + // geometry. No-op when the kick was already consumed (or when + // reseedAfterReconnect just ran it). scheduleAttachRedrawKickIfNeeded() } case let .capturePane(paneId): diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 609727324984..8c9f1caf3e3c 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -141,13 +141,16 @@ final class RemoteTmuxSessionMirror { ) else { continue } panelIdByWindow[windowId] = panel.id panelIdByPane[firstPaneId] = panel.id - connection.capturePane(paneId: firstPaneId) // Classify the pane (shell → reflow on resize; TUI/alt-screen → no // reflow). One-shot first (always works) so a shell reflows even on // tmux builds where the live subscription doesn't deliver, then the // subscription for live re-classification (e.g. bash → node). + // Queued BEFORE the (3-command) capture so the classification lands + // first — it only matters at the next resize, and arriving early + // shrinks the window in which a resize hits the no-reflow default. connection.requestPaneReflow(paneId: firstPaneId) connection.subscribePaneReflow(paneId: firstPaneId) + connection.capturePane(paneId: firstPaneId) // Track the pane's working directory so the tab shows the remote // cwd (initial value + live `cd`) instead of staying at "~". connection.requestPanePath(paneId: firstPaneId) diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index ad99e7af20d0..0d997d59681e 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -78,11 +78,13 @@ final class RemoteTmuxWindowMirror { guard let panel = makePanel(paneId) else { continue } panelsByPaneId[paneId] = panel syntheticPaneIds[paneId] = PaneID() - connection?.capturePane(paneId: paneId) // One-shot reflow classification (always works) + live subscription, so a // shell pane reflows on resize even where the subscription doesn't deliver. + // Queued before the capture so the classification lands first (it only + // matters at the next resize). connection?.requestPaneReflow(paneId: paneId) connection?.subscribePaneReflow(paneId: paneId) + connection?.capturePane(paneId: paneId) // Track this pane's working directory (initial + live) so the window // tab reflects the remote cwd. The session mirror's cwd observer maps // the pane back to this window's tab. From feff74cb0ee283e1977cc91656b39740c60d3b3f Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Wed, 10 Jun 2026 00:37:43 +0300 Subject: [PATCH 36/73] =?UTF-8?q?remote-tmux:=20shrink=20GhosttyTerminalVi?= =?UTF-8?q?ew=20footprint=20=E2=80=94=20move=20initial=20sizing=20into=20t?= =?UTF-8?q?he=20session=20mirror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GhosttyTerminalView.swift is a shared file; keep the remote-tmux delta in it minimal and move policy into remote-tmux-owned code: - The single-pane initial client-sizing retry moves from TerminalSurface (retry task + constants + two methods + teardown hooks) to RemoteTmuxSessionMirror.scheduleInitialClientSizing — the symmetric twin of the multi-pane RemoteTmuxWindowMirrorView.scheduleClientSize (same shape: one synchronous attempt, then a sleep-first 150ms×20 retry). The surface keeps only a small renderedGridCells() accessor (parallel to cellSizePoints()); updateSize's manual-grid report block returns to the exact upstream text. Surface-readiness notifications (terminalSurfaceDidBecomeReady / HostedViewDidMoveToWindow, the BackgroundWorkspacePrimeCoordinator pattern) re-arm the sizing so a background workspace is sized when first shown — the old in-file retry started at createSurface and covered that implicitly. - applyManualAwareSurfaceSize (single caller) is inlined back into updateSize as a two-condition change to the upstream DECAWM block (suppress = manualIO && manualIONoReflow). - The createSurface block move is reverted; instead the single flushPendingRemoteOutput call moves below the upstream sizing block, with the same buffered-seed-paints-into-sized-grid effect. Shared-file delta drops from +185/−57 to roughly a third of that, all behavior-preserving (verified by a two-round review pipeline). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Sources/GhosttyTerminalView.swift | 268 +++++++++----------------- Sources/RemoteTmuxSessionMirror.swift | 80 ++++++++ 2 files changed, 175 insertions(+), 173 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c19ac82656fb..39c4bde2405b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5464,29 +5464,15 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Last grid reported through ``onManualGridResize`` (so we fire only on a real /// column/row change, not on every sub-cell pixel nudge). private var lastReportedManualGrid: (columns: Int, rows: Int)? - /// For MANUAL-I/O remote tmux display surfaces: whether this pane must NOT - /// reflow/rewrap its primary screen on resize. `true` (the safe default) = - /// suppress reflow — correct for inline TUIs like claude (whose box-drawing - /// can't rewrap) and for alt-screen apps. `false` = a plain shell on the - /// primary screen, so ghostty natively reflows its seeded scrollback on resize - /// (shrink rewraps, grow restores, scroll position preserved) — the behavior a - /// normal terminal attached to tmux has. Driven live by the remote pane's - /// classification (`#{alternate_on}` + `#{pane_current_command}`); consulted in - /// ``updateSize(...)`` to gate the DECAWM no-reflow toggle. Default `true` so a - /// pane is never reflowed before it's classified (a reattached claude can't - /// briefly rewrap during the subscription's first-emit latency). + /// For MANUAL-I/O remote tmux display surfaces: whether to suppress ghostty's + /// primary-screen reflow on resize. `true` (the safe default until classified) + /// for inline-TUI / alt-screen panes; `false` for plain shells, which reflow + /// like a real terminal. Driven by the remote pane's live classification via + /// ``setManualIONoReflow(_:)``; consulted by the DECAWM gate in ``updateSize``. private var manualIONoReflow: Bool = true /// Retained userdata for the MANUAL-mode `io_write_cb`; released alongside /// the surface (see ``teardownSurface()``). private var manualIOContext: Unmanaged<RemoteTmuxManualIOWriteBox>? - /// Brief retry that guarantees the rendered grid is pushed to the remote tmux - /// client on attach even when `createSurface` stamped the final grid (so the - /// post-create `updateSize` sees no size change and never reports). See - /// ``scheduleInitialManualGridReport()``. Cancelled on teardown. - /// ~3s of 150ms ticks, matching `RemoteTmuxWindowMirrorView.scheduleClientSize`. - private var manualGridReportRetryTask: Task<Void, Never>? - private static let manualGridReportRetryIntervalMs = 150 - private static let manualGridReportRetryCount = 20 /// Output delivered via ``processRemoteOutput(_:)`` before the runtime /// surface exists (e.g. a background remote-tmux workspace not yet hosted). /// Flushed into the surface once it is created so content isn't lost. @@ -5970,6 +5956,23 @@ final class TerminalSurface: Identifiable, ObservableObject { ) } + /// The on-screen rendered grid (columns × rows), or `nil` while the runtime + /// surface isn't live, isn't in a window, or has no real grid yet. The remote + /// tmux mirrors poll this briefly on attach to size the remote client (see + /// `RemoteTmuxSessionMirror.scheduleInitialClientSizing`): `updateSize` only + /// reports a grid CHANGE, so a surface whose final grid was already stamped + /// at creation would otherwise never report it. + @MainActor + func renderedGridCells() -> (columns: Int, rows: Int)? { + guard attachedView?.window != nil, + let surface = liveSurfaceForGhosttyAccess(reason: "renderedGridCells") else { return nil } + let size = ghostty_surface_size(surface) + let cols = Int(size.columns) + let rows = Int(size.rows) + guard cols > 1, rows > 1 else { return nil } + return (cols, rows) + } + /// Forward a mobile tap to this real surface as a left mouse click at the /// given grid cell. libghostty does the mode-correct thing: a program with /// mouse reporting (alt-screen TUIs like lazygit/htop/fzf) gets an encoded @@ -6203,8 +6206,6 @@ final class TerminalSurface: Identifiable, ObservableObject { // the surface is focused, which a surface being torn down is not. manualIOContext?.release() manualIOContext = nil - manualGridReportRetryTask?.cancel() - manualGridReportRetryTask = nil let surfaceToFree = surface if let surfaceToFree { @@ -6267,8 +6268,6 @@ final class TerminalSurface: Identifiable, ObservableObject { // the surface is focused, which a surface being torn down is not. manualIOContext?.release() manualIOContext = nil - manualGridReportRetryTask?.cancel() - manualGridReportRetryTask = nil let surfaceToFree = surface if let surfaceToFree { @@ -6893,30 +6892,6 @@ final class TerminalSurface: Identifiable, ObservableObject { guard let createdSurface = surface else { return } TerminalSurfaceRegistry.shared.registerRuntimeSurface(createdSurface, ownerId: id) recordRuntimeSurfaceCreation() - // Size the surface to its grid BEFORE flushing the buffered seed. The - // remote-tmux capture seed is full-width (the remote pane's width); flushing - // it while the surface is still at ghostty_surface_new's default grid wraps - // the wide rows, and the later (no-reflow) set_size can't recover them — so - // a seed buffered before surface creation (e.g. a background workspace) - // would render mis-wrapped. Plain set_size only — NO render_now / DECAWM - // toggle here (that races the renderer during init and caused a SIGBUS; - // see updateSize). - ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) - let initialBackingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size - let initialWpx = pixelDimension(from: initialBackingSize.width) - let initialHpx = pixelDimension(from: initialBackingSize.height) - if initialWpx > 0, initialHpx > 0 { - ghostty_surface_set_size(createdSurface, initialWpx, initialHpx) - lastPixelWidth = initialWpx - lastPixelHeight = initialHpx - lastUncappedPixelWidth = initialWpx - lastUncappedPixelHeight = initialHpx - lastXScale = scaleFactors.x - lastYScale = scaleFactors.y - } - - // Now flush the buffered remote-tmux output into the correctly-sized grid. - flushPendingRemoteOutput(to: createdSurface) // Install the PTY tee so MobileTerminalByteTee receives every byte // the read thread produces, in order, before the VT parser runs. // Paired iPhones consume these bytes via `terminal.bytes` events @@ -6952,8 +6927,25 @@ final class TerminalSurface: Identifiable, ObservableObject { ghostty_surface_set_display_id(createdSurface, displayID) } - // (Surface sizing now happens BEFORE the seed flush above, so the captured - // full-width rows paint into the correctly-sized grid.) + ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) + let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size + let wpx = pixelDimension(from: backingSize.width) + let hpx = pixelDimension(from: backingSize.height) + if wpx > 0, hpx > 0 { + ghostty_surface_set_size(createdSurface, wpx, hpx) + lastPixelWidth = wpx + lastPixelHeight = hpx + lastUncappedPixelWidth = wpx + lastUncappedPixelHeight = hpx + lastXScale = scaleFactors.x + lastYScale = scaleFactors.y + } + + // Flush any remote-tmux output that arrived before the surface existed — + // AFTER the sizing above, so a buffered capture seed (e.g. a background + // remote-tmux workspace) paints into the final grid instead of wrapping at + // the default-size grid, which the no-reflow resize could never repair. + flushPendingRemoteOutput(to: createdSurface) // Some GhosttyKit builds can drop inherited font_size during post-create // config/scale reconciliation. If runtime points don't match the inherited @@ -6985,14 +6977,6 @@ final class TerminalSurface: Identifiable, ObservableObject { view.forceRefreshSurface() ghostty_surface_refresh(createdSurface) - // Push the rendered grid to the remote tmux client now. createSurface stamps - // the final grid above and (correctly) doesn't reflow, so the post-create - // updateSize sees no size change and would never report it — leaving the - // remote at ssh's 80×24 and a single-pane mirror (e.g. claude) rendered into - // a sub-region. This retrying push is the single-pane analogue of the - // multi-pane mirror's scheduleClientSize. No-op for non-manual surfaces. - scheduleInitialManualGridReport() - NotificationCenter.default.post( name: .terminalSurfaceDidBecomeReady, object: self, @@ -7069,96 +7053,71 @@ final class TerminalSurface: Identifiable, ObservableObject { } if sizeChanged { - // Mirror (manual-I/O) panes reflow CONDITIONALLY on resize. A plain - // shell's primary-screen scrollback SHOULD reflow (shrink rewraps long - // lines, grow restores them, the viewport stays anchored — exactly like - // a real terminal attached to tmux); without it a shrink hard-clips the - // seeded scrollback and a grow never restores it. But an inline TUI like - // claude (and any alt-screen app) must NOT reflow, or its box-drawing - // rewraps and corrupts — tmux owns the grid and only streams the app's - // post-SIGWINCH redraw, so a client that reflows independently diverges - // (iTerm2 likewise doesn't reflow TUI panes). The per-pane choice is - // `manualIONoReflow`, driven live by the remote pane's classification - // (`#{alternate_on}` + `#{pane_current_command}`) and defaulting to - // no-reflow so a not-yet-classified (e.g. reattached) claude is never - // rewrapped. (Reflow of a shell pane works from the LIVE %output, which - // already carries real soft-wraps; the capture seed is left faithful — - // `capture-pane -J` was tried but corrupted TUI seeds, so it was dropped.) - // The DECAWM-gated sizing lives in `applyManualAwareSurfaceSize`; it is - // used only here on live resizes, never in createSurface (which must not - // inject bytes / force a render during surface init — that races the - // renderer thread). - applyManualAwareSurfaceSize(surface, width: wpx, height: hpx) + // Mirror (manual-I/O) surfaces must NOT reflow/rewrap their primary + // screen on resize. tmux is the authority on a pane's reflow and only + // streams the app's incremental post-SIGWINCH redraw (never a full grid + // repaint), so a client that reflows independently diverges from tmux — + // inline TUIs like claude then render misaligned after a resize. iTerm2 + // avoids this by never reflowing locally ("tmux owns the grid"). ghostty + // reflows iff DECAWM (wraparound) is on at resize time, so disable it + // across the resize and restore it after. No writes occur during this + // window, so autowrap is otherwise unaffected. (A ghostty "no-reflow + // surface" flag would be cleaner but needs a GhosttyKit rebuild.) + // + // Exception: a pane whose remote foreground is a plain PRIMARY-screen + // shell (`manualIONoReflow == false`, classified live from + // `#{alternate_on}` + `#{pane_current_command}`) keeps DECAWM on, so + // ghostty natively reflows its seeded scrollback like a real terminal + // (shrink rewraps long lines, grow restores them). The default is + // no-reflow until classified, so a TUI is never rewrapped. + // + // Caveat (no-reflow panes): this restores DECAWM to ON unconditionally. + // That is correct for the overwhelmingly common case (DECAWM defaults + // to on); a remote app that had explicitly turned it OFF is transiently + // re-enabled until its next %output re-asserts the mode (self-healing). + // A faithful save/restore would need a ghostty read-mode API that + // doesn't exist. + let suppressManualReflow = manualIO && manualIONoReflow + if suppressManualReflow { + writeProcessOutputData(Self.decawmDisableSequence, to: surface) + } + ghostty_surface_set_size(surface, wpx, hpx) lastPixelWidth = wpx lastPixelHeight = hpx + // For manual-I/O surfaces the `.manual` termio backend applies the + // terminal resize synchronously inside set_size (with DECAWM off, so it + // does not reflow), so the buffer already matches the just-set grid here. + // render_now forces a GPU frame so the resized grid is shown promptly + // before the post-resize %output arrives; then DECAWM is restored. This + // fires only on an actual size change (never while typing). + if manualIO { + ghostty_surface_render_now(surface) + if suppressManualReflow { + writeProcessOutputData(Self.decawmEnableSequence, to: surface) + } + } } // Remote tmux display surfaces: keep the remote tmux client sized to the // rendered grid so a freshly attached session doesn't stay at ssh's - // default 80×24 (which mangles TUIs like claude / claude agents). Shared - // with the initial-report retry so both size the remote the same way. - reportManualGridIfNeeded() + // default 80×24 (which mangles TUIs like claude / claude agents). Only + // report when actually on screen and when the cell grid — not just the + // pixel area — changed. + if manualIO, let report = onManualGridResize, attachedView?.window != nil { + let grid = ghostty_surface_size(surface) + let cols = Int(grid.columns) + let rows = Int(grid.rows) + if cols > 1, rows > 1, + lastReportedManualGrid?.columns != cols || lastReportedManualGrid?.rows != rows { + lastReportedManualGrid = (cols, rows) + report(cols, rows) + } + } // Let Ghostty continue rendering on its own wakeups for steady-state frames. return true } - /// Reports the current rendered grid (cols×rows) to the remote tmux client via - /// ``onManualGridResize``, when this is a manual-I/O mirror surface that is - /// live, in-window, has a valid grid, and whose grid changed since the last - /// report. Returns `true` once a valid in-window grid has been reported (or was - /// already current) — the initial-report retry uses that to know it can stop. - /// - /// This is the SINGLE place a single-pane mirror sizes the remote. It is reached - /// two ways: live resizes via ``updateSize(...)``, and ``scheduleInitialManualGridReport()`` - /// — because `createSurface` stamps the final grid and bypasses this, so the - /// post-create `updateSize` sees no size change and would otherwise never report, - /// leaving the remote at ssh's 80×24 and claude rendered into a sub-region. - @discardableResult - @MainActor - private func reportManualGridIfNeeded() -> Bool { - guard manualIO, let report = onManualGridResize, attachedView?.window != nil else { return false } - guard let surface = liveSurfaceForGhosttyAccess(reason: "reportManualGrid") else { return false } - let grid = ghostty_surface_size(surface) - let cols = Int(grid.columns) - let rows = Int(grid.rows) - guard cols > 1, rows > 1 else { return false } - if lastReportedManualGrid?.columns != cols || lastReportedManualGrid?.rows != rows { - lastReportedManualGrid = (cols, rows) - report(cols, rows) - } - return true - } - - /// Ensures the remote tmux client is sized to the rendered grid on attach, even - /// when `createSurface` stamped the final grid (so the post-create `updateSize` - /// sees no size change and never reports — the dominant cause of a single-pane - /// mirror rendering at the stale 80×24 width/height). Reports immediately if it - /// can, else retries briefly until the surface is live + in-window with a valid - /// grid, then reports once; later resizes flow through ``updateSize(...)``. This - /// is the single-pane analogue of the multi-pane mirror's `scheduleClientSize` - /// retry. No-op for non-manual surfaces or those without a resize hook. - @MainActor - private func scheduleInitialManualGridReport() { - guard manualIO, onManualGridResize != nil else { return } - if reportManualGridIfNeeded() { return } - manualGridReportRetryTask?.cancel() - manualGridReportRetryTask = Task { @MainActor [weak self] in - for _ in 0..<Self.manualGridReportRetryCount { - do { - try await ContinuousClock().sleep( - for: .milliseconds(Self.manualGridReportRetryIntervalMs)) - } catch { return } - guard let self else { return } - // The hook is cleared when a single-pane window is promoted to a - // multi-pane mirror — the report can never succeed then, so stop - // instead of no-op-ticking out the rest of the retry budget. - guard self.onManualGridResize != nil else { return } - if self.reportManualGridIfNeeded() { return } - } - } - } - @discardableResult @MainActor func applyMobileViewportLimit(columns: Int, rows: Int, reason: String) -> Bool { @@ -7954,43 +7913,6 @@ final class TerminalSurface: Identifiable, ObservableObject { manualIONoReflow = value } - /// Applies a LIVE-resize surface pixel-size change (from ``updateSize``), - /// honoring the manual-I/O no-reflow policy. For a manual-I/O (remote tmux - /// mirror) pane classified as no-reflow (``manualIONoReflow``, the default until - /// a pane is classified), DECAWM (autowrap) is disabled across - /// `ghostty_surface_set_size` so ghostty does not reflow/rewrap the primary - /// screen — an inline TUI's box-drawing must not rewrap — then restored; a pane - /// classified as a plain shell reflows natively. `render_now` forces a prompt - /// GPU frame for manual-I/O surfaces. Non-manual surfaces just set the size - /// (no toggle, no render_now), matching prior behavior. Must be on the main actor. - /// - /// NOT used by `createSurface`: injecting process-output bytes (the DECAWM - /// toggle) and forcing a synchronous render during surface init races the - /// renderer thread (observed SIGBUS) and can mis-size the initial grid. A fresh - /// surface has no prior grid to reflow, so it just calls `ghostty_surface_set_size`. - /// - /// Caveat (no-reflow panes only): the toggle restores DECAWM to ON - /// unconditionally. Correct for the common case (DECAWM defaults on); an app - /// that had explicitly turned it OFF is transiently re-enabled until its next - /// %output re-asserts the mode (self-healing). A faithful save/restore would - /// need a ghostty read-mode API that doesn't exist. - @MainActor - private func applyManualAwareSurfaceSize( - _ surface: ghostty_surface_t, width: UInt32, height: UInt32 - ) { - let suppressReflow = manualIO && manualIONoReflow - if suppressReflow { - writeProcessOutputData(Self.decawmDisableSequence, to: surface) - } - ghostty_surface_set_size(surface, width, height) - if manualIO { - ghostty_surface_render_now(surface) - if suppressReflow { - writeProcessOutputData(Self.decawmEnableSequence, to: surface) - } - } - } - /// Injects raw terminal output bytes into this surface's VT parser, as if /// they came from a PTY. Used by MANUAL-I/O remote-tmux display surfaces to /// render a remote pane's `%output`. If the runtime surface doesn't exist diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 8c9f1caf3e3c..eabdccfaa9f1 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -29,6 +29,14 @@ final class RemoteTmuxSessionMirror { /// Per-window multi-pane renderers (present once a window has >1 pane). private var windowMirrorByWindowId: [Int: RemoteTmuxWindowMirror] = [:] private var observerToken: RemoteTmuxControlConnection.ObserverToken? + /// Initial client-sizing retry; see ``scheduleInitialClientSizing()``. + private var initialSizingTask: Task<Void, Never>? + /// Re-arm the initial sizing when one of this workspace's surfaces becomes + /// ready / enters a window: a background workspace's surfaces may not even + /// EXIST while the rebuild-time retry runs (they are created when the + /// workspace is first shown), so that retry alone could expire and leave the + /// remote at ssh's default 80×24. Removed in ``detachObserver()``. + private var surfaceReadyObservers: [NSObjectProtocol] = [] init( host: RemoteTmuxHost, @@ -65,6 +73,27 @@ final class RemoteTmuxSessionMirror { } ) rebuild() + installSurfaceReadinessObservers(workspaceId: workspace.id) + } + + /// Observes surface readiness/window-attachment for this workspace and re-arms + /// ``scheduleInitialClientSizing()`` — the sizing only succeeds once a surface + /// is live and in a window, which for a background workspace happens long + /// after `rebuild()`. Same observation pattern as + /// `BackgroundWorkspacePrimeCoordinator.installReadinessObservers`. + private func installSurfaceReadinessObservers(workspaceId: UUID) { + let names: [Notification.Name] = [ + .terminalSurfaceDidBecomeReady, .terminalSurfaceHostedViewDidMoveToWindow, + ] + for name in names { + surfaceReadyObservers.append(NotificationCenter.default.addObserver( + forName: name, object: nil, queue: .main + ) { [weak self] notification in + guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, + readyWorkspaceId == workspaceId else { return } + Task { @MainActor in self?.scheduleInitialClientSizing() } + }) + } } /// The remote session ended for good (its last tmux window was killed, it was @@ -91,6 +120,10 @@ final class RemoteTmuxSessionMirror { /// multi-pane renderers (called when the mirror is torn down so its callbacks /// don't linger on a shared connection and its pane surfaces don't leak). func detachObserver() { + initialSizingTask?.cancel() + initialSizingTask = nil + for observer in surfaceReadyObservers { NotificationCenter.default.removeObserver(observer) } + surfaceReadyObservers.removeAll() if let observerToken { connection.removeObserver(observerToken) self.observerToken = nil @@ -113,6 +146,7 @@ final class RemoteTmuxSessionMirror { /// local tab(s) once at least one remote tab exists. func rebuild() { guard let workspace else { return } + var createdNewPanel = false for windowId in connection.windowOrder { guard let window = connection.windowsByID[windowId], let firstPaneId = window.paneIDsInOrder.first else { continue } @@ -156,9 +190,11 @@ final class RemoteTmuxSessionMirror { connection.requestPanePath(paneId: firstPaneId) connection.subscribePanePath(paneId: firstPaneId) panelId = panel.id + createdNewPanel = true } reconcileWindowMirror(windowId: windowId, panelId: panelId, window: window, in: workspace) } + if createdNewPanel { scheduleInitialClientSizing() } // Close tabs for windows tmux removed, so a closed remote window doesn't // leave a frozen tab behind. let liveWindows = Set(connection.windowOrder) @@ -189,6 +225,50 @@ final class RemoteTmuxSessionMirror { } } + /// Brief retry that sizes the remote tmux client to a single-pane tab's + /// rendered grid on attach. Needed because `createSurface` stamps the final + /// grid before the tab is on screen, and `TerminalSurface.updateSize` only + /// reports grid CHANGES — so without an initial push the remote would stay at + /// ssh's default 80×24 (mangling TUIs) until the user resizes the window. + /// This is the single-pane analogue of the multi-pane path's + /// `RemoteTmuxWindowMirrorView.scheduleClientSize` (same shape: one synchronous + /// attempt, then a sleep-first retry). One push from the first on-screen + /// surface suffices (the tmux client has a single size); live resizes + /// afterwards flow through the panel's `onResize` hook. Re-armed by the + /// surface-readiness observers whenever a surface becomes displayable, so a + /// background workspace is sized when first shown even though this retry + /// budget expired long before. + private func scheduleInitialClientSizing() { + initialSizingTask?.cancel() + if pushInitialClientSize() { return } + initialSizingTask = Task { @MainActor [weak self] in + for _ in 0..<20 { + do { try await Task.sleep(for: .milliseconds(150)) } catch { return } + guard let self else { return } + if self.pushInitialClientSize() { return } + } + } + } + + /// One initial-sizing attempt. Returns `true` when there is nothing (more) to + /// do: the size was pushed from the first single-pane surface with an + /// on-screen grid, or no single-pane window remains to size (multi-pane + /// windows are skipped — their mirror view owns client sizing). + private func pushInitialClientSize() -> Bool { + guard let workspace else { return true } + let singlePanePanelIds = panelIdByWindow + .filter { windowMirrorByWindowId[$0.key] == nil } + .values + guard !singlePanePanelIds.isEmpty else { return true } + for panelId in singlePanePanelIds { + guard let panel = workspace.panels[panelId] as? TerminalPanel, + let grid = panel.surface.renderedGridCells() else { continue } + connection.setClientSize(columns: grid.columns, rows: grid.rows) + return true + } + return false + } + /// Creates the in-tab multi-pane renderer the first time a window has more /// than one pane, and reconciles it on subsequent layout changes. Once /// created it persists for that window (rendering even a single pane), so the From f4a003c0a1d706a244dcccebde9344561d802f43 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Wed, 10 Jun 2026 11:33:05 +0300 Subject: [PATCH 37/73] remote-tmux: simplification pass (seedPane helper, PostAttachAction enum, shared teardown, shared-file cleanup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving cleanup from a simplification review of the PR: - Add RemoteTmuxControlConnection.seedPane(paneId:) replacing the 5-command seed sequence duplicated across reseedAfterReconnect, RemoteTmuxSessionMirror.rebuild, and RemoteTmuxWindowMirror.reconcile; the classification-before-capture rationale now lives in one place. - Unify the pendingReconnectReseed / pendingFirstConnectSizeApply one-shots into a single PostAttachAction enum (.reseed / .applyClientSize) set on .enter and consumed at the first list-windows result; the debounced size send clears only .applyClientSize so a pending reseed survives. - Extract cancelScheduledWork() shared by stop() and %exit — also fixes the drift where neither path cleared the reseed flag. - Inline single-statement handleWriteFailure(); drop a redundant empty-input guard in handleWindowWorkspacesClosed; flatten two IIFEs in RemoteTmuxSessionListParser; replace detachObserver's mutate-while-iterating loop with iterate + removeAll(). - Move the pure mirrorTabReorder static from Workspace.swift to RemoteTmuxSessionMirror (remote-tmux logic out of a shared file); retarget its tests. - Re-indent the misindented else branch in WorkspaceContentView (whitespace-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Sources/RemoteTmuxControlConnection.swift | 145 +++++++++---------- Sources/RemoteTmuxController.swift | 1 - Sources/RemoteTmuxSessionListParser.swift | 12 +- Sources/RemoteTmuxSessionMirror.swift | 36 +++-- Sources/RemoteTmuxWindowMirror.swift | 15 +- Sources/Workspace.swift | 18 +-- Sources/WorkspaceContentView.swift | 90 ++++++------ cmuxTests/RemoteTmuxControlParserTests.swift | 10 +- 8 files changed, 147 insertions(+), 180 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index eaada5f072b9..b0ac58008b2d 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -97,17 +97,18 @@ final class RemoteTmuxControlConnection { /// after a reconnect so the resumed session keeps the mirror's grid instead of /// reverting to ssh's default 80×24. private var lastClientSize: (columns: Int, rows: Int)? - /// Set when a reconnect reaches control mode; the actual pane re-seed is deferred - /// to the first post-reconnect `list-windows` result so it can't queue commands - /// before the attach's own `%begin`/`%end` block is consumed (which would misalign - /// the command-result FIFO). - private var pendingReconnectReseed = false - /// Set on a FIRST connect whose `.enter` arrived with a grid already stored (or - /// not yet computed); the `refresh-client -C` re-apply is deferred to the first - /// `list-windows` result for the same FIFO reason as ``pendingReconnectReseed`` - /// — `.enter` precedes the attach block, and any command queued before that - /// block is consumed would shift the positional result FIFO. - private var pendingFirstConnectSizeApply = false + /// Work deferred from `.enter` to the first `list-windows` result: `.enter` + /// precedes the attach's own `%begin`/`%end` block, and any command queued + /// before that block is consumed would shift the positional result FIFO. + private enum PostAttachAction { + /// Reconnect: re-seed every mirrored pane (the fresh client lost the + /// screen, subscriptions, and client size). + case reseed + /// First connect: re-apply a client grid stored before stdin was live, + /// so the remote doesn't stay at ssh's default 80×24. + case applyClientSize + } + private var pendingPostAttachAction: PostAttachAction? /// Trailing-edge debounce for `refresh-client -C`. SwiftUI layout settle makes the /// rendered grid oscillate (e.g. cols 154→155→156→161→…, ~15 distinct grids in @@ -418,8 +419,10 @@ final class RemoteTmuxControlConnection { guard let self, self.connectionState == .connected, let size = self.lastClientSize else { return } self.send("refresh-client -C \(size.columns)x\(size.rows)") // This send already applied the stored grid — the deferred first-connect - // apply would only duplicate it. - self.pendingFirstConnectSizeApply = false + // apply would only duplicate it (a deferred reconnect re-seed must stay). + if self.pendingPostAttachAction == .applyClientSize { + self.pendingPostAttachAction = nil + } // Do NOT re-capture here. A re-capture would run capture-pane before the // remote app (claude) finishes its post-SIGWINCH redraw, snapshotting the // stale pre-resize frame and clobbering the correct redraw — the exact @@ -582,6 +585,21 @@ final class RemoteTmuxControlConnection { ) } + /// Seeds (or re-seeds) a mirrored pane in the one canonical sequence: reflow + /// classification FIRST (the one-shot query — always works — then the live + /// subscription for re-classification, e.g. bash → node), then the content + /// capture, then cwd tracking (initial value + live `cd`). Classification is + /// queued before the (3-command) capture because it only matters at the next + /// resize — the earlier it lands, the smaller the window in which a resize + /// hits the conservative no-reflow default on a slow link. + func seedPane(paneId: Int) { + requestPaneReflow(paneId: paneId) + subscribePaneReflow(paneId: paneId) + capturePane(paneId: paneId) + requestPanePath(paneId: paneId) + subscribePanePath(paneId: paneId) + } + /// One-shot query of a pane's working directory (`pane_current_path`), /// delivered to the cwd observers. Guarantees an initial folder for the /// mirrored tab even on tmux builds without control-mode subscriptions. @@ -702,6 +720,14 @@ final class RemoteTmuxControlConnection { // `%exit` or a session found gone on reconnect) notifies exit observers — so // detach / quit / window-close (preserve) and transport drops do not. connectionState = .ended + cancelScheduledWork() + teardownProcessHandles() + } + + /// Cancels every scheduled follow-up (reconnect, debounced size send, redraw + /// kick) and the deferred post-attach work. Shared by deliberate teardown + /// (``stop()``) and a genuine remote end (`%exit`). + private func cancelScheduledWork() { reconnectTask?.cancel() reconnectTask = nil clientSizeDebounceTask?.cancel() @@ -709,8 +735,7 @@ final class RemoteTmuxControlConnection { attachRedrawKickTask?.cancel() attachRedrawKickTask = nil pendingAttachRedrawKick = false - pendingFirstConnectSizeApply = false - teardownProcessHandles() + pendingPostAttachAction = nil } /// Tears down the current spawn's process and I/O handles WITHOUT changing @@ -750,10 +775,12 @@ final class RemoteTmuxControlConnection { // The control pipe is dead (broken pipe). Crucially, do NOT enqueue // a pending command for a write that never reached tmux: the // %begin/%end correlation FIFO is positional, so one phantom entry - // permanently misaligns every subsequent command result. Tear the - // connection down instead and let observers reconnect. + // permanently misaligns every subsequent command result. Keep the + // mirror frozen and reconnect instead — the remote tmux session + // survives an ssh client death (`beginReconnecting` guards the + // source state). record("stdin-write-failed") - handleWriteFailure() + beginReconnecting() return } // Record only after the bytes are confirmed written, so the pending @@ -761,13 +788,6 @@ final class RemoteTmuxControlConnection { pendingCommands.append(kind) } - private func handleWriteFailure() { - // A broken-pipe write means the transport dropped. Keep the mirror frozen - // and reconnect (the remote tmux session survives an ssh client death) - // rather than ending. `beginReconnecting` guards the source state. - beginReconnecting() - } - private func ingest(_ data: Data) { for message in parser.feed(data) { handle(message) @@ -879,15 +899,7 @@ final class RemoteTmuxControlConnection { for window in windowsByID.values { for paneId in window.paneIDsInOrder { observers.emitPaneOutput(paneId, Data("\u{1b}[H\u{1b}[2J\u{1b}[3J".utf8)) - // Reflow classification first so it lands before the (3-command) - // capture seed; the flag only matters at the next resize, and the - // earlier it arrives the smaller the window in which a resize hits - // the conservative no-reflow default on a slow link. - requestPaneReflow(paneId: paneId) - subscribePaneReflow(paneId: paneId) - capturePane(paneId: paneId) - requestPanePath(paneId: paneId) - subscribePanePath(paneId: paneId) + seedPane(paneId: paneId) } } } @@ -910,42 +922,24 @@ final class RemoteTmuxControlConnection { reconnectAttemptCount = 0 reconnectTask?.cancel() reconnectTask = nil - // Resync the surfaces after a reconnect (a fresh client lost the - // screen/subscriptions). DON'T reseed here: the attach's own - // %begin/%end block hasn't been consumed yet, so queuing CORRELATED - // commands now would misalign the %end correlation FIFO. Defer until - // the first post-reconnect list-windows result (attach block drained, - // windowsByID freshly repopulated). Skipped on the first connect (the - // mirror seeds new tabs itself). - if wasReconnecting { - pendingReconnectReseed = true - } else { - // First connect: if a surface already computed its grid before we - // reached `.connected`, setClientSize stored it but dropped the - // send (no live stdin yet) and nothing re-applies it on first - // connect — so the remote would stay at ssh's 80×24. Do NOT send - // it from here: `.enter` is emitted BEFORE the attach's own - // %begin/%end block is consumed, and EVERY send (even kind - // `.other`) joins the positional result FIFO — a command queued - // now would be popped by the attach block and shift every later - // result one slot. Defer to the first list-windows result (attach - // block drained), exactly like the reconnect re-seed above. - pendingFirstConnectSizeApply = true - } + // DON'T send anything from here: `.enter` is emitted BEFORE the + // attach's own %begin/%end block is consumed, and EVERY send (even + // kind `.other`) joins the positional result FIFO — a command + // queued now would be popped by the attach block and shift every + // later result one slot. Defer to the first list-windows result + // (attach block drained, windowsByID freshly repopulated): a + // reconnect re-seeds the surfaces (the fresh client lost the + // screen and subscriptions); a first connect re-applies a grid + // that `setClientSize` stored before stdin was live (nothing else + // re-applies it, and the remote would stay at ssh's 80×24). + pendingPostAttachAction = wasReconnecting ? .reseed : .applyClientSize } case let .exit(reason): record("exit\(reason.map { " " + $0 } ?? "")") // A genuine remote end (session/server intentionally exited). No reconnect. guard connectionState != .ended else { return } connectionState = .ended - reconnectTask?.cancel() - reconnectTask = nil - clientSizeDebounceTask?.cancel() - clientSizeDebounceTask = nil - attachRedrawKickTask?.cancel() - attachRedrawKickTask = nil - pendingAttachRedrawKick = false - pendingFirstConnectSizeApply = false + cancelScheduledWork() observers.notifyExit() case let .output(paneId, data): paneOutputByteCounts[paneId, default: 0] += data.count @@ -1066,23 +1060,22 @@ final class RemoteTmuxControlConnection { paneOutputByteCounts = paneOutputByteCounts.filter { livePanes.contains($0.key) } windowOrder = order observers.notifyTopologyChanged() - // Now that the attach block is drained and the topology is fresh, run - // the deferred reconnect re-seed (re-capture each pane). Queuing the - // capture commands here keeps the result FIFO aligned. - if pendingReconnectReseed { - pendingReconnectReseed = false + // The attach block is drained and the topology is fresh — run the + // deferred post-attach work; commands queued here correlate cleanly + // (see ``PostAttachAction``). + switch pendingPostAttachAction { + case .reseed: reseedAfterReconnect() - } - // First connect: apply the grid that was stored before `.enter` (the - // attach block is drained here, so the send's result block correlates - // cleanly — see `pendingFirstConnectSizeApply`). A surface that hasn't - // computed a grid yet is covered by the debounced `setClientSize`. - if pendingFirstConnectSizeApply { - pendingFirstConnectSizeApply = false + case .applyClientSize: + // A surface that hasn't computed a grid yet is covered by the + // debounced `setClientSize` instead. if let size = lastClientSize { send("refresh-client -C \(size.columns)x\(size.rows)") } + case nil: + break } + pendingPostAttachAction = nil // First-connect coverage for the attach redraw kick: if the grid was // computed before `.enter`, no post-connect `setClientSize` may ever // fire (layout settled + same-size dedupe upstream), so the diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 2221bcccf42e..a090875f2f66 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -642,7 +642,6 @@ final class RemoteTmuxController { /// Window close = detach + preserve remote (no kill); pane surfaces are torn /// down via `detachObserver`. func handleWindowWorkspacesClosed(workspaceIds: [UUID]) { - guard !workspaceIds.isEmpty else { return } let ids = Set(workspaceIds) var affectedHosts: [String: RemoteTmuxHost] = [:] for (key, mirror) in sessionMirrors { diff --git a/Sources/RemoteTmuxSessionListParser.swift b/Sources/RemoteTmuxSessionListParser.swift index ce321a591e5f..53f7b042fcff 100644 --- a/Sources/RemoteTmuxSessionListParser.swift +++ b/Sources/RemoteTmuxSessionListParser.swift @@ -28,14 +28,10 @@ enum RemoteTmuxSessionListParser { let name = fields[1] guard !id.isEmpty else { continue } let windowCount = Int(fields[2].trimmingCharacters(in: .whitespaces)) ?? 0 - let attached: Bool = { - guard fields.count >= 4 else { return false } - return (Int(fields[3].trimmingCharacters(in: .whitespaces)) ?? 0) > 0 - }() - let createdUnix: Int? = { - guard fields.count >= 5 else { return nil } - return Int(fields[4].trimmingCharacters(in: .whitespaces)) - }() + let attached = fields.count >= 4 + && (Int(fields[3].trimmingCharacters(in: .whitespaces)) ?? 0) > 0 + let createdUnix: Int? = fields.count >= 5 + ? Int(fields[4].trimmingCharacters(in: .whitespaces)) : nil sessions.append( RemoteTmuxSession( id: id, diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index eabdccfaa9f1..2d54bab01e7e 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -128,11 +128,11 @@ final class RemoteTmuxSessionMirror { connection.removeObserver(observerToken) self.observerToken = nil } - for (windowId, mirror) in windowMirrorByWindowId { + for mirror in windowMirrorByWindowId.values { workspace?.setRemoteTmuxWindowMirror(nil, forPanelId: mirror.panelId) mirror.teardown() - windowMirrorByWindowId[windowId] = nil } + windowMirrorByWindowId.removeAll() } /// The tmux window id (if any) whose layout currently contains `paneId`. @@ -175,20 +175,7 @@ final class RemoteTmuxSessionMirror { ) else { continue } panelIdByWindow[windowId] = panel.id panelIdByPane[firstPaneId] = panel.id - // Classify the pane (shell → reflow on resize; TUI/alt-screen → no - // reflow). One-shot first (always works) so a shell reflows even on - // tmux builds where the live subscription doesn't deliver, then the - // subscription for live re-classification (e.g. bash → node). - // Queued BEFORE the (3-command) capture so the classification lands - // first — it only matters at the next resize, and arriving early - // shrinks the window in which a resize hits the no-reflow default. - connection.requestPaneReflow(paneId: firstPaneId) - connection.subscribePaneReflow(paneId: firstPaneId) - connection.capturePane(paneId: firstPaneId) - // Track the pane's working directory so the tab shows the remote - // cwd (initial value + live `cd`) instead of staying at "~". - connection.requestPanePath(paneId: firstPaneId) - connection.subscribePanePath(paneId: firstPaneId) + connection.seedPane(paneId: firstPaneId) panelId = panel.id createdNewPanel = true } @@ -445,4 +432,21 @@ final class RemoteTmuxSessionMirror { } return nil } + + /// Computes the target tab order for a remote-tmux-driven reorder, or `nil` + /// when no reorder is needed or safe. Pure helper called by + /// `Workspace.reorderRemoteTmuxMirrorTabs(toPanelOrder:)`. + /// + /// - Parameters: + /// - current: the workspace's current mirror-tab order (panel ids). + /// - requested: the tmux window order mapped to panel ids. + /// - Returns: the new order to apply, or `nil` when the tabs already match + /// `requested` or when `requested` (restricted to currently-present tabs) is + /// not a permutation of `current` (sets diverge — leave the tabs untouched). + nonisolated static func mirrorTabReorder(current: [UUID], requested: [UUID]) -> [UUID]? { + let present = Set(current) + let desired = requested.filter { present.contains($0) } + guard desired.count == current.count, Set(desired) == present else { return nil } + return desired == current ? nil : desired + } } diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 0d997d59681e..e3632bb4762e 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -78,18 +78,9 @@ final class RemoteTmuxWindowMirror { guard let panel = makePanel(paneId) else { continue } panelsByPaneId[paneId] = panel syntheticPaneIds[paneId] = PaneID() - // One-shot reflow classification (always works) + live subscription, so a - // shell pane reflows on resize even where the subscription doesn't deliver. - // Queued before the capture so the classification lands first (it only - // matters at the next resize). - connection?.requestPaneReflow(paneId: paneId) - connection?.subscribePaneReflow(paneId: paneId) - connection?.capturePane(paneId: paneId) - // Track this pane's working directory (initial + live) so the window - // tab reflects the remote cwd. The session mirror's cwd observer maps - // the pane back to this window's tab. - connection?.requestPanePath(paneId: paneId) - connection?.subscribePanePath(paneId: paneId) + // Canonical seed (reflow classification → capture → cwd). The session + // mirror's cwd observer maps the pane back to this window's tab. + connection?.seedPane(paneId: paneId) } for (paneId, panel) in panelsByPaneId where !livePaneIds.contains(paneId) { // Use the full panel close (detaches the portal from the registry diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index cb68456e3568..58faf088a6d2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -16290,22 +16290,6 @@ final class Workspace: Identifiable, ObservableObject { return true } - /// Computes the target tab order for a remote-tmux-driven reorder, or `nil` - /// when no reorder is needed or safe. - /// - /// - Parameters: - /// - current: the workspace's current mirror-tab order (panel ids). - /// - requested: the tmux window order mapped to panel ids. - /// - Returns: the new order to apply, or `nil` when the tabs already match - /// `requested` or when `requested` (restricted to currently-present tabs) is - /// not a permutation of `current` (sets diverge — leave the tabs untouched). - nonisolated static func mirrorTabReorder(current: [UUID], requested: [UUID]) -> [UUID]? { - let present = Set(current) - let desired = requested.filter { present.contains($0) } - guard desired.count == current.count, Set(desired) == present else { return nil } - return desired == current ? nil : desired - } - /// Reorders this workspace's remote-tmux mirror tabs so their left-to-right /// order matches `panelOrder` (the tmux window order), preserving the user's /// current tab selection and pane focus. @@ -16334,7 +16318,7 @@ final class Workspace: Identifiable, ObservableObject { let presentPaneIds = Set(panelOrder.compactMap { paneId(forPanelId: $0) }) guard presentPaneIds.count == 1, let paneId = presentPaneIds.first else { return false } let currentPanelIds = bonsplitController.tabs(inPane: paneId).compactMap { panelIdFromSurfaceId($0.id) } - guard let desired = Self.mirrorTabReorder(current: currentPanelIds, requested: panelOrder) else { return false } + guard let desired = RemoteTmuxSessionMirror.mirrorTabReorder(current: currentPanelIds, requested: panelOrder) else { return false } #if DEBUG cmuxDebugLog("remote-tmux: reorder mirror tabs ws=\(id.uuidString.prefix(5)) count=\(desired.count)") #endif diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 1c9ed10d029b..b8abccd41cbe 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -251,51 +251,51 @@ struct WorkspaceContentView: View { workspace.bonsplitController.focusPane(paneId) } } else { - PanelContentView( - panel: panel, - workspaceId: workspace.id, - paneId: paneId, - isFocused: isFocused, - isSelectedInPane: isSelectedInPane, - isVisibleInUI: isVisibleInUI, - portalPriority: workspacePortalPriority, - isSplit: isSplit, - appearance: appearance, - hasUnreadNotification: showsNotificationRing && !usesWorkspacePaneOverlay, - terminalAgentContext: Self.terminalAgentContext(panel: panel, workspace: workspace), - onFocus: { - // Keep bonsplit focus in sync with the AppKit first responder for the - // active workspace. This prevents divergence between the blue focused-tab - // indicator and where keyboard input/flash-focus actually lands. - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id, trigger: .terminalFirstResponder) - }, - onRequestPanelFocus: { - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - AppDelegate.shared?.noteMainPanelKeyboardFocusIntent( - workspaceId: workspace.id, - panelId: panel.id, - in: NSApp.keyWindow ?? NSApp.mainWindow - ) - workspace.focusPanel(panel.id) - }, - onResumeAgentHibernation: { - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.resumeAgentHibernation(panelId: panel.id, focus: true) - }, - onAutoResumeAgentHibernation: { - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.resumeAgentHibernation(panelId: panel.id, focus: false) - }, - onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } - ) - .onTapGesture { - workspace.bonsplitController.focusPane(paneId) - } + PanelContentView( + panel: panel, + workspaceId: workspace.id, + paneId: paneId, + isFocused: isFocused, + isSelectedInPane: isSelectedInPane, + isVisibleInUI: isVisibleInUI, + portalPriority: workspacePortalPriority, + isSplit: isSplit, + appearance: appearance, + hasUnreadNotification: showsNotificationRing && !usesWorkspacePaneOverlay, + terminalAgentContext: Self.terminalAgentContext(panel: panel, workspace: workspace), + onFocus: { + // Keep bonsplit focus in sync with the AppKit first responder for the + // active workspace. This prevents divergence between the blue focused-tab + // indicator and where keyboard input/flash-focus actually lands. + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.focusPanel(panel.id, trigger: .terminalFirstResponder) + }, + onRequestPanelFocus: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + AppDelegate.shared?.noteMainPanelKeyboardFocusIntent( + workspaceId: workspace.id, + panelId: panel.id, + in: NSApp.keyWindow ?? NSApp.mainWindow + ) + workspace.focusPanel(panel.id) + }, + onResumeAgentHibernation: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.resumeAgentHibernation(panelId: panel.id, focus: true) + }, + onAutoResumeAgentHibernation: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.resumeAgentHibernation(panelId: panel.id, focus: false) + }, + onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } + ) + .onTapGesture { + workspace.bonsplitController.focusPane(paneId) + } } } else { // Fallback for tabs without panels (shouldn't happen normally) diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift index 92718b3b984b..026e5cb62d44 100644 --- a/cmuxTests/RemoteTmuxControlParserTests.swift +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -315,23 +315,23 @@ import Testing @Test func mirrorTabReorderFollowsRemoteWindowOrder() { let a = UUID(), b = UUID(), c = UUID() // Remote moved the windows → cmux tabs rearrange to match. - #expect(Workspace.mirrorTabReorder(current: [a, b, c], requested: [c, a, b]) == [c, a, b]) + #expect(RemoteTmuxSessionMirror.mirrorTabReorder(current: [a, b, c], requested: [c, a, b]) == [c, a, b]) // A requested id that has no tab yet (filtered out) still yields the valid // reorder of the present tabs. - #expect(Workspace.mirrorTabReorder(current: [a, b], requested: [b, a, UUID()]) == [b, a]) + #expect(RemoteTmuxSessionMirror.mirrorTabReorder(current: [a, b], requested: [b, a, UUID()]) == [b, a]) } @Test func mirrorTabReorderNoOpsWhenAlreadyOrdered() { let a = UUID(), b = UUID(), c = UUID() - #expect(Workspace.mirrorTabReorder(current: [a, b, c], requested: [a, b, c]) == nil) + #expect(RemoteTmuxSessionMirror.mirrorTabReorder(current: [a, b, c], requested: [a, b, c]) == nil) } @Test func mirrorTabReorderSkipsWhenSetsDiverge() { let a = UUID(), b = UUID(), c = UUID() // Requested is missing a present tab → not a permutation → leave untouched. - #expect(Workspace.mirrorTabReorder(current: [a, b, c], requested: [a, b]) == nil) + #expect(RemoteTmuxSessionMirror.mirrorTabReorder(current: [a, b, c], requested: [a, b]) == nil) // Requested drops one present tab and only reorders the rest → sets diverge. - #expect(Workspace.mirrorTabReorder(current: [a, b, c], requested: [c, b]) == nil) + #expect(RemoteTmuxSessionMirror.mirrorTabReorder(current: [a, b, c], requested: [c, b]) == nil) } // MARK: - Reconnect: session-gone classification From 38be9cdce8ad7891757021e19868d470d1333252 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Wed, 10 Jun 2026 11:39:11 +0300 Subject: [PATCH 38/73] remote-tmux: pin vendor/bonsplit to merged upstream main (bonsplit#143) manaflow-ai/bonsplit#143 (didReorderTabsInPane delegate) is merged, so the submodule no longer needs the fork commit: bump c209a0db3 -> 5728c21fd, the merge commit on manaflow-ai/bonsplit main. The bump also picks up the divider-thickness work (bonsplit#139) already merged on bonsplit main. .gitmodules already pointed at manaflow-ai/bonsplit; this PR now builds standalone with no unmerged dependencies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index c209a0db3cff..5728c21fd4fc 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit c209a0db3cffd02a9117a17ee5a5854e79a6137c +Subproject commit 5728c21fd4fcd0ad4f9d74f5e99a4d31b56ca1ca From f98e4992150469a6d373280f95a1402eb2cac829 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Wed, 10 Jun 2026 22:01:37 +0300 Subject: [PATCH 39/73] docs: remote-tmux troubleshooting note for Permission denied on incomplete ssh aliases An ~/.ssh/config alias without a User directive makes ssh fall back to the local username, so 'cmux ssh-tmux <alias>' fails with Permission denied and the interactive-auth fallback can't help on key-only hosts. Add a short troubleshooting sub-section to the Attaching docs (en + ja): complete the alias or pass user@host explicitly. Requested in PR #5553 dogfood feedback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- web/app/[locale]/docs/remote-tmux/page.tsx | 5 +++++ web/messages/en.json | 3 +++ web/messages/ja.json | 3 +++ 3 files changed, 11 insertions(+) diff --git a/web/app/[locale]/docs/remote-tmux/page.tsx b/web/app/[locale]/docs/remote-tmux/page.tsx index e0fe252b677d..9f83db3b1e38 100644 --- a/web/app/[locale]/docs/remote-tmux/page.tsx +++ b/web/app/[locale]/docs/remote-tmux/page.tsx @@ -54,6 +54,11 @@ export default function RemoteTmuxPage() { <CodeBlock lang="bash">{`cmux ssh-tmux dev@example.com\ncmux ssh-tmux my-ssh-alias --port 2222 --identity ~/.ssh/id_ed25519`}</CodeBlock> <p>{t("attachSockets")}</p> + <DocsHeading level={3} id="permission-denied">{t("troubleshootTitle")}</DocsHeading> + <p>{t("troubleshootDesc")}</p> + <CodeBlock lang="text">{`Host my-ssh-alias\n HostName 203.0.113.10\n User dev\n IdentityFile ~/.ssh/id_ed25519`}</CodeBlock> + <p>{t("troubleshootFallback")}</p> + <DocsHeading level={2} id="how-it-works">{t("howTitle")}</DocsHeading> <p>{t("howDesc")}</p> diff --git a/web/messages/en.json b/web/messages/en.json index e2b686c21ca7..67997f54836b 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -656,6 +656,9 @@ "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", "howTitle": "How it works", "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", "behaviorTitle": "What it supports", diff --git a/web/messages/ja.json b/web/messages/ja.json index b7cfde1de75d..154847fcfd77 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -612,6 +612,9 @@ "attachIntro": "ターミナルで cmux ssh-tmux <宛先>(~/.ssh/config のエイリアスまたは user@host)を実行します。cmux はそのホストの tmux セッションをミラーリングする新しいウィンドウを開きます。各セッションはワークスペースに、各ウィンドウはタブに、複数ペインのウィンドウはタブ内の分割になります。", "attachSockets": "remote.tmux.* のソケットコマンド(下記)でより細かく制御できます。たとえば remote.tmux.mirror は、専用ウィンドウを開く代わりに、ホストのセッションを現在のウィンドウのサイドバーにミラーリングします。", "attachCli": "非対話で認証されるホスト(ssh-agent や ~/.ssh/config の鍵)はプロンプトなしで接続されます。対話的な認証(パスワード、ホストキーの確認、多要素認証)が必要なホストでは、cmux がそのターミナル内で ssh を実行するため、その場で認証でき、その後 cmux がミラーウィンドウを開きます。--port と --identity を指定できます。", + "troubleshootTitle": "「Permission denied」が出る場合", + "troubleshootDesc": "宛先はそのまま ssh に渡されるため、エイリアスにはログインに必要な設定をすべて含める必要があります。特に User(未指定だと ssh はローカルのユーザー名を使います)と IdentityFile です:", + "troubleshootFallback": "または cmux ssh-tmux dev@my-ssh-alias のようにユーザーを明示指定してください。素の ssh <宛先> でログインできない場合は、cmux ssh-tmux <宛先> でも接続できません。", "howTitle": "仕組み", "howDesc": "cmux は ssh … tmux -CC attach を起動し、組み込みの tmux ビューアーに頼らずコントロールモードのストリームを自前で解析します。そのためプロトコルや %begin/%end のコマンド対応付けは完全に cmux が管理します。各リモートペインは tmux の %output を流し込む専用のターミナルサーフェスに描画され、キー入力やマウスなどの入力は tmux の send-keys でリモートへ送られます。ペインのサイズやリフローはリモートの tmux サーバーが管理し、cmux はそれに追従してローカルではリフローしません。", "behaviorTitle": "サポートしている機能", From 28d3259b7c6b35636ed5c384a226ca8a0cf03023 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Thu, 11 Jun 2026 09:08:03 +0300 Subject: [PATCH 40/73] remote-tmux: refresh swift file-length budget for files grown by this PR Targeted refresh after merging current main: bump the eight tracked entries this feature grew (CLI/cmux.swift, TerminalController, Workspace, ContentView, AppDelegate, GhosttyTerminalView, TabManager, WorkspaceContentView) and track the three new >=500-line files (RemoteTmuxControlConnection 1178, RemoteTmuxController 812, TerminalImageTransfer 504). The two remote-tmux files already went through a responsibility split (99ef05e53: transport/window registries, observers, diagnostics, message decoding); the remaining size is the cohesive control-mode connection and controller cores, accepted as known debt per the budget-refresh convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .github/swift-file-length-budget.tsv | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 71c8e62f0718..abef986d6a3a 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,15 +1,15 @@ # cmux-owned Swift file length budget. # Format: max_lines<TAB>relative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -32949 CLI/cmux.swift -22426 Sources/TerminalController.swift -19955 Sources/Workspace.swift -19245 Sources/ContentView.swift -18056 Sources/AppDelegate.swift -16539 Sources/GhosttyTerminalView.swift +33182 CLI/cmux.swift +22438 Sources/TerminalController.swift +20215 Sources/Workspace.swift +19267 Sources/ContentView.swift +18167 Sources/AppDelegate.swift +16777 Sources/GhosttyTerminalView.swift 13589 Sources/Panels/BrowserPanel.swift 11916 cmuxTests/AppDelegateShortcutRoutingTests.swift -9992 Sources/TabManager.swift +10030 Sources/TabManager.swift 8494 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7737 Sources/Panels/BrowserPanelView.swift 7198 cmuxTests/WorkspaceUnitTests.swift @@ -72,6 +72,7 @@ 1285 cmuxUITests/SidebarHelpMenuUITests.swift 1239 Sources/Feed/FeedCoordinator.swift 1197 cmuxTests/CodexAppServerSessionTests.swift +1178 Sources/RemoteTmuxControlConnection.swift 1156 cmuxTests/SidebarOrderingTests.swift 1144 Sources/VaultAgentProcessScanner.swift 1139 cmuxTests/PiVaultAgentPersistenceTests.swift @@ -93,7 +94,7 @@ 924 Sources/DockPanelView.swift 913 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift -878 Sources/WorkspaceContentView.swift +893 Sources/WorkspaceContentView.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift 866 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 863 Sources/Panels/TerminalPanel.swift @@ -102,6 +103,7 @@ 845 cmuxTests/SSHStartupSignalLifecycleTests.swift 842 Sources/Panels/MarkdownWebRenderer.swift 830 Sources/TaskManagerTypes.swift +812 Sources/RemoteTmuxController.swift 810 Packages/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift 787 Sources/ClosedItemHistory.swift 768 Sources/MainWindowFocusController.swift @@ -182,6 +184,7 @@ 507 Sources/TerminalControllerTopSupport.swift 506 Sources/App/MainWindowVisibilityController.swift 505 cmuxUITests/DisplayResolutionRegressionUITests.swift +504 Sources/TerminalImageTransfer.swift 504 cmuxTests/TerminalNotificationSocketActionTests.swift 502 Sources/CmuxEventPublishing.swift 502 Sources/Settings/ConfigSource.swift From d178b6748a212f5f2b812e76e5344ed4bf1240d7 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Thu, 11 Jun 2026 14:29:37 +0300 Subject: [PATCH 41/73] remote-tmux: confirm before closing mirror tabs/panes running a command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror window-tabs and panes previously sent kill-window/kill-pane instantly on ⌘W / tab ✕ / pane ✕. They now get the same "Close tab?" confirmation as local terminals when a command is active. - PaneForegroundState classifies each pane from "#{alternate_on}|#{pane_current_command}" (alt-screen on OR non-shell foreground = active). Cache fed by a one-shot seed plus live refresh-client -B subscriptions. - Close decisions use a LIVE activity query (list-panes / display-message via CommandKind.activityQuery + completion map, flushed with nil on stream resets) because the subscription cache lags ~1s behind tmux. The dialog names the live foreground command rather than the tab title, which lags tmux automatic-rename. - Covered entrypoints: ⌘W/tab ✕ (bonsplit shouldCloseTab veto), pane-header ✕ (requestRemoteTmuxPaneClose), batch close (routes mirrors via handleMirrorTabCloseRequested after the aggregate confirm), workspace close + quit warning via mirror-aware panelNeedsConfirmClose. Warnings-disabled kills with no added round trip; always-warn presents immediately with the cached name. - Fixes a pre-existing bug where the -B subscription commands were sent unquoted: tmux rejects unquoted #{…} with a parse error that the control stream drops silently, so the cwd and reflow subscriptions never existed at all. Command builders are now static and quoted, pinned by tests. - Mirror workspaces no longer feed their (remote) directories into local per-directory cmux.json config tracking: CmuxConfigStore walks the ancestor chain with FileManager.fileExists on the main thread, and stat'ing remote paths blocks on the autofs automounter for hundreds of ms per close/tab-switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Sources/RemoteTmuxControlConnection.swift | 282 ++++++++++++++++--- Sources/RemoteTmuxController.swift | 109 ++++++- Sources/RemoteTmuxWindowMirror.swift | 22 ++ Sources/RemoteTmuxWindowMirrorView.swift | 12 +- Sources/Workspace.swift | 194 ++++++++++++- Sources/WorkspaceCloseTabsBatching.swift | 12 + Sources/WorkspaceContentView.swift | 7 +- cmuxTests/RemoteTmuxControlParserTests.swift | 185 ++++++++++++ 8 files changed, 765 insertions(+), 58 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index b0ac58008b2d..85c67f6b966c 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -50,6 +50,19 @@ final class RemoteTmuxControlConnection { private(set) var activePaneByWindow: [Int: Int] = [:] private(set) var paneOutputByteCounts: [Int: Int] = [:] private(set) var totalOutputBytes = 0 + /// Last-known foreground classification per pane, kept current by the same + /// one-shot query + live subscription that drive reflow classification + /// (`#{alternate_on}` + `#{pane_current_command}`, see + /// ``requestPaneReflow(paneId:)``). Read at close time to decide whether + /// killing a mirrored pane/window needs a confirmation dialog — a mirror + /// surface has no local child process for ghostty's needs-confirm check. + private(set) var paneForegroundStates: [Int: PaneForegroundState] = [:] + /// In-flight close-time activity queries by token (see + /// ``queryWindowActivity(windowId:completion:)``). Failed with `nil` when the + /// control stream becomes unusable, so a pending close decision falls back to + /// the cached classification instead of hanging until a reconnect that may + /// never come. + private var activityQueryCompletions: [UUID: ([Int: PaneForegroundState]?) -> Void] = [:] private var process: Process? private var stdinHandle: FileHandle? @@ -139,7 +152,8 @@ final class RemoteTmuxControlConnection { private static let maxStderrBytes = 8 * 1024 private enum CommandKind: Equatable { - case listWindows, capturePane(Int), paneState(Int), panePath(Int), paneReflow(Int), paneAltScreen(Int), other + case listWindows, capturePane(Int), paneState(Int), panePath(Int), paneReflow(Int), paneAltScreen(Int), + activityQuery(UUID), other } /// The lifecycle phase of a control connection. @@ -173,28 +187,70 @@ final class RemoteTmuxControlConnection { /// routing, mirroring ``cwdSubscriptionPrefix``. private static let reflowSubscriptionPrefix = "cmux_reflow_" - /// Field separator inside the reflow subscription value - /// (`#{alternate_on}|#{pane_current_command}`). A pipe never appears in a tmux - /// `alternate_on` flag (0/1) and is not part of a process's `comm` name. - private static let reflowFieldSeparator: Character = "|" - - /// Foreground commands (`#{pane_current_command}`) whose primary-screen - /// scrollback is safe to reflow on resize — i.e. plain interactive shells that - /// keep their history as ordinary soft-wrapped text. Anything else (node for - /// claude, python, REPLs, pagers, editors) is treated as no-reflow so an inline - /// TUI's fixed-width frame is never rewrapped. Alt-screen apps are caught - /// separately by `#{alternate_on}`. Login shells can be reported with a leading - /// dash, so the common traditional/POSIX dash variants are listed too (not every - /// shell has one — a missing dash variant just doesn't reflow). Classification - /// defaults to no-reflow on anything not in this set (and on an unparseable - /// value), which is the safe direction (worst case: a shell's scrollback doesn't - /// reflow until reclassified) — so a login shell whose dash variant is missing - /// here simply doesn't reflow, never breaks. - private static let reflowSafeShellCommands: Set<String> = [ - "bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh", "ash", - "mksh", "pdksh", "elvish", "nu", "xonsh", "pwsh", "powershell", "oil", "osh", - "-bash", "-zsh", "-fish", "-sh", "-dash", "-ksh", "-tcsh", "-csh", "-ash", - ] + /// A pane's parsed `#{alternate_on}|#{pane_current_command}` value — the one + /// fact behind two policies (reflow suppression and close confirmation), + /// which deliberately default in opposite directions on an empty/unparseable + /// value: reflow must stay suppressed (rewrapping a TUI corrupts it) while a + /// close confirmation must not fire (a spurious dialog on every close). + /// Deliberately not `@MainActor` (a pure value), so the classification + /// constants live here rather than on the actor-isolated class. + struct PaneForegroundState: Equatable, Sendable { + /// Field separator inside the reflow subscription value + /// (`#{alternate_on}|#{pane_current_command}`). A pipe never appears in a tmux + /// `alternate_on` flag (0/1) and is not part of a process's `comm` name. + static let fieldSeparator: Character = "|" + + /// Foreground commands (`#{pane_current_command}`) whose primary-screen + /// scrollback is safe to reflow on resize — i.e. plain interactive shells that + /// keep their history as ordinary soft-wrapped text. Anything else (node for + /// claude, python, REPLs, pagers, editors) is treated as no-reflow so an inline + /// TUI's fixed-width frame is never rewrapped. Alt-screen apps are caught + /// separately by `#{alternate_on}`. Login shells can be reported with a leading + /// dash, so the common traditional/POSIX dash variants are listed too (not every + /// shell has one — a missing dash variant just doesn't reflow). Classification + /// defaults to no-reflow on anything not in this set (and on an unparseable + /// value), which is the safe direction (worst case: a shell's scrollback doesn't + /// reflow until reclassified) — so a login shell whose dash variant is missing + /// here simply doesn't reflow, never breaks. + static let plainShellCommands: Set<String> = [ + "bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh", "ash", + "mksh", "pdksh", "elvish", "nu", "xonsh", "pwsh", "powershell", "oil", "osh", + "-bash", "-zsh", "-fish", "-sh", "-dash", "-ksh", "-tcsh", "-csh", "-ash", + ] + + let alternateOn: Bool + let command: String + + init(rawValue: String) { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split( + separator: Self.fieldSeparator, + maxSplits: 1, omittingEmptySubsequences: false + ) + alternateOn = parts.first.map(String.init) == "1" + command = parts.count > 1 + ? String(parts[1]).trimmingCharacters(in: .whitespaces) + : "" + } + + /// Reflow policy: suppress primary-screen reflow on resize unless the + /// foreground command is a known plain shell (and the pane is not on the + /// alternate screen). `true` for an empty/unknown command — safe default. + var suppressesReflow: Bool { + alternateOn || !Self.plainShellCommands.contains(command) + } + + /// Close-confirmation policy: the pane is running something beyond an + /// idle shell — a KNOWN non-shell foreground command (sleep, vim, node…) + /// or the alternate screen (full-screen TUI). Unlike ``suppressesReflow``, + /// an empty/unreported command is NOT active, so an unclassified pane + /// never triggers a spurious dialog. Foreground-only by nature: a remote + /// background job (`sleep 10 &`) keeps `pane_current_command` at the + /// shell and is not detected — tmux exposes no cheap process-tree check. + var hasActiveCommand: Bool { + alternateOn || (!command.isEmpty && !Self.plainShellCommands.contains(command)) + } + } /// `ESC[?1049h` — enter the alternate screen, emitted to a mirror surface when /// the remote pane is on the alternate screen (see ``capturePane(paneId:)``). @@ -290,6 +346,9 @@ final class RemoteTmuxControlConnection { // FIFO are stale and must not bleed into the new %begin/%end correlation. parser = RemoteTmuxControlStreamParser() pendingCommands.removeAll() + // Normally already flushed by beginReconnecting; kept here so a future + // caller of spawnProcess can't strand a close decision. + failPendingActivityQueries() attachBlockDrained = false stderrBuffer = "" enterReceived = false @@ -610,6 +669,16 @@ final class RemoteTmuxControlConnection { ) } + /// The exact `refresh-client -B` line that subscribes `paneId`'s working + /// directory. The `name:target:format` argument MUST stay double-quoted: + /// tmux's command parser rejects an unquoted `#{…}` mid-argument with + /// `parse error: syntax error` (verified on tmux 3.6a), and because the + /// result FIFO drops `%error` blocks the subscription would silently never + /// exist — the mirrored tab's folder would just never update. + static func panePathSubscriptionCommand(paneId: Int) -> String { + "refresh-client -B \"\(cwdSubscriptionPrefix)\(paneId):%\(paneId):#{pane_current_path}\"" + } + /// Subscribes to live `pane_current_path` changes for `paneId` via tmux /// control-mode `refresh-client -B`, so a remote `cd` updates the mirrored /// tab's folder without polling. tmux emits the value once on subscribe and @@ -617,7 +686,7 @@ final class RemoteTmuxControlConnection { /// Best-effort: on tmux builds that don't support subscriptions the command is /// a no-op and ``requestPanePath(paneId:)`` still supplies the initial folder. func subscribePanePath(paneId: Int) { - send("refresh-client -B \(Self.cwdSubscriptionPrefix)\(paneId):%\(paneId):#{pane_current_path}") + send(Self.panePathSubscriptionCommand(paneId: paneId)) } /// Removes the live `pane_current_path` subscription for `paneId` (issued once @@ -637,34 +706,40 @@ final class RemoteTmuxControlConnection { func requestPaneReflow(paneId: Int) { sendInternal( "display-message -p -t %\(paneId) -F \"" - + "#{alternate_on}\(Self.reflowFieldSeparator)#{pane_current_command}\"", + + "#{alternate_on}\(PaneForegroundState.fieldSeparator)#{pane_current_command}\"", kind: .paneReflow(paneId) ) } /// Classifies a raw `#{alternate_on}|#{pane_current_command}` value (from the - /// one-shot query or a live subscription) and emits the no-reflow decision. + /// one-shot query or a live subscription), records it as the pane's foreground + /// state (for the close-confirmation check), and emits the no-reflow decision. /// No-reflow when on the alternate screen OR the foreground command isn't a known /// plain shell; defaults to no-reflow on an empty/unparseable value (safe). private func classifyAndEmitReflow(paneId: Int, rawValue: String, source: String) { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.split( - separator: Self.reflowFieldSeparator, maxSplits: 1, omittingEmptySubsequences: false - ) - let altOn = parts.first.map(String.init) == "1" - let command = parts.count > 1 - ? String(parts[1]).trimmingCharacters(in: .whitespaces) - : "" - let noReflow = altOn || !Self.reflowSafeShellCommands.contains(command) + let state = PaneForegroundState(rawValue: rawValue) + paneForegroundStates[paneId] = state + let noReflow = state.suppressesReflow #if DEBUG cmuxDebugLog( - "remote.reflow.classify pane=\(paneId) src=\(source) raw=\"\(trimmed)\" " - + "alt=\(altOn ? 1 : 0) cmd=\"\(command)\" noReflow=\(noReflow ? 1 : 0)" + "remote.reflow.classify pane=\(paneId) src=\(source) raw=\"\(rawValue.trimmingCharacters(in: .whitespacesAndNewlines))\" " + + "alt=\(state.alternateOn ? 1 : 0) cmd=\"\(state.command)\" noReflow=\(noReflow ? 1 : 0)" ) #endif observers.emitPaneReflow(paneId, noReflow) } + /// The exact `refresh-client -B` line that subscribes `paneId`'s foreground + /// classification. Same quoting requirement as + /// ``panePathSubscriptionCommand(paneId:)`` — unquoted, tmux rejects the + /// `#{…}` with a (silently dropped) parse error and the live classification + /// never arrives, so a pane that starts a command after its seed keeps its + /// stale idle-shell state and the close confirmation never fires. + static func paneReflowSubscriptionCommand(paneId: Int) -> String { + "refresh-client -B \"\(reflowSubscriptionPrefix)\(paneId):%\(paneId):" + + "#{alternate_on}\(PaneForegroundState.fieldSeparator)#{pane_current_command}\"" + } + /// Subscribes to live reflow-classification changes for `paneId` via tmux /// control-mode `refresh-client -B`. The subscribed value is /// `#{alternate_on}|#{pane_current_command}`; tmux emits it once on subscribe @@ -672,14 +747,12 @@ final class RemoteTmuxControlConnection { /// and an inline TUI (e.g. bash → node when claude launches) is reclassified /// without polling. The mirror surface uses this to decide whether to reflow its /// primary screen on resize (shells reflow; alt-screen / inline-TUI panes do - /// not). Best-effort: on tmux builds without subscriptions this is a no-op and + /// not), and the close confirmation uses it to track the active foreground + /// command. Best-effort: on tmux builds without subscriptions this is a no-op and /// the surface keeps its safe no-reflow default. See ``subscriptionChanged`` - /// handling for the parse, and ``reflowSafeShellCommands`` for the policy. + /// handling for the parse, and ``PaneForegroundState/plainShellCommands`` for the policy. func subscribePaneReflow(paneId: Int) { - send( - "refresh-client -B \(Self.reflowSubscriptionPrefix)\(paneId):%\(paneId):" - + "#{alternate_on}\(Self.reflowFieldSeparator)#{pane_current_command}" - ) + send(Self.paneReflowSubscriptionCommand(paneId: paneId)) } /// Removes the live reflow-classification subscription for `paneId` (issued once @@ -688,6 +761,90 @@ final class RemoteTmuxControlConnection { send("refresh-client -B \(Self.reflowSubscriptionPrefix)\(paneId)") } + /// Format for close-time activity queries: the pane id (for cache refresh and + /// multi-pane correlation) plus the same `alternate_on`/`pane_current_command` + /// pair the reflow subscription streams. Quoted by the command builders — see + /// ``panePathSubscriptionCommand(paneId:)`` for why the quoting is load-bearing. + private static let activityQueryFormat = "#{pane_id}\(PaneForegroundState.fieldSeparator)" + + "#{alternate_on}\(PaneForegroundState.fieldSeparator)#{pane_current_command}" + + /// The `list-panes` line behind ``queryWindowActivity(windowId:completion:)``. + static func windowActivityQueryCommand(windowId: Int) -> String { + "list-panes -t @\(windowId) -F \"\(activityQueryFormat)\"" + } + + /// The `display-message` line behind ``queryPaneActivity(paneId:completion:)``. + static func paneActivityQueryCommand(paneId: Int) -> String { + "display-message -p -t %\(paneId) -F \"\(activityQueryFormat)\"" + } + + /// Live, close-time query of every pane's foreground state in `windowId`. + /// tmux evaluates `pane_current_command` AT QUERY TIME, so a command started + /// the instant before ⌘W is already visible — unlike the `%subscription-changed` + /// cache, which tmux only re-checks about once a second. Results also refresh + /// ``paneForegroundStates`` so the synchronous consumers (batch close, + /// workspace close, quit warning) get the freshness for free. `completion` is + /// called exactly once, on the main actor; `nil` means the query could not be + /// issued or the stream reset first (caller falls back to the cache). + func queryWindowActivity(windowId: Int, completion: @escaping ([Int: PaneForegroundState]?) -> Void) { + sendActivityQuery(Self.windowActivityQueryCommand(windowId: windowId), completion: completion) + } + + /// Single-pane variant of ``queryWindowActivity(windowId:completion:)``, for + /// the multi-pane mirror's pane-header ✕ close. + func queryPaneActivity(paneId: Int, completion: @escaping ([Int: PaneForegroundState]?) -> Void) { + sendActivityQuery(Self.paneActivityQueryCommand(paneId: paneId), completion: completion) + } + + private func sendActivityQuery( + _ command: String, completion: @escaping ([Int: PaneForegroundState]?) -> Void + ) { + guard !exited else { + completion(nil) + return + } + let token = UUID() + activityQueryCompletions[token] = completion + sendInternal(command, kind: .activityQuery(token)) + // sendInternal enqueues the kind only after a successful write; a dead + // pipe (write failure, or no stdin while reconnecting) means no result + // will ever correlate — fail the query now so the close decision can + // proceed on the cache. (A write failure triggers beginReconnecting, + // which may already have flushed this completion — removeValue makes + // the fail-once exactly once.) + if !pendingCommands.contains(.activityQuery(token)), + let orphaned = activityQueryCompletions.removeValue(forKey: token) { + orphaned(nil) + } + } + + /// Parses one activity-query line (``activityQueryFormat``): + /// `%<paneId>|<alternate_on>|<pane_current_command>`. `nil` for an + /// unparseable line — the caller treats that pane as unclassified. + /// `maxSplits: 1` is deliberate (NOT 2): this strips only the `%paneId` + /// prefix, and ``PaneForegroundState/init(rawValue:)`` applies its own + /// `maxSplits: 1` for the second field — so a `|` inside a command name + /// stays in the command instead of truncating it. + static func parseActivityQueryLine(_ line: String) -> (paneId: Int, state: PaneForegroundState)? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split( + separator: PaneForegroundState.fieldSeparator, maxSplits: 1, omittingEmptySubsequences: false + ) + guard parts.count == 2, + let paneId = RemoteTmuxControlStreamParser.id(parts[0], sigil: "%") else { return nil } + return (paneId, PaneForegroundState(rawValue: String(parts[1]))) + } + + /// Fails every in-flight activity query — called whenever the control stream + /// becomes unusable (reconnect begins, deliberate stop, genuine `%exit`), so + /// a pending close decision falls back to the cached classification. + private func failPendingActivityQueries() { + guard !activityQueryCompletions.isEmpty else { return } + let completions = Array(activityQueryCompletions.values) + activityQueryCompletions.removeAll() + for completion in completions { completion(nil) } + } + /// Sends literal key bytes to a pane via tmux `send-keys -H` (hex-encoded), /// which is binary-safe and needs no shell quoting. func sendKeys(paneId: Int, data: Data) { @@ -728,6 +885,7 @@ final class RemoteTmuxControlConnection { /// kick) and the deferred post-attach work. Shared by deliberate teardown /// (``stop()``) and a genuine remote end (`%exit`). private func cancelScheduledWork() { + failPendingActivityQueries() reconnectTask?.cancel() reconnectTask = nil clientSizeDebounceTask?.cancel() @@ -836,6 +994,9 @@ final class RemoteTmuxControlConnection { private func beginReconnecting() { guard connectionState == .connected || connectionState == .connecting else { return } record("reconnecting") + // The stream is dead: a close decision awaiting an activity query must + // not hang for the whole backoff window — fail it onto the cache now. + failPendingActivityQueries() teardownProcessHandles() reconnectAttemptCount = 0 connectionState = .reconnecting @@ -962,7 +1123,10 @@ final class RemoteTmuxControlConnection { // Release the closed window's per-pane/per-window diagnostic state so // it doesn't accumulate across window churn. if let closing = windowsByID[id] { - for pane in closing.paneIDsInOrder { paneOutputByteCounts[pane] = nil } + for pane in closing.paneIDsInOrder { + paneOutputByteCounts[pane] = nil + paneForegroundStates[pane] = nil + } } activePaneByWindow[id] = nil windowsByID[id] = nil @@ -1020,7 +1184,23 @@ final class RemoteTmuxControlConnection { // misalign the positional correlation. guard !pendingCommands.isEmpty else { return } let kind = pendingCommands.removeFirst() - guard !isError else { return } + guard !isError else { + // An errored activity query must still complete (with nil) — a close + // decision is waiting on it and falls back to the cached state. + if case let .activityQuery(token) = kind, + let completion = activityQueryCompletions.removeValue(forKey: token) { + completion(nil) + } + // Errors are dropped by design (results correlate positionally), but + // an invisible %error has already hidden one real bug — an unquoted + // refresh-client -B that never subscribed — so leave a trace. + #if DEBUG + cmuxDebugLog( + "remote.tmux.commandError kind=\(kind) error=\"\(lines.joined(separator: " / "))\"" + ) + #endif + return + } switch kind { case .listWindows: var order: [Int] = [] @@ -1058,6 +1238,7 @@ final class RemoteTmuxControlConnection { activePaneByWindow = activePaneByWindow.filter { liveIDs.contains($0.key) } let livePanes = Set(next.values.flatMap { $0.paneIDsInOrder }) paneOutputByteCounts = paneOutputByteCounts.filter { livePanes.contains($0.key) } + paneForegroundStates = paneForegroundStates.filter { livePanes.contains($0.key) } windowOrder = order observers.notifyTopologyChanged() // The attach block is drained and the topology is fresh — run the @@ -1118,6 +1299,17 @@ final class RemoteTmuxControlConnection { // One-shot reflow classification result (see requestPaneReflow). Empty // lines → classifyAndEmitReflow defaults to no-reflow (safe). classifyAndEmitReflow(paneId: paneId, rawValue: lines.first ?? "", source: "oneshot") + case let .activityQuery(token): + guard let completion = activityQueryCompletions.removeValue(forKey: token) else { break } + var states: [Int: PaneForegroundState] = [:] + for line in lines { + guard let parsed = Self.parseActivityQueryLine(line) else { continue } + states[parsed.paneId] = parsed.state + } + // The fresh answer flows back into the cache, so the synchronous + // consumers (batch close, workspace close, quit warning) benefit too. + for (paneId, state) in states { paneForegroundStates[paneId] = state } + completion(states) case let .paneAltScreen(paneId): // Match the mirror surface to the remote pane's screen (alt = no reflow on // resize). Emitted before the capture paint that follows in the FIFO, so the diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index a090875f2f66..9e397962d546 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -438,6 +438,19 @@ final class RemoteTmuxController { mirror.connection.send("rename-window -t @\(windowId) \(RemoteTmuxHost.shellSingleQuoted(name))") } + /// The live session mirror + tmux window id behind a mirrored window-tab, or + /// `nil` when `panelId` isn't a mirrored window-tab of `workspaceId` with a + /// live connection. Shared by the kill routing and the close-confirmation + /// check so the two can never disagree about which tabs route remotely. + private func mirrorWindowTarget(workspaceId: UUID, panelId: UUID) + -> (mirror: RemoteTmuxSessionMirror, windowId: Int)? + { + guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), + !mirror.connection.exited, + let windowId = mirror.windowId(forPanel: panelId) else { return nil } + return (mirror, windowId) + } + /// A tab close was requested in a mirrored workspace → kill that tmux window /// on the remote. The local tab is removed when tmux reports `%window-close`, /// so the caller should VETO the immediate local close. @@ -446,13 +459,101 @@ final class RemoteTmuxController { /// `false` if there is no live mirror/connection or the panel isn't a /// mirrored window (caller proceeds with the normal local close). func handleMirrorTabCloseRequested(workspaceId: UUID, panelId: UUID) -> Bool { - guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), - !mirror.connection.exited, - let windowId = mirror.windowId(forPanel: panelId) else { return false } - mirror.connection.send("kill-window -t @\(windowId)") + guard let target = mirrorWindowTarget(workspaceId: workspaceId, panelId: panelId) else { return false } + target.mirror.connection.send("kill-window -t @\(target.windowId)") return true } + /// A close-time answer for a mirrored window-tab: whether any pane runs an + /// active command, plus the command name for the dialog ("This will close + /// \"sleep\"."). The name comes from the foreground classification rather + /// than the tab title — tmux's automatic-rename refreshes the title on its + /// own lagging schedule, so a title-based dialog can still say "bash" while + /// the tab is about to read "sleep", looking like it names a different tab. + struct MirrorTabActivity { + let hasActiveCommand: Bool + /// The first active pane's foreground command (tmux's active pane + /// preferred, then layout order); `nil` when idle or unnamed (the + /// dialog then falls back to the tab title). + let activeCommandName: String? + } + + /// Builds ``MirrorTabActivity`` from per-pane foreground states. Pure; + /// `activePaneId` is checked first so a multi-pane window names the pane + /// the user is looking at, then `paneOrder` (the window's layout order). + static func mirrorTabActivity( + states: [Int: RemoteTmuxControlConnection.PaneForegroundState], + paneOrder: [Int], + activePaneId: Int? + ) -> MirrorTabActivity { + let hasActive = states.values.contains { $0.hasActiveCommand } + var name: String? + // Focused pane first, then the rest in layout order (filtered so the + // focused pane isn't revisited); first active, named pane wins. + let orderedPanes = (activePaneId.map { [$0] } ?? []) + paneOrder.filter { $0 != activePaneId } + for paneId in orderedPanes { + guard let state = states[paneId], state.hasActiveCommand, !state.command.isEmpty else { continue } + name = state.command + break + } + return MirrorTabActivity(hasActiveCommand: hasActive, activeCommandName: name) + } + + /// ``MirrorTabActivity`` from the subscription-fed cache (≤~1s stale). + private func mirrorTabActivityFromCache( + target: (mirror: RemoteTmuxSessionMirror, windowId: Int) + ) -> MirrorTabActivity { + let connection = target.mirror.connection + let order = connection.windowsByID[target.windowId]?.paneIDsInOrder ?? [] + var states: [Int: RemoteTmuxControlConnection.PaneForegroundState] = [:] + for paneId in order { + states[paneId] = connection.paneForegroundStates[paneId] + } + return Self.mirrorTabActivity( + states: states, paneOrder: order, + activePaneId: connection.activePaneByWindow[target.windowId] + ) + } + + /// The cached activity answer for a mirrored window-tab, or `nil` when + /// `panelId` isn't a live mirrored window-tab. Used where a round trip + /// isn't warranted (the always-warn dialog path). + func cachedMirrorTabActivity(workspaceId: UUID, panelId: UUID) -> MirrorTabActivity? { + guard let target = mirrorWindowTarget(workspaceId: workspaceId, panelId: panelId) else { return nil } + return mirrorTabActivityFromCache(target: target) + } + + /// Live, close-time variant of ``cachedMirrorTabActivity(workspaceId:panelId:)``: + /// asks tmux NOW (one round trip) instead of trusting the subscription cache, + /// which tmux only refreshes about once a second — so a command started right + /// before ⌘W still gets its confirmation, with the fresh command name for the + /// dialog. Falls back to the cached answer when the query can't run (link + /// down, reconnecting, target gone). `completion` runs exactly once, on the + /// main actor. + func queryMirrorTabActivity( + workspaceId: UUID, panelId: UUID, completion: @escaping (MirrorTabActivity) -> Void + ) { + guard let target = mirrorWindowTarget(workspaceId: workspaceId, panelId: panelId) else { + completion(MirrorTabActivity(hasActiveCommand: false, activeCommandName: nil)) + return + } + // Strong captures: the controller is app-lifetime and the completion + // fires exactly once (flushed on stream resets), so nothing can leak. + target.mirror.connection.queryWindowActivity(windowId: target.windowId) { states in + if let states { + let connection = target.mirror.connection + completion(Self.mirrorTabActivity( + states: states, + paneOrder: connection.windowsByID[target.windowId]?.paneIDsInOrder + ?? Array(states.keys).sorted(), + activePaneId: connection.activePaneByWindow[target.windowId] + )) + } else { + completion(self.mirrorTabActivityFromCache(target: target)) + } + } + } + /// Creates a new tmux session on a dedicated remote window's host (and mirrors it /// into that window) when a new workspace is requested while a mirror tab is active. /// The single source of truth for the remote-vs-local decision, so every diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index e3632bb4762e..41b86682c8b2 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -144,6 +144,28 @@ final class RemoteTmuxWindowMirror { connection?.send("kill-pane -t @\(windowId).%\(tmuxPaneId)") } + /// The pane's last-known foreground classification (alt-screen flag + + /// `pane_current_command`), driving the kill-pane close confirmation. + /// `nil` when the pane was never classified (closes without a dialog). + func paneForegroundState(_ tmuxPaneId: Int) -> RemoteTmuxControlConnection.PaneForegroundState? { + connection?.paneForegroundStates[tmuxPaneId] + } + + /// Live, close-time query of `tmuxPaneId`'s foreground state (see + /// ``RemoteTmuxControlConnection/queryPaneActivity(paneId:completion:)``). + /// Completes with `nil` when the connection is gone — the caller falls back + /// to ``paneForegroundState(_:)``. + func queryPaneActivity( + _ tmuxPaneId: Int, + completion: @escaping ([Int: RemoteTmuxControlConnection.PaneForegroundState]?) -> Void + ) { + guard let connection else { + completion(nil) + return + } + connection.queryPaneActivity(paneId: tmuxPaneId, completion: completion) + } + /// Tears down every pane panel (called when the window-tab is removed). func teardown() { // Unsubscribe each pane's cwd subscription first — matching reconcile(layout:), diff --git a/Sources/RemoteTmuxWindowMirrorView.swift b/Sources/RemoteTmuxWindowMirrorView.swift index e431d78711d0..b3968e2bbe97 100644 --- a/Sources/RemoteTmuxWindowMirrorView.swift +++ b/Sources/RemoteTmuxWindowMirrorView.swift @@ -11,6 +11,9 @@ struct RemoteTmuxWindowMirrorView: View { let appearance: PanelAppearance let isVisibleInUI: Bool let portalPriority: Int + /// Pane-header ✕ handler — owned by the workspace layer so the kill-pane can + /// be gated on a close confirmation (the view stays dialog-free). + let onClosePane: (Int) -> Void @State private var sizingRetryTask: Task<Void, Never>? var body: some View { @@ -20,7 +23,8 @@ struct RemoteTmuxWindowMirrorView: View { mirror: mirror, appearance: appearance, isVisibleInUI: isVisibleInUI, - portalPriority: portalPriority + portalPriority: portalPriority, + onClosePane: onClosePane ) .frame(width: geo.size.width, height: geo.size.height) // Size the remote tmux window to the rendered area so pane content @@ -66,6 +70,7 @@ private struct RemoteTmuxLayoutContainer: View { let appearance: PanelAppearance let isVisibleInUI: Bool let portalPriority: Int + let onClosePane: (Int) -> Void private let dividerThickness: CGFloat = 2 @@ -91,7 +96,7 @@ private struct RemoteTmuxLayoutContainer: View { onFocus: { mirror.focus(pane: paneId) }, onSplitRight: { mirror.requestSplit(fromPane: paneId, vertical: false) }, onSplitDown: { mirror.requestSplit(fromPane: paneId, vertical: true) }, - onClose: { mirror.requestKillPane(paneId) } + onClose: { onClosePane(paneId) } ) TerminalPanelView( panel: panel, @@ -156,7 +161,8 @@ private struct RemoteTmuxLayoutContainer: View { mirror: mirror, appearance: appearance, isVisibleInUI: isVisibleInUI, - portalPriority: portalPriority + portalPriority: portalPriority, + onClosePane: onClosePane ) .frame( width: axis == .horizontal ? dimension : nil, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 56c72fdde66b..bd06111cf3c8 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -11556,6 +11556,10 @@ final class Workspace: Identifiable, ObservableObject { /// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs. private var pendingCloseConfirmTabIds: Set<TabID> = [] + /// tmux pane ids (multi-pane mirror ✕) with a close-time activity query or + /// confirmation in flight, so click spam can't double-kill or stack dialogs. + private var pendingRemoteTmuxPaneCloseIds: Set<Int> = [] + /// Tab IDs whose next close attempt should be treated as an explicit /// workspace-close gesture from the user (the tab-strip X button, or the Close Tab /// shortcut when the shortcut preference is set to close the workspace on the last surface), @@ -12483,6 +12487,13 @@ final class Workspace: Identifiable, ObservableObject { } private func configTrackingDirectory(for panelId: UUID?) -> String? { + // A remote tmux mirror's directories are paths on the REMOTE host. + // Feeding one into local cmux.json tracking makes CmuxConfigStore walk + // the ancestor chain with FileManager.fileExists on the main thread, + // and stat'ing e.g. /home/… locally blocks on the autofs automounter + // for hundreds of ms (measured via sample during tab-reveal stalls). + // No local per-directory config can apply to a remote path — track none. + if isRemoteTmuxMirror { return nil } if let panelId { for candidate in [ panelDirectories[panelId], @@ -12816,6 +12827,16 @@ final class Workspace: Identifiable, ObservableObject { func panelNeedsConfirmClose(panelId: UUID) -> Bool { guard let panel = panels[panelId] else { return false } + // Mirrored remote tmux window-tab: closing it kills the remote window, + // and its manual-I/O surface has no local child process for the ghostty + // fallback (which reports "needs confirm" whenever the cursor isn't at a + // marked prompt — i.e. always, for a mirror). Ask the control connection + // whether any of the window's panes is running an active command instead. + if isRemoteTmuxMirror, + let activity = AppDelegate.shared?.remoteTmuxController + .cachedMirrorTabActivity(workspaceId: id, panelId: panelId) { + return activity.hasActiveCommand + } if let terminalPanel = panel as? TerminalPanel { return panelNeedsConfirmClose( panelId: panelId, @@ -15178,6 +15199,60 @@ final class Workspace: Identifiable, ObservableObject { return newPanel } + /// Closes one pane of a mirrored multi-pane tmux window (the pane-header ✕), + /// confirming first when that pane is running an active foreground command — + /// kill-pane is destructive, and the mirror pane has no local child process + /// for the normal needs-confirm check. The decision uses a LIVE activity + /// query (the subscription cache lags ~1s, which would let a just-started + /// command slip through), falling back to the cached state when the link is + /// down. The pane is removed by the resulting `%layout-change` (or + /// `%window-close` for the window's last pane), never locally. + func requestRemoteTmuxPaneClose(windowMirror: RemoteTmuxWindowMirror, tmuxPaneId: Int) { + // Close warnings disabled → even an active command wouldn't confirm; + // kill with no added round trip. + guard CloseTabConfirmationPolicy.shouldConfirm( + requiresConfirmation: true, source: .tabCloseButton + ) else { + windowMirror.requestKillPane(tmuxPaneId) + return + } + guard !pendingRemoteTmuxPaneCloseIds.contains(tmuxPaneId) else { return } + pendingRemoteTmuxPaneCloseIds.insert(tmuxPaneId) + windowMirror.queryPaneActivity(tmuxPaneId) { [weak self, weak windowMirror] states in + // Hop off the control-stream dispatch before a (modal) dialog can + // block it; the defer keeps the in-flight guard balanced on every path. + Task { @MainActor [weak self, weak windowMirror] in + guard let self else { return } + defer { self.pendingRemoteTmuxPaneCloseIds.remove(tmuxPaneId) } + guard let windowMirror else { return } + let state = states?[tmuxPaneId] ?? windowMirror.paneForegroundState(tmuxPaneId) + if CloseTabConfirmationPolicy.shouldConfirm( + requiresConfirmation: state?.hasActiveCommand ?? false, + source: .tabCloseButton + ) { + // No manager → no way to ask → refuse the destructive kill rather + // than falling through to an unconfirmed one (only reachable in + // teardown states where the pane header shouldn't be clickable). + guard let manager = self.owningTabManager + ?? AppDelegate.shared?.tabManagerFor(tabId: self.id) + ?? AppDelegate.shared?.tabManager else { return } + let message: String + if let command = state?.command, state?.hasActiveCommand == true, !command.isEmpty { + message = String(localized: "dialog.closeTab.messageNamed", defaultValue: "This will close \"\(command)\".") + } else { + message = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") + } + guard manager.confirmClose( + title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + message: message, + acceptCmdD: false + ) else { return } + } + windowMirror.requestKillPane(tmuxPaneId) + } + } + } + /// Updates a mirrored remote tmux tab's title (e.g. after a tmux /// `%window-renamed`). No-ops if the panel is no longer mounted. func updateRemoteTmuxTabTitle(panelId: UUID, title: String) { @@ -18860,9 +18935,18 @@ extension Workspace: BonsplitDelegate { } @MainActor - private func confirmClosePanel(for tabId: TabID) async -> Bool { + /// - Parameter nameOverride: when non-nil, the dialog names this instead of + /// the panel title. The mirror window-tab path passes the LIVE foreground + /// command here so the dialog says "sleep" the instant the close fires — + /// the tab's own title (tmux's window name) only catches up to the + /// automatic-rename a beat later, which otherwise reads like the dialog is + /// naming a different tab. + private func confirmClosePanel(for tabId: TabID, nameOverride: String? = nil) async -> Bool { let title = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") let panelName: String? = { + if let nameOverride, !nameOverride.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return nameOverride + } guard let panelId = panelIdFromSurfaceId(tabId) else { return nil } if let custom = panelCustomTitles[panelId], !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return custom @@ -19345,12 +19429,112 @@ extension Workspace: BonsplitDelegate { // used by the mirror's own rebuild) are excluded — they do the actual // removal. Falls through to the normal local close when there is no live // mirror connection. + // + // Kill-window is destructive (unlike detach), so it gets the same close + // confirmation as a local tab with a running process. The decision uses a + // LIVE activity query (tmux evaluates pane_current_command at query time) + // rather than the subscription cache, which tmux only refreshes about + // once a second — otherwise a command started right before ⌘W would + // slip through unconfirmed. The kill is only sent on Confirm (or when + // the fresh answer says idle); the %window-close round trip still does + // the actual tab removal, so the silent-close case costs one extra + // round trip on a path that already waits one. Batch closes never reach + // this confirmation: they confirm once up front and route the kill + // directly (see closeTabsFromContextMenu), bypassing this delegate. if isRemoteTmuxMirror, !forceCloseTabIds.contains(tab.id), let panelId = panelIdFromSurfaceId(tab.id), - AppDelegate.shared?.remoteTmuxController.handleMirrorTabCloseRequested( - workspaceId: id, panelId: panelId - ) == true { - return false + let remoteTmuxController = AppDelegate.shared?.remoteTmuxController, + remoteTmuxController.cachedMirrorTabActivity(workspaceId: id, panelId: panelId) != nil { + let confirmationSource: CloseTabConfirmationPolicy.Source = + tabCloseButtonClose ? .tabCloseButton : .shortcut + if !CloseTabConfirmationPolicy.shouldConfirm( + requiresConfirmation: true, source: confirmationSource + ) { + // Close warnings disabled → even an active command wouldn't + // confirm; kill with no added round trip. Veto unconditionally: + // the target resolved two lines up on the same main-actor tick, + // and falling through to a LOCAL close of a mirror tab would + // leave the remote window alive to resurrect it. + _ = remoteTmuxController.handleMirrorTabCloseRequested(workspaceId: id, panelId: panelId) + return false + } else { + if pendingCloseConfirmTabIds.contains(tab.id) { + return false + } + let confirmationManager = owningTabManager + ?? AppDelegate.shared?.tabManagerFor(tabId: id) + ?? AppDelegate.shared?.tabManager + if let confirmationManager, confirmationManager.isCloseConfirmationInFlight { + return false + } + pendingCloseConfirmTabIds.insert(tab.id) + let tabId = tab.id + + // Begins the confirmation session and runs the dialog → kill-window + // flow; shared by the always-warn path (no query) and the queried + // active-command path. `commandName` (the live foreground command) + // names the dialog so it can't lag the tab's own rename. Balances + // pendingCloseConfirmTabIds on every exit. + let presentConfirmation: @MainActor (String?) -> Void = { [weak self] commandName in + guard let self else { return } + if let confirmationManager, !confirmationManager.beginCloseConfirmationSession() { + self.pendingCloseConfirmTabIds.remove(tabId) + return + } + Task { @MainActor in + defer { + self.pendingCloseConfirmTabIds.remove(tabId) + confirmationManager?.endCloseConfirmationSession() + } + + // If the tab disappeared while we were scheduling (e.g. the + // command finished and another client killed the window), do nothing. + guard self.panelIdFromSurfaceId(tabId) != nil else { return } + + let confirmed = await self.confirmClosePanel(for: tabId, nameOverride: commandName) + guard confirmed else { return } + + // Re-resolves the target, so a window that died while the + // dialog was up is a no-op rather than a stray kill. + _ = remoteTmuxController.handleMirrorTabCloseRequested( + workspaceId: self.id, panelId: panelId + ) + } + } + + // "Always warn on the tab ✕" makes the dialog unconditional — a + // live query couldn't change WHETHER we confirm, but it still + // supplies the fresh command name, so use the cached classification + // for the name (no round trip) and present immediately. + if CloseTabConfirmationPolicy.shouldConfirm( + requiresConfirmation: false, source: confirmationSource + ) { + let cached = remoteTmuxController.cachedMirrorTabActivity(workspaceId: id, panelId: panelId) + presentConfirmation(cached?.activeCommandName) + return false + } + + remoteTmuxController.queryMirrorTabActivity( + workspaceId: id, panelId: panelId + ) { [weak self] activity in + guard let self else { return } + // Tab vanished while the query was in flight (e.g. the window + // died remotely) — nothing left to close. + guard self.panelIdFromSurfaceId(tabId) != nil else { + self.pendingCloseConfirmTabIds.remove(tabId) + return + } + guard activity.hasActiveCommand else { + self.pendingCloseConfirmTabIds.remove(tabId) + _ = remoteTmuxController.handleMirrorTabCloseRequested( + workspaceId: self.id, panelId: panelId + ) + return + } + presentConfirmation(activity.activeCommandName) + } + return false + } } if forceCloseTabIds.contains(tab.id) { diff --git a/Sources/WorkspaceCloseTabsBatching.swift b/Sources/WorkspaceCloseTabsBatching.swift index 2b17cb003000..c98ec6363102 100644 --- a/Sources/WorkspaceCloseTabsBatching.swift +++ b/Sources/WorkspaceCloseTabsBatching.swift @@ -79,6 +79,18 @@ extension Workspace { } for candidate in candidates { + // Remote tmux mirror tabs: the batch prompt above already covered + // them (panelNeedsConfirmClose is mirror-aware), so route the kill + // to the remote directly — the tab is removed on %window-close. A + // local force-close would bypass the shouldCloseTab kill routing + // (its confirmation session is still winding down) and leave the + // remote window alive, resurrecting the tab on the next rebuild. + if isRemoteTmuxMirror, let panelId = candidate.panelId, + AppDelegate.shared?.remoteTmuxController.handleMirrorTabCloseRequested( + workspaceId: id, panelId: panelId + ) == true { + continue + } _ = requestCloseTabRecordingHistory(candidate.tabId, force: needsConfirmation) } } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index b8abccd41cbe..961195b614d2 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -245,7 +245,12 @@ struct WorkspaceContentView: View { mirror: windowMirror, appearance: appearance, isVisibleInUI: isVisibleInUI, - portalPriority: workspacePortalPriority + portalPriority: workspacePortalPriority, + onClosePane: { tmuxPaneId in + workspace.requestRemoteTmuxPaneClose( + windowMirror: windowMirror, tmuxPaneId: tmuxPaneId + ) + } ) .onTapGesture { workspace.bonsplitController.focusPane(paneId) diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift index 026e5cb62d44..439f48bf2ae7 100644 --- a/cmuxTests/RemoteTmuxControlParserTests.swift +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -410,3 +410,188 @@ import Testing #expect(RemoteTmuxRawLayoutParser.parse("80x24,0,0,1xyz") == nil) } } + +/// Behavior tests for the per-pane foreground classification +/// (`#{alternate_on}|#{pane_current_command}`) that drives BOTH the reflow +/// suppression and the kill-window/kill-pane close confirmation — exercised on +/// raw subscription values as tmux delivers them. +@Suite struct RemoteTmuxPaneForegroundStateTests { + private typealias State = RemoteTmuxControlConnection.PaneForegroundState + + @Test func idleShellIsNeitherActiveNorNoReflow() { + let state = State(rawValue: "0|bash") + #expect(!state.hasActiveCommand) + #expect(!state.suppressesReflow) + } + + @Test func loginShellDashVariantIsIdle() { + let state = State(rawValue: "0|-zsh") + #expect(!state.hasActiveCommand) + #expect(!state.suppressesReflow) + } + + @Test func foregroundCommandIsActive() { + // `sleep 10` in a remote pane: pane_current_command reports "sleep". + let state = State(rawValue: "0|sleep") + #expect(state.hasActiveCommand) + #expect(state.suppressesReflow) + } + + @Test func alternateScreenIsActiveEvenForShellCommandName() { + let state = State(rawValue: "1|bash") + #expect(state.hasActiveCommand) + #expect(state.suppressesReflow) + } + + @Test func fullScreenTUIIsActive() { + let state = State(rawValue: "1|vim") + #expect(state.hasActiveCommand) + #expect(state.suppressesReflow) + } + + /// The two policies deliberately diverge on an unclassifiable value: reflow + /// stays suppressed (rewrapping a TUI corrupts it) while close confirmation + /// must NOT fire (it would add a dialog to every close of a healthy shell + /// pane that simply hasn't reported yet). + @Test func emptyOrGarbageValueSuppressesReflowButIsNotActive() { + for raw in ["", "0|", "garbage-without-separator", " \n"] { + let state = State(rawValue: raw) + #expect(state.suppressesReflow, "raw=\(raw)") + #expect(!state.hasActiveCommand, "raw=\(raw)") + } + } + + @Test func surroundingWhitespaceIsTrimmed() { + let state = State(rawValue: " 0|node \r\n") + #expect(!state.alternateOn) + #expect(state.command == "node") + #expect(state.hasActiveCommand) + } +} + +/// The `refresh-client -B` subscribe lines must keep their `name:target:format` +/// argument double-quoted: tmux's command parser rejects an unquoted `#{…}` +/// mid-argument with `parse error: syntax error` (verified on tmux 3.6a), and +/// control mode silently drops the `%error` result — the subscription then +/// never exists, a pane's live foreground/cwd state never updates, and the +/// kill close-confirmation sees a stale idle shell forever. +@Suite struct RemoteTmuxSubscriptionCommandTests { + @Test @MainActor func reflowSubscribeCommandKeepsFormatQuoted() { + #expect( + RemoteTmuxControlConnection.paneReflowSubscriptionCommand(paneId: 15) + == "refresh-client -B \"cmux_reflow_15:%15:#{alternate_on}|#{pane_current_command}\"" + ) + } + + @Test @MainActor func cwdSubscribeCommandKeepsFormatQuoted() { + #expect( + RemoteTmuxControlConnection.panePathSubscriptionCommand(paneId: 7) + == "refresh-client -B \"cmux_cwd_7:%7:#{pane_current_path}\"" + ) + } +} + +/// Close-time activity queries: the wire commands (same quoting constraint as +/// the subscriptions) and the per-line `%<pane>|<alt>|<command>` result parse +/// that feeds the kill close-confirmation. +@Suite struct RemoteTmuxActivityQueryTests { + @Test @MainActor func windowQueryCommandKeepsFormatQuoted() { + #expect( + RemoteTmuxControlConnection.windowActivityQueryCommand(windowId: 3) + == "list-panes -t @3 -F \"#{pane_id}|#{alternate_on}|#{pane_current_command}\"" + ) + } + + @Test @MainActor func paneQueryCommandKeepsFormatQuoted() { + #expect( + RemoteTmuxControlConnection.paneActivityQueryCommand(paneId: 9) + == "display-message -p -t %9 -F \"#{pane_id}|#{alternate_on}|#{pane_current_command}\"" + ) + } + + @Test @MainActor func parsesActiveCommandLine() { + let parsed = RemoteTmuxControlConnection.parseActivityQueryLine("%5|0|sleep") + #expect(parsed?.paneId == 5) + #expect(parsed?.state.hasActiveCommand == true) + #expect(parsed?.state.command == "sleep") + } + + @Test @MainActor func parsesIdleShellLine() { + let parsed = RemoteTmuxControlConnection.parseActivityQueryLine("%12|0|bash") + #expect(parsed?.paneId == 12) + #expect(parsed?.state.hasActiveCommand == false) + } + + @Test @MainActor func parsesAltScreenLine() { + let parsed = RemoteTmuxControlConnection.parseActivityQueryLine("%7|1|vim") + #expect(parsed?.paneId == 7) + #expect(parsed?.state.alternateOn == true) + #expect(parsed?.state.hasActiveCommand == true) + } + + @Test @MainActor func commandContainingSeparatorSurvives() { + // maxSplits strips only the pane id; the state parser strips only the + // alternate_on flag — a pipe in the command name stays in the command. + let parsed = RemoteTmuxControlConnection.parseActivityQueryLine("%5|0|my|weird") + #expect(parsed?.state.command == "my|weird") + #expect(parsed?.state.hasActiveCommand == true) + } + + @Test @MainActor func rejectsLinesWithoutPaneId() { + #expect(RemoteTmuxControlConnection.parseActivityQueryLine("0|bash") == nil) + #expect(RemoteTmuxControlConnection.parseActivityQueryLine("garbage") == nil) + #expect(RemoteTmuxControlConnection.parseActivityQueryLine("") == nil) + } +} + +/// Naming the kill-window confirmation dialog from the live foreground +/// classification (`RemoteTmuxController.mirrorTabActivity`) so it can't lag the +/// tab's own tmux automatic-rename. +@Suite struct RemoteTmuxMirrorTabActivityTests { + private typealias State = RemoteTmuxControlConnection.PaneForegroundState + + @Test @MainActor func namesTheActivePaneCommand() { + let activity = RemoteTmuxController.mirrorTabActivity( + states: [1: State(rawValue: "0|bash"), 2: State(rawValue: "0|sleep")], + paneOrder: [1, 2], activePaneId: nil + ) + #expect(activity.hasActiveCommand) + #expect(activity.activeCommandName == "sleep") + } + + @Test @MainActor func prefersTheFocusedPaneWhenSeveralAreActive() { + let activity = RemoteTmuxController.mirrorTabActivity( + states: [1: State(rawValue: "0|vim"), 2: State(rawValue: "0|sleep")], + paneOrder: [1, 2], activePaneId: 2 + ) + #expect(activity.activeCommandName == "sleep") + } + + @Test @MainActor func namesAnActiveBackgroundPaneWhenTheFocusedOneIsIdle() { + // Focused pane idle, another pane active → fall past the focused pane to + // the active one in layout order (the deduped second half of the scan). + let activity = RemoteTmuxController.mirrorTabActivity( + states: [1: State(rawValue: "0|bash"), 2: State(rawValue: "0|sleep")], + paneOrder: [1, 2], activePaneId: 1 + ) + #expect(activity.hasActiveCommand) + #expect(activity.activeCommandName == "sleep") + } + + @Test @MainActor func idleWindowHasNoNameAndIsNotActive() { + let activity = RemoteTmuxController.mirrorTabActivity( + states: [1: State(rawValue: "0|bash"), 2: State(rawValue: "0|zsh")], + paneOrder: [1, 2], activePaneId: 1 + ) + #expect(!activity.hasActiveCommand) + #expect(activity.activeCommandName == nil) + } + + @Test @MainActor func unclassifiedWindowIsIdle() { + let activity = RemoteTmuxController.mirrorTabActivity( + states: [:], paneOrder: [1, 2], activePaneId: nil + ) + #expect(!activity.hasActiveCommand) + #expect(activity.activeCommandName == nil) + } +} From 555a74db303d9a08b493214aee1e413fcc02feaf Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Thu, 11 Jun 2026 14:29:38 +0300 Subject: [PATCH 42/73] perf: never stat possibly-remote paths on the main thread in menu/icon paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by sampling main-thread stalls during the remote-tmux close-lag investigation: tab closes/reveals stalled 400-1000ms because UI code stat'ed REMOTE working directories (recorded from remote tmux panes), which blocks on the autofs automounter (e.g. /home/… on macOS). - ClosedItemHistory: title helpers used URL(fileURLWithPath:), which lstat()s the path to infer directory-ness, inside the App commands body on every menu rebuild — for every history record. Use string path math instead, and build menu items only for the records the menu actually shows. - DetachedFolderDragIcon: NSWorkspace.icon(forFile:) and FileManager.displayName(atPath:) stat the path. Resolve icons off-main through a per-path cache (generic UTType folder icon shown until resolved); ctrl-click path-menu titles start as the last path component and refine off-main; the drag image uses the cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Sources/ClosedItemHistory.swift | 26 ++++++++---- Sources/DetachedFolderDragIcon.swift | 63 +++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/Sources/ClosedItemHistory.swift b/Sources/ClosedItemHistory.swift index 068acaddc976..a7ef517a06df 100644 --- a/Sources/ClosedItemHistory.swift +++ b/Sources/ClosedItemHistory.swift @@ -257,18 +257,20 @@ final class ClosedItemHistoryStore: ObservableObject { } func menuSnapshot(maxItemCount: Int? = nil) -> ClosedItemHistoryMenuSnapshot { - let allItems = records.reversed().map(Self.menuItem(for:)) - if let maxItemCount, maxItemCount >= 0, allItems.count > maxItemCount { + // Build items only for the records the menu will show — this runs in + // the App commands body on every menu rebuild, and `records` is + // unbounded persisted history. + if let maxItemCount, maxItemCount >= 0, records.count > maxItemCount { return ClosedItemHistoryMenuSnapshot( - items: Array(allItems.prefix(maxItemCount)), - totalItemCount: allItems.count, + items: records.suffix(maxItemCount).reversed().map(Self.menuItem(for:)), + totalItemCount: records.count, isLimited: true ) } return ClosedItemHistoryMenuSnapshot( - items: allItems, - totalItemCount: allItems.count, + items: records.reversed().map(Self.menuItem(for:)), + totalItemCount: records.count, isLimited: false ) } @@ -691,7 +693,13 @@ final class ClosedItemHistoryStore: ObservableObject { let candidates = [ snapshot.customTitle, snapshot.title, - snapshot.directory.map { URL(fileURLWithPath: $0).lastPathComponent } + // String-only path math — NOT URL(fileURLWithPath:), which lstat()s + // the path to infer directory-ness. These snapshots can hold REMOTE + // working directories (closed remote-tmux tabs); stat'ing one on the + // main thread blocks on the autofs automounter (e.g. /home/…) for + // hundreds of ms per record, and this runs inside the App commands + // body on every menu rebuild. + snapshot.directory.map { ($0 as NSString).lastPathComponent } ] if let title = candidates.compactMap({ $0?.trimmingCharacters(in: .whitespacesAndNewlines) }) .first(where: { !$0.isEmpty }) { @@ -737,7 +745,9 @@ final class ClosedItemHistoryStore: ObservableObject { private static func directoryTitleCandidate(_ directory: String) -> String? { let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != "." else { return nil } - return URL(fileURLWithPath: trimmed).lastPathComponent + // String-only path math — see title(for:): URL(fileURLWithPath:) would + // lstat() a possibly-remote path on the main thread. + return (trimmed as NSString).lastPathComponent } private static func normalizedTitleCandidate(_ candidate: String?) -> String? { diff --git a/Sources/DetachedFolderDragIcon.swift b/Sources/DetachedFolderDragIcon.swift index e57a18ce3b93..2824876069eb 100644 --- a/Sources/DetachedFolderDragIcon.swift +++ b/Sources/DetachedFolderDragIcon.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers struct DetachedFolderDragIcon: NSViewRepresentable { let directory: String @@ -68,14 +69,42 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { updateIcon() } + /// Per-path icons already resolved this session. `NSWorkspace.icon(forFile:)` + /// stats the path, and `directory` can be a REMOTE working directory + /// (remote tmux) where that stat blocks on the autofs automounter for + /// hundreds of ms — never pay it twice, and never pay it on the main thread. + private static var resolvedIconsByPath: [String: NSImage] = [:] + + /// Returns the cached icon for `path`, or the generic folder icon + /// (UTType-based — no filesystem access) while resolving the real one + /// off-main. `onResolved` runs on the main thread once a fresh icon is + /// fetched and cached; it is not called on a cache hit. + private static func icon(forPath path: String, onResolved: @escaping (NSImage) -> Void) -> NSImage { + if let cached = resolvedIconsByPath[path] { return cached } + DispatchQueue.global(qos: .userInitiated).async { + let icon = NSWorkspace.shared.icon(forFile: path) + DispatchQueue.main.async { + icon.size = NSSize(width: 16, height: 16) + if resolvedIconsByPath.count > 256 { resolvedIconsByPath.removeAll() } + resolvedIconsByPath[path] = icon + onResolved(icon) + } + } + let generic = NSWorkspace.shared.icon(for: .folder) + generic.size = NSSize(width: 16, height: 16) + return generic + } + func updateIcon() { #if DEBUG dispatchPrecondition(condition: .onQueue(.main)) #endif - let icon = NSWorkspace.shared.icon(forFile: directory) - icon.size = NSSize(width: 16, height: 16) - imageView.image = icon + let target = directory + imageView.image = Self.icon(forPath: target) { [weak self] icon in + guard let self, self.directory == target else { return } + self.imageView.image = icon + } } func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { @@ -154,7 +183,9 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { let fileURL = URL(fileURLWithPath: directory) let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) - let iconImage = NSWorkspace.shared.icon(forFile: directory) + // Cosmetic drag image: use the already-resolved icon (or the generic + // folder) rather than re-statting `directory` — see updateIcon(). + let iconImage = (Self.resolvedIconsByPath[directory] ?? NSWorkspace.shared.icon(for: .folder)).copy() as! NSImage iconImage.size = NSSize(width: 32, height: 32) draggingItem.setDraggingFrame(bounds, contents: iconImage) @@ -192,25 +223,35 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { // Add path components (current dir at top, root at bottom - matches native macOS) for pathURL in pathComponents { - let icon = NSWorkspace.shared.icon(forFile: pathURL.path) - icon.size = NSSize(width: 16, height: 16) - + let path = pathURL.path let displayName: String - if pathURL.path == "/" { - // Use the volume name for root + if path == "/" { + // Use the volume name for root ("/" is always local — safe to stat) if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { displayName = volumeName } else { displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") } } else { - displayName = FileManager.default.displayName(atPath: pathURL.path) + // Placeholder; the localized display name comes from a stat'ing + // API and is refined off-main below, like the icons. + displayName = (path as NSString).lastPathComponent } let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") item.target = self - item.image = icon + item.image = Self.icon(forPath: path) { [weak item] icon in + item?.image = icon + } item.representedObject = pathURL + if path != "/" { + DispatchQueue.global(qos: .userInitiated).async { + let localizedName = FileManager.default.displayName(atPath: path) + DispatchQueue.main.async { [weak item] in + item?.title = localizedName + } + } + } menu.addItem(item) } From 8d3e029587c8234b8cb80ccd49f9bb1273f0051b Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Thu, 11 Jun 2026 14:43:20 +0300 Subject: [PATCH 43/73] remote-tmux: refresh swift file-length budget for files grown by this PR Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .github/swift-file-length-budget.tsv | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 19d67e0ae63f..2dbf1822ddba 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33188 CLI/cmux.swift 22459 Sources/TerminalController.swift -20318 Sources/Workspace.swift +20502 Sources/Workspace.swift 19270 Sources/ContentView.swift 18168 Sources/AppDelegate.swift 16777 Sources/GhosttyTerminalView.swift @@ -66,13 +66,13 @@ 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1376 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1372 cmuxTests/AppDelegateIssue2907RoutingTests.swift +1370 Sources/RemoteTmuxControlConnection.swift 1365 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift 1313 cmuxTests/MobileHostAuthorizationTests.swift 1285 cmuxUITests/SidebarHelpMenuUITests.swift 1239 Sources/Feed/FeedCoordinator.swift 1197 cmuxTests/CodexAppServerSessionTests.swift -1178 Sources/RemoteTmuxControlConnection.swift 1156 cmuxTests/SidebarOrderingTests.swift 1144 Sources/VaultAgentProcessScanner.swift 1139 cmuxTests/PiVaultAgentPersistenceTests.swift @@ -92,9 +92,10 @@ 937 Sources/TextBoxMentionIndexStore.swift 937 cmuxTests/RestorableAgentSessionIndexTests.swift 924 Sources/DockPanelView.swift +913 Sources/RemoteTmuxController.swift 913 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift -893 Sources/WorkspaceContentView.swift +898 Sources/WorkspaceContentView.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift 866 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 864 Sources/Panels/TerminalPanel.swift @@ -103,9 +104,8 @@ 845 cmuxTests/SSHStartupSignalLifecycleTests.swift 842 Sources/Panels/MarkdownWebRenderer.swift 830 Sources/TaskManagerTypes.swift -812 Sources/RemoteTmuxController.swift 810 Packages/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift -787 Sources/ClosedItemHistory.swift +797 Sources/ClosedItemHistory.swift 768 Sources/MainWindowFocusController.swift 760 Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 757 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift @@ -145,6 +145,7 @@ 614 cmuxTests/SessionIndexViewTests.swift 613 Sources/PortScanner.swift 599 Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift +597 cmuxTests/RemoteTmuxControlParserTests.swift 596 cmuxTests/CmuxEventBusTests.swift 594 Sources/SessionIndexModels.swift 594 cmuxTests/PortalTabDragRoutingTests.swift From f8c238d47829e9e57094512e5932f986d19bc75e Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Thu, 11 Jun 2026 21:53:02 +0300 Subject: [PATCH 44/73] settings: add Remote tmux beta row to the search-anchor contract list The remote-tmux beta added the Settings row (BetaFeaturesSection) and the curated search entry, but never added setting:betaFeatures:remoteTmux to SettingsRowAnchorResolutionTests' hand-maintained explicitlyAnchoredEntryIDs contract, so everyCuratedSettingEntryIsReachable has been red on this branch since the row landed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 1452d17526b4..3ccfd40dee68 100644 --- a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -132,6 +132,7 @@ struct SettingsRowAnchorResolutionTests { "setting:betaFeatures:feed", "setting:betaFeatures:dock", "setting:betaFeatures:customSidebars", + "setting:betaFeatures:remoteTmux", "setting:browser:history", "setting:browser:http-allowlist", "setting:workspaceColors:palette", From 774d4dc8b7abb0b9822952c3c898c3a9c198c04f Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Thu, 11 Jun 2026 21:53:16 +0300 Subject: [PATCH 45/73] fix NSMenuItem non-Sendable capture warning in the path-menu title refinement The off-main displayName(atPath:) hop captured the NSMenuItem strongly in the @Sendable global-queue closure (the [weak item] capture sat on the inner main.async closure), tripping the Swift warning budget in tests-build-and-lag (+1 over a 0 bucket). Restructure it as a localizedDisplayName(forPath:onResolved:) helper mirroring the warning-free icon(forPath:onResolved:) shape in the same file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Sources/DetachedFolderDragIcon.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Sources/DetachedFolderDragIcon.swift b/Sources/DetachedFolderDragIcon.swift index 2824876069eb..785ca57c0fbc 100644 --- a/Sources/DetachedFolderDragIcon.swift +++ b/Sources/DetachedFolderDragIcon.swift @@ -95,6 +95,18 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { return generic } + /// Resolves the localized display name for `path` off-main (the API + /// stats), then runs `onResolved` on the main thread — same shape as + /// ``icon(forPath:onResolved:)``. + private static func localizedDisplayName(forPath path: String, onResolved: @escaping (String) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let localizedName = FileManager.default.displayName(atPath: path) + DispatchQueue.main.async { + onResolved(localizedName) + } + } + } + func updateIcon() { #if DEBUG dispatchPrecondition(condition: .onQueue(.main)) @@ -245,11 +257,8 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { } item.representedObject = pathURL if path != "/" { - DispatchQueue.global(qos: .userInitiated).async { - let localizedName = FileManager.default.displayName(atPath: path) - DispatchQueue.main.async { [weak item] in - item?.title = localizedName - } + Self.localizedDisplayName(forPath: path) { [weak item] localizedName in + item?.title = localizedName } } menu.addItem(item) From d7e5a52dccf1edc79c03713e744b29ede0465264 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Thu, 11 Jun 2026 21:54:32 +0300 Subject: [PATCH 46/73] =?UTF-8?q?remote-tmux:=20regression=20test=20?= =?UTF-8?q?=E2=80=94=20a=20mirror-workspace=20split=20must=20never=20creat?= =?UTF-8?q?e=20a=20local=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red without the fix: a socket/CLI split aimed at a remote tmux mirror workspace with no routable mirror falls through the bonsplit veto and creates an orphan local panel the mirror's rebuild() never reconciles (and on a live mirror, the routed split is reported as internal_error, making automation retry and duplicate remote panes — see the PR dogfood report). The fix lands in the next commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- cmux.xcodeproj/project.pbxproj | 4 + .../RemoteTmuxMirrorSplitRoutingTests.swift | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 776515a4318a..6ccff48c4e46 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -502,6 +502,7 @@ 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */; }; E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */; }; 7DA09B544027E3F73AA09518 /* RemoteTmuxManualIOWrite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */; }; + D4F8A2E61C5B39707A8E6F12 /* RemoteTmuxMirrorSplitRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C4D2B3F09865E217D4C0 /* RemoteTmuxMirrorSplitRoutingTests.swift */; }; C9BA962E6CB31C9FD62505BF /* RemoteTmuxRawLayoutParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */; }; A42DAD21BD502098E446999C /* RemoteTmuxSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */; }; 008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */; }; @@ -1286,6 +1287,7 @@ 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxHost.swift; sourceTree = "<group>"; }; 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutNode.swift; sourceTree = "<group>"; }; 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxManualIOWrite.swift; sourceTree = "<group>"; }; + A7E1C4D2B3F09865E217D4C0 /* RemoteTmuxMirrorSplitRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorSplitRoutingTests.swift; sourceTree = "<group>"; }; 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxRawLayoutParser.swift; sourceTree = "<group>"; }; 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSession.swift; sourceTree = "<group>"; }; E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParser.swift; sourceTree = "<group>"; }; @@ -2356,6 +2358,7 @@ 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */, + A7E1C4D2B3F09865E217D4C0 /* RemoteTmuxMirrorSplitRoutingTests.swift */, FA00C0DE0002BEEF0002CAFE /* RemoteTmuxWindowRegistryTests.swift */, 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */, 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */, @@ -3466,6 +3469,7 @@ C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */, 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */, B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, + D4F8A2E61C5B39707A8E6F12 /* RemoteTmuxMirrorSplitRoutingTests.swift in Sources */, 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */, FA00C0DE0001BEEF0001CAFE /* RemoteTmuxWindowRegistryTests.swift in Sources */, F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift b/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift new file mode 100644 index 000000000000..2e8b128d60d4 --- /dev/null +++ b/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift @@ -0,0 +1,77 @@ +import AppKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Regression coverage for the remote-tmux mirror split routing contract +/// (https://github.com/manaflow-ai/cmux/pull/5553): a split request on a +/// remote tmux mirror workspace must never create a local panel — it is +/// routed to the remote tmux session (the pane arrives via %layout-change), +/// or fails when no live mirror exists. A local panel here would be an +/// orphan the mirror's rebuild() never reconciles, and the socket layer +/// reporting routed requests as errors makes automation retry and duplicate +/// remote panes. +@MainActor +@Suite(.serialized) struct RemoteTmuxMirrorSplitRoutingTests { + @Test func mirrorWorkspaceSplitNeverCreatesLocalPanel() throws { + let harness = try Harness() + defer { harness.tearDown() } + + harness.workspace.isRemoteTmuxMirror = true + let panelsBefore = harness.workspace.panels.count + + let panel = harness.workspace.newTerminalSplit( + from: harness.sourcePanelId, + orientation: .horizontal, + focus: false + ) + + #expect(panel == nil) + #expect(harness.workspace.panels.count == panelsBefore) + } + + @Test func localWorkspaceSplitStillCreatesLocalPanel() throws { + let harness = try Harness() + defer { harness.tearDown() } + + let panelsBefore = harness.workspace.panels.count + + let panel = harness.workspace.newTerminalSplit( + from: harness.sourcePanelId, + orientation: .horizontal, + focus: false + ) + + #expect(panel != nil) + #expect(harness.workspace.panels.count == panelsBefore + 1) + } + + @MainActor + private struct Harness { + let appDelegate: AppDelegate + let windowId: UUID + let workspace: Workspace + let sourcePanelId: UUID + + init() throws { + appDelegate = try #require(AppDelegate.shared) + windowId = appDelegate.createMainWindow() + let manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) + workspace = try #require(manager.selectedWorkspace) + sourcePanelId = try #require(workspace.focusedPanelId) + } + + func tearDown() { + workspace.isRemoteTmuxMirror = false + let identifier = "cmux.main.\(windowId.uuidString)" + if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) { + window.performClose(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } + } + } +} From c09e0eea624f0335bfd1382a79f64d758f405080 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Thu, 11 Jun 2026 21:55:03 +0300 Subject: [PATCH 47/73] remote-tmux: report socket/CLI splits routed to a mirror as accepted, not internal_error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A split/new-surface request on a remote tmux mirror workspace is routed to the remote (tmux split-window / new-window; the pane arrives via %layout-change / %window-add) and the local creation is intentionally suppressed — but the socket layer treated the resulting nil as failure and replied 'internal_error: Failed to create split' even though the remote mutation succeeded, so automation retried and duplicated remote panes. - New TerminalPanelCreationOutcome (created / routedToRemote / failed); newTerminalSplit/newTerminalSurface stay nil-returning wrappers over new *Outcome variants. The split path gains an explicit mirror guard that routes by the requested panel (the bonsplit-level veto can only see the pane's selected tab) and never creates a local panel in a mirror. - v2 surface.split / surface.create / pane.create / new_terminal_right and v1 new_split / new_pane / new_surface report routed requests as success: v2 returns accepted: true, routed: "remote-tmux" with null surface ids; v1 returns OK routed-to-remote-tmux. The CLI prints an accepted summary. - Requests carrying options the routed tmux command cannot honor (initial_command, tmux_start_command, working_directory, startup_environment, initial_divider_position, remote_pty_session_id, left/up placement) are rejected with invalid_params BEFORE the remote is mutated, so an error never means 'it actually happened'. focus stays best-effort (the socket default is focus=false and tmux focuses the new remote pane). - Routing claims require a live .connected control stream: while reconnecting, send() silently drops, so 'accepted' would have lied. The bonsplit shouldSplitPane veto now always blocks local splits in mirrors (a dead route must not fall through to an orphan local pane). - Codex Teams spawning and tmux-compat split-window handle the accepted shape (compat succeeds quietly for plain routed splits; -P errors with an accurate message). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .github/swift-file-length-budget.tsv | 8 +- CLI/cmux.swift | 44 +++- Sources/RemoteTmuxController.swift | 13 +- Sources/RemoteTmuxSessionMirror.swift | 8 +- Sources/TerminalController.swift | 243 ++++++++++++++++++--- Sources/TerminalPanelCreationOutcome.swift | 28 +++ Sources/Workspace.swift | 161 +++++++++++++- cmux.xcodeproj/project.pbxproj | 4 + web/messages/en.json | 2 +- web/messages/ja.json | 2 +- 10 files changed, 452 insertions(+), 61 deletions(-) create mode 100644 Sources/TerminalPanelCreationOutcome.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 024b1fae1924..78675c55fd11 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,9 +1,9 @@ # cmux-owned Swift file length budget. # Format: max_lines<TAB>relative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -33188 CLI/cmux.swift -22588 Sources/TerminalController.swift -20399 Sources/Workspace.swift +33224 CLI/cmux.swift +22759 Sources/TerminalController.swift +20540 Sources/Workspace.swift 19270 Sources/ContentView.swift 18168 Sources/AppDelegate.swift 16777 Sources/GhosttyTerminalView.swift @@ -92,7 +92,7 @@ 937 Sources/TextBoxMentionIndexStore.swift 937 cmuxTests/RestorableAgentSessionIndexTests.swift 924 Sources/DockPanelView.swift -913 Sources/RemoteTmuxController.swift +918 Sources/RemoteTmuxController.swift 913 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift 898 Sources/WorkspaceContentView.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d612a2953d7b..0d7c5fee5c90 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3915,7 +3915,7 @@ struct CMUXCLI { if let sfId { params["surface_id"] = sfId } try applyFocusOption(focusOpt, defaultValue: false, to: ¶ms) let payload = try client.sendV2(method: "surface.split", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2CreationSummary(payload, idFormat: idFormat)) case "list-panes": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) @@ -4011,7 +4011,7 @@ struct CMUXCLI { if let url { params["url"] = url } try applyFocusOption(focusOpt, defaultValue: false, to: ¶ms) let payload = try client.sendV2(method: "pane.create", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"])) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2CreationSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"])) case "new-surface": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) @@ -4039,7 +4039,7 @@ struct CMUXCLI { } try applyFocusOption(focusOpt, defaultValue: false, to: ¶ms) let payload = try client.sendV2(method: "surface.create", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"])) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2CreationSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"])) case "surface": try runSurfaceCommand( @@ -10508,7 +10508,7 @@ struct CMUXCLI { output, jsonOutput: jsonOutput, idFormat: idFormat, - fallbackText: v2OKSummary(output, idFormat: idFormat, kinds: ["surface", "pane", "workspace"]) + fallbackText: v2CreationSummary(output, idFormat: idFormat, kinds: ["surface", "pane", "workspace"]) ) } @@ -15986,6 +15986,21 @@ struct CMUXCLI { return parts.joined(separator: " ") } + /// Summary for surface.split / surface.create responses, which report + /// `accepted: true` (and no surface id) when the request was routed to a + /// remote tmux mirror — the new pane arrives asynchronously. + func v2CreationSummary(_ payload: [String: Any], idFormat: CLIIDFormat, kinds: [String] = ["surface", "workspace"]) -> String { + guard (payload["accepted"] as? Bool) == true else { + return v2OKSummary(payload, idFormat: idFormat, kinds: kinds) + } + var parts = ["OK", "accepted"] + if let handle = formatHandle(payload, kind: "workspace", idFormat: idFormat) { + parts.append(handle) + } + parts.append("(routed to remote tmux; the new pane arrives asynchronously)") + return parts.joined(separator: " ") + } + private struct TreeCommandOptions { let includeAllWindows: Bool let workspaceHandle: String? @@ -19353,6 +19368,12 @@ struct CMUXCLI { } let created = try socketClient.sendV2(method: "surface.split", params: splitParams) + if (created["accepted"] as? Bool) == true { + // Routed to a remote tmux mirror: the pane was created on the + // remote session and there is no local surface id to attach the + // subagent to. Retrying would duplicate the remote pane. + throw CLIError(message: "Codex subagent panes are not supported in remote tmux mirror workspaces (surface.split was routed to the remote tmux session)") + } guard let surfaceId = created["surface_id"] as? String else { throw CLIError(message: "surface.split did not return surface_id") } @@ -21082,6 +21103,21 @@ struct CMUXCLI { splitParams["initial_divider_position"] = dividerPosition } let created = try client.sendV2(method: "surface.split", params: splitParams) + if (created["accepted"] as? Bool) == true { + // Routed to a remote tmux mirror: the split was applied to the + // remote tmux session and the pane arrives asynchronously. + // Option-carrying requests (command, cwd, sizing) are rejected + // server-side BEFORE the remote mutation, so reaching here + // means a plain split: succeed quietly (tmux split-window + // prints nothing without -P) and skip local layout tracking. + // Erroring here would invite retries that duplicate the + // already-created remote pane. -P is the one thing we cannot + // honor after the fact — there is no local surface id yet. + if parsed.hasFlag("-P") { + throw CLIError(message: "split-window -P is not supported in a remote tmux mirror workspace (the split was already applied to the remote tmux session; the new pane id is not yet known)") + } + break + } guard let surfaceId = created["surface_id"] as? String else { throw CLIError(message: "surface.split did not return surface_id") } diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 9e397962d546..729d2b4c2740 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -283,12 +283,17 @@ final class RemoteTmuxController { /// that session. The new tab arrives via the `%window-add` notification (one /// source of truth), so the caller must NOT also create a local tab. /// - /// - Returns: `true` if routed to the remote (caller suppresses the local - /// tab); `false` if there is no live mirror/connection (caller proceeds - /// with normal local behavior). + /// Requires a live `.connected` stream — NOT just `!exited`: while + /// reconnecting there is no stdin and `send` silently drops the command, so + /// returning `true` would let socket callers report an accepted mutation + /// that never reached tmux. + /// + /// - Returns: `true` if routed to the remote; `false` if there is no live + /// mirror/connection (callers must still NOT create a local tab in a + /// mirror workspace — they report failure instead). func handleMirrorNewTabRequested(workspaceId: UUID) -> Bool { guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), - !mirror.connection.exited else { return false } + mirror.connection.connectionState == .connected else { return false } mirror.connection.send("new-window") return true } diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 2d54bab01e7e..b40e5d294f24 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -391,8 +391,14 @@ final class RemoteTmuxSessionMirror { /// Used by the split BUTTON / `shouldSplitPane` path, which works at the /// bonsplit-pane (tab) level rather than per mirror surface. Returns `true` /// if handled (the caller vetoes the local split). + /// + /// Requires a live `.connected` stream — NOT just `!exited`: while + /// reconnecting there is no stdin and `send` silently drops the command, + /// so claiming "routed" would report success for a mutation that never + /// reached tmux (socket callers translate `true` into an accepted reply). func requestSplit(windowPanelId panelId: UUID, vertical: Bool) -> Bool { - guard !connection.exited, let windowId = windowId(forPanel: panelId) else { return false } + guard connection.connectionState == .connected, + let windowId = windowId(forPanel: panelId) else { return false } let targetPane = windowMirrorByWindowId[windowId]?.activePaneId ?? connection.windowsByID[windowId]?.paneIDsInOrder.first guard let targetPane else { return false } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index d6eef1efc878..37fba697ff78 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -7320,17 +7320,30 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: focus) else { + switch workspace.newTerminalSurfaceOutcome(inPane: paneId, focus: focus) { + case .created(let newPanel): + _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) + finish([ + "created_surface_id": newPanel.id.uuidString, + "created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id), + "created_tab_id": newPanel.id.uuidString, + "created_tab_ref": v2TabRef(uuid: newPanel.id) + ]) + case .routedToRemote: + // Routed to the remote tmux mirror as `new-window`; the tab + // arrives via %window-add (tmux appends, so no local reorder). + finish([ + "accepted": true, + "routed": "remote-tmux", + "created_surface_id": NSNull(), + "created_surface_ref": NSNull(), + "created_tab_id": NSNull(), + "created_tab_ref": NSNull() + ]) + case .failed: result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } - _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) - finish([ - "created_surface_id": newPanel.id.uuidString, - "created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id), - "created_tab_id": newPanel.id.uuidString, - "created_tab_ref": v2TabRef(uuid: newPanel.id) - ]) case "new_browser_right", "new_browser_to_right", "new_browser_tab_to_right": guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), @@ -7945,6 +7958,73 @@ class TerminalController { return (providerID, rendererKind, nil) } + /// v1 socket error for a left/up split directed at a mirror workspace. + /// Shared by both v1 split handlers so wording stays consistent. + private static let v1MirrorDirectionError = + "ERROR: direction left/up is not supported in a remote tmux mirror workspace" + + /// Pre-mutation validation for terminal create/split requests aimed at a + /// remote tmux mirror workspace. The routed tmux command (`split-window` / + /// `new-window`) cannot honor these options, and reporting "accepted" while + /// silently dropping them would lie to the caller — so reject BEFORE + /// routing, while the remote session is still unmutated (an error after the + /// mutation invites retries that duplicate remote panes). `focus` is + /// deliberately NOT validated: the socket default is `focus=false` and tmux + /// always focuses the new remote pane, so it stays best-effort. + /// + /// Returns `nil` when all options are compatible (no rejection needed). + private func v2MirrorUnsupportedOptionsError( + insertFirst: Bool = false, + workingDirectory: String? = nil, + initialCommand: String? = nil, + tmuxStartCommand: String? = nil, + startupEnvironment: [String: String] = [:], + initialDividerPosition: Double? = nil, + remotePTYSessionID: String? = nil + ) -> V2CallResult? { + var unsupported: [String] = [] + if insertFirst { unsupported.append("direction=left/up") } + if workingDirectory != nil { unsupported.append("working_directory") } + if initialCommand != nil { unsupported.append("initial_command") } + if tmuxStartCommand != nil { unsupported.append("tmux_start_command") } + if !startupEnvironment.isEmpty { unsupported.append("startup_environment") } + if initialDividerPosition != nil { unsupported.append("initial_divider_position") } + if remotePTYSessionID != nil { unsupported.append("remote_pty_session_id") } + guard !unsupported.isEmpty else { return nil } + return .err( + code: "invalid_params", + message: "Not supported when targeting a remote tmux mirror workspace (the request is routed to tmux and these options cannot be applied): \(unsupported.joined(separator: ", "))", + data: ["unsupported": unsupported, "routed_target": "remote-tmux"] + ) + } + + /// Success payload for a terminal split/surface request that was routed to a + /// remote tmux mirror: the new pane/tab arrives asynchronously via the + /// mirror's topology events (`%layout-change` / `%window-add`), so there is + /// no local surface id to return yet. Returning an error here instead would + /// make automation retry and duplicate remote panes — the remote session was + /// already mutated. + private func v2RemoteRoutedCreationResult( + tabManager: TabManager, + workspace ws: Workspace, + panelType: PanelType = .terminal + ) -> V2CallResult { + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ + "accepted": true, + "routed": "remote-tmux", + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": NSNull(), + "pane_ref": NSNull(), + "surface_id": NSNull(), + "surface_ref": NSNull(), + "type": panelType.rawValue + ]) + } + private func v2SurfaceSplit(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -7999,6 +8079,20 @@ class TerminalController { return } + if ws.isRemoteTmuxMirror, panelType == .terminal, + let err = v2MirrorUnsupportedOptionsError( + insertFirst: direction.insertFirst, + workingDirectory: workingDirectory, + initialCommand: initialCommand, + tmuxStartCommand: tmuxStartCommand, + startupEnvironment: startupEnvironment, + initialDividerPosition: initialDividerPosition, + remotePTYSessionID: remotePTYSessionID + ) { + result = err + return + } + v2MaybeFocusWindow(for: tabManager) v2MaybeSelectWorkspace(tabManager, workspace: ws) @@ -8017,10 +8111,10 @@ class TerminalController { initialDividerPosition: initialDividerPosition.map { CGFloat($0) } )?.id } else { - newId = tabManager.newSplit( - tabId: ws.id, - surfaceId: targetSurfaceId, - direction: direction, + switch ws.newTerminalSplitOutcome( + from: targetSurfaceId, + orientation: orientation, + insertFirst: insertFirst, focus: focus, workingDirectory: workingDirectory, initialCommand: initialCommand, @@ -8028,7 +8122,15 @@ class TerminalController { startupEnvironment: startupEnvironment, initialDividerPosition: initialDividerPosition.map { CGFloat($0) }, remotePTYSessionID: remotePTYSessionID - ) + ) { + case .created(let panel): + newId = panel.id + case .routedToRemote: + result = v2RemoteRoutedCreationResult(tabManager: tabManager, workspace: ws, panelType: panelType) + return + case .failed: + newId = nil + } } if let newId { @@ -8244,6 +8346,18 @@ class TerminalController { return } + if ws.isRemoteTmuxMirror, panelType == .terminal, + let err = v2MirrorUnsupportedOptionsError( + workingDirectory: workingDirectory, + initialCommand: initialCommand, + tmuxStartCommand: tmuxStartCommand, + startupEnvironment: startupEnvironment, + remotePTYSessionID: remotePTYSessionID + ) { + result = err + return + } + let newPanelId: UUID? let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) if panelType == .browser { @@ -8262,7 +8376,7 @@ class TerminalController { focus: focus )?.id } else { - newPanelId = ws.newTerminalSurface( + switch ws.newTerminalSurfaceOutcome( inPane: paneId, focus: focus, workingDirectory: workingDirectory, @@ -8270,7 +8384,15 @@ class TerminalController { tmuxStartCommand: tmuxStartCommand, startupEnvironment: startupEnvironment, remotePTYSessionID: remotePTYSessionID - )?.id + ) { + case .created(let panel): + newPanelId = panel.id + case .routedToRemote: + result = v2RemoteRoutedCreationResult(tabManager: tabManager, workspace: ws, panelType: panelType) + return + case .failed: + newPanelId = nil + } } guard let newPanelId else { @@ -9651,6 +9773,19 @@ class TerminalController { return } + if ws.isRemoteTmuxMirror, panelType == .terminal, + let err = v2MirrorUnsupportedOptionsError( + insertFirst: insertFirst, + workingDirectory: workingDirectory, + initialCommand: initialCommand, + tmuxStartCommand: tmuxStartCommand, + startupEnvironment: startupEnvironment, + initialDividerPosition: initialDividerPosition + ) { + result = err + return + } + let newPanelId: UUID? let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) if panelType == .browser { @@ -9664,7 +9799,7 @@ class TerminalController { initialDividerPosition: initialDividerPosition.map { CGFloat($0) } )?.id } else { - newPanelId = ws.newTerminalSplit( + switch ws.newTerminalSplitOutcome( from: sourcePanelId, orientation: orientation, insertFirst: insertFirst, @@ -9674,7 +9809,15 @@ class TerminalController { tmuxStartCommand: tmuxStartCommand, startupEnvironment: startupEnvironment, initialDividerPosition: initialDividerPosition.map { CGFloat($0) } - )?.id + ) { + case .created(let panel): + newPanelId = panel.id + case .routedToRemote: + result = v2RemoteRoutedCreationResult(tabManager: tabManager, workspace: ws, panelType: panelType) + return + case .failed: + newPanelId = nil + } } guard let newPanelId else { @@ -17721,8 +17864,24 @@ class TerminalController { return } - if let newPanelId = tabManager.newSplit(tabId: tabId, surfaceId: targetSurface, direction: direction) { - result = "OK \(newPanelId.uuidString)" + if tab.isRemoteTmuxMirror, direction.insertFirst { + // Routed tmux `split-window` cannot insert before the target + // pane; reject before mutating the remote session. + result = Self.v1MirrorDirectionError + return + } + + switch tab.newTerminalSplitOutcome( + from: targetSurface, + orientation: direction.orientation, + insertFirst: direction.insertFirst + ) { + case .created(let panel): + result = "OK \(panel.id.uuidString)" + case .routedToRemote: + result = "OK routed-to-remote-tmux" + case .failed: + break } } return result @@ -19468,27 +19627,35 @@ class TerminalController { return } - let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSplit( + if let id = tab.newBrowserSplit( from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url, focus: focus, creationPolicy: .automationPreload - )?.id + )?.id { + result = "OK \(id.uuidString)" + } + } else if tab.isRemoteTmuxMirror, insertFirst { + // Routed tmux `split-window` cannot insert before the target + // pane; reject before mutating the remote session. + result = Self.v1MirrorDirectionError } else { - newPanelId = tab.newTerminalSplit( + switch tab.newTerminalSplitOutcome( from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, focus: focus - )?.id - } - - if let id = newPanelId { - result = "OK \(id.uuidString)" + ) { + case .created(let panel): + result = "OK \(panel.id.uuidString)" + case .routedToRemote: + result = "OK routed-to-remote-tmux" + case .failed: + break + } } } return result @@ -21224,20 +21391,24 @@ class TerminalController { return } - let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface( + if let id = tab.newBrowserSurface( inPane: targetPaneId, url: url, focus: focus, creationPolicy: .automationPreload - )?.id + )?.id { + result = "OK \(id.uuidString)" + } } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: focus)?.id - } - - if let id = newPanelId { - result = "OK \(id.uuidString)" + switch tab.newTerminalSurfaceOutcome(inPane: targetPaneId, focus: focus) { + case .created(let panel): + result = "OK \(panel.id.uuidString)" + case .routedToRemote: + result = "OK routed-to-remote-tmux" + case .failed: + break + } } } return result diff --git a/Sources/TerminalPanelCreationOutcome.swift b/Sources/TerminalPanelCreationOutcome.swift new file mode 100644 index 000000000000..352879d3884f --- /dev/null +++ b/Sources/TerminalPanelCreationOutcome.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Outcome of a terminal split/surface creation request in a workspace that may +/// route the mutation to a remote tmux mirror instead of mutating locally. +/// +/// Socket/CLI handlers need to distinguish "the request became a tmux command +/// and the panel arrives asynchronously via the mirror's topology events" +/// (`routedToRemote`) from a genuine failure: reporting an error for a routed +/// request makes automation retry and duplicate remote tmux panes even though +/// the first request already mutated the remote session. +enum TerminalPanelCreationOutcome { + /// A local panel was created synchronously. + case created(TerminalPanel) + /// The request was forwarded to the remote tmux session backing this + /// mirror workspace. No local panel exists yet — it arrives via the + /// mirror's `%layout-change` / `%window-add` handling. + case routedToRemote + /// Nothing was created or routed. + case failed + + /// The created panel, or `nil` for `.routedToRemote` / `.failed`. + /// Convenience for callers that only need the nil-vs-panel distinction + /// (e.g. the `newTerminalSplit` / `newTerminalSurface` wrappers). + var panel: TerminalPanel? { + if case .created(let p) = self { return p } + return nil + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a6b42167dfce..bc9d4b8752ed 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14705,6 +14705,81 @@ final class Workspace: Identifiable, ObservableObject { initialDividerPosition: CGFloat? = nil, remotePTYSessionID: String? = nil ) -> TerminalPanel? { + return newTerminalSplitOutcome( + from: panelId, + orientation: orientation, + insertFirst: insertFirst, + focus: focus, + workingDirectory: workingDirectory, + initialCommand: initialCommand, + tmuxStartCommand: tmuxStartCommand, + startupEnvironment: startupEnvironment, + initialDividerPosition: initialDividerPosition, + remotePTYSessionID: remotePTYSessionID + ).panel + } + + /// Like ``newTerminalSplit(from:orientation:insertFirst:focus:workingDirectory:initialCommand:tmuxStartCommand:startupEnvironment:initialDividerPosition:remotePTYSessionID:)`` + /// but distinguishes a split routed to the remote tmux mirror from a genuine + /// failure, so socket/CLI handlers can report the routed request as accepted. + /// (Reporting an error makes automation retry and duplicate remote panes.) + func newTerminalSplitOutcome( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + focus: Bool = true, + workingDirectory: String? = nil, + initialCommand: String? = nil, + tmuxStartCommand: String? = nil, + startupEnvironment: [String: String] = [:], + initialDividerPosition: CGFloat? = nil, + remotePTYSessionID: String? = nil + ) -> TerminalPanelCreationOutcome { + // In a remote tmux mirror workspace a split means "split the mirrored + // tmux pane": route it to the remote and let the resulting + // %layout-change render the new pane (one source of truth). NEVER + // create a local split here, even when the route can't be taken + // (dead/missing connection) — a local pane would be an orphan the + // mirror's rebuild() never reconciles, breaking the 1:1 invariant + // (same rule as newTerminalSurfaceOutcome). Routing by the requested + // panel — not the pane's selected tab, which is all the bonsplit-level + // veto in splitTabBar(_:shouldSplitPane:orientation:) can see — keeps + // programmatic splits aimed at a background window-tab precise. + if isRemoteTmuxMirror { + let routed = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested( + workspaceId: id, + panelId: panelId, + vertical: orientation == .vertical + ) ?? false + return routed ? .routedToRemote : .failed + } + guard let panel = newTerminalSplitLocal( + from: panelId, + orientation: orientation, + insertFirst: insertFirst, + focus: focus, + workingDirectory: workingDirectory, + initialCommand: initialCommand, + tmuxStartCommand: tmuxStartCommand, + startupEnvironment: startupEnvironment, + initialDividerPosition: initialDividerPosition, + remotePTYSessionID: remotePTYSessionID + ) else { return .failed } + return .created(panel) + } + + private func newTerminalSplitLocal( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool, + focus: Bool, + workingDirectory: String?, + initialCommand: String?, + tmuxStartCommand: String?, + startupEnvironment: [String: String], + initialDividerPosition: CGFloat?, + remotePTYSessionID: String? + ) -> TerminalPanel? { #if DEBUG let splitTimingStart = ProcessInfo.processInfo.systemUptime let splitTransport = remoteConfiguration?.transport.rawValue ?? "local" @@ -14906,6 +14981,37 @@ final class Workspace: Identifiable, ObservableObject { remotePTYSessionID: String? = nil, suppressWorkspaceRemoteStartupCommand: Bool = false ) -> TerminalPanel? { + return newTerminalSurfaceOutcome( + inPane: paneId, + focus: focus, + workingDirectory: workingDirectory, + initialCommand: initialCommand, + tmuxStartCommand: tmuxStartCommand, + initialInput: initialInput, + startupEnvironment: startupEnvironment, + autoRefreshMetadata: autoRefreshMetadata, + preserveFocusWhenUnfocused: preserveFocusWhenUnfocused, + remotePTYSessionID: remotePTYSessionID, + suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand + ).panel + } + + /// Like ``newTerminalSurface(inPane:focus:workingDirectory:initialCommand:tmuxStartCommand:initialInput:startupEnvironment:autoRefreshMetadata:preserveFocusWhenUnfocused:remotePTYSessionID:suppressWorkspaceRemoteStartupCommand:)`` + /// but distinguishes a request routed to the remote tmux mirror from a genuine + /// failure, so socket/CLI handlers can report the routed request as accepted. + func newTerminalSurfaceOutcome( + inPane paneId: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + initialCommand: String? = nil, + tmuxStartCommand: String? = nil, + initialInput: String? = nil, + startupEnvironment: [String: String] = [:], + autoRefreshMetadata: Bool = true, + preserveFocusWhenUnfocused: Bool = true, + remotePTYSessionID: String? = nil, + suppressWorkspaceRemoteStartupCommand: Bool = false + ) -> TerminalPanelCreationOutcome { // In a remote tmux mirror workspace, a new tab means "create a tmux // window" — route it to the remote and let the resulting %window-add // notification add the tab (one source of truth). NEVER create a local @@ -14914,9 +15020,39 @@ final class Workspace: Identifiable, ObservableObject { // breaking the 1:1 invariant (symmetric with newBrowserSurface). A dead // mirror workspace is torn down separately via handleSessionEndedRemotely. if isRemoteTmuxMirror { - _ = AppDelegate.shared?.remoteTmuxController.handleMirrorNewTabRequested(workspaceId: id) - return nil + let routed = AppDelegate.shared?.remoteTmuxController + .handleMirrorNewTabRequested(workspaceId: id) ?? false + return routed ? .routedToRemote : .failed } + guard let panel = newTerminalSurfaceLocal( + inPane: paneId, + focus: focus, + workingDirectory: workingDirectory, + initialCommand: initialCommand, + tmuxStartCommand: tmuxStartCommand, + initialInput: initialInput, + startupEnvironment: startupEnvironment, + autoRefreshMetadata: autoRefreshMetadata, + preserveFocusWhenUnfocused: preserveFocusWhenUnfocused, + remotePTYSessionID: remotePTYSessionID, + suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand + ) else { return .failed } + return .created(panel) + } + + private func newTerminalSurfaceLocal( + inPane paneId: PaneID, + focus: Bool?, + workingDirectory: String?, + initialCommand: String?, + tmuxStartCommand: String?, + initialInput: String?, + startupEnvironment: [String: String], + autoRefreshMetadata: Bool, + preserveFocusWhenUnfocused: Bool, + remotePTYSessionID: String?, + suppressWorkspaceRemoteStartupCommand: Bool + ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView @@ -19704,14 +19840,19 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { // In a remote tmux mirror, a split (button or any bonsplit-level split) // becomes a tmux `split-window`; the new pane arrives via %layout-change. - // Veto the local split when handled. Local workspaces split normally. - guard isRemoteTmuxMirror, - let tabId = bonsplitController.selectedTab(inPane: pane)?.id, - let panelId = panelIdFromSurfaceId(tabId) else { return true } - let handled = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested( - workspaceId: id, panelId: panelId, vertical: orientation == .vertical - ) ?? false - return !handled + // Local workspaces split normally. ALWAYS veto the local split in a + // mirror — even when the route can't be taken (tab lookup failed, or + // the connection is reconnecting and can't deliver the command) — a + // local pane would be an orphan the mirror's rebuild() never + // reconciles, breaking the 1:1 invariant. + guard isRemoteTmuxMirror else { return true } + if let tabId = bonsplitController.selectedTab(inPane: pane)?.id, + let panelId = panelIdFromSurfaceId(tabId) { + _ = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested( + workspaceId: id, panelId: panelId, vertical: orientation == .vertical + ) + } + return false } func splitTabBar(_ controller: BonsplitController, didReorderTabsInPane pane: PaneID, orderedTabIds: [TabID]) { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6ccff48c4e46..331a9497d017 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -667,6 +667,7 @@ A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; + E8B3D5F71A2C49608B7D5E31 /* TerminalPanelCreationOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A6C8E40D5B17293C8F6A42 /* TerminalPanelCreationOutcome.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; C0DE53350000000000000001 /* TerminalSearchOverlayHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE53350000000000000002 /* TerminalSearchOverlayHostingView.swift */; }; C0DE53360000000000000001 /* TerminalSearchOverlayMouseReleaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE53360000000000000002 /* TerminalSearchOverlayMouseReleaseTests.swift */; }; @@ -1446,6 +1447,7 @@ A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; }; D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = "<group>"; }; A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = "<group>"; }; + F2A6C8E40D5B17293C8F6A42 /* TerminalPanelCreationOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPanelCreationOutcome.swift; sourceTree = "<group>"; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; }; C0DE53350000000000000002 /* TerminalSearchOverlayHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/TerminalSearchOverlayHostingView.swift; sourceTree = "<group>"; }; C0DE53360000000000000002 /* TerminalSearchOverlayMouseReleaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSearchOverlayMouseReleaseTests.swift; sourceTree = "<group>"; }; @@ -2111,6 +2113,7 @@ A5001611 /* SessionPersistence.swift */, E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */, FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */, + F2A6C8E40D5B17293C8F6A42 /* TerminalPanelCreationOutcome.swift */, D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */, 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */, 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */, @@ -3192,6 +3195,7 @@ A5001095 /* TerminalNotificationStore.swift in Sources */, D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, + E8B3D5F71A2C49608B7D5E31 /* TerminalPanelCreationOutcome.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, C0DE53350000000000000001 /* TerminalSearchOverlayHostingView.swift in Sources */, A5001543 /* TerminalSSHSessionDetector.swift in Sources */, diff --git a/web/messages/en.json b/web/messages/en.json index 37fa14238e39..3f5a595cfe1a 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -663,7 +663,7 @@ "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", "behaviorTitle": "What it supports", "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", diff --git a/web/messages/ja.json b/web/messages/ja.json index 62753b38e131..df8b0cc3db2d 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -619,7 +619,7 @@ "howDesc": "cmux は ssh … tmux -CC attach を起動し、組み込みの tmux ビューアーに頼らずコントロールモードのストリームを自前で解析します。そのためプロトコルや %begin/%end のコマンド対応付けは完全に cmux が管理します。各リモートペインは tmux の %output を流し込む専用のターミナルサーフェスに描画され、キー入力やマウスなどの入力は tmux の send-keys でリモートへ送られます。ペインのサイズやリフローはリモートの tmux サーバーが管理し、cmux はそれに追従してローカルではリフローしません。", "behaviorTitle": "サポートしている機能", "behaviorSize": "サイズ調整: リモートクライアントを描画中のグリッドに合わせてリサイズします(refresh-client -C)。TUI が tmux のデフォルトの 80×24 のままになりません。", - "behaviorSplit": "分割: ミラーされたウィンドウでペインを分割・クローズすると tmux の split-window に伝播し、cmux と tmux のレイアウトが同期します。", + "behaviorSplit": "分割: ミラーされたウィンドウでペインを分割・クローズすると tmux の split-window に伝播し、cmux と tmux のレイアウトが同期します。プログラムからの分割(cmux new-split やソケット経由の surface.split)は accepted を返し、surface id は含まれません。新しいペインは tmux がレイアウト変更を確定した後に非同期で追加されます。ルーティングされる分割が適用できないオプション(起動コマンド、作業ディレクトリ、分割位置、左/上への配置)を含むリクエストは、リモートセッションを変更する前に拒否されます。", "behaviorReorder": "並べ替え: ミラーされたタブをドラッグで並べ替えると、swap-window で tmux のウィンドウが並べ替えられます。", "behaviorCwd": "作業ディレクトリ: リモートペインの現在のフォルダーを追跡し、タブに表示します。", "behaviorPaste": "貼り付けとドロップ: 貼り付けたテキストやドロップした画像・ファイルは tmux の paste-buffer -p 経由で渡されるため、リモートのアプリ(コーディングエージェントなど)は本物のブラケットペーストを受け取ります。画像はローカルパスではなく [Image #N] として届きます。", From a0a84927cbfb925384efc1e8b157f430f53842fd Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 16:03:31 -0700 Subject: [PATCH 48/73] Add remote tmux control name regression tests --- cmuxTests/RemoteTmuxAuthTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index c567562858e7..6cd3867738da 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -109,6 +109,21 @@ import Testing #expect(base.controlSocketPath == RemoteTmuxHost(destination: "user@host").controlSocketPath) } + @Test func controlModeCommandNameRejectsLineDelimitersAndControlScalars() { + #expect(RemoteTmuxHost.controlModeCommandName("work session") == "work session") + #expect(RemoteTmuxHost.controlModeCommandName(" work session ") == "work session") + #expect(RemoteTmuxHost.controlModeCommandName("") == nil) + #expect(RemoteTmuxHost.controlModeCommandName("safe\nrename-window injected") == nil) + #expect(RemoteTmuxHost.controlModeCommandName("safe\rrename-window injected") == nil) + #expect(RemoteTmuxHost.controlModeCommandName("safe\u{7f}") == nil) + } + + @Test func confirmedControlModeNamesPreserveSafeSpacing() { + #expect(RemoteTmuxHost.controlModeLineSafeName(" work session ") == " work session ") + #expect(RemoteTmuxHost.controlModeLineSafeName("work\tbad") == nil) + #expect(RemoteTmuxHost.controlModeLineSafeName("work\nbad") == nil) + } + // MARK: - Interactive auth invocation (what `cmux ssh-tmux` runs in the tty) @Test func interactiveAuthInvocationShape() { From d32d2907c5a0039ff90f028d9824805f094706a1 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 16:03:35 -0700 Subject: [PATCH 49/73] Harden remote tmux mirror state changes --- Sources/RemoteTmuxConnectionObservers.swift | 12 ++++ Sources/RemoteTmuxControlConnection.swift | 13 +++- Sources/RemoteTmuxController.swift | 62 ++++++++++++++----- Sources/RemoteTmuxHost.swift | 19 ++++++ Sources/RemoteTmuxSessionMirror.swift | 13 ++++ ...nalController+ControlSidebarContext3.swift | 3 + ...nalController+ControlSurfaceContext2.swift | 3 + ...inalController+ControlSystemContext2.swift | 2 +- Sources/TerminalController.swift | 3 + Sources/Workspace.swift | 23 +++++++ 10 files changed, 137 insertions(+), 16 deletions(-) diff --git a/Sources/RemoteTmuxConnectionObservers.swift b/Sources/RemoteTmuxConnectionObservers.swift index 1dd74131ed7b..1be53c7c2056 100644 --- a/Sources/RemoteTmuxConnectionObservers.swift +++ b/Sources/RemoteTmuxConnectionObservers.swift @@ -18,6 +18,7 @@ final class RemoteTmuxConnectionObservers { private var paneCwdObservers: [Token: (_ paneId: Int, _ path: String) -> Void] = [:] private var paneReflowObservers: [Token: (_ paneId: Int, _ noReflow: Bool) -> Void] = [:] private var activePaneObservers: [Token: (_ windowId: Int, _ paneId: Int) -> Void] = [:] + private var sessionChangedObservers: [Token: (_ oldName: String, _ newName: String) -> Void] = [:] private var topologyObservers: [Token: () -> Void] = [:] private var exitObservers: [Token: () -> Void] = [:] private var stateObservers: [Token: (RemoteTmuxControlConnection.ConnectionState) -> Void] = [:] @@ -40,6 +41,9 @@ final class RemoteTmuxConnectionObservers { /// - onActivePaneChanged: fires when a window's active pane changes /// (`%window-pane-changed`), so consumers can re-project per-pane state /// (e.g. the active pane's directory) onto the window's tab. + /// - onSessionChanged: fires when tmux confirms a session rename via + /// `%session-changed`; consumers must treat this as the authoritative + /// point for re-keying session-owned state. /// - onTopologyChanged: fires when the window/pane topology changes. /// - onExit: fires once when the connection PERMANENTLY ends (a genuine tmux /// `%exit`, or a session found gone on reconnect). A transient transport loss @@ -53,6 +57,7 @@ final class RemoteTmuxConnectionObservers { onPaneCwd: ((_ paneId: Int, _ path: String) -> Void)?, onPaneReflow: ((_ paneId: Int, _ noReflow: Bool) -> Void)?, onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)?, + onSessionChanged: ((_ oldName: String, _ newName: String) -> Void)?, onTopologyChanged: (() -> Void)?, onExit: (() -> Void)?, onConnectionStateChanged: ((RemoteTmuxControlConnection.ConnectionState) -> Void)? @@ -62,6 +67,7 @@ final class RemoteTmuxConnectionObservers { if let onPaneCwd { paneCwdObservers[token] = onPaneCwd } if let onPaneReflow { paneReflowObservers[token] = onPaneReflow } if let onActivePaneChanged { activePaneObservers[token] = onActivePaneChanged } + if let onSessionChanged { sessionChangedObservers[token] = onSessionChanged } if let onTopologyChanged { topologyObservers[token] = onTopologyChanged } if let onExit { exitObservers[token] = onExit } if let onConnectionStateChanged { stateObservers[token] = onConnectionStateChanged } @@ -74,6 +80,7 @@ final class RemoteTmuxConnectionObservers { paneCwdObservers[token] = nil paneReflowObservers[token] = nil activePaneObservers[token] = nil + sessionChangedObservers[token] = nil topologyObservers[token] = nil exitObservers[token] = nil stateObservers[token] = nil @@ -101,6 +108,11 @@ final class RemoteTmuxConnectionObservers { for callback in Array(activePaneObservers.values) { callback(windowId, paneId) } } + /// Notifies every observer that the remote session name changed. + func emitSessionChanged(oldName: String, newName: String) { + for callback in Array(sessionChangedObservers.values) { callback(oldName, newName) } + } + /// Notifies every topology observer that the window/pane layout changed. func notifyTopologyChanged() { for callback in Array(topologyObservers.values) { callback() } diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 85c67f6b966c..ad4f5d4a6dbe 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -291,6 +291,8 @@ final class RemoteTmuxControlConnection { /// - onActivePaneChanged: fires when a window's active pane changes /// (`%window-pane-changed`), so consumers can re-project per-pane state /// (e.g. the active pane's directory) onto the window's tab. + /// - onSessionChanged: fires when tmux confirms a session rename via + /// `%session-changed`. /// - onTopologyChanged: fires when the window/pane topology changes. /// - onExit: fires once when the connection PERMANENTLY ends (a genuine tmux /// `%exit`, or a session found gone on reconnect). A transient transport loss @@ -304,6 +306,7 @@ final class RemoteTmuxControlConnection { onPaneCwd: ((_ paneId: Int, _ path: String) -> Void)? = nil, onPaneReflow: ((_ paneId: Int, _ noReflow: Bool) -> Void)? = nil, onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)? = nil, + onSessionChanged: ((_ oldName: String, _ newName: String) -> Void)? = nil, onTopologyChanged: (() -> Void)? = nil, onExit: (() -> Void)? = nil, onConnectionStateChanged: ((ConnectionState) -> Void)? = nil @@ -313,6 +316,7 @@ final class RemoteTmuxControlConnection { onPaneCwd: onPaneCwd, onPaneReflow: onPaneReflow, onActivePaneChanged: onActivePaneChanged, + onSessionChanged: onSessionChanged, onTopologyChanged: onTopologyChanged, onExit: onExit, onConnectionStateChanged: onConnectionStateChanged @@ -1107,12 +1111,19 @@ final class RemoteTmuxControlConnection { totalOutputBytes += data.count observers.emitPaneOutput(paneId, data) case let .sessionChanged(id, name): + guard let safeName = RemoteTmuxHost.controlModeLineSafeName(name) else { + record("session-changed-invalid $\(id)") + requestWindows() + return + } + let oldName = sessionName sessionId = id // Track the new name too: `sessionName` is the value reused for // attach/reconnect, so a remote rename must update it or the next // reconnect targets a stale session and is wrongly declared gone. - sessionName = name + sessionName = safeName record("session-changed $\(id)") + observers.emitSessionChanged(oldName: oldName, newName: safeName) requestWindows() case .sessionsChanged: record("sessions-changed") diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 729d2b4c2740..a7b7a4932438 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -301,28 +301,56 @@ final class RemoteTmuxController { /// A mirrored workspace was renamed → `rename-session` on the remote so the /// tmux session name tracks the cmux workspace title. func handleMirrorWorkspaceRenamed(workspaceId: UUID, title: String?) { - let name = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, + guard let name = RemoteTmuxHost.controlModeCommandName(title), let entry = sessionMirrors.first(where: { $0.value.mirroredWorkspaceId == workspaceId }) else { return } let mirror = entry.value let oldName = mirror.sessionName guard name != oldName, !mirror.connection.exited else { return } - let host = mirror.host // Target by the stable session id when known, so the rename can't race a // prior rename's name. - let target = mirror.connection.sessionId.map { "$\($0)" } - ?? RemoteTmuxHost.shellSingleQuoted(oldName) + guard let target = mirror.connection.sessionId.map({ "$\($0)" }) + ?? RemoteTmuxHost.controlModeLineSafeName(oldName).map(RemoteTmuxHost.shellSingleQuoted) + else { return } mirror.connection.send("rename-session -t \(target) \(RemoteTmuxHost.shellSingleQuoted(name))") - // Re-key all per-session state from the old name to the new one so - // detach / kill / attach-reuse keep working after the rename. + // Do not re-key local state here. tmux can reject a rename (for example + // duplicate session name); `%session-changed` is the confirmation point. + } + + /// Tmux confirmed that a mirrored session's name changed. This is the single + /// place that re-keys controller dictionaries keyed by host+session name. + func handleMirrorSessionNameChanged( + mirror: RemoteTmuxSessionMirror, + oldName: String, + newName: String + ) { + guard let safeName = RemoteTmuxHost.controlModeLineSafeName(newName), + oldName != safeName else { + return + } + let host = mirror.host let oldKey = Self.connectionKey(host: host, sessionName: oldName) - let newKey = Self.connectionKey(host: host, sessionName: name) - mirror.setSessionName(name) - mirror.connection.setSessionName(name) + let newKey = Self.connectionKey(host: host, sessionName: safeName) + if let existing = sessionMirrors[newKey], existing !== mirror { return } + if let existing = connectionsByHostSession[newKey], existing !== mirror.connection { return } + + mirror.setSessionName(safeName) + mirror.connection.setSessionName(safeName) + if oldKey != newKey { - if let m = sessionMirrors.removeValue(forKey: oldKey) { sessionMirrors[newKey] = m } - if let c = connectionsByHostSession.removeValue(forKey: oldKey) { connectionsByHostSession[newKey] = c } + if let entry = sessionMirrors.removeValue(forKey: oldKey) { + sessionMirrors[newKey] = entry + } else if let currentKey = sessionMirrors.first(where: { $0.value === mirror })?.key { + sessionMirrors.removeValue(forKey: currentKey) + sessionMirrors[newKey] = mirror + } + + if let connection = connectionsByHostSession.removeValue(forKey: oldKey) { + connectionsByHostSession[newKey] = connection + } else if let currentKey = connectionsByHostSession.first(where: { $0.value === mirror.connection })?.key { + connectionsByHostSession.removeValue(forKey: currentKey) + connectionsByHostSession[newKey] = mirror.connection + } } } @@ -435,8 +463,7 @@ final class RemoteTmuxController { /// A mirrored window's tab was renamed → `rename-window` on the remote. func handleMirrorWindowRenamed(workspaceId: UUID, panelId: UUID, title: String?) { - let name = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, + guard let name = RemoteTmuxHost.controlModeCommandName(title), let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), !mirror.connection.exited, let windowId = mirror.windowId(forPanel: panelId) else { return } @@ -456,6 +483,13 @@ final class RemoteTmuxController { return (mirror, windowId) } + /// Whether the panel is currently a tmux window tab in a mirrored workspace. + /// This lets non-interactive socket close paths route or reject before they + /// mark the tab as a forced local close. + func isMirrorWindowTab(workspaceId: UUID, panelId: UUID) -> Bool { + mirrorWindowTarget(workspaceId: workspaceId, panelId: panelId) != nil + } + /// A tab close was requested in a mirrored workspace → kill that tmux window /// on the remote. The local tab is removed when tmux reports `%window-close`, /// so the caller should VETO the immediate local close. diff --git a/Sources/RemoteTmuxHost.swift b/Sources/RemoteTmuxHost.swift index bfd20b902336..165e52e5967b 100644 --- a/Sources/RemoteTmuxHost.swift +++ b/Sources/RemoteTmuxHost.swift @@ -174,6 +174,25 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } + /// Returns a non-empty tmux control-mode command argument, or `nil` when the + /// value could break the line-oriented control stream. Shell quoting is not + /// enough here: CR/LF/control bytes can terminate a `rename-*` command line + /// before tmux parses the quoted argument. + static func controlModeCommandName(_ value: String?) -> String? { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return controlModeLineSafeName(trimmed) + } + + /// Validates a name already received from tmux. Unlike + /// ``controlModeCommandName(_:)``, this preserves surrounding spaces because + /// tmux is the source of truth for confirmed session/window names. + static func controlModeLineSafeName(_ value: String) -> String? { + guard !value.isEmpty else { return nil } + let forbidden = CharacterSet.controlCharacters.union(.newlines) + guard value.unicodeScalars.allSatisfy({ !forbidden.contains($0) }) else { return nil } + return value + } + /// Builds the `ssh` argv (for direct `Process` execution, no shell) that /// runs `tmux -CC` control mode for `sessionName` on this host. /// diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index b40e5d294f24..d7883115e31e 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -65,6 +65,9 @@ final class RemoteTmuxSessionMirror { onActivePaneChanged: { [weak self] windowId, paneId in self?.handleActivePaneChanged(windowId: windowId, paneId: paneId) }, + onSessionChanged: { [weak self] oldName, newName in + self?.handleSessionNameChanged(oldName: oldName, newName: newName) + }, onTopologyChanged: { [weak self] in self?.rebuild() }, @@ -108,6 +111,16 @@ final class RemoteTmuxSessionMirror { ) } + /// Tmux confirmed a session rename. The controller owns the session-keyed + /// dictionaries, so it performs the re-key and then updates this mirror. + private func handleSessionNameChanged(oldName: String, newName: String) { + AppDelegate.shared?.remoteTmuxController.handleMirrorSessionNameChanged( + mirror: self, + oldName: oldName, + newName: newName + ) + } + /// The cmux workspace mirroring this session (if still alive). var mirroredWorkspaceId: UUID? { workspace?.id } diff --git a/Sources/TerminalController+ControlSidebarContext3.swift b/Sources/TerminalController+ControlSidebarContext3.swift index 413fe1284285..d216f99fbace 100644 --- a/Sources/TerminalController+ControlSidebarContext3.swift +++ b/Sources/TerminalController+ControlSidebarContext3.swift @@ -324,6 +324,9 @@ extension TerminalController { force: Bool ) -> Bool { if let tabId = workspace.surfaceIdFromPanelId(surfaceId) { + if force { + return workspace.requestNonInteractiveCloseTabRecordingHistory(tabId) + } return workspace.requestCloseTabRecordingHistory(tabId, force: force) } diff --git a/Sources/TerminalController+ControlSurfaceContext2.swift b/Sources/TerminalController+ControlSurfaceContext2.swift index cbb7d9f760f6..70bc7b2e8d56 100644 --- a/Sources/TerminalController+ControlSurfaceContext2.swift +++ b/Sources/TerminalController+ControlSurfaceContext2.swift @@ -433,6 +433,9 @@ extension TerminalController { force: Bool ) -> Bool { if let tabId = workspace.surfaceIdFromPanelId(surfaceId) { + if force { + return workspace.requestNonInteractiveCloseTabRecordingHistory(tabId) + } return workspace.requestCloseTabRecordingHistory(tabId, force: force) } workspace.markCloseHistoryEligible(panelId: surfaceId) diff --git a/Sources/TerminalController+ControlSystemContext2.swift b/Sources/TerminalController+ControlSystemContext2.swift index 8ce756acbf81..9a91dbbe2fb4 100644 --- a/Sources/TerminalController+ControlSystemContext2.swift +++ b/Sources/TerminalController+ControlSystemContext2.swift @@ -94,7 +94,7 @@ extension TerminalController { if workspace.panels.count <= 1 { break } - if workspace.requestCloseTabRecordingHistory(tabId, force: true) { + if workspace.requestNonInteractiveCloseTabRecordingHistory(tabId) { closed += 1 } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0763bd3bad6d..626d722256e2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4236,6 +4236,9 @@ class TerminalController { @discardableResult func closeSurfaceRecordingHistory(in workspace: Workspace, surfaceId: UUID, force: Bool) -> Bool { if let tabId = workspace.surfaceIdFromPanelId(surfaceId) { + if force { + return workspace.requestNonInteractiveCloseTabRecordingHistory(tabId) + } return workspace.requestCloseTabRecordingHistory(tabId, force: force) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2c967ca468d0..c2add5e29334 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4118,6 +4118,29 @@ final class Workspace: Identifiable, ObservableObject { return closed } + /// Non-interactive socket/API close path. Remote-tmux mirror tabs must be + /// routed to tmux before a local forced close is attempted; otherwise + /// `forceCloseTabIds` bypasses `shouldCloseTab` and removes the cmux tab + /// while leaving the remote tmux window alive. + @discardableResult + func requestNonInteractiveCloseTabRecordingHistory(_ tabId: TabID) -> Bool { + if routeRemoteTmuxNonInteractiveTabCloseIfNeeded(tabId) { + return true + } + return requestCloseTabRecordingHistory(tabId, force: true) + } + + private func routeRemoteTmuxNonInteractiveTabCloseIfNeeded(_ tabId: TabID) -> Bool { + guard isRemoteTmuxMirror, + let panelId = panelIdFromSurfaceId(tabId), + let remoteTmuxController = AppDelegate.shared?.remoteTmuxController, + remoteTmuxController.isMirrorWindowTab(workspaceId: id, panelId: panelId) + else { + return false + } + return remoteTmuxController.handleMirrorTabCloseRequested(workspaceId: id, panelId: panelId) + } + func withClosedPanelHistorySuppressed(_ body: () -> Void) { let previous = suppressClosedPanelHistory suppressClosedPanelHistory = true From 88fce0b0bb1543e73c2e260fe370223babd05414 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 16:18:00 -0700 Subject: [PATCH 50/73] Fix remote tmux control stream backpressure --- Sources/RemoteTmuxControlConnection.swift | 186 +++++++++++++++++----- Sources/RemoteTmuxController.swift | 24 +-- Sources/RemoteTmuxSessionMirror.swift | 3 +- Sources/Workspace.swift | 20 ++- cmuxTests/RemoteTmuxAuthTests.swift | 5 + 5 files changed, 179 insertions(+), 59 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index ad4f5d4a6dbe..97b43a2ade95 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -1,4 +1,81 @@ import Foundation +import os + +/// Bounded off-main writer for the SSH control client's stdin pipe. +/// +/// `RemoteTmuxControlConnection` records command FIFO entries on the main actor +/// before this writer can emit bytes, so tmux `%begin`/`%end` replies cannot +/// outrun their local correlation slot. The write itself may block on a stalled +/// SSH pipe; keeping it on this serial queue prevents that from freezing UI. +private final class RemoteTmuxControlPipeWriter: @unchecked Sendable { + private struct State { + var closed = false + var pendingBytes = 0 + } + + private let handle: FileHandle + private let queue: DispatchQueue + private let maxPendingBytes: Int + private let onFailure: @Sendable (Error) -> Void + private let state = OSAllocatedUnfairLock(initialState: State()) + + init( + handle: FileHandle, + label: String, + maxPendingBytes: Int, + onFailure: @escaping @Sendable (Error) -> Void + ) { + self.handle = handle + self.queue = DispatchQueue(label: label, qos: .userInitiated) + self.maxPendingBytes = maxPendingBytes + self.onFailure = onFailure + } + + func enqueue(_ data: Data) -> Bool { + guard !data.isEmpty else { return true } + let accepted = state.withLock { state in + guard !state.closed, + data.count <= maxPendingBytes - state.pendingBytes else { + return false + } + state.pendingBytes += data.count + return true + } + guard accepted else { return false } + + queue.async { [handle, onFailure, state] in + var writeError: Error? + let shouldWrite = state.withLock { !$0.closed } + if shouldWrite { + do { + try handle.write(contentsOf: data) + } catch { + writeError = error + } + } + + let shouldReportFailure = state.withLock { state in + state.pendingBytes = max(0, state.pendingBytes - data.count) + return !state.closed + } + if let writeError, shouldReportFailure { + onFailure(writeError) + } + } + return true + } + + func close() { + let shouldClose = state.withLock { state in + guard !state.closed else { return false } + state.closed = true + return true + } + if shouldClose { + try? handle.close() + } + } +} /// A live tmux control-mode connection to one remote session. /// @@ -65,7 +142,7 @@ final class RemoteTmuxControlConnection { private var activityQueryCompletions: [UUID: ([Int: PaneForegroundState]?) -> Void] = [:] private var process: Process? - private var stdinHandle: FileHandle? + private var stdinWriter: RemoteTmuxControlPipeWriter? private var stdoutReader: FileHandle? private var stderrReader: FileHandle? private var streamContinuation: AsyncStream<Data>.Continuation? @@ -150,6 +227,10 @@ final class RemoteTmuxControlConnection { private static let reconnectMaxDelaySeconds: Double = 10 /// Cap on captured stderr (bytes) so a noisy/hostile remote can't grow it unbounded. private static let maxStderrBytes = 8 * 1024 + /// Cap queued stdin bytes while the dedicated writer is backpressured. Above + /// this, mutations are rejected and the connection reconnects instead of + /// accepting unbounded user input that may never reach tmux. + private static let maxPendingStdinBytes = 256 * 1024 private enum CommandKind: Equatable { case listWindows, capturePane(Int), paneState(Int), panePath(Int), paneReflow(Int), paneAltScreen(Int), @@ -367,7 +448,16 @@ final class RemoteTmuxControlConnection { proc.standardInput = inPipe proc.standardOutput = outPipe proc.standardError = errPipe - stdinHandle = inPipe.fileHandleForWriting + let stdinWriter = RemoteTmuxControlPipeWriter( + handle: inPipe.fileHandleForWriting, + label: "com.cmux.remote-tmux.stdin.\(UUID().uuidString)", + maxPendingBytes: Self.maxPendingStdinBytes, + onFailure: { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleStdinWriteFailure() + } + } + ) let (stream, continuation) = AsyncStream<Data>.makeStream() let reader = outPipe.fileHandleForReading @@ -407,17 +497,17 @@ final class RemoteTmuxControlConnection { } catch { // Don't latch `started` on a failed launch, so a later attach can // replace this connection instead of reusing a dead one. Close the - // stdin handle too, so the connection is left in a clean, retry-safe + // stdin writer too, so the connection is left in a clean, retry-safe // state instead of holding a dead pipe that silently EPIPEs on write. reader.readabilityHandler = nil errReader.readabilityHandler = nil continuation.finish() errContinuation.finish() - try? stdinHandle?.close() - stdinHandle = nil + stdinWriter.close() throw error } process = proc + self.stdinWriter = stdinWriter stdoutReader = reader stderrReader = errReader streamContinuation = continuation @@ -447,7 +537,8 @@ final class RemoteTmuxControlConnection { } /// Sends a tmux command on the control stream (newline-terminated). - func send(_ command: String) { + @discardableResult + func send(_ command: String) -> Bool { sendInternal(command, kind: .other) } @@ -803,22 +894,17 @@ final class RemoteTmuxControlConnection { private func sendActivityQuery( _ command: String, completion: @escaping ([Int: PaneForegroundState]?) -> Void ) { - guard !exited else { + guard connectionState == .connected else { completion(nil) return } let token = UUID() activityQueryCompletions[token] = completion - sendInternal(command, kind: .activityQuery(token)) - // sendInternal enqueues the kind only after a successful write; a dead - // pipe (write failure, or no stdin while reconnecting) means no result - // will ever correlate — fail the query now so the close decision can - // proceed on the cache. (A write failure triggers beginReconnecting, - // which may already have flushed this completion — removeValue makes - // the fail-once exactly once.) - if !pendingCommands.contains(.activityQuery(token)), - let orphaned = activityQueryCompletions.removeValue(forKey: token) { - orphaned(nil) + guard sendInternal(command, kind: .activityQuery(token)) else { + // The stream could not accept the query, so no result can correlate. + // Fail now and let the close decision proceed on the cached state. + activityQueryCompletions.removeValue(forKey: token)?(nil) + return } } @@ -851,10 +937,24 @@ final class RemoteTmuxControlConnection { /// Sends literal key bytes to a pane via tmux `send-keys -H` (hex-encoded), /// which is binary-safe and needs no shell quoting. - func sendKeys(paneId: Int, data: Data) { - guard !data.isEmpty else { return } - let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ") - sendInternal("send-keys -t %\(paneId) -H \(hex)", kind: .other) + @discardableResult + func sendKeys(paneId: Int, data: Data) -> Bool { + guard !data.isEmpty else { return true } + let hex = Self.hexByteArguments(data) + return sendInternal("send-keys -t %\(paneId) -H \(hex)", kind: .other) + } + + nonisolated static func hexByteArguments(_ data: Data) -> String { + guard !data.isEmpty else { return "" } + let digits = Array("0123456789abcdef".utf8) + var bytes: [UInt8] = [] + bytes.reserveCapacity(data.count * 3 - 1) + for byte in data { + if !bytes.isEmpty { bytes.append(UInt8(ascii: " ")) } + bytes.append(digits[Int(byte >> 4)]) + bytes.append(digits[Int(byte & 0x0f)]) + } + return String(decoding: bytes, as: UTF8.self) } /// Pastes `text` into `paneId` as a tmux paste (`paste-buffer -p`), which wraps @@ -919,35 +1019,39 @@ final class RemoteTmuxControlConnection { streamContinuation = nil stderrContinuation?.finish() stderrContinuation = nil - try? stdinHandle?.close() - stdinHandle = nil + stdinWriter?.close() + stdinWriter = nil process?.terminate() process = nil } // MARK: - Internals - private func sendInternal(_ command: String, kind: CommandKind) { - guard let stdinHandle else { return } + @discardableResult + private func sendInternal(_ command: String, kind: CommandKind) -> Bool { + guard connectionState == .connected, let stdinWriter else { return false } let line = command.hasSuffix("\n") ? command : command + "\n" - guard let data = line.data(using: .utf8) else { return } - do { - try stdinHandle.write(contentsOf: data) - } catch { - // The control pipe is dead (broken pipe). Crucially, do NOT enqueue - // a pending command for a write that never reached tmux: the - // %begin/%end correlation FIFO is positional, so one phantom entry - // permanently misaligns every subsequent command result. Keep the - // mirror frozen and reconnect instead — the remote tmux session - // survives an ssh client death (`beginReconnecting` guards the - // source state). - record("stdin-write-failed") + guard let data = line.data(using: .utf8) else { return false } + // Record before the writer can emit bytes, so a fast `%begin`/`%end` + // reply never outruns its local FIFO slot. If the bounded writer rejects + // the command, remove this slot immediately and reconnect. + pendingCommands.append(kind) + guard stdinWriter.enqueue(data) else { + pendingCommands.removeLast() + record("stdin-write-backpressure") beginReconnecting() - return + return false } - // Record only after the bytes are confirmed written, so the pending - // FIFO stays in lock-step with what tmux actually received. - pendingCommands.append(kind) + return true + } + + private func handleStdinWriteFailure() { + guard connectionState == .connected || connectionState == .connecting else { return } + // The control pipe is dead (broken pipe or a closed SSH child). Keep the + // mirror frozen and reconnect; teardown finishes the old streams so + // pending command correlation cannot consume replies from a dead client. + record("stdin-write-failed") + beginReconnecting() } private func ingest(_ data: Data) { diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index a7b7a4932438..a5e5ba1ad67a 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -294,8 +294,7 @@ final class RemoteTmuxController { func handleMirrorNewTabRequested(workspaceId: UUID) -> Bool { guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), mirror.connection.connectionState == .connected else { return false } - mirror.connection.send("new-window") - return true + return mirror.connection.send("new-window") } /// A mirrored workspace was renamed → `rename-session` on the remote so the @@ -306,13 +305,13 @@ final class RemoteTmuxController { else { return } let mirror = entry.value let oldName = mirror.sessionName - guard name != oldName, !mirror.connection.exited else { return } + guard name != oldName, mirror.connection.connectionState == .connected else { return } // Target by the stable session id when known, so the rename can't race a // prior rename's name. guard let target = mirror.connection.sessionId.map({ "$\($0)" }) ?? RemoteTmuxHost.controlModeLineSafeName(oldName).map(RemoteTmuxHost.shellSingleQuoted) else { return } - mirror.connection.send("rename-session -t \(target) \(RemoteTmuxHost.shellSingleQuoted(name))") + _ = mirror.connection.send("rename-session -t \(target) \(RemoteTmuxHost.shellSingleQuoted(name))") // Do not re-key local state here. tmux can reject a rename (for example // duplicate session name); `%session-changed` is the confirmation point. } @@ -364,7 +363,7 @@ final class RemoteTmuxController { /// churn. `-d` keeps the active window unchanged. func handleMirrorWindowsReordered(workspaceId: UUID, orderedPanelIds: [UUID]) { guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), - !mirror.connection.exited else { return } + mirror.connection.connectionState == .connected else { return } let desired = orderedPanelIds.compactMap { mirror.windowId(forPanel: $0) } guard desired.count >= 2 else { return } // Current tmux window order (as last reported by list-windows), restricted @@ -376,7 +375,9 @@ final class RemoteTmuxController { var swapped = false for index in desired.indices where current[index] != desired[index] { guard let swapFrom = current.firstIndex(of: desired[index]) else { continue } - mirror.connection.send("swap-window -d -s @\(current[index]) -t @\(current[swapFrom])") + guard mirror.connection.send("swap-window -d -s @\(current[index]) -t @\(current[swapFrom])") else { + return + } current.swapAt(index, swapFrom) swapped = true } @@ -465,9 +466,9 @@ final class RemoteTmuxController { func handleMirrorWindowRenamed(workspaceId: UUID, panelId: UUID, title: String?) { guard let name = RemoteTmuxHost.controlModeCommandName(title), let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), - !mirror.connection.exited, + mirror.connection.connectionState == .connected, let windowId = mirror.windowId(forPanel: panelId) else { return } - mirror.connection.send("rename-window -t @\(windowId) \(RemoteTmuxHost.shellSingleQuoted(name))") + _ = mirror.connection.send("rename-window -t @\(windowId) \(RemoteTmuxHost.shellSingleQuoted(name))") } /// The live session mirror + tmux window id behind a mirrored window-tab, or @@ -478,7 +479,6 @@ final class RemoteTmuxController { -> (mirror: RemoteTmuxSessionMirror, windowId: Int)? { guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), - !mirror.connection.exited, let windowId = mirror.windowId(forPanel: panelId) else { return nil } return (mirror, windowId) } @@ -498,9 +498,9 @@ final class RemoteTmuxController { /// `false` if there is no live mirror/connection or the panel isn't a /// mirrored window (caller proceeds with the normal local close). func handleMirrorTabCloseRequested(workspaceId: UUID, panelId: UUID) -> Bool { - guard let target = mirrorWindowTarget(workspaceId: workspaceId, panelId: panelId) else { return false } - target.mirror.connection.send("kill-window -t @\(target.windowId)") - return true + guard let target = mirrorWindowTarget(workspaceId: workspaceId, panelId: panelId), + target.mirror.connection.connectionState == .connected else { return false } + return target.mirror.connection.send("kill-window -t @\(target.windowId)") } /// A close-time answer for a mirrored window-tab: whether any pane runs an diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index d7883115e31e..7715d04c1e93 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -415,8 +415,7 @@ final class RemoteTmuxSessionMirror { let targetPane = windowMirrorByWindowId[windowId]?.activePaneId ?? connection.windowsByID[windowId]?.paneIDsInOrder.first guard let targetPane else { return false } - connection.send("split-window \(vertical ? "-v" : "-h") -t @\(windowId).%\(targetPane)") - return true + return connection.send("split-window \(vertical ? "-v" : "-h") -t @\(windowId).%\(targetPane)") } /// Whether `surfaceId` is one of this session mirror's pane surfaces — a diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c2add5e29334..dceecb7fd7ae 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4124,21 +4124,33 @@ final class Workspace: Identifiable, ObservableObject { /// while leaving the remote tmux window alive. @discardableResult func requestNonInteractiveCloseTabRecordingHistory(_ tabId: TabID) -> Bool { - if routeRemoteTmuxNonInteractiveTabCloseIfNeeded(tabId) { + switch routeRemoteTmuxNonInteractiveTabCloseIfNeeded(tabId) { + case .routed: return true + case .rejectedMirrorTab: + return false + case .notMirrorTab: + return requestCloseTabRecordingHistory(tabId, force: true) } - return requestCloseTabRecordingHistory(tabId, force: true) } - private func routeRemoteTmuxNonInteractiveTabCloseIfNeeded(_ tabId: TabID) -> Bool { + private enum RemoteTmuxNonInteractiveCloseRoute { + case notMirrorTab + case rejectedMirrorTab + case routed + } + + private func routeRemoteTmuxNonInteractiveTabCloseIfNeeded(_ tabId: TabID) -> RemoteTmuxNonInteractiveCloseRoute { guard isRemoteTmuxMirror, let panelId = panelIdFromSurfaceId(tabId), let remoteTmuxController = AppDelegate.shared?.remoteTmuxController, remoteTmuxController.isMirrorWindowTab(workspaceId: id, panelId: panelId) else { - return false + return .notMirrorTab } return remoteTmuxController.handleMirrorTabCloseRequested(workspaceId: id, panelId: panelId) + ? .routed + : .rejectedMirrorTab } func withClosedPanelHistorySuppressed(_ body: () -> Void) { diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index 6cd3867738da..deb0a06f624e 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -124,6 +124,11 @@ import Testing #expect(RemoteTmuxHost.controlModeLineSafeName("work\nbad") == nil) } + @Test func sendKeysHexArgumentsAreLowercaseSpaceSeparatedBytes() { + #expect(RemoteTmuxControlConnection.hexByteArguments(Data([0x00, 0x0f, 0x10, 0xff])) == "00 0f 10 ff") + #expect(RemoteTmuxControlConnection.hexByteArguments(Data()) == "") + } + // MARK: - Interactive auth invocation (what `cmux ssh-tmux` runs in the tty) @Test func interactiveAuthInvocationShape() { From 7f742df5d1f7adb3fe58e5f09450f01bca7fd506 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 16:39:16 -0700 Subject: [PATCH 51/73] Fix remote tmux mirror snapshot and seeding --- Sources/AppDelegate.swift | 24 ++++++---- Sources/RemoteTmuxController.swift | 10 ++++ Sources/RemoteTmuxSessionMirror.swift | 8 +++- cmuxTests/RemoteTmuxControlParserTests.swift | 18 +++++++ cmuxTests/TabManagerUnitTests.swift | 49 ++++++++++++++++++++ 5 files changed, 99 insertions(+), 10 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 54e98218713d..1c44bf16042c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4299,11 +4299,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func sortedMainWindowContextsForSessionSnapshot() -> [MainWindowContext] { mainWindowContexts.values - // Exclude dedicated remote-tmux mirror windows: a mirror needs a live - // SSH connection, so snapshotting one yields an empty window that would - // restore as a useless leftover. Remote mirrors are not auto-restored on - // launch; the user re-attaches with `cmux ssh-tmux`. - .filter { !remoteTmuxController.isDedicatedRemoteWindow($0.windowId) } .sorted { lhs, rhs in let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) @@ -4326,16 +4321,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !contexts.isEmpty else { return nil } let restorableAgentIndex = suppliedRestorableAgentIndex ?? RestorableAgentSessionIndex.load() - let windows: [SessionWindowSnapshot] = contexts - .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) - .map { context in - sessionWindowSnapshot( + let windows = Array( + contexts.lazy.compactMap { context -> SessionWindowSnapshot? in + let snapshot = self.sessionWindowSnapshot( for: context, includeScrollback: includeScrollback, restorableAgentIndex: restorableAgentIndex, surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex ) + // A dedicated remote-tmux mirror window needs a live SSH control + // connection and should not restore as an empty shell. If the user + // dragged local workspaces into that window, keep those local + // workspaces: TabManager already prunes remote mirror workspaces + // from its snapshot. + if self.remoteTmuxController.isDedicatedRemoteWindow(context.windowId), + snapshot.tabManager.workspaces.isEmpty { + return nil + } + return snapshot } + .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) + ) guard !windows.isEmpty else { return nil } return AppSessionSnapshot( diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index a5e5ba1ad67a..d86bf217728d 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -100,6 +100,16 @@ final class RemoteTmuxController { windowRegistry.isDedicatedWindow(windowId) } +#if DEBUG + func bindDedicatedWindowForTesting(host: RemoteTmuxHost, windowId: UUID) { + windowRegistry.bind(host: host, windowId: windowId) + } + + func unbindDedicatedWindowForTesting(windowId: UUID) { + windowRegistry.unbind(windowId: windowId) + } +#endif + /// Opens a NEW cmux window dedicated to `host` and mirrors every tmux session /// on it 1:1 (each session a workspace, each window a tab). This keeps remote /// work in its own window so the user's local windows are untouched. diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 7715d04c1e93..2b56fb712651 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -188,7 +188,9 @@ final class RemoteTmuxSessionMirror { ) else { continue } panelIdByWindow[windowId] = panel.id panelIdByPane[firstPaneId] = panel.id - connection.seedPane(paneId: firstPaneId) + if Self.shouldSeedSinglePaneDisplay(for: window) { + connection.seedPane(paneId: firstPaneId) + } panelId = panel.id createdNewPanel = true } @@ -225,6 +227,10 @@ final class RemoteTmuxSessionMirror { } } + nonisolated static func shouldSeedSinglePaneDisplay(for window: RemoteTmuxWindow) -> Bool { + window.paneIDsInOrder.count == 1 + } + /// Brief retry that sizes the remote tmux client to a single-pane tab's /// rendered grid on attach. Needed because `createSurface` stamps the final /// grid before the tab is on screen, and `TerminalSurface.updateSize` only diff --git a/cmuxTests/RemoteTmuxControlParserTests.swift b/cmuxTests/RemoteTmuxControlParserTests.swift index 439f48bf2ae7..816bec745206 100644 --- a/cmuxTests/RemoteTmuxControlParserTests.swift +++ b/cmuxTests/RemoteTmuxControlParserTests.swift @@ -334,6 +334,24 @@ import Testing #expect(RemoteTmuxSessionMirror.mirrorTabReorder(current: [a, b, c], requested: [c, b]) == nil) } + @Test func singlePaneDisplaySeedsOnlySinglePaneWindows() throws { + let singlePane = RemoteTmuxWindow( + id: 1, + width: 80, + height: 24, + layout: try #require(RemoteTmuxRawLayoutParser.parse("80x24,0,0,1")) + ) + let multiPane = RemoteTmuxWindow( + id: 2, + width: 120, + height: 40, + layout: try #require(RemoteTmuxRawLayoutParser.parse("abcd,120x40,0,0{60x40,0,0,4,59x40,61,0,5}")) + ) + + #expect(RemoteTmuxSessionMirror.shouldSeedSinglePaneDisplay(for: singlePane)) + #expect(!RemoteTmuxSessionMirror.shouldSeedSinglePaneDisplay(for: multiPane)) + } + // MARK: - Reconnect: session-gone classification @Test func stderrSessionGoneIsDetected() { diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 921dd4ab0f0f..d59f23b2113c 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -594,6 +594,55 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertTrue(snapshot.windows[0].tabManager.workspaces.isEmpty) } + @MainActor + func testSessionSnapshotSkipsDedicatedRemoteTmuxWindowWithOnlyMirrorWorkspaces() throws { + let originalAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + AppDelegate.shared = appDelegate + let manager = TabManager(autoWelcomeIfNeeded: false) + let workspace = try XCTUnwrap(manager.selectedWorkspace) + workspace.isRemoteTmuxMirror = true + let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + let host = RemoteTmuxHost(destination: "user@example.test") + appDelegate.remoteTmuxController.bindDedicatedWindowForTesting(host: host, windowId: windowId) + defer { + appDelegate.remoteTmuxController.unbindDedicatedWindowForTesting(windowId: windowId) + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + AppDelegate.shared = originalAppDelegate + } + + XCTAssertNil(appDelegate.sessionSnapshotForTesting()) + } + + @MainActor + func testSessionSnapshotPreservesLocalWorkspaceInDedicatedRemoteTmuxWindow() throws { + let originalAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + AppDelegate.shared = appDelegate + let manager = TabManager(autoWelcomeIfNeeded: false) + let localWorkspace = try XCTUnwrap(manager.selectedWorkspace) + localWorkspace.setCustomTitle("Local") + let remoteWorkspace = manager.addWorkspace( + title: "remote", + select: true, + autoWelcomeIfNeeded: false + ) + remoteWorkspace.isRemoteTmuxMirror = true + let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + let host = RemoteTmuxHost(destination: "user@example.test") + appDelegate.remoteTmuxController.bindDedicatedWindowForTesting(host: host, windowId: windowId) + defer { + appDelegate.remoteTmuxController.unbindDedicatedWindowForTesting(windowId: windowId) + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + AppDelegate.shared = originalAppDelegate + } + + let snapshot = try XCTUnwrap(appDelegate.sessionSnapshotForTesting()) + XCTAssertEqual(snapshot.windows.count, 1) + XCTAssertEqual(snapshot.windows[0].tabManager.workspaces.map(\.workspaceId), [localWorkspace.id]) + XCTAssertNil(snapshot.windows[0].tabManager.selectedWorkspaceIndex) + } + func testClosedWindowHistorySkipsWindowWithNoRestorableWorkspaces() throws { let originalAppDelegate = AppDelegate.shared let appDelegate = AppDelegate() From bcb3387c07b7d3a31af32e559c24e48e301a1a8f Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 16:52:18 -0700 Subject: [PATCH 52/73] Fix remote tmux quit and paste routing --- .github/swift-file-length-budget.tsv | 6 +-- Sources/AppDelegate.swift | 49 +++++++++++++++-------- Sources/RemoteTmuxControlConnection.swift | 8 ++-- Sources/RemoteTmuxController.swift | 5 +-- cmuxTests/RemoteTmuxAuthTests.swift | 6 +++ 5 files changed, 47 insertions(+), 27 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index d66264af56ce..867961ff21a4 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,11 +3,11 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33554 CLI/cmux.swift 19234 Sources/ContentView.swift -18189 Sources/AppDelegate.swift +18210 Sources/AppDelegate.swift 16274 Sources/GhosttyTerminalView.swift -14658 Sources/TerminalController.swift +14661 Sources/TerminalController.swift 13568 Sources/Panels/BrowserPanel.swift -13002 Sources/Workspace.swift +13037 Sources/Workspace.swift 12044 cmuxTests/AppDelegateShortcutRoutingTests.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7737 Sources/Panels/BrowserPanelView.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b220cd9747b8..d2974017a28e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1782,6 +1782,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func deferTerminateForMarkedRemoteTmuxKills(reason: String) -> Bool { + let markedForKill = remoteTmuxController.windowsMarkedForKillOnClose() + guard !markedForKill.isEmpty else { return false } + if !isAwaitingTerminateKills { + isAwaitingTerminateKills = true + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.killLater", fields: ["windows": String(markedForKill.count), "reason": reason]) + Task { @MainActor in + await self.remoteTmuxController.killMarkedSessionsBeforeTerminate() + self.replyToTerminateOnce(true) + } + // Watchdog: guarantee the quit is released even if the deferred Task + // is starved (it can return .terminateLater from inside the modal's + // nested run loop). 3.5s > the 3s kill budget so it never preempts a + // completing kill; replyToTerminateOnce makes it a no-op if already replied. + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in + self?.replyToTerminateOnce(true) + } + } + return true + } + + private func clearMarkedRemoteTmuxKills() { + for windowId in remoteTmuxController.windowsMarkedForKillOnClose() { + remoteTmuxController.consumeKillSessionsOnWindowClose(windowId: windowId) + } + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { // A deferred kill-then-quit is already in flight (a prior terminate returned // .terminateLater; its Task + watchdog own the single reply). A re-entrant @@ -1830,23 +1857,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // ⌘Q or a window close) takes the synchronous .terminateNow path below and // only detaches. Gated on the marker (not `reason`) so it fires on dev // builds too — otherwise dogfooding (always dev) never exercises the kill. - let markedForKill = remoteTmuxController.windowsMarkedForKillOnClose() - if !markedForKill.isEmpty { - if !isAwaitingTerminateKills { - isAwaitingTerminateKills = true - StartupBreadcrumbLog.append("appDelegate.shouldTerminate.killLater", fields: ["windows": String(markedForKill.count), "reason": reason]) - Task { @MainActor in - await self.remoteTmuxController.killMarkedSessionsBeforeTerminate() - self.replyToTerminateOnce(true) - } - // Watchdog: guarantee the quit is released even if the deferred Task - // is starved (it can return .terminateLater from inside the modal's - // nested run loop). 3.5s > the 3s kill budget so it never preempts a - // completing kill; replyToTerminateOnce makes it a no-op if already replied. - DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in - self?.replyToTerminateOnce(true) - } - } + if deferTerminateForMarkedRemoteTmuxKills(reason: reason) { return .terminateLater } StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": reason]) @@ -1875,9 +1886,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.isQuitWarningConfirmed = true self.closeAllWebInspectorsBeforeAppTeardown() StartupBreadcrumbLog.append("appDelegate.shouldTerminate.reply", fields: ["shouldQuit": "1"]) + if self.deferTerminateForMarkedRemoteTmuxKills(reason: "confirmedDialog") { + return + } } else { // Reset so that the next quit attempt can show the dialog again. self.isTerminatingApp = false + self.clearMarkedRemoteTmuxKills() StartupBreadcrumbLog.append("appDelegate.shouldTerminate.reply", fields: ["shouldQuit": "0"]) } self.replyToTerminateOnce(shouldQuit) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 97b43a2ade95..fe77f75177a9 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -966,11 +966,11 @@ final class RemoteTmuxControlConnection { /// deliver. Uses a dedicated, immediately-deleted (`-d`) per-pane buffer so /// there's no buffer-name collision. `text` must be a single line (callers route /// only single-line content — e.g. file/image paths — here). - func pastePane(paneId: Int, text: String) { - guard !text.isEmpty else { return } + func pastePane(paneId: Int, text: String) -> Bool { + guard !text.isEmpty else { return false } let buffer = "cmux-paste-\(paneId)" - send("set-buffer -b \(buffer) \(RemoteTmuxHost.shellSingleQuoted(text))") - send("paste-buffer -p -d -b \(buffer) -t %\(paneId)") + return send("set-buffer -b \(buffer) \(RemoteTmuxHost.shellSingleQuoted(text))") + && send("paste-buffer -p -d -b \(buffer) -t %\(paneId)") } /// Detaches: terminating ssh kills the control client but leaves the remote diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index d86bf217728d..c97c4bf6a2a1 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -434,8 +434,7 @@ final class RemoteTmuxController { func pasteIntoMirror(surfaceId: UUID, text: String) -> Bool { guard !text.isEmpty, !text.contains(where: { $0 == "\n" || $0 == "\r" }) else { return false } guard let target = pasteTarget(forSurfaceId: surfaceId) else { return false } - target.connection.pastePane(paneId: target.paneId, text: text) - return true + return target.connection.pastePane(paneId: target.paneId, text: text) } /// The live control connection + tmux pane id behind a remote-tmux @@ -443,7 +442,7 @@ final class RemoteTmuxController { private func pasteTarget(forSurfaceId surfaceId: UUID) -> (connection: RemoteTmuxControlConnection, paneId: Int)? { - for sessionMirror in sessionMirrors.values where !sessionMirror.connection.exited { + for sessionMirror in sessionMirrors.values where sessionMirror.connection.connectionState == .connected { if let paneId = sessionMirror.paneId(forSurfaceId: surfaceId) { return (sessionMirror.connection, paneId) } diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index deb0a06f624e..82fcfc86d7a5 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -129,6 +129,12 @@ import Testing #expect(RemoteTmuxControlConnection.hexByteArguments(Data()) == "") } + @Test @MainActor func pastePaneRejectsDisconnectedControlStream() { + let connection = RemoteTmuxControlConnection(host: RemoteTmuxHost(destination: "user@host"), sessionName: "work") + #expect(connection.pastePane(paneId: 1, text: "/tmp/image.png") == false) + #expect(connection.pastePane(paneId: 1, text: "") == false) + } + // MARK: - Interactive auth invocation (what `cmux ssh-tmux` runs in the tty) @Test func interactiveAuthInvocationShape() { From 0bb1f165fbc202108d70853df7a18eb2166e8717 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 17:04:47 -0700 Subject: [PATCH 53/73] Restore terminal cwd inheritance --- .github/swift-file-length-budget.tsv | 4 +- ...nalController+ControlSidebarContext3.swift | 6 +- ...nalController+ControlSurfaceContext2.swift | 3 +- ...inalController+ControlSystemContext2.swift | 7 +- Sources/Workspace.swift | 73 ++++++++++++++++--- cmuxTests/WorkspaceUnitTests.swift | 41 +++++++++++ 6 files changed, 118 insertions(+), 16 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 867961ff21a4..51baf85d6f4a 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -7,11 +7,11 @@ 16274 Sources/GhosttyTerminalView.swift 14661 Sources/TerminalController.swift 13568 Sources/Panels/BrowserPanel.swift -13037 Sources/Workspace.swift +13088 Sources/Workspace.swift 12044 cmuxTests/AppDelegateShortcutRoutingTests.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7737 Sources/Panels/BrowserPanelView.swift -7306 cmuxTests/WorkspaceUnitTests.swift +7347 cmuxTests/WorkspaceUnitTests.swift 6943 cmuxTests/WorkspaceRemoteConnectionTests.swift 6555 cmuxTests/GhosttyConfigTests.swift 6330 cmuxTests/SessionPersistenceTests.swift diff --git a/Sources/TerminalController+ControlSidebarContext3.swift b/Sources/TerminalController+ControlSidebarContext3.swift index d216f99fbace..e8d6261538f0 100644 --- a/Sources/TerminalController+ControlSidebarContext3.swift +++ b/Sources/TerminalController+ControlSidebarContext3.swift @@ -259,7 +259,11 @@ extension TerminalController { } return .created(id) } - switch tab.newTerminalSurfaceOutcome(inPane: targetPaneId, focus: focus) { + switch tab.newTerminalSurfaceOutcome( + inPane: targetPaneId, + focus: focus, + inheritWorkingDirectoryFallback: true + ) { case .created(let panel): return .created(panel.id) case .routedToRemote: diff --git a/Sources/TerminalController+ControlSurfaceContext2.swift b/Sources/TerminalController+ControlSurfaceContext2.swift index 70bc7b2e8d56..20419ea188ba 100644 --- a/Sources/TerminalController+ControlSurfaceContext2.swift +++ b/Sources/TerminalController+ControlSurfaceContext2.swift @@ -364,7 +364,8 @@ extension TerminalController { initialCommand: inputs.initialCommand, tmuxStartCommand: inputs.tmuxStartCommand, startupEnvironment: inputs.startupEnvironment, - remotePTYSessionID: inputs.remotePTYSessionID + remotePTYSessionID: inputs.remotePTYSessionID, + inheritWorkingDirectoryFallback: true ) { case .created(let panel): newPanelId = panel.id diff --git a/Sources/TerminalController+ControlSystemContext2.swift b/Sources/TerminalController+ControlSystemContext2.swift index 9a91dbbe2fb4..f1c7cf3b07ea 100644 --- a/Sources/TerminalController+ControlSystemContext2.swift +++ b/Sources/TerminalController+ControlSystemContext2.swift @@ -183,7 +183,12 @@ extension TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - switch workspace.newTerminalSurfaceOutcome(inPane: paneId, focus: focus) { + switch workspace.newTerminalSurfaceOutcome( + inPane: paneId, + focus: focus, + inheritWorkingDirectoryFallback: true, + workingDirectoryFallbackSourcePanelId: surfaceId + ) { case .created(let newPanel): _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) return finish(.created(newPanel.id)) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index dceecb7fd7ae..631344f30cec 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7068,6 +7068,23 @@ final class Workspace: Identifiable, ObservableObject { lastTerminalConfigInheritanceFontPoints } + nonisolated private static func normalizedTerminalWorkingDirectory(_ workingDirectory: String?) -> String? { + let trimmed = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private func resolvedTerminalStartupWorkingDirectory( + requestedWorkingDirectory: String?, + sourcePanelId: UUID? + ) -> String? { + [ + requestedWorkingDirectory, + sourcePanelId.flatMap { panelDirectories[$0] }, + sourcePanelId.flatMap { terminalPanel(for: $0)?.requestedWorkingDirectory }, + currentDirectory, + ].lazy.compactMap(Self.normalizedTerminalWorkingDirectory).first + } + /// Candidate terminal panels used as the source when creating inherited Ghostty config. /// Preference order: /// 1) explicitly preferred terminal panel (when the caller has one), @@ -7476,7 +7493,9 @@ final class Workspace: Identifiable, ObservableObject { autoRefreshMetadata: Bool = true, preserveFocusWhenUnfocused: Bool = true, remotePTYSessionID: String? = nil, - suppressWorkspaceRemoteStartupCommand: Bool = false + suppressWorkspaceRemoteStartupCommand: Bool = false, + inheritWorkingDirectoryFallback: Bool = false, + workingDirectoryFallbackSourcePanelId: UUID? = nil ) -> TerminalPanel? { return newTerminalSurfaceOutcome( inPane: paneId, @@ -7489,7 +7508,9 @@ final class Workspace: Identifiable, ObservableObject { autoRefreshMetadata: autoRefreshMetadata, preserveFocusWhenUnfocused: preserveFocusWhenUnfocused, remotePTYSessionID: remotePTYSessionID, - suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand + suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, + inheritWorkingDirectoryFallback: inheritWorkingDirectoryFallback, + workingDirectoryFallbackSourcePanelId: workingDirectoryFallbackSourcePanelId ).panel } @@ -7507,7 +7528,9 @@ final class Workspace: Identifiable, ObservableObject { autoRefreshMetadata: Bool = true, preserveFocusWhenUnfocused: Bool = true, remotePTYSessionID: String? = nil, - suppressWorkspaceRemoteStartupCommand: Bool = false + suppressWorkspaceRemoteStartupCommand: Bool = false, + inheritWorkingDirectoryFallback: Bool = false, + workingDirectoryFallbackSourcePanelId: UUID? = nil ) -> TerminalPanelCreationOutcome { // In a remote tmux mirror workspace, a new tab means "create a tmux // window" — route it to the remote and let the resulting %window-add @@ -7532,7 +7555,9 @@ final class Workspace: Identifiable, ObservableObject { autoRefreshMetadata: autoRefreshMetadata, preserveFocusWhenUnfocused: preserveFocusWhenUnfocused, remotePTYSessionID: remotePTYSessionID, - suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand + suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, + inheritWorkingDirectoryFallback: inheritWorkingDirectoryFallback, + workingDirectoryFallbackSourcePanelId: workingDirectoryFallbackSourcePanelId ) else { return .failed } return .created(panel) } @@ -7548,7 +7573,9 @@ final class Workspace: Identifiable, ObservableObject { autoRefreshMetadata: Bool, preserveFocusWhenUnfocused: Bool, remotePTYSessionID: String?, - suppressWorkspaceRemoteStartupCommand: Bool + suppressWorkspaceRemoteStartupCommand: Bool, + inheritWorkingDirectoryFallback: Bool, + workingDirectoryFallbackSourcePanelId: UUID? ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId @@ -7572,13 +7599,21 @@ final class Workspace: Identifiable, ObservableObject { template.waitAfterCommand = true inheritedConfig = template } + let fallbackSourcePanelId = workingDirectoryFallbackSourcePanelId + ?? bonsplitController.selectedTab(inPane: paneId).map(\.id).flatMap(panelIdFromSurfaceId) + let requestedWorkingDirectory = inheritWorkingDirectoryFallback && startupCommand == nil + ? resolvedTerminalStartupWorkingDirectory( + requestedWorkingDirectory: workingDirectory, + sourcePanelId: fallbackSourcePanelId + ) + : workingDirectory // Create new terminal panel let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - workingDirectory: workingDirectory, + workingDirectory: requestedWorkingDirectory, portOrdinal: portOrdinal, initialCommand: startupCommand, tmuxStartCommand: tmuxStartCommand, @@ -9843,7 +9878,12 @@ final class Workspace: Identifiable, ObservableObject { @discardableResult func newTerminalSurfaceInFocusedPane(focus: Bool? = nil, initialInput: String? = nil) -> TerminalPanel? { guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil } - return newTerminalSurface(inPane: focusedPaneId, focus: focus, initialInput: initialInput) + return newTerminalSurface( + inPane: focusedPaneId, + focus: focus, + initialInput: initialInput, + inheritWorkingDirectoryFallback: true + ) } @discardableResult @@ -10866,7 +10906,13 @@ final class Workspace: Identifiable, ObservableObject { private func createTerminalToRight(of anchorTabId: TabID, inPane paneId: PaneID) { let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newTerminalSurface(inPane: paneId, focus: true) else { return } + let sourcePanelId = panelIdFromSurfaceId(anchorTabId) + guard let newPanel = newTerminalSurface( + inPane: paneId, + focus: true, + inheritWorkingDirectoryFallback: true, + workingDirectoryFallbackSourcePanelId: sourcePanelId + ) else { return } _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) } @@ -12807,7 +12853,12 @@ extension Workspace: BonsplitDelegate { case .currentTerminal: self.selectedTerminalPanel(inPane: pane)?.sendInput(shellInput) case .newTabInCurrentPane: - _ = self.newTerminalSurface(inPane: pane, focus: true, initialInput: shellInput) + _ = self.newTerminalSurface( + inPane: pane, + focus: true, + initialInput: shellInput, + inheritWorkingDirectoryFallback: true + ) } } guard didExecute else { @@ -12818,11 +12869,11 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) { switch kind { case "terminal": - _ = newTerminalSurface(inPane: pane) + _ = newTerminalSurface(inPane: pane, inheritWorkingDirectoryFallback: true) case "browser": _ = newBrowserSurface(inPane: pane) default: - _ = newTerminalSurface(inPane: pane) + _ = newTerminalSurface(inPane: pane, inheritWorkingDirectoryFallback: true) } } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index dd2082d079eb..5eb0b5fee743 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -4537,6 +4537,47 @@ final class WorkspaceSplitWorkingDirectoryTests: XCTestCase { ) } + func testNewTerminalSurfaceFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { + let workspace = Workspace() + guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else { + XCTFail("Expected focused pane in new workspace") + return + } + + let staleCurrentDirectory = workspace.currentDirectory + let requestedDirectory = "/tmp/cmux-requested-tab-cwd-\(UUID().uuidString)" + guard let sourcePanel = workspace.newTerminalSurface( + inPane: sourcePaneId, + focus: true, + workingDirectory: requestedDirectory + ) else { + XCTFail("Expected source terminal panel to be created") + return + } + + XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory) + XCTAssertNil( + workspace.panelDirectories[sourcePanel.id], + "Expected requested cwd to exist before shell integration reports a live cwd" + ) + XCTAssertEqual( + workspace.currentDirectory, + staleCurrentDirectory, + "Expected focused workspace cwd to remain stale before panel directory updates" + ) + + guard let newTabPanel = workspace.newTerminalSurfaceInFocusedPane(focus: false) else { + XCTFail("Expected new terminal tab panel to be created") + return + } + + XCTAssertEqual( + newTabPanel.requestedWorkingDirectory, + requestedDirectory, + "Expected new terminal tab to inherit the selected source terminal's requested cwd when no reported cwd exists yet" + ) + } + func testNewTerminalSplitSkipsFreedInheritedSurfacePointer() throws { #if DEBUG let workspace = Workspace() From 2ba604d60f85919bf096236c5d28655520fbe427 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 17:19:49 -0700 Subject: [PATCH 54/73] Harden remote tmux attach and output buffering --- .github/swift-file-length-budget.tsv | 18 +- Sources/AppDelegate.swift | 49 +-- Sources/DetachedFolderDragIcon.swift | 8 +- Sources/RemoteTmuxConnectionState.swift | 14 + Sources/RemoteTmuxControlCommandKind.swift | 12 + Sources/RemoteTmuxControlConnection.swift | 303 +++++++----------- .../RemoteTmuxControlConnectionSnapshot.swift | 11 + Sources/RemoteTmuxControlPipeWriter.swift | 64 ++++ Sources/RemoteTmuxController.swift | 122 +++++-- Sources/RemoteTmuxLayoutContainer.swift | 114 +++++++ Sources/RemoteTmuxLayoutContent.swift | 11 + Sources/RemoteTmuxLayoutNode.swift | 13 +- Sources/RemoteTmuxMirrorTabActivity.swift | 6 + Sources/RemoteTmuxPaneForegroundState.swift | 44 +++ Sources/RemoteTmuxPaneHeader.swift | 58 ++++ Sources/RemoteTmuxPostAttachAction.swift | 9 + Sources/RemoteTmuxProcessCancellation.swift | 23 ++ Sources/RemoteTmuxSSHTransport.swift | 57 ++-- Sources/RemoteTmuxSessionEndAction.swift | 11 + Sources/RemoteTmuxSessionMirror.swift | 2 +- Sources/RemoteTmuxWindowMirrorView.swift | 176 +--------- Sources/TerminalController+RemoteTmux.swift | 25 +- Sources/Workspace.swift | 9 +- ...ceRemoteTmuxNonInteractiveCloseRoute.swift | 5 + cmux.xcodeproj/project.pbxproj | 60 ++++ .../RemoteTmuxSessionSnapshotTests.swift | 57 ++++ cmuxTests/TabManagerUnitTests.swift | 49 --- ...erminalWorkingDirectoryFallbackTests.swift | 50 +++ cmuxTests/WorkspaceUnitTests.swift | 41 --- 29 files changed, 843 insertions(+), 578 deletions(-) create mode 100644 Sources/RemoteTmuxConnectionState.swift create mode 100644 Sources/RemoteTmuxControlCommandKind.swift create mode 100644 Sources/RemoteTmuxControlConnectionSnapshot.swift create mode 100644 Sources/RemoteTmuxControlPipeWriter.swift create mode 100644 Sources/RemoteTmuxLayoutContainer.swift create mode 100644 Sources/RemoteTmuxLayoutContent.swift create mode 100644 Sources/RemoteTmuxMirrorTabActivity.swift create mode 100644 Sources/RemoteTmuxPaneForegroundState.swift create mode 100644 Sources/RemoteTmuxPaneHeader.swift create mode 100644 Sources/RemoteTmuxPostAttachAction.swift create mode 100644 Sources/RemoteTmuxProcessCancellation.swift create mode 100644 Sources/RemoteTmuxSessionEndAction.swift create mode 100644 Sources/WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift create mode 100644 cmuxTests/RemoteTmuxSessionSnapshotTests.swift create mode 100644 cmuxTests/WorkspaceTerminalWorkingDirectoryFallbackTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 51baf85d6f4a..7ff06d9410b0 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -14,7 +14,7 @@ 7347 cmuxTests/WorkspaceUnitTests.swift 6943 cmuxTests/WorkspaceRemoteConnectionTests.swift 6555 cmuxTests/GhosttyConfigTests.swift -6330 cmuxTests/SessionPersistenceTests.swift +6332 cmuxTests/SessionPersistenceTests.swift 6153 CLI/cmux_open.swift 6108 Sources/TabManager.swift 6071 Sources/TextBoxInput.swift @@ -27,7 +27,7 @@ 4227 Sources/BrowserWindowPortal.swift 4009 cmuxTests/WindowAndDragTests.swift 3937 Sources/Feed/FeedPanelView.swift -3762 cmuxTests/TabManagerUnitTests.swift +3811 cmuxTests/TabManagerUnitTests.swift 3699 cmuxTests/CLIGenericHookPersistenceTests.swift 3665 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3397 Sources/CmuxConfig.swift @@ -51,11 +51,12 @@ 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1860 cmuxTests/NotificationAndMenuBarTests.swift 1794 Sources/SessionIndexStore.swift +1777 Sources/RestorableAgentSession.swift 1751 Sources/WindowDragHandleView.swift -1744 Sources/RestorableAgentSession.swift 1722 cmuxTests/TerminalControllerSocketSecurityTests.swift 1693 cmuxTests/WorkspacePullRequestSidebarTests.swift 1677 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +1653 Sources/RemoteTmuxControlConnection.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1574 cmuxTests/MarkdownPanelTests.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift @@ -66,23 +67,23 @@ 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1376 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1372 cmuxTests/AppDelegateIssue2907RoutingTests.swift -1370 Sources/RemoteTmuxControlConnection.swift 1365 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift 1312 cmuxTests/MobileHostAuthorizationTests.swift 1285 cmuxUITests/SidebarHelpMenuUITests.swift +1270 cmuxTests/RestorableAgentSessionIndexTests.swift 1256 Sources/Feed/FeedCoordinator.swift 1205 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1197 cmuxTests/CodexAppServerSessionTests.swift +1166 Sources/VaultAgentProcessScanner.swift 1157 cmuxTests/SidebarOrderingTests.swift -1144 Sources/VaultAgentProcessScanner.swift 1139 cmuxTests/PiVaultAgentPersistenceTests.swift 1126 cmuxTests/FileExplorerStoreTests.swift +1119 cmuxTests/AgentHibernationTests.swift 1107 Sources/AppDelegate+CmuxSSHURL.swift 1096 Sources/GhosttyConfig.swift 1093 cmuxUITests/BonsplitTabDragUITests.swift -1084 cmuxTests/AgentHibernationTests.swift -1084 cmuxTests/RestorableAgentSessionIndexTests.swift +1040 Sources/RemoteTmuxController.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1000 cmuxTests/CmuxTopSnapshotScopeTests.swift @@ -92,7 +93,6 @@ 941 Sources/App/TerminalDirectoryOpenSupport.swift 937 Sources/TextBoxMentionIndexStore.swift 924 Sources/DockPanelView.swift -918 Sources/RemoteTmuxController.swift 917 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift 903 Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -147,12 +147,12 @@ 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift 619 cmuxTests/FinderFileDropRegressionTests.swift +615 cmuxTests/RemoteTmuxControlParserTests.swift 614 Sources/PortScanner.swift 614 cmuxTests/SessionIndexViewTests.swift 608 Packages/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 603 Sources/SettingsNavigation.swift 599 Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift -597 cmuxTests/RemoteTmuxControlParserTests.swift 596 cmuxTests/CmuxEventBusTests.swift 594 Sources/SessionIndexModels.swift 594 cmuxTests/PortalTabDragRoutingTests.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d2974017a28e..eaa3f8bd89df 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1051,13 +1051,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Set to true when the user has already confirmed quit via the warning dialog, // so applicationShouldTerminate does not show a second alert. private var isQuitWarningConfirmed = false - // One-shot guard so NSApp.reply(toApplicationShouldTerminate:) is funneled through - // replyToTerminateOnce and can never be called twice (deferred kill Task + watchdog - // + a re-entrant terminate) or hang the quit. + // One-shot guard for deferred terminate replies. private var didReplyToTerminate = false - // True while a deferred-kill .terminateLater is in flight, so a re-entrant - // applicationShouldTerminate doesn't spawn a second kill Task / second reply. + // True while remote tmux kill-before-quit owns the terminate reply. private var isAwaitingTerminateKills = false + private var terminateKillWatchdogTask: Task<Void, Never>? private var didInstallLifecycleSnapshotObservers = false private var didDisableSuddenTermination = false private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] @@ -1766,16 +1764,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } - /// Sole caller of `NSApp.reply(toApplicationShouldTerminate:)`, guarded so the - /// deferred-kill Task, its watchdog, and any re-entrant terminate can't double-reply. + /// Sole caller of `NSApp.reply(toApplicationShouldTerminate:)`. private func replyToTerminateOnce(_ shouldTerminate: Bool) { guard !didReplyToTerminate else { return } didReplyToTerminate = true NSApp.reply(toApplicationShouldTerminate: shouldTerminate) - // The guard only coalesces replies WITHIN a single terminate request. A - // cancelled quit (`false`) ends that request, so reset both flags — otherwise - // a later quit attempt's reply would be silently swallowed and the app could - // never terminate. + terminateKillWatchdogTask?.cancel() + terminateKillWatchdogTask = nil + // A cancelled quit ends this terminate request; the next quit must reply again. if !shouldTerminate { didReplyToTerminate = false isAwaitingTerminateKills = false @@ -1792,11 +1788,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent await self.remoteTmuxController.killMarkedSessionsBeforeTerminate() self.replyToTerminateOnce(true) } - // Watchdog: guarantee the quit is released even if the deferred Task - // is starved (it can return .terminateLater from inside the modal's - // nested run loop). 3.5s > the 3s kill budget so it never preempts a - // completing kill; replyToTerminateOnce makes it a no-op if already replied. - DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in + // Watchdog: release quit if the deferred Task is starved inside a nested run loop. + terminateKillWatchdogTask?.cancel() + terminateKillWatchdogTask = Task { @MainActor [weak self] in + try? await ContinuousClock().sleep(for: .milliseconds(3_500)) + guard !Task.isCancelled else { return } self?.replyToTerminateOnce(true) } } @@ -1810,11 +1806,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - // A deferred kill-then-quit is already in flight (a prior terminate returned - // .terminateLater; its Task + watchdog own the single reply). A re-entrant - // terminate must defer to it WITHOUT re-evaluating — the kill Task consumes the - // marker early, so re-checking windowsMarkedForKillOnClose() here would find it - // empty and wrongly fall through to .terminateNow, quitting before the kill lands. + // A re-entrant terminate must wait for the in-flight kill-before-quit reply. if isAwaitingTerminateKills { return .terminateLater } let buildFlavor = BuildFlavor.current let hasDirtyWorkspaces = hasQuitConfirmationDirtyWorkspaces() @@ -1850,13 +1842,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { reason = "policy" } - // An explicit tab/session close of a remote window's LAST tab escalates to - // this quit. Those windows are marked for kill-on-close: defer termination, - // KILL the remote session(s) (awaited, bounded), then reply to let the app - // quit — "close the session, then close cmux". A plain quit (no marker, e.g. - // ⌘Q or a window close) takes the synchronous .terminateNow path below and - // only detaches. Gated on the marker (not `reason`) so it fires on dev - // builds too — otherwise dogfooding (always dev) never exercises the kill. + // Explicit last-tab closes kill marked remote sessions before quit. + // Plain app/window quits have no marker and only detach. if deferTerminateForMarkedRemoteTmuxKills(reason: reason) { return .terminateLater } @@ -1937,11 +1924,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillTerminate(_ notification: Notification) { StartupBreadcrumbLog.append("appDelegate.willTerminate.begin") isTerminatingApp = true - // Detach remote tmux control connections (kills the local ssh clients); the - // remote tmux server + sessions stay alive for resume next launch. Any explicit - // tab/session close that escalated to this quit already killed its session in - // applicationShouldTerminate's deferred path and removed that host's - // transport/connections, so detachAll finds nothing for it and can't race the kill. + // Plain quit detaches local ssh clients; explicit close already killed marked sessions. remoteTmuxController.detachAll() closeAllWebInspectorsBeforeAppTeardown() _ = saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) diff --git a/Sources/DetachedFolderDragIcon.swift b/Sources/DetachedFolderDragIcon.swift index 785ca57c0fbc..e6e53d1c9bbe 100644 --- a/Sources/DetachedFolderDragIcon.swift +++ b/Sources/DetachedFolderDragIcon.swift @@ -81,9 +81,9 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { /// fetched and cached; it is not called on a cache hit. private static func icon(forPath path: String, onResolved: @escaping (NSImage) -> Void) -> NSImage { if let cached = resolvedIconsByPath[path] { return cached } - DispatchQueue.global(qos: .userInitiated).async { + Task.detached(priority: .userInitiated) { let icon = NSWorkspace.shared.icon(forFile: path) - DispatchQueue.main.async { + await MainActor.run { icon.size = NSSize(width: 16, height: 16) if resolvedIconsByPath.count > 256 { resolvedIconsByPath.removeAll() } resolvedIconsByPath[path] = icon @@ -99,9 +99,9 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { /// stats), then runs `onResolved` on the main thread — same shape as /// ``icon(forPath:onResolved:)``. private static func localizedDisplayName(forPath path: String, onResolved: @escaping (String) -> Void) { - DispatchQueue.global(qos: .userInitiated).async { + Task.detached(priority: .userInitiated) { let localizedName = FileManager.default.displayName(atPath: path) - DispatchQueue.main.async { + await MainActor.run { onResolved(localizedName) } } diff --git a/Sources/RemoteTmuxConnectionState.swift b/Sources/RemoteTmuxConnectionState.swift new file mode 100644 index 000000000000..0b4af4e6474f --- /dev/null +++ b/Sources/RemoteTmuxConnectionState.swift @@ -0,0 +1,14 @@ +enum RemoteTmuxConnectionState: Sendable, Equatable { + /// The initial connection is being established before the first control-mode + /// `%enter`. + case connecting + + /// Live: control mode is up and streaming. + case connected + + /// The transport dropped; retrying with backoff while the mirror stays frozen. + case reconnecting + + /// Permanently over: genuine `%exit`, session gone, or deliberate stop. + case ended +} diff --git a/Sources/RemoteTmuxControlCommandKind.swift b/Sources/RemoteTmuxControlCommandKind.swift new file mode 100644 index 000000000000..5310cca18fe8 --- /dev/null +++ b/Sources/RemoteTmuxControlCommandKind.swift @@ -0,0 +1,12 @@ +import Foundation + +enum RemoteTmuxControlCommandKind: Equatable { + case listWindows + case capturePane(Int) + case paneState(Int) + case panePath(Int) + case paneReflow(Int) + case paneAltScreen(Int) + case activityQuery(UUID) + case other +} diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index fe77f75177a9..3dabf1de4cd5 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -1,82 +1,6 @@ import Foundation import os -/// Bounded off-main writer for the SSH control client's stdin pipe. -/// -/// `RemoteTmuxControlConnection` records command FIFO entries on the main actor -/// before this writer can emit bytes, so tmux `%begin`/`%end` replies cannot -/// outrun their local correlation slot. The write itself may block on a stalled -/// SSH pipe; keeping it on this serial queue prevents that from freezing UI. -private final class RemoteTmuxControlPipeWriter: @unchecked Sendable { - private struct State { - var closed = false - var pendingBytes = 0 - } - - private let handle: FileHandle - private let queue: DispatchQueue - private let maxPendingBytes: Int - private let onFailure: @Sendable (Error) -> Void - private let state = OSAllocatedUnfairLock(initialState: State()) - - init( - handle: FileHandle, - label: String, - maxPendingBytes: Int, - onFailure: @escaping @Sendable (Error) -> Void - ) { - self.handle = handle - self.queue = DispatchQueue(label: label, qos: .userInitiated) - self.maxPendingBytes = maxPendingBytes - self.onFailure = onFailure - } - - func enqueue(_ data: Data) -> Bool { - guard !data.isEmpty else { return true } - let accepted = state.withLock { state in - guard !state.closed, - data.count <= maxPendingBytes - state.pendingBytes else { - return false - } - state.pendingBytes += data.count - return true - } - guard accepted else { return false } - - queue.async { [handle, onFailure, state] in - var writeError: Error? - let shouldWrite = state.withLock { !$0.closed } - if shouldWrite { - do { - try handle.write(contentsOf: data) - } catch { - writeError = error - } - } - - let shouldReportFailure = state.withLock { state in - state.pendingBytes = max(0, state.pendingBytes - data.count) - return !state.closed - } - if let writeError, shouldReportFailure { - onFailure(writeError) - } - } - return true - } - - func close() { - let shouldClose = state.withLock { state in - guard !state.closed else { return false } - state.closed = true - return true - } - if shouldClose { - try? handle.close() - } - } -} - /// A live tmux control-mode connection to one remote session. /// /// Spawns `ssh -tt <ControlMaster> host tmux -CC attach -t <session>` as a @@ -87,6 +11,12 @@ private final class RemoteTmuxControlPipeWriter: @unchecked Sendable { /// command-queue desync because we issue and correlate commands ourselves. @MainActor final class RemoteTmuxControlConnection { + typealias ConnectionState = RemoteTmuxConnectionState + typealias PaneForegroundState = RemoteTmuxPaneForegroundState + typealias Snapshot = RemoteTmuxControlConnectionSnapshot + private typealias CommandKind = RemoteTmuxControlCommandKind + private typealias PostAttachAction = RemoteTmuxPostAttachAction + /// The host this connection talks to. let host: RemoteTmuxHost /// The tmux session name this connection attaches to. Mutable because a @@ -114,6 +44,14 @@ final class RemoteTmuxControlConnection { didSet { guard oldValue != connectionState else { return } observers.notifyStateChanged(connectionState) + switch connectionState { + case .connected: + finishConnectionWaiters(connected: true) + case .ended: + finishConnectionWaiters(connected: false) + case .connecting, .reconnecting: + break + } } } /// `true` once the connection has permanently ended (genuine tmux `%exit`, a @@ -154,6 +92,7 @@ final class RemoteTmuxControlConnection { private var parser = RemoteTmuxControlStreamParser() private var ingestTask: Task<Void, Never>? private var pendingCommands: [CommandKind] = [] + private var connectionWaiters: [UUID: (Bool) -> Void] = [:] /// `false` until the attach command's own `%begin`/`%end` block — always the /// FIRST block on each control stream, preceding every notification — has been /// consumed. That first block is matched explicitly (see the `.commandResult` @@ -187,17 +126,6 @@ final class RemoteTmuxControlConnection { /// after a reconnect so the resumed session keeps the mirror's grid instead of /// reverting to ssh's default 80×24. private var lastClientSize: (columns: Int, rows: Int)? - /// Work deferred from `.enter` to the first `list-windows` result: `.enter` - /// precedes the attach's own `%begin`/`%end` block, and any command queued - /// before that block is consumed would shift the positional result FIFO. - private enum PostAttachAction { - /// Reconnect: re-seed every mirrored pane (the fresh client lost the - /// screen, subscriptions, and client size). - case reseed - /// First connect: re-apply a client grid stored before stdin was live, - /// so the remote doesn't stay at ssh's default 80×24. - case applyClientSize - } private var pendingPostAttachAction: PostAttachAction? /// Trailing-edge debounce for `refresh-client -C`. SwiftUI layout settle makes the @@ -231,29 +159,11 @@ final class RemoteTmuxControlConnection { /// this, mutations are rejected and the connection reconnects instead of /// accepting unbounded user input that may never reach tmux. private static let maxPendingStdinBytes = 256 * 1024 - - private enum CommandKind: Equatable { - case listWindows, capturePane(Int), paneState(Int), panePath(Int), paneReflow(Int), paneAltScreen(Int), - activityQuery(UUID), other - } - - /// The lifecycle phase of a control connection. - /// - /// A transport loss moves `.connected` → `.reconnecting` (the mirror stays - /// frozen and keeps retrying); a successful re-attach returns to `.connected` - /// and re-seeds the panes. Only a genuine tmux `%exit`, a session found gone on - /// reconnect, or a deliberate ``stop()`` reaches `.ended`. - enum ConnectionState: Sendable, Equatable { - /// The initial connection is being established (before the first control-mode - /// `%enter`). - case connecting - /// Live: control mode is up and streaming. - case connected - /// The transport dropped; retrying with backoff while the mirror stays frozen. - case reconnecting - /// Permanently over (genuine `%exit`, session gone, or deliberate stop). - case ended - } + /// Cap pending stdout chunks between SSH's pipe callback and the main-actor + /// parser. A full buffer means parsing/rendering has fallen behind remote + /// output; reconnecting and re-seeding is safer than corrupting the stream by + /// dropping arbitrary control-mode bytes or growing memory without bound. + private static let maxPendingStdoutChunks = 16 /// Subscription-name prefix for per-pane `pane_current_path` (`refresh-client -B`). /// The tmux pane id is appended so an inbound `%subscription-changed` can be @@ -268,71 +178,6 @@ final class RemoteTmuxControlConnection { /// routing, mirroring ``cwdSubscriptionPrefix``. private static let reflowSubscriptionPrefix = "cmux_reflow_" - /// A pane's parsed `#{alternate_on}|#{pane_current_command}` value — the one - /// fact behind two policies (reflow suppression and close confirmation), - /// which deliberately default in opposite directions on an empty/unparseable - /// value: reflow must stay suppressed (rewrapping a TUI corrupts it) while a - /// close confirmation must not fire (a spurious dialog on every close). - /// Deliberately not `@MainActor` (a pure value), so the classification - /// constants live here rather than on the actor-isolated class. - struct PaneForegroundState: Equatable, Sendable { - /// Field separator inside the reflow subscription value - /// (`#{alternate_on}|#{pane_current_command}`). A pipe never appears in a tmux - /// `alternate_on` flag (0/1) and is not part of a process's `comm` name. - static let fieldSeparator: Character = "|" - - /// Foreground commands (`#{pane_current_command}`) whose primary-screen - /// scrollback is safe to reflow on resize — i.e. plain interactive shells that - /// keep their history as ordinary soft-wrapped text. Anything else (node for - /// claude, python, REPLs, pagers, editors) is treated as no-reflow so an inline - /// TUI's fixed-width frame is never rewrapped. Alt-screen apps are caught - /// separately by `#{alternate_on}`. Login shells can be reported with a leading - /// dash, so the common traditional/POSIX dash variants are listed too (not every - /// shell has one — a missing dash variant just doesn't reflow). Classification - /// defaults to no-reflow on anything not in this set (and on an unparseable - /// value), which is the safe direction (worst case: a shell's scrollback doesn't - /// reflow until reclassified) — so a login shell whose dash variant is missing - /// here simply doesn't reflow, never breaks. - static let plainShellCommands: Set<String> = [ - "bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh", "ash", - "mksh", "pdksh", "elvish", "nu", "xonsh", "pwsh", "powershell", "oil", "osh", - "-bash", "-zsh", "-fish", "-sh", "-dash", "-ksh", "-tcsh", "-csh", "-ash", - ] - - let alternateOn: Bool - let command: String - - init(rawValue: String) { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.split( - separator: Self.fieldSeparator, - maxSplits: 1, omittingEmptySubsequences: false - ) - alternateOn = parts.first.map(String.init) == "1" - command = parts.count > 1 - ? String(parts[1]).trimmingCharacters(in: .whitespaces) - : "" - } - - /// Reflow policy: suppress primary-screen reflow on resize unless the - /// foreground command is a known plain shell (and the pane is not on the - /// alternate screen). `true` for an empty/unknown command — safe default. - var suppressesReflow: Bool { - alternateOn || !Self.plainShellCommands.contains(command) - } - - /// Close-confirmation policy: the pane is running something beyond an - /// idle shell — a KNOWN non-shell foreground command (sleep, vim, node…) - /// or the alternate screen (full-screen TUI). Unlike ``suppressesReflow``, - /// an empty/unreported command is NOT active, so an unclassified pane - /// never triggers a spurious dialog. Foreground-only by nature: a remote - /// background job (`sleep 10 &`) keeps `pane_current_command` at the - /// shell and is not detected — tmux exposes no cheap process-tree check. - var hasActiveCommand: Bool { - alternateOn || (!command.isEmpty && !Self.plainShellCommands.contains(command)) - } - } - /// `ESC[?1049h` — enter the alternate screen, emitted to a mirror surface when /// the remote pane is on the alternate screen (see ``capturePane(paneId:)``). private static let altScreenEnterSequence = Data("\u{1b}[?1049h".utf8) @@ -418,6 +263,49 @@ final class RemoteTmuxControlConnection { started = true } + /// Suspends until the control stream really enters tmux control mode, or until + /// the connection reaches a permanent end. Launch success alone is not enough: + /// `ssh` can start and then fail authentication/session attach before tmux emits + /// `%enter`. + func waitUntilConnected() async -> Bool { + switch connectionState { + case .connected: + return true + case .ended: + return false + case .connecting, .reconnecting: + break + } + + let token = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + switch connectionState { + case .connected: + continuation.resume(returning: true) + return + case .ended: + continuation.resume(returning: false) + return + case .connecting, .reconnecting: + break + } + + connectionWaiters[token] = { connected in + continuation.resume(returning: connected) + } + + if Task.isCancelled { + finishConnectionWaiter(token, connected: false) + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.finishConnectionWaiter(token, connected: false) + } + } + } + /// Spawns (or re-spawns, on reconnect) the SSH `tmux -CC` process and wires its /// stdout into the parser, consuming stderr for session-gone classification. /// Resets the per-process state (parser, pending-command FIFO, captured stderr, @@ -452,22 +340,38 @@ final class RemoteTmuxControlConnection { handle: inPipe.fileHandleForWriting, label: "com.cmux.remote-tmux.stdin.\(UUID().uuidString)", maxPendingBytes: Self.maxPendingStdinBytes, - onFailure: { [weak self] _ in - Task { @MainActor [weak self] in - self?.handleStdinWriteFailure() - } + onFailure: { [weak self] in + self?.handleStdinWriteFailure() } ) - let (stream, continuation) = AsyncStream<Data>.makeStream() + let (stream, continuation) = AsyncStream<Data>.makeStream( + bufferingPolicy: .bufferingOldest(Self.maxPendingStdoutChunks) + ) let reader = outPipe.fileHandleForReading - reader.readabilityHandler = { handle in + reader.readabilityHandler = { [weak self] handle in let chunk = handle.availableData if chunk.isEmpty { handle.readabilityHandler = nil continuation.finish() - } else { - continuation.yield(chunk) + return + } + + switch continuation.yield(chunk) { + case .enqueued: + break + case .dropped, .terminated: + handle.readabilityHandler = nil + continuation.finish() + Task { @MainActor [weak self] in + self?.handleStdoutBackpressureOverflow() + } + @unknown default: + handle.readabilityHandler = nil + continuation.finish() + Task { @MainActor [weak self] in + self?.handleStdoutBackpressureOverflow() + } } } // Capture stderr via its own AsyncStream so a failed reconnect attempt can be @@ -935,6 +839,19 @@ final class RemoteTmuxControlConnection { for completion in completions { completion(nil) } } + private func finishConnectionWaiters(connected: Bool) { + guard !connectionWaiters.isEmpty else { return } + let waiters = Array(connectionWaiters.values) + connectionWaiters.removeAll() + for waiter in waiters { + waiter(connected) + } + } + + private func finishConnectionWaiter(_ token: UUID, connected: Bool) { + connectionWaiters.removeValue(forKey: token)?(connected) + } + /// Sends literal key bytes to a pane via tmux `send-keys -H` (hex-encoded), /// which is binary-safe and needs no shell quoting. @discardableResult @@ -1054,6 +971,15 @@ final class RemoteTmuxControlConnection { beginReconnecting() } + private func handleStdoutBackpressureOverflow() { + guard connectionState == .connected || connectionState == .connecting else { return } + // The parser fell far enough behind the SSH pipe that preserving every + // control-mode byte would exceed the bridge budget. Reconnect instead of + // dropping bytes and desynchronizing command/result parsing. + record("stdout-backpressure") + beginReconnecting() + } + private func ingest(_ data: Data) { for message in parser.feed(data) { handle(message) @@ -1471,15 +1397,4 @@ final class RemoteTmuxControlConnection { ) } - struct Snapshot: Sendable { - let started: Bool - let enterReceived: Bool - let exited: Bool - let sessionId: Int? - let windowCount: Int - let windowIDs: [Int] - let paneOutputByteCounts: [Int: Int] - let totalOutputBytes: Int - let recentEvents: [String] - } } diff --git a/Sources/RemoteTmuxControlConnectionSnapshot.swift b/Sources/RemoteTmuxControlConnectionSnapshot.swift new file mode 100644 index 000000000000..75e34df6f127 --- /dev/null +++ b/Sources/RemoteTmuxControlConnectionSnapshot.swift @@ -0,0 +1,11 @@ +struct RemoteTmuxControlConnectionSnapshot: Sendable { + let started: Bool + let enterReceived: Bool + let exited: Bool + let sessionId: Int? + let windowCount: Int + let windowIDs: [Int] + let paneOutputByteCounts: [Int: Int] + let totalOutputBytes: Int + let recentEvents: [String] +} diff --git a/Sources/RemoteTmuxControlPipeWriter.swift b/Sources/RemoteTmuxControlPipeWriter.swift new file mode 100644 index 000000000000..dffd3e54ad16 --- /dev/null +++ b/Sources/RemoteTmuxControlPipeWriter.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Bounded off-main writer for the SSH control client's stdin pipe. +/// +/// `RemoteTmuxControlConnection` records command FIFO entries on the main actor +/// before this writer can emit bytes, so tmux `%begin`/`%end` replies cannot +/// outrun their local correlation slot. The write itself may block on a stalled +/// SSH pipe; keeping it on this serial queue prevents that from freezing UI. +@MainActor +final class RemoteTmuxControlPipeWriter { + private let handle: FileHandle + private let queue: DispatchQueue + private let maxPendingBytes: Int + private let onFailure: @MainActor @Sendable () -> Void + private var closed = false + private var pendingBytes = 0 + + init( + handle: FileHandle, + label: String, + maxPendingBytes: Int, + onFailure: @escaping @MainActor @Sendable () -> Void + ) { + self.handle = handle + self.queue = DispatchQueue(label: label, qos: .userInitiated) + self.maxPendingBytes = maxPendingBytes + self.onFailure = onFailure + } + + func enqueue(_ data: Data) -> Bool { + guard !data.isEmpty else { return true } + guard !closed, + data.count <= maxPendingBytes - pendingBytes else { + return false + } + pendingBytes += data.count + + queue.async { [weak self, handle, data] in + var didFail = false + do { + try handle.write(contentsOf: data) + } catch { + didFail = true + } + Task { @MainActor [weak self] in + self?.finishWrite(byteCount: data.count, didFail: didFail) + } + } + return true + } + + private func finishWrite(byteCount: Int, didFail: Bool) { + pendingBytes = max(0, pendingBytes - byteCount) + if didFail, !closed { + onFailure() + } + } + + func close() { + guard !closed else { return } + closed = true + try? handle.close() + } +} diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index c97c4bf6a2a1..f194a8a42114 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -14,6 +14,9 @@ import CmuxSettings /// be reached from the v2 socket dispatcher via `AppDelegate.shared`. @MainActor final class RemoteTmuxController { + typealias MirrorTabActivity = RemoteTmuxMirrorTabActivity + typealias SessionEndAction = RemoteTmuxSessionEndAction + /// Per-endpoint SSH transports (keyed by ``RemoteTmuxHost/connectionHash``), /// owned by ``RemoteTmuxController`` and delegated to for discovery + master teardown. private let transportRegistry = RemoteTmuxTransportRegistry() @@ -82,6 +85,98 @@ final class RemoteTmuxController { return connection } + /// Attaches a single control connection and returns success only after tmux has + /// emitted `%enter`. Before launching the long-lived control stream, run a + /// BatchMode tmux probe through the shared transport so auth/session failures + /// are reported synchronously instead of looking like a successful attach. + func attachControlStreamWhenReady( + host: RemoteTmuxHost, + sessionName: String, + createIfMissing: Bool = false + ) async throws -> [String]? { + if let sshArgv = try await preflightControlAttach( + host: host, + sessionName: sessionName, + createIfMissing: createIfMissing + ) { + return sshArgv + } + + let connection = try attach( + host: host, + sessionName: sessionName, + createIfMissing: createIfMissing + ) + guard await connection.waitUntilConnected() else { + stopCachedConnectionIfCurrent(connection, host: host, sessionName: sessionName) + try Task.checkCancellation() + throw RemoteTmuxError.unreachable("tmux control stream ended before attach for \(host.destination)") + } + return nil + } + + private func stopCachedConnectionIfCurrent( + _ connection: RemoteTmuxControlConnection, + host: RemoteTmuxHost, + sessionName: String + ) { + let key = Self.connectionKey(host: host, sessionName: sessionName) + guard connectionsByHostSession[key] === connection else { return } + connectionsByHostSession.removeValue(forKey: key) + connection.stop() + } + + /// Ensures the requested session is attachable via a non-interactive tmux + /// command. Returns an auth-required outcome when BatchMode SSH cannot prompt; + /// returns `nil` when the control stream may be launched. + private func preflightControlAttach( + host: RemoteTmuxHost, + sessionName: String, + createIfMissing: Bool + ) async throws -> [String]? { + let transport = transport(for: host) + + do { + let existing = try await transport.runTmux(["has-session", "-t", sessionName]) + if existing.succeeded { + return nil + } + if let sshArgv = Self.authRequiredAttachArgv(host: host, result: existing) { + return sshArgv + } + + guard createIfMissing else { + throw RemoteTmuxError.commandFailed(exitCode: existing.exitCode, stderr: existing.stderr) + } + + let created = try await transport.runTmux(["new-session", "-d", "-s", sessionName]) + guard created.succeeded else { + if let sshArgv = Self.authRequiredAttachArgv(host: host, result: created) { + return sshArgv + } + throw RemoteTmuxError.commandFailed(exitCode: created.exitCode, stderr: created.stderr) + } + return nil + } catch let error as RemoteTmuxError { + if case .commandFailed(_, let stderr) = error, + RemoteTmuxSSHTransport.indicatesAuthRequired(stderr) { + return host.interactiveAuthInvocation() + } + throw error + } + } + + private static func authRequiredAttachArgv( + host: RemoteTmuxHost, + result: RemoteTmuxCommandResult + ) -> [String]? { + guard !result.succeeded, + RemoteTmuxSSHTransport.indicatesAuthRequired(result.stderr) else { + return nil + } + return host.interactiveAuthInvocation() + } + // MARK: - Sidebar mirroring (P3, initial increment) /// Active session→workspace mirrors keyed `connectionHash\u{1}session` @@ -512,20 +607,6 @@ final class RemoteTmuxController { return target.mirror.connection.send("kill-window -t @\(target.windowId)") } - /// A close-time answer for a mirrored window-tab: whether any pane runs an - /// active command, plus the command name for the dialog ("This will close - /// \"sleep\"."). The name comes from the foreground classification rather - /// than the tab title — tmux's automatic-rename refreshes the title on its - /// own lagging schedule, so a title-based dialog can still say "bash" while - /// the tab is about to read "sleep", looking like it names a different tab. - struct MirrorTabActivity { - let hasActiveCommand: Bool - /// The first active pane's foreground command (tmux's active pane - /// preferred, then layout order); `nil` when idle or unnamed (the - /// dialog then falls back to the tab title). - let activeCommandName: String? - } - /// Builds ``MirrorTabActivity`` from per-pane foreground states. Pure; /// `activePaneId` is checked first so a multi-pane window names the pane /// the user is looking at, then `paneOrder` (the window's layout order). @@ -639,19 +720,6 @@ final class RemoteTmuxController { return true } - /// What to do when a mirrored session ends remotely: close the dead session's - /// workspace, or — when the host's dedicated mirror window has just lost its - /// last session — close that whole window (the disconnect UX). - enum SessionEndAction: Equatable { - /// Close only the dead session's workspace (host still has other sessions, - /// or the mirror lives in a shared/non-dedicated window). - case closeWorkspace - /// Close the dedicated remote-tmux window wholesale, because its last - /// session disconnected and `closeWorkspace` can't remove a window's last - /// workspace. - case closeDedicatedWindow(UUID) - } - /// Decides how a remote session-end is reflected: close just the dead workspace, /// or the whole dedicated window when it lost its last session. /// diff --git a/Sources/RemoteTmuxLayoutContainer.swift b/Sources/RemoteTmuxLayoutContainer.swift new file mode 100644 index 000000000000..bcc4664ab2dc --- /dev/null +++ b/Sources/RemoteTmuxLayoutContainer.swift @@ -0,0 +1,114 @@ +import Bonsplit +import SwiftUI + +/// Recursive split container that lays out one ``RemoteTmuxLayoutNode`` subtree, +/// sizing children in proportion to their tmux cell extents. The gaps between +/// children show the divider color so both horizontal and vertical separators +/// are visible. +@MainActor +struct RemoteTmuxLayoutContainer: View { + let node: RemoteTmuxLayoutNode + let mirror: RemoteTmuxWindowMirror + let appearance: PanelAppearance + let isVisibleInUI: Bool + let portalPriority: Int + let onClosePane: (Int) -> Void + + private let dividerThickness: CGFloat = 2 + + var body: some View { + switch node.content { + case let .pane(paneId): + leaf(paneId: paneId) + case let .horizontal(children): + splitStack(children: children, axis: .horizontal) + case let .vertical(children): + splitStack(children: children, axis: .vertical) + } + } + + @ViewBuilder + private func leaf(paneId: Int) -> some View { + if let panel = mirror.panel(forPane: paneId), + let syntheticPaneId = mirror.syntheticPaneID(forPane: paneId) { + VStack(spacing: 0) { + RemoteTmuxPaneHeader( + isActive: mirror.activePaneId == paneId, + appearance: appearance, + onFocus: { mirror.focus(pane: paneId) }, + onSplitRight: { mirror.requestSplit(fromPane: paneId, vertical: false) }, + onSplitDown: { mirror.requestSplit(fromPane: paneId, vertical: true) }, + onClose: { onClosePane(paneId) } + ) + TerminalPanelView( + panel: panel, + paneId: syntheticPaneId, + isFocused: mirror.activePaneId == paneId, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, + isSplit: true, + appearance: appearance, + hasUnreadNotification: false, + terminalAgentContext: "", + onFocus: { mirror.focus(pane: paneId) }, + onResumeAgentHibernation: {}, + onAutoResumeAgentHibernation: {}, + onTriggerFlash: {} + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .id(paneId) + .background(Color(nsColor: appearance.backgroundColor)) + } else { + Color(nsColor: appearance.backgroundColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private func splitStack(children: [RemoteTmuxLayoutNode], axis: Axis) -> some View { + let weights = children.map { CGFloat(axis == .horizontal ? $0.width : $0.height) } + let total = max(1, weights.reduce(0, +)) + GeometryReader { geo in + let span = axis == .horizontal ? geo.size.width : geo.size.height + let usable = max(1, span - dividerThickness * CGFloat(max(0, children.count - 1))) + if axis == .horizontal { + HStack(spacing: dividerThickness) { + childViews(children, weights: weights, total: total, usable: usable, axis: axis) + } + .frame(width: geo.size.width, height: geo.size.height) + } else { + VStack(spacing: dividerThickness) { + childViews(children, weights: weights, total: total, usable: usable, axis: axis) + } + .frame(width: geo.size.width, height: geo.size.height) + } + } + .background(appearance.dividerColor) + } + + @ViewBuilder + private func childViews( + _ children: [RemoteTmuxLayoutNode], + weights: [CGFloat], + total: CGFloat, + usable: CGFloat, + axis: Axis + ) -> some View { + ForEach(children.indices, id: \.self) { index in + let dimension = usable * weights[index] / total + RemoteTmuxLayoutContainer( + node: children[index], + mirror: mirror, + appearance: appearance, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, + onClosePane: onClosePane + ) + .frame( + width: axis == .horizontal ? dimension : nil, + height: axis == .vertical ? dimension : nil + ) + } + } +} diff --git a/Sources/RemoteTmuxLayoutContent.swift b/Sources/RemoteTmuxLayoutContent.swift new file mode 100644 index 000000000000..32055ea984fd --- /dev/null +++ b/Sources/RemoteTmuxLayoutContent.swift @@ -0,0 +1,11 @@ +enum RemoteTmuxLayoutContent: Sendable, Equatable { + /// A leaf pane, identified by its numeric tmux pane id (the `%N` without the + /// leading `%`). + case pane(Int) + + /// A left-to-right split of child nodes. + case horizontal([RemoteTmuxLayoutNode]) + + /// A top-to-bottom split of child nodes. + case vertical([RemoteTmuxLayoutNode]) +} diff --git a/Sources/RemoteTmuxLayoutNode.swift b/Sources/RemoteTmuxLayoutNode.swift index db7d2f923759..c11489a35906 100644 --- a/Sources/RemoteTmuxLayoutNode.swift +++ b/Sources/RemoteTmuxLayoutNode.swift @@ -14,6 +14,8 @@ import Foundation /// "horizontal": [ { …, "pane": 1 }, { …, "pane": 2 } ] } /// ``` struct RemoteTmuxLayoutNode: Sendable, Equatable, Codable { + typealias Content = RemoteTmuxLayoutContent + /// Width of the node in terminal cells. let width: Int /// Height of the node in terminal cells. @@ -25,17 +27,6 @@ struct RemoteTmuxLayoutNode: Sendable, Equatable, Codable { /// The node's content: a leaf pane or a split. let content: Content - /// A layout node is either a leaf pane or a directional split. - enum Content: Sendable, Equatable { - /// A leaf pane, identified by its numeric tmux pane id (the `%N` - /// without the leading `%`). - case pane(Int) - /// A left→right split of child nodes. - case horizontal([RemoteTmuxLayoutNode]) - /// A top→bottom split of child nodes. - case vertical([RemoteTmuxLayoutNode]) - } - init(width: Int, height: Int, x: Int, y: Int, content: Content) { self.width = width self.height = height diff --git a/Sources/RemoteTmuxMirrorTabActivity.swift b/Sources/RemoteTmuxMirrorTabActivity.swift new file mode 100644 index 000000000000..32ffb114c760 --- /dev/null +++ b/Sources/RemoteTmuxMirrorTabActivity.swift @@ -0,0 +1,6 @@ +struct RemoteTmuxMirrorTabActivity { + let hasActiveCommand: Bool + + /// The first active pane's foreground command, or `nil` when idle or unnamed. + let activeCommandName: String? +} diff --git a/Sources/RemoteTmuxPaneForegroundState.swift b/Sources/RemoteTmuxPaneForegroundState.swift new file mode 100644 index 000000000000..3e328906f5b1 --- /dev/null +++ b/Sources/RemoteTmuxPaneForegroundState.swift @@ -0,0 +1,44 @@ +import Foundation + +struct RemoteTmuxPaneForegroundState: Equatable, Sendable { + /// Field separator inside the reflow subscription value + /// (`#{alternate_on}|#{pane_current_command}`). A pipe never appears in a tmux + /// `alternate_on` flag (0/1) and is not part of a process's `comm` name. + static let fieldSeparator: Character = "|" + + /// Foreground commands whose primary-screen scrollback is safe to reflow on + /// resize. Everything else is treated as no-reflow so inline TUIs are not + /// rewrapped into corrupted frames. + static let plainShellCommands: Set<String> = [ + "bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh", "ash", + "mksh", "pdksh", "elvish", "nu", "xonsh", "pwsh", "powershell", "oil", "osh", + "-bash", "-zsh", "-fish", "-sh", "-dash", "-ksh", "-tcsh", "-csh", "-ash", + ] + + let alternateOn: Bool + let command: String + + init(rawValue: String) { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split( + separator: Self.fieldSeparator, + maxSplits: 1, omittingEmptySubsequences: false + ) + alternateOn = parts.first.map(String.init) == "1" + command = parts.count > 1 + ? String(parts[1]).trimmingCharacters(in: .whitespaces) + : "" + } + + /// Reflow policy: suppress primary-screen reflow on resize unless the pane is + /// a known plain shell and not on the alternate screen. + var suppressesReflow: Bool { + alternateOn || !Self.plainShellCommands.contains(command) + } + + /// Close-confirmation policy: active for a known non-shell foreground command + /// or the alternate screen. Empty/unreported commands are treated as idle. + var hasActiveCommand: Bool { + alternateOn || (!command.isEmpty && !Self.plainShellCommands.contains(command)) + } +} diff --git a/Sources/RemoteTmuxPaneHeader.swift b/Sources/RemoteTmuxPaneHeader.swift new file mode 100644 index 000000000000..9cf9f8b07374 --- /dev/null +++ b/Sources/RemoteTmuxPaneHeader.swift @@ -0,0 +1,58 @@ +import SwiftUI + +/// A compact per-pane control bar shown above each mirrored tmux pane. +@MainActor +struct RemoteTmuxPaneHeader: View { + let isActive: Bool + let appearance: PanelAppearance + let onFocus: () -> Void + let onSplitRight: () -> Void + let onSplitDown: () -> Void + let onClose: () -> Void + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(isActive ? Color.accentColor : Color.secondary.opacity(0.35)) + .frame(width: 6, height: 6) + Spacer(minLength: 0) + button( + system: "square.split.2x1", + label: String(localized: "remoteTmux.pane.splitRight", defaultValue: "Split Right"), + action: onSplitRight + ) + button( + system: "square.split.1x2", + label: String(localized: "remoteTmux.pane.splitDown", defaultValue: "Split Down"), + action: onSplitDown + ) + button( + system: "xmark", + label: String(localized: "remoteTmux.pane.close", defaultValue: "Close Pane"), + action: onClose + ) + } + .padding(.horizontal, 8) + .frame(height: 24) + .frame(maxWidth: .infinity) + .background(Color(nsColor: appearance.backgroundColor)) + .overlay(alignment: .bottom) { + Rectangle().fill(appearance.dividerColor).frame(height: 1) + } + .contentShape(Rectangle()) + .onTapGesture(perform: onFocus) + } + + private func button(system: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: system) + .font(.system(size: 11, weight: .medium)) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(label) + .accessibilityLabel(label) + } +} diff --git a/Sources/RemoteTmuxPostAttachAction.swift b/Sources/RemoteTmuxPostAttachAction.swift new file mode 100644 index 000000000000..cce97f9a68b6 --- /dev/null +++ b/Sources/RemoteTmuxPostAttachAction.swift @@ -0,0 +1,9 @@ +enum RemoteTmuxPostAttachAction { + /// Reconnect: re-seed every mirrored pane after the fresh client lost the + /// screen, subscriptions, and client size. + case reseed + + /// First connect: re-apply a client grid stored before stdin was live, so + /// the remote does not stay at ssh's default 80x24. + case applyClientSize +} diff --git a/Sources/RemoteTmuxProcessCancellation.swift b/Sources/RemoteTmuxProcessCancellation.swift new file mode 100644 index 000000000000..a06cf607e081 --- /dev/null +++ b/Sources/RemoteTmuxProcessCancellation.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Safety: the cancellation handler requires a `Sendable` capture, and this wrapper +/// stores immutable Foundation handles only to send idempotent terminate/close calls. +final class RemoteTmuxProcessCancellation: @unchecked Sendable { + private let process: Process + private let stdout: FileHandle + private let stderr: FileHandle + + init(process: Process, stdout: FileHandle, stderr: FileHandle) { + self.process = process + self.stdout = stdout + self.stderr = stderr + } + + func cancel() { + if process.isRunning { + process.terminate() + } + try? stdout.close() + try? stderr.close() + } +} diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index c8d709b7d9f9..d959ca6434fe 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -14,6 +14,8 @@ import Foundation /// Modeled as an `actor` because it owns the per-host connection lifecycle and /// serializes process launches; reads/writes are `async`. actor RemoteTmuxSSHTransport { + private static let maxCapturedOutputBytes = 1_048_576 + /// The host this transport talks to. /// /// `nonisolated` so the controller can read it synchronously (it's an immutable @@ -136,7 +138,7 @@ actor RemoteTmuxSSHTransport { await kills.waitForAll() } } - group.addTask { try? await Task.sleep(for: timeout) } + group.addTask { try? await ContinuousClock().sleep(for: timeout) } await group.next() group.cancelAll() } @@ -180,7 +182,7 @@ actor RemoteTmuxSSHTransport { // MARK: - Process plumbing - /// Launches a process and captures stdout/stderr without blocking the actor. + /// Launches a process and captures bounded stdout/stderr without blocking the actor. /// /// Each pipe is drained to EOF on a detached task so a chatty command can't /// deadlock against a full 64 KiB pipe buffer while we await termination. @@ -203,8 +205,13 @@ actor RemoteTmuxSSHTransport { let outFD = outPipe.fileHandleForReading.fileDescriptor let errFD = errPipe.fileHandleForReading.fileDescriptor - let outRead = Task.detached { Self.drain(fd: outFD) } - let errRead = Task.detached { Self.drain(fd: errFD) } + let outRead = Task.detached { Self.drain(fd: outFD, maxBytes: Self.maxCapturedOutputBytes) } + let errRead = Task.detached { Self.drain(fd: errFD, maxBytes: Self.maxCapturedOutputBytes) } + let cancellation = RemoteTmuxProcessCancellation( + process: process, + stdout: outPipe.fileHandleForReading, + stderr: errPipe.fileHandleForReading + ) // Install the termination handler BEFORE launching, then launch inside the // continuation. If `run()` and the handler assignment were separate steps, a @@ -215,22 +222,30 @@ actor RemoteTmuxSSHTransport { // the fast auth-failure exits the `cmux ssh-tmux` flow classifies. let exitCode: Int32 do { - exitCode = try await withCheckedThrowingContinuation { continuation in - process.terminationHandler = { proc in - continuation.resume(returning: proc.terminationStatus) - } - do { - try process.run() - } catch { - // The process never started, so the handler will not fire; resume - // exactly once here with the launch failure. - process.terminationHandler = nil - continuation.resume(throwing: RemoteTmuxError.launchFailed(error.localizedDescription)) + exitCode = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { proc in + continuation.resume(returning: proc.terminationStatus) + } + do { + try process.run() + } catch { + // The process never started, so the handler will not fire; resume + // exactly once here with the launch failure. + process.terminationHandler = nil + continuation.resume(throwing: RemoteTmuxError.launchFailed(error.localizedDescription)) + } } + } onCancel: { + cancellation.cancel() } + try Task.checkCancellation() } catch { + cancellation.cancel() outRead.cancel() errRead.cancel() + _ = await outRead.value + _ = await errRead.value throw error } @@ -243,20 +258,26 @@ actor RemoteTmuxSSHTransport { ) } - /// Reads a file descriptor to EOF, returning everything read. + /// Reads a file descriptor to EOF, returning at most `maxBytes`. /// /// Uses the raw `read(2)` so nothing non-`Sendable` crosses the task /// boundary; the owning `Pipe` keeps `fd` open for the duration. - private static func drain(fd: Int32) -> Data { + private static func drain(fd: Int32, maxBytes: Int) -> Data { var data = Data() + var remaining = max(0, maxBytes) let bufferSize = 65_536 var buffer = [UInt8](repeating: 0, count: bufferSize) while true { + if Task.isCancelled { break } let count = buffer.withUnsafeMutableBytes { ptr -> Int in read(fd, ptr.baseAddress, bufferSize) } if count > 0 { - data.append(contentsOf: buffer[0..<count]) + if remaining > 0 { + let kept = min(count, remaining) + data.append(contentsOf: buffer[0..<kept]) + remaining -= kept + } } else if count == 0 { break // EOF } else if errno == EINTR { diff --git a/Sources/RemoteTmuxSessionEndAction.swift b/Sources/RemoteTmuxSessionEndAction.swift new file mode 100644 index 000000000000..0e4097125acb --- /dev/null +++ b/Sources/RemoteTmuxSessionEndAction.swift @@ -0,0 +1,11 @@ +import Foundation + +enum RemoteTmuxSessionEndAction: Equatable { + /// Close only the dead session's workspace. + case closeWorkspace + + /// Close the dedicated remote-tmux window wholesale because its last session + /// disconnected and closing only the workspace cannot remove a window's last + /// workspace. + case closeDedicatedWindow(UUID) +} diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 2b56fb712651..309b4eafeded 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -249,7 +249,7 @@ final class RemoteTmuxSessionMirror { if pushInitialClientSize() { return } initialSizingTask = Task { @MainActor [weak self] in for _ in 0..<20 { - do { try await Task.sleep(for: .milliseconds(150)) } catch { return } + do { try await ContinuousClock().sleep(for: .milliseconds(150)) } catch { return } guard let self else { return } if self.pushInitialClientSize() { return } } diff --git a/Sources/RemoteTmuxWindowMirrorView.swift b/Sources/RemoteTmuxWindowMirrorView.swift index b3968e2bbe97..310a5888a4c1 100644 --- a/Sources/RemoteTmuxWindowMirrorView.swift +++ b/Sources/RemoteTmuxWindowMirrorView.swift @@ -1,4 +1,3 @@ -import Bonsplit import SwiftUI /// Renders a mirrored tmux window's multi-pane layout as nested splits inside a @@ -52,182 +51,9 @@ struct RemoteTmuxWindowMirrorView: View { // normally a frame or two; budget generously for a loaded system). do/catch // (not try?) so a cancelled sleep returns immediately without a stale apply. for _ in 0..<20 { - do { try await Task.sleep(for: .milliseconds(150)) } catch { return } + do { try await ContinuousClock().sleep(for: .milliseconds(150)) } catch { return } if mirror.updateClientSize(contentSizePoints: size) { return } } } } } - -/// Recursive split container that lays out one ``RemoteTmuxLayoutNode`` subtree, -/// sizing children in proportion to their tmux cell extents. The gaps between -/// children show the divider color so both horizontal and vertical separators -/// are visible. -@MainActor -private struct RemoteTmuxLayoutContainer: View { - let node: RemoteTmuxLayoutNode - let mirror: RemoteTmuxWindowMirror - let appearance: PanelAppearance - let isVisibleInUI: Bool - let portalPriority: Int - let onClosePane: (Int) -> Void - - private let dividerThickness: CGFloat = 2 - - var body: some View { - switch node.content { - case let .pane(paneId): - leaf(paneId: paneId) - case let .horizontal(children): - splitStack(children: children, axis: .horizontal) - case let .vertical(children): - splitStack(children: children, axis: .vertical) - } - } - - @ViewBuilder - private func leaf(paneId: Int) -> some View { - if let panel = mirror.panel(forPane: paneId), - let syntheticPaneId = mirror.syntheticPaneID(forPane: paneId) { - VStack(spacing: 0) { - RemoteTmuxPaneHeader( - isActive: mirror.activePaneId == paneId, - appearance: appearance, - onFocus: { mirror.focus(pane: paneId) }, - onSplitRight: { mirror.requestSplit(fromPane: paneId, vertical: false) }, - onSplitDown: { mirror.requestSplit(fromPane: paneId, vertical: true) }, - onClose: { onClosePane(paneId) } - ) - TerminalPanelView( - panel: panel, - paneId: syntheticPaneId, - isFocused: mirror.activePaneId == paneId, - isVisibleInUI: isVisibleInUI, - portalPriority: portalPriority, - isSplit: true, - appearance: appearance, - hasUnreadNotification: false, - terminalAgentContext: "", - onFocus: { mirror.focus(pane: paneId) }, - onResumeAgentHibernation: {}, - onAutoResumeAgentHibernation: {}, - onTriggerFlash: {} - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .id(paneId) - .background(Color(nsColor: appearance.backgroundColor)) - } else { - Color(nsColor: appearance.backgroundColor) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - @ViewBuilder - private func splitStack(children: [RemoteTmuxLayoutNode], axis: Axis) -> some View { - let weights = children.map { CGFloat(axis == .horizontal ? $0.width : $0.height) } - let total = max(1, weights.reduce(0, +)) - GeometryReader { geo in - let span = axis == .horizontal ? geo.size.width : geo.size.height - let usable = max(1, span - dividerThickness * CGFloat(max(0, children.count - 1))) - if axis == .horizontal { - HStack(spacing: dividerThickness) { - childViews(children, weights: weights, total: total, usable: usable, axis: axis) - } - .frame(width: geo.size.width, height: geo.size.height) - } else { - VStack(spacing: dividerThickness) { - childViews(children, weights: weights, total: total, usable: usable, axis: axis) - } - .frame(width: geo.size.width, height: geo.size.height) - } - } - // The inter-child gaps reveal this as the split divider. - .background(appearance.dividerColor) - } - - @ViewBuilder - private func childViews( - _ children: [RemoteTmuxLayoutNode], - weights: [CGFloat], - total: CGFloat, - usable: CGFloat, - axis: Axis - ) -> some View { - ForEach(children.indices, id: \.self) { index in - let dimension = usable * weights[index] / total - RemoteTmuxLayoutContainer( - node: children[index], - mirror: mirror, - appearance: appearance, - isVisibleInUI: isVisibleInUI, - portalPriority: portalPriority, - onClosePane: onClosePane - ) - .frame( - width: axis == .horizontal ? dimension : nil, - height: axis == .vertical ? dimension : nil - ) - } - } -} - -/// A compact per-pane control bar shown above each mirrored tmux pane: a focus -/// indicator plus split-right / split-down / close buttons (which drive tmux -/// `split-window` / `kill-pane`). Gives mirrored panes native-feeling chrome and -/// a clearly visible separator. -@MainActor -private struct RemoteTmuxPaneHeader: View { - let isActive: Bool - let appearance: PanelAppearance - let onFocus: () -> Void - let onSplitRight: () -> Void - let onSplitDown: () -> Void - let onClose: () -> Void - - var body: some View { - HStack(spacing: 8) { - Circle() - .fill(isActive ? Color.accentColor : Color.secondary.opacity(0.35)) - .frame(width: 6, height: 6) - Spacer(minLength: 0) - button( - system: "square.split.2x1", - label: String(localized: "remoteTmux.pane.splitRight", defaultValue: "Split Right"), - action: onSplitRight - ) - button( - system: "square.split.1x2", - label: String(localized: "remoteTmux.pane.splitDown", defaultValue: "Split Down"), - action: onSplitDown - ) - button( - system: "xmark", - label: String(localized: "remoteTmux.pane.close", defaultValue: "Close Pane"), - action: onClose - ) - } - .padding(.horizontal, 8) - .frame(height: 24) - .frame(maxWidth: .infinity) - .background(Color(nsColor: appearance.backgroundColor)) - .overlay(alignment: .bottom) { - Rectangle().fill(appearance.dividerColor).frame(height: 1) - } - .contentShape(Rectangle()) - .onTapGesture(perform: onFocus) - } - - private func button(system: String, label: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: system) - .font(.system(size: 11, weight: .medium)) - .frame(width: 18, height: 18) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .help(label) - .accessibilityLabel(label) - } -} diff --git a/Sources/TerminalController+RemoteTmux.swift b/Sources/TerminalController+RemoteTmux.swift index 7c3e84fdde4a..252ab5b0356a 100644 --- a/Sources/TerminalController+RemoteTmux.swift +++ b/Sources/TerminalController+RemoteTmux.swift @@ -93,16 +93,21 @@ extension TerminalController { return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.sessionRequired", defaultValue: "session is required")) } let createIfMissing = (params["create"] as? Bool) ?? false - return v2VmCall(id: id, timeoutSeconds: 20) { - try await MainActor.run { - guard let controller = AppDelegate.shared?.remoteTmuxController else { - throw RemoteTmuxError.unreachable("app not ready") - } - _ = try controller.attach( - host: host, - sessionName: session, - createIfMissing: createIfMissing - ) + return v2VmCall(id: id, timeoutSeconds: 60) { + guard let controller = await MainActor.run(body: { AppDelegate.shared?.remoteTmuxController }) else { + throw RemoteTmuxError.unreachable("app not ready") + } + if let sshArgv = try await controller.attachControlStreamWhenReady( + host: host, + sessionName: session, + createIfMissing: createIfMissing + ) { + return [ + "host": host.destination, + "session": session, + "auth_required": true, + "ssh_argv": sshArgv, + ] } return [ "host": host.destination, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 631344f30cec..ee132c317139 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4134,11 +4134,7 @@ final class Workspace: Identifiable, ObservableObject { } } - private enum RemoteTmuxNonInteractiveCloseRoute { - case notMirrorTab - case rejectedMirrorTab - case routed - } + private typealias RemoteTmuxNonInteractiveCloseRoute = WorkspaceRemoteTmuxNonInteractiveCloseRoute private func routeRemoteTmuxNonInteractiveTabCloseIfNeeded(_ tabId: TabID) -> RemoteTmuxNonInteractiveCloseRoute { guard isRemoteTmuxMirror, @@ -5803,7 +5799,7 @@ final class Workspace: Identifiable, ObservableObject { /// in-tab split container (``RemoteTmuxWindowMirrorView``) instead of the /// single-surface ``PanelContentView``. Owned by ``RemoteTmuxSessionMirror``; /// the view layer only reads it. - @Published private(set) var remoteTmuxWindowMirrors: [UUID: RemoteTmuxWindowMirror] = [:] + private(set) var remoteTmuxWindowMirrors: [UUID: RemoteTmuxWindowMirror] = [:] /// The multi-pane renderer for a window-tab's panel, if that window is /// currently multi-pane. @@ -5813,6 +5809,7 @@ final class Workspace: Identifiable, ObservableObject { /// Registers (or replaces) a window's multi-pane renderer. func setRemoteTmuxWindowMirror(_ mirror: RemoteTmuxWindowMirror?, forPanelId panelId: UUID) { + objectWillChange.send() if let mirror { remoteTmuxWindowMirrors[panelId] = mirror } else { diff --git a/Sources/WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift b/Sources/WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift new file mode 100644 index 000000000000..4be0d00a776f --- /dev/null +++ b/Sources/WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift @@ -0,0 +1,5 @@ +enum WorkspaceRemoteTmuxNonInteractiveCloseRoute { + case notMirrorTab + case rejectedMirrorTab + case routed +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index cbc7419f3e64..6abd12189edd 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -532,22 +532,35 @@ 9C988B800BBFB3CFFD46E611 /* RemoteTmuxCommandResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */; }; 0C17C0DE0C17C0DE0C17C002 /* RemoteTmuxConnectionDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C17C0DE0C17C0DE0C17C001 /* RemoteTmuxConnectionDiagnostics.swift */; }; 0D17C0DE0D17C0DE0D17C002 /* RemoteTmuxConnectionObservers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */; }; + 6A9D9021221BF8B429986368 /* RemoteTmuxConnectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A18D24AB9D3168ABACBCF8FE /* RemoteTmuxConnectionState.swift */; }; + 477796B44AAC7290AA9EFB6C /* RemoteTmuxControlCommandKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAD58EFBF501E599E6D9E9A /* RemoteTmuxControlCommandKind.swift */; }; 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */; }; + 42FBA382CDEAEC146FA5A895 /* RemoteTmuxControlConnectionSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2587F4E278EFECB84E2ED34B /* RemoteTmuxControlConnectionSnapshot.swift */; }; F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */; }; DCCD118CAFF7B3B9C6F184B4 /* RemoteTmuxControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */; }; 0B17C0DE0B17C0DE0B17C002 /* RemoteTmuxControlMessageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B17C0DE0B17C0DE0B17C001 /* RemoteTmuxControlMessageDecoding.swift */; }; B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */; }; + AFEC676A8FB901E750BA5641 /* RemoteTmuxControlPipeWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9420A497CBC16357FF3F2C9A /* RemoteTmuxControlPipeWriter.swift */; }; E1B13CF7FE1A77324A142521 /* RemoteTmuxControlStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */; }; 2FAA10BC0D7B50DE57F507A4 /* RemoteTmuxError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */; }; 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */; }; + 00B223273DE0DBED98437C7A /* RemoteTmuxLayoutContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A1B00F615F44EA3BA234483 /* RemoteTmuxLayoutContainer.swift */; }; + B12E809B546C64DC359C54E3 /* RemoteTmuxLayoutContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A830D507D913809EB02B0 /* RemoteTmuxLayoutContent.swift */; }; E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */; }; 7DA09B544027E3F73AA09518 /* RemoteTmuxManualIOWrite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */; }; D4F8A2E61C5B39707A8E6F12 /* RemoteTmuxMirrorSplitRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C4D2B3F09865E217D4C0 /* RemoteTmuxMirrorSplitRoutingTests.swift */; }; + F861B8D7C62A0BCB252A523A /* RemoteTmuxMirrorTabActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FC55CC7B42874062C64013A /* RemoteTmuxMirrorTabActivity.swift */; }; + E9A8CC35D632F14A8669B7D1 /* RemoteTmuxPaneForegroundState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 123BF93B1ADC00C5D25EE028 /* RemoteTmuxPaneForegroundState.swift */; }; + 740FC5FDE4E13EAA440872C3 /* RemoteTmuxPaneHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F225C1630E640224D11E09 /* RemoteTmuxPaneHeader.swift */; }; + 761639EDDD6C40883902D781 /* RemoteTmuxPostAttachAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE24906539C19AB10E4C1C71 /* RemoteTmuxPostAttachAction.swift */; }; + A4C6F928D7E14B2AA924B47C /* RemoteTmuxProcessCancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59B80C0A6FC64A59BD274E1E /* RemoteTmuxProcessCancellation.swift */; }; C9BA962E6CB31C9FD62505BF /* RemoteTmuxRawLayoutParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */; }; A42DAD21BD502098E446999C /* RemoteTmuxSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */; }; + 0A2A2FA17CD71DA5B4495DEE /* RemoteTmuxSessionEndAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3E94F6022485BB12789444 /* RemoteTmuxSessionEndAction.swift */; }; 008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */; }; 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */; }; 1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */; }; + 9A5CF3AA2E6462E77144F9E6 /* RemoteTmuxSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */; }; 6D3C19C2014FF9C754358EFF /* RemoteTmuxSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */; }; 0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */; }; A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */; }; @@ -837,6 +850,7 @@ EE30D5000000000000000004 /* WorkspaceRemoteRelayCommandRewriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE30D5000000000000000003 /* WorkspaceRemoteRelayCommandRewriter.swift */; }; EE30D6000000000000000004 /* WorkspaceRemoteSessionBuildInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE30D6000000000000000003 /* WorkspaceRemoteSessionBuildInfo.swift */; }; EE30D6000000000000000002 /* WorkspaceRemoteSessionHostAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE30D6000000000000000001 /* WorkspaceRemoteSessionHostAdapter.swift */; }; + 44FF9EFEEF762BE6CF158E42 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92CA6F2F239631988B2FB0C8 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift */; }; 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */; }; 5659A0015659A0015659A001 /* WorkspaceSidebarObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5659A0025659A0025659A002 /* WorkspaceSidebarObservation.swift */; }; 5659A0035659A0035659A003 /* WorkspaceSidebarObservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5659A0045659A0045659A004 /* WorkspaceSidebarObservationTests.swift */; }; @@ -849,6 +863,7 @@ E3B7A4000000000000000003 /* WorkspaceTabColorEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A4000000000000000004 /* WorkspaceTabColorEntry.swift */; }; E30750000000000000000002 /* WorkspaceTabColorResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000001 /* WorkspaceTabColorResolution.swift */; }; E3B7A4000000000000000001 /* WorkspaceTabColorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A4000000000000000002 /* WorkspaceTabColorSettings.swift */; }; + 634AA6233A5BBF781FE83E89 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08850489C70ECFACA540C2F3 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift */; }; 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; /* End PBXBuildFile section */ @@ -1390,22 +1405,35 @@ 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxCommandResult.swift; sourceTree = "<group>"; }; 0C17C0DE0C17C0DE0C17C001 /* RemoteTmuxConnectionDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionDiagnostics.swift; sourceTree = "<group>"; }; 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionObservers.swift; sourceTree = "<group>"; }; + A18D24AB9D3168ABACBCF8FE /* RemoteTmuxConnectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionState.swift; sourceTree = "<group>"; }; + AAAD58EFBF501E599E6D9E9A /* RemoteTmuxControlCommandKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlCommandKind.swift; sourceTree = "<group>"; }; 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlConnection.swift; sourceTree = "<group>"; }; + 2587F4E278EFECB84E2ED34B /* RemoteTmuxControlConnectionSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlConnectionSnapshot.swift; sourceTree = "<group>"; }; 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxController.swift; sourceTree = "<group>"; }; 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlMessage.swift; sourceTree = "<group>"; }; 0B17C0DE0B17C0DE0B17C001 /* RemoteTmuxControlMessageDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlMessageDecoding.swift; sourceTree = "<group>"; }; C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlParserTests.swift; sourceTree = "<group>"; }; + 9420A497CBC16357FF3F2C9A /* RemoteTmuxControlPipeWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlPipeWriter.swift; sourceTree = "<group>"; }; FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlStreamParser.swift; sourceTree = "<group>"; }; AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxError.swift; sourceTree = "<group>"; }; 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxHost.swift; sourceTree = "<group>"; }; + 3A1B00F615F44EA3BA234483 /* RemoteTmuxLayoutContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutContainer.swift; sourceTree = "<group>"; }; + 4B7A830D507D913809EB02B0 /* RemoteTmuxLayoutContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutContent.swift; sourceTree = "<group>"; }; 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutNode.swift; sourceTree = "<group>"; }; 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxManualIOWrite.swift; sourceTree = "<group>"; }; A7E1C4D2B3F09865E217D4C0 /* RemoteTmuxMirrorSplitRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorSplitRoutingTests.swift; sourceTree = "<group>"; }; + 6FC55CC7B42874062C64013A /* RemoteTmuxMirrorTabActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorTabActivity.swift; sourceTree = "<group>"; }; + 123BF93B1ADC00C5D25EE028 /* RemoteTmuxPaneForegroundState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxPaneForegroundState.swift; sourceTree = "<group>"; }; + F6F225C1630E640224D11E09 /* RemoteTmuxPaneHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxPaneHeader.swift; sourceTree = "<group>"; }; + CE24906539C19AB10E4C1C71 /* RemoteTmuxPostAttachAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxPostAttachAction.swift; sourceTree = "<group>"; }; + 59B80C0A6FC64A59BD274E1E /* RemoteTmuxProcessCancellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxProcessCancellation.swift; sourceTree = "<group>"; }; 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxRawLayoutParser.swift; sourceTree = "<group>"; }; 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSession.swift; sourceTree = "<group>"; }; + 9E3E94F6022485BB12789444 /* RemoteTmuxSessionEndAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionEndAction.swift; sourceTree = "<group>"; }; E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParser.swift; sourceTree = "<group>"; }; 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParserTests.swift; sourceTree = "<group>"; }; D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionMirror.swift; sourceTree = "<group>"; }; + 31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionSnapshotTests.swift; sourceTree = "<group>"; }; E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSSHTransport.swift; sourceTree = "<group>"; }; 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxTransportRegistry.swift; sourceTree = "<group>"; }; AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindow.swift; sourceTree = "<group>"; }; @@ -1689,6 +1717,7 @@ EE30D5000000000000000003 /* WorkspaceRemoteRelayCommandRewriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteRelayCommandRewriter.swift; sourceTree = "<group>"; }; EE30D6000000000000000003 /* WorkspaceRemoteSessionBuildInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSessionBuildInfo.swift; sourceTree = "<group>"; }; EE30D6000000000000000001 /* WorkspaceRemoteSessionHostAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSessionHostAdapter.swift; sourceTree = "<group>"; }; + 92CA6F2F239631988B2FB0C8 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift; sourceTree = "<group>"; }; 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/WorkspaceRuntimeSettings.swift; sourceTree = "<group>"; }; 5659A0025659A0025659A002 /* WorkspaceSidebarObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarObservation.swift; sourceTree = "<group>"; }; 5659A0045659A0045659A004 /* WorkspaceSidebarObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarObservationTests.swift; sourceTree = "<group>"; }; @@ -1701,6 +1730,7 @@ E3B7A4000000000000000004 /* WorkspaceTabColorEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorEntry.swift; sourceTree = "<group>"; }; E30750000000000000000001 /* WorkspaceTabColorResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorResolution.swift; sourceTree = "<group>"; }; E3B7A4000000000000000002 /* WorkspaceTabColorSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorSettings.swift; sourceTree = "<group>"; }; + 08850489C70ECFACA540C2F3 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTerminalWorkingDirectoryFallbackTests.swift; sourceTree = "<group>"; }; 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = "<group>"; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -2346,16 +2376,28 @@ D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */, 82AE501F3F383EF0144F6884 /* RemoteTmuxManualIOWrite.swift */, 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */, + AAAD58EFBF501E599E6D9E9A /* RemoteTmuxControlCommandKind.swift */, + 2587F4E278EFECB84E2ED34B /* RemoteTmuxControlConnectionSnapshot.swift */, + 9420A497CBC16357FF3F2C9A /* RemoteTmuxControlPipeWriter.swift */, 0B17C0DE0B17C0DE0B17C001 /* RemoteTmuxControlMessageDecoding.swift */, 0C17C0DE0C17C0DE0C17C001 /* RemoteTmuxConnectionDiagnostics.swift */, 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */, + A18D24AB9D3168ABACBCF8FE /* RemoteTmuxConnectionState.swift */, 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */, FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */, 07A7B144E94D24E7E1A99106 /* RemoteTmuxControlMessage.swift */, AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */, + 4B7A830D507D913809EB02B0 /* RemoteTmuxLayoutContent.swift */, + 3A1B00F615F44EA3BA234483 /* RemoteTmuxLayoutContainer.swift */, 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */, + 6FC55CC7B42874062C64013A /* RemoteTmuxMirrorTabActivity.swift */, + 123BF93B1ADC00C5D25EE028 /* RemoteTmuxPaneForegroundState.swift */, + F6F225C1630E640224D11E09 /* RemoteTmuxPaneHeader.swift */, + CE24906539C19AB10E4C1C71 /* RemoteTmuxPostAttachAction.swift */, + 59B80C0A6FC64A59BD274E1E /* RemoteTmuxProcessCancellation.swift */, 95B11974CF8937E165A3FAEE /* TerminalController+RemoteTmux.swift */, 50FE16F529BD6FBA4FBDECFC /* RemoteTmuxController.swift */, + 9E3E94F6022485BB12789444 /* RemoteTmuxSessionEndAction.swift */, 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */, 0F17C0DE0F17C0DE0F17C001 /* RemoteTmuxWindowRegistry.swift */, E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */, @@ -2365,6 +2407,7 @@ 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */, 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */, 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */, + 92CA6F2F239631988B2FB0C8 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift */, A5001671 /* SessionRestoredTerminalCommandStore.swift */, B35751000000000000000001 /* TmuxResumeParser.swift */, A5001661 /* RestorableAgentSession.swift */, @@ -2603,6 +2646,8 @@ FA00C0DE0002BEEF0002CAFE /* RemoteTmuxWindowRegistryTests.swift */, 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */, 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */, + 31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */, + 08850489C70ECFACA540C2F3 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift */, 4F34462AE2F2EECEF5298031 /* GhosttyOptionAsAltModsTests.swift */, D7C0DE00000000000000A104 /* CmuxWebViewContextMenuLinkCaptureTests.swift */, D1F0A00300000000000000C2 /* PhonePushPresenceGateTests.swift */, @@ -3376,17 +3421,29 @@ 9C988B800BBFB3CFFD46E611 /* RemoteTmuxCommandResult.swift in Sources */, 0C17C0DE0C17C0DE0C17C002 /* RemoteTmuxConnectionDiagnostics.swift in Sources */, 0D17C0DE0D17C0DE0D17C002 /* RemoteTmuxConnectionObservers.swift in Sources */, + 6A9D9021221BF8B429986368 /* RemoteTmuxConnectionState.swift in Sources */, + 477796B44AAC7290AA9EFB6C /* RemoteTmuxControlCommandKind.swift in Sources */, 1A8D46CE776340BB6BC49FC2 /* RemoteTmuxControlConnection.swift in Sources */, + 42FBA382CDEAEC146FA5A895 /* RemoteTmuxControlConnectionSnapshot.swift in Sources */, F67BABC493A488943ED1DB90 /* RemoteTmuxController.swift in Sources */, DCCD118CAFF7B3B9C6F184B4 /* RemoteTmuxControlMessage.swift in Sources */, 0B17C0DE0B17C0DE0B17C002 /* RemoteTmuxControlMessageDecoding.swift in Sources */, + AFEC676A8FB901E750BA5641 /* RemoteTmuxControlPipeWriter.swift in Sources */, E1B13CF7FE1A77324A142521 /* RemoteTmuxControlStreamParser.swift in Sources */, 2FAA10BC0D7B50DE57F507A4 /* RemoteTmuxError.swift in Sources */, 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */, + 00B223273DE0DBED98437C7A /* RemoteTmuxLayoutContainer.swift in Sources */, + B12E809B546C64DC359C54E3 /* RemoteTmuxLayoutContent.swift in Sources */, E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */, 7DA09B544027E3F73AA09518 /* RemoteTmuxManualIOWrite.swift in Sources */, + F861B8D7C62A0BCB252A523A /* RemoteTmuxMirrorTabActivity.swift in Sources */, + E9A8CC35D632F14A8669B7D1 /* RemoteTmuxPaneForegroundState.swift in Sources */, + 740FC5FDE4E13EAA440872C3 /* RemoteTmuxPaneHeader.swift in Sources */, + 761639EDDD6C40883902D781 /* RemoteTmuxPostAttachAction.swift in Sources */, + A4C6F928D7E14B2AA924B47C /* RemoteTmuxProcessCancellation.swift in Sources */, C9BA962E6CB31C9FD62505BF /* RemoteTmuxRawLayoutParser.swift in Sources */, A42DAD21BD502098E446999C /* RemoteTmuxSession.swift in Sources */, + 0A2A2FA17CD71DA5B4495DEE /* RemoteTmuxSessionEndAction.swift in Sources */, 008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */, 1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */, 6D3C19C2014FF9C754358EFF /* RemoteTmuxSSHTransport.swift in Sources */, @@ -3581,6 +3638,7 @@ EE30D5000000000000000004 /* WorkspaceRemoteRelayCommandRewriter.swift in Sources */, EE30D6000000000000000004 /* WorkspaceRemoteSessionBuildInfo.swift in Sources */, EE30D6000000000000000002 /* WorkspaceRemoteSessionHostAdapter.swift in Sources */, + 44FF9EFEEF762BE6CF158E42 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift in Sources */, 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */, 5659A0015659A0015659A001 /* WorkspaceSidebarObservation.swift in Sources */, D7AB34400000000000000001 /* WorkspaceSurfaceConfig.swift in Sources */, @@ -3810,6 +3868,7 @@ B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, D4F8A2E61C5B39707A8E6F12 /* RemoteTmuxMirrorSplitRoutingTests.swift in Sources */, 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */, + 9A5CF3AA2E6462E77144F9E6 /* RemoteTmuxSessionSnapshotTests.swift in Sources */, FA00C0DE0001BEEF0001CAFE /* RemoteTmuxWindowRegistryTests.swift in Sources */, C58410010000000000000001 /* RenderableSystemSymbolTests.swift in Sources */, D36A00050000000000000001 /* RendererRealizationPlannerTests.swift in Sources */, @@ -3887,6 +3946,7 @@ 6B524A0CB34FD46A771335AB /* WorkspaceSplitStartupCommandTests.swift in Sources */, F6120000A1B2C3D4E5F60718 /* WorkspaceSSHFishShellTests.swift in Sources */, FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, + 634AA6233A5BBF781FE83E89 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift in Sources */, 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/cmuxTests/RemoteTmuxSessionSnapshotTests.swift b/cmuxTests/RemoteTmuxSessionSnapshotTests.swift new file mode 100644 index 000000000000..9fe27b448cc2 --- /dev/null +++ b/cmuxTests/RemoteTmuxSessionSnapshotTests.swift @@ -0,0 +1,57 @@ +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct RemoteTmuxSessionSnapshotTests { + @Test func sessionSnapshotSkipsDedicatedRemoteTmuxWindowWithOnlyMirrorWorkspaces() throws { + let originalAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + AppDelegate.shared = appDelegate + let manager = TabManager(autoWelcomeIfNeeded: false) + let workspace = try #require(manager.selectedWorkspace) + workspace.isRemoteTmuxMirror = true + let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + let host = RemoteTmuxHost(destination: "user@example.test") + appDelegate.remoteTmuxController.bindDedicatedWindowForTesting(host: host, windowId: windowId) + defer { + appDelegate.remoteTmuxController.unbindDedicatedWindowForTesting(windowId: windowId) + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + AppDelegate.shared = originalAppDelegate + } + + #expect(appDelegate.sessionSnapshotForTesting() == nil) + } + + @Test func sessionSnapshotPreservesLocalWorkspaceInDedicatedRemoteTmuxWindow() throws { + let originalAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + AppDelegate.shared = appDelegate + let manager = TabManager(autoWelcomeIfNeeded: false) + let localWorkspace = try #require(manager.selectedWorkspace) + localWorkspace.setCustomTitle("Local") + let remoteWorkspace = manager.addWorkspace( + title: "remote", + select: true, + autoWelcomeIfNeeded: false + ) + remoteWorkspace.isRemoteTmuxMirror = true + let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + let host = RemoteTmuxHost(destination: "user@example.test") + appDelegate.remoteTmuxController.bindDedicatedWindowForTesting(host: host, windowId: windowId) + defer { + appDelegate.remoteTmuxController.unbindDedicatedWindowForTesting(windowId: windowId) + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + AppDelegate.shared = originalAppDelegate + } + + let snapshot = try #require(appDelegate.sessionSnapshotForTesting()) + #expect(snapshot.windows.count == 1) + #expect(snapshot.windows[0].tabManager.workspaces.map(\.workspaceId) == [localWorkspace.id]) + #expect(snapshot.windows[0].tabManager.selectedWorkspaceIndex == nil) + } +} diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index d59f23b2113c..921dd4ab0f0f 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -594,55 +594,6 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertTrue(snapshot.windows[0].tabManager.workspaces.isEmpty) } - @MainActor - func testSessionSnapshotSkipsDedicatedRemoteTmuxWindowWithOnlyMirrorWorkspaces() throws { - let originalAppDelegate = AppDelegate.shared - let appDelegate = AppDelegate() - AppDelegate.shared = appDelegate - let manager = TabManager(autoWelcomeIfNeeded: false) - let workspace = try XCTUnwrap(manager.selectedWorkspace) - workspace.isRemoteTmuxMirror = true - let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) - let host = RemoteTmuxHost(destination: "user@example.test") - appDelegate.remoteTmuxController.bindDedicatedWindowForTesting(host: host, windowId: windowId) - defer { - appDelegate.remoteTmuxController.unbindDedicatedWindowForTesting(windowId: windowId) - appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) - AppDelegate.shared = originalAppDelegate - } - - XCTAssertNil(appDelegate.sessionSnapshotForTesting()) - } - - @MainActor - func testSessionSnapshotPreservesLocalWorkspaceInDedicatedRemoteTmuxWindow() throws { - let originalAppDelegate = AppDelegate.shared - let appDelegate = AppDelegate() - AppDelegate.shared = appDelegate - let manager = TabManager(autoWelcomeIfNeeded: false) - let localWorkspace = try XCTUnwrap(manager.selectedWorkspace) - localWorkspace.setCustomTitle("Local") - let remoteWorkspace = manager.addWorkspace( - title: "remote", - select: true, - autoWelcomeIfNeeded: false - ) - remoteWorkspace.isRemoteTmuxMirror = true - let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) - let host = RemoteTmuxHost(destination: "user@example.test") - appDelegate.remoteTmuxController.bindDedicatedWindowForTesting(host: host, windowId: windowId) - defer { - appDelegate.remoteTmuxController.unbindDedicatedWindowForTesting(windowId: windowId) - appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) - AppDelegate.shared = originalAppDelegate - } - - let snapshot = try XCTUnwrap(appDelegate.sessionSnapshotForTesting()) - XCTAssertEqual(snapshot.windows.count, 1) - XCTAssertEqual(snapshot.windows[0].tabManager.workspaces.map(\.workspaceId), [localWorkspace.id]) - XCTAssertNil(snapshot.windows[0].tabManager.selectedWorkspaceIndex) - } - func testClosedWindowHistorySkipsWindowWithNoRestorableWorkspaces() throws { let originalAppDelegate = AppDelegate.shared let appDelegate = AppDelegate() diff --git a/cmuxTests/WorkspaceTerminalWorkingDirectoryFallbackTests.swift b/cmuxTests/WorkspaceTerminalWorkingDirectoryFallbackTests.swift new file mode 100644 index 000000000000..2ce4c0470685 --- /dev/null +++ b/cmuxTests/WorkspaceTerminalWorkingDirectoryFallbackTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct WorkspaceTerminalWorkingDirectoryFallbackTests { + @Test func newTerminalSurfaceFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() throws { + let workspace = Workspace() + let sourcePaneId = try #require( + workspace.bonsplitController.focusedPaneId, + "Expected focused pane in new workspace" + ) + + let staleCurrentDirectory = workspace.currentDirectory + let requestedDirectory = "/tmp/cmux-requested-tab-cwd-\(UUID().uuidString)" + let sourcePanel = try #require( + workspace.newTerminalSurface( + inPane: sourcePaneId, + focus: true, + workingDirectory: requestedDirectory + ), + "Expected source terminal panel to be created" + ) + + #expect(sourcePanel.requestedWorkingDirectory == requestedDirectory) + #expect( + workspace.panelDirectories[sourcePanel.id] == nil, + "Expected requested cwd to exist before shell integration reports a live cwd" + ) + #expect( + workspace.currentDirectory == staleCurrentDirectory, + "Expected focused workspace cwd to remain stale before panel directory updates" + ) + + let newTabPanel = try #require( + workspace.newTerminalSurfaceInFocusedPane(focus: false), + "Expected new terminal tab panel to be created" + ) + + #expect( + newTabPanel.requestedWorkingDirectory == requestedDirectory, + "Expected new terminal tab to inherit the selected source terminal's requested cwd when no reported cwd exists yet" + ) + } +} diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 5eb0b5fee743..dd2082d079eb 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -4537,47 +4537,6 @@ final class WorkspaceSplitWorkingDirectoryTests: XCTestCase { ) } - func testNewTerminalSurfaceFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { - let workspace = Workspace() - guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else { - XCTFail("Expected focused pane in new workspace") - return - } - - let staleCurrentDirectory = workspace.currentDirectory - let requestedDirectory = "/tmp/cmux-requested-tab-cwd-\(UUID().uuidString)" - guard let sourcePanel = workspace.newTerminalSurface( - inPane: sourcePaneId, - focus: true, - workingDirectory: requestedDirectory - ) else { - XCTFail("Expected source terminal panel to be created") - return - } - - XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory) - XCTAssertNil( - workspace.panelDirectories[sourcePanel.id], - "Expected requested cwd to exist before shell integration reports a live cwd" - ) - XCTAssertEqual( - workspace.currentDirectory, - staleCurrentDirectory, - "Expected focused workspace cwd to remain stale before panel directory updates" - ) - - guard let newTabPanel = workspace.newTerminalSurfaceInFocusedPane(focus: false) else { - XCTFail("Expected new terminal tab panel to be created") - return - } - - XCTAssertEqual( - newTabPanel.requestedWorkingDirectory, - requestedDirectory, - "Expected new terminal tab to inherit the selected source terminal's requested cwd when no reported cwd exists yet" - ) - } - func testNewTerminalSplitSkipsFreedInheritedSurfacePointer() throws { #if DEBUG let workspace = Workspace() From ede23973646d96e5a2392c7cbb9d1f6831cd2d97 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 18:11:21 -0700 Subject: [PATCH 55/73] Fix remote tmux reconnect auth and paste guarding PR: https://github.com/manaflow-ai/cmux/pull/5553 --- Sources/RemoteTmuxControlConnection.swift | 15 ++++++++++++--- Sources/RemoteTmuxHost.swift | 9 +++++---- cmuxTests/RemoteTmuxAuthTests.swift | 17 +++++++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 3dabf1de4cd5..8f2a95dcc763 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -884,10 +884,19 @@ final class RemoteTmuxControlConnection { /// there's no buffer-name collision. `text` must be a single line (callers route /// only single-line content — e.g. file/image paths — here). func pastePane(paneId: Int, text: String) -> Bool { - guard !text.isEmpty else { return false } + guard let commands = Self.pastePaneCommands(paneId: paneId, text: text) else { return false } + return send(commands.setBuffer) && send(commands.pasteBuffer) + } + + nonisolated static func pastePaneCommands(paneId: Int, text: String) + -> (setBuffer: String, pasteBuffer: String)? + { + guard !text.isEmpty else { return nil } let buffer = "cmux-paste-\(paneId)" - return send("set-buffer -b \(buffer) \(RemoteTmuxHost.shellSingleQuoted(text))") - && send("paste-buffer -p -d -b \(buffer) -t %\(paneId)") + return ( + setBuffer: "set-buffer -b \(buffer) -- \(RemoteTmuxHost.shellSingleQuoted(text))", + pasteBuffer: "paste-buffer -p -d -b \(buffer) -t %\(paneId)" + ) } /// Detaches: terminating ssh kills the control client but leaves the remote diff --git a/Sources/RemoteTmuxHost.swift b/Sources/RemoteTmuxHost.swift index 165e52e5967b..972dda576e1c 100644 --- a/Sources/RemoteTmuxHost.swift +++ b/Sources/RemoteTmuxHost.swift @@ -103,9 +103,10 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { /// /// - Parameter controlPersistSeconds: how long the master lingers idle /// after the last client detaches, so back-to-back commands stay fast. - /// - Parameter batchMode: when `true`, ssh never prompts interactively - /// (correct for non-interactive discovery/mutation commands; the - /// `tmux -CC` control client runs under a PTY and must NOT set this). + /// - Parameter batchMode: when `true`, ssh never prompts interactively. + /// Use this for discovery/mutation commands and for the pipe-backed local + /// `tmux -CC` control client; interactive prompts are handled only by + /// ``interactiveAuthInvocation()`` running in the user's terminal. func sshControlArguments(controlPersistSeconds: Int, batchMode: Bool) -> [String] { var args = [ "-o", "ControlMaster=auto", @@ -214,7 +215,7 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { var args = ["-tt"] args.append(contentsOf: sshControlArguments( controlPersistSeconds: controlPersistSeconds, - batchMode: false + batchMode: true )) let quotedName = Self.shellSingleQuoted(sessionName) let remoteCommand = createIfMissing diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index 82fcfc86d7a5..8a7a37cb463d 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -73,6 +73,13 @@ import Testing #expect(!args.contains("BatchMode=yes")) } + @Test func controlModeArgumentsAreNonInteractive() { + let host = RemoteTmuxHost(destination: "user@host") + let args = host.controlModeArguments(sessionName: "work", createIfMissing: false) + #expect(consecutive(args, "-o", "BatchMode=yes")) + #expect(!args.contains("BatchMode=no")) + } + @Test func controlArgsAppendPortAndIdentity() { let host = RemoteTmuxHost(destination: "user@host", port: 2222, identityFile: "/keys/id") let args = host.sshControlArguments(controlPersistSeconds: 180, batchMode: true) @@ -135,6 +142,16 @@ import Testing #expect(connection.pastePane(paneId: 1, text: "") == false) } + @Test func pastePaneCommandsProtectOptionLookingText() throws { + let commands = try #require(RemoteTmuxControlConnection.pastePaneCommands(paneId: 7, text: "-n not-an-option")) + #expect(commands.setBuffer == "set-buffer -b cmux-paste-7 -- '-n not-an-option'") + #expect(commands.pasteBuffer == "paste-buffer -p -d -b cmux-paste-7 -t %7") + } + + @Test func pastePaneCommandsRejectEmptyText() { + #expect(RemoteTmuxControlConnection.pastePaneCommands(paneId: 7, text: "") == nil) + } + // MARK: - Interactive auth invocation (what `cmux ssh-tmux` runs in the tty) @Test func interactiveAuthInvocationShape() { From c0a146f66f9b2ad904fffdd74699fae8e3439b8e Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 21:13:36 -0700 Subject: [PATCH 56/73] Bound remote tmux parser and folder lookups --- Sources/DetachedFolderDragIcon.swift | 91 ++++++++++++++++--- Sources/RemoteTmuxControlConnection.swift | 3 + Sources/RemoteTmuxControlMessage.swift | 4 + Sources/RemoteTmuxControlStreamParser.swift | 48 ++++++++-- cmux.xcodeproj/project.pbxproj | 8 ++ .../DetachedFolderPathLookupCacheTests.swift | 56 ++++++++++++ ...teTmuxControlStreamParserBudgetTests.swift | 27 ++++++ 7 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 cmuxTests/DetachedFolderPathLookupCacheTests.swift create mode 100644 cmuxTests/RemoteTmuxControlStreamParserBudgetTests.swift diff --git a/Sources/DetachedFolderDragIcon.swift b/Sources/DetachedFolderDragIcon.swift index e6e53d1c9bbe..e684fb704949 100644 --- a/Sources/DetachedFolderDragIcon.swift +++ b/Sources/DetachedFolderDragIcon.swift @@ -2,6 +2,69 @@ import AppKit import SwiftUI import UniformTypeIdentifiers +@MainActor +final class DetachedFolderPathLookupCache<Value> { + private let capacity: Int + private let maxPendingPaths: Int + private let maxCallbacksPerPath: Int + private var valuesByPath: [String: Value] = [:] + private var lruPaths: [String] = [] + private var pendingCallbacksByPath: [String: [(Value) -> Void]] = [:] + + init(capacity: Int = 256, maxPendingPaths: Int = 256, maxCallbacksPerPath: Int = 64) { + self.capacity = max(1, capacity) + self.maxPendingPaths = max(1, maxPendingPaths) + self.maxCallbacksPerPath = max(1, maxCallbacksPerPath) + } + + var pendingPathCount: Int { pendingCallbacksByPath.count } + + func pendingCallbackCount(forPath path: String) -> Int { + pendingCallbacksByPath[path]?.count ?? 0 + } + + func value(forPath path: String) -> Value? { + guard let value = valuesByPath[path] else { return nil } + touch(path) + return value + } + + /// Returns true only for the first queued callback for a path, which is the + /// caller's signal to start exactly one off-main lookup task. + func enqueueCallback(forPath path: String, callback: @escaping (Value) -> Void) -> Bool { + if var callbacks = pendingCallbacksByPath[path] { + if callbacks.count < maxCallbacksPerPath { + callbacks.append(callback) + pendingCallbacksByPath[path] = callbacks + } + return false + } + guard pendingCallbacksByPath.count < maxPendingPaths else { return false } + pendingCallbacksByPath[path] = [callback] + return true + } + + func resolve(path: String, value: Value) { + valuesByPath[path] = value + touch(path) + let callbacks = pendingCallbacksByPath.removeValue(forKey: path) ?? [] + for callback in callbacks { + callback(value) + } + } + + private func touch(_ path: String) { + if let existingIndex = lruPaths.firstIndex(of: path) { + lruPaths.remove(at: existingIndex) + } + lruPaths.append(path) + while lruPaths.count > capacity, let evicted = lruPaths.first { + lruPaths.removeFirst() + valuesByPath[evicted] = nil + } + } +} + struct DetachedFolderDragIcon: NSViewRepresentable { let directory: String @@ -73,21 +136,22 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { /// stats the path, and `directory` can be a REMOTE working directory /// (remote tmux) where that stat blocks on the autofs automounter for /// hundreds of ms — never pay it twice, and never pay it on the main thread. - private static var resolvedIconsByPath: [String: NSImage] = [:] + private static let iconLookups = DetachedFolderPathLookupCache<NSImage>() + private static let displayNameLookups = DetachedFolderPathLookupCache<String>() /// Returns the cached icon for `path`, or the generic folder icon /// (UTType-based — no filesystem access) while resolving the real one /// off-main. `onResolved` runs on the main thread once a fresh icon is /// fetched and cached; it is not called on a cache hit. private static func icon(forPath path: String, onResolved: @escaping (NSImage) -> Void) -> NSImage { - if let cached = resolvedIconsByPath[path] { return cached } - Task.detached(priority: .userInitiated) { - let icon = NSWorkspace.shared.icon(forFile: path) - await MainActor.run { - icon.size = NSSize(width: 16, height: 16) - if resolvedIconsByPath.count > 256 { resolvedIconsByPath.removeAll() } - resolvedIconsByPath[path] = icon - onResolved(icon) + if let cached = iconLookups.value(forPath: path) { return cached } + if iconLookups.enqueueCallback(forPath: path, callback: onResolved) { + Task.detached(priority: .userInitiated) { + let icon = NSWorkspace.shared.icon(forFile: path) + await MainActor.run { + icon.size = NSSize(width: 16, height: 16) + iconLookups.resolve(path: path, value: icon) + } } } let generic = NSWorkspace.shared.icon(for: .folder) @@ -99,10 +163,15 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { /// stats), then runs `onResolved` on the main thread — same shape as /// ``icon(forPath:onResolved:)``. private static func localizedDisplayName(forPath path: String, onResolved: @escaping (String) -> Void) { + if let cached = displayNameLookups.value(forPath: path) { + onResolved(cached) + return + } + guard displayNameLookups.enqueueCallback(forPath: path, callback: onResolved) else { return } Task.detached(priority: .userInitiated) { let localizedName = FileManager.default.displayName(atPath: path) await MainActor.run { - onResolved(localizedName) + displayNameLookups.resolve(path: path, value: localizedName) } } } @@ -197,7 +266,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { // Cosmetic drag image: use the already-resolved icon (or the generic // folder) rather than re-statting `directory` — see updateIcon(). - let iconImage = (Self.resolvedIconsByPath[directory] ?? NSWorkspace.shared.icon(for: .folder)).copy() as! NSImage + let iconImage = (Self.iconLookups.value(forPath: directory) ?? NSWorkspace.shared.icon(for: .folder)).copy() as! NSImage iconImage.size = NSSize(width: 32, height: 32) draggingItem.setDraggingFrame(bounds, contents: iconImage) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 8f2a95dcc763..c838db95a34e 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -1223,6 +1223,9 @@ final class RemoteTmuxControlConnection { } else { handleCommandResult(lines: lines, isError: isError) } + case let .streamError(reason): + record("stream-error \(reason)") + beginReconnecting() case .ignoredNotification, .unparsed: break } diff --git a/Sources/RemoteTmuxControlMessage.swift b/Sources/RemoteTmuxControlMessage.swift index 0cf46b96f6af..e388d7d30a3f 100644 --- a/Sources/RemoteTmuxControlMessage.swift +++ b/Sources/RemoteTmuxControlMessage.swift @@ -51,6 +51,10 @@ enum RemoteTmuxControlMessage: Sendable, Equatable { /// The coalesced output of one command block (`%begin`…`%end`/`%error`). case commandResult(commandNumber: Int, lines: [String], isError: Bool) + /// The control stream became unsafe to keep parsing, for example because an + /// unterminated line or command block exceeded the parser's memory budget. + case streamError(String) + /// A recognized notification cmux does not act on (kept for diagnostics). case ignoredNotification(String) diff --git a/Sources/RemoteTmuxControlStreamParser.swift b/Sources/RemoteTmuxControlStreamParser.swift index ee6e70624bdd..9baf1542cf13 100644 --- a/Sources/RemoteTmuxControlStreamParser.swift +++ b/Sources/RemoteTmuxControlStreamParser.swift @@ -15,10 +15,21 @@ import Foundation /// (see ``parseOutput(rawLine:)``) so those characters survive for ghostty to /// reassemble; a String round-trip would replace each split half with U+FFFD. struct RemoteTmuxControlStreamParser { + private let maxBufferedLineBytes: Int + private let maxCommandBlockBytes: Int private var buffer: [UInt8] = [] private var inBlock = false private var blockNumber = 0 private var blockLines: [String] = [] + private var blockBufferedBytes = 0 + + init( + maxBufferedLineBytes: Int = 1_048_576, + maxCommandBlockBytes: Int = 16_777_216 + ) { + self.maxBufferedLineBytes = max(1, maxBufferedLineBytes) + self.maxCommandBlockBytes = max(1, maxCommandBlockBytes) + } /// The DCS sequence tmux emits to enter control mode: `ESC P 1000 p`. private static let enterSequence: [UInt8] = [0x1b, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70] @@ -30,12 +41,22 @@ struct RemoteTmuxControlStreamParser { /// Feeds a chunk of stream bytes and returns any newly completed messages. mutating func feed(_ data: Data) -> [RemoteTmuxControlMessage] { var messages: [RemoteTmuxControlMessage] = [] - buffer.append(contentsOf: data) - while let newlineIndex = buffer.firstIndex(of: 0x0a) { - var lineBytes = Array(buffer[..<newlineIndex]) - buffer.removeSubrange(...newlineIndex) - if lineBytes.last == 0x0d { lineBytes.removeLast() } // strip pty CR - for message in parse(lineBytes: lineBytes) { messages.append(message) } + for byte in data { + if byte == 0x0a { + var lineBytes = buffer + buffer.removeAll(keepingCapacity: true) + if lineBytes.last == 0x0d { lineBytes.removeLast() } // strip pty CR + for message in parse(lineBytes: lineBytes) { + messages.append(message) + if case .streamError = message { return messages } + } + } else { + buffer.append(byte) + if buffer.count > maxBufferedLineBytes { + messages.append(streamError("line exceeded \(maxBufferedLineBytes) bytes")) + return messages + } + } } return messages } @@ -86,6 +107,7 @@ struct RemoteTmuxControlStreamParser { ) inBlock = false blockLines = [] + blockBufferedBytes = 0 return prefixMessages + [result] } // Block content is always tmux-formatted text — `capture-pane`/ @@ -93,6 +115,10 @@ struct RemoteTmuxControlStreamParser { // bytes split mid-character — so this String round-trip is lossless. // Only `%output` (handled above, from raw bytes) carries raw PTY bytes // that a String decode would corrupt. + if blockBufferedBytes + bytes.count + 1 > maxCommandBlockBytes { + return prefixMessages + [streamError("command block exceeded \(maxCommandBlockBytes) bytes")] + } + blockBufferedBytes += bytes.count + 1 blockLines.append(line) return prefixMessages } @@ -108,12 +134,22 @@ struct RemoteTmuxControlStreamParser { blockNumber = number inBlock = true blockLines = [] + blockBufferedBytes = 0 return prefixMessages } return prefixMessages + [parseNotification(line)] } + private mutating func streamError(_ reason: String) -> RemoteTmuxControlMessage { + buffer.removeAll(keepingCapacity: false) + inBlock = false + blockNumber = 0 + blockLines = [] + blockBufferedBytes = 0 + return .streamError(reason) + } + /// Parses an `%output %<pane> <octal-escaped data…>` line directly from its raw /// bytes, preserving the data's multi-byte UTF-8 exactly. Returns `nil` if the /// line is not a well-formed `%output` notification, so the caller falls back to diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6abd12189edd..f1e23a020264 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -305,6 +305,7 @@ DEBDADADADADADADAD000003 /* DebugDogfoodCredentialResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBDADADADADADADAD000004 /* DebugDogfoodCredentialResolverTests.swift */; }; A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */; }; A5001F000000000000000001 /* DetachedFolderDragIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001F000000000000000002 /* DetachedFolderDragIcon.swift */; }; + B0555303B0555303B0555303 /* DetachedFolderPathLookupCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0555304B0555304B0555304 /* DetachedFolderPathLookupCacheTests.swift */; }; DE71CE000000000000000004 /* DeviceRegistryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000003 /* DeviceRegistryClient.swift */; }; DE71CE000000000000000002 /* DeviceRegistryClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000001 /* DeviceRegistryClientTests.swift */; }; DE000A020000000000000001 /* DevWindowDisplayDebugWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE000A020000000000000002 /* DevWindowDisplayDebugWindow.swift */; }; @@ -542,6 +543,7 @@ B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */; }; AFEC676A8FB901E750BA5641 /* RemoteTmuxControlPipeWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9420A497CBC16357FF3F2C9A /* RemoteTmuxControlPipeWriter.swift */; }; E1B13CF7FE1A77324A142521 /* RemoteTmuxControlStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */; }; + B0555301B0555301B0555301 /* RemoteTmuxControlStreamParserBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0555302B0555302B0555302 /* RemoteTmuxControlStreamParserBudgetTests.swift */; }; 2FAA10BC0D7B50DE57F507A4 /* RemoteTmuxError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */; }; 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */; }; 00B223273DE0DBED98437C7A /* RemoteTmuxLayoutContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A1B00F615F44EA3BA234483 /* RemoteTmuxLayoutContainer.swift */; }; @@ -1183,6 +1185,7 @@ DEBDADADADADADADAD000004 /* DebugDogfoodCredentialResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugDogfoodCredentialResolverTests.swift; sourceTree = "<group>"; }; A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/DebugLogging.swift; sourceTree = "<group>"; }; A5001F000000000000000002 /* DetachedFolderDragIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetachedFolderDragIcon.swift; sourceTree = "<group>"; }; + B0555304B0555304B0555304 /* DetachedFolderPathLookupCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetachedFolderPathLookupCacheTests.swift; sourceTree = "<group>"; }; DE71CE000000000000000003 /* DeviceRegistryClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceRegistryClient.swift; sourceTree = "<group>"; }; DE71CE000000000000000001 /* DeviceRegistryClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRegistryClientTests.swift; sourceTree = "<group>"; }; DE000A020000000000000002 /* DevWindowDisplayDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevWindowDisplayDebugWindow.swift; sourceTree = "<group>"; }; @@ -1415,6 +1418,7 @@ C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlParserTests.swift; sourceTree = "<group>"; }; 9420A497CBC16357FF3F2C9A /* RemoteTmuxControlPipeWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlPipeWriter.swift; sourceTree = "<group>"; }; FD5030BD9629431ACE9A4A74 /* RemoteTmuxControlStreamParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlStreamParser.swift; sourceTree = "<group>"; }; + B0555302B0555302B0555302 /* RemoteTmuxControlStreamParserBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlStreamParserBudgetTests.swift; sourceTree = "<group>"; }; AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxError.swift; sourceTree = "<group>"; }; 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxHost.swift; sourceTree = "<group>"; }; 3A1B00F615F44EA3BA234483 /* RemoteTmuxLayoutContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutContainer.swift; sourceTree = "<group>"; }; @@ -2642,6 +2646,8 @@ 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, C0793DC7D7B61CF54886EC36 /* RemoteTmuxControlParserTests.swift */, + B0555302B0555302B0555302 /* RemoteTmuxControlStreamParserBudgetTests.swift */, + B0555304B0555304B0555304 /* DetachedFolderPathLookupCacheTests.swift */, A7E1C4D2B3F09865E217D4C0 /* RemoteTmuxMirrorSplitRoutingTests.swift */, FA00C0DE0002BEEF0002CAFE /* RemoteTmuxWindowRegistryTests.swift */, 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */, @@ -3805,6 +3811,7 @@ C0DEF4120000000000000001 /* CommandPaletteSettingsToggleTests.swift in Sources */, C1713006C1713006C1713006 /* CommandPaletteShortcutCustomizationTests.swift in Sources */, DEBDADADADADADADAD000003 /* DebugDogfoodCredentialResolverTests.swift in Sources */, + B0555303B0555303B0555303 /* DetachedFolderPathLookupCacheTests.swift in Sources */, DE71CE000000000000000002 /* DeviceRegistryClientTests.swift in Sources */, D1FFC0DE000000000000C002 /* DiffCommentStoreTests.swift in Sources */, D3622100A1B2C3D4E5F60718 /* EditableTextViewArrowKeyForwardingTests.swift in Sources */, @@ -3866,6 +3873,7 @@ C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */, 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */, B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, + B0555301B0555301B0555301 /* RemoteTmuxControlStreamParserBudgetTests.swift in Sources */, D4F8A2E61C5B39707A8E6F12 /* RemoteTmuxMirrorSplitRoutingTests.swift in Sources */, 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */, 9A5CF3AA2E6462E77144F9E6 /* RemoteTmuxSessionSnapshotTests.swift in Sources */, diff --git a/cmuxTests/DetachedFolderPathLookupCacheTests.swift b/cmuxTests/DetachedFolderPathLookupCacheTests.swift new file mode 100644 index 000000000000..09df0ec754bd --- /dev/null +++ b/cmuxTests/DetachedFolderPathLookupCacheTests.swift @@ -0,0 +1,56 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class DetachedFolderPathLookupCacheTests: XCTestCase { + func testCoalescesDuplicatePendingPathLookups() { + let cache = DetachedFolderPathLookupCache<Int>(capacity: 4, maxPendingPaths: 4, maxCallbacksPerPath: 4) + var resolvedValues: [Int] = [] + + XCTAssertTrue(cache.enqueueCallback(forPath: "/remote/project") { resolvedValues.append($0) }) + XCTAssertFalse(cache.enqueueCallback(forPath: "/remote/project") { resolvedValues.append($0 * 10) }) + XCTAssertEqual(cache.pendingPathCount, 1) + XCTAssertEqual(cache.pendingCallbackCount(forPath: "/remote/project"), 2) + + cache.resolve(path: "/remote/project", value: 3) + + XCTAssertEqual(resolvedValues, [3, 30]) + XCTAssertEqual(cache.value(forPath: "/remote/project"), 3) + XCTAssertEqual(cache.pendingPathCount, 0) + } + + func testPendingQueuesAreBounded() { + let cache = DetachedFolderPathLookupCache<Int>(capacity: 4, maxPendingPaths: 1, maxCallbacksPerPath: 1) + var resolvedValues: [Int] = [] + + XCTAssertTrue(cache.enqueueCallback(forPath: "/remote/a") { resolvedValues.append($0) }) + XCTAssertFalse(cache.enqueueCallback(forPath: "/remote/a") { resolvedValues.append($0 * 10) }) + XCTAssertFalse(cache.enqueueCallback(forPath: "/remote/b") { resolvedValues.append($0 * 100) }) + XCTAssertEqual(cache.pendingPathCount, 1) + XCTAssertEqual(cache.pendingCallbackCount(forPath: "/remote/a"), 1) + + cache.resolve(path: "/remote/a", value: 7) + + XCTAssertEqual(resolvedValues, [7]) + XCTAssertNil(cache.value(forPath: "/remote/b")) + } + + func testResolvedValuesEvictLeastRecentlyUsedPath() { + let cache = DetachedFolderPathLookupCache<Int>(capacity: 2) + + cache.resolve(path: "/remote/a", value: 1) + cache.resolve(path: "/remote/b", value: 2) + XCTAssertEqual(cache.value(forPath: "/remote/a"), 1) + + cache.resolve(path: "/remote/c", value: 3) + + XCTAssertEqual(cache.value(forPath: "/remote/a"), 1) + XCTAssertNil(cache.value(forPath: "/remote/b")) + XCTAssertEqual(cache.value(forPath: "/remote/c"), 3) + } +} diff --git a/cmuxTests/RemoteTmuxControlStreamParserBudgetTests.swift b/cmuxTests/RemoteTmuxControlStreamParserBudgetTests.swift new file mode 100644 index 000000000000..bf429e17b651 --- /dev/null +++ b/cmuxTests/RemoteTmuxControlStreamParserBudgetTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct RemoteTmuxControlStreamParserBudgetTests { + @Test func pendingLineOverflowEmitsStreamErrorAndResetsParser() { + var parser = RemoteTmuxControlStreamParser(maxBufferedLineBytes: 8, maxCommandBlockBytes: 1024) + + let overflow = parser.feed(Data("abcdefghi".utf8)) + #expect(overflow == [.streamError("line exceeded 8 bytes")]) + #expect(parser.feed(Data("%exit\r\n".utf8)) == [.exit(reason: nil)]) + } + + @Test func commandBlockOverflowEmitsStreamErrorAndResetsParser() { + var parser = RemoteTmuxControlStreamParser(maxBufferedLineBytes: 128, maxCommandBlockBytes: 10) + + #expect(parser.feed(Data("%begin 1700000000 7 1\r\n".utf8)).isEmpty) + let overflow = parser.feed(Data("123456\r\nabcdef\r\n".utf8)) + #expect(overflow == [.streamError("command block exceeded 10 bytes")]) + #expect(parser.feed(Data("%window-add @5\r\n".utf8)) == [.windowAdd(windowId: 5)]) + } +} From 4edc81a0f0d5c325949bcc5c8c31dbfea95de4e4 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 21:31:05 -0700 Subject: [PATCH 57/73] Reject remote tmux split print before routing --- CLI/cmux.swift | 8 +- .../ControlCommandCoordinator+Surface.swift | 2 +- .../Surface/ControlSurfaceSplitInputs.swift | 4 + Sources/DetachedFolderDragIcon.swift | 63 ---- Sources/DetachedFolderPathLookupCache.swift | 62 ++++ ...nalController+ControlSurfaceContext2.swift | 1 + cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 273 ++++++++++++++++++ .../DetachedFolderPathLookupCacheTests.swift | 46 +-- 9 files changed, 375 insertions(+), 92 deletions(-) create mode 100644 Sources/DetachedFolderPathLookupCache.swift create mode 100644 cmuxTests/CLITmuxCompatRemoteSplitTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1a2de8996bbb..1d5bc70b09b8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -21200,6 +21200,7 @@ struct CMUXCLI { if let startCommand = tmuxStartCommand(commandTokens: parsed.positional) { splitParams["tmux_start_command"] = startCommand } + if parsed.hasFlag("-P") { splitParams["remote_tmux_unsupported_options"] = ["-P"] } let sizeTargetPaneId = target.paneId ?? (try? tmuxResolvePaneTarget(parsed.value("-t"), client: client).paneId) ?? (try? tmuxPaneIdForSurface( @@ -21227,11 +21228,8 @@ struct CMUXCLI { // means a plain split: succeed quietly (tmux split-window // prints nothing without -P) and skip local layout tracking. // Erroring here would invite retries that duplicate the - // already-created remote pane. -P is the one thing we cannot - // honor after the fact — there is no local surface id yet. - if parsed.hasFlag("-P") { - throw CLIError(message: "split-window -P is not supported in a remote tmux mirror workspace (the split was already applied to the remote tmux session; the new pane id is not yet known)") - } + // already-created remote pane. Current apps reject -P before + // routing; if an older app still accepts it, the split already happened. break } guard let surfaceId = created["surface_id"] as? String else { diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift index b36ba18de593..1969efa875ba 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift @@ -9,7 +9,6 @@ internal import Foundation /// This file carries the dispatch plus the read/lifecycle methods; the remaining /// methods live in `+Surface2.swift` / `+Surface3.swift` (500-line budget). extension ControlCommandCoordinator { - /// Runs one decoded request if it belongs to the surface domain, returning the /// typed result; returns `nil` otherwise so the caller can fall through. The /// integrator calls this from the core `handle`. @@ -249,6 +248,7 @@ extension ControlCommandCoordinator { tmuxStartCommand: optionalTrimmedRawString(params, "tmux_start_command"), remotePTYSessionID: optionalTrimmedRawString(params, "remote_pty_session_id"), startupEnvironment: trimmedStringMap(params, keys: ["startup_environment", "initial_env"]), + clientUnsupportedRemoteTmuxOptions: stringArray(params, "remote_tmux_unsupported_options") ?? [], requestedFocus: bool(params, "focus") ?? false, initialDividerPosition: parsedDivider.value ) diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitInputs.swift index d9c0e2763070..62d3771eeb40 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitInputs.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitInputs.swift @@ -27,6 +27,8 @@ public struct ControlSurfaceSplitInputs: Sendable, Equatable { public let remotePTYSessionID: String? /// The startup environment (`startup_environment`/`initial_env`), `[:]` if none. public let startupEnvironment: [String: String] + /// Options the caller already knows a routed remote tmux split cannot honor. + public let clientUnsupportedRemoteTmuxOptions: [String] /// Whether the request asked to focus the new split. public let requestedFocus: Bool /// The clamped `[0.1, 0.9]` initial divider position, or `nil`. @@ -43,6 +45,7 @@ public struct ControlSurfaceSplitInputs: Sendable, Equatable { tmuxStartCommand: String?, remotePTYSessionID: String?, startupEnvironment: [String: String], + clientUnsupportedRemoteTmuxOptions: [String], requestedFocus: Bool, initialDividerPosition: Double? ) { @@ -55,6 +58,7 @@ public struct ControlSurfaceSplitInputs: Sendable, Equatable { self.tmuxStartCommand = tmuxStartCommand self.remotePTYSessionID = remotePTYSessionID self.startupEnvironment = startupEnvironment + self.clientUnsupportedRemoteTmuxOptions = clientUnsupportedRemoteTmuxOptions self.requestedFocus = requestedFocus self.initialDividerPosition = initialDividerPosition } diff --git a/Sources/DetachedFolderDragIcon.swift b/Sources/DetachedFolderDragIcon.swift index e684fb704949..a5857b085ec5 100644 --- a/Sources/DetachedFolderDragIcon.swift +++ b/Sources/DetachedFolderDragIcon.swift @@ -2,69 +2,6 @@ import AppKit import SwiftUI import UniformTypeIdentifiers -@MainActor -final class DetachedFolderPathLookupCache<Value> { - private let capacity: Int - private let maxPendingPaths: Int - private let maxCallbacksPerPath: Int - private var valuesByPath: [String: Value] = [:] - private var lruPaths: [String] = [] - private var pendingCallbacksByPath: [String: [(Value) -> Void]] = [:] - - init(capacity: Int = 256, maxPendingPaths: Int = 256, maxCallbacksPerPath: Int = 64) { - self.capacity = max(1, capacity) - self.maxPendingPaths = max(1, maxPendingPaths) - self.maxCallbacksPerPath = max(1, maxCallbacksPerPath) - } - - var pendingPathCount: Int { pendingCallbacksByPath.count } - - func pendingCallbackCount(forPath path: String) -> Int { - pendingCallbacksByPath[path]?.count ?? 0 - } - - func value(forPath path: String) -> Value? { - guard let value = valuesByPath[path] else { return nil } - touch(path) - return value - } - - /// Returns true only for the first queued callback for a path, which is the - /// caller's signal to start exactly one off-main lookup task. - func enqueueCallback(forPath path: String, callback: @escaping (Value) -> Void) -> Bool { - if var callbacks = pendingCallbacksByPath[path] { - if callbacks.count < maxCallbacksPerPath { - callbacks.append(callback) - pendingCallbacksByPath[path] = callbacks - } - return false - } - guard pendingCallbacksByPath.count < maxPendingPaths else { return false } - pendingCallbacksByPath[path] = [callback] - return true - } - - func resolve(path: String, value: Value) { - valuesByPath[path] = value - touch(path) - let callbacks = pendingCallbacksByPath.removeValue(forKey: path) ?? [] - for callback in callbacks { - callback(value) - } - } - - private func touch(_ path: String) { - if let existingIndex = lruPaths.firstIndex(of: path) { - lruPaths.remove(at: existingIndex) - } - lruPaths.append(path) - while lruPaths.count > capacity, let evicted = lruPaths.first { - lruPaths.removeFirst() - valuesByPath[evicted] = nil - } - } -} - struct DetachedFolderDragIcon: NSViewRepresentable { let directory: String diff --git a/Sources/DetachedFolderPathLookupCache.swift b/Sources/DetachedFolderPathLookupCache.swift new file mode 100644 index 000000000000..02ec41a183b1 --- /dev/null +++ b/Sources/DetachedFolderPathLookupCache.swift @@ -0,0 +1,62 @@ +@MainActor +final class DetachedFolderPathLookupCache<Value> { + private let capacity: Int + private let maxPendingPaths: Int + private let maxCallbacksPerPath: Int + private var valuesByPath: [String: Value] = [:] + private var lruPaths: [String] = [] + private var pendingCallbacksByPath: [String: [(Value) -> Void]] = [:] + + init(capacity: Int = 256, maxPendingPaths: Int = 256, maxCallbacksPerPath: Int = 64) { + self.capacity = max(1, capacity) + self.maxPendingPaths = max(1, maxPendingPaths) + self.maxCallbacksPerPath = max(1, maxCallbacksPerPath) + } + + var pendingPathCount: Int { pendingCallbacksByPath.count } + + func pendingCallbackCount(forPath path: String) -> Int { + pendingCallbacksByPath[path]?.count ?? 0 + } + + func value(forPath path: String) -> Value? { + guard let value = valuesByPath[path] else { return nil } + touch(path) + return value + } + + /// Returns true only for the first queued callback for a path, which is the + /// caller's signal to start exactly one off-main lookup task. + func enqueueCallback(forPath path: String, callback: @escaping (Value) -> Void) -> Bool { + if var callbacks = pendingCallbacksByPath[path] { + if callbacks.count < maxCallbacksPerPath { + callbacks.append(callback) + pendingCallbacksByPath[path] = callbacks + } + return false + } + guard pendingCallbacksByPath.count < maxPendingPaths else { return false } + pendingCallbacksByPath[path] = [callback] + return true + } + + func resolve(path: String, value: Value) { + valuesByPath[path] = value + touch(path) + let callbacks = pendingCallbacksByPath.removeValue(forKey: path) ?? [] + for callback in callbacks { + callback(value) + } + } + + private func touch(_ path: String) { + if let existingIndex = lruPaths.firstIndex(of: path) { + lruPaths.remove(at: existingIndex) + } + lruPaths.append(path) + while lruPaths.count > capacity, let evicted = lruPaths.first { + lruPaths.removeFirst() + valuesByPath[evicted] = nil + } + } +} diff --git a/Sources/TerminalController+ControlSurfaceContext2.swift b/Sources/TerminalController+ControlSurfaceContext2.swift index 20419ea188ba..8818c09a7586 100644 --- a/Sources/TerminalController+ControlSurfaceContext2.swift +++ b/Sources/TerminalController+ControlSurfaceContext2.swift @@ -143,6 +143,7 @@ extension TerminalController { initialDividerPosition: inputs.initialDividerPosition, remotePTYSessionID: inputs.remotePTYSessionID ) + + inputs.clientUnsupportedRemoteTmuxOptions if !unsupported.isEmpty { return .mirrorUnsupportedOptions(unsupported) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f1e23a020264..06210339a820 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -132,6 +132,7 @@ C0F16B000000000000000001 /* CLIRemoteShellStartupPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */; }; A5D41209A1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */; }; B900004AA1B2C3D4E5F60719 /* CLISocketPathResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */; }; + B05553B10000000000000001 /* CLITmuxCompatRemoteSplitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */; }; C10D51700000000000000002 /* ClosedItemHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D51700000000000000001 /* ClosedItemHistory.swift */; }; B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; @@ -305,6 +306,7 @@ DEBDADADADADADADAD000003 /* DebugDogfoodCredentialResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBDADADADADADADAD000004 /* DebugDogfoodCredentialResolverTests.swift */; }; A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */; }; A5001F000000000000000001 /* DetachedFolderDragIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001F000000000000000002 /* DetachedFolderDragIcon.swift */; }; + B05553C10000000000000001 /* DetachedFolderPathLookupCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B05553C10000000000000002 /* DetachedFolderPathLookupCache.swift */; }; B0555303B0555303B0555303 /* DetachedFolderPathLookupCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0555304B0555304B0555304 /* DetachedFolderPathLookupCacheTests.swift */; }; DE71CE000000000000000004 /* DeviceRegistryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000003 /* DeviceRegistryClient.swift */; }; DE71CE000000000000000002 /* DeviceRegistryClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000001 /* DeviceRegistryClientTests.swift */; }; @@ -1064,6 +1066,7 @@ C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIRemoteShellStartupPerformanceTests.swift; sourceTree = "<group>"; }; A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIRovoDevHookPersistenceTests.swift; sourceTree = "<group>"; }; B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLISocketPathResolver.swift; sourceTree = "<group>"; }; + B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatRemoteSplitTests.swift; sourceTree = "<group>"; }; C10D51700000000000000001 /* ClosedItemHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedItemHistory.swift; sourceTree = "<group>"; }; B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = "<group>"; }; @@ -1185,6 +1188,7 @@ DEBDADADADADADADAD000004 /* DebugDogfoodCredentialResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugDogfoodCredentialResolverTests.swift; sourceTree = "<group>"; }; A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/DebugLogging.swift; sourceTree = "<group>"; }; A5001F000000000000000002 /* DetachedFolderDragIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetachedFolderDragIcon.swift; sourceTree = "<group>"; }; + B05553C10000000000000002 /* DetachedFolderPathLookupCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetachedFolderPathLookupCache.swift; sourceTree = "<group>"; }; B0555304B0555304B0555304 /* DetachedFolderPathLookupCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetachedFolderPathLookupCacheTests.swift; sourceTree = "<group>"; }; DE71CE000000000000000003 /* DeviceRegistryClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceRegistryClient.swift; sourceTree = "<group>"; }; DE71CE000000000000000001 /* DeviceRegistryClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRegistryClientTests.swift; sourceTree = "<group>"; }; @@ -2004,6 +2008,7 @@ E4C001000000000000000004 /* ExtensionWorktreePrototype.swift */, C0DE36230000000000000002 /* WorkspaceFinderDirectoryResolver.swift */, A5001F000000000000000002 /* DetachedFolderDragIcon.swift */, + B05553C10000000000000002 /* DetachedFolderPathLookupCache.swift */, D0B1001BA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift */, D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */, D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */, @@ -2691,6 +2696,7 @@ A5D41204A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift */, C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */, A5D41206A1B2C3D4E5F60718 /* CLINotifyProcessTestSupport.swift */, + B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */, A5D41208A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift */, A5D41212A1B2C3D4E5F60718 /* CLIHookNoResponseTests.swift */, A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */, @@ -3282,6 +3288,7 @@ DEBDADADADADADADAD000001 /* DebugDogfoodCredentialResolver.swift in Sources */, A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */, A5001F000000000000000001 /* DetachedFolderDragIcon.swift in Sources */, + B05553C10000000000000001 /* DetachedFolderPathLookupCache.swift in Sources */, DE71CE000000000000000004 /* DeviceRegistryClient.swift in Sources */, DE000A020000000000000001 /* DevWindowDisplayDebugWindow.swift in Sources */, DE000A010000000000000001 /* DevWindowDisplayDefault.swift in Sources */, @@ -3788,6 +3795,7 @@ A5D41205A1B2C3D4E5F60718 /* CLINotifyProcessTestSupport.swift in Sources */, C0F16B000000000000000001 /* CLIRemoteShellStartupPerformanceTests.swift in Sources */, A5D41209A1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift in Sources */, + B05553B10000000000000001 /* CLITmuxCompatRemoteSplitTests.swift in Sources */, C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */, C0DEF0A40000000000000001 /* CmuxConfigContextMenuTests.swift in Sources */, E30750000000000000000006 /* CmuxConfigNamedColorTests.swift in Sources */, diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift new file mode 100644 index 000000000000..0d29bc711c38 --- /dev/null +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -0,0 +1,273 @@ +import Darwin +import Foundation +import Testing + +@Suite(.serialized) struct CLITmuxCompatRemoteSplitTests { + @Test func splitPrintCarriesPreMutationRemoteRejection() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath(for: CLITmuxCompatRemoteSplitBundleToken.self) + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-tmux-compat-print-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let socketPath = Self.makeSocketPath("tmuxprint") + let listenerFD = try Self.bindUnixSocket(at: socketPath) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let state = ServerState() + let handled = Self.startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = Self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return Self.malformedRequestResponse(raw: line) + } + switch method { + case "surface.list": + return Self.v2Response(id: id, ok: true, result: [ + "surfaces": [["id": surfaceId, "ref": "surface:1", "index": 0, "focused": true]], + ]) + case "surface.split": + let params = payload["params"] as? [String: Any] ?? [:] + state.expect(params["workspace_id"] as? String == workspaceId, "workspace_id did not match") + state.expect(params["surface_id"] as? String == surfaceId, "surface_id did not match") + state.expect( + params["remote_tmux_unsupported_options"] as? [String] == ["-P"], + "surface.split did not carry remote_tmux_unsupported_options=[\"-P\"]" + ) + return Self.v2Response(id: id, ok: false, error: [ + "code": "invalid_params", + "message": "Not supported when targeting a remote tmux mirror workspace (the request is routed to tmux and these options cannot be applied): -P", + ]) + default: + return Self.v2Response(id: id, ok: false, error: ["code": "unsupported", "message": method]) + } + } + + let result = Self.runProcess( + executablePath: cliPath, + arguments: ["__tmux-compat", "split-window", "-P"], + environment: [ + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "HOME": tmpDir.path, + "PATH": ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + timeout: 30 + ) + #expect(handled.wait(timeout: .now() + 30) == .success) + + #expect(state.errorSnapshot() == []) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status != 0) + #expect( + result.stderr.contains("Not supported when targeting a remote tmux mirror workspace"), + Comment(rawValue: result.stderr) + ) + #expect(!result.stderr.contains("already applied"), Comment(rawValue: result.stderr)) + } + + private final class CLITmuxCompatRemoteSplitBundleToken {} + + private final class ServerState: @unchecked Sendable { + private let lock = NSLock() + private var errors: [String] = [] + + func expect(_ condition: Bool, _ message: String) { + guard !condition else { return } + lock.lock() + errors.append(message) + lock.unlock() + } + + func errorSnapshot() -> [String] { + lock.lock() + let value = errors + lock.unlock() + return value + } + } + + private struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + private static func makeSocketPath(_ name: String) -> String { + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cli-\(name.prefix(6))-\(shortID).sock") + .path + } + + private static func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + let utf8 = Array(path.utf8) + guard utf8.count < maxPathLength else { + Darwin.close(fd) + throw NSError(domain: "cmux.tests", code: Int(ENAMETOOLONG), userInfo: [ + NSLocalizedDescriptionKey: "Unix socket path is too long: \(path)", + ]) + } + withUnsafeMutablePointer(to: &addr.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in + for index in 0..<utf8.count { + buffer[index] = CChar(bitPattern: utf8[index]) + } + buffer[utf8.count] = 0 + } + } + + let bindResult = withUnsafePointer(to: &addr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) + } + } + guard bindResult == 0 else { + Darwin.close(fd) + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard Darwin.listen(fd, 1) == 0 else { + Darwin.close(fd) + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + return fd + } + + private static func startMockServer( + listenerFD: Int32, + state: ServerState, + handler: @escaping @Sendable (String) -> String + ) -> DispatchSemaphore { + let handled = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + defer { handled.signal() } + + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + state.expect(false, "mock socket server failed to accept a client") + return + } + defer { Darwin.close(clientFD) } + + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + state.expect(false, "mock socket server read failed with errno \(errno)") + return + } + if count == 0 { return } + pending.append(buffer, count: count) + + while let newlineRange = pending.firstRange(of: Data([0x0A])) { + let lineData = pending.subdata(in: 0..<newlineRange.lowerBound) + pending.removeSubrange(0...newlineRange.lowerBound) + guard let line = String(data: lineData, encoding: .utf8) else { continue } + let response = handler(line) + "\n" + _ = response.withCString { ptr in + Darwin.write(clientFD, ptr, strlen(ptr)) + } + } + } + } + return handled + } + + private static func v2Response( + id: String, + ok: Bool, + result: [String: Any]? = nil, + error: [String: Any]? = nil + ) -> String { + var payload: [String: Any] = ["id": id, "ok": ok] + if let result { payload["result"] = result } + if let error { payload["error"] = error } + let data = try? JSONSerialization.data(withJSONObject: payload, options: []) + return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" + } + + private static func malformedRequestResponse(id: String? = nil, raw: String) -> String { + v2Response( + id: id ?? "unknown", + ok: false, + error: ["code": "malformed_request", "message": "invalid or non-JSON payload", "raw": raw] + ) + } + + private static func jsonObject(_ line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + private static func runProcess( + executablePath: String, + arguments: [String], + environment: [String: String], + timeout: TimeInterval + ) -> ProcessRunResult { + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + return ProcessRunResult(status: -1, stdout: "", stderr: String(describing: error), timedOut: false) + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + if exitSignal.wait(timeout: .now() + 1) == .timedOut { + kill(process.processIdentifier, SIGKILL) + _ = exitSignal.wait(timeout: .now() + 1) + } + } + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return ProcessRunResult( + status: process.isRunning ? SIGKILL : process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } +} diff --git a/cmuxTests/DetachedFolderPathLookupCacheTests.swift b/cmuxTests/DetachedFolderPathLookupCacheTests.swift index 09df0ec754bd..34d3b1c21db7 100644 --- a/cmuxTests/DetachedFolderPathLookupCacheTests.swift +++ b/cmuxTests/DetachedFolderPathLookupCacheTests.swift @@ -1,4 +1,4 @@ -import XCTest +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -7,50 +7,50 @@ import XCTest #endif @MainActor -final class DetachedFolderPathLookupCacheTests: XCTestCase { - func testCoalescesDuplicatePendingPathLookups() { +@Suite(.serialized) struct DetachedFolderPathLookupCacheTests { + @Test func coalescesDuplicatePendingPathLookups() { let cache = DetachedFolderPathLookupCache<Int>(capacity: 4, maxPendingPaths: 4, maxCallbacksPerPath: 4) var resolvedValues: [Int] = [] - XCTAssertTrue(cache.enqueueCallback(forPath: "/remote/project") { resolvedValues.append($0) }) - XCTAssertFalse(cache.enqueueCallback(forPath: "/remote/project") { resolvedValues.append($0 * 10) }) - XCTAssertEqual(cache.pendingPathCount, 1) - XCTAssertEqual(cache.pendingCallbackCount(forPath: "/remote/project"), 2) + #expect(cache.enqueueCallback(forPath: "/remote/project") { resolvedValues.append($0) }) + #expect(!cache.enqueueCallback(forPath: "/remote/project") { resolvedValues.append($0 * 10) }) + #expect(cache.pendingPathCount == 1) + #expect(cache.pendingCallbackCount(forPath: "/remote/project") == 2) cache.resolve(path: "/remote/project", value: 3) - XCTAssertEqual(resolvedValues, [3, 30]) - XCTAssertEqual(cache.value(forPath: "/remote/project"), 3) - XCTAssertEqual(cache.pendingPathCount, 0) + #expect(resolvedValues == [3, 30]) + #expect(cache.value(forPath: "/remote/project") == 3) + #expect(cache.pendingPathCount == 0) } - func testPendingQueuesAreBounded() { + @Test func pendingQueuesAreBounded() { let cache = DetachedFolderPathLookupCache<Int>(capacity: 4, maxPendingPaths: 1, maxCallbacksPerPath: 1) var resolvedValues: [Int] = [] - XCTAssertTrue(cache.enqueueCallback(forPath: "/remote/a") { resolvedValues.append($0) }) - XCTAssertFalse(cache.enqueueCallback(forPath: "/remote/a") { resolvedValues.append($0 * 10) }) - XCTAssertFalse(cache.enqueueCallback(forPath: "/remote/b") { resolvedValues.append($0 * 100) }) - XCTAssertEqual(cache.pendingPathCount, 1) - XCTAssertEqual(cache.pendingCallbackCount(forPath: "/remote/a"), 1) + #expect(cache.enqueueCallback(forPath: "/remote/a") { resolvedValues.append($0) }) + #expect(!cache.enqueueCallback(forPath: "/remote/a") { resolvedValues.append($0 * 10) }) + #expect(!cache.enqueueCallback(forPath: "/remote/b") { resolvedValues.append($0 * 100) }) + #expect(cache.pendingPathCount == 1) + #expect(cache.pendingCallbackCount(forPath: "/remote/a") == 1) cache.resolve(path: "/remote/a", value: 7) - XCTAssertEqual(resolvedValues, [7]) - XCTAssertNil(cache.value(forPath: "/remote/b")) + #expect(resolvedValues == [7]) + #expect(cache.value(forPath: "/remote/b") == nil) } - func testResolvedValuesEvictLeastRecentlyUsedPath() { + @Test func resolvedValuesEvictLeastRecentlyUsedPath() { let cache = DetachedFolderPathLookupCache<Int>(capacity: 2) cache.resolve(path: "/remote/a", value: 1) cache.resolve(path: "/remote/b", value: 2) - XCTAssertEqual(cache.value(forPath: "/remote/a"), 1) + #expect(cache.value(forPath: "/remote/a") == 1) cache.resolve(path: "/remote/c", value: 3) - XCTAssertEqual(cache.value(forPath: "/remote/a"), 1) - XCTAssertNil(cache.value(forPath: "/remote/b")) - XCTAssertEqual(cache.value(forPath: "/remote/c"), 3) + #expect(cache.value(forPath: "/remote/a") == 1) + #expect(cache.value(forPath: "/remote/b") == nil) + #expect(cache.value(forPath: "/remote/c") == 3) } } From 500d77e72cc0b7e0f6a848db92f0a6e34752f240 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 21:40:15 -0700 Subject: [PATCH 58/73] Veto remote tmux batch close on route failure --- Sources/Workspace.swift | 4 +--- Sources/WorkspaceCloseTabsBatching.swift | 17 +++++++++-------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ee132c317139..66edcb50d935 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4134,9 +4134,7 @@ final class Workspace: Identifiable, ObservableObject { } } - private typealias RemoteTmuxNonInteractiveCloseRoute = WorkspaceRemoteTmuxNonInteractiveCloseRoute - - private func routeRemoteTmuxNonInteractiveTabCloseIfNeeded(_ tabId: TabID) -> RemoteTmuxNonInteractiveCloseRoute { + func routeRemoteTmuxNonInteractiveTabCloseIfNeeded(_ tabId: TabID) -> WorkspaceRemoteTmuxNonInteractiveCloseRoute { guard isRemoteTmuxMirror, let panelId = panelIdFromSurfaceId(tabId), let remoteTmuxController = AppDelegate.shared?.remoteTmuxController, diff --git a/Sources/WorkspaceCloseTabsBatching.swift b/Sources/WorkspaceCloseTabsBatching.swift index c98ec6363102..02d0ab170265 100644 --- a/Sources/WorkspaceCloseTabsBatching.swift +++ b/Sources/WorkspaceCloseTabsBatching.swift @@ -81,15 +81,16 @@ extension Workspace { for candidate in candidates { // Remote tmux mirror tabs: the batch prompt above already covered // them (panelNeedsConfirmClose is mirror-aware), so route the kill - // to the remote directly — the tab is removed on %window-close. A - // local force-close would bypass the shouldCloseTab kill routing - // (its confirmation session is still winding down) and leave the - // remote window alive, resurrecting the tab on the next rebuild. - if isRemoteTmuxMirror, let panelId = candidate.panelId, - AppDelegate.shared?.remoteTmuxController.handleMirrorTabCloseRequested( - workspaceId: id, panelId: panelId - ) == true { + // to the remote directly and veto local close. If routing fails + // while reconnecting, keep the tab so the mirror can retry later. + // A local force-close would bypass the shouldCloseTab kill routing + // and leave the remote window alive, resurrecting the tab on the + // next rebuild. + switch routeRemoteTmuxNonInteractiveTabCloseIfNeeded(candidate.tabId) { + case .routed, .rejectedMirrorTab: continue + case .notMirrorTab: + break } _ = requestCloseTabRecordingHistory(candidate.tabId, force: needsConfirmation) } From c0cdce352389c14aac116daf984c61366696fd26 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 21:52:14 -0700 Subject: [PATCH 59/73] Scope remote tmux dedicated window ownership --- Sources/RemoteTmuxController.swift | 54 ++++++++++++++++------------ web/messages/ar.json | 56 +++++++++++++++++++++++++++++- web/messages/bs.json | 56 +++++++++++++++++++++++++++++- web/messages/da.json | 56 +++++++++++++++++++++++++++++- web/messages/de.json | 56 +++++++++++++++++++++++++++++- web/messages/es.json | 56 +++++++++++++++++++++++++++++- web/messages/fr.json | 56 +++++++++++++++++++++++++++++- web/messages/it.json | 56 +++++++++++++++++++++++++++++- web/messages/km.json | 56 +++++++++++++++++++++++++++++- web/messages/ko.json | 56 +++++++++++++++++++++++++++++- web/messages/no.json | 56 +++++++++++++++++++++++++++++- web/messages/pl.json | 56 +++++++++++++++++++++++++++++- web/messages/pt-BR.json | 56 +++++++++++++++++++++++++++++- web/messages/ru.json | 56 +++++++++++++++++++++++++++++- web/messages/th.json | 56 +++++++++++++++++++++++++++++- web/messages/tr.json | 56 +++++++++++++++++++++++++++++- web/messages/uk.json | 56 +++++++++++++++++++++++++++++- web/messages/zh-CN.json | 56 +++++++++++++++++++++++++++++- web/messages/zh-TW.json | 56 +++++++++++++++++++++++++++++- 19 files changed, 1022 insertions(+), 40 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index f194a8a42114..09891d798274 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -309,13 +309,14 @@ final class RemoteTmuxController { #endif } } - // If every session failed to attach (e.g. all were killed between discovery - // and attach), don't leave a sticky empty dedicated window: the reuse check - // at the top would hand it back on the next attach and never retry. Tear down - // the window, bindings, and master, and surface the failure so the CLI reports - // it instead of a false success. (Bindings are cleared before the window - // close so its onClose handleRemoteWindowClosed is a no-op — no double exit.) - guard sessionMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }) else { + // Avoid binding an empty dedicated window when sessions failed or were + // already mirrored elsewhere; the next attach must be able to retry. + let newWindowWorkspaceIds = Set(manager.tabs.map(\.id)) + let newWindowHasMirrorForHost = sessionMirrors.values.contains { mirror in + mirror.host.connectionHash == host.connectionHash + && mirror.mirroredWorkspaceId.map(newWindowWorkspaceIds.contains) == true + } + guard newWindowHasMirrorForHost else { windowRegistry.unbind(hostHash: host.connectionHash) transportRegistry.remove(connectionHash: host.connectionHash) RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) @@ -902,38 +903,47 @@ final class RemoteTmuxController { for windowId in windowRegistry.windowsMarkedForKillOnClose() { guard windowRegistry.consumeKillSessionsOnClose(windowId: windowId), let host = windowRegistry.host(forWindowId: windowId) else { continue } + let closingWorkspaceIds = Set(AppDelegate.shared?.tabManagerFor(windowId: windowId)?.tabs.map(\.id) ?? []) let transport = transport(for: host) - // Snapshot (filter) so removeValue doesn't mutate the iterated collection. - for (key, mirror) in sessionMirrors.filter({ $0.value.host.connectionHash == host.connectionHash }) { + let mirrorsInWindow = sessionMirrors.filter { _, mirror in + mirror.host.connectionHash == host.connectionHash + && mirror.mirroredWorkspaceId.map(closingWorkspaceIds.contains) == true + } + for (key, mirror) in mirrorsInWindow { sessionMirrors.removeValue(forKey: key) mirror.detachObserver() detach(host: host, sessionName: mirror.sessionName) // removes the connection too jobs.append((transport, mirror.connection.sessionId.map { "$\($0)" } ?? mirror.sessionName)) } - windowRegistry.unbind(hostHash: host.connectionHash) - transportRegistry.remove(connectionHash: host.connectionHash) + let stillUsed = sessionMirrors.values.contains { $0.host.connectionHash == host.connectionHash } || connectionsByHostSession.values.contains { $0.host.connectionHash == host.connectionHash } + if !stillUsed { + windowRegistry.unbind(hostHash: host.connectionHash) + transportRegistry.remove(connectionHash: host.connectionHash) + } } await RemoteTmuxSSHTransport.killSessions(jobs, timeout: timeout) } - /// Handles close of a dedicated remote window (Option 1): detaches every control - /// connection for that host (ssh clients shut down) but does NOT kill any remote - /// session — a window close only detaches, leaving the tmux server alive for resume. + /// Dedicated window close detaches only that window's mirrors; same-host mirrors + /// in other windows keep their control streams. func handleRemoteWindowClosed(windowId: UUID) { guard let host = windowRegistry.host(forWindowId: windowId) else { return } + let closingWorkspaceIds = Set(AppDelegate.shared?.tabManagerFor(windowId: windowId)?.tabs.map(\.id) ?? []) windowRegistry.unbind(windowId: windowId) - for (key, mirror) in sessionMirrors where mirror.host.connectionHash == host.connectionHash { + let mirrorsInWindow = sessionMirrors.filter { _, mirror in + mirror.host.connectionHash == host.connectionHash + && mirror.mirroredWorkspaceId.map(closingWorkspaceIds.contains) == true + } + for (key, mirror) in mirrorsInWindow { mirror.detachObserver() sessionMirrors.removeValue(forKey: key) + connectionsByHostSession.removeValue(forKey: key)?.stop() } - for (key, connection) in connectionsByHostSession where connection.host.connectionHash == host.connectionHash { - connection.stop() - connectionsByHostSession.removeValue(forKey: key) + let stillUsed = sessionMirrors.values.contains { $0.host.connectionHash == host.connectionHash } || connectionsByHostSession.values.contains { $0.host.connectionHash == host.connectionHash } + if !stillUsed { + transportRegistry.remove(connectionHash: host.connectionHash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } - // Close the shared SSH ControlMaster the CLI's `ssh -f` left running (fire- - // and-forget, reliable even when this is the last window → app quit). - transportRegistry.remove(connectionHash: host.connectionHash) - RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } /// Handles user-initiated close of a mirrored session workspace: detaches the diff --git a/web/messages/ar.json b/web/messages/ar.json index c3b48eebfae8..5c1ab8b82a5b 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (تجريبي)" + "textBox": "TextBox (تجريبي)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "اضبط القيم الافتراضية نفسها في ~/.config/cmux/cmux.json ضمن terminal:", "focusNote": "تحتفظ الجلسات المستعادة بحالة ظهور TextBox والتركيز المحفوظة. تنطبق هذه القيم الافتراضية فقط على أسطح الطرفية المنشأة حديثًا." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/bs.json b/web/messages/bs.json index 5de6575b903b..414dd8a610f8 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)" + "textBox": "TextBox (Beta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Iste zadane vrijednosti postavite u ~/.config/cmux/cmux.json pod terminal:", "focusNote": "Obnovljene sesije zadržavaju spremljenu vidljivost TextBoxa i stanje fokusa. Ove zadane vrijednosti važe samo za novokreirane terminalske površine." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/da.json b/web/messages/da.json index 68d2468b820c..02b8bdb0c76f 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (beta)" + "textBox": "TextBox (beta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Sæt de samme standarder i ~/.config/cmux/cmux.json under terminal:", "focusNote": "Gendannede sessioner bevarer deres gemte TextBox-synlighed og fokusstatus. Disse standarder gælder kun for nyoprettede terminalflader." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/de.json b/web/messages/de.json index 5c137ce71a77..83b9b62c599d 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)" + "textBox": "TextBox (Beta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Dieselben Standardwerte kannst du in ~/.config/cmux/cmux.json unter terminal setzen:", "focusNote": "Wiederhergestellte Sitzungen behalten ihre gespeicherte TextBox-Sichtbarkeit und ihren Fokusstatus. Diese Standardwerte gelten nur fuer neu erstellte Terminal-Oberflaechen." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/es.json b/web/messages/es.json index a1f3b912603c..945cc1a6ebeb 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)" + "textBox": "TextBox (Beta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Define los mismos valores predeterminados en ~/.config/cmux/cmux.json dentro de terminal:", "focusNote": "Las sesiones restauradas conservan su visibilidad y estado de foco de TextBox guardados. Estos valores solo se aplican a superficies de terminal nuevas." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/fr.json b/web/messages/fr.json index 83247748eb53..88ecda554eec 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (bêta)" + "textBox": "TextBox (bêta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Définissez les mêmes valeurs par défaut dans ~/.config/cmux/cmux.json sous terminal :", "focusNote": "Les sessions restaurées conservent leur visibilité TextBox et leur état de focus enregistrés. Ces valeurs par défaut ne s'appliquent qu'aux nouvelles surfaces de terminal." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/it.json b/web/messages/it.json index 9cbc859655d7..5b7e98fc21da 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)" + "textBox": "TextBox (Beta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Imposta gli stessi valori predefiniti in ~/.config/cmux/cmux.json sotto terminal:", "focusNote": "Le sessioni ripristinate mantengono la visibilità e lo stato del focus di TextBox salvati. Questi valori si applicano solo alle nuove superfici terminale." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/km.json b/web/messages/km.json index c7e18ebbd94f..db2dff87e93f 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (បេតា)" + "textBox": "TextBox (បេតា)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "កំណត់លំនាំដើមដូចគ្នានៅក្នុង ~/.config/cmux/cmux.json ក្រោម terminal:", "focusNote": "Session ដែលបានស្តារឡើងវិញរក្សាស្ថានភាពបង្ហាញ និង focus របស់ TextBox ដែលបានរក្សាទុក។ លំនាំដើមទាំងនេះអនុវត្តតែចំពោះផ្ទៃស្ថានីយដែលទើបបង្កើតថ្មីប៉ុណ្ណោះ។" + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/ko.json b/web/messages/ko.json index 04d8622b59e5..efb65e74f857 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox(베타)" + "textBox": "TextBox(베타)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "동일한 기본값을 ~/.config/cmux/cmux.json의 terminal 아래에 설정할 수 있습니다.", "focusNote": "복원된 세션은 저장된 TextBox 표시 및 포커스 상태를 유지합니다. 이 기본값은 새로 생성된 터미널 화면에만 적용됩니다." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/no.json b/web/messages/no.json index e520674cfb4d..df177951d0b1 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (beta)" + "textBox": "TextBox (beta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Sett de samme standardverdiene i ~/.config/cmux/cmux.json under terminal:", "focusNote": "Gjenopprettede økter beholder lagret TextBox-synlighet og fokusstatus. Disse standardverdiene gjelder bare nyopprettede terminalflater." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/pl.json b/web/messages/pl.json index 3e3c7396f4b4..e72fa978774e 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)" + "textBox": "TextBox (Beta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Te same ustawienia domyślne ustawisz w ~/.config/cmux/cmux.json w sekcji terminal:", "focusNote": "Przywrócone sesje zachowują zapisany stan widoczności i fokusu TextBox. Te ustawienia dotyczą tylko nowo utworzonych powierzchni terminala." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index c50eb1b4369c..106127c00ed6 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)" + "textBox": "TextBox (Beta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Defina os mesmos padrões em ~/.config/cmux/cmux.json dentro de terminal:", "focusNote": "Sessões restauradas mantêm a visibilidade e o estado de foco do TextBox salvos. Esses padrões se aplicam apenas a superfícies de terminal recém-criadas." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/ru.json b/web/messages/ru.json index 5d5c82a52940..cfe056a0567a 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (бета)" + "textBox": "TextBox (бета)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Те же значения по умолчанию можно задать в ~/.config/cmux/cmux.json в разделе terminal:", "focusNote": "Восстановленные сеансы сохраняют сохраненные видимость TextBox и состояние фокуса. Эти значения применяются только к новым поверхностям терминала." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/th.json b/web/messages/th.json index 54c84ac48ded..74b9124c29e6 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (เบต้า)" + "textBox": "TextBox (เบต้า)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "ตั้งค่าเริ่มต้นเดียวกันได้ใน ~/.config/cmux/cmux.json ภายใต้ terminal:", "focusNote": "เซสชันที่กู้คืนจะเก็บสถานะการแสดงและโฟกัสของ TextBox ที่บันทึกไว้ ค่าเริ่มต้นเหล่านี้ใช้กับพื้นผิวเทอร์มินัลที่สร้างใหม่เท่านั้น" + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/tr.json b/web/messages/tr.json index 123eec5b4a8d..8a94fb9b11b0 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)" + "textBox": "TextBox (Beta)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "Aynı varsayılanları ~/.config/cmux/cmux.json içinde terminal altında ayarlayın:", "focusNote": "Geri yüklenen oturumlar kayıtlı TextBox görünürlüğünü ve odak durumunu korur. Bu varsayılanlar yalnızca yeni oluşturulan terminal yüzeylerine uygulanır." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/uk.json b/web/messages/uk.json index 29fa3ab5bbf8..8f18a752d9d2 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -1308,7 +1308,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (бета)" + "textBox": "TextBox (бета)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1457,6 +1458,59 @@ "configTitle": "cmux.json", "configDesc": "Ті самі типові значення можна встановити в ~/.config/cmux/cmux.json у розділі terminal:", "focusNote": "Відновлені сеанси зберігають записану видимість TextBox і стан фокуса. Ці типові значення застосовуються лише до новостворених поверхонь термінала." + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 44be0ed4e8f4..c53173c534e7 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox(测试版)" + "textBox": "TextBox(测试版)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "也可以在 ~/.config/cmux/cmux.json 的 terminal 下设置相同默认值:", "focusNote": "恢复的会话会保留已保存的 TextBox 可见性和焦点状态。这些默认值只适用于新建的终端界面。" + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 737517d0e368..6106267aca2f 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -1223,7 +1223,8 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox(測試版)" + "textBox": "TextBox(測試版)", + "remoteTmux": "Remote tmux" }, "dock": { "metaTitle": "Dock", @@ -1372,6 +1373,59 @@ "configTitle": "cmux.json", "configDesc": "也可以在 ~/.config/cmux/cmux.json 的 terminal 下設定相同預設值:", "focusNote": "還原的工作階段會保留已儲存的 TextBox 顯示和焦點狀態。這些預設值只會套用到新建的終端機介面。" + }, + "remoteTmux": { + "title": "Remote tmux", + "metaTitle": "Remote tmux (beta)", + "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", + "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", + "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", + "mappingTitle": "How tmux maps to cmux", + "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", + "mapTmux": "tmux", + "mapCmux": "cmux", + "rowSession": "A dedicated workspace in the sidebar.", + "rowWindow": "A tab in that workspace.", + "rowPane": "A pane in a native split inside that tab.", + "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", + "requirementsTitle": "Requirements", + "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", + "enableTitle": "Enable it", + "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", + "attachTitle": "Attaching", + "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", + "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", + "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", + "troubleshootTitle": "If you get \"Permission denied\"", + "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", + "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", + "howTitle": "How it works", + "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "behaviorTitle": "What it supports", + "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", + "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", + "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", + "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", + "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", + "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", + "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", + "socketTitle": "Socket commands", + "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", + "socketMethod": "Method", + "socketParams": "Params", + "socketMeaning": "Description", + "methodSessions": "List the tmux sessions on a host.", + "methodAttach": "Attach a control client to a session (create attaches-or-creates).", + "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", + "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", + "methodDetach": "Detach the control client; the remote session keeps running.", + "methodState": "Report the control client's observed state (diagnostics).", + "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", + "limitationsTitle": "Limitations", + "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", + "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", + "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", + "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { From 8cc2d68bc91c5705f279cb2cfebc01f59e5284ed Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 22:37:26 -0700 Subject: [PATCH 60/73] Fix remote tmux auth and capabilities --- Sources/RemoteTmuxSSHTransport.swift | 5 ++++- Sources/TerminalController.swift | 4 ++-- cmuxTests/RemoteTmuxAuthTests.swift | 13 +++++++++++++ .../TerminalControllerSocketSecurityTests.swift | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index d959ca6434fe..9a329a1981ca 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -51,6 +51,9 @@ actor RemoteTmuxSSHTransport { "list-sessions", "-F", RemoteTmuxSessionListParser.formatString, ]) if !result.succeeded { + if Self.indicatesAuthRequired(result.stderr) { + throw RemoteTmuxError.commandFailed(exitCode: result.exitCode, stderr: result.stderr) + } if Self.indicatesNoServer(result.stderr) { return [] } throw RemoteTmuxError.commandFailed(exitCode: result.exitCode, stderr: result.stderr) } @@ -151,7 +154,7 @@ actor RemoteTmuxSSHTransport { let lowered = stderr.lowercased() return lowered.contains("no server running") || lowered.contains("no sessions") - || lowered.contains("error connecting to") + || (lowered.contains("error connecting to /") && lowered.contains("/tmux-")) } /// Whether a failed non-interactive (`BatchMode=yes`) connect failed because diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 626d722256e2..ca2676ec2fe9 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2002,7 +2002,7 @@ class TerminalController { "mobile.terminal.input", "mobile.terminal.paste", "mobile.terminal.replay", - "mobile.terminal.viewport", + "mobile.terminal.viewport", "mobile.events.subscribe", "mobile.events.unsubscribe", "terminal.create", "terminal.input", "terminal.paste", @@ -2070,7 +2070,7 @@ class TerminalController { "workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_attach_end", - "workspace.remote.terminal_session_end", + "workspace.remote.terminal_session_end", "remote.tmux.sessions", "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", "remote.tmux.window", "session.restore_previous", "settings.open", "feedback.open", diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index 8a7a37cb463d..afb1fc82164b 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -52,6 +52,19 @@ import Testing let stderr = "no server running on /tmp/tmux-501/default" #expect(RemoteTmuxSSHTransport.indicatesNoServer(stderr)) #expect(!RemoteTmuxSSHTransport.indicatesAuthRequired(stderr)) + + let socketMissing = "error connecting to /tmp/tmux-501/default (No such file or directory)" + #expect(RemoteTmuxSSHTransport.indicatesNoServer(socketMissing)) + #expect(!RemoteTmuxSSHTransport.indicatesAuthRequired(socketMissing)) + } + + @Test func staleSSHAgentErrorDoesNotMaskPermissionDeniedAuthRequirement() { + let stderr = """ + Error connecting to agent: No such file or directory + user@host: Permission denied (publickey,password). + """ + #expect(!RemoteTmuxSSHTransport.indicatesNoServer(stderr)) + #expect(RemoteTmuxSSHTransport.indicatesAuthRequired(stderr)) } // MARK: - Host-key policy in the standard control args diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index bb4f918e4c37..d53a344a6a40 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -522,7 +522,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { "mobile.terminal.replay", "terminal.replay", "mobile.terminal.viewport", - "terminal.viewport", + "terminal.viewport", "remote.tmux.sessions", "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", "remote.tmux.window", "mobile.events.subscribe", "mobile.events.unsubscribe", ] From 80bf6edce9d2886866820d40b58f5d57e6fc8eca Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 23:06:53 -0700 Subject: [PATCH 61/73] Propagate remote tmux split send failures --- Sources/GhosttyTerminalView.swift | 12 +- Sources/RemoteTmuxController.swift | 5 +- Sources/RemoteTmuxWindowMirror.swift | 6 +- cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/RemoteTmuxCapabilitiesTests.swift | 29 ++ .../RemoteTmuxMirrorSplitRoutingTests.swift | 13 + .../RightSidebarRemoteCommandTests.swift | 262 ++++++++++ ...erminalControllerSocketSecurityTests.swift | 491 ++++++------------ 8 files changed, 480 insertions(+), 346 deletions(-) create mode 100644 cmuxTests/RemoteTmuxCapabilitiesTests.swift create mode 100644 cmuxTests/RightSidebarRemoteCommandTests.swift diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6a2f469fa3aa..89ee7466eeb3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -11424,13 +11424,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { @discardableResult private func splitCurrentSurface(direction: SplitDirection) -> Bool { guard let surfaceId = terminalSurface?.id else { return false } - // Remote tmux mirror pane: route the split to tmux `split-window` and let - // the resulting %layout-change rebuild the in-tab splits (one source of - // truth). Local splits are unaffected (this returns false for them). - if AppDelegate.shared?.remoteTmuxController.handleMirrorSplitRequested( - surfaceId: surfaceId, vertical: !direction.isHorizontal - ) == true { - return true + // Remote tmux mirror pane: never fall through to a local split. The tmux + // command either reaches the live stream, or the action reports false. + if let controller = AppDelegate.shared?.remoteTmuxController, + controller.isMirrorPaneSurface(surfaceId) { + return controller.handleMirrorSplitRequested(surfaceId: surfaceId, vertical: !direction.isHorizontal) } guard let tabId, let app = AppDelegate.shared, diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 09891d798274..295982002dc8 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -502,10 +502,9 @@ final class RemoteTmuxController { /// `%layout-change`. Returns `true` if `surfaceId` is a mirror pane (the /// caller suppresses the local split). func handleMirrorSplitRequested(surfaceId: UUID, vertical: Bool) -> Bool { - for sessionMirror in sessionMirrors.values where !sessionMirror.connection.exited { + for sessionMirror in sessionMirrors.values { if let match = sessionMirror.windowMirror(forSurfaceId: surfaceId) { - match.mirror.requestSplit(fromPane: match.tmuxPaneId, vertical: vertical) - return true + return match.mirror.requestSplit(fromPane: match.tmuxPaneId, vertical: vertical) } } return false diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 41b86682c8b2..19e9d9324777 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -133,8 +133,10 @@ final class RemoteTmuxWindowMirror { /// Propagates a user split of `tmuxPaneId` to tmux `split-window` /// (`-h` = side-by-side, `-v` = stacked). The new pane arrives via the /// resulting `%layout-change` → ``reconcile(layout:)``. - func requestSplit(fromPane tmuxPaneId: Int, vertical: Bool) { - connection?.send("split-window \(vertical ? "-v" : "-h") -t @\(windowId).%\(tmuxPaneId)") + @discardableResult + func requestSplit(fromPane tmuxPaneId: Int, vertical: Bool) -> Bool { + guard let connection, connection.connectionState == .connected else { return false } + return connection.send("split-window \(vertical ? "-v" : "-h") -t @\(windowId).%\(tmuxPaneId)") } /// Propagates a user close of `tmuxPaneId` to tmux `kill-pane`. The pane is diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 06210339a820..6dc59193d536 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -532,6 +532,7 @@ E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */; }; 0A17C0DE0A17C0DE0A17C002 /* RemoteTmuxAttachOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */; }; 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */; }; + 5F5553CA5553CA5553CA0001 /* RemoteTmuxCapabilitiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F5553CA5553CA5553CA0002 /* RemoteTmuxCapabilitiesTests.swift */; }; 9C988B800BBFB3CFFD46E611 /* RemoteTmuxCommandResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */; }; 0C17C0DE0C17C0DE0C17C002 /* RemoteTmuxConnectionDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C17C0DE0C17C0DE0C17C001 /* RemoteTmuxConnectionDiagnostics.swift */; }; 0D17C0DE0D17C0DE0D17C002 /* RemoteTmuxConnectionObservers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */; }; @@ -590,6 +591,7 @@ B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A00000000000000000004 /* RightSidebarMode+Availability.swift */; }; FE003103 /* RightSidebarPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003003 /* RightSidebarPanelView.swift */; }; B37A0000000000000000000D /* RightSidebarRemoteCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000E /* RightSidebarRemoteCommand.swift */; }; + 5F5553CB5553CB5553CB0001 /* RightSidebarRemoteCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F5553CB5553CB5553CB0002 /* RightSidebarRemoteCommandTests.swift */; }; FE0031A3 /* RightSidebarToolPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0030A3 /* RightSidebarToolPanel.swift */; }; C46790000000000000000005 /* RosettaNativeRelaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000006 /* RosettaNativeRelaunch.swift */; }; C46790000000000000000007 /* RosettaNativeRelaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000008 /* RosettaNativeRelaunchTests.swift */; }; @@ -1409,6 +1411,7 @@ E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteShellSessionParsing.swift; sourceTree = "<group>"; }; 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxAttachOutcome.swift; sourceTree = "<group>"; }; 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxAuthTests.swift; sourceTree = "<group>"; }; + 5F5553CA5553CA5553CA0002 /* RemoteTmuxCapabilitiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxCapabilitiesTests.swift; sourceTree = "<group>"; }; 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxCommandResult.swift; sourceTree = "<group>"; }; 0C17C0DE0C17C0DE0C17C001 /* RemoteTmuxConnectionDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionDiagnostics.swift; sourceTree = "<group>"; }; 0D17C0DE0D17C0DE0D17C001 /* RemoteTmuxConnectionObservers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionObservers.swift; sourceTree = "<group>"; }; @@ -1467,6 +1470,7 @@ B37A00000000000000000004 /* RightSidebarMode+Availability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarMode+Availability.swift"; sourceTree = "<group>"; }; FE003003 /* RightSidebarPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarPanelView.swift; sourceTree = "<group>"; }; B37A0000000000000000000E /* RightSidebarRemoteCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarRemoteCommand.swift; sourceTree = "<group>"; }; + 5F5553CB5553CB5553CB0002 /* RightSidebarRemoteCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarRemoteCommandTests.swift; sourceTree = "<group>"; }; FE0030A3 /* RightSidebarToolPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarToolPanel.swift; sourceTree = "<group>"; }; C46790000000000000000006 /* RosettaNativeRelaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RosettaNativeRelaunch.swift; sourceTree = "<group>"; }; C46790000000000000000008 /* RosettaNativeRelaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RosettaNativeRelaunchTests.swift; sourceTree = "<group>"; }; @@ -2656,6 +2660,8 @@ A7E1C4D2B3F09865E217D4C0 /* RemoteTmuxMirrorSplitRoutingTests.swift */, FA00C0DE0002BEEF0002CAFE /* RemoteTmuxWindowRegistryTests.swift */, 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */, + 5F5553CA5553CA5553CA0002 /* RemoteTmuxCapabilitiesTests.swift */, + 5F5553CB5553CB5553CB0002 /* RightSidebarRemoteCommandTests.swift */, 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */, 31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */, 08850489C70ECFACA540C2F3 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift */, @@ -3880,6 +3886,7 @@ C135190000000000000000A1 /* PreferredEditorSettingsTests.swift in Sources */, C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */, 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */, + 5F5553CA5553CA5553CA0001 /* RemoteTmuxCapabilitiesTests.swift in Sources */, B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, B0555301B0555301B0555301 /* RemoteTmuxControlStreamParserBudgetTests.swift in Sources */, D4F8A2E61C5B39707A8E6F12 /* RemoteTmuxMirrorSplitRoutingTests.swift in Sources */, @@ -3893,6 +3900,7 @@ F5410002A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift in Sources */, F5410006A1B2C3D4E5F60718 /* RestorableAgentSessionIndexTests.swift in Sources */, C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */, + 5F5553CB5553CB5553CB0001 /* RightSidebarRemoteCommandTests.swift in Sources */, C46790000000000000000007 /* RosettaNativeRelaunchTests.swift in Sources */, F5310000A1B2C3D4E5F60718 /* RovoDevHookConfigTests.swift in Sources */, F5300000A1B2C3D4E5F60718 /* RovoDevSessionIndexTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxCapabilitiesTests.swift b/cmuxTests/RemoteTmuxCapabilitiesTests.swift new file mode 100644 index 000000000000..d81d666f2fd2 --- /dev/null +++ b/cmuxTests/RemoteTmuxCapabilitiesTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct RemoteTmuxCapabilitiesTests { + @Test func systemCapabilitiesAdvertisesRemoteTmuxMethods() throws { + let request = #"{"jsonrpc":"2.0","id":1,"method":"system.capabilities","params":{}}"# + let responseText = TerminalController.shared.handleSocketLine(request) + let responseData = try #require(responseText.data(using: .utf8)) + let response = try #require(JSONSerialization.jsonObject(with: responseData) as? [String: Any]) + let result = try #require(response["result"] as? [String: Any]) + let methods = try #require(result["methods"] as? [String]) + let advertisedMethods = Set(methods) + + #expect([ + "remote.tmux.sessions", + "remote.tmux.attach", + "remote.tmux.detach", + "remote.tmux.state", + "remote.tmux.mirror", + "remote.tmux.window", + ].allSatisfy { advertisedMethods.contains($0) }) + } +} diff --git a/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift b/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift index 2e8b128d60d4..aad099518c9e 100644 --- a/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift +++ b/cmuxTests/RemoteTmuxMirrorSplitRoutingTests.swift @@ -50,6 +50,19 @@ import Testing #expect(harness.workspace.panels.count == panelsBefore + 1) } + @Test func windowMirrorSplitRejectsWhileConnecting() { + let connection = RemoteTmuxControlConnection(host: RemoteTmuxHost(destination: "user@host"), sessionName: "work") + let mirror = RemoteTmuxWindowMirror( + windowId: 1, + panelId: UUID(), + connection: connection, + layout: RemoteTmuxLayoutNode(width: 80, height: 24, x: 0, y: 0, content: .pane(7)), + makePanel: { _ in nil } + ) + + #expect(!mirror.requestSplit(fromPane: 7, vertical: true)) + } + @MainActor private struct Harness { let appDelegate: AppDelegate diff --git a/cmuxTests/RightSidebarRemoteCommandTests.swift b/cmuxTests/RightSidebarRemoteCommandTests.swift new file mode 100644 index 000000000000..84521d000f52 --- /dev/null +++ b/cmuxTests/RightSidebarRemoteCommandTests.swift @@ -0,0 +1,262 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension TerminalControllerSocketSecurityTests { + @Test func v1CommandsDriveExistingState() throws { + let previousAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + defer { AppDelegate.shared = previousAppDelegate } + + let windowId = UUID() + let tabManager = TabManager() + let fileExplorerState = FileExplorerState() + + appDelegate.fileExplorerState = fileExplorerState + appDelegate.registerMainWindowContextForTesting( + windowId: windowId, + tabManager: tabManager, + fileExplorerState: fileExplorerState + ) + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) } + + fileExplorerState.setVisible(false) + fileExplorerState.mode = .files + + #expect(TerminalController.shared.handleSocketLine("right_sidebar show") == "OK") + #expect(fileExplorerState.isVisible) + + #expect(TerminalController.shared.handleSocketLine("right_sidebar set find") == "OK") + #expect(fileExplorerState.mode == .find) + #expect(fileExplorerState.isVisible) + + #expect(TerminalController.shared.handleSocketLine("right_sidebar set vault --no-focus") == "OK") + #expect(fileExplorerState.mode == .sessions) + + #expect(TerminalController.shared.handleSocketLine("right_sidebar set sessions --no-focus") == "OK") + #expect(fileExplorerState.mode == .sessions) + + #expect(TerminalController.shared.handleSocketLine("right_sidebar hide") == "OK") + #expect(!fileExplorerState.isVisible) + + #expect(TerminalController.shared.handleSocketLine("right_sidebar toggle") == "OK") + #expect(fileExplorerState.isVisible) + + #expect(TerminalController.shared.handleSocketLine("right_sidebar focus") == "OK") + #expect(fileExplorerState.isVisible) + + let modeResponse = TerminalController.shared.handleSocketLine("right_sidebar mode") + let modeData = try #require(modeResponse.data(using: .utf8)) + let modePayload = try #require(JSONSerialization.jsonObject(with: modeData) as? [String: Any]) + #expect(modePayload["visible"] as? Bool == true) + #expect(modePayload["mode"] as? String == "sessions") + + #expect(TerminalController.shared.handleSocketLine("right_sidebar set unknown").hasPrefix("ERROR:")) + } + + @Test func v1ParserProducesRemoteCommands() throws { +#if DEBUG + let workspaceId = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + let windowId = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + let cases: [(String, RightSidebarRemoteRequest)] = [ + ( + "right_sidebar toggle", + RightSidebarRemoteRequest(command: .toggle, target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar show --window=\(windowId.uuidString)", + RightSidebarRemoteRequest(command: .show, target: RightSidebarRemoteTarget(windowId: windowId, workspaceId: nil)) + ), + ( + "right_sidebar hide --tab=\(workspaceId.uuidString)", + RightSidebarRemoteRequest(command: .hide, target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceId)) + ), + ( + "right_sidebar focus", + RightSidebarRemoteRequest(command: .focus, target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar set find", + RightSidebarRemoteRequest(command: .setMode(.find, focus: true), target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar set vault --no-focus", + RightSidebarRemoteRequest(command: .setMode(.sessions, focus: false), target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar sessions", + RightSidebarRemoteRequest(command: .setMode(.sessions, focus: true), target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar mode", + RightSidebarRemoteRequest(command: .getState, target: RightSidebarRemoteTarget()) + ), + ( + "right_sidebar state --workspace \(workspaceId.uuidString) --window \(windowId.uuidString)", + RightSidebarRemoteRequest(command: .getState, target: RightSidebarRemoteTarget(windowId: windowId, workspaceId: workspaceId)) + ), + ] + + for (line, expected) in cases { + let result = TerminalController.shared.parseRightSidebarRemoteRequestForTesting(line) + #expect(try result.get() == expected, Comment(rawValue: line)) + } + + let invalidCases: [(String, String)] = [ + ("right_sidebar", "Usage: right_sidebar"), + ("right_sidebar set", "Usage: right_sidebar set"), + ("right_sidebar set unknown", "Unknown right sidebar mode"), + ("right_sidebar show --no-focus", "Usage: right_sidebar show"), + ("right_sidebar files --no-focus", "--no-focus is only valid"), + ("right_sidebar --bad", "Unknown right sidebar option"), + ("right_sidebar show --tab not-a-uuid", "Invalid right sidebar --tab id"), + ("right_sidebar show --window", "--window requires an id"), + ] + + for (line, expectedMessage) in invalidCases { + switch TerminalController.shared.parseRightSidebarRemoteRequestForTesting(line) { + case .success(let request): + Issue.record("Expected parser failure for \(line), got \(request)") + case .failure(let error): + #expect( + error.message.contains(expectedMessage), + "Expected \(line) to contain \(expectedMessage), got \(error.message)" + ) + } + } +#endif + } + + @Test func v1FocusPolicyIsCommandSpecific() throws { +#if DEBUG + let cases: [(String, Bool)] = [ + ("right_sidebar toggle", true), + ("right_sidebar show", true), + ("right_sidebar focus", true), + ("right_sidebar set find", true), + ("right_sidebar sessions", true), + ("right_sidebar set vault --no-focus", false), + ("right_sidebar hide", false), + ("right_sidebar mode", false), + ("right_sidebar state", false), + ("right_sidebar set unknown", false), + ] + + for (line, expected) in cases { + #expect( + TerminalController.shared.rightSidebarCommandAllowsInAppFocusMutationsForTesting(line) == expected, + Comment(rawValue: line) + ) + } +#endif + } + + @Test func remoteCommandsCanTargetRegisteredWindowOrWorkspaceWithoutFocus() throws { + let previousAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + defer { AppDelegate.shared = previousAppDelegate } + let windowAId = UUID() + let windowBId = UUID() + let managerA = TabManager() + let managerB = TabManager() + let managerC = TabManager() + _ = managerA.addWorkspace(select: false, eagerLoadTerminal: false) + let workspaceB = managerB.addWorkspace(select: false, eagerLoadTerminal: false) + let workspaceC = managerC.addWorkspace(select: false, eagerLoadTerminal: false) + let stateA = FileExplorerState() + let stateB = FileExplorerState() + let fallbackState = FileExplorerState() + + stateA.setVisible(false) + stateA.mode = .files + stateB.setVisible(false) + stateB.mode = .files + fallbackState.setVisible(true) + fallbackState.mode = .dock + appDelegate.fileExplorerState = fallbackState + + appDelegate.registerMainWindowContextForTesting( + windowId: windowAId, + tabManager: managerA, + fileExplorerState: stateA + ) + appDelegate.registerMainWindowContextForTesting( + windowId: windowBId, + tabManager: managerB, + fileExplorerState: stateB + ) + let windowCId = appDelegate.registerMainWindowContextForTesting( + tabManager: managerC + ) + defer { + appDelegate.unregisterMainWindowContextForTesting(windowId: windowAId) + appDelegate.unregisterMainWindowContextForTesting(windowId: windowBId) + appDelegate.unregisterMainWindowContextForTesting(windowId: windowCId) + } + + #expect(appDelegate.applyRightSidebarRemoteCommand( + .setMode(.find, focus: false), + target: RightSidebarRemoteTarget(windowId: windowAId, workspaceId: nil) + ) == .ok) + #expect(stateA.isVisible) + #expect(stateA.mode == .find) + #expect(!stateB.isVisible) + #expect(stateB.mode == .files) + + #expect(appDelegate.applyRightSidebarRemoteCommand( + .setMode(.sessions, focus: false), + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) + ) == .ok) + #expect(stateB.isVisible) + #expect(stateB.mode == .sessions) + #expect(stateA.mode == .find) + + #expect(appDelegate.applyRightSidebarRemoteCommand( + .hide, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) + ) == .ok) + #expect(!stateB.isVisible) + #expect(stateA.isVisible) + + switch appDelegate.applyRightSidebarRemoteCommand( + .toggle, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) + ) { + case .failure(let message): + #expect(message.contains("target not found"), Comment(rawValue: message)) + case .ok, .state: + Issue.record("Expected targeted toggle without a window to fail") + } + #expect(!stateB.isVisible) + + #expect(appDelegate.applyRightSidebarRemoteCommand( + .getState, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) + ) == .state(.init(visible: false, mode: .sessions))) + + switch appDelegate.applyRightSidebarRemoteCommand( + .getState, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceC.id) + ) { + case .failure(let message): + #expect(message.contains("state not available"), Comment(rawValue: message)) + case .ok, .state: + Issue.record("Expected explicit target without right-sidebar state to fail") + } + + switch appDelegate.applyRightSidebarRemoteCommand( + .hide, + target: RightSidebarRemoteTarget(windowId: nil, workspaceId: UUID()) + ) { + case .failure(let message): + #expect(message.contains("target not found"), Comment(rawValue: message)) + case .ok, .state: + Issue.record("Expected missing workspace target to fail") + } + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index d53a344a6a40..7743bbb837dd 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -1,14 +1,129 @@ -import XCTest -import CmuxCore import AppKit +import CmuxCore import Darwin +import Foundation +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) @testable import cmux #endif + +private func testComment(_ message: @autoclosure () -> String) -> Comment? { + let value = message() + return value.isEmpty ? nil : Comment(rawValue: value) +} + +private func XCTAssertEqual<T: Equatable>( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation +) { + do { + let value1 = try expression1() + let value2 = try expression2() + #expect(value1 == value2, testComment(message()), sourceLocation: sourceLocation) + } catch { + Issue.record(error, sourceLocation: sourceLocation) + } +} + +private func XCTAssertNotEqual<T: Equatable>( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation +) { + do { + let value1 = try expression1() + let value2 = try expression2() + #expect(value1 != value2, testComment(message()), sourceLocation: sourceLocation) + } catch { + Issue.record(error, sourceLocation: sourceLocation) + } +} + +private func XCTAssertTrue( + _ expression: @autoclosure () throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation +) { + do { + #expect(try expression(), testComment(message()), sourceLocation: sourceLocation) + } catch { + Issue.record(error, sourceLocation: sourceLocation) + } +} + +private func XCTAssertFalse( + _ expression: @autoclosure () throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation +) { + do { + let value = try expression() + #expect(!value, testComment(message()), sourceLocation: sourceLocation) + } catch { + Issue.record(error, sourceLocation: sourceLocation) + } +} + +private func XCTAssertNil<T>( + _ expression: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation +) { + do { + #expect(try expression() == nil, testComment(message()), sourceLocation: sourceLocation) + } catch { + Issue.record(error, sourceLocation: sourceLocation) + } +} + +private func XCTUnwrap<T>( + _ expression: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation +) throws -> T { + let value = try expression() + return try #require(value, testComment(message()), sourceLocation: sourceLocation) +} + +private func XCTFail( + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation +) { + Issue.record(Comment(rawValue: message()), sourceLocation: sourceLocation) +} + @MainActor -final class TerminalControllerSocketSecurityTests: XCTestCase { +@Suite(.serialized) +final class TerminalControllerSocketSecurityTests { + private var teardownBlocks: [() -> Void] = [] + + init() { + TerminalController.shared.stop() + } + + deinit { + teardownBlocks.forEach { $0() } + } + private func makeSocketPath(_ name: String) -> String { let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) return URL(fileURLWithPath: NSTemporaryDirectory()) @@ -16,17 +131,11 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { .path } - override func setUp() { - super.setUp() - TerminalController.shared.stop() - } - - override func tearDown() { - TerminalController.shared.stop() - super.tearDown() + private func addTeardownBlock(_ block: @escaping () -> Void) { + teardownBlocks.append(block) } - func testSocketPermissionsFollowAccessMode() throws { + @Test func testSocketPermissionsFollowAccessMode() throws { let tabManager = TabManager() let allowAllPath = makeSocketPath("allow-all") @@ -50,7 +159,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(try socketMode(at: restrictedPath), 0o600) } - func testPasswordModeRejectsUnauthenticatedCommands() throws { + @Test func testPasswordModeRejectsUnauthenticatedCommands() throws { let socketPath = makeSocketPath("password-mode") let tabManager = TabManager() @@ -75,7 +184,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertTrue(wrongAuthThenPing[1].hasPrefix("ERROR:")) } - func testSocketCommandPolicyDistinguishesFocusIntent() throws { + @Test func testSocketCommandPolicyDistinguishesFocusIntent() throws { #if DEBUG let nonFocus = TerminalController.debugSocketCommandPolicySnapshot( commandKey: "ping", @@ -137,11 +246,11 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertTrue(debugType.insideSuppressed) XCTAssertFalse(debugType.insideAllowsFocus) #else - throw XCTSkip("Socket command policy snapshot helper is debug-only.") + return #endif } - func testDebugTextBoxEndpointsRejectBlankSurfaceID() throws { + @Test func testDebugTextBoxEndpointsRejectBlankSurfaceID() throws { #if DEBUG TerminalController.shared.setActiveTabManager(TabManager()) defer { TerminalController.shared.setActiveTabManager(nil) } @@ -172,11 +281,11 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(error["message"] as? String, "surface_id cannot be empty") } #else - throw XCTSkip("Debug-only regression test") + return #endif } - func testRemoteStatusPayloadOmitsSensitiveSSHConfiguration() { + @Test func testRemoteStatusPayloadOmitsSensitiveSSHConfiguration() { let tabManager = TabManager() let workspace = tabManager.addWorkspace(select: false, eagerLoadTerminal: false) @@ -203,7 +312,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(payload["has_ssh_options"] as? Bool, true) } - func testRemoteConfigureRejectsInvalidPersistentDaemonSlot() throws { + @Test func testRemoteConfigureRejectsInvalidPersistentDaemonSlot() throws { let response = try handleV2Request( method: "workspace.remote.configure", params: [ @@ -223,7 +332,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } - func testRemoteConfigureDefaultsPersistentDaemonSlotForBootstrapSSH() throws { + @Test func testRemoteConfigureDefaultsPersistentDaemonSlotForBootstrapSSH() throws { let previousAppDelegate = AppDelegate.shared let appDelegate = AppDelegate() AppDelegate.shared = appDelegate @@ -258,7 +367,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } - func testRemoteConfigureDerivesAgentSocketPathFromForwardAgentOption() throws { + @Test func testRemoteConfigureDerivesAgentSocketPathFromForwardAgentOption() throws { let previousAgentSocketPath = getenv("SSH_AUTH_SOCK").map { String(cString: $0) } let agentSocketPath = try makeExistingAgentSocketPath() setenv("SSH_AUTH_SOCK", agentSocketPath, 1) @@ -302,7 +411,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(workspace.remoteConfiguration?.sshProcessEnvironment?["SSH_AUTH_SOCK"], agentSocketPath) } - func testRemoteConfigureExplicitEmptyAgentSocketSuppressesForwardAgentFallback() throws { + @Test func testRemoteConfigureExplicitEmptyAgentSocketSuppressesForwardAgentFallback() throws { let previousAgentSocketPath = getenv("SSH_AUTH_SOCK").map { String(cString: $0) } let agentSocketPath = try makeExistingAgentSocketPath() setenv("SSH_AUTH_SOCK", agentSocketPath, 1) @@ -347,7 +456,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertNil(workspace.remoteConfiguration?.sshProcessEnvironment?["SSH_AUTH_SOCK"]) } - func testRemoteConfigureUsesLastForwardAgentOption() throws { + @Test func testRemoteConfigureUsesLastForwardAgentOption() throws { let previousAgentSocketPath = getenv("SSH_AUTH_SOCK").map { String(cString: $0) } let agentSocketPath = try makeExistingAgentSocketPath() setenv("SSH_AUTH_SOCK", agentSocketPath, 1) @@ -391,7 +500,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertNil(workspace.remoteConfiguration?.sshProcessEnvironment?["SSH_AUTH_SOCK"]) } - func testRemoteConfigureRejectsPersistentDaemonSlotWithoutPreserve() throws { + @Test func testRemoteConfigureRejectsPersistentDaemonSlotWithoutPreserve() throws { let response = try handleV2Request( method: "workspace.remote.configure", params: [ @@ -411,7 +520,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } - func testRemotePTYResizeRunsOnSocketWorker() async throws { + @Test func testRemotePTYResizeRunsOnSocketWorker() async throws { let socketPath = makeSocketPath("pty-worker") let tabManager = TabManager() TerminalController.shared.start( @@ -449,7 +558,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(workerError["code"] as? String, "not_found") } - func testWorkspaceWorkerMethodRejectsWindowAliasInsteadOfDefaultWindowFallback() async throws { + @Test func testWorkspaceWorkerMethodRejectsWindowAliasInsteadOfDefaultWindowFallback() async throws { let socketPath = makeSocketPath("alias-worker") let tabManager = TabManager() TerminalController.shared.start( @@ -477,7 +586,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { try assertUnsupportedWorkspaceWindowAlias(workerEnvelope) } - func testHeartbeatMethodsSupportInProcessAndSocketDispatch() async throws { + @Test func testHeartbeatMethodsSupportInProcessAndSocketDispatch() async throws { let socketPath = makeSocketPath("heartbeat-worker") let tabManager = TabManager() TerminalController.shared.start( @@ -522,7 +631,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { "mobile.terminal.replay", "terminal.replay", "mobile.terminal.viewport", - "terminal.viewport", "remote.tmux.sessions", "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", "remote.tmux.window", + "terminal.viewport", "mobile.events.subscribe", "mobile.events.unsubscribe", ] @@ -537,7 +646,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { } } - func testRemotePTYBridgeWaitForReadyRunsOnSocketWorker() async throws { + @Test func testRemotePTYBridgeWaitForReadyRunsOnSocketWorker() async throws { let socketPath = makeSocketPath("pty-bridge-worker") let tabManager = TabManager() TerminalController.shared.start( @@ -573,7 +682,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(workerError["code"] as? String, "not_found") } - func testRemotePTYAttachEndRoutesMovedSurfaceToCurrentWorkspace() throws { + @Test func testRemotePTYAttachEndRoutesMovedSurfaceToCurrentWorkspace() throws { let previousAppDelegate = AppDelegate.shared let appDelegate = AppDelegate() defer { AppDelegate.shared = previousAppDelegate } @@ -610,7 +719,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(moved.destination.activeRemoteTerminalSessionCount, 0) } - func testRemotePTYRejectsWorkspaceSurfaceMismatchWithoutMovedSurfaceOptIn() async throws { + @Test func testRemotePTYRejectsWorkspaceSurfaceMismatchWithoutMovedSurfaceOptIn() async throws { let previousAppDelegate = AppDelegate.shared let appDelegate = AppDelegate() defer { AppDelegate.shared = previousAppDelegate } @@ -652,7 +761,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(data["resolved_workspace_id"] as? String, moved.destination.id.uuidString) } - func testRemotePTYResizeRoutesMovedSurfaceToCurrentWorkspace() async throws { + @Test func testRemotePTYResizeRoutesMovedSurfaceToCurrentWorkspace() async throws { let previousAppDelegate = AppDelegate.shared let appDelegate = AppDelegate() defer { AppDelegate.shared = previousAppDelegate } @@ -705,7 +814,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(data["attachment_id"] as? String, moved.panel.id.uuidString) } - func testRemotePTYBridgeRoutesMovedSurfaceToCurrentWorkspace() async throws { + @Test func testRemotePTYBridgeRoutesMovedSurfaceToCurrentWorkspace() async throws { let previousAppDelegate = AppDelegate.shared let appDelegate = AppDelegate() defer { AppDelegate.shared = previousAppDelegate } @@ -746,7 +855,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(data["attachment_id"] as? String, moved.panel.id.uuidString) } - func testRemotePTYAllWorkspacesTreatsMissingPTYListAsUnsupported() { + @Test func testRemotePTYAllWorkspacesTreatsMissingPTYListAsUnsupported() { let unsupported = NSError( domain: "cmux.remote.daemon.rpc", code: 14, @@ -775,291 +884,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertFalse(remotePTYSessionListErrorIsUnsupportedDaemon(differentRPCMethod)) } - func testRightSidebarV1CommandsDriveExistingState() throws { - let previousAppDelegate = AppDelegate.shared - let appDelegate = AppDelegate() - defer { AppDelegate.shared = previousAppDelegate } - - let windowId = UUID() - let tabManager = TabManager() - let sidebarState = SidebarState() - let sidebarSelectionState = SidebarSelectionState() - let fileExplorerState = FileExplorerState() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false - ) - window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(windowId.uuidString)") - - appDelegate.fileExplorerState = fileExplorerState - appDelegate.registerMainWindow( - window, - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState, - fileExplorerState: fileExplorerState - ) - defer { - appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) - window.close() - } - - fileExplorerState.setVisible(false) - fileExplorerState.mode = .files - - XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar show"), "OK") - XCTAssertTrue(fileExplorerState.isVisible) - - XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar set find"), "OK") - XCTAssertEqual(fileExplorerState.mode, .find) - XCTAssertTrue(fileExplorerState.isVisible) - - XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar set vault --no-focus"), "OK") - XCTAssertEqual(fileExplorerState.mode, .sessions) - - XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar set sessions --no-focus"), "OK") - XCTAssertEqual(fileExplorerState.mode, .sessions) - - XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar hide"), "OK") - XCTAssertFalse(fileExplorerState.isVisible) - - XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar toggle"), "OK") - XCTAssertTrue(fileExplorerState.isVisible) - - XCTAssertEqual(TerminalController.shared.handleSocketLine("right_sidebar focus"), "OK") - XCTAssertTrue(fileExplorerState.isVisible) - - let modeResponse = TerminalController.shared.handleSocketLine("right_sidebar mode") - let modeData = try XCTUnwrap(modeResponse.data(using: .utf8)) - let modePayload = try XCTUnwrap(JSONSerialization.jsonObject(with: modeData) as? [String: Any]) - XCTAssertEqual(modePayload["visible"] as? Bool, true) - XCTAssertEqual(modePayload["mode"] as? String, "sessions") - - XCTAssertTrue(TerminalController.shared.handleSocketLine("right_sidebar set unknown").hasPrefix("ERROR:")) - } - - func testRightSidebarV1ParserProducesRemoteCommands() throws { -#if DEBUG - let workspaceId = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let windowId = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! - let cases: [(String, RightSidebarRemoteRequest)] = [ - ( - "right_sidebar toggle", - RightSidebarRemoteRequest(command: .toggle, target: RightSidebarRemoteTarget()) - ), - ( - "right_sidebar show --window=\(windowId.uuidString)", - RightSidebarRemoteRequest(command: .show, target: RightSidebarRemoteTarget(windowId: windowId, workspaceId: nil)) - ), - ( - "right_sidebar hide --tab=\(workspaceId.uuidString)", - RightSidebarRemoteRequest(command: .hide, target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceId)) - ), - ( - "right_sidebar focus", - RightSidebarRemoteRequest(command: .focus, target: RightSidebarRemoteTarget()) - ), - ( - "right_sidebar set find", - RightSidebarRemoteRequest(command: .setMode(.find, focus: true), target: RightSidebarRemoteTarget()) - ), - ( - "right_sidebar set vault --no-focus", - RightSidebarRemoteRequest(command: .setMode(.sessions, focus: false), target: RightSidebarRemoteTarget()) - ), - ( - "right_sidebar sessions", - RightSidebarRemoteRequest(command: .setMode(.sessions, focus: true), target: RightSidebarRemoteTarget()) - ), - ( - "right_sidebar mode", - RightSidebarRemoteRequest(command: .getState, target: RightSidebarRemoteTarget()) - ), - ( - "right_sidebar state --workspace \(workspaceId.uuidString) --window \(windowId.uuidString)", - RightSidebarRemoteRequest(command: .getState, target: RightSidebarRemoteTarget(windowId: windowId, workspaceId: workspaceId)) - ), - ] - - for (line, expected) in cases { - let result = TerminalController.shared.parseRightSidebarRemoteRequestForTesting(line) - XCTAssertEqual(try result.get(), expected, line) - } - - let invalidCases: [(String, String)] = [ - ("right_sidebar", "Usage: right_sidebar"), - ("right_sidebar set", "Usage: right_sidebar set"), - ("right_sidebar set unknown", "Unknown right sidebar mode"), - ("right_sidebar show --no-focus", "Usage: right_sidebar show"), - ("right_sidebar files --no-focus", "--no-focus is only valid"), - ("right_sidebar --bad", "Unknown right sidebar option"), - ("right_sidebar show --tab not-a-uuid", "Invalid right sidebar --tab id"), - ("right_sidebar show --window", "--window requires an id"), - ] - - for (line, expectedMessage) in invalidCases { - switch TerminalController.shared.parseRightSidebarRemoteRequestForTesting(line) { - case .success(let request): - XCTFail("Expected parser failure for \(line), got \(request)") - case .failure(let error): - XCTAssertTrue( - error.message.contains(expectedMessage), - "Expected \(line) to contain \(expectedMessage), got \(error.message)" - ) - } - } -#else - throw XCTSkip("Right sidebar parser helper is debug-only.") -#endif - } - - func testRightSidebarV1FocusPolicyIsCommandSpecific() throws { -#if DEBUG - let cases: [(String, Bool)] = [ - ("right_sidebar toggle", true), - ("right_sidebar show", true), - ("right_sidebar focus", true), - ("right_sidebar set find", true), - ("right_sidebar sessions", true), - ("right_sidebar set vault --no-focus", false), - ("right_sidebar hide", false), - ("right_sidebar mode", false), - ("right_sidebar state", false), - ("right_sidebar set unknown", false), - ] - - for (line, expected) in cases { - XCTAssertEqual( - TerminalController.shared.rightSidebarCommandAllowsInAppFocusMutationsForTesting(line), - expected, - line - ) - } -#else - throw XCTSkip("Right sidebar focus policy helper is debug-only.") -#endif - } - - func testRightSidebarRemoteCommandsCanTargetRegisteredWindowOrWorkspaceWithoutFocus() throws { - let previousAppDelegate = AppDelegate.shared - let appDelegate = AppDelegate() - defer { AppDelegate.shared = previousAppDelegate } - let windowAId = UUID() - let windowBId = UUID() - let managerA = TabManager() - let managerB = TabManager() - let managerC = TabManager() - _ = managerA.addWorkspace(select: false, eagerLoadTerminal: false) - let workspaceB = managerB.addWorkspace(select: false, eagerLoadTerminal: false) - let workspaceC = managerC.addWorkspace(select: false, eagerLoadTerminal: false) - let stateA = FileExplorerState() - let stateB = FileExplorerState() - let fallbackState = FileExplorerState() - - stateA.setVisible(false) - stateA.mode = .files - stateB.setVisible(false) - stateB.mode = .files - fallbackState.setVisible(true) - fallbackState.mode = .dock - appDelegate.fileExplorerState = fallbackState - - appDelegate.registerMainWindowContextForTesting( - windowId: windowAId, - tabManager: managerA, - fileExplorerState: stateA - ) - appDelegate.registerMainWindowContextForTesting( - windowId: windowBId, - tabManager: managerB, - fileExplorerState: stateB - ) - let windowCId = appDelegate.registerMainWindowContextForTesting( - tabManager: managerC - ) - defer { - appDelegate.unregisterMainWindowContextForTesting(windowId: windowAId) - appDelegate.unregisterMainWindowContextForTesting(windowId: windowBId) - appDelegate.unregisterMainWindowContextForTesting(windowId: windowCId) - } - - XCTAssertEqual( - appDelegate.applyRightSidebarRemoteCommand( - .setMode(.find, focus: false), - target: RightSidebarRemoteTarget(windowId: windowAId, workspaceId: nil) - ), - .ok - ) - XCTAssertTrue(stateA.isVisible) - XCTAssertEqual(stateA.mode, .find) - XCTAssertFalse(stateB.isVisible) - XCTAssertEqual(stateB.mode, .files) - - XCTAssertEqual( - appDelegate.applyRightSidebarRemoteCommand( - .setMode(.sessions, focus: false), - target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) - ), - .ok - ) - XCTAssertTrue(stateB.isVisible) - XCTAssertEqual(stateB.mode, .sessions) - XCTAssertEqual(stateA.mode, .find) - - XCTAssertEqual( - appDelegate.applyRightSidebarRemoteCommand( - .hide, - target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) - ), - .ok - ) - XCTAssertFalse(stateB.isVisible) - XCTAssertTrue(stateA.isVisible) - - switch appDelegate.applyRightSidebarRemoteCommand( - .toggle, - target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) - ) { - case .failure(let message): - XCTAssertTrue(message.contains("target not found"), message) - case .ok, .state: - XCTFail("Expected targeted toggle without a window to fail") - } - XCTAssertFalse(stateB.isVisible) - - XCTAssertEqual( - appDelegate.applyRightSidebarRemoteCommand( - .getState, - target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceB.id) - ), - .state(.init(visible: false, mode: .sessions)) - ) - - switch appDelegate.applyRightSidebarRemoteCommand( - .getState, - target: RightSidebarRemoteTarget(windowId: nil, workspaceId: workspaceC.id) - ) { - case .failure(let message): - XCTAssertTrue(message.contains("state not available"), message) - case .ok, .state: - XCTFail("Expected explicit target without right-sidebar state to fail") - } - - switch appDelegate.applyRightSidebarRemoteCommand( - .hide, - target: RightSidebarRemoteTarget(windowId: nil, workspaceId: UUID()) - ) { - case .failure(let message): - XCTAssertTrue(message.contains("target not found"), message) - case .ok, .state: - XCTFail("Expected missing workspace target to fail") - } - } - - func testNotificationCreateUsesExplicitSurfaceIDWhenProvided() async throws { + @Test func testNotificationCreateUsesExplicitSurfaceIDWhenProvided() async throws { let socketPath = makeSocketPath("notify-surface") let store = TerminalNotificationStore.shared let appDelegate = AppDelegate.shared ?? AppDelegate() @@ -1127,7 +952,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: focusedPanelId)) } - func testPaneCreateStartupEnvironmentMarksManagedSubagentForRawNotificationSuppression() async throws { + @Test func testPaneCreateStartupEnvironmentMarksManagedSubagentForRawNotificationSuppression() async throws { let socketPath = makeSocketPath("pane-env") let manager = TabManager() let workspace = manager.addWorkspace(select: true) @@ -1180,7 +1005,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: sourcePanelId)) } - func testSurfaceRelayRPCsReturnResolvedFocusedSurfaceWhenSurfaceIDOmitted() async throws { + @Test func testSurfaceRelayRPCsReturnResolvedFocusedSurfaceWhenSurfaceIDOmitted() async throws { let socketPath = makeSocketPath("relay-fallback") let manager = TabManager() let workspace = manager.addWorkspace(select: true) @@ -1228,7 +1053,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(portsKickResult["surface_id"] as? String, focusedPanelId.uuidString) } - func testSurfaceRelayRPCsRejectExplicitUnknownSurfaceID() async throws { + @Test func testSurfaceRelayRPCsRejectExplicitUnknownSurfaceID() async throws { let socketPath = makeSocketPath("relay-invalid") let manager = TabManager() let workspace = manager.addWorkspace(select: true) @@ -1281,7 +1106,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(portsKickData["surface_id"] as? String, unknownSurfaceId.uuidString) } - func testWorkspaceCloseRejectsPinnedWorkspace() async throws { + @Test func testWorkspaceCloseRejectsPinnedWorkspace() async throws { let socketPath = makeSocketPath("close-pinned") let manager = TabManager() let pinnedWorkspace = manager.addWorkspace(select: false) @@ -1319,7 +1144,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertTrue(manager.tabs.contains(where: { $0.id == pinnedWorkspace.id })) } - func testV2SurfaceCloseCommandsRecordRecentlyClosedHistory() throws { + @Test func testV2SurfaceCloseCommandsRecordRecentlyClosedHistory() throws { ClosedItemHistoryStore.shared.removeAll() let defaults = UserDefaults.standard let previousBrowserDisabled = defaults.object(forKey: BrowserAvailabilitySettings.disabledKey) @@ -1373,7 +1198,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } - func testBrowserOpenSplitDoesNotExternallyOpenDiffViewerWhenBrowserDisabled() throws { + @Test func testBrowserOpenSplitDoesNotExternallyOpenDiffViewerWhenBrowserDisabled() throws { let defaults = UserDefaults.standard let previousBrowserDisabled = defaults.object(forKey: BrowserAvailabilitySettings.disabledKey) BrowserAvailabilitySettings.setDisabled(true) @@ -1401,7 +1226,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(error["code"] as? String, "browser_disabled") } - func testLegacyCloseSurfaceCommandRecordsRecentlyClosedHistory() throws { + @Test func testLegacyCloseSurfaceCommandRecordsRecentlyClosedHistory() throws { ClosedItemHistoryStore.shared.removeAll() defer { ClosedItemHistoryStore.shared.removeAll() @@ -1426,14 +1251,12 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { } private func waitForSocket(at path: String, timeout: TimeInterval = 5.0) throws { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate { _, _ in - FileManager.default.fileExists(atPath: path) - }, - object: NSObject() - ) - if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed { - return + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: path) { + return + } + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) } XCTFail("Timed out waiting for socket at \(path)") throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) From 0a5667432f33c39950545a8cbfedbce85fd40311 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 23:34:56 -0700 Subject: [PATCH 62/73] Add remote tmux initial topology regression test --- Sources/RemoteTmuxControlConnection.swift | 14 ++++++++++++++ cmuxTests/RemoteTmuxAuthTests.swift | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index c838db95a34e..eddbfcacea68 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -1409,4 +1409,18 @@ final class RemoteTmuxControlConnection { ) } + #if DEBUG + func installStdinWriterForTesting(_ writer: RemoteTmuxControlPipeWriter) { + stdinWriter = writer + } + + func handleMessageForTesting(_ message: RemoteTmuxControlMessage) { + handle(message) + } + + var pendingCommandKindsForTesting: [RemoteTmuxControlCommandKind] { + pendingCommands + } + #endif + } diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index afb1fc82164b..8623d256f002 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -155,6 +155,29 @@ import Testing #expect(connection.pastePane(paneId: 1, text: "") == false) } + @Test @MainActor func attachBlockDrainQueuesInitialWindowRequest() { + let connection = RemoteTmuxControlConnection(host: RemoteTmuxHost(destination: "user@host"), sessionName: "work") + let pipe = Pipe() + let writer = RemoteTmuxControlPipeWriter( + handle: pipe.fileHandleForWriting, + label: "remote-tmux-initial-window-request-test", + maxPendingBytes: 4096, + onFailure: {} + ) + connection.installStdinWriterForTesting(writer) + defer { + writer.close() + try? pipe.fileHandleForReading.close() + } + + connection.handleMessageForTesting(.enter) + #expect(connection.pendingCommandKindsForTesting.isEmpty) + + connection.handleMessageForTesting(.commandResult(commandNumber: 1, lines: [], isError: false)) + + #expect(connection.pendingCommandKindsForTesting == [.listWindows]) + } + @Test func pastePaneCommandsProtectOptionLookingText() throws { let commands = try #require(RemoteTmuxControlConnection.pastePaneCommands(paneId: 7, text: "-n not-an-option")) #expect(commands.setBuffer == "set-buffer -b cmux-paste-7 -- '-n not-an-option'") From 168033f2363629da09a36706f0fa9bf609dfe0e0 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 23:37:01 -0700 Subject: [PATCH 63/73] Seed remote tmux topology after attach --- Sources/RemoteTmuxControlConnection.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index eddbfcacea68..1605171db312 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -1220,6 +1220,7 @@ final class RemoteTmuxControlConnection { // the positional FIFO (see ``attachBlockDrained``). if !attachBlockDrained { attachBlockDrained = true + requestWindows() } else { handleCommandResult(lines: lines, isError: isError) } From f0bc81bd586a256fc954bdfb199c3a9ce09d0370 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 23:40:25 -0700 Subject: [PATCH 64/73] Tighten remote tmux policy cleanup --- .../Surface/TerminalManualIOWrite.swift | 32 +++++++++++++++++++ .../Surface/TerminalSurface.swift | 31 ------------------ Sources/RemoteTmuxControlConnection.swift | 27 ++++------------ 3 files changed, 38 insertions(+), 52 deletions(-) create mode 100644 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualIOWrite.swift diff --git a/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualIOWrite.swift b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualIOWrite.swift new file mode 100644 index 000000000000..ebc3f7d4176d --- /dev/null +++ b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualIOWrite.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Heap-allocated userdata for a `TerminalSurface` created in libghostty +/// MANUAL I/O mode. +/// +/// In MANUAL mode ghostty spawns no process and owns no PTY: bytes the user +/// types are delivered to `onWrite` instead of a PTY, and output is injected +/// with `ghostty_surface_process_output`. cmux uses this for remote-tmux pane +/// display surfaces. +final class TerminalManualIOWriteBox { + /// Invoked with bytes the user typed into the surface. Runs on ghostty's + /// I/O thread, so the closure must be Sendable and hop to the main actor + /// itself before touching MainActor state. + let onWrite: @Sendable (Data) -> Void + + init(onWrite: @escaping @Sendable (Data) -> Void) { + self.onWrite = onWrite + } +} + +/// C trampoline matching `ghostty_io_write_cb` for MANUAL-mode surfaces. +let terminalManualIOWriteCallback: @convention(c) ( + UnsafeMutableRawPointer?, UnsafePointer<CChar>?, UInt +) -> Void = { userdata, bytes, len in + guard let userdata, let bytes, len > 0 else { return } + let box = Unmanaged<TerminalManualIOWriteBox>.fromOpaque(userdata).takeUnretainedValue() + let count = Int(len) + let data = bytes.withMemoryRebound(to: UInt8.self, capacity: count) { rebound in + Data(buffer: UnsafeBufferPointer(start: rebound, count: count)) + } + box.onWrite(data) +} diff --git a/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index c6301b665eec..65ff34bb56c2 100644 --- a/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -7,37 +7,6 @@ public import CmuxTerminalCore internal import CMUXDebugLog #endif -/// Heap-allocated userdata for a `TerminalSurface` created in libghostty -/// MANUAL I/O mode. -/// -/// In MANUAL mode ghostty spawns no process and owns no PTY: bytes the user -/// types are delivered to `onWrite` instead of a PTY, and output is injected -/// with `ghostty_surface_process_output`. cmux uses this for remote-tmux pane -/// display surfaces. -final class TerminalManualIOWriteBox { - /// Invoked with bytes the user typed into the surface. Runs on ghostty's - /// I/O thread, so the closure must be Sendable and hop to the main actor - /// itself before touching MainActor state. - let onWrite: @Sendable (Data) -> Void - - init(onWrite: @escaping @Sendable (Data) -> Void) { - self.onWrite = onWrite - } -} - -/// C trampoline matching `ghostty_io_write_cb` for MANUAL-mode surfaces. -let terminalManualIOWriteCallback: @convention(c) ( - UnsafeMutableRawPointer?, UnsafePointer<CChar>?, UInt -) -> Void = { userdata, bytes, len in - guard let userdata, let bytes, len > 0 else { return } - let box = Unmanaged<TerminalManualIOWriteBox>.fromOpaque(userdata).takeUnretainedValue() - let count = Int(len) - let data = bytes.withMemoryRebound(to: UInt8.self, capacity: count) { rebound in - Data(buffer: UnsafeBufferPointer(start: rebound, count: count)) - } - box.onWrite(data) -} - /// The owner of one `ghostty_surface_t` lifecycle: spawn inputs, runtime /// creation/teardown, pending input queues, portal-host leases, and renderer /// reclamation state. diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 1605171db312..7335ad2fd8c4 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -1126,16 +1126,9 @@ final class RemoteTmuxControlConnection { reconnectAttemptCount = 0 reconnectTask?.cancel() reconnectTask = nil - // DON'T send anything from here: `.enter` is emitted BEFORE the - // attach's own %begin/%end block is consumed, and EVERY send (even - // kind `.other`) joins the positional result FIFO — a command - // queued now would be popped by the attach block and shift every - // later result one slot. Defer to the first list-windows result - // (attach block drained, windowsByID freshly repopulated): a - // reconnect re-seeds the surfaces (the fresh client lost the - // screen and subscriptions); a first connect re-applies a grid - // that `setClientSize` stored before stdin was live (nothing else - // re-applies it, and the remote would stay at ssh's 80×24). + // Do not send here: `.enter` precedes the attach result block, so a + // command queued now could be consumed by that result and shift the + // FIFO. The attach-block drain queues list-windows once alignment is safe. pendingPostAttachAction = wasReconnecting ? .reseed : .applyClientSize } case let .exit(reason): @@ -1411,17 +1404,9 @@ final class RemoteTmuxControlConnection { } #if DEBUG - func installStdinWriterForTesting(_ writer: RemoteTmuxControlPipeWriter) { - stdinWriter = writer - } - - func handleMessageForTesting(_ message: RemoteTmuxControlMessage) { - handle(message) - } - - var pendingCommandKindsForTesting: [RemoteTmuxControlCommandKind] { - pendingCommands - } + func installStdinWriterForTesting(_ writer: RemoteTmuxControlPipeWriter) { stdinWriter = writer } + func handleMessageForTesting(_ message: RemoteTmuxControlMessage) { handle(message) } + var pendingCommandKindsForTesting: [RemoteTmuxControlCommandKind] { pendingCommands } #endif } From 01704d191236b58f8af8523fbcb057a4444b0f4d Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sat, 13 Jun 2026 23:52:15 -0700 Subject: [PATCH 65/73] Avoid off-main folder icon resolution --- Sources/DetachedFolderDragIcon.swift | 49 +++++++--------------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/Sources/DetachedFolderDragIcon.swift b/Sources/DetachedFolderDragIcon.swift index a5857b085ec5..59ee118aa8c0 100644 --- a/Sources/DetachedFolderDragIcon.swift +++ b/Sources/DetachedFolderDragIcon.swift @@ -69,36 +69,18 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { updateIcon() } - /// Per-path icons already resolved this session. `NSWorkspace.icon(forFile:)` - /// stats the path, and `directory` can be a REMOTE working directory - /// (remote tmux) where that stat blocks on the autofs automounter for - /// hundreds of ms — never pay it twice, and never pay it on the main thread. - private static let iconLookups = DetachedFolderPathLookupCache<NSImage>() private static let displayNameLookups = DetachedFolderPathLookupCache<String>() - /// Returns the cached icon for `path`, or the generic folder icon - /// (UTType-based — no filesystem access) while resolving the real one - /// off-main. `onResolved` runs on the main thread once a fresh icon is - /// fetched and cached; it is not called on a cache hit. - private static func icon(forPath path: String, onResolved: @escaping (NSImage) -> Void) -> NSImage { - if let cached = iconLookups.value(forPath: path) { return cached } - if iconLookups.enqueueCallback(forPath: path, callback: onResolved) { - Task.detached(priority: .userInitiated) { - let icon = NSWorkspace.shared.icon(forFile: path) - await MainActor.run { - icon.size = NSSize(width: 16, height: 16) - iconLookups.resolve(path: path, value: icon) - } - } - } - let generic = NSWorkspace.shared.icon(for: .folder) - generic.size = NSSize(width: 16, height: 16) + /// UTType-based generic folder icon. Avoid `icon(forFile:)`: it stats the + /// path, and remote tmux directories can block on the autofs automounter. + private static func genericFolderIcon(size: CGFloat) -> NSImage { + let generic = (NSWorkspace.shared.icon(for: .folder).copy() as? NSImage) ?? NSWorkspace.shared.icon(for: .folder) + generic.size = NSSize(width: size, height: size) return generic } /// Resolves the localized display name for `path` off-main (the API - /// stats), then runs `onResolved` on the main thread — same shape as - /// ``icon(forPath:onResolved:)``. + /// stats), then runs `onResolved` on the main thread. private static func localizedDisplayName(forPath path: String, onResolved: @escaping (String) -> Void) { if let cached = displayNameLookups.value(forPath: path) { onResolved(cached) @@ -118,11 +100,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { dispatchPrecondition(condition: .onQueue(.main)) #endif - let target = directory - imageView.image = Self.icon(forPath: target) { [weak self] icon in - guard let self, self.directory == target else { return } - self.imageView.image = icon - } + imageView.image = Self.genericFolderIcon(size: 16) } func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { @@ -201,10 +179,9 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { let fileURL = URL(fileURLWithPath: directory) let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) - // Cosmetic drag image: use the already-resolved icon (or the generic - // folder) rather than re-statting `directory` — see updateIcon(). - let iconImage = (Self.iconLookups.value(forPath: directory) ?? NSWorkspace.shared.icon(for: .folder)).copy() as! NSImage - iconImage.size = NSSize(width: 32, height: 32) + // Cosmetic drag image: use the generic folder rather than re-statting + // `directory` — see updateIcon(). + let iconImage = Self.genericFolderIcon(size: 32) draggingItem.setDraggingFrame(bounds, contents: iconImage) let session = beginDraggingSession(with: [draggingItem], event: event, source: self) @@ -252,15 +229,13 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { } } else { // Placeholder; the localized display name comes from a stat'ing - // API and is refined off-main below, like the icons. + // API and is refined off-main below. displayName = (path as NSString).lastPathComponent } let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") item.target = self - item.image = Self.icon(forPath: path) { [weak item] icon in - item?.image = icon - } + item.image = Self.genericFolderIcon(size: 16) item.representedObject = pathURL if path != "/" { Self.localizedDisplayName(forPath: path) { [weak item] localizedName in From dd169e38f8bcadb51138907b77af74ce310c9d9e Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sun, 14 Jun 2026 00:10:57 -0700 Subject: [PATCH 66/73] Add remote tmux pane pruning regression test --- cmuxTests/RemoteTmuxAuthTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index 8623d256f002..c9da50086e8c 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -178,6 +178,25 @@ import Testing #expect(connection.pendingCommandKindsForTesting == [.listWindows]) } + @Test @MainActor func layoutChangePrunesRemovedPaneDiagnosticState() { + let connection = RemoteTmuxControlConnection(host: RemoteTmuxHost(destination: "user@host"), sessionName: "work") + connection.handleMessageForTesting(.layoutChange( + windowId: 1, + layout: "abcd,120x40,0,0{60x40,0,0,4,59x40,61,0,5}" + )) + connection.handleMessageForTesting(.output(paneId: 4, data: Data("left".utf8))) + connection.handleMessageForTesting(.output(paneId: 5, data: Data("right".utf8))) + connection.handleMessageForTesting(.subscriptionChanged(name: "cmux_reflow_4", value: "0|zsh")) + connection.handleMessageForTesting(.subscriptionChanged(name: "cmux_reflow_5", value: "1|vim")) + + connection.handleMessageForTesting(.layoutChange(windowId: 1, layout: "f92f,80x24,0,0,4")) + + #expect(connection.snapshot().paneOutputByteCounts[4] == 4) + #expect(connection.snapshot().paneOutputByteCounts[5] == nil) + #expect(connection.paneForegroundStates[4] != nil) + #expect(connection.paneForegroundStates[5] == nil) + } + @Test func pastePaneCommandsProtectOptionLookingText() throws { let commands = try #require(RemoteTmuxControlConnection.pastePaneCommands(paneId: 7, text: "-n not-an-option")) #expect(commands.setBuffer == "set-buffer -b cmux-paste-7 -- '-n not-an-option'") From 6ab052d05206efcf157f7207b163f4f9a5f11761 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sun, 14 Jun 2026 00:11:01 -0700 Subject: [PATCH 67/73] Prune remote tmux pane state on layout changes --- Sources/RemoteTmuxControlConnection.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 7335ad2fd8c4..750a5ed0a226 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -1283,9 +1283,7 @@ final class RemoteTmuxControlConnection { let liveIDs = Set(order) windowsByID = next activePaneByWindow = activePaneByWindow.filter { liveIDs.contains($0.key) } - let livePanes = Set(next.values.flatMap { $0.paneIDsInOrder }) - paneOutputByteCounts = paneOutputByteCounts.filter { livePanes.contains($0.key) } - paneForegroundStates = paneForegroundStates.filter { livePanes.contains($0.key) } + prunePaneState(keeping: Set(next.values.flatMap { $0.paneIDsInOrder })) windowOrder = order observers.notifyTopologyChanged() // The attach block is drained and the topology is fresh — run the @@ -1382,6 +1380,12 @@ final class RemoteTmuxControlConnection { id: windowId, name: existingName, width: node.width, height: node.height, layout: node ) if !windowOrder.contains(windowId) { windowOrder.append(windowId) } + prunePaneState(keeping: Set(windowsByID.values.flatMap { $0.paneIDsInOrder })) + } + + private func prunePaneState(keeping livePanes: Set<Int>) { + paneOutputByteCounts = paneOutputByteCounts.filter { livePanes.contains($0.key) } + paneForegroundStates = paneForegroundStates.filter { livePanes.contains($0.key) } } private func record(_ event: String) { From 6cb00ba32fbbbf864cf39079f2703b80259f04f2 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sun, 14 Jun 2026 00:11:06 -0700 Subject: [PATCH 68/73] Gate remote tmux docs to translated locales --- web/app/[locale]/components/docs-nav-items.ts | 31 +++++++++- web/app/[locale]/components/docs-pager.tsx | 7 ++- web/app/[locale]/components/docs-sidebar.tsx | 6 +- web/app/[locale]/docs/remote-tmux/page.tsx | 26 +++++++-- web/i18n/seo.ts | 8 ++- web/messages/ar.json | 56 +------------------ web/messages/bs.json | 56 +------------------ web/messages/da.json | 56 +------------------ web/messages/de.json | 56 +------------------ web/messages/es.json | 56 +------------------ web/messages/fr.json | 56 +------------------ web/messages/it.json | 56 +------------------ web/messages/km.json | 56 +------------------ web/messages/ko.json | 56 +------------------ web/messages/no.json | 56 +------------------ web/messages/pl.json | 56 +------------------ web/messages/pt-BR.json | 56 +------------------ web/messages/ru.json | 56 +------------------ web/messages/th.json | 56 +------------------ web/messages/tr.json | 56 +------------------ web/messages/uk.json | 56 +------------------ web/messages/zh-CN.json | 56 +------------------ web/messages/zh-TW.json | 56 +------------------ 23 files changed, 83 insertions(+), 1003 deletions(-) diff --git a/web/app/[locale]/components/docs-nav-items.ts b/web/app/[locale]/components/docs-nav-items.ts index 73750a955719..a0b505ee7edb 100644 --- a/web/app/[locale]/components/docs-nav-items.ts +++ b/web/app/[locale]/components/docs-nav-items.ts @@ -1,7 +1,15 @@ -export type NavLink = { titleKey: string; href: string }; +import type { Locale } from "../../../i18n/routing"; + +export type NavLink = { + titleKey: string; + href: string; + locales?: readonly Locale[]; +}; export type NavSection = { sectionKey: string; children: NavLink[] }; export type NavEntry = NavLink | NavSection; +export const remoteTmuxDocsLocales = ["en", "ja"] as const satisfies readonly Locale[]; + export function isSection(entry: NavEntry): entry is NavSection { return "sectionKey" in entry; } @@ -11,6 +19,25 @@ export function flatNavItems(entries: NavEntry[]): NavLink[] { return entries.flatMap((e) => (isSection(e) ? e.children : [e])); } +function isLinkVisible(item: NavLink, locale: string): boolean { + return !item.locales || item.locales.includes(locale as Locale); +} + +export function navItemsForLocale(locale: string): NavEntry[] { + const entries: NavEntry[] = []; + for (const entry of navItems) { + if (!isSection(entry)) { + if (isLinkVisible(entry, locale)) entries.push(entry); + continue; + } + const children = entry.children.filter((child) => + isLinkVisible(child, locale) + ); + if (children.length > 0) entries.push({ ...entry, children }); + } + return entries; +} + export const navItems: NavEntry[] = [ { titleKey: "gettingStarted", href: "/docs/getting-started" }, { titleKey: "concepts", href: "/docs/concepts" }, @@ -26,7 +53,7 @@ export const navItems: NavEntry[] = [ { titleKey: "skills", href: "/docs/skills" }, { titleKey: "notifications", href: "/docs/notifications" }, { titleKey: "ssh", href: "/docs/ssh" }, - { titleKey: "remoteTmux", href: "/docs/remote-tmux" }, + { titleKey: "remoteTmux", href: "/docs/remote-tmux", locales: remoteTmuxDocsLocales }, { sectionKey: "agentIntegrations", children: [ diff --git a/web/app/[locale]/components/docs-pager.tsx b/web/app/[locale]/components/docs-pager.tsx index dcfb987ea590..2438b455a93e 100644 --- a/web/app/[locale]/components/docs-pager.tsx +++ b/web/app/[locale]/components/docs-pager.tsx @@ -1,13 +1,14 @@ "use client"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { Link, usePathname } from "../../../i18n/navigation"; -import { navItems, flatNavItems } from "./docs-nav-items"; +import { navItemsForLocale, flatNavItems } from "./docs-nav-items"; export function DocsPager() { const pathname = usePathname(); + const locale = useLocale(); const t = useTranslations("docs.navItems"); - const flat = flatNavItems(navItems); + const flat = flatNavItems(navItemsForLocale(locale)); const index = flat.findIndex((item) => item.href === pathname); const prev = index > 0 ? flat[index - 1] : null; const next = index < flat.length - 1 ? flat[index + 1] : null; diff --git a/web/app/[locale]/components/docs-sidebar.tsx b/web/app/[locale]/components/docs-sidebar.tsx index 159eda457719..c4d665facefc 100644 --- a/web/app/[locale]/components/docs-sidebar.tsx +++ b/web/app/[locale]/components/docs-sidebar.tsx @@ -1,8 +1,8 @@ "use client"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { Link, usePathname } from "../../../i18n/navigation"; -import { navItems, isSection, type NavLink } from "./docs-nav-items"; +import { navItemsForLocale, isSection, type NavLink } from "./docs-nav-items"; import { DocsSearch } from "./docs-search"; function SidebarLink({ @@ -38,7 +38,9 @@ function SidebarLink({ export function DocsSidebar({ onNavigate }: { onNavigate?: () => void }) { const pathname = usePathname(); + const locale = useLocale(); const t = useTranslations("docs.navItems"); + const navItems = navItemsForLocale(locale); return ( <> diff --git a/web/app/[locale]/docs/remote-tmux/page.tsx b/web/app/[locale]/docs/remote-tmux/page.tsx index 9f83db3b1e38..3120b61402d5 100644 --- a/web/app/[locale]/docs/remote-tmux/page.tsx +++ b/web/app/[locale]/docs/remote-tmux/page.tsx @@ -1,22 +1,40 @@ -import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; import { buildAlternates } from "../../../../i18n/seo"; import { Callout } from "../../components/callout"; import { CodeBlock } from "../../components/code-block"; import { DocsHeading } from "../../components/docs-heading"; +import { remoteTmuxDocsLocales } from "../../components/docs-nav-items"; + +function assertSupportedLocale(locale: string) { + if ( + !remoteTmuxDocsLocales.includes( + locale as (typeof remoteTmuxDocsLocales)[number], + ) + ) { + notFound(); + } +} export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; + assertSupportedLocale(locale); const t = await getTranslations({ locale, namespace: "docs.remoteTmux" }); return { title: t("metaTitle"), description: t("metaDescription"), - alternates: buildAlternates(locale, "/docs/remote-tmux"), + alternates: buildAlternates(locale, "/docs/remote-tmux", remoteTmuxDocsLocales), }; } -export default function RemoteTmuxPage() { - const t = useTranslations("docs.remoteTmux"); +export default async function RemoteTmuxPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + assertSupportedLocale(locale); + const t = await getTranslations({ locale, namespace: "docs.remoteTmux" }); return ( <> diff --git a/web/i18n/seo.ts b/web/i18n/seo.ts index 21683e5a900b..8bf4aa30da71 100644 --- a/web/i18n/seo.ts +++ b/web/i18n/seo.ts @@ -7,9 +7,13 @@ const BASE = "https://cmux.com"; * for a given locale and path. Use in every generateMetadata that * sets alternates so child metadata doesn't wipe parent hreflang. */ -export function buildAlternates(locale: string, path: string) { +export function buildAlternates( + locale: string, + path: string, + availableLocales: readonly string[] = locales, +) { const languages: Record<string, string> = {}; - for (const loc of locales) { + for (const loc of availableLocales) { languages[loc] = loc === "en" ? `${BASE}${path}` : `${BASE}/${loc}${path}`; } diff --git a/web/messages/ar.json b/web/messages/ar.json index 46f6dfd041b7..2b8950bb8668 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (تجريبي)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (تجريبي)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "اضبط القيم الافتراضية نفسها في ~/.config/cmux/cmux.json ضمن terminal:", "focusNote": "تحتفظ الجلسات المستعادة بحالة ظهور TextBox والتركيز المحفوظة. تنطبق هذه القيم الافتراضية فقط على أسطح الطرفية المنشأة حديثًا." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/bs.json b/web/messages/bs.json index ee99b1c39d4c..7fee08865066 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (Beta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Iste zadane vrijednosti postavite u ~/.config/cmux/cmux.json pod terminal:", "focusNote": "Obnovljene sesije zadržavaju spremljenu vidljivost TextBoxa i stanje fokusa. Ove zadane vrijednosti važe samo za novokreirane terminalske površine." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/da.json b/web/messages/da.json index 8fe3eb2fd7de..b50e3b743310 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (beta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (beta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Sæt de samme standarder i ~/.config/cmux/cmux.json under terminal:", "focusNote": "Gendannede sessioner bevarer deres gemte TextBox-synlighed og fokusstatus. Disse standarder gælder kun for nyoprettede terminalflader." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/de.json b/web/messages/de.json index aad3f3a0e5e0..feb9b35cad5e 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (Beta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Dieselben Standardwerte kannst du in ~/.config/cmux/cmux.json unter terminal setzen:", "focusNote": "Wiederhergestellte Sitzungen behalten ihre gespeicherte TextBox-Sichtbarkeit und ihren Fokusstatus. Diese Standardwerte gelten nur fuer neu erstellte Terminal-Oberflaechen." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/es.json b/web/messages/es.json index 5287209393d9..33b50261ab4d 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (Beta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Define los mismos valores predeterminados en ~/.config/cmux/cmux.json dentro de terminal:", "focusNote": "Las sesiones restauradas conservan su visibilidad y estado de foco de TextBox guardados. Estos valores solo se aplican a superficies de terminal nuevas." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/fr.json b/web/messages/fr.json index 39f14519ea9b..1d3e65a1bedb 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (bêta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (bêta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Définissez les mêmes valeurs par défaut dans ~/.config/cmux/cmux.json sous terminal :", "focusNote": "Les sessions restaurées conservent leur visibilité TextBox et leur état de focus enregistrés. Ces valeurs par défaut ne s'appliquent qu'aux nouvelles surfaces de terminal." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/it.json b/web/messages/it.json index 1bd401793d3a..0a0b5cdd5708 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (Beta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Imposta gli stessi valori predefiniti in ~/.config/cmux/cmux.json sotto terminal:", "focusNote": "Le sessioni ripristinate mantengono la visibilità e lo stato del focus di TextBox salvati. Questi valori si applicano solo alle nuove superfici terminale." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/km.json b/web/messages/km.json index 0e49cc56c225..60f1875cf37b 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (បេតា)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (បេតា)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "កំណត់លំនាំដើមដូចគ្នានៅក្នុង ~/.config/cmux/cmux.json ក្រោម terminal:", "focusNote": "Session ដែលបានស្តារឡើងវិញរក្សាស្ថានភាពបង្ហាញ និង focus របស់ TextBox ដែលបានរក្សាទុក។ លំនាំដើមទាំងនេះអនុវត្តតែចំពោះផ្ទៃស្ថានីយដែលទើបបង្កើតថ្មីប៉ុណ្ណោះ។" - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/ko.json b/web/messages/ko.json index cd822187add5..b16179b34842 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox(베타)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox(베타)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "동일한 기본값을 ~/.config/cmux/cmux.json의 terminal 아래에 설정할 수 있습니다.", "focusNote": "복원된 세션은 저장된 TextBox 표시 및 포커스 상태를 유지합니다. 이 기본값은 새로 생성된 터미널 화면에만 적용됩니다." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/no.json b/web/messages/no.json index 9468d5bc05d0..1ba6718d58fc 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (beta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (beta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Sett de samme standardverdiene i ~/.config/cmux/cmux.json under terminal:", "focusNote": "Gjenopprettede økter beholder lagret TextBox-synlighet og fokusstatus. Disse standardverdiene gjelder bare nyopprettede terminalflater." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/pl.json b/web/messages/pl.json index a7ab0978887f..99d15d7a0501 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (Beta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Te same ustawienia domyślne ustawisz w ~/.config/cmux/cmux.json w sekcji terminal:", "focusNote": "Przywrócone sesje zachowują zapisany stan widoczności i fokusu TextBox. Te ustawienia dotyczą tylko nowo utworzonych powierzchni terminala." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index bcdb7f7791f8..bc1fee59e3a8 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (Beta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Defina os mesmos padrões em ~/.config/cmux/cmux.json dentro de terminal:", "focusNote": "Sessões restauradas mantêm a visibilidade e o estado de foco do TextBox salvos. Esses padrões se aplicam apenas a superfícies de terminal recém-criadas." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/ru.json b/web/messages/ru.json index bdd6563c0713..8cf357e2d45e 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (бета)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (бета)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Те же значения по умолчанию можно задать в ~/.config/cmux/cmux.json в разделе terminal:", "focusNote": "Восстановленные сеансы сохраняют сохраненные видимость TextBox и состояние фокуса. Эти значения применяются только к новым поверхностям терминала." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/th.json b/web/messages/th.json index 4f145dd7222d..989137e7d874 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (เบต้า)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (เบต้า)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "ตั้งค่าเริ่มต้นเดียวกันได้ใน ~/.config/cmux/cmux.json ภายใต้ terminal:", "focusNote": "เซสชันที่กู้คืนจะเก็บสถานะการแสดงและโฟกัสของ TextBox ที่บันทึกไว้ ค่าเริ่มต้นเหล่านี้ใช้กับพื้นผิวเทอร์มินัลที่สร้างใหม่เท่านั้น" - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/tr.json b/web/messages/tr.json index bbb5341b029e..c829d1dd798a 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (Beta)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (Beta)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "Aynı varsayılanları ~/.config/cmux/cmux.json içinde terminal altında ayarlayın:", "focusNote": "Geri yüklenen oturumlar kayıtlı TextBox görünürlüğünü ve odak durumunu korur. Bu varsayılanlar yalnızca yeni oluşturulan terminal yüzeylerine uygulanır." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/uk.json b/web/messages/uk.json index d9f1a2e5b8fa..c71fc4b35336 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -1310,8 +1310,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox (бета)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox (бета)" }, "dock": { "metaTitle": "Dock", @@ -1460,59 +1459,6 @@ "configTitle": "cmux.json", "configDesc": "Ті самі типові значення можна встановити в ~/.config/cmux/cmux.json у розділі terminal:", "focusNote": "Відновлені сеанси зберігають записану видимість TextBox і стан фокуса. Ці типові значення застосовуються лише до новостворених поверхонь термінала." - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index f24d4ee3485b..9bdc64b1d0bd 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox(测试版)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox(测试版)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "也可以在 ~/.config/cmux/cmux.json 的 terminal 下设置相同默认值:", "focusNote": "恢复的会话会保留已保存的 TextBox 可见性和焦点状态。这些默认值只适用于新建的终端界面。" - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 50b7c0e0c35a..53fa3690c5db 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -1225,8 +1225,7 @@ "ohMyCodex": "oh-my-codex", "ohMyClaudeCode": "oh-my-claudecode", "dock": "Dock", - "textBox": "TextBox(測試版)", - "remoteTmux": "Remote tmux" + "textBox": "TextBox(測試版)" }, "dock": { "metaTitle": "Dock", @@ -1375,59 +1374,6 @@ "configTitle": "cmux.json", "configDesc": "也可以在 ~/.config/cmux/cmux.json 的 terminal 下設定相同預設值:", "focusNote": "還原的工作階段會保留已儲存的 TextBox 顯示和焦點狀態。這些預設值只會套用到新建的終端機介面。" - }, - "remoteTmux": { - "title": "Remote tmux", - "metaTitle": "Remote tmux (beta)", - "metaDescription": "Drive a remote tmux session from cmux over SSH using tmux control mode. Sessions become workspaces, windows become tabs, and a window's panes become a native cmux split.", - "intro": "Remote tmux mirrors a tmux session running on a remote machine into cmux over SSH, using tmux control mode (tmux -CC). Instead of a plain SSH terminal, the remote session is reprojected into cmux's native UI — workspaces, tabs, splits, scrollback, and copy — and cmux drives the real tmux server behind it. It is a Beta Features flag and does not change local terminals.", - "betaNote": "Beta. This feature is opt-in and still stabilizing — see the limitations below.", - "mappingTitle": "How tmux maps to cmux", - "mappingIntro": "cmux and tmux nest their layouts in opposite directions. In cmux, a pane holds a row of tabs (each tab is one terminal). In tmux, a window holds a split of panes. Remote tmux bridges the two by projecting tmux's structure onto cmux's:", - "mapTmux": "tmux", - "mapCmux": "cmux", - "rowSession": "A dedicated workspace in the sidebar.", - "rowWindow": "A tab in that workspace.", - "rowPane": "A pane in a native split inside that tab.", - "mappingPanes": "The new capability is panes inside a tab: a cmux tab used to hold a single terminal, but a tmux window with several panes is now rendered as a real cmux split layout within one tab — each remote pane is a native cmux terminal pane with the usual chrome. The bridge is two-way: splitting or closing a pane in that tab runs tmux split-window, and reordering the tabs reorders the tmux windows with swap-window.", - "requirementsTitle": "Requirements", - "requirementsDesc": "A reachable SSH host (cmux reads your ~/.ssh/config) with tmux installed. Control mode (tmux -CC) is a standard tmux feature, so no special build is needed; a current tmux is recommended for the full feature set. cmux attaches over an SSH ControlMaster connection, and the remote tmux server keeps running when you detach.", - "enableTitle": "Enable it", - "enableDesc": "Open Settings → Beta Features and turn on Remote tmux. The toggle is off by default, so nothing changes for local terminals until you opt in.", - "attachTitle": "Attaching", - "attachIntro": "Run cmux ssh-tmux <destination> in a terminal — an ~/.ssh/config alias or user@host. cmux opens a new window that mirrors that host's tmux sessions: each session becomes a workspace, each window a tab, and multi-pane windows become in-tab splits.", - "attachSockets": "The remote.tmux.* socket commands (below) give finer control — for example, remote.tmux.mirror mirrors a host's sessions into the current window's sidebar instead of opening a dedicated window.", - "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then opens the mirror window. Accepts --port and --identity.", - "troubleshootTitle": "If you get \"Permission denied\"", - "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", - "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", - "howTitle": "How it works", - "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", - "behaviorTitle": "What it supports", - "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", - "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", - "behaviorReorder": "Reordering: drag-reordering the mirrored tabs reorders the tmux windows with swap-window.", - "behaviorCwd": "Working directory: the remote pane's current folder is tracked and shown on the tab.", - "behaviorPaste": "Paste & drop: pasted text and dropped images/files go through tmux paste-buffer -p, so a remote app (e.g. a coding agent) receives a real bracketed paste — an image arrives as [Image #N], not a local path.", - "behaviorMouse": "Mouse: clicks, scroll, and drag reach the remote app when it has mouse mode on. Hold Shift while dragging for a native cmux text selection/copy (the same as any terminal running a mouse-mode app).", - "behaviorUnicode": "Unicode-correct output: multi-byte characters are preserved even when tmux splits them across updates, so box-drawing and other wide content render cleanly.", - "socketTitle": "Socket commands", - "socketDesc": "The feature is also driven by socket commands (gated on the Beta Features flag). host is an SSH destination or ~/.ssh/config alias; session is a tmux session name.", - "socketMethod": "Method", - "socketParams": "Params", - "socketMeaning": "Description", - "methodSessions": "List the tmux sessions on a host.", - "methodAttach": "Attach a control client to a session (create attaches-or-creates).", - "methodMirror": "Mirror every session on a host, each as its own workspace (windows become tabs).", - "methodWindow": "Open a dedicated window mirroring every session on a host (the cmux ssh-tmux entry point); returns the ssh command to run for interactive auth when the host needs it.", - "methodDetach": "Detach the control client; the remote session keeps running.", - "methodState": "Report the control client's observed state (diagnostics).", - "socketSafetyDesc": "A dash-prefixed host or identity file is rejected at the trust boundary as a defense against SSH option injection.", - "limitationsTitle": "Limitations", - "limitReconnect": "Transient SSH drops reconnect automatically: if the connection blips mid-session the mirror freezes and cmux retries with capped exponential backoff, re-seeding the panes when it resumes. The mirror only closes for good when the remote session itself ends, or when you close the mirror window or quit cmux — and it isn't restored on relaunch (re-attach with cmux ssh-tmux).", - "limitPaste": "Only single-line paste/drop (such as a file or image path) is delivered as a real bracketed paste via paste-buffer -p; multi-line text is sent as plain keystrokes, so a remote app won't treat it as one paste.", - "limitCwd": "Live working-directory updates use control-mode subscriptions (tmux 3.2+); on older tmux the tab shows the initial folder but doesn't update on cd.", - "limitReflow": "Primary-screen scrollback isn't re-wrapped on resize — cmux leaves reflow to tmux, so older lines can stay at the previous width until the app repaints. Full-screen TUIs, which redraw on resize, are unaffected." } }, "legal": { From 4d837470ebe2e10c77a8c3c1036d696e10399f94 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sun, 14 Jun 2026 00:12:24 -0700 Subject: [PATCH 69/73] Keep remote tmux control file within budget --- Sources/RemoteTmuxControlConnection.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 750a5ed0a226..75797fa63c9e 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -1274,12 +1274,8 @@ final class RemoteTmuxControlConnection { // stream-end (see `handleConnectionExited` → `handleSessionEndedRemotely`), // which is the path that closes the workspace / dedicated window. if !order.isEmpty { - // Replace the topology, don't merge: a window closed remotely while we - // were disconnected never delivers a %window-close (it lands on the dead - // connection), so merging would leave it lingering in - // windowsByID/activePaneByWindow/paneOutputByteCounts and - // reseedAfterReconnect (which iterates windowsByID) would re-capture its - // dead panes. Prune anything not in the fresh reply. + // Replace topology instead of merging: a remote close missed while + // disconnected leaves no %window-close, so prune stale panes here. let liveIDs = Set(order) windowsByID = next activePaneByWindow = activePaneByWindow.filter { liveIDs.contains($0.key) } From 1e02fd4691af8b7e83ac6c73fa913acb675e90c1 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Sun, 14 Jun 2026 00:27:51 -0700 Subject: [PATCH 70/73] Close remote tmux pipe writer on writer queue --- Sources/RemoteTmuxControlPipeWriter.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/RemoteTmuxControlPipeWriter.swift b/Sources/RemoteTmuxControlPipeWriter.swift index dffd3e54ad16..7fbfefd564a7 100644 --- a/Sources/RemoteTmuxControlPipeWriter.swift +++ b/Sources/RemoteTmuxControlPipeWriter.swift @@ -59,6 +59,8 @@ final class RemoteTmuxControlPipeWriter { func close() { guard !closed else { return } closed = true - try? handle.close() + queue.async { [handle] in + try? handle.close() + } } } From 503aed2ab5ca881001f2d2c8b56ce32af3982f31 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Sun, 14 Jun 2026 11:32:24 +0300 Subject: [PATCH 71/73] Fix double-free crash on manual-I/O surface resize (display-scale change) The mirror (manual-I/O) resize path called ghostty_surface_render_now synchronously on the main thread after ghostty_surface_set_size. On macOS the renderer thread is always live and runs updateFrame on its own wakeups, so render_now's synchronous main-thread updateFrame races it: a grid-size change tears down RenderState cell arrays / the shaper on one thread while the other reads them, yielding a double-free / use-after-free (EXC_BAD_ACCESS at the 0xaa.. poison pattern). render_now exists for iOS, which has no renderer thread; this package is macOS-only, so the renderer thread always exists and render_now is the wrong primitive here. Switch to async ghostty_surface_refresh so only the renderer thread runs updateFrame (the grid paints one frame later, which is cosmetic). The DECAWM no-reflow re-enable stays in call order after the resize, so manual-I/O reflow suppression is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../Surface/TerminalSurface+Sizing.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift index f661c3ec3401..cfe82ceb7c17 100644 --- a/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift +++ b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift @@ -108,7 +108,17 @@ extension TerminalSurface { lastPixelWidth = wpx lastPixelHeight = hpx if manualIO { - ghostty_surface_render_now(surface) + // Repaint via the renderer thread instead of a synchronous + // main-thread `ghostty_surface_render_now`. render_now runs the + // full updateFrame on the main thread while the always-live + // macOS renderer thread runs it too, so a grid-size change races + // the shaper / RenderState teardown → double-free / use-after-free + // (crash on display-move backing-scale changes, upstream #6040). + // `refresh` only wakes the renderer thread, so updateFrame runs on + // one thread; the grid paints a frame later (cosmetic). The DECAWM + // re-enable stays in call order after the resize, preserving the + // manual-I/O no-reflow wrapping. + ghostty_surface_refresh(surface) if suppressManualReflow { writeProcessOutputData(Self.decawmEnableSequence, to: surface) } From 3f18017f6b24e4adb2213f75680c1850416d4c02 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu <github@nisipeanu.com> Date: Sun, 14 Jun 2026 11:39:50 +0300 Subject: [PATCH 72/73] Tighten render_now->refresh comment to the load-bearing constraints Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../Surface/TerminalSurface+Sizing.swift | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift index cfe82ceb7c17..502f38218af0 100644 --- a/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift +++ b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift @@ -108,16 +108,10 @@ extension TerminalSurface { lastPixelWidth = wpx lastPixelHeight = hpx if manualIO { - // Repaint via the renderer thread instead of a synchronous - // main-thread `ghostty_surface_render_now`. render_now runs the - // full updateFrame on the main thread while the always-live - // macOS renderer thread runs it too, so a grid-size change races - // the shaper / RenderState teardown → double-free / use-after-free - // (crash on display-move backing-scale changes, upstream #6040). - // `refresh` only wakes the renderer thread, so updateFrame runs on - // one thread; the grid paints a frame later (cosmetic). The DECAWM - // re-enable stays in call order after the resize, preserving the - // manual-I/O no-reflow wrapping. + // Async refresh, not render_now: render_now runs updateFrame on + // the main thread and races the always-live macOS renderer + // thread on a grid-size change (shaper double-free). Keep the + // DECAWM re-enable after the resize so no-reflow ordering holds. ghostty_surface_refresh(surface) if suppressManualReflow { writeProcessOutputData(Self.decawmEnableSequence, to: surface) From c690dc74f0d6ba880a4e6f6955b5846f1539ec93 Mon Sep 17 00:00:00 2001 From: Aziz Albahar <abdulaziz@albahar.net> Date: Mon, 15 Jun 2026 12:05:55 -0700 Subject: [PATCH 73/73] Mark remote tmux onResize closure @Sendable to satisfy warning budget addRemoteTmuxDisplayPane assigned the onResize parameter to the @Sendable-requiring TerminalSurface.onManualGridResize property, producing a "non-sendable parameter to @Sendable closure" warning that exceeded the swift-warning-budget (budget 0 for this bucket). The closure is genuinely main-actor-only: its sole caller (RemoteTmuxSessionMirror.rebuild) captures the @MainActor final (implicitly Sendable) RemoteTmuxControlConnection weakly and calls setClientSize on the main actor. Marking the parameter @MainActor @Sendable, matching the sibling onInput parameter, removes the conversion warning without changing behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- Sources/Workspace.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 38cb7142ec8d..6a49a44a7fd4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7536,7 +7536,7 @@ final class Workspace: Identifiable, ObservableObject { title customTitle: String? = nil, focus: Bool = false, onInput: @escaping @Sendable (Data) -> Void, - onResize: (@MainActor (_ columns: Int, _ rows: Int) -> Void)? = nil + onResize: (@MainActor @Sendable (_ columns: Int, _ rows: Int) -> Void)? = nil ) -> TerminalPanel? { guard let paneId = bonsplitController.focusedPaneId ?? bonsplitController.allPaneIds.first else { return nil }