From 25909b4989926563bf80b6110588bde1cb7ad0f6 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 22:09:00 -0700 Subject: [PATCH 1/3] remote-tmux: strip the screen/tmux ESC k window-title escape from mirror output Implement the byte-stream filter in the CmuxRemoteSession package (pure logic, no app-lifecycle deps) rather than the app target root, matching the established pattern for lifted remote-session logic. Build into a [UInt8] buffer, and assert on raw Data in tests so a byte-corruption regression fails fast. --- .../Values/RemoteTmuxScreenTitleFilter.swift | 82 +++++++++++++++++++ .../RemoteTmuxScreenTitleFilterTests.swift | 78 ++++++++++++++++++ Sources/RemoteTmuxSessionMirror.swift | 25 +++++- 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift create mode 100644 Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxScreenTitleFilterTests.swift diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift new file mode 100644 index 000000000000..2dce6b9d968a --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift @@ -0,0 +1,82 @@ +public import Foundation + +/// Strips the GNU screen / tmux window-title escape (`ESC k 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) + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxScreenTitleFilterTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxScreenTitleFilterTests.swift new file mode 100644 index 000000000000..b68dd75c732f --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxScreenTitleFilterTests.swift @@ -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")) + } +} diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 58f0004d3d62..c288734036b6 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxRemoteSession /// Mirrors one remote tmux session into a dedicated cmux sidebar workspace. /// @@ -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? @@ -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() @@ -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 @@ -402,17 +415,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 From 0f2678e8aab10bab4acfb4924b0d0c6a75fa1f98 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 30 Jun 2026 12:51:52 -0700 Subject: [PATCH 2/3] Fix remote tmux mirror file budget --- Sources/RemoteTmuxSessionMirror+Helpers.swift | 25 +++++++++++++++++++ Sources/RemoteTmuxSessionMirror.swift | 21 ---------------- cmux.xcodeproj/project.pbxproj | 20 +++++++++------ 3 files changed, 37 insertions(+), 29 deletions(-) create mode 100644 Sources/RemoteTmuxSessionMirror+Helpers.swift diff --git a/Sources/RemoteTmuxSessionMirror+Helpers.swift b/Sources/RemoteTmuxSessionMirror+Helpers.swift new file mode 100644 index 000000000000..8cb06e34b9ae --- /dev/null +++ b/Sources/RemoteTmuxSessionMirror+Helpers.swift @@ -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 + } +} diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index c288734036b6..b37ab47aa446 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -266,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 @@ -503,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 - } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f2579b883363..8efd7d01663d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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>"; }; @@ -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 */, @@ -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 */, From 537ebc8fd2d846b892d6ec7ea4b039553c9a00ca Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 30 Jun 2026 12:57:06 -0700 Subject: [PATCH 3/3] Satisfy remote tmux filter package policy --- .../Values/RemoteTmuxScreenTitleFilter.swift | 10 ++-------- .../Values/RemoteTmuxScreenTitleFilterState.swift | 7 +++++++ 2 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilterState.swift diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift index 2dce6b9d968a..762259227f02 100644 --- a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift @@ -18,15 +18,9 @@ public import Foundation /// 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 + 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. diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilterState.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilterState.swift new file mode 100644 index 000000000000..399ab3b6af17 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilterState.swift @@ -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 +}