Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
85cc7f5
remote-tmux: classify ProxyCommand-closed transports as interactive-r…
ejc3 Jun 28, 2026
69d84dd
remote-tmux: only classify SILENT proxy closures as interactive-retry…
ejc3 Jun 28, 2026
a1d36dc
remote-tmux: exclude DNS-resolution failures from silent-proxy-close …
ejc3 Jun 28, 2026
d69bcad
remote-tmux: exclude BSD/macOS bare `getaddrinfo` NXDOMAIN from silen…
ejc3 Jun 28, 2026
c260404
remote-tmux: treat Linux connect-timeout and banner-exchange proxy cl…
ejc3 Jun 28, 2026
f7bae0c
remote-tmux: linked-view beta flag + pure view reconciler (foundation)
ejc3 Jun 28, 2026
91e9c12
remote-tmux: owned view-session identity for linked-view mode
ejc3 Jun 28, 2026
7c6ea6a
remote-tmux: window->workspace regrouping model for linked-view
ejc3 Jun 28, 2026
3fb8a21
remote-tmux: composed linked-view planner (brain of the live coordina…
ejc3 Jun 28, 2026
33ab697
remote-tmux: harden linked-view core against adversarial review findings
ejc3 Jun 28, 2026
3daec99
remote-tmux: linked-view core — fix /code-review findings (delimiter,…
ejc3 Jun 28, 2026
c35b630
remote-tmux: /simplify linked-view core + fix pre-existing CRLF parse…
ejc3 Jun 28, 2026
00b24fe
remote-tmux: add query() channel to the control connection (linked-vi…
ejc3 Jun 28, 2026
986c965
remote-tmux: live view-connection coordinator for linked-view mode
ejc3 Jun 28, 2026
10234df
remote-tmux: SessionMirror window-id filter for linked-view fan-out
ejc3 Jun 28, 2026
6e32b55
remote-tmux: wire linked-view mode end-to-end through the controller
ejc3 Jun 28, 2026
8dfef3e
remote-tmux: fix linked-view lifecycle gaps from adversarial review
ejc3 Jun 28, 2026
dd40973
remote-tmux: wire linked-view mirrors into all per-action handlers
ejc3 Jun 28, 2026
06ed8be
remote-tmux: anchor linked-view new-tab on the home session by name
ejc3 Jun 28, 2026
e1cecc2
remote-tmux: reconcile after linked-view new-tab so it surfaces
ejc3 Jun 28, 2026
f3c0f02
remote-tmux: keep browser New-Workspace/button local in a mirror window
ejc3 Jun 28, 2026
a64dc7b
remote-tmux: size each linked-view window to its cmux pane
ejc3 Jun 28, 2026
c688d6a
remote-tmux: open browser in a new local workspace from interactive m…
ejc3 Jun 28, 2026
3b69008
remote-tmux: add a Settings toggle for the linked-view beta flag
ejc3 Jun 28, 2026
9912963
docs: document remote-tmux linked view and multiple servers in one wi…
ejc3 Jun 28, 2026
3c0a5e6
remote-tmux: multiple servers in one linked-view window
ejc3 Jun 28, 2026
b7ed525
remote-tmux: fix multi-server findings from adversarial review
ejc3 Jun 28, 2026
d659c63
remote-tmux: open the new-browser button as a tab in the mirror works…
ejc3 Jun 28, 2026
aa0c57b
remote-tmux: allow aggregating hosts into a regular window, not just …
ejc3 Jun 28, 2026
4441763
remote-tmux: per-origin color rail in the sidebar for multi-origin wi…
ejc3 Jun 28, 2026
b8f6442
remote-tmux: show the server name in the window title for mirror work…
ejc3 Jun 28, 2026
042d9a3
remote-tmux: harden browser-tab + aggregate-into-regular-window from …
ejc3 Jun 28, 2026
2f77b0f
remote-tmux: simplify pastel() to use NSColor.blended(withFraction:of…
ejc3 Jun 28, 2026
7c0bc25
remote-tmux: subtle origin-rail palette + keep mirror workspaces sing…
ejc3 Jun 28, 2026
3582b5a
remote-tmux: show the host name in cmux's own title bar + command lab…
ejc3 Jun 28, 2026
fe36c4f
remote-tmux: ship the toned-down origin-rail palette + add palette de…
ejc3 Jun 28, 2026
6663c90
remote-tmux: strip the screen/tmux ESC k window-title escape from mir…
ejc3 Jun 28, 2026
6100b33
remote-tmux: ssh-tmux reuses the caller's window by default (multiple…
ejc3 Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 57 additions & 11 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8480,8 +8480,9 @@ struct CMUXCLI {
)
}

/// `cmux ssh-tmux <destination>` — open a dedicated cmux window mirroring a remote
/// host's tmux sessions over `tmux -CC` (the remote-tmux beta).
/// `cmux ssh-tmux <destination>` — mirror a remote host's tmux sessions over
/// `tmux -CC` (the remote-tmux beta). Aggregates into the caller's current window
/// by default; `--new-window`/`--into-window` override the target.
///
/// Unlike `cmux ssh`, this carries no cmuxd-remote/relay bootstrap: it only
/// drives the SSH ControlMaster the mirror multiplexes over. The app's mirror
Expand All @@ -8499,6 +8500,8 @@ struct CMUXCLI {
var port: Int?
var identityFile: String?
var noFocus = false
var intoWindow: String?
var newWindow = false

// Intentional subset of parseSSHCommandOptions: the mirror verb has a
// different pipeline (no relay/cmuxd bootstrap, no `--` passthrough, no
Expand Down Expand Up @@ -8526,6 +8529,15 @@ struct CMUXCLI {
case "--no-focus":
noFocus = true
index += 1
case "--into-window":
guard index + 1 < commandArgs.count else {
throw CLIError(message: "ssh-tmux: --into-window requires a window id or 'current'")
}
intoWindow = commandArgs[index + 1]
index += 2
case "--new-window":
newWindow = true
index += 1
default:
if arg.hasPrefix("-") {
throw CLIError(
Expand All @@ -8549,6 +8561,32 @@ struct CMUXCLI {
if let port { params["port"] = port }
if let identityFile, !identityFile.isEmpty { params["identity_file"] = identityFile }
if noFocus { params["activate"] = false }
// Window targeting. By DEFAULT, aggregate the host into the caller's current
// window ("multiple servers in one window") so `cmux ssh-tmux <host>` run from
// inside a cmux surface reuses that window instead of spawning a new dedicated
// one. `--into-window <id|current>` targets a window explicitly; `--new-window`
// forces a fresh dedicated window. Surfaces export CMUX_WORKSPACE_ID (the
// workspace id, not the window id), which the app maps to the host window.
if intoWindow != nil && newWindow {
throw CLIError(message: "ssh-tmux: pass only one of --into-window or --new-window")
}
if let intoWindow {
if intoWindow == "current" {
guard let workspaceId = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"],
!workspaceId.isEmpty else {
throw CLIError(message: "ssh-tmux: --into-window current must be run from inside a cmux surface")
}
params["into_workspace"] = workspaceId
} else {
params["into_window"] = intoWindow
}
} else if !newWindow,
let workspaceId = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"],
!workspaceId.isEmpty {
// Default: reuse the caller's window. Outside a cmux surface (no
// CMUX_WORKSPACE_ID) this falls through to a new window, as does --new-window.
params["into_workspace"] = workspaceId
}

// The first call runs a non-interactive (BatchMode) discovery in the app,
// which can take a couple of seconds; show progress so it doesn't look idle.
Expand Down Expand Up @@ -14924,11 +14962,17 @@ struct CMUXCLI {
case "ssh-tmux":
return String(localized: "cli.help.ssh-tmux", defaultValue: """
Usage: cmux ssh-tmux <destination> [--port <n>] [--identity <path>] [--no-focus]
[--into-window <id|current>] [--new-window]

Mirror a remote host's tmux sessions over tmux control mode (tmux -CC) via
SSH: each tmux session becomes a workspace, each window a tab, and a
multi-pane window a native split. Requires the "Remote tmux" beta to be
enabled in Settings.

Open a dedicated cmux window that mirrors a remote host's tmux sessions over
tmux control mode (tmux -CC) via SSH: each tmux session becomes a workspace,
each window a tab, and a multi-pane window a native split. Requires the
"Remote tmux" beta to be enabled in Settings.
Run from inside a cmux surface, this aggregates the host into your CURRENT
window by default (multiple servers, plus local, in one window). Pass
--new-window to open a fresh dedicated window instead, or --into-window to
target a specific window. Outside a cmux surface it opens a new window.

If the host needs interactive authentication (password, host-key confirmation,
MFA, or a security-key touch), cmux runs ssh inline in this terminal so you can
Expand All @@ -14938,13 +14982,15 @@ struct CMUXCLI {
settings are honored.

Flags:
--port <n> SSH port
--identity <path> SSH identity file path
--no-focus Open the mirror window without activating it
--port <n> SSH port
--identity <path> SSH identity file path
--no-focus Mirror without activating the window
--into-window <id|current> Aggregate into a specific window (or the caller's)
--new-window Open a new dedicated window instead of reusing the current one

Example:
cmux ssh-tmux dev@my-host
cmux ssh-tmux my-ssh-alias
cmux ssh-tmux dev@my-host # aggregate into the current window
cmux ssh-tmux my-ssh-alias --new-window # open a separate window
cmux ssh-tmux dev@my-host --port 2222 --identity ~/.ssh/id_ed25519
""")
case "ssh-session-list":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
public import Foundation

/// Strips the GNU screen / tmux window-title escape (`ESC k <title> ESC \`) from a
/// mirrored pane's output stream.
///
/// A remote shell running *inside* tmux sees `TERM=screen*`/`tmux*`, so its prompt
/// (e.g. oh-my-zsh) sets the title with the screen sequence `\ek<cmd>\e\\` instead of
/// the xterm OSC. `%output` is the raw pty copy, so tmux forwards the `ESC k` bytes
/// verbatim and only interprets them for its OWN screen (window name) — its rendered
/// pane (`capture-pane`) has the title stripped. cmux's mirror surface is an
/// xterm-style emulator that doesn't recognize `ESC k`, so it would instead print the
/// title text onto the screen — e.g. `echo "ej"\r\n\ekecho\e\\ej` renders as `echoej`.
/// To match what the remote tmux actually shows, the mirror interprets/strips the
/// sequence here (the tab name already tracks tmux's `window_name`).
///
/// Stateful across calls: a `%output` chunk can split the sequence at any byte. Like
/// tmux/screen, `ESC k` is terminated ONLY by ST (`ESC \`), so an unterminated title
/// consumes until ST — matching tmux's own screen exactly (verified empirically by
/// diffing cmux's render against `capture-pane`).
public struct RemoteTmuxScreenTitleFilter {
private enum State {
case text // normal passthrough
case esc // saw ESC, holding it until we know if it's `ESC k`
case title // inside `ESC k …`, dropping the title bytes
case titleEsc // inside the title, saw ESC — maybe the `ESC \` terminator
}

private var state: State = .text

public init() {}

/// Returns `data` with any `ESC k … ESC \` title sequences removed.
public mutating func filter(_ data: Data) -> Data {
// Hot path: routeOutput calls this for every %output chunk. When we're not
// mid-sequence and the chunk has no ESC, there is nothing to strip — return it
// unchanged and skip the per-byte copy + allocation.
if state == .text, !data.contains(0x1b) { return data }
// Build into a `[UInt8]` buffer (cheaper than per-byte `Data.append`) and wrap
// it once at the end.
var out = [UInt8]()
out.reserveCapacity(data.count)
for byte in data {
switch state {
case .text:
if byte == 0x1b {
state = .esc // hold the ESC; emit it only if it isn't `ESC k`
} else {
out.append(byte)
}
case .esc:
if byte == UInt8(ascii: "k") {
state = .title // `ESC k` → start of title; drop both bytes
} else {
out.append(0x1b) // not a title: emit the held ESC …
if byte == 0x1b {
// another ESC: keep holding it (stay in .esc)
} else {
out.append(byte) // … followed by this byte
state = .text
}
}
case .title:
// tmux/screen terminate `ESC k` ONLY on ST (`ESC \`), never on BEL —
// so a BEL is part of the title and the title runs until ST (matching
// what the remote tmux renders). Drop everything until then.
if byte == 0x1b {
state = .titleEsc // maybe the `ESC \` terminator
}
// otherwise (incl. BEL): title text — drop it
case .titleEsc:
if byte == 0x5c {
state = .text // `ESC \` (ST) terminates the title
} else if byte == 0x1b {
state = .titleEsc // consecutive ESC — keep waiting
} else {
state = .title // ESC + other byte: still inside the title
}
}
}
return Data(out)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation
import Testing
@testable import CmuxRemoteSession

/// Tests the screen/tmux window-title escape stripper used on mirrored `%output`.
/// A remote shell inside tmux (TERM=screen*/tmux*) sets its title with
/// `ESC k <title> ST`; cmux's xterm-style mirror surface would print the title text
/// otherwise (the `echoej` bug). The filter must drop the sequence, survive chunk
/// splits, and leave everything else byte-identical.
///
/// Assertions compare raw `Data` (not UTF-8-decoded strings): the filter is a
/// byte-stream transform, and `String(decoding:as:)` silently replaces invalid
/// UTF-8 — which would mask a byte-corruption regression instead of failing.
@Suite struct RemoteTmuxScreenTitleFilterTests {
private func run(_ chunks: [String]) -> Data {
var f = RemoteTmuxScreenTitleFilter()
var out = Data()
for c in chunks { out.append(f.filter(Data(c.utf8))) }
return out
}
private func run(_ s: String) -> Data { run([s]) }

private func bytes(_ s: String) -> Data { Data(s.utf8) }

private let ESC = "\u{1b}"

@Test func stripsStTerminatedTitleBetweenText() {
// The exact echoej repro: command output `ej` preceded by `ESC k echo ESC \`.
let input = "\(ESC)kecho\(ESC)\\ej"
#expect(run(input) == bytes("ej"))
}

@Test func belDoesNotTerminateTitleMatchingTmux() {
// tmux/screen end `ESC k` only on ST (`ESC \`), never BEL. A BEL is swallowed
// as title text and the title runs until ST — matching the remote's rendering.
#expect(run("a\(ESC)kfoo\u{07}bar\(ESC)\\Z") == bytes("aZ")) // ST ends it; BEL consumed
#expect(run("a\(ESC)kfoo\u{07}bar") == bytes("a")) // no ST: rest consumed
}

@Test func stripsMultipleTitlesAndKeepsSurroundingText() {
// Prompt sets title to `~`, command sets it to `echo`, output is `ej`.
let input = "\(ESC)k~\(ESC)\\prompt \(ESC)kecho\(ESC)\\ej\r\n"
#expect(run(input) == bytes("prompt ej\r\n"))
}

@Test func survivesChunkSplitsAtEveryBoundary() {
let full = "X\(ESC)kabc\(ESC)\\Y"
let allBytes = Array(full.utf8)
// Split the stream after each byte and confirm the result is always "XY".
for cut in 1..<allBytes.count {
var f = RemoteTmuxScreenTitleFilter()
var out = Data()
out.append(f.filter(Data(allBytes[0..<cut])))
out.append(f.filter(Data(allBytes[cut...])))
#expect(out == bytes("XY"), "split at \(cut)")
}
}

@Test func preservesCsiAndOtherEscapes() {
// Color SGR and cursor moves must pass through untouched.
let input = "\(ESC)[32mgreen\(ESC)[0m\(ESC)[2J\(ESC)[H"
#expect(run(input) == bytes(input))
}

@Test func preservesEscFollowedByNonK() {
// `ESC \` (ST) on its own, and an OSC title, are not `ESC k` and pass through.
#expect(run("\(ESC)\\done") == bytes("\(ESC)\\done"))
#expect(run("\(ESC)]0;title\u{07}x") == bytes("\(ESC)]0;title\u{07}x"))
}

@Test func plainTextUnchanged() {
#expect(run("echo \"ej\"\r\nej\r\n") == bytes("echo \"ej\"\r\nej\r\n"))
}

@Test func titleImmediatelyFollowedByMoreTitle() {
#expect(run("\(ESC)ka\(ESC)\\\(ESC)kb\(ESC)\\Z") == bytes("Z"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,22 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection {
userDefaultsKey: "remoteTmux.beta.enabled"
)

/// Remote tmux linked-view mode: an experimental transport for hosts whose
/// `sshd` caps each connection to a single concurrent session
/// (`MaxSessions 1`, e.g. hosts with per-connection 2FA). Instead of one `tmux -CC` control
/// client per remote session — which needs one concurrent SSH session each
/// and so fails past the first — cmux drives ONE control client attached to a
/// hidden, cmux-owned aggregate "view" session and `link-window`s every
/// mirrored session's windows into it. All workspaces then stream live over
/// the single allowed connection (one auth), while the real tmux sessions are
/// preserved for `tmux ls` / `tmux attach` interop. Defaults off; requires
/// ``remoteTmux`` to be on. While off, remote tmux uses the per-session
/// control connections unchanged.
public let remoteTmuxLinkedView = DefaultsKey<Bool>(
id: "remoteTmux.linkedView.beta.enabled",
defaultValue: false,
userDefaultsKey: "remoteTmux.linkedView.beta.enabled"
)

public init() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ public struct BetaFeaturesSection: View {
@State private var extensions: DefaultsValueModel<Bool>
@State private var customSidebars: DefaultsValueModel<Bool>
@State private var remoteTmux: DefaultsValueModel<Bool>
@State private var remoteTmuxLinkedView: DefaultsValueModel<Bool>

public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog) {
_feed = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.rightSidebarFeed))
_dock = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.rightSidebarDock))
_extensions = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.extensions))
_customSidebars = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.customSidebars))
_remoteTmux = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.remoteTmux))
_remoteTmuxLinkedView = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.remoteTmuxLinkedView))
}

