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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 var state: RemoteTmuxScreenTitleFilterState = .text

/// Creates a filter with no buffered escape-sequence state.
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,7 @@
/// Parser state for ``RemoteTmuxScreenTitleFilter``.
enum RemoteTmuxScreenTitleFilterState {
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
}
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"))
}
}
25 changes: 25 additions & 0 deletions Sources/RemoteTmuxSessionMirror+Helpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation
import CmuxRemoteSession

extension RemoteTmuxSessionMirror {
nonisolated static func shouldSeedSinglePaneDisplay(for window: RemoteTmuxWindow) -> Bool {
window.paneIDsInOrder.count == 1
}

/// Computes the target tab order for a remote-tmux-driven reorder, or `nil`
/// when no reorder is needed or safe. Pure helper called by
/// `Workspace.reorderRemoteTmuxMirrorTabs(toPanelOrder:)`.
///
/// - Parameters:
/// - current: the workspace's current mirror-tab order (panel ids).
/// - requested: the tmux window order mapped to panel ids.
/// - Returns: the new order to apply, or `nil` when the tabs already match
/// `requested` or when `requested` (restricted to currently-present tabs) is
/// not a permutation of `current` (sets diverge; leave the tabs untouched).
nonisolated static func mirrorTabReorder(current: [UUID], requested: [UUID]) -> [UUID]? {
let present = Set(current)
let desired = requested.filter { present.contains($0) }
guard desired.count == current.count, Set(desired) == present else { return nil }
return desired == current ? nil : desired
}
}
46 changes: 23 additions & 23 deletions Sources/RemoteTmuxSessionMirror.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import CmuxRemoteSession

/// Mirrors one remote tmux session into a dedicated cmux sidebar workspace.
///
Expand Down Expand Up @@ -50,6 +51,9 @@ final class RemoteTmuxSessionMirror {
/// Last-known working directory per tmux pane, so switching the active pane of
/// a multi-pane window can re-project that pane's directory onto the tab.
private var cwdByPane: [Int: String] = [:]
/// Per-pane filter that strips the screen/tmux `ESC k <title> ST` window-title
/// escape from `%output` (stateful across chunk boundaries).
private var titleFilters: [Int: RemoteTmuxScreenTitleFilter] = [:]
/// Per-window multi-pane renderers (present once a window has >1 pane).
private var windowMirrorByWindowId: [Int: RemoteTmuxWindowMirror] = [:]
private var observerToken: RemoteTmuxControlConnection.ObserverToken?
Expand Down Expand Up @@ -99,6 +103,14 @@ final class RemoteTmuxSessionMirror {
},
onExit: { [weak self] in
self?.handleConnectionExited()
},
onConnectionStateChanged: { [weak self] state in
// Drop any mid-`ESC k` title-filter state when the stream isn't live:
// a reconnect's `reseedAfterReconnect` re-emits clear/capture bytes,
// and a filter stuck mid-title from before the drop would swallow them.
// Resetting on the disconnect edge is ordering-independent (no output
// arrives while not connected).
if state != .connected { self?.titleFilters.removeAll() }
}
)
rebuild()
Expand Down Expand Up @@ -240,6 +252,7 @@ final class RemoteTmuxSessionMirror {
// stays bounded across window/pane churn (tmux pane ids never recur).
let livePanes = Set(connection.windowsByID.values.flatMap { $0.paneIDsInOrder })
cwdByPane = cwdByPane.filter { livePanes.contains($0.key) }
titleFilters = titleFilters.filter { livePanes.contains($0.key) }
closeDefaultTabsIfNeeded()
// Follow out-of-band tmux window reorders (a second client, or a manual
// move-window / a new-window inserted mid-list): the cmux tabs are created
Expand All @@ -253,10 +266,6 @@ final class RemoteTmuxSessionMirror {
}
}

nonisolated static func shouldSeedSinglePaneDisplay(for window: RemoteTmuxWindow) -> Bool {
window.paneIDsInOrder.count == 1
}

/// Brief retry that sizes the remote tmux client to a single-pane tab's
/// rendered grid on attach. Needed because `createSurface` stamps the final
/// grid before the tab is on screen, and `TerminalSurface.updateSize` only
Expand Down Expand Up @@ -402,17 +411,25 @@ final class RemoteTmuxSessionMirror {
}

private func routeOutput(paneId: Int, data: Data) {
// Strip the screen/tmux `ESC k <title> ST` window-title escape that a remote
// shell (TERM=screen*/tmux*) emits — the mirror's xterm-style surface would
// otherwise print the title text onto the screen (see
// ``RemoteTmuxScreenTitleFilter``). Per-pane state survives chunk splits.
var filter = titleFilters[paneId] ?? RemoteTmuxScreenTitleFilter()
let cleaned = filter.filter(data)
titleFilters[paneId] = filter

// Multi-pane window: its in-tab renderer owns the pane's surface.
if let windowId = windowIdContaining(pane: paneId),
let mirror = windowMirrorByWindowId[windowId] {
mirror.routeOutput(paneId: paneId, data: data)
mirror.routeOutput(paneId: paneId, data: cleaned)
return
}
// Single-pane window: route to the window-tab's panel surface.
guard let workspace,
let panelId = panelIdByPane[paneId],
let panel = workspace.panels[panelId] as? TerminalPanel else { return }
panel.surface.processRemoteOutput(data)
panel.surface.processRemoteOutput(cleaned)
}

