From b0f134bada09c405a974663411e53d3163ff5972 Mon Sep 17 00:00:00 2001 From: Eric Chee Date: Tue, 7 Apr 2026 12:36:36 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20tmux=20control=20mode=20=E2=80=94=20ses?= =?UTF-8?q?sion=20picker,=20full=20layout=20tree,=20reconciler=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session picker: list and attach to existing remote tmux sessions from the UI - Full recursive layout tree parser (TmuxLayoutNode/TmuxLayout) replacing flat pane-id extraction; geometry, split direction, and zoom flag all preserved - TmuxLayoutReconciler rewritten with TmuxReconcilerWorkspace protocol seam, orphan-based move-pane handling, window-scoped pending panel FIFO, and deferred purge task for break-pane/join-pane without panel recreation - Zoom reconciliation fix: removed skip guard — always reconcile on %layout-change regardless of zoom state; isZoomed exposed for display only - %window-pane-changed now calls focusPanel (was only logging) - New protocol stubs: %pane-mode-changed, %paste-buffer-changed, %client-session-changed - Dead daemon tmux.control.subscribe/send transport removed (was unused; direct SSH tmux -CC in Workspace.swift is the live path) - tmuxExactTarget consistency: all session-targeting handlers now use exact-match prefix - Watchdog escalation: silence timeout sets remoteConnectionState = .disconnected - TmuxSessionPickerSheet moved to Sources/Panels/ - 37 TmuxControlParserTests + 16 TmuxLayoutReconcilerTests + 2 Go adapter tests --- GhosttyTabs.xcodeproj/project.pbxproj | 24 + Resources/Localizable.xcstrings | 152 +++ Sources/Panels/TmuxSessionPickerSheet.swift | 157 +++ Sources/SentrySymbolizerStub.c | 47 + Sources/TmuxControlParser.swift | 339 +++++++ Sources/TmuxLayoutReconciler.swift | 251 +++++ Sources/Workspace.swift | 945 +++++++++++++++++- Sources/WorkspaceContentView.swift | 3 + cmuxTests/TmuxControlParserTests.swift | 327 ++++++ cmuxTests/TmuxLayoutReconcilerTests.swift | 364 +++++++ daemon/remote/cmd/cmuxd-remote/main.go | 14 + .../remote/cmd/cmuxd-remote/tmux_adapter.go | 383 +++++++ .../cmd/cmuxd-remote/tmux_adapter_test.go | 88 ++ 13 files changed, 3088 insertions(+), 6 deletions(-) create mode 100644 Sources/Panels/TmuxSessionPickerSheet.swift create mode 100644 Sources/SentrySymbolizerStub.c create mode 100644 Sources/TmuxControlParser.swift create mode 100644 Sources/TmuxLayoutReconciler.swift create mode 100644 cmuxTests/TmuxControlParserTests.swift create mode 100644 cmuxTests/TmuxLayoutReconcilerTests.swift create mode 100644 daemon/remote/cmd/cmuxd-remote/tmux_adapter.go create mode 100644 daemon/remote/cmd/cmuxd-remote/tmux_adapter_test.go diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ab8673d1fd..cf7c258019 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -25,6 +25,10 @@ A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; + A5001602 /* SentrySymbolizerStub.c in Sources */ = {isa = PBXBuildFile; fileRef = A5001603 /* SentrySymbolizerStub.c */; }; + A5001604 /* TmuxSessionPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001605 /* TmuxSessionPickerSheet.swift */; }; + A5001606 /* TmuxControlParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001607 /* TmuxControlParser.swift */; }; + A5001608 /* TmuxLayoutReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001609 /* TmuxLayoutReconciler.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; D1320AA0D1320AA0D1320AA2 /* CmuxDockTilePlugin.plugin in Copy Dock Tile Plugin */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -122,6 +126,8 @@ 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */; }; + F5ADBBF1059E95176B66E842 /* TmuxControlParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3176B9EF001F07720476B3CE /* TmuxControlParserTests.swift */; }; + E816ABB9D0A581FCAD1BAF19 /* TmuxLayoutReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657304DC9CA7CF79E3E9463E /* TmuxLayoutReconcilerTests.swift */; }; 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; }; 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; @@ -225,6 +231,10 @@ A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; + A5001603 /* SentrySymbolizerStub.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = SentrySymbolizerStub.c; sourceTree = ""; }; + A5001605 /* TmuxSessionPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TmuxSessionPickerSheet.swift; sourceTree = ""; }; + A5001607 /* TmuxControlParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControlParser.swift; sourceTree = ""; }; + A5001609 /* TmuxLayoutReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxLayoutReconciler.swift; sourceTree = ""; }; A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; }; D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = ""; }; A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; }; @@ -320,6 +330,8 @@ 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = ""; }; + 3176B9EF001F07720476B3CE /* TmuxControlParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControlParserTests.swift; sourceTree = ""; }; + 657304DC9CA7CF79E3E9463E /* TmuxLayoutReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxLayoutReconcilerTests.swift; sourceTree = ""; }; BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; }; 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = ""; }; BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; @@ -479,6 +491,10 @@ A5001545 /* TerminalSSHSessionDetector.swift */, A5001225 /* SocketControlSettings.swift */, A5001600 /* SentryHelper.swift */, + A5001603 /* SentrySymbolizerStub.c */, + A5001605 /* TmuxSessionPickerSheet.swift */, + A5001607 /* TmuxControlParser.swift */, + A5001609 /* TmuxLayoutReconciler.swift */, A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, @@ -600,6 +616,8 @@ 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */, 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */, + 3176B9EF001F07720476B3CE /* TmuxControlParserTests.swift */, + 657304DC9CA7CF79E3E9463E /* TmuxLayoutReconcilerTests.swift */, BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */, BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, @@ -805,6 +823,10 @@ A5001543 /* TerminalSSHSessionDetector.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */, + A5001602 /* SentrySymbolizerStub.c in Sources */, + A5001604 /* TmuxSessionPickerSheet.swift in Sources */, + A5001606 /* TmuxControlParser.swift in Sources */, + A5001608 /* TmuxLayoutReconciler.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, @@ -900,6 +922,8 @@ 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */, 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */, + F5ADBBF1059E95176B66E842 /* TmuxControlParserTests.swift in Sources */, + E816ABB9D0A581FCAD1BAF19 /* TmuxLayoutReconcilerTests.swift in Sources */, 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */, CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 5590e18511..bef7d523c6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -87289,6 +87289,158 @@ } } } + }, + "tmux.picker.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect to tmux Session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "tmuxセッションに接続" + } + } + } + }, + "tmux.picker.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This remote host has tmux. Select a session to attach to, or create a new one. Your shells will survive network disconnects." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このリモートホストはtmuxが使えます。接続するセッションを選択するか、新規作成してください。シェルはネットワーク切断後も維持されます。" + } + } + } + }, + "tmux.picker.existing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Existing sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "既存のセッション" + } + } + } + }, + "tmux.picker.windows.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { "state": "translated", "value": "1 window" } + }, + "ja": { + "stringUnit": { "state": "translated", "value": "1ウィンドウ" } + } + } + }, + "tmux.picker.windows.many": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { "state": "translated", "value": "%lld windows" } + }, + "ja": { + "stringUnit": { "state": "translated", "value": "%lldウィンドウ" } + } + } + }, + "tmux.picker.attached": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { "state": "translated", "value": " · attached" } + }, + "ja": { + "stringUnit": { "state": "translated", "value": " · 接続中" } + } + } + }, + "tmux.picker.new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create new session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規セッションを作成" + } + } + } + }, + "tmux.picker.create": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成" + } + } + } + }, + "tmux.picker.skip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップ" + } + } + } + }, + "tmux.picker.invalid_name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session names may only contain letters, digits, - and _." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション名には英数字、-、_のみ使用できます。" + } + } + } } } } diff --git a/Sources/Panels/TmuxSessionPickerSheet.swift b/Sources/Panels/TmuxSessionPickerSheet.swift new file mode 100644 index 0000000000..e0498e229b --- /dev/null +++ b/Sources/Panels/TmuxSessionPickerSheet.swift @@ -0,0 +1,157 @@ +import SwiftUI + +/// Sheet shown after connecting to a remote that has tmux available. +/// Lets the user pick an existing session or create a new one. +struct TmuxSessionPickerSheet: View { + @ObservedObject var workspace: Workspace + @Environment(\.dismiss) private var dismiss + + @State private var newSessionName: String = "" + /// Prevents double-submission if the user taps Create rapidly. + @State private var isSubmitting: Bool = false + + private var suggestedNewName: String { + "cmux-\(workspace.id.uuidString.prefix(8).lowercased())" + } + + /// Returns true when the effective new-session name is acceptable to tmux. + private var newNameIsValid: Bool { + let name = effectiveNewName + return name.range(of: #"^[a-zA-Z0-9_-]+$"#, options: .regularExpression) != nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text(String(localized: "tmux.picker.title", defaultValue: "Connect to tmux Session")) + .font(.title3.weight(.semibold)) + + Text( + String( + localized: "tmux.picker.subtitle", + defaultValue: "This remote host has tmux. Select a session to attach to, or create a new one. Your shells will survive network disconnects." + ) + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if !workspace.remoteTmuxSessions.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "tmux.picker.existing", defaultValue: "Existing sessions")) + .font(.subheadline.weight(.medium)) + + VStack(spacing: 2) { + ForEach(workspace.remoteTmuxSessions) { session in + Button { + guard !isSubmitting else { return } + isSubmitting = true + // Do NOT call dismiss() here. The sheet is presented via + // `showTmuxSessionPicker` and will be dismissed automatically + // when `applyRemoteTmuxSession` sets that flag to false on + // a successful attach. This keeps the picker open if attach fails. + workspace.selectTmuxSession(session.name) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(session.name) + .font(.system(size: 13, design: .monospaced)) + Text(sessionInfoLine(session)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "arrow.right.circle") + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + } + } + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "tmux.picker.new", defaultValue: "Create new session")) + .font(.subheadline.weight(.medium)) + + HStack(spacing: 8) { + TextField(suggestedNewName, text: $newSessionName) + .font(.system(size: 13, design: .monospaced)) + .textFieldStyle(.roundedBorder) + .onSubmit { createSession() } + .disabled(isSubmitting) + + Button(String(localized: "tmux.picker.create", defaultValue: "Create")) { + createSession() + } + .keyboardShortcut(.defaultAction) + .disabled(isSubmitting || !newNameIsValid) + } + + if !newSessionName.isEmpty && !newNameIsValid { + Text(String( + localized: "tmux.picker.invalid_name", + defaultValue: "Session names may only contain letters, digits, - and _." + )) + .font(.system(size: 11)) + .foregroundStyle(.red) + } + } + + HStack { + Spacer() + Button(String(localized: "tmux.picker.skip", defaultValue: "Skip")) { + // Use skipTmuxPicker() rather than dismiss() so the workspace can + // record that the user explicitly opted out. Without this, subsequent + // terminal lifecycle events would immediately re-show the picker. + workspace.skipTmuxPicker() + } + .keyboardShortcut(.cancelAction) + .disabled(isSubmitting) + } + } + .padding(24) + .frame(width: 460) + .onChange(of: workspace.remoteDaemonStatus) { status in + // If the daemon reports an error after we submitted, the attach failed. + // Reset isSubmitting so the user can retry or choose a different session. + if isSubmitting, status.state == .error { + isSubmitting = false + } + } + } + + private func sessionInfoLine(_ session: RemoteTmuxSession) -> String { + let windowPart = session.windows == 1 + ? String(localized: "tmux.picker.windows.one", defaultValue: "1 window") + : String(format: String(localized: "tmux.picker.windows.many", defaultValue: "%lld windows"), + Int64(session.windows)) + if session.attached { + return windowPart + String(localized: "tmux.picker.attached", defaultValue: " · attached") + } + return windowPart + } + + private var effectiveNewName: String { + let trimmed = newSessionName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? suggestedNewName : trimmed + } + + private func createSession() { + guard !isSubmitting, newNameIsValid else { return } + let name = effectiveNewName + guard !name.isEmpty else { return } + isSubmitting = true + // Do NOT call dismiss() here. The sheet is dismissed automatically when + // `applyRemoteTmuxSession` sets `showTmuxSessionPicker = false` on success. + // This keeps the picker visible (with the spinner) if attachment fails. + workspace.selectTmuxSession(name) + } +} diff --git a/Sources/SentrySymbolizerStub.c b/Sources/SentrySymbolizerStub.c new file mode 100644 index 0000000000..a043525525 --- /dev/null +++ b/Sources/SentrySymbolizerStub.c @@ -0,0 +1,47 @@ +/* + * SentrySymbolizerStub.c + * + * Provides sentry__symbolize() which is referenced by sentry_scope.o inside + * libghostty.a but was omitted from the archive during xcframework assembly + * (static-library merging with libtool only pulls in object files that resolve + * external references; sentry_symbolizer_unix.o is only referenced internally + * within libsentry.a and therefore gets dropped). + * + * This implementation is functionally identical to the upstream + * sentry-native sentry_symbolizer_unix.c (MIT License). + */ + +#include +#include +#include + +/* Mirror of sentry_frame_info_t from the sentry-native C SDK. */ +typedef struct { + const char *object_name; + void *load_addr; + void *symbol_addr; + void *instruction_addr; + const char *symbol; + int lineno; + const char *filename; +} sentry_frame_info_t; + +bool +sentry__symbolize( + void *addr, void (*func)(const sentry_frame_info_t *, void *), void *data) +{ + Dl_info info; + if (dladdr(addr, &info) == 0) { + return false; + } + + sentry_frame_info_t frame_info; + memset(&frame_info, 0, sizeof(frame_info)); + frame_info.load_addr = info.dli_fbase; + frame_info.symbol_addr = info.dli_saddr; + frame_info.instruction_addr = addr; + frame_info.symbol = info.dli_sname; + frame_info.object_name = info.dli_fname; + func(&frame_info, data); + return true; +} diff --git a/Sources/TmuxControlParser.swift b/Sources/TmuxControlParser.swift new file mode 100644 index 0000000000..86090f7744 --- /dev/null +++ b/Sources/TmuxControlParser.swift @@ -0,0 +1,339 @@ +import Foundation + +// MARK: - Future tmux protocol features (not yet implemented) +// +// TODO(tmux-phase3): %pause % / %continue — pause-mode backpressure (tmux ≥3.2). +// Requires per-pane buffer monitoring and `refresh-client -f pause-after=`. +// +// TODO(tmux-phase3): %subscription-changed ... — subscription-based option monitoring +// (tmux ≥3.2). Replaces polling for format/option values. +// +// TODO(tmux-phase3): Pane history retrieval — capture-pane -p -S - to populate +// scrollback buffer on attach so users see prior output. +// +// TODO(tmux-phase3): Copy-mode exit on attach — send `copy-mode -q` if pane starts +// in copy mode (e.g. tmux.conf errors). Check pane_in_mode format flag. +// +// TODO(tmux-phase3): Per-window resize via `refresh-client -C` (tmux ≥3.4) so each +// window can have its own terminal size rather than a single global size. +// +// TODO(tmux-phase3): Focus events — check/set `focus-events` tmux option so apps +// that need focus state (vim, emacs) behave correctly in tmux panes. +// +// TODO(tmux-phase3): Double-attach detection — check @cmux_id session variable to +// detect re-entry from same or different cmux instance before attaching. +// +// TODO(tmux-phase3): Session variable persistence — save/restore remoteTmuxSessionName +// via `@cmux_` session variable so reattach works after app restart +// without needing to re-show the session picker. + +// MARK: - Layout tree types + +/// Geometry and identity of a leaf tmux pane in a layout string. +struct TmuxPaneGeometry: Equatable { + let paneId: String // e.g. "%3" + let width: Int + let height: Int + let x: Int + let y: Int +} + +/// A node in the tmux layout tree. Leaf nodes represent panes; inner nodes +/// represent horizontal (`{...}`) or vertical (`[...]`) splits. +indirect enum TmuxLayoutNode: Equatable { + /// A leaf pane with its geometry. + case pane(TmuxPaneGeometry) + /// A horizontal split (`{...}`) — children are arranged side-by-side. + case horizontal([TmuxLayoutNode], width: Int, height: Int, x: Int, y: Int) + /// A vertical split (`[...]`) — children are stacked top-to-bottom. + case vertical([TmuxLayoutNode], width: Int, height: Int, x: Int, y: Int) + + /// All pane IDs reachable from this node, in tree traversal order. + var allPaneIds: [String] { + switch self { + case .pane(let g): + return [g.paneId] + case .horizontal(let children, _, _, _, _), + .vertical(let children, _, _, _, _): + return children.flatMap(\.allPaneIds) + } + } + + static func == (lhs: TmuxLayoutNode, rhs: TmuxLayoutNode) -> Bool { + switch (lhs, rhs) { + case (.pane(let a), .pane(let b)): + return a == b + case let (.horizontal(ac, aw, ah, ax, ay), .horizontal(bc, bw, bh, bx, by)), + let (.vertical(ac, aw, ah, ax, ay), .vertical(bc, bw, bh, bx, by)): + return ac == bc && aw == bw && ah == bh && ax == bx && ay == by + default: + return false + } + } +} + +/// A fully parsed tmux window layout. +struct TmuxLayout: Equatable { + let windowId: String + /// Raw window-flags token from `%layout-change` (e.g. `"*Z"`, `""`). + let windowFlags: String + let root: TmuxLayoutNode + + /// True when the window flags include "Z" (zoomed pane active). + var isZoomed: Bool { windowFlags.contains("Z") } + + /// All pane IDs in the layout, in tree traversal order. + var allPaneIds: [String] { root.allPaneIds } +} + +// MARK: - Events + +/// An event emitted by tmux control mode (`tmux -CC`). +enum TmuxControlEvent { + /// Layout changed in a window. The full layout tree is included. + case layoutChange(layout: TmuxLayout) + /// A new window was added to the session. + case windowAdd(window: String) + /// A window was closed. + case windowClose(window: String) + /// The session was renamed. Format: `%session-renamed $ `. + case sessionRenamed(sessionId: String, newName: String) + /// One or more sessions were added, removed, or changed. + case sessionsChanged + /// A window was renamed. + case windowRenamed(window: String, newName: String) + /// The active window in a session changed. + case sessionWindowChanged(sessionId: String, window: String) + /// The active pane in a window changed. + case windowPaneChanged(window: String, paneId: String) + /// A pane's mode changed (e.g. entered or exited copy mode). tmux ≥2.5. + case paneModeChanged(paneId: String, mode: String) + /// A paste buffer was added or modified. + case pasteBufferChanged + /// A client switched to a different session. tmux ≥3.6. + case clientSessionChanged + /// tmux control mode is exiting. + case exit +} + +// MARK: - Parser + +/// Parses lines from `tmux -CC` control mode stdout. +struct TmuxControlParser { + private init() {} + + /// Parse a single raw line. Returns `nil` for lines that are not known events + /// (e.g. `%begin`/`%end` wrappers, escape sequences, blank lines). + static func parseLine(_ raw: String) -> TmuxControlEvent? { + // Strip leading/trailing whitespace and carriage returns. + var line = raw + while line.hasSuffix("\r") || line.hasSuffix("\n") { + line.removeLast() + } + line = line.trimmingCharacters(in: .whitespaces) + + // Skip DCS escape sequences and anything without the % prefix. + guard line.hasPrefix("%") else { return nil } + + // Split on the first two spaces only so the layout string (which may + // contain commas but not spaces) is preserved intact. + let tokens = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: false) + .map(String.init) + guard !tokens.isEmpty else { return nil } + + switch tokens[0] { + case "%layout-change": + // Format: %layout-change @ [] [] + // We split with maxSplits:2 above so tokens[2] may contain multiple space-separated + // tokens for visibleLayout and windowFlags. Extract them lazily. + guard tokens.count >= 3 else { return nil } + let rest = tokens[2].split(separator: " ", maxSplits: 2, omittingEmptySubsequences: false) + .map(String.init) + let layoutStr = rest[0] + // windowFlags is the 3rd token if present (e.g. "Z" for zoomed, "*" for current). + let windowFlags = rest.count >= 3 ? rest[2] : "" + guard let layout = parseLayoutTree(windowId: tokens[1], flags: windowFlags, + layoutString: layoutStr) else { + return nil + } + return .layoutChange(layout: layout) + + case "%window-add": + guard tokens.count >= 2 else { return nil } + return .windowAdd(window: tokens[1]) + + case "%window-close": + guard tokens.count >= 2 else { return nil } + return .windowClose(window: tokens[1]) + + case "%session-renamed": + // Format: %session-renamed $ + guard tokens.count >= 3 else { return nil } + return .sessionRenamed(sessionId: tokens[1], newName: tokens[2]) + + case "%sessions-changed": + return .sessionsChanged + + case "%window-renamed": + // Format: %window-renamed @ + guard tokens.count >= 3 else { return nil } + return .windowRenamed(window: tokens[1], newName: tokens[2]) + + case "%session-window-changed": + // Format: %session-window-changed $ @ + guard tokens.count >= 3 else { return nil } + return .sessionWindowChanged(sessionId: tokens[1], window: tokens[2]) + + case "%window-pane-changed": + // Format: %window-pane-changed @ % + guard tokens.count >= 3 else { return nil } + return .windowPaneChanged(window: tokens[1], paneId: tokens[2]) + + case "%pane-mode-changed": + // Format: %pane-mode-changed % (tmux ≥2.5) + guard tokens.count >= 2 else { return nil } + let mode = tokens.count >= 3 ? tokens[2] : "" + return .paneModeChanged(paneId: tokens[1], mode: mode) + + case "%paste-buffer-changed": + return .pasteBufferChanged + + case "%client-session-changed": + return .clientSessionChanged + + case "%exit": + return .exit + + default: + return nil + } + } + + // MARK: - Layout tree parser + + /// Parse a tmux layout string into a `TmuxLayout` tree. + static func parseLayoutTree(windowId: String, flags: String, layoutString: String) -> TmuxLayout? { + var idx = layoutString.startIndex + skipChecksumIfPresent(layoutString, idx: &idx) + guard let root = parseNode(layoutString, idx: &idx) else { return nil } + return TmuxLayout(windowId: windowId, windowFlags: flags, root: root) + } + + /// Return all pane IDs from a layout string, sorted numerically. + /// + /// This is a convenience wrapper around `parseLayoutTree` for standalone use + /// (e.g. unit tests and callers that only need the ID list). + static func extractPaneIds(from layoutStr: String) -> [String] { + guard let layout = parseLayoutTree(windowId: "", flags: "", layoutString: layoutStr) else { + return [] + } + return layout.allPaneIds.sorted { a, b in + let na = Int(a.dropFirst()) ?? 0 + let nb = Int(b.dropFirst()) ?? 0 + return na < nb + } + } + + // MARK: - Private recursive tree parser + + /// Parse one layout node starting at `idx`. Advances `idx` past the node. + /// + /// Grammar (after optional checksum prefix): + /// ``` + /// node = WxH,X,Y,ID (leaf pane) + /// | WxH,X,Y{node,node,...} (horizontal split) + /// | WxH,X,Y[node,node,...] (vertical split) + /// ``` + private static func parseNode(_ s: String, idx: inout String.Index) -> TmuxLayoutNode? { + guard let (w, h, x, y) = readGeometry(s, idx: &idx), + idx < s.endIndex else { return nil } + + switch s[idx] { + case ",": + // Leaf pane: next token after "," is the numeric pane ID. + s.formIndex(after: &idx) + let n = readDigits(s, idx: &idx) + guard !n.isEmpty else { return nil } + return .pane(TmuxPaneGeometry(paneId: "%" + n, width: w, height: h, x: x, y: y)) + + case "{": + // Horizontal split. + s.formIndex(after: &idx) + var children: [TmuxLayoutNode] = [] + while idx < s.endIndex && s[idx] != "}" { + if let child = parseNode(s, idx: &idx) { children.append(child) } + if idx < s.endIndex && s[idx] == "," { s.formIndex(after: &idx) } + } + if idx < s.endIndex { s.formIndex(after: &idx) } // consume "}" + return .horizontal(children, width: w, height: h, x: x, y: y) + + case "[": + // Vertical split. + s.formIndex(after: &idx) + var children: [TmuxLayoutNode] = [] + while idx < s.endIndex && s[idx] != "]" { + if let child = parseNode(s, idx: &idx) { children.append(child) } + if idx < s.endIndex && s[idx] == "," { s.formIndex(after: &idx) } + } + if idx < s.endIndex { s.formIndex(after: &idx) } // consume "]" + return .vertical(children, width: w, height: h, x: x, y: y) + + default: + return nil + } + } + + /// Read `WxH,X,Y` geometry and return `(width, height, x, y)`, advancing `idx`. + private static func readGeometry(_ s: String, idx: inout String.Index) -> (Int, Int, Int, Int)? { + guard let w = readInt(s, idx: &idx), + idx < s.endIndex, s[idx] == "x" else { return nil } + s.formIndex(after: &idx) + guard let h = readInt(s, idx: &idx), + let x = readCommaInt(s, idx: &idx), + let y = readCommaInt(s, idx: &idx) else { return nil } + return (w, h, x, y) + } + + /// Read `,` only when the character after `,` is a decimal digit. + /// This prevents accidentally consuming the `,` child separator inside `{...}`. + private static func readCommaInt(_ s: String, idx: inout String.Index) -> Int? { + guard idx < s.endIndex, s[idx] == "," else { return nil } + let peek = s.index(after: idx) + guard peek < s.endIndex, s[peek].isNumber else { return nil } + s.formIndex(after: &idx) // skip "," + return readInt(s, idx: &idx) + } + + private static func readInt(_ s: String, idx: inout String.Index) -> Int? { + let d = readDigits(s, idx: &idx) + return d.isEmpty ? nil : Int(d) + } + + private static func readDigits(_ s: String, idx: inout String.Index) -> String { + var result = "" + while idx < s.endIndex && s[idx].isNumber { + result.append(s[idx]) + s.formIndex(after: &idx) + } + return result + } + + // MARK: - Checksum prefix + + /// Skip the optional `,` checksum prefix emitted by tmux control mode. + /// + /// A checksum is a run of hex digits followed by a comma and then a decimal + /// digit (start of the geometry width). Peek ahead to confirm before skipping. + private static func skipChecksumIfPresent(_ s: String, idx: inout String.Index) { + var probe = idx + while probe < s.endIndex && s[probe].isHexDigit { s.formIndex(after: &probe) } + guard probe > idx, + probe < s.endIndex, + s[probe] == ",", + s.index(after: probe) < s.endIndex, + s[s.index(after: probe)].isNumber + else { return } + s.formIndex(after: &probe) // skip "," + idx = probe + } +} diff --git a/Sources/TmuxLayoutReconciler.swift b/Sources/TmuxLayoutReconciler.swift new file mode 100644 index 0000000000..6abf56d970 --- /dev/null +++ b/Sources/TmuxLayoutReconciler.swift @@ -0,0 +1,251 @@ +import Foundation + +// MARK: - Workspace protocol seam + +/// Abstraction over `Workspace` that `TmuxLayoutReconciler` uses to create and +/// close panels. Using a protocol lets unit tests inject a mock without a real +/// `Workspace` instance. +@MainActor +protocol TmuxReconcilerWorkspace: AnyObject { + /// Create a new terminal split backed by the given tmux pane. + /// + /// - Parameters: + /// - paneId: The tmux pane identifier, e.g. `"%3"`. + /// - windowHint: The tmux window the pane belongs to. The workspace uses + /// this to prefer a pending user-initiated panel registered for the same + /// window before falling back to any untagged pending panel. + /// - Returns: The UUID of the newly created (or claimed) panel, or `nil` if + /// creation failed (e.g. no suitable source panel is available). + func newTerminalSplitForTmuxPane(_ paneId: String, windowHint: String) -> UUID? + + /// Close the panel with the given UUID. + @discardableResult func closePanel(_ panelId: UUID, force: Bool) -> Bool +} + +// MARK: - Reconciler + +/// Reconciles tmux control mode events with cmux workspace panels. +/// +/// Tracks a bidirectional mapping between tmux pane IDs (e.g. `"%1"`) and +/// cmux panel UUIDs. On each `%layout-change` event it: +/// - Creates new cmux splits for tmux panes that have no corresponding panel +/// - Orphans panels whose tmux panes have disappeared from that window +/// - Adopts orphaned panels when their pane reappears in another window +/// (handles `break-pane` / `join-pane` without recreating the panel) +/// - Purges panels that were orphaned but never adopted +/// +/// **Zoom handling:** tmux sends the full layout in every `%layout-change` +/// regardless of whether the window is zoomed. Reconciliation always runs. +/// The zoom flag is exposed via `TmuxLayout.isZoomed` for display use only. +/// +/// **Move-pane handling:** when a pane disappears from window A's layout, +/// its panel is placed in an "orphan" set rather than closed immediately. +/// A deferred task runs after the current run-loop cycle completes. If the +/// pane reappears in window B's layout before the purge, it is adopted there. +/// If not adopted, the panel is closed. +/// +/// All methods must be called on the **main thread**. +final class TmuxLayoutReconciler { + + /// Pane ID (e.g. `"%1"`) → panel UUID for currently tracked panes. + private var trackedPanes: [String: UUID] = [:] + /// Panel UUID → pane ID (reverse of `trackedPanes`). + private var panelToPane: [UUID: String] = [:] + /// Window ID → set of pane IDs currently tracked in that window. + private var windowPanes: [String: Set] = [:] + /// Pane ID → window ID for currently tracked panes. + private var paneToWindow: [String: String] = [:] + /// Panes that disappeared from their last known window but have not yet been + /// purged. Values are the panel UUIDs that will be closed on purge. + private var orphanedPanes: [String: UUID] = [:] + /// Pane IDs that the user explicitly closed while the pane was still alive. + /// Suppressed from future reconciliation until the pane truly exits tmux. + private var userDismissedPanes: Set = [] + /// True when a purge task is already queued (prevents duplicate scheduling). + private var purgePending = false + + /// The workspace this reconciler interacts with. Stored weakly so the + /// reconciler does not create a retain cycle. + private weak var workspace: (any TmuxReconcilerWorkspace)? + + // MARK: - Lifecycle + + /// Attach the reconciler to its workspace. Call once after initialisation + /// and before delivering any events via `apply(_:)`. + @MainActor + func attach(to workspace: any TmuxReconcilerWorkspace) { + self.workspace = workspace + } + + // MARK: - Public API + + /// Process a tmux control event. + @MainActor + func apply(_ event: TmuxControlEvent) { + guard let workspace else { return } + + switch event { + case .layoutChange(let layout): + reconcile(layout: layout, workspace: workspace) + + case .windowClose(let window): + // Close all panels for panes in this window immediately. + if let paneIds = windowPanes.removeValue(forKey: window) { + for paneId in paneIds { + paneToWindow.removeValue(forKey: paneId) + userDismissedPanes.remove(paneId) + if let panelId = trackedPanes.removeValue(forKey: paneId) { + panelToPane.removeValue(forKey: panelId) + workspace.closePanel(panelId, force: true) + } + } + } + + case .exit, .windowAdd, + .sessionRenamed, .sessionsChanged, .windowRenamed, + .sessionWindowChanged, .windowPaneChanged, + .paneModeChanged, .pasteBufferChanged, .clientSessionChanged: + // These events are handled at the Workspace level. + break + } + } + + /// Returns the cmux panel UUID mapped to the given tmux pane ID, or nil. + @MainActor func panelId(forTmuxPane paneId: String) -> UUID? { + trackedPanes[paneId] + } + + /// Returns the tmux window ID for the panel with the given UUID, or nil. + @MainActor func windowId(forPanel panelId: UUID) -> String? { + guard let paneId = panelToPane[panelId] else { return nil } + return paneToWindow[paneId] + } + + /// Returns all currently tracked cmux panel UUIDs. + @MainActor func allTrackedPanelIds() -> Set { + Set(trackedPanes.values) + } + + /// Remove tracking for a specific cmux panel (called when the user closes it). + /// Adds the pane to the dismissed set so subsequent `%layout-change` events + /// do not reopen a panel for a pane the user intentionally closed. + @MainActor func removeTracking(forPanel panelId: UUID) { + guard let paneId = panelToPane.removeValue(forKey: panelId) else { return } + trackedPanes.removeValue(forKey: paneId) + if let windowId = paneToWindow.removeValue(forKey: paneId) { + windowPanes[windowId]?.remove(paneId) + } + userDismissedPanes.insert(paneId) + } + + /// Clear all tracked state (called when the session changes or disconnects). + @MainActor func reset() { + trackedPanes.removeAll() + panelToPane.removeAll() + windowPanes.removeAll() + paneToWindow.removeAll() + orphanedPanes.removeAll() + userDismissedPanes.removeAll() + purgePending = false + } + + // MARK: - Private reconciliation + + @MainActor + private func reconcile(layout: TmuxLayout, workspace: any TmuxReconcilerWorkspace) { + let window = layout.windowId + let livePaneIds = Set(layout.allPaneIds) + let previousPanesInWindow = windowPanes[window] ?? [] + + // --- Step 1: Adopt orphaned panes that reappeared in this window --- + // This handles break-pane / join-pane: a pane that was orphaned from + // another window is rebinding here before its deferred purge fires. + for paneId in livePaneIds { + if let orphanPanelId = orphanedPanes.removeValue(forKey: paneId) { + // Rebind the existing panel to this window. + trackedPanes[paneId] = orphanPanelId + panelToPane[orphanPanelId] = paneId + paneToWindow[paneId] = window + // Remove from any stale window tracking. + for wid in windowPanes.keys where wid != window { + windowPanes[wid]?.remove(paneId) + } + } + } + + // --- Step 2: Orphan panes that disappeared from this window --- + // Do not close immediately — give the next reconciliation cycle a chance + // to adopt them if they reappear in another window (break-pane). + let removedFromThisWindow = previousPanesInWindow.subtracting(livePaneIds) + for paneId in removedFromThisWindow { + paneToWindow.removeValue(forKey: paneId) + windowPanes[window]?.remove(paneId) + // Purge the dismissed-pane entry for this pane regardless; the pane + // is leaving its current window context and dismissal is no longer valid. + userDismissedPanes.remove(paneId) + if let panelId = trackedPanes.removeValue(forKey: paneId) { + panelToPane.removeValue(forKey: panelId) + orphanedPanes[paneId] = panelId + } + } + // Schedule a deferred purge. If the pane reappears in another window's + // layout change (dispatched before the purge task runs), it will be + // adopted in Step 1 and removed from orphanedPanes first. + schedulePurgeIfNeeded(workspace: workspace) + + // --- Step 3: Create panels for panes new to this window --- + // "New" means: live in this window AND not already tracked. + // Dismissed panes are skipped — the user intentionally closed them. + let newPanes = livePaneIds + .subtracting(trackedPanes.keys) + .subtracting(userDismissedPanes) + .sorted { a, b in + let na = Int(a.dropFirst()) ?? 0 + let nb = Int(b.dropFirst()) ?? 0 + return na < nb + } + + var reconciledPaneIds: Set = livePaneIds.filter { trackedPanes[$0] != nil } + reconciledPaneIds.formUnion(userDismissedPanes.intersection(livePaneIds)) + + for paneId in newPanes { + if let panelId = workspace.newTerminalSplitForTmuxPane(paneId, windowHint: window) { + trackedPanes[paneId] = panelId + panelToPane[panelId] = paneId + paneToWindow[paneId] = window + reconciledPaneIds.insert(paneId) + } + // If creation fails the pane is excluded from reconciledPaneIds + // and will be retried on the next %layout-change. + } + + windowPanes[window] = reconciledPaneIds + } + + /// Schedule a deferred orphan purge unless one is already queued. + /// + /// The purge runs after the current run-loop cycle, which is after any + /// queued `%layout-change` events have been processed on the main thread. + /// This gives move-pane events one reconcile cycle to adopt the orphan. + @MainActor + private func schedulePurgeIfNeeded(workspace: any TmuxReconcilerWorkspace) { + guard !purgePending, !orphanedPanes.isEmpty else { return } + purgePending = true + // Capture the workspace reference for the async closure. + // The workspace is a long-lived object; the weak capture is a safety net. + Task { @MainActor [weak self, weak workspace] in + guard let self, let workspace else { return } + self.purgePending = false + self.purgeOrphans(workspace: workspace) + } + } + + /// Close all panels that are still in the orphan set (were never adopted). + @MainActor + private func purgeOrphans(workspace: any TmuxReconcilerWorkspace) { + for (_, panelId) in orphanedPanes { + workspace.closePanel(panelId, force: true) + } + orphanedPanes.removeAll() + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e94abe72c0..1ddbdb7a73 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1643,6 +1643,19 @@ private final class WorkspaceRemoteDaemonRPCClient { "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } + /// JSON-encode a string value (with quotes). Safe for embedding in RPC JSON. + private static func jsonString(_ value: String) -> String { + if let data = try? JSONSerialization.data(withJSONObject: value), + let str = String(data: data, encoding: .utf8) { + return str + } + // Safe fallback: manually escape backslashes and quotes. + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + private static func bestErrorLine(stderr: String) -> String? { let lines = stderr .split(separator: "\n") @@ -3378,16 +3391,50 @@ final class WorkspaceRemoteSessionController { private var reverseRelayRestartWorkItem: DispatchWorkItem? private var reverseRelayStderrBuffer = "" private var reconnectRetryCount = 0 + /// The tmux session name selected for this connection. + /// + /// This is the controller's authoritative copy, used to auto-reattach on + /// reconnect (see `provisionTmuxSessionLocked`). It is set immediately + /// when the user picks a session (`ensureTmuxSessionLocked`) and must stay + /// in sync with `Workspace.remoteTmuxSessionName`, which is the @Published + /// copy used by the UI layer. Both are written together via the + /// `applyRemoteTmuxSession` → main-thread path. + private var remoteTmuxSessionName: String? + /// tmux version string returned by the remote probe (e.g. "3.4"). + /// Stored so `startTmuxControlModeLocked` can version-gate exact-match `=` targets. + private var tmuxProbeVersion: String? + private var tmuxControlProcess: Process? + /// Stdin pipe for the tmux -CC SSH process. Kept open so tmux does not see EOF. + private var tmuxControlStdinPipe: Pipe? + /// Raw byte buffer for incoming tmux -CC output. Kept as Data so multibyte UTF-8 + /// characters that arrive split across pipe reads are reassembled before decoding. + private var tmuxControlDataBuffer: Data = Data() + /// Last time a line was received from the tmux -CC process (used by watchdog). + private var tmuxControlLastActivity: Date? + /// Watchdog timer: fires if no tmux control output arrives for too long. + private var tmuxControlWatchdogTimer: DispatchSourceTimer? private var reconnectWorkItem: DispatchWorkItem? private var heartbeatCount: Int = 0 private var connectionAttemptStartedAt: Date? private static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 - init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { + init( + workspace: Workspace, + configuration: WorkspaceRemoteConfiguration, + controllerID: UUID, + existingTmuxSessionName: String? = nil, + existingTmuxProbeVersion: String? = nil + ) { self.workspace = workspace self.configuration = configuration self.controllerID = controllerID + // Seed the session name from a previous controller so `provisionTmuxSessionLocked` + // can auto-reattach on reconnect without re-showing the picker. + self.remoteTmuxSessionName = existingTmuxSessionName + // Carry the probed tmux version so `tmuxExactTarget` can version-gate + // exact-match targets on reconnect (reconnect skips the probe path). + self.tmuxProbeVersion = existingTmuxProbeVersion queue.setSpecific(key: queueKey, value: ()) } @@ -3489,6 +3536,7 @@ final class WorkspaceRemoteSessionController { bootstrapRemoteTTYFetchInFlight = false bootstrapRemoteTTYRetryCount = 0 + stopTmuxControlProcessLocked() proxyLease?.release() proxyLease = nil proxyEndpoint = nil @@ -3546,6 +3594,9 @@ final class WorkspaceRemoteSessionController { remotePath: hello.remotePath ) recordHeartbeatActivityLocked() + if hello.capabilities.contains("tmux.adapter") { + provisionTmuxSessionLocked(remotePath: hello.remotePath) + } startReverseRelayLocked(remotePath: hello.remotePath) requestBootstrapRemoteTTYIfNeededLocked() startProxyLocked() @@ -3561,6 +3612,374 @@ final class WorkspaceRemoteSessionController { } } + // MARK: - Tmux session provisioning + + private func provisionTmuxSessionLocked(remotePath: String) { + // On reconnect, if a session was already selected, auto-reattach without showing the picker. + if let existingSession = remoteTmuxSessionName { + ensureTmuxSessionLocked(existingSession, remotePath: remotePath) + return + } + + // First connect: probe + list sessions, then show picker. + // Batch two RPC calls: probe + session list. + let probeReq = #"{"id":1,"method":"tmux.probe","params":{}}"# + let listReq = #"{"id":2,"method":"tmux.session.list","params":{}}"# + let input = probeReq + "\n" + listReq + "\n" + let script = "printf '%s' \(Self.shellSingleQuoted(input)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(Self.shellSingleQuoted(script))" + let arguments = sshCommonArguments(batchMode: true) + [configuration.destination, command] + + do { + let result = try sshExec(arguments: arguments, timeout: 8) + guard result.status == 0 else { return } + let lines = result.stdout.split(separator: "\n").map(String.init) + var tmuxAvailable = false + var tmuxVersion: String? + var tmuxUTF8OK = true + var sessions: [RemoteTmuxSession] = [] + for line in lines { + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let ok = payload["ok"] as? Bool, ok, + let resultObj = payload["result"] as? [String: Any] else { continue } + if let avail = resultObj["available"] as? Bool { + tmuxAvailable = avail + if let v = resultObj["version"] as? String, !v.isEmpty { + tmuxVersion = v + } + if let utf8 = resultObj["utf8"] as? Bool { + tmuxUTF8OK = utf8 + } + } + if let rawSessions = resultObj["sessions"] as? [[String: Any]] { + sessions = rawSessions.compactMap { obj -> RemoteTmuxSession? in + guard let name = obj["name"] as? String else { return nil } + let windows = obj["windows"] as? Int ?? 0 + let attached = obj["attached"] as? Bool ?? false + return RemoteTmuxSession(name: name, windows: windows, attached: attached) + } + } + } + guard tmuxAvailable else { + debugLog("remote.tmux.unavailable") + return + } + debugLog("remote.tmux.available version=\(tmuxVersion ?? "?") utf8=\(tmuxUTF8OK) sessions=\(sessions.map(\.name))") + // Persist version for version-gated target syntax in startTmuxControlModeLocked. + tmuxProbeVersion = tmuxVersion + publishTmuxDiscovery(available: true, sessions: sessions, version: tmuxVersion, utf8OK: tmuxUTF8OK) + } catch { + // Non-fatal: tmux probe failure falls back to bare shells. + debugLog("remote.tmux.probeFailed detail=\(error.localizedDescription)") + } + } + + private func publishTmuxDiscovery( + available: Bool, + sessions: [RemoteTmuxSession], + version: String? = nil, + utf8OK: Bool = true + ) { + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + workspace.applyRemoteTmuxDiscovery( + available: available, + sessions: sessions, + version: version, + utf8OK: utf8OK + ) + } + } + + /// Re-lists tmux sessions and publishes the updated list to the workspace. + /// Called when a `%sessions-changed` control event is received. + func refreshTmuxSessionList() { + queue.async { [weak self] in + guard let self, let remotePath = self.daemonRemotePath else { return } + let listReq = #"{"id":1,"method":"tmux.session.list","params":{}}"# + let input = listReq + "\n" + let script = "printf '%s' \(Self.shellSingleQuoted(input)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(Self.shellSingleQuoted(script))" + let arguments = self.sshCommonArguments(batchMode: true) + [self.configuration.destination, command] + guard let result = try? self.sshExec(arguments: arguments, timeout: 8), + result.status == 0 else { return } + let lines = result.stdout.split(separator: "\n").map(String.init) + var sessions: [RemoteTmuxSession] = [] + for line in lines { + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let ok = payload["ok"] as? Bool, ok, + let resultObj = payload["result"] as? [String: Any], + let rawSessions = resultObj["sessions"] as? [[String: Any]] else { continue } + sessions = rawSessions.compactMap { obj -> RemoteTmuxSession? in + guard let name = obj["name"] as? String else { return nil } + let windows = obj["windows"] as? Int ?? 0 + let attached = obj["attached"] as? Bool ?? false + return RemoteTmuxSession(name: name, windows: windows, attached: attached) + } + } + self.debugLog("remote.tmux.sessionsRefreshed count=\(sessions.count)") + DispatchQueue.main.async { [weak workspace = self.workspace] in + workspace?.remoteTmuxSessions = sessions + } + } + } + + /// Updates the stored session name (called when tmux sends `%session-renamed`). + func updateTmuxSessionName(_ newName: String) { + queue.async { [weak self] in + self?.remoteTmuxSessionName = newName + } + } + + private func ensureTmuxSessionLocked(_ sessionName: String, remotePath: String) { + let ensureReq = """ + {"id":1,"method":"tmux.session.ensure","params":{"session":\(Self.jsonString(sessionName))}} + """ + let input = ensureReq + "\n" + let script = "printf '%s' \(Self.shellSingleQuoted(input)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(Self.shellSingleQuoted(script))" + let arguments = sshCommonArguments(batchMode: true) + [configuration.destination, command] + do { + let result = try sshExec(arguments: arguments, timeout: 8) + guard result.status == 0 else { + debugLog("remote.tmux.ensureFailed status=\(result.status)") + publishDaemonStatus(.error, detail: "Failed to attach tmux session \"\(sessionName)\" (exit \(result.status))") + return + } + // Parse the JSON response. The daemon may return ok:false even when the + // SSH process exits 0 (e.g. tmux new-session failed on the remote). In that + // case the payload contains an error message; bail out to keep the picker open. + var sessionId: String? + var rpcOK = false + var rpcErrorMsg: String? + for line in result.stdout.split(separator: "\n").map(String.init) { + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + if let ok = payload["ok"] as? Bool { + rpcOK = ok + if !ok, let errObj = payload["error"] as? [String: Any] { + rpcErrorMsg = errObj["message"] as? String + } + } + if let resultObj = payload["result"] as? [String: Any], + let sid = resultObj["session_id"] as? String, !sid.isEmpty { + sessionId = sid + } + break + } + guard rpcOK else { + let detail = rpcErrorMsg ?? "unknown error" + debugLog("remote.tmux.ensureFailed rpc_error=\(detail)") + publishDaemonStatus(.error, detail: "Failed to attach tmux session \"\(sessionName)\": \(detail)") + return + } + debugLog("remote.tmux.ensured session=\(sessionName) id=\(sessionId ?? "?")") + // Persist the chosen session so that reconnects can auto-reattach + // without showing the picker again. + remoteTmuxSessionName = sessionName + let sid = sessionId + // IMPORTANT: initialize workspace state (reconciler, notification guard) on the + // main thread BEFORE starting the control-mode SSH process. The initial tmux -CC + // burst can deliver %layout-change events immediately; applyTmuxControlEvent drops + // events until tmuxNotificationsEnabled is set inside applyRemoteTmuxSession. Both + // operations are dispatched as async blocks to the main queue — FIFO ordering + // guarantees applyRemoteTmuxSession runs before any control-mode events arrive. + DispatchQueue.main.async { [weak workspace] in + workspace?.applyRemoteTmuxSession(sessionName, sessionId: sid) + } + // Dispatch control-mode start as a SECOND main-queue block so it runs after the + // applyRemoteTmuxSession block above has already executed and enabled the guard. + let sessionNameCopy = sessionName + DispatchQueue.main.async { [weak self] in + self?.queue.async { self?.startTmuxControlModeLocked(sessionName: sessionNameCopy) } + } + } catch { + debugLog("remote.tmux.ensureFailed detail=\(error.localizedDescription)") + publishDaemonStatus(.error, detail: "Failed to attach tmux session \"\(sessionName)\": \(error.localizedDescription)") + } + } + + func selectTmuxSession(_ sessionName: String) { + queue.async { [weak self] in + guard let self, let remotePath = self.daemonRemotePath else { return } + self.ensureTmuxSessionLocked(sessionName, remotePath: remotePath) + } + } + + // MARK: - Tmux control mode (tmux -CC) + + /// Start a persistent SSH subprocess running `tmux -CC attach-session` and stream + /// its output through `TmuxControlParser` → `Workspace.applyTmuxControlEvent`. + private func startTmuxControlModeLocked(sessionName: String) { + guard !isStopping else { return } + stopTmuxControlProcessLocked() + + var args = sshCommonArguments(batchMode: true) + args.append(configuration.destination) + // Prefix "=" forces exact-match semantics in tmux target resolution (tmux ≥2.5). + // Without it, a session name containing ":" or "." is parsed as a + // "session:window(.pane)" target, causing attach to fail or hit the wrong session. + // Only use the prefix when the probed remote version supports it. + let controlTarget = tmuxExactTarget(sessionName) + args.append("tmux -CC attach-session -t " + Self.shellSingleQuoted(controlTarget)) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = args + // Use a Pipe for stdin rather than /dev/null. tmux -CC reads commands from + // its client stdin; presenting EOF immediately (as /dev/null does) causes the + // control-mode session to terminate or stop emitting events right after attach. + // We keep the pipe open but write nothing — this gives tmux a non-EOF stdin + // for the lifetime of the process. + let inPipe = Pipe() + process.standardInput = inPipe + + let outPipe = Pipe() + process.standardOutput = outPipe + process.standardError = FileHandle.nullDevice + + // Capture `process` by value so the handler can guard against stale callbacks. + // If startTmuxControlModeLocked runs again before this process exits (e.g. session + // switch or reconnect), tmuxControlProcess will point to the new process. Checking + // identity prevents the old process's termination from clobbering the new session. + process.terminationHandler = { [weak self, process] proc in + self?.queue.async { + guard let self else { return } + guard self.tmuxControlProcess === process else { return } + let exitStatus = proc.terminationStatus + self.tmuxControlProcess = nil + self.stopTmuxControlWatchdogLocked() + self.debugLog("remote.tmux.control.exited status=\(exitStatus)") + let ws = self.workspace + // Disable notification guard on the workspace so stale events + // from a previous session don't bleed into the next attach. + // If the process exited with a non-zero status (tmux attach failed — + // e.g. session renamed or deleted between ensure and attach), publish + // an error so the user can see the failure and retry via reconnect. + if exitStatus != 0 { + self.publishDaemonStatus( + .error, + detail: "tmux control mode exited (status \(exitStatus)) — session may have been deleted or renamed" + ) + DispatchQueue.main.async { ws?.tmuxNotificationsEnabled = false } + } else { + DispatchQueue.main.async { ws?.tmuxNotificationsEnabled = false } + } + } + } + + do { + try process.run() + } catch { + debugLog("remote.tmux.control.startFailed detail=\(error.localizedDescription)") + // Disable the notification guard so the workspace doesn't stay in a + // half-attached state (tmuxNotificationsEnabled=true but no control stream). + let ws = workspace + DispatchQueue.main.async { ws?.tmuxNotificationsEnabled = false } + return + } + + tmuxControlProcess = process + tmuxControlStdinPipe = inPipe + tmuxControlLastActivity = Date() + startTmuxControlWatchdogLocked() + debugLog("remote.tmux.control.started session=\(sessionName)") + + outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + // Pass raw bytes to the consumer so that multibyte UTF-8 characters split + // across pipe read boundaries are buffered intact and decoded only once a + // complete newline-terminated line has accumulated. + self?.queue.async { self?.consumeTmuxControlData(data) } + } + } + + private func consumeTmuxControlData(_ chunk: Data) { + tmuxControlLastActivity = Date() + tmuxControlDataBuffer.append(chunk) + // Split on newline bytes (0x0A). Each complete line is decoded independently + // so that a multibyte UTF-8 character split across pipe reads is not decoded + // until all its bytes have arrived. + while let nlOffset = tmuxControlDataBuffer.firstIndex(of: 0x0A) { + let lineData = tmuxControlDataBuffer[.. Self.tmuxWatchdogTimeout { + // tmux -CC is event-driven: a healthy session is silent whenever the user + // is just typing in panes. Extended silence is unusual but not impossible + // during idle periods. After a second watchdog interval with no activity + // (i.e., 3× the interval = 3 minutes total), treat it as a probable stall + // and surface an error so the user can reconnect. + let escalate = silence > Self.tmuxWatchdogTimeout * 1.5 + self.debugLog("remote.tmux.control.watchdog silence=\(Int(silence))s — \(escalate ? "escalating to error" : "monitoring")") + if escalate { + DispatchQueue.main.async { [weak workspace = self.workspace] in + guard let workspace, workspace.tmuxNotificationsEnabled else { return } + // Only escalate if we are still nominally "connected" to avoid + // double-firing when a disconnect is already in progress. + workspace.tmuxNotificationsEnabled = false + workspace.remoteConnectionState = .disconnected + } + } + } + } + tmuxControlWatchdogTimer = timer + timer.resume() + } + + private func stopTmuxControlWatchdogLocked() { + tmuxControlWatchdogTimer?.cancel() + tmuxControlWatchdogTimer = nil + } + private func startProxyLocked() { guard !isStopping else { return } guard daemonReady else { return } @@ -4096,6 +4515,12 @@ final class WorkspaceRemoteSessionController { return args } + /// SSH arguments suitable for an interactive terminal connection (adds `-t`). + /// Used by `Workspace.buildTmuxSSHStartupScript` to avoid duplicating option assembly. + fileprivate func terminalSSHArguments() -> [String] { + return ["-t"] + sshCommonArguments(batchMode: false) + } + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { let loweredKey = key.lowercased() for option in options { @@ -4930,6 +5355,30 @@ final class WorkspaceRemoteSessionController { "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } + /// Returns `=` when the probed remote tmux version supports exact-match targets + /// (tmux ≥2.5), or the bare `` otherwise. Defaults to exact-match when version + /// is unknown (nil) so newer servers — the common case — get correct targeting. + private func tmuxExactTarget(_ name: String) -> String { + guard let version = tmuxProbeVersion else { return "=" + name } + let parts = version.split(separator: ".").map { $0.prefix(while: { $0.isNumber }) } + guard parts.count >= 2, + let major = Int(parts[0]), + let minor = Int(parts[1]) else { return "=" + name } + return (major, minor) >= (2, 5) ? "=" + name : name + } + + private static func jsonString(_ value: String) -> String { + if let data = try? JSONSerialization.data(withJSONObject: value), + let str = String(data: data, encoding: .utf8) { + return str + } + // Safe fallback: manually escape backslashes and quotes. + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + static func remoteCLIWrapperScript() -> String { """ #!/bin/sh @@ -6051,6 +6500,14 @@ private func normalizedSidebarBranchName(_ branch: String?) -> String? { return trimmed.isEmpty ? nil : trimmed } +/// A tmux session discovered on a remote host. +struct RemoteTmuxSession: Identifiable, Equatable { + var id: String { name } + let name: String + let windows: Int + let attached: Bool +} + struct SidebarPullRequestState: Equatable { let number: Int let label: String @@ -6584,6 +7041,51 @@ final class Workspace: Identifiable, ObservableObject { @Published var surfaceListeningPorts: [UUID: [Int]] = [:] var agentListeningPorts: [Int] = [] @Published var remoteConfiguration: WorkspaceRemoteConfiguration? + /// The active tmux session name, published for UI bindings. + /// + /// Written only by `applyRemoteTmuxSession(_:)` on the main thread. + /// The controller keeps a parallel private copy (`WorkspaceRemoteSessionController + /// .remoteTmuxSessionName`) for reconnect logic; both are always set together + /// through that path so they stay in sync. + @Published var remoteTmuxSessionName: String? + /// The stable tmux session ID (e.g. "$3") for the attached session. + /// Used to filter `%session-renamed` events — only updates that carry this ID + /// are applied; renames of other sessions on the same server are ignored. + fileprivate var remoteTmuxSessionId: String? + /// tmux version string returned by the initial probe (e.g. "3.4"). nil until probed. + @Published private(set) var remoteTmuxVersion: String? + /// False if tmux reported UTF-8 mode is disabled; shown as a warning in the picker. + @Published private(set) var remoteTmuxUTF8OK: Bool = true + /// Whether the remote host has tmux available (set after probe). + @Published fileprivate(set) var remoteTmuxAvailable: Bool = false + /// Existing tmux sessions on the remote (populated before showing picker). + @Published fileprivate(set) var remoteTmuxSessions: [RemoteTmuxSession] = [] + /// Whether the tmux session picker sheet should be shown. + @Published var showTmuxSessionPicker: Bool = false + /// True when the user explicitly dismissed the picker with Skip. Prevents + /// `trackRemoteTerminalSurface` from re-showing the picker on subsequent + /// terminal lifecycle events within the same connection. + private var userSkippedTmuxPicker: Bool = false + /// Panels created by user-initiated splits in tmux mode that have not yet been + /// associated with a specific tmux pane ID. When the tmux control-mode + /// `%layout-change` event fires for the resulting new pane, the reconciler + /// claims the best-matching pending panel rather than opening a duplicate split. + /// + /// Each entry carries an optional `windowHint` (tmux window ID of the source + /// panel at split time). The reconciler prefers a window-matched entry when + /// claiming, then falls back to any untagged entry. This prevents a remote + /// pane creation from racing with a user-initiated split and claiming the + /// wrong pending panel. + var pendingTmuxPanelIds: [(panelId: UUID, windowHint: String?)] = [] + /// Panels that existed before a tmux session was selected (plain SSH shells). + /// Cleared and closed lazily after the first %layout-change creates tmux-backed + /// replacements so that the remote connection is never dropped prematurely. + private var preTmuxTerminalIds: Set = [] + /// Reconciles live tmux pane changes with cmux panels. Created when a session is selected. + private var tmuxLayoutReconciler: TmuxLayoutReconciler? + /// Guard flag: tmux control events are ignored until the reconciler is ready. + /// Set to true by applyRemoteTmuxSession; reset on disconnect/session change. + fileprivate var tmuxNotificationsEnabled: Bool = false @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected @Published var remoteConnectionDetail: String? @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() @@ -8084,7 +8586,27 @@ final class Workspace: Identifiable, ObservableObject { func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { skipControlMasterCleanupAfterDetachedRemoteTransfer = false + // Preserve tmux session state across reconnects to the same destination so that + // (a) the new controller can auto-reattach without showing the picker again, and + // (b) existing pane→panel mappings stay valid for the reconnect reconciliation pass. + // Clear everything for fresh connects (different destination) so stale tmux state + // from the previous host does not contaminate the new connection. + let isSameDestination = remoteConfiguration?.destination == configuration.destination + let existingTmuxSessionName = isSameDestination ? remoteTmuxSessionName : nil + let existingReconciler = isSameDestination ? tmuxLayoutReconciler : nil remoteConfiguration = configuration + remoteTmuxSessionName = isSameDestination ? remoteTmuxSessionName : nil + remoteTmuxSessionId = isSameDestination ? remoteTmuxSessionId : nil + remoteTmuxVersion = isSameDestination ? remoteTmuxVersion : nil + remoteTmuxUTF8OK = isSameDestination ? remoteTmuxUTF8OK : true + remoteTmuxAvailable = false + remoteTmuxSessions = [] + showTmuxSessionPicker = false + userSkippedTmuxPicker = false + pendingTmuxPanelIds.removeAll() + preTmuxTerminalIds.removeAll() + tmuxLayoutReconciler = existingReconciler + tmuxNotificationsEnabled = false seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) clearRemoteDetectedSurfacePorts() remoteDetectedPorts = [] @@ -8126,7 +8648,9 @@ final class Workspace: Identifiable, ObservableObject { let controller = WorkspaceRemoteSessionController( workspace: self, configuration: configuration, - controllerID: controllerID + controllerID: controllerID, + existingTmuxSessionName: existingTmuxSessionName, + existingTmuxProbeVersion: isSameDestination ? remoteTmuxVersion : nil ) activeRemoteSessionControllerID = controllerID remoteSessionController = controller @@ -8234,6 +8758,14 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) _ = applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) + // For workspaces that had no terminal panels during the initial tmux discovery + // (e.g. browser-only/proxy-only workspaces), the picker was suppressed because + // no split source existed yet. Re-evaluate now that a terminal is available — + // but only if the user has not already explicitly skipped the picker this session. + if remoteTmuxAvailable, !showTmuxSessionPicker, remoteTmuxSessionName == nil, + !userSkippedTmuxPicker { + showTmuxSessionPicker = true + } } private func untrackRemoteTerminalSurface(_ panelId: UUID) { @@ -8311,6 +8843,255 @@ final class Workspace: Identifiable, ObservableObject { } @MainActor + fileprivate func applyRemoteTmuxSession(_ sessionName: String, sessionId: String? = nil) { + let trimmed = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + remoteTmuxSessionName = trimmed + showTmuxSessionPicker = false + pendingTmuxPanelIds.removeAll() + + // On a fresh attach (first time picking a session): record which terminal panels + // exist RIGHT NOW so they can be cleaned up after the first %layout-change creates + // tmux-backed replacements. These are "bare SSH shell" panels, not yet attached to + // any tmux pane. + // + // On reconnect (tmuxLayoutReconciler already had tracked panels): skip this step. + // The existing panels are already tmux-backed; marking them as pre-tmux would cause + // the reconciler to recreate and then close panels that already have valid sessions, + // losing scrollback and panel state after every reconnect. + // + // We cannot close pre-tmux panels here because: + // 1. closePanel() would drive activeRemoteTerminalSurfaceIds to zero, + // triggering disconnectRemoteConnection before tmux attach completes. + // 2. newTerminalSplitForTmuxPane() needs a live terminal as a split source. + let existingReconciler = tmuxLayoutReconciler + let existingTracked = existingReconciler?.allTrackedPanelIds() ?? [] + + // Determine whether this is a true reconnect (same tmux session, panels still valid) + // or a session recreation (new session ID means all pane IDs are fresh). Use the + // session ID ($N) to distinguish: if both old and new IDs are known and they differ, + // the tmux server/session was recreated while disconnected and the old mapping is stale. + // IMPORTANT: read remoteTmuxSessionId BEFORE overwriting it below so the comparison + // uses the previous session's ID, not the incoming one. + let sessionRecreated: Bool = { + guard let newId = sessionId, !newId.isEmpty, + let oldId = remoteTmuxSessionId, !oldId.isEmpty else { return false } + return newId != oldId + }() + // Now safe to update the session ID. + remoteTmuxSessionId = sessionId + let isTrueReconnect = !existingTracked.isEmpty && !sessionRecreated + + if isTrueReconnect { + // Reconnect to the same live session — panels are already tmux-backed. + // Keep the existing reconciler WITH its pane→panel mapping intact. + // The first %layout-change after reconnect will diff the live pane list + // against the pre-disconnect mapping: + // - panes still alive → panels unchanged (no create, no close) + // - panes that died while disconnected → panels closed + // - new panes that appeared while disconnected → new panels created + // This gives correct incremental reconciliation without duplicate panels or + // loss of scrollback/panel state. + preTmuxTerminalIds.removeAll() + } else { + // Fresh attach or session recreation: start with a clean reconciler. + // Record all current remote terminal panels for lazy cleanup — after the + // first %layout-change creates new pane-backed replacements, the old ones + // will be closed. DO NOT close panels here: closing tracked panels + // immediately drives activeRemoteTerminalSurfaceIds to zero in terminal-only + // workspaces, which triggers workspace disconnection before replacement panels + // are created. Let the reconciler handle cleanup naturally via %layout-change. + preTmuxTerminalIds = Set( + panels.values + .compactMap { $0 as? TerminalPanel } + .map(\.id) + .filter { activeRemoteTerminalSurfaceIds.contains($0) } + ) + // Create a fresh reconciler — no previous tmux pane state to preserve. + let newReconciler = TmuxLayoutReconciler() + newReconciler.attach(to: self) + tmuxLayoutReconciler = newReconciler + } + + // Enable the notification guard now that the reconciler is ready. + tmuxNotificationsEnabled = true + } + + @MainActor + fileprivate func applyRemoteTmuxDiscovery( + available: Bool, + sessions: [RemoteTmuxSession], + version: String? = nil, + utf8OK: Bool = true + ) { + remoteTmuxAvailable = available + remoteTmuxSessions = sessions + if let v = version { remoteTmuxVersion = v } + remoteTmuxUTF8OK = utf8OK + // Only show the tmux session picker when this workspace has at least one remote + // terminal panel. Browser-only or proxy-only workspaces have no TerminalPanel to + // use as a split source in `newTerminalSplitForTmuxPane`, so the picker would lead + // to a non-functional tmux attachment with no pane displayed. + // Also suppress the picker if the user already explicitly skipped it this session. + showTmuxSessionPicker = available && activeRemoteTerminalSessionCount > 0 + && !userSkippedTmuxPicker + } + + /// Called by the remote session controller when a tmux control mode event arrives. + @MainActor + func applyTmuxControlEvent(_ event: TmuxControlEvent) { + // Drop events until the reconciler is ready (notification guard). + guard tmuxNotificationsEnabled, let reconciler = tmuxLayoutReconciler else { return } + + switch event { + case .sessionRenamed(let renamedId, let newName): + // Only update if the renamed session is the one we're attached to. + // Match by session ID ($N) when available; other sessions on the same + // tmux server may also be renamed and must not corrupt our stored name. + let isOurSession: Bool + if let myId = remoteTmuxSessionId, !myId.isEmpty { + isOurSession = renamedId == myId + } else { + // No ID tracked (e.g. old daemon) — fall back to any rename while attached. + isOurSession = remoteTmuxSessionName != nil + } + if isOurSession { + remoteSessionController?.updateTmuxSessionName(newName) + remoteTmuxSessionName = newName + // Session ID is stable across renames; keep remoteTmuxSessionId unchanged. + } + + case .sessionsChanged: + // One or more sessions changed — refresh the list so the picker stays current + // if it's still open, and so future reconnects see the correct session names. + remoteSessionController?.refreshTmuxSessionList() + + case .windowRenamed: + // Window name changes are not currently surfaced in the cmux UI. + break + + case .sessionWindowChanged: + // Active window in session changed. Future: sync window focus. + break + + case .windowPaneChanged(_, let paneId): + // Active pane in a window changed. Focus the corresponding panel if tracked. + if let panelId = reconciler.panelId(forTmuxPane: paneId) { + requestFocusPanel(panelId) + } + + case .exit: + // Control mode exited. Disable notifications until re-attach. + tmuxNotificationsEnabled = false + reconciler.apply(event) + + case .layoutChange(let layout): + reconciler.apply(event) + // After the first successful layout sync, close any pre-tmux terminal panels + // (plain SSH shells that existed before the session was selected). They cannot + // be repurposed as tmux-backed panels; the reconciler just created proper + // replacements. We defer the close until here so the new tmux panels are already + // tracked in activeRemoteTerminalSurfaceIds — preventing a spurious disconnect. + // Guard on !tmuxTracked.isEmpty to ensure at least one tmux panel was created + // (reconciliation can be a no-op if the pane list didn't change). + if !preTmuxTerminalIds.isEmpty { + let tmuxTracked = reconciler.allTrackedPanelIds() + guard !tmuxTracked.isEmpty else { break } + let toClose = preTmuxTerminalIds.filter { !tmuxTracked.contains($0) } + preTmuxTerminalIds.removeAll() + for panelId in toClose { + closePanel(panelId, force: true) + } + } + _ = layout // suppresses "unused let" warning; layout is consumed by reconciler + + case .paneModeChanged, .pasteBufferChanged, .clientSessionChanged: + // Parsed but not yet acted on. Stubs allow future handling without + // dropping these messages into the .default nil-parse path. + break + + default: + reconciler.apply(event) + } + } + + /// Focus the panel with the given ID. Called when tmux signals an active-pane change. + @MainActor + private func requestFocusPanel(_ panelId: UUID) { +#if DEBUG + dlog("tmux.windowPaneChanged requesting focus panelId=\(panelId.uuidString.prefix(8))") +#endif + focusPanel(panelId) + } + + /// Create a new terminal split attached to a specific tmux pane ID. + /// Implements `TmuxReconcilerWorkspace`. Used by `TmuxLayoutReconciler` + /// when a new pane appears in the tmux session. + /// + /// Pending-panel claim order: + /// 1. A pending panel tagged with `windowHint` (user split inside this window) + /// 2. A pending panel with no window tag (older code path or unknown window) + /// 3. Create a fresh split if no pending panel matches + @MainActor + @discardableResult + func newTerminalSplitForTmuxPane(_ paneId: String, windowHint: String) -> UUID? { + // Prefer a pending panel tagged for this specific tmux window. + if let idx = pendingTmuxPanelIds.firstIndex(where: { $0.windowHint == windowHint }), + let panel = panels[pendingTmuxPanelIds[idx].panelId] as? TerminalPanel { + pendingTmuxPanelIds.remove(at: idx) + return panel.id + } + // Fall back to any untagged pending panel. + if let idx = pendingTmuxPanelIds.firstIndex(where: { $0.windowHint == nil }), + let panel = panels[pendingTmuxPanelIds[idx].panelId] as? TerminalPanel { + pendingTmuxPanelIds.remove(at: idx) + return panel.id + } + + // No pending panel — create a fresh split. Only use a remote terminal as the + // split source. In mixed workspaces, picking a local terminal would attach the + // tmux-backed panel to the wrong subtree. Prefer the focused panel if it is + // tracked as a remote terminal; fall back to any remote terminal panel. + let sourcePanelId: UUID? + if let focused = focusedPanelId, + panels[focused] is TerminalPanel, + activeRemoteTerminalSurfaceIds.contains(focused) { + sourcePanelId = focused + } else { + sourcePanelId = activeRemoteTerminalSurfaceIds.first { + panels[$0] is TerminalPanel + } + } + guard let sourcePanelId else { return nil } + return newTerminalSplit( + from: sourcePanelId, + orientation: .horizontal, + focus: false, + tmuxPaneId: paneId + )?.id + } + + /// Called by the tmux session picker when the user selects or creates a session. + /// Ensures the session exists on the remote (creates if needed) then activates it. + /// + /// The picker sheet is NOT dismissed here — it is dismissed only after the controller + /// confirms a successful attach via `applyRemoteTmuxSession`. This prevents leaving + /// the user stranded (picker gone, no tmux session) when the async attach fails. + func selectTmuxSession(_ sessionName: String) { + let trimmed = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let controller = remoteSessionController else { return } + controller.selectTmuxSession(trimmed) + } + + /// Called when the user explicitly dismisses the tmux picker with "Skip". + /// Records the choice so the picker is not re-shown on subsequent terminal + /// lifecycle events within the same connection. + @MainActor func skipTmuxPicker() { + showTmuxSessionPicker = false + userSkippedTmuxPicker = true + } + fileprivate func applyBootstrapRemoteTTY(_ ttyName: String) { let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedTTY.isEmpty else { return } @@ -8782,7 +9563,8 @@ final class Workspace: Identifiable, ObservableObject { from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, - focus: Bool = true + focus: Bool = true, + tmuxPaneId: String? = nil ) -> TerminalPanel? { // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } @@ -8797,7 +9579,7 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + let remoteTerminalStartupCommand = remoteTerminalStartupCommand(tmuxPaneId: tmuxPaneId) // Inherit working directory: prefer the source panel's reported cwd, // then its requested startup cwd if shell integration has not reported @@ -8839,6 +9621,17 @@ final class Workspace: Identifiable, ObservableObject { } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + // If this is a user-initiated split in tmux mode (no specific pane ID provided), + // register the panel as pending so the reconciler can claim it when the ensuing + // %layout-change event reports the newly created tmux pane, instead of opening a + // duplicate cmux split for the same remote pane. + // Tag the entry with the source panel's tmux window so the reconciler can prefer + // it over remote-pane-triggered panels created concurrently in the same event burst. + if tmuxPaneId == nil, remoteTmuxSessionName != nil { + let windowHint = tmuxLayoutReconciler?.windowId(forPanel: panelId) + pendingTmuxPanelIds.append((panelId: newPanel.id, windowHint: windowHint)) + } + // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit // mutates layout state (avoids transient "Empty Panel" flashes during split). let newTab = Bonsplit.Tab( @@ -8865,6 +9658,8 @@ final class Workspace: Identifiable, ObservableObject { if remoteTerminalStartupCommand != nil { untrackRemoteTerminalSurface(newPanel.id) } + // Remove from pending list if the split creation failed. + pendingTmuxPanelIds.removeAll { $0.panelId == newPanel.id } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -8915,7 +9710,12 @@ final class Workspace: Identifiable, ObservableObject { let previousHostedView = focusedTerminalPanel?.hostedView let inheritedConfig = inheritedTerminalConfig(inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + // In tmux mode, create a simple SSH+tmux-attach terminal rather than the + // `tmux new-window` command used by split creation. This keeps the surface + // remote without mutating the tmux session layout (no extra window is created + // and the reconciler is not involved). The user lands on the session's active + // pane, which is acceptable for a new tab. + let remoteTerminalStartupCommand = remoteTerminalStartupCommandForSurface() // Create new terminal panel let newPanel = TerminalPanel( @@ -8979,7 +9779,52 @@ final class Workspace: Identifiable, ObservableObject { return newPanel } - private func remoteTerminalStartupCommand() -> String? { + /// Startup command for `newTerminalSurface` in a tmux-backed remote workspace. + /// Unlike `remoteTerminalStartupCommand(tmuxPaneId:)` which creates a new tmux window, + /// this attaches to the existing session without mutating its layout. The user lands + /// on the session's currently active pane, which is suitable for a new tab. + private func remoteTerminalStartupCommandForSurface() -> String? { + if let sessionName = remoteTmuxSessionName, + let config = remoteConfiguration { + let exactTarget = remoteTmuxSupportsExactTarget ? "=" + sessionName : sessionName + let remoteCmd = "exec tmux attach-session -t \(shellSingleQuote(exactTarget))" + return buildTmuxSSHStartupScript(config: config, remoteCommand: remoteCmd, tmuxPaneId: nil) + } + // Non-tmux remote: use the configured startup command (e.g. plain ssh). + guard let command = remoteConfiguration?.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines), + !command.isEmpty else { + return nil + } + return command + } + + private func remoteTerminalStartupCommand(tmuxPaneId: String? = nil) -> String? { + // If a tmux session has been selected, generate an SSH+tmux startup script. + if let sessionName = remoteTmuxSessionName, + let config = remoteConfiguration { + let remoteCmd: String + // Use `=` exact-match prefix (tmux ≥2.5) so that session names + // containing `:` or `.` are not mis-parsed as `session:window.pane` targets. + // Fall back to bare name on older tmux (pre-2.5) that doesn't support the prefix. + let exactTarget = remoteTmuxSupportsExactTarget ? "=" + sessionName : sessionName + if let paneId = tmuxPaneId { + // Attach to the session and immediately focus the specific pane. + // `attach-session` only accepts session/window targets, not pane IDs, + // so we attach first and then select the pane within that client. + // `-As` with an exact target: `-A` attaches if session exists (name lookup), + // `-s` names the new session if it must be created. + remoteCmd = "exec tmux new-session -As \(shellSingleQuote(exactTarget)) \\; select-pane -t \(shellSingleQuote(paneId))" + } else { + // User-initiated split: create a new tmux window in the session and + // attach to it. This gives the user a distinct remote pane rather than + // re-attaching to the same existing active pane. The control-mode + // subscriber will observe the resulting %layout-change event and + // reconcile the new pane with the cmux split. + remoteCmd = "exec tmux new-window -t \(shellSingleQuote(exactTarget)) \\; attach-session -t \(shellSingleQuote(exactTarget))" + } + return buildTmuxSSHStartupScript(config: config, remoteCommand: remoteCmd, tmuxPaneId: tmuxPaneId) + } guard let command = remoteConfiguration?.terminalStartupCommand? .trimmingCharacters(in: .whitespacesAndNewlines), !command.isEmpty else { @@ -8988,6 +9833,79 @@ final class Workspace: Identifiable, ObservableObject { return command } + /// Build a local shell script (written to a temp file) that opens an + /// interactive SSH session and runs `remoteCommand` on the remote. + private func buildTmuxSSHStartupScript( + config: WorkspaceRemoteConfiguration, + remoteCommand: String, + tmuxPaneId: String? = nil + ) -> String? { + guard let controller = remoteSessionController else { return nil } + let sshArgs = controller.terminalSSHArguments() + [config.destination] + + // Build the ssh invocation with the remote command appended. + let sshInvocation = (["ssh"] + sshArgs + [remoteCommand]) + .map { shellSingleQuote($0) } + .joined(separator: " ") + + // Self-delete before exec replaces the shell process so temp files do not + // accumulate across sessions. `rm -f -- "$0"` removes the script file + // immediately after the shell reads it. The kernel keeps the open inode + // alive until exec completes (the fd remains valid through the exec call + // itself since the script is already loaded into the shell's memory). + let script = """ + #!/bin/sh + rm -f -- "$0" + exec \(sshInvocation) + """ + + do { + let tempDir = FileManager.default.temporaryDirectory + // Build a unique suffix so concurrent script creations each get their own + // file. For reconciler-driven splits, use the pane ID (e.g. "3" from "%3"). + // For user-initiated splits (tmuxPaneId == nil), use a random UUID so that + // two rapid manual splits don't share a path — the self-deleting script + // would be removed by the first SSH exec before the second one starts. + let uniqueSuffix: String + if let pid = tmuxPaneId { + uniqueSuffix = "-" + pid.dropFirst() + } else { + uniqueSuffix = "-" + UUID().uuidString.prefix(8).lowercased() + } + let scriptURL = tempDir.appendingPathComponent( + "cmux-tmux-ssh-\(id.uuidString.lowercased())\(uniqueSuffix).sh" + ) + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: scriptURL.path + ) + return scriptURL.path + } catch { +#if DEBUG + dlog("tmux.startupScript.failed error=\(error)") +#endif + return nil + } + } + + private func shellSingleQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + /// Returns true when the remote tmux version supports exact-match session targets + /// (the `=` prefix, added in tmux 2.5). Defaults to true when the version + /// is unknown so that newer servers (the common case) get correct targeting. + private var remoteTmuxSupportsExactTarget: Bool { + guard let version = remoteTmuxVersion else { return true } + // Version string is e.g. "3.4" or "2.5a". Parse major.minor numerically. + let parts = version.split(separator: ".").map { $0.prefix(while: { $0.isNumber }) } + guard parts.count >= 2, + let major = Int(parts[0]), + let minor = Int(parts[1]) else { return true } + return (major, minor) >= (2, 5) + } + /// Create a new browser panel split @discardableResult func newBrowserSplit( @@ -11125,6 +12043,14 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - BonsplitDelegate +// MARK: - TmuxReconcilerWorkspace conformance + +extension Workspace: TmuxReconcilerWorkspace { + // newTerminalSplitForTmuxPane(_:windowHint:) is defined in the main class body above. + // closePanel(_:force:) matches the existing func closePanel(_ panelId: UUID, force: Bool). + // Swift satisfies the protocol via those existing implementations. +} + extension Workspace: BonsplitDelegate { @MainActor private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool { @@ -11724,6 +12650,12 @@ extension Workspace: BonsplitDelegate { surfaceTTYNames.removeValue(forKey: panelId) syncRemotePortScanTTYs() restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + // Remove any pending tmux pane assignment for this panel so the reconciler + // does not attempt to claim a destroyed panel for an incoming %layout-change. + pendingTmuxPanelIds.removeAll { $0.panelId == panelId } + // Remove any tmux pane tracking so a user-closed tmux-backed split is not + // re-opened by the next %layout-change (the tmux pane may still be running). + tmuxLayoutReconciler?.removeTracking(forPanel: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) if lastTerminalConfigInheritancePanelId == panelId { @@ -11877,6 +12809,7 @@ extension Workspace: BonsplitDelegate { surfaceTTYNames.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + pendingTmuxPanelIds.removeAll { $0.panelId == panelId } PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 13b8778173..ea56b0c197 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -385,6 +385,9 @@ struct WorkspaceContentView: View { bonsplitView } } + .sheet(isPresented: $workspace.showTmuxSessionPicker) { + TmuxSessionPickerSheet(workspace: workspace) + } } private func syncBonsplitNotificationBadges() { diff --git a/cmuxTests/TmuxControlParserTests.swift b/cmuxTests/TmuxControlParserTests.swift new file mode 100644 index 0000000000..9afa915be2 --- /dev/null +++ b/cmuxTests/TmuxControlParserTests.swift @@ -0,0 +1,327 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class TmuxControlParserTests: XCTestCase { + + // MARK: - %layout-change (TmuxControlEvent) + + func testLayoutChange_singlePane() { + let event = TmuxControlParser.parseLine("%layout-change @1 bb62,220x50,0,0,1") + guard case .layoutChange(let layout) = event else { + return XCTFail("Expected .layoutChange, got \(String(describing: event))") + } + XCTAssertEqual(layout.windowId, "@1") + XCTAssertEqual(layout.allPaneIds, ["%1"]) + XCTAssertFalse(layout.isZoomed) + } + + func testLayoutChange_horizontalSplit() { + let line = "%layout-change @2 ab12,220x50,0,0{110x50,0,0,3,110x50,111,0,4}" + let event = TmuxControlParser.parseLine(line) + guard case .layoutChange(let layout) = event else { + return XCTFail("Expected .layoutChange") + } + XCTAssertEqual(layout.windowId, "@2") + XCTAssertEqual(layout.allPaneIds, ["%3", "%4"]) + } + + func testLayoutChange_verticalSplit() { + let line = "%layout-change @3 cd34,220x50,0,0[220x24,0,0,5,220x25,0,25,6]" + let event = TmuxControlParser.parseLine(line) + guard case .layoutChange(let layout) = event else { + return XCTFail("Expected .layoutChange") + } + XCTAssertEqual(layout.windowId, "@3") + XCTAssertEqual(layout.allPaneIds, ["%5", "%6"]) + } + + func testLayoutChange_paneIdsSortedNumerically() { + // allPaneIds returns traversal order; extractPaneIds returns sorted. + let line = "%layout-change @4 ef56,220x50,0,0{110x50,0,0,10,110x50,111,0,2}" + let event = TmuxControlParser.parseLine(line) + guard case .layoutChange(let layout) = event else { + return XCTFail("Expected .layoutChange") + } + // Tree traversal order is left-to-right: %10, %2 + XCTAssertEqual(layout.allPaneIds, ["%10", "%2"]) + // extractPaneIds is the numerically-sorted convenience wrapper + XCTAssertEqual(TmuxControlParser.extractPaneIds(from: "ef56,220x50,0,0{110x50,0,0,10,110x50,111,0,2}"), ["%2", "%10"]) + } + + func testLayoutChange_zoomFlag() { + let line = "%layout-change @5 aa00,220x50,0,0,7 aa00,220x50,0,0,7 Z" + let event = TmuxControlParser.parseLine(line) + guard case .layoutChange(let layout) = event else { + return XCTFail("Expected .layoutChange") + } + XCTAssertTrue(layout.isZoomed) + } + + func testLayoutChange_noZoomFlag() { + let line = "%layout-change @5 aa00,220x50,0,0,7 aa00,220x50,0,0,7" + let event = TmuxControlParser.parseLine(line) + guard case .layoutChange(let layout) = event else { + return XCTFail("Expected .layoutChange") + } + XCTAssertFalse(layout.isZoomed) + } + + func testLayoutChange_missingLayoutTokenReturnsNil() { + XCTAssertNil(TmuxControlParser.parseLine("%layout-change @1")) + } + + // MARK: - %window-add / %window-close + + func testWindowAdd() { + let event = TmuxControlParser.parseLine("%window-add @7") + guard case .windowAdd(let window) = event else { + return XCTFail("Expected .windowAdd") + } + XCTAssertEqual(window, "@7") + } + + func testWindowClose() { + let event = TmuxControlParser.parseLine("%window-close @8") + guard case .windowClose(let window) = event else { + return XCTFail("Expected .windowClose") + } + XCTAssertEqual(window, "@8") + } + + // MARK: - Session events + + func testSessionRenamed() { + // Real tmux format: %session-renamed $ + let event = TmuxControlParser.parseLine("%session-renamed $1 my-new-name") + guard case .sessionRenamed(let id, let name) = event else { + return XCTFail("Expected .sessionRenamed") + } + XCTAssertEqual(id, "$1") + XCTAssertEqual(name, "my-new-name") + } + + func testSessionsChanged() { + let event = TmuxControlParser.parseLine("%sessions-changed") + guard case .sessionsChanged = event else { + return XCTFail("Expected .sessionsChanged") + } + } + + func testWindowRenamed() { + let event = TmuxControlParser.parseLine("%window-renamed @3 bash") + guard case .windowRenamed(let window, let name) = event else { + return XCTFail("Expected .windowRenamed") + } + XCTAssertEqual(window, "@3") + XCTAssertEqual(name, "bash") + } + + func testSessionWindowChanged() { + let event = TmuxControlParser.parseLine("%session-window-changed $2 @5") + guard case .sessionWindowChanged(let sid, let win) = event else { + return XCTFail("Expected .sessionWindowChanged") + } + XCTAssertEqual(sid, "$2") + XCTAssertEqual(win, "@5") + } + + func testWindowPaneChanged() { + let event = TmuxControlParser.parseLine("%window-pane-changed @4 %9") + guard case .windowPaneChanged(let window, let pane) = event else { + return XCTFail("Expected .windowPaneChanged") + } + XCTAssertEqual(window, "@4") + XCTAssertEqual(pane, "%9") + } + + // MARK: - New stub events (tmux ≥2.5 / ≥3.6) + + func testPaneModeChanged() { + let event = TmuxControlParser.parseLine("%pane-mode-changed %3 copy") + guard case .paneModeChanged(let paneId, let mode) = event else { + return XCTFail("Expected .paneModeChanged") + } + XCTAssertEqual(paneId, "%3") + XCTAssertEqual(mode, "copy") + } + + func testPaneModeChanged_noMode() { + let event = TmuxControlParser.parseLine("%pane-mode-changed %5") + guard case .paneModeChanged(let paneId, let mode) = event else { + return XCTFail("Expected .paneModeChanged") + } + XCTAssertEqual(paneId, "%5") + XCTAssertEqual(mode, "") + } + + func testPasteBufferChanged() { + let event = TmuxControlParser.parseLine("%paste-buffer-changed") + guard case .pasteBufferChanged = event else { + return XCTFail("Expected .pasteBufferChanged") + } + } + + func testClientSessionChanged() { + let event = TmuxControlParser.parseLine("%client-session-changed") + guard case .clientSessionChanged = event else { + return XCTFail("Expected .clientSessionChanged") + } + } + + // MARK: - %exit + + func testExit() { + let event = TmuxControlParser.parseLine("%exit") + guard case .exit = event else { + return XCTFail("Expected .exit") + } + } + + // MARK: - Unknown / non-events return nil + + func testBeginReturnsNil() { + XCTAssertNil(TmuxControlParser.parseLine("%begin 1234567890 123 0")) + } + + func testEndReturnsNil() { + XCTAssertNil(TmuxControlParser.parseLine("%end 1234567890 123 0")) + } + + func testErrorReturnsNil() { + XCTAssertNil(TmuxControlParser.parseLine("%error 1234567890 123 0")) + } + + func testNonPercentLineReturnsNil() { + XCTAssertNil(TmuxControlParser.parseLine("some random output")) + } + + func testEmptyLineReturnsNil() { + XCTAssertNil(TmuxControlParser.parseLine("")) + } + + func testCarriageReturnStripped() { + let event = TmuxControlParser.parseLine("%window-close @1\r\n") + guard case .windowClose = event else { + return XCTFail("Expected .windowClose after CR/LF stripping") + } + } + + // MARK: - Layout checksum skipping + + func testChecksumPrefixSkipped() { + let event = TmuxControlParser.parseLine("%layout-change @1 bb62,100x30,0,0,42") + guard case .layoutChange(let layout) = event else { + return XCTFail("Expected .layoutChange") + } + XCTAssertEqual(layout.allPaneIds, ["%42"]) + } + + func testNestedLayout_threeWaySplit() { + let layoutStr = "aa00,220x50,0,0{110x50,0,0,1,110x50,111,0[110x24,111,0,2,110x25,111,25,3]}" + let event = TmuxControlParser.parseLine("%layout-change @1 \(layoutStr)") + guard case .layoutChange(let layout) = event else { + return XCTFail("Expected .layoutChange") + } + XCTAssertEqual(Set(layout.allPaneIds), Set(["%1", "%2", "%3"])) + XCTAssertEqual(layout.allPaneIds.count, 3) + } + + // MARK: - extractPaneIds standalone + + func testExtractPaneIds_singleLeaf() { + XCTAssertEqual(TmuxControlParser.extractPaneIds(from: "220x50,0,0,5"), ["%5"]) + } + + func testExtractPaneIds_withChecksum() { + XCTAssertEqual(TmuxControlParser.extractPaneIds(from: "bb62,220x50,0,0,5"), ["%5"]) + } + + func testExtractPaneIds_emptyString() { + XCTAssertEqual(TmuxControlParser.extractPaneIds(from: ""), []) + } + + // MARK: - Layout tree (TmuxLayoutNode / TmuxLayout) + + func testParseLayoutTree_singlePane_geometry() { + let layout = TmuxControlParser.parseLayoutTree(windowId: "@1", flags: "", layoutString: "bb62,220x50,0,0,3") + XCTAssertNotNil(layout) + guard case .pane(let geom) = layout?.root else { + return XCTFail("Expected .pane root") + } + XCTAssertEqual(geom.paneId, "%3") + XCTAssertEqual(geom.width, 220) + XCTAssertEqual(geom.height, 50) + XCTAssertEqual(geom.x, 0) + XCTAssertEqual(geom.y, 0) + } + + func testParseLayoutTree_horizontalSplit_structure() { + // Two panes side by side + let str = "abcd,220x50,0,0{110x50,0,0,1,110x50,111,0,2}" + let layout = TmuxControlParser.parseLayoutTree(windowId: "@2", flags: "", layoutString: str) + XCTAssertNotNil(layout) + guard case .horizontal(let children, let w, _, _, _) = layout?.root else { + return XCTFail("Expected .horizontal root") + } + XCTAssertEqual(w, 220) + XCTAssertEqual(children.count, 2) + guard case .pane(let left) = children[0], case .pane(let right) = children[1] else { + return XCTFail("Expected two pane children") + } + XCTAssertEqual(left.paneId, "%1") + XCTAssertEqual(right.paneId, "%2") + } + + func testParseLayoutTree_verticalSplit_structure() { + // Two panes stacked + let str = "abcd,220x50,0,0[220x25,0,0,1,220x24,0,26,2]" + let layout = TmuxControlParser.parseLayoutTree(windowId: "@3", flags: "", layoutString: str) + XCTAssertNotNil(layout) + guard case .vertical(let children, _, _, _, _) = layout?.root else { + return XCTFail("Expected .vertical root") + } + XCTAssertEqual(children.count, 2) + } + + func testParseLayoutTree_nestedSplit_threePane() { + // Left pane | right side [top/bottom] + let str = "aa00,220x50,0,0{110x50,0,0,1,110x50,111,0[110x24,111,0,2,110x25,111,25,3]}" + let layout = TmuxControlParser.parseLayoutTree(windowId: "@4", flags: "", layoutString: str) + XCTAssertNotNil(layout) + guard case .horizontal(let children, _, _, _, _) = layout?.root else { + return XCTFail("Expected .horizontal root") + } + XCTAssertEqual(children.count, 2) + guard case .pane(let left) = children[0] else { + return XCTFail("Expected left child to be pane") + } + XCTAssertEqual(left.paneId, "%1") + guard case .vertical(let rightChildren, _, _, _, _) = children[1] else { + return XCTFail("Expected right child to be vertical split") + } + XCTAssertEqual(rightChildren.count, 2) + XCTAssertEqual(layout?.allPaneIds, ["%1", "%2", "%3"]) + } + + func testParseLayoutTree_zoomFlag() { + let layout = TmuxControlParser.parseLayoutTree(windowId: "@5", flags: "*Z", + layoutString: "aa00,220x50,0,0,7") + XCTAssertEqual(layout?.isZoomed, true) + XCTAssertEqual(layout?.windowFlags, "*Z") + } + + func testParseLayoutTree_noZoomFlag() { + let layout = TmuxControlParser.parseLayoutTree(windowId: "@5", flags: "", + layoutString: "aa00,220x50,0,0,7") + XCTAssertEqual(layout?.isZoomed, false) + } + + func testParseLayoutTree_emptyStringReturnsNil() { + XCTAssertNil(TmuxControlParser.parseLayoutTree(windowId: "@1", flags: "", layoutString: "")) + } +} diff --git a/cmuxTests/TmuxLayoutReconcilerTests.swift b/cmuxTests/TmuxLayoutReconcilerTests.swift new file mode 100644 index 0000000000..0eee926469 --- /dev/null +++ b/cmuxTests/TmuxLayoutReconcilerTests.swift @@ -0,0 +1,364 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +// MARK: - Mock workspace + +/// Test double for `TmuxReconcilerWorkspace`. Records calls and controls +/// the panel UUIDs returned by `newTerminalSplitForTmuxPane`. +@MainActor +final class MockTmuxReconcilerWorkspace: TmuxReconcilerWorkspace { + /// Pane IDs passed to `newTerminalSplitForTmuxPane`, in call order. + var createdPaneIds: [String] = [] + /// Window hints passed alongside each `newTerminalSplitForTmuxPane` call. + var createdWindowHints: [String] = [] + /// Panel IDs passed to `closePanel`, in call order. + var closedPanelIds: [UUID] = [] + /// Optional override: if set, `newTerminalSplitForTmuxPane` returns these + /// in FIFO order. If empty it auto-generates a fresh UUID per call. + var panelIdsToReturn: [UUID] = [] + + func newTerminalSplitForTmuxPane(_ paneId: String, windowHint: String) -> UUID? { + createdPaneIds.append(paneId) + createdWindowHints.append(windowHint) + if !panelIdsToReturn.isEmpty { + return panelIdsToReturn.removeFirst() + } + return UUID() + } + + @discardableResult + func closePanel(_ panelId: UUID, force: Bool) -> Bool { + closedPanelIds.append(panelId) + return true + } + + func reset() { + createdPaneIds.removeAll() + createdWindowHints.removeAll() + closedPanelIds.removeAll() + panelIdsToReturn.removeAll() + } +} + +// MARK: - Helpers + +private func makeLayout( + window: String, + paneIds: [String], + flags: String = "" +) -> TmuxControlEvent { + // Build a flat horizontal layout containing `paneIds` as leaves. + let root: TmuxLayoutNode + if paneIds.isEmpty { + // No panes — return an event that won't match any reconcile path. + // (In practice tmux never sends an empty layout, but guard anyway.) + return .windowClose(window: window) + } else if paneIds.count == 1 { + root = .pane(TmuxPaneGeometry(paneId: paneIds[0], width: 220, height: 50, x: 0, y: 0)) + } else { + let children = paneIds.enumerated().map { idx, pid in + TmuxLayoutNode.pane(TmuxPaneGeometry(paneId: pid, + width: 110, height: 50, + x: idx * 111, y: 0)) + } + root = .horizontal(children, width: 220, height: 50, x: 0, y: 0) + } + let layout = TmuxLayout(windowId: window, windowFlags: flags, root: root) + return .layoutChange(layout: layout) +} + +/// Drive the reconciler with an event and wait for any deferred Tasks to run. +@MainActor +private func applyAndDrain(_ reconciler: TmuxLayoutReconciler, _ event: TmuxControlEvent) async { + reconciler.apply(event) + // Yield twice so async Task { } closures scheduled inside the reconciler + // have a chance to execute on the main actor before the test assertion. + await Task.yield() + await Task.yield() +} + +// MARK: - Tests + +@MainActor +final class TmuxLayoutReconcilerTests: XCTestCase { + + // MARK: - Basic panel lookup + + func testPanelIdForTmuxPane_unknownPaneReturnsNil() { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + reconciler.attach(to: mock) + XCTAssertNil(reconciler.panelId(forTmuxPane: "%99")) + } + + // MARK: - reset() + + func testReset_clearsAllState() { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + reconciler.attach(to: mock) + reconciler.reset() + XCTAssertNil(reconciler.panelId(forTmuxPane: "%1")) + XCTAssertNil(reconciler.panelId(forTmuxPane: "%2")) + } + + func testReset_idempotent() { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + reconciler.attach(to: mock) + reconciler.reset() + reconciler.reset() + } + + // MARK: - Fresh attach + + func testFreshAttach_createsOnePanelPerPane() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + reconciler.attach(to: mock) + + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1", "%2", "%3"])) + + XCTAssertEqual(mock.createdPaneIds.sorted(), ["%1", "%2", "%3"]) + XCTAssertEqual(mock.closedPanelIds.count, 0) + } + + func testFreshAttach_panelsAreTracked() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + let panelA = UUID() + let panelB = UUID() + mock.panelIdsToReturn = [panelA, panelB] + reconciler.attach(to: mock) + + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1", "%2"])) + + XCTAssertEqual(reconciler.panelId(forTmuxPane: "%1"), panelA) + XCTAssertEqual(reconciler.panelId(forTmuxPane: "%2"), panelB) + } + + // MARK: - Pane removal + + func testPaneRemoved_closesPanel() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + let panel1 = UUID() + let panel2 = UUID() + mock.panelIdsToReturn = [panel1, panel2] + reconciler.attach(to: mock) + + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1", "%2"])) + mock.closedPanelIds.removeAll() + + // Remove %2 from layout + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1"])) + + XCTAssertEqual(mock.closedPanelIds, [panel2]) + XCTAssertNil(reconciler.panelId(forTmuxPane: "%2")) + XCTAssertNotNil(reconciler.panelId(forTmuxPane: "%1")) + } + + // MARK: - Zoom handling (must NOT skip reconciliation) + + func testZoomedWindow_stillReconciles() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + reconciler.attach(to: mock) + + // First layout establishes %1 + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1"])) + XCTAssertEqual(mock.createdPaneIds, ["%1"]) + + mock.createdPaneIds.removeAll() + + // Second layout arrives while zoomed — %2 added while zoom active. + // The reconciler must still process pane additions/removals. + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1", "%2"], flags: "Z")) + + XCTAssertEqual(mock.createdPaneIds, ["%2"], + "Reconciliation must run even when the window is zoomed") + } + + func testZoomedWindow_isZoomedFlagExposed() { + let layout = TmuxLayout(windowId: "@1", windowFlags: "*Z", + root: .pane(TmuxPaneGeometry(paneId: "%1", width: 220, + height: 50, x: 0, y: 0))) + XCTAssertTrue(layout.isZoomed) + } + + // MARK: - Move-pane (break-pane / join-pane) + + func testMovePaneAcrossWindows_preservesPanel() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + let panel1 = UUID() + let panel2 = UUID() + mock.panelIdsToReturn = [panel1, panel2] + reconciler.attach(to: mock) + + // Initial state: @A has %1 and %2 + await applyAndDrain(reconciler, makeLayout(window: "@A", paneIds: ["%1", "%2"])) + XCTAssertEqual(reconciler.panelId(forTmuxPane: "%2"), panel2) + mock.closedPanelIds.removeAll() + + // Simulate break-pane: tmux sends both layout changes in the same event burst + // (i.e. before the run-loop yields). Apply both synchronously, then drain. + // If we drained between the two applies the purge would fire prematurely and + // close panel2 before @B's layout-change can adopt it — that's not the real + // tmux ordering where events arrive in a tight FIFO sequence. + reconciler.apply(makeLayout(window: "@A", paneIds: ["%1"])) // %2 orphaned + reconciler.apply(makeLayout(window: "@B", paneIds: ["%2"])) // %2 adopted before purge + + // Drain: purge task runs — nothing left to close since %2 was adopted. + await Task.yield() + await Task.yield() + + XCTAssertEqual(mock.closedPanelIds, [], "panel2 should be adopted, not closed") + XCTAssertEqual(reconciler.panelId(forTmuxPane: "%2"), panel2) + XCTAssertEqual(reconciler.windowId(forPanel: panel2), "@B") + } + + func testPaneDies_closesPanel() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + let panel1 = UUID() + let panel2 = UUID() + mock.panelIdsToReturn = [panel1, panel2] + reconciler.attach(to: mock) + + await applyAndDrain(reconciler, makeLayout(window: "@A", paneIds: ["%1", "%2"])) + + // %2 exits — it disappears from @A and never appears elsewhere + await applyAndDrain(reconciler, makeLayout(window: "@A", paneIds: ["%1"])) + + XCTAssertEqual(mock.closedPanelIds, [panel2], "Dead pane's panel must be closed after purge") + XCTAssertNil(reconciler.panelId(forTmuxPane: "%2")) + } + + // MARK: - Window-scoped pending panel claim + + func testPendingPanel_claimedForCorrectWindow() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + reconciler.attach(to: mock) + + // Establish two windows each with one pane + let panelW1 = UUID() + let panelW2 = UUID() + mock.panelIdsToReturn = [panelW1, panelW2] + await applyAndDrain(reconciler, makeLayout(window: "@W1", paneIds: ["%1"])) + await applyAndDrain(reconciler, makeLayout(window: "@W2", paneIds: ["%2"])) + mock.createdPaneIds.removeAll() + + // The reconciler provides the correct window hint for each new pane + await applyAndDrain(reconciler, makeLayout(window: "@W1", paneIds: ["%1", "%3"])) + XCTAssertEqual(mock.createdWindowHints.last, "@W1", + "New pane in @W1 should carry windowHint @W1") + + await applyAndDrain(reconciler, makeLayout(window: "@W2", paneIds: ["%2", "%4"])) + XCTAssertEqual(mock.createdWindowHints.last, "@W2", + "New pane in @W2 should carry windowHint @W2") + } + + // MARK: - User dismiss + + func testUserDismiss_suppressesReopenForLivePane() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + let panel1 = UUID() + mock.panelIdsToReturn = [panel1] + reconciler.attach(to: mock) + + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1"])) + XCTAssertEqual(reconciler.panelId(forTmuxPane: "%1"), panel1) + + // User closes the panel (tmux pane is still alive) + reconciler.removeTracking(forPanel: panel1) + mock.createdPaneIds.removeAll() + + // Next layout change still reports %1 alive — must NOT reopen + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1"])) + + XCTAssertEqual(mock.createdPaneIds, [], "Dismissed pane must not be recreated") + } + + // MARK: - windowClose event + + func testWindowClose_closesAllPanelsInWindow() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + let p1 = UUID(), p2 = UUID() + mock.panelIdsToReturn = [p1, p2] + reconciler.attach(to: mock) + + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1", "%2"])) + mock.closedPanelIds.removeAll() + + reconciler.apply(.windowClose(window: "@1")) + + XCTAssertEqual(Set(mock.closedPanelIds), Set([p1, p2])) + XCTAssertNil(reconciler.panelId(forTmuxPane: "%1")) + XCTAssertNil(reconciler.panelId(forTmuxPane: "%2")) + } + + // MARK: - allTrackedPanelIds + + func testAllTrackedPanelIds_reflectsLiveSet() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + let p1 = UUID(), p2 = UUID() + mock.panelIdsToReturn = [p1, p2] + reconciler.attach(to: mock) + + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1", "%2"])) + XCTAssertEqual(reconciler.allTrackedPanelIds(), Set([p1, p2])) + + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1"])) + XCTAssertEqual(reconciler.allTrackedPanelIds(), Set([p1])) + } + + // MARK: - windowId(forPanel:) + + func testWindowIdForPanel_returnsCorrectWindow() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + let p1 = UUID() + mock.panelIdsToReturn = [p1] + reconciler.attach(to: mock) + + await applyAndDrain(reconciler, makeLayout(window: "@W", paneIds: ["%1"])) + XCTAssertEqual(reconciler.windowId(forPanel: p1), "@W") + } + + func testWindowIdForPanel_unknownPanelReturnsNil() { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + reconciler.attach(to: mock) + XCTAssertNil(reconciler.windowId(forPanel: UUID())) + } + + // MARK: - reset clears all state including orphans + + func testReset_cancelsOrphansAndClearsAll() async { + let reconciler = TmuxLayoutReconciler() + let mock = MockTmuxReconcilerWorkspace() + let p1 = UUID() + mock.panelIdsToReturn = [p1] + reconciler.attach(to: mock) + + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1"])) + // Orphan the pane (remove from layout without replacement) + reconciler.apply(makeLayout(window: "@1", paneIds: [])) // triggers orphan but not valid + reconciler.reset() + mock.closedPanelIds.removeAll() + + // After reset, nothing should close or re-create on next layout + await applyAndDrain(reconciler, makeLayout(window: "@1", paneIds: ["%1"])) + XCTAssertEqual(mock.closedPanelIds, [], "Reset should have purged orphans without closing") + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 78c647a324..e11855fbfd 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -317,6 +317,7 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { "proxy.socks5", "proxy.stream", "proxy.stream.push", + "tmux.adapter", }, }, } @@ -348,6 +349,18 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { return s.handleSessionDetach(req) case "session.status": return s.handleSessionStatus(req) + case "tmux.probe": + return s.handleTmuxProbe(req) + case "tmux.session.list": + return s.handleTmuxSessionList(req) + case "tmux.session.ensure": + return s.handleTmuxSessionEnsure(req) + case "tmux.pane.new": + return s.handleTmuxPaneNew(req) + case "tmux.pane.list": + return s.handleTmuxPaneList(req) + case "tmux.pane.exists": + return s.handleTmuxPaneExists(req) default: return rpcResponse{ ID: req.ID, @@ -989,6 +1002,7 @@ func (s *rpcServer) closeAll() { for _, conn := range streams { _ = conn.Close() } + } func (s *rpcServer) streamPump(streamID string, conn net.Conn) { diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_adapter.go b/daemon/remote/cmd/cmuxd-remote/tmux_adapter.go new file mode 100644 index 0000000000..a6aa87a760 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/tmux_adapter.go @@ -0,0 +1,383 @@ +package main + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// validNewTmuxName restricts names for newly created tmux sessions to characters +// that are safe, portable, and unambiguous in tmux targets. This is enforced +// only when the user creates a new session via the picker; existing sessions +// discovered by tmux.session.list may have any name and are never validated — +// all tmux calls use exec.CommandContext (not a shell), so injection is not +// possible regardless of the session name. +var validNewTmuxName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +func isValidNewTmuxName(s string) bool { return s != "" && validNewTmuxName.MatchString(s) } + +// tmuxExactTarget returns a tmux target string for the given session name. +// On tmux ≥2.5, the "=" prefix forces exact-match semantics so that names +// containing ":" or "." are not parsed as "session:window(.pane)" targets. +// On older versions (which lack the "=" prefix), we fall back to the raw name; +// in that case names with ":" or "." may resolve incorrectly, but such session +// names are extremely rare and would be pre-existing user configuration. +func tmuxExactTarget(name string) string { + out, err := tmuxOutput("-V") + if err != nil { + return name // can't probe version; use raw name as safe fallback + } + version := strings.TrimSpace(string(out)) + if parts := strings.SplitN(version, " ", 2); len(parts) == 2 { + version = parts[1] + } + if tmuxVersionAtLeast(version, 2, 5) { + return "=" + name + } + return name +} + +// tmuxVersionAtLeast returns true if the tmux version string is >= major.minor. +func tmuxVersionAtLeast(version string, major, minor int) bool { + // Strip any trailing non-numeric suffix (e.g. "3.4a" → "3.4"). + clean := strings.TrimRight(version, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + parts := strings.SplitN(clean, ".", 2) + maj, err := strconv.Atoi(parts[0]) + if err != nil { + return false + } + if maj != major { + return maj > major + } + if len(parts) < 2 { + return minor == 0 + } + min, err := strconv.Atoi(parts[1]) + if err != nil { + return false + } + return min >= minor +} + +// tmuxExecTimeout is the maximum time to wait for a one-shot tmux command. +const tmuxExecTimeout = 15 * time.Second + +// tmuxOutput runs "tmux " with a hard timeout and returns stdout. +func tmuxOutput(args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), tmuxExecTimeout) + defer cancel() + return exec.CommandContext(ctx, "tmux", args...).Output() +} + +// tmuxRun runs "tmux " with a hard timeout and returns only the error. +func tmuxRun(args ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), tmuxExecTimeout) + defer cancel() + return exec.CommandContext(ctx, "tmux", args...).Run() +} + +// tmuxCombinedOutput runs "tmux " with a hard timeout and returns combined stdout+stderr. +func tmuxCombinedOutput(args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), tmuxExecTimeout) + defer cancel() + return exec.CommandContext(ctx, "tmux", args...).CombinedOutput() +} + +// --- RPC handlers --- + +func (s *rpcServer) handleTmuxProbe(req rpcRequest) rpcResponse { + out, err := tmuxOutput("-V") + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "available": false, + "version": "", + }, + } + } + version := strings.TrimSpace(string(out)) + // "tmux 3.4" → "3.4" + if parts := strings.SplitN(version, " ", 2); len(parts) == 2 { + version = parts[1] + } + + // Gate availability on control-mode support. tmux -CC was introduced in tmux 1.8. + // Advertising available=true on older builds would let the picker proceed, then + // fail silently when the control-mode SSH process exits immediately. + if !tmuxVersionAtLeast(version, 1, 8) { + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "available": false, + "version": version, + }, + } + } + + // Also probe UTF-8 mode to surface potential encoding issues. + // Use "show-options -gv utf8" rather than "display-message -p #{client_utf8}" + // because the latter requires an attached client and always fails when probed + // before any client connects (which is the common case on fresh tmux servers). + utf8OK := true // default true; only flag false when explicitly disabled + if out2, err2 := tmuxOutput("show-options", "-gv", "utf8"); err2 == nil { + v := strings.TrimSpace(string(out2)) + utf8OK = v != "off" && v != "0" + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "available": true, + "version": version, + "utf8": utf8OK, + }, + } +} + +func (s *rpcServer) handleTmuxSessionList(req rpcRequest) rpcResponse { + // Format: session_name TAB windows TAB attached TAB created (seconds since epoch) + format := "#{session_name}\t#{session_windows}\t#{session_attached}\t#{session_created}" + out, err := tmuxOutput("list-sessions", "-F", format) + if err != nil { + // tmux not running or no sessions — return empty list, not an error. + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "sessions": []any{}, + }, + } + } + + var sessions []map[string]any + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 4) + if len(parts) == 0 || parts[0] == "" { + continue + } + session := map[string]any{ + "name": parts[0], + "windows": 0, + "attached": false, + } + if len(parts) > 1 { + if n, err := strconv.Atoi(parts[1]); err == nil { + session["windows"] = n + } + } + if len(parts) > 2 { + session["attached"] = parts[2] != "0" + } + sessions = append(sessions, session) + } + if sessions == nil { + sessions = []map[string]any{} + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "sessions": sessions, + }, + } +} + +func (s *rpcServer) handleTmuxSessionEnsure(req rpcRequest) rpcResponse { + session, ok := getStringParam(req.Params, "session") + if !ok || session == "" { + return errResponse(req.ID, "invalid_params", "tmux.session.ensure requires session") + } + + // Use an exact-match target (tmux ≥2.5) so that session names containing ":" + // or "." are not resolved as "session:window(.pane)" targets. + exactTarget := tmuxExactTarget(session) + + // Helper: get the session's stable $-prefixed ID after ensure succeeds. + getSessionID := func() string { + out, err := tmuxOutput("display-message", "-t", exactTarget, "-p", "#{session_id}") + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) + } + + // Check if session already exists. + if tmuxRun("has-session", "-t", exactTarget) == nil { + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "session": session, + "session_id": getSessionID(), + "created": false, + }, + } + } + + // Session does not exist — create it. Only allow safe names for new sessions + // so they can be referenced unambiguously in future tmux targets. + if !isValidNewTmuxName(session) { + return errResponse(req.ID, "invalid_params", fmt.Sprintf("invalid tmux session name: %q (new sessions must use letters, digits, _ and - only)", session)) + } + + // Create detached session. Use -x 220 -y 50 as initial size; will be + // resized when the first pane attaches. + if out, err := tmuxCombinedOutput("new-session", "-d", "-s", session, "-x", "220", "-y", "50"); err != nil { + return errResponse(req.ID, "tmux_error", + fmt.Sprintf("failed to create tmux session: %v: %s", err, strings.TrimSpace(string(out)))) + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "session": session, + "session_id": getSessionID(), + "created": true, + }, + } +} + +func (s *rpcServer) handleTmuxPaneNew(req rpcRequest) rpcResponse { + session, ok := getStringParam(req.Params, "session") + if !ok || session == "" { + return errResponse(req.ID, "invalid_params", "tmux.pane.new requires session") + } + cwd, _ := getStringParam(req.Params, "cwd") + + // Use exact-match target (tmux ≥2.5) so that session names containing ":" or "." + // are not mis-parsed as "session:window(.pane)" targets. + exactTarget := tmuxExactTarget(session) + args := []string{"new-window", "-t", exactTarget, "-P", "-F", "#{pane_id}"} + if cwd != "" { + args = append(args, "-c", cwd) + } + out, err := tmuxOutput(args...) + if err != nil { + return errResponse(req.ID, "tmux_error", + fmt.Sprintf("tmux new-window failed: %v", err)) + } + paneID := strings.TrimSpace(string(out)) + if paneID == "" { + return errResponse(req.ID, "tmux_error", "tmux new-window returned empty pane_id") + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "pane_id": paneID, + }, + } +} + +func (s *rpcServer) handleTmuxPaneList(req rpcRequest) rpcResponse { + session, ok := getStringParam(req.Params, "session") + if !ok || session == "" { + return errResponse(req.ID, "invalid_params", "tmux.pane.list requires session") + } + + // Use exact-match target (tmux ≥2.5) so that session names containing ":" or "." + // are not mis-parsed as "session:window(.pane)" targets. + exactTarget := tmuxExactTarget(session) + + // List all panes across all windows in the session. + // Format: pane_id TAB pane_current_path TAB pane_title TAB pane_current_command + format := "#{pane_id}\t#{pane_current_path}\t#{pane_title}\t#{pane_current_command}" + out, err := tmuxOutput("list-panes", "-s", "-t", exactTarget, "-F", format) + if err != nil { + // Session may not exist yet; return empty list rather than error. + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "panes": []any{}, + }, + } + } + + var panes []map[string]any + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 4) + if len(parts) == 0 || parts[0] == "" { + continue + } + pane := map[string]any{ + "pane_id": parts[0], + "cwd": "", + "title": "", + "command": "", + } + if len(parts) > 1 { + pane["cwd"] = parts[1] + } + if len(parts) > 2 { + pane["title"] = parts[2] + } + if len(parts) > 3 { + pane["command"] = parts[3] + } + panes = append(panes, pane) + } + if panes == nil { + panes = []map[string]any{} + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "panes": panes, + }, + } +} + +func (s *rpcServer) handleTmuxPaneExists(req rpcRequest) rpcResponse { + paneID, ok := getStringParam(req.Params, "pane_id") + if !ok || paneID == "" { + return errResponse(req.ID, "invalid_params", "tmux.pane.exists requires pane_id") + } + err := tmuxRun("display-message", "-t", paneID, "-p", "") + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "exists": err == nil, + }, + } +} + +// handleTmuxControlSubscribe starts tmux control mode for the given session and +// streams control-mode lines back to the client as push events named +// "tmux.control.line". Each event carries the raw line base64-encoded. +// +// If a subscription for the same session already exists, the existing stream ID +// is returned. If a subscription for a different session exists, it is stopped +// and a new one is started. +// --- helpers --- + +func errResponse(id any, code, message string) rpcResponse { + return rpcResponse{ + ID: id, + OK: false, + Error: &rpcError{ + Code: code, + Message: message, + }, + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_adapter_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_adapter_test.go new file mode 100644 index 0000000000..53eceb99fb --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/tmux_adapter_test.go @@ -0,0 +1,88 @@ +package main + +import "testing" + +// TestTmuxVersionAtLeast verifies the version comparison logic that gates +// exact-match target syntax (tmux ≥2.5) and control-mode support (tmux ≥1.8). +func TestTmuxVersionAtLeast(t *testing.T) { + tests := []struct { + version string + major int + minor int + want bool + }{ + // Exact match + {"2.5", 2, 5, true}, + {"1.8", 1, 8, true}, + {"3.4", 3, 4, true}, + + // Greater than + {"3.0", 2, 5, true}, + {"3.4", 3, 3, true}, + {"3.5", 3, 4, true}, + {"4.0", 3, 4, true}, + {"2.9", 2, 5, true}, + + // Less than + {"2.4", 2, 5, false}, + {"1.7", 1, 8, false}, + {"3.3", 3, 4, false}, + {"1.9", 2, 0, false}, + + // Suffix stripped (e.g. "3.4a" release candidates) + {"3.4a", 3, 4, true}, + {"3.3b", 3, 4, false}, + {"2.5a", 2, 5, true}, + + // Minor == 0 edge cases + {"3.0", 3, 0, true}, + {"2.0", 3, 0, false}, + + // Malformed input — should not panic, return false + {"", 2, 5, false}, + {"abc", 2, 5, false}, + {"x.y", 2, 5, false}, + } + for _, tt := range tests { + got := tmuxVersionAtLeast(tt.version, tt.major, tt.minor) + if got != tt.want { + t.Errorf("tmuxVersionAtLeast(%q, %d, %d) = %v, want %v", + tt.version, tt.major, tt.minor, got, tt.want) + } + } +} + +// TestIsValidNewTmuxName verifies names that are allowed for new tmux sessions. +// The restriction is intentionally tighter than what tmux itself permits — +// only ASCII alphanumeric, dash, and underscore — to ensure unambiguous exact targets. +func TestIsValidNewTmuxName(t *testing.T) { + valid := []string{ + "dev", + "my-session", + "my_session", + "Session123", + "abc-123_DEF", + "a", + } + invalid := []string{ + "", // empty + "my session", // space + "my:session", // colon — tmux would parse as window target + "my.session", // dot — tmux would parse as pane target + "foo/bar", // slash + "@special", // at-sign + "$id", // dollar — tmux session ID prefix + "%pane", // percent — tmux pane ID prefix + } + + for _, name := range valid { + if !isValidNewTmuxName(name) { + t.Errorf("isValidNewTmuxName(%q) = false, want true", name) + } + } + for _, name := range invalid { + if isValidNewTmuxName(name) { + t.Errorf("isValidNewTmuxName(%q) = true, want false", name) + } + } +}