public var body: some View {
Expand All @@ -38,6 +40,8 @@ public struct BetaFeaturesSection: View {
customSidebarsRow
SettingsCardDivider()
remoteTmuxRow
SettingsCardDivider()
remoteTmuxLinkedViewRow
}
}
.task { startObservingSettings() }
Expand All @@ -50,6 +54,7 @@ public struct BetaFeaturesSection: View {
extensions,
customSidebars,
remoteTmux,
remoteTmuxLinkedView,
]
models.forEach { $0.startObserving() }
}
Expand Down Expand Up @@ -138,6 +143,24 @@ public struct BetaFeaturesSection: View {
.accessibilityIdentifier("SettingsBetaRemoteTmuxToggle")
}
}

@ViewBuilder
private var remoteTmuxLinkedViewRow: some View {
SettingsCardRow(
configurationReview: .settingsOnly,
searchAnchorID: "setting:betaFeatures:remoteTmuxLinkedView",
String(localized: "settings.betaFeatures.remoteTmuxLinkedView", defaultValue: "Remote tmux linked view"),
subtitle: remoteTmuxLinkedView.current
? String(localized: "settings.betaFeatures.remoteTmuxLinkedView.subtitleOn", defaultValue: "Mirrors a host's tmux sessions over a single ssh connection by linking every session's windows into one cmux-owned view. Use for hosts that allow only one session per connection (e.g. per-connection 2FA). Requires Remote tmux.")
: String(localized: "settings.betaFeatures.remoteTmuxLinkedView.subtitleOff", defaultValue: "Uses a separate ssh connection per remote tmux session until you enable this. Requires Remote tmux.")
) {
Toggle("", isOn: Binding(get: { remoteTmuxLinkedView.current }, set: { remoteTmuxLinkedView.set($0) }))
.labelsHidden()
.controlSize(.small)
.disabled(!remoteTmux.current)
.accessibilityIdentifier("SettingsBetaRemoteTmuxLinkedViewToggle")
}
}
}

/// Small warning callout with a yellow triangle, used at the top of
Expand Down
Loading