/// Applies a pane's reflow classification to its mirror surface (suppress
Expand Down Expand Up @@ -482,21 +499,4 @@ final class RemoteTmuxSessionMirror {
}
return nil
}

/// Computes the target tab order for a remote-tmux-driven reorder, or `nil`
/// when no reorder is needed or safe. Pure helper called by
/// `Workspace.reorderRemoteTmuxMirrorTabs(toPanelOrder:)`.
///
/// - Parameters:
/// - current: the workspace's current mirror-tab order (panel ids).
/// - requested: the tmux window order mapped to panel ids.
/// - Returns: the new order to apply, or `nil` when the tabs already match
/// `requested` or when `requested` (restricted to currently-present tabs) is
/// not a permutation of `current` (sets diverge — leave the tabs untouched).
nonisolated static func mirrorTabReorder(current: [UUID], requested: [UUID]) -> [UUID]? {
let present = Set(current)
let desired = requested.filter { present.contains($0) }
guard desired.count == current.count, Set(desired) == present else { return nil }
return desired == current ? nil : desired
}
}
20 changes: 12 additions & 8 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,7 @@
0A2A2FA17CD71DA5B4495DEE /* RemoteTmuxSessionEndAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3E94F6022485BB12789444 /* RemoteTmuxSessionEndAction.swift */; };
008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */; };
3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */; };
7F023A000000000000007024 /* RemoteTmuxSessionMirror+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F023A000000000000007023 /* RemoteTmuxSessionMirror+Helpers.swift */; };
1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */; };
3AC9AB9046E742B93726A501 /* RemoteTmuxSessionRenameTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F704ED4F177122D7DCD0B01 /* RemoteTmuxSessionRenameTitleTests.swift */; };
9A5CF3AA2E6462E77144F9E6 /* RemoteTmuxSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */; };
Expand Down Expand Up @@ -2049,6 +2050,7 @@
9E3E94F6022485BB12789444 /* RemoteTmuxSessionEndAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionEndAction.swift; sourceTree = "<group>"; };
E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParser.swift; sourceTree = "<group>"; };
3F704ED4F177122D7DCD0BCC /* RemoteTmuxSessionListParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParserTests.swift; sourceTree = "<group>"; };
7F023A000000000000007023 /* RemoteTmuxSessionMirror+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxSessionMirror+Helpers.swift"; sourceTree = "<group>"; };
D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionMirror.swift; sourceTree = "<group>"; };
3F704ED4F177122D7DCD0B01 /* RemoteTmuxSessionRenameTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionRenameTitleTests.swift; sourceTree = "<group>"; };
31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionSnapshotTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3290,10 +3292,11 @@
A5001611 /* SessionPersistence.swift */,
C65930020000000000000001 /* SessionPersistencePolicy+CrashStorage.swift */,
E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */,
FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */,
F2A6C8E40D5B17293C8F6A42 /* TerminalPanelCreationOutcome.swift */,
D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */,
602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */,
FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */,
F2A6C8E40D5B17293C8F6A42 /* TerminalPanelCreationOutcome.swift */,
D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */,
7F023A000000000000007023 /* RemoteTmuxSessionMirror+Helpers.swift */,
602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */,
AAAD58EFBF501E599E6D9E9A /* RemoteTmuxControlCommandKind.swift */,
2587F4E278EFECB84E2ED34B /* RemoteTmuxControlConnectionSnapshot.swift */,
9420A497CBC16357FF3F2C9A /* RemoteTmuxControlPipeWriter.swift */,
Expand Down Expand Up @@ -4700,10 +4703,11 @@
A4C6F928D7E14B2AA924B47C /* RemoteTmuxProcessCancellation.swift in Sources */,
C9BA962E6CB31C9FD62505BF /* RemoteTmuxRawLayoutParser.swift in Sources */,
A42DAD21BD502098E446999C /* RemoteTmuxSession.swift in Sources */,
0A2A2FA17CD71DA5B4495DEE /* RemoteTmuxSessionEndAction.swift in Sources */,
008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */,
1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */,
6D3C19C2014FF9C754358EFF /* RemoteTmuxSSHTransport.swift in Sources */,
0A2A2FA17CD71DA5B4495DEE /* RemoteTmuxSessionEndAction.swift in Sources */,
008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */,
7F023A000000000000007024 /* RemoteTmuxSessionMirror+Helpers.swift in Sources */,
1255599FA91128E5925D3983 /* RemoteTmuxSessionMirror.swift in Sources */,
6D3C19C2014FF9C754358EFF /* RemoteTmuxSSHTransport.swift in Sources */,
4E9111628FAF490FBC51D350 /* RemoteTmuxStdoutPipeReader.swift in Sources */,
0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */,
0A17C0DE0A17C0DE0A17C902 /* RemoteTmuxVersion.swift in Sources */,
Expand Down
Loading