Skip to content
Open
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
2 changes: 2 additions & 0 deletions Sources/RemoteTmuxControlConnection+CommandResults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,10 @@ extension RemoteTmuxControlConnection {
// (see ``PostAttachAction``).
switch pendingPostAttachAction {
case .reseed:
pushMirrorSessionEnvironment()
reseedAfterReconnect()
case .applyClientSize:
pushMirrorSessionEnvironment()
// A surface that hasn't computed a grid yet is covered by the
// debounced `setClientSize` instead.
if let size = lastClientSize {
Expand Down
56 changes: 56 additions & 0 deletions Sources/RemoteTmuxControlConnection+Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,62 @@ extension RemoteTmuxControlConnection {
sendInternal(command, kind: .other)
}

// MARK: - Mirror session environment (issue #833)

/// Marker signalling to remote shell integration that this tmux session is
/// mirrored by a local cmux over ssh-tmux (`tmux -CC`, no relay socket).
static let mirrorMarkerEnvironmentKey = "CMUX_REMOTE_TMUX_MIRROR"

/// Pushes the mirror marker + identity pairs into the remote tmux SESSION
/// environment. Called from the first post-attach `list-windows` result, so
/// both paths — first connect and every reconnect — refresh values that
/// would otherwise go permanently stale after an app relaunch (issue #833:
/// the `tmux -CC` attach has no cmux wrapper shell outside tmux on the
/// remote, so nobody else ever re-publishes them).
///
/// Deliberately NOT pushed: `CMUX_SOCKET_PATH`. The ssh-tmux transport has
/// no relay/reverse forward, so the local Mac socket path is meaningless on
/// the remote host; publishing it would point remote `cmux` CLI invocations
/// at a dead socket. Notification delivery instead rides the OSC 777/9
/// intercept in ``RemoteTmuxSessionMirror`` (see
/// ``RemoteTmuxNotificationOSCFilter``).
///
/// Session scope (`-t`, not `-g`): the shell-integration refresh path runs
/// a session-scoped `show-environment`, which does not surface `-g` values.
func pushMirrorSessionEnvironment() {
// Target by the stable session id when known so the push can't race a
// rename (same convention as `rename-session`).
guard let target = sessionId.map({ "$\($0)" })
?? RemoteTmuxHost.controlModeLineSafeName(sessionName)
.map(RemoteTmuxHost.shellSingleQuoted)
else { return }
var pairs = mirrorEnvironment
pairs[Self.mirrorMarkerEnvironmentKey] = "1"
let commands = Self.mirrorEnvironmentCommands(target: target, pairs: pairs)
guard !commands.isEmpty else { return }
_ = sendBatchInternal(commands, kinds: commands.map { _ in .other })
Comment on lines +44 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

socket="cmux-env-review-$$"
session="cmux-env-review-$$"
trap 'tmux -L "$socket" kill-server 2>/dev/null || true' EXIT

tmux -L "$socket" new-session -d -s "$session"
tmux -L "$socket" set-environment -t "$session" CMUX_SOCKET_PATH "/tmp/dead.sock"
tmux -L "$socket" show-environment -t "$session" | grep -Fx "CMUX_SOCKET_PATH=/tmp/dead.sock"

tmux -L "$socket" set-environment -u -t "$session" CMUX_SOCKET_PATH"
! tmux -L "$socket" show-environment -t "$session" | grep -q '^CMUX_SOCKET_PATH='

Repository: manaflow-ai/cmux

Length of output: 196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
git ls-files | rg 'RemoteTmuxControlConnection|Tmux|tmux|Environment|Tests' | head -200

printf '%s\n' '--- symbols and references ---'
rg -n -C 4 \
  'CMUX_SOCKET_PATH|mirrorEnvironmentCommands|pushMirrorSessionEnvironment|mirrorEnvironment|sendBatchInternal' \
  Sources Tests . 2>/dev/null | head -500

printf '%s\n' '--- target file outline ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline Sources/RemoteTmuxControlConnection+Commands.swift
else
  wc -l Sources/RemoteTmuxControlConnection+Commands.swift
fi

printf '%s\n' '--- target implementation ---'
sed -n '1,130p' Sources/RemoteTmuxControlConnection+Commands.swift

Repository: manaflow-ai/cmux

Length of output: 18889


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
git ls-files | rg 'RemoteTmuxControlConnection|Tmux|tmux|Environment|Tests' | head -200

printf '%s\n' '--- symbols and references ---'
rg -n -C 4 \
  'CMUX_SOCKET_PATH|mirrorEnvironmentCommands|pushMirrorSessionEnvironment|mirrorEnvironment|sendBatchInternal' \
  Sources Tests . 2>/dev/null | head -500

printf '%s\n' '--- target implementation ---'
sed -n '1,130p' Sources/RemoteTmuxControlConnection+Commands.swift

Repository: manaflow-ai/cmux

Length of output: 18889


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- target file status ---'
ls -l Sources/RemoteTmuxControlConnection+Commands.swift 2>&1 || true
wc -l Sources/RemoteTmuxControlConnection+Commands.swift 2>&1 || true

printf '%s\n' '--- target implementation ---'
sed -n '1,140p' Sources/RemoteTmuxControlConnection+Commands.swift 2>&1 || true

printf '%s\n' '--- matching paths ---'
git ls-files | grep -Ei 'remote.*tmux|tmux.*connection|tmux' | head -300 || true

printf '%s\n' '--- all relevant references ---'
grep -RIn --exclude-dir=.git --exclude-dir=Packages --exclude='*.jsonl' \
  -E 'CMUX_SOCKET_PATH|mirrorEnvironmentCommands|pushMirrorSessionEnvironment|mirrorEnvironment|sendBatchInternal' \
  Sources CLI Tests 2>/dev/null | head -500 || true

Repository: manaflow-ai/cmux

Length of output: 32254


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- environment push tests ---'
sed -n '1,260p' cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift

printf '%s\n' '--- environment state and setter ---'
sed -n '170,225p' Sources/RemoteTmuxControlConnection.swift
sed -n '590,630p' Sources/RemoteTmuxControlConnection.swift

printf '%s\n' '--- push call sites ---'
sed -n '220,260p' Sources/RemoteTmuxControlConnection+CommandResults.swift
sed -n '290,325p' Sources/RemoteTmuxController.swift

printf '%s\n' '--- remote shell integration consumers ---'
rg -n -C 6 \
  'show-environment|CMUX_REMOTE_TMUX_MIRROR|CMUX_WORKSPACE_ID|CMUX_SURFACE_ID|CMUX_TERMINAL_LIFECYCLE_ID' \
  Sources CLI cmuxTests 2>/dev/null | head -700 || true

Repository: manaflow-ai/cmux

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- exact mirror marker references ---'
rg -n -C 12 'CMUX_REMOTE_TMUX_MIRROR' . 2>/dev/null | head -250 || true

printf '%s\n' '--- environment setter call sites ---'
rg -n -C 8 'setMirrorEnvironment\(' Sources cmuxTests 2>/dev/null | head -350 || true

printf '%s\n' '--- remote tmux attach/bootstrap references ---'
rg -n -C 8 \
  'tmux -CC|ssh-tmux|RemoteTmux|remote tmux|control-mode|attach-session|new-session' \
  Sources/RemoteTmux*.swift Sources/SSH*.swift Sources/TerminalController+RemoteTmux.swift 2>/dev/null \
  | head -600 || true

printf '%s\n' '--- repository state ---'
git status --short
git diff --stat -- Sources/RemoteTmuxControlConnection+Commands.swift Sources/RemoteTmuxControlConnection.swift Sources/RemoteTmuxControlConnection+CommandResults.swift Sources/RemoteTmuxController.swift cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift

Repository: manaflow-ai/cmux

Length of output: 50372


🌐 Web query:

tmux man page set-environment -u session environment variable show-environment

💡 Result:

In tmux, environment variables are managed through two levels: the global environment and the session-specific environment [1][2]. When the tmux server starts, it initializes the global environment [1][3]. When a new window or pane is created, tmux merges the global and current session environments—with the session-specific value taking precedence if a variable exists in both—to form the environment for that new process [1][2]. Key commands for managing these variables include: set-environment (alias: setenv) This command is used to set, unset, or modify environment variables [1]. Usage: tmux set-environment [-gru] [-t target-session] name [value] [1] -g: Applies the change to the global environment [1][4]. Without this flag, it applies to the specified session (or the current one) [1][4]. -u: Unsets (removes) the specified variable [1]. -r: Indicates the variable should be removed from the environment before starting a new process [1]. show-environment (alias: showenv) This command displays the environment variables for a specific session or the global environment [1][4]. Usage: tmux show-environment [-g] [-t target-session] [name] [1] -g: Displays the global environment [1][4]. -s: (Often used) Formats the output as shell commands (e.g., export VAR=val), which can be evaluated directly by a shell to update the current environment [5]. Important Considerations: 1. Retroactive Updates: Commands like set-environment only affect new windows, panes, or processes created after the command is run [6][7]. They do not retroactively modify the environment of already running shells [6][7]. 2. Synchronizing Existing Shells: To update an existing shell within tmux, you can manually source the output of the show-environment command [7][5]. A common technique is to use a shell hook, such as PROMPT_COMMAND in Bash, to automatically run eval "$(tmux show-environment -s)" each time the prompt is displayed [5]. 3. update-environment: This is a session option that controls which environment variables are automatically copied from the client into the session environment when a new session is created or reattached [1][7]. You can modify this list using set-option -g update-environment "VAR1 VAR2" [7][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- concise repository state ---'
git status --short 2>/dev/null || true
git diff --stat -- Sources/RemoteTmuxControlConnection+Commands.swift \
  Sources/RemoteTmuxControlConnection.swift \
  Sources/RemoteTmuxControlConnection+CommandResults.swift \
  Sources/RemoteTmuxController.swift \
  cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift 2>/dev/null || true

printf '%s\n' '--- relevant remote socket consumers ---'
rg -n -C 5 \
  'CMUX_SOCKET_PATH|CMUX_SOCKET|CMUX_REMOTE_TMUX_MIRROR|show-environment' \
  Sources/RemoteTmux*.swift Sources/RemoteInteractiveShellBootstrapBuilder.swift \
  Sources/Workspace+RemoteDisconnectPlaceholder.swift \
  CLI/CMUXCLI+TmuxCompat*.swift 2>/dev/null | head -400 || true

printf '%s\n' '--- exact environment-push source and tests ---'
nl -ba Sources/RemoteTmuxControlConnection+Commands.swift | sed -n '15,65p'
nl -ba cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift | sed -n '115,215p'

Repository: manaflow-ai/cmux

Length of output: 8174


Clear stale CMUX_SOCKET_PATH in the mirror session.

set-environment does not remove omitted variables. A previously stored path can remain visible through show-environment and route remote cmux commands to a dead socket. Send set-environment -u -t <target> CMUX_SOCKET_PATH in the same batch, and update the test to seed and assert removal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RemoteTmuxControlConnection`+Commands.swift around lines 44 - 48,
Update the mirror-session command flow around mirrorEnvironmentCommands to
include a set-environment -u command removing CMUX_SOCKET_PATH for the target in
the same batch, while preserving the existing environment assignments. Extend
the relevant test to seed CMUX_SOCKET_PATH and assert that the mirror operation
removes it.

}

/// Builds the `set-environment -t <target> KEY VALUE` command lines for a
/// push, dropping any pair that could break the line-oriented control
/// stream. Pure (and deterministic — sorted by key) so tests can pin the
/// exact wire format.
static func mirrorEnvironmentCommands(
target: String,
pairs: [String: String]
) -> [String] {
pairs.sorted { $0.key < $1.key }.compactMap { key, value in
// Keys are ours (static identifiers), but guard anyway: one CR/LF
// would terminate the command line before tmux parses the quotes.
guard RemoteTmuxHost.controlModeLineSafeName(key) != nil,
!key.contains(" "),
RemoteTmuxHost.controlModeLineSafeName(value) != nil
else { return nil }
return "set-environment -t \(target) \(key) "
+ RemoteTmuxHost.shellSingleQuoted(value)
}
}

/// Sends a command and reports how its `%begin`/`%end` block resolved:
/// `true` on `%end`, `false` on `%error` — or `false` if the stream resets
/// before the block arrives, since a fresh control stream can never answer
Expand Down
19 changes: 19 additions & 0 deletions Sources/RemoteTmuxControlConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,25 @@ final class RemoteTmuxControlConnection {
var lastSizingSendAt: ContinuousClock.Instant?
var pendingPostAttachAction: PostAttachAction?

/// Session-scoped environment pairs identifying the local mirror, pushed to
/// the remote tmux session on every attach AND reconnect (issue #833).
/// Session scope (`set-environment -t`) is deliberate: the shell-integration
/// pull path runs a session-scoped `show-environment`, which does not see
/// global (`-g`) values. Set by the controller when the mirror workspace is
/// created; empty until then (and for non-mirror consumers).
private(set) var mirrorEnvironment: [String: String] = [:]

/// Replaces the identity pairs pushed by ``pushMirrorSessionEnvironment()``.
/// A connection that already passed its post-attach point (a reused,
/// still-connected connection) pushes the fresh pairs immediately;
/// otherwise the pending post-attach drain pushes them.
func setMirrorEnvironment(_ pairs: [String: String]) {
mirrorEnvironment = pairs
if connectionState == .connected, attachBlockDrained, pendingPostAttachAction == nil {
pushMirrorSessionEnvironment()
}
}

/// Trailing-edge debounce for `refresh-client -C`. SwiftUI layout settle makes the
/// rendered grid oscillate (e.g. cols 154→155→156→161→…, ~15 distinct grids in
/// ~1.3s), and each previously sent its own `refresh-client -C` → ~15 SIGWINCH /
Expand Down
10 changes: 10 additions & 0 deletions Sources/RemoteTmuxController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,16 @@ final class RemoteTmuxController {
applyCreationTitleAsCustomTitle: false
)
workspace.isRemoteTmuxMirror = true
// Identity pairs the connection pushes into the remote SESSION
// environment on attach and every reconnect (issue #833). Workspace id
// is published under both keys, matching the SSH-workspace bootstrap
// convention (`CMUX_TAB_ID` is the legacy alias). No socket path: the
// ssh-tmux transport has no relay, so a local path would be dead on the
// remote — see ``RemoteTmuxControlConnection/pushMirrorSessionEnvironment()``.
connection.setMirrorEnvironment([
"CMUX_WORKSPACE_ID": workspace.id.uuidString,
"CMUX_TAB_ID": workspace.id.uuidString,
])
workspace.remoteTmuxWindowOrderSync = { [weak self, weak workspace] orderedPanelIds, verification in
guard let self, let workspace else { return false }
return self.handleMirrorWindowsReordered(
Expand Down
201 changes: 201 additions & 0 deletions Sources/RemoteTmuxNotificationOSCFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import Foundation

/// Intercepts OSC desktop-notification escapes in a mirrored pane's output
/// stream (issue #833).
///
/// A remote process inside an ssh-tmux mirror emits notifications with the
/// xterm/urxvt OSC sequences:
/// - `ESC ] 777;notify;<title>;<body> BEL|ST` (rxvt-unicode / wezterm style)
/// - `ESC ] 9;<body> BEL|ST` (iTerm2 growl style; no title)
///
/// `%output` is the raw pty copy, so tmux forwards these bytes verbatim to the
/// mirror. The mirror's Ghostty surface would parse them, but its
/// `desktop_notification` callback attributes by the surface's local process
/// TTY — which a manual-mirror surface does not have — and mirror workspaces
/// are excluded from agent TTY delivery (`AgentDeliveryTargetResolution`). So
/// the mirror layer intercepts the sequence here, strips it from the stream,
/// and reports `(title, body)` so the session mirror can attribute the
/// notification to the pane's surface + workspace itself.
///
/// Any other OSC sequence (titles, hyperlinks, clipboard, non-`notify`
/// OSC 777 subcommands) passes through byte-identical.
///
/// Stateful across calls: a `%output` chunk can split the sequence at any
/// byte. While a sequence still *might* be a notification its bytes are
/// buffered; the moment the payload diverges from both notification prefixes
/// the buffer is flushed verbatim and the rest of the sequence streams
/// through unbuffered. An unfinished candidate that exceeds
/// ``maxBufferedBytes`` is flushed verbatim too, so a hostile or corrupt
/// stream can never pin memory or swallow output.
struct RemoteTmuxNotificationOSCFilter {
private enum State {
case text // normal passthrough
case esc // saw ESC, holding it until we know if it's `ESC ]`
case collect // inside an OSC that may still be a notification; buffering
case collectEsc // in collect, saw ESC; maybe the `ESC \` terminator
case passOsc // inside a non-notification OSC; streaming through
case passOscEsc // in passOsc, saw ESC; maybe the `ESC \` terminator
}

/// Ceiling on bytes buffered for an unfinished candidate sequence. An
/// overflowing sequence is passed through verbatim instead of stripped.
static let maxBufferedBytes = 4096

private static let notifyPrefix = Array("777;notify;".utf8)
private static let osc9Prefix = Array("9;".utf8)
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

OSC 9 subcommands are not discriminated, in the filter or in the tests. osc9Prefix is 9;, so every OSC 9 subcommand is classified as an iTerm2 growl notification, and the suite only exercises 9;<text>. The suite already pins the equivalent OSC 777 behavior with nonNotifyOsc777SubcommandPassesVerbatim, so the OSC 9 gap let the over-match ship untested.

  • Sources/RemoteTmuxNotificationOSCFilter.swift#L44-L45: add a progress-subcommand prefix (9;4;) and exclude it in both finishCandidate and reclassifyCandidate, so the sequence streams through instead of being stripped and reported.
  • cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift#L155-L160: add an OSC 9 counterpart to nonNotifyOsc777SubcommandPassesVerbatim that asserts ESC ] 9;4;1;50 BEL passes verbatim and reports no notification.
📍 Affects 2 files
  • Sources/RemoteTmuxNotificationOSCFilter.swift#L44-L45 (this comment)
  • cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift#L155-L160
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RemoteTmuxNotificationOSCFilter.swift` around lines 44 - 45, Update
Sources/RemoteTmuxNotificationOSCFilter.swift lines 44-45 by defining the OSC 9
progress prefix 9;4; and excluding it in both finishCandidate and
reclassifyCandidate so progress sequences pass through unchanged. Update
cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift lines 155-160 by adding a
counterpart to nonNotifyOsc777SubcommandPassesVerbatim that verifies ESC ]
9;4;1;50 BEL is preserved verbatim and produces no notification.


private var state: State = .text
/// Original bytes of the candidate sequence (`ESC ]` + payload so far),
/// replayed verbatim when the sequence turns out not to be a notification.
private var raw: [UInt8] = []
/// Payload bytes only (after `ESC ]`), matched against the prefixes and
/// decoded into `(title, body)` on a hit.
private var payload: [UInt8] = []

/// Creates a filter with no buffered escape-sequence state.
init() {}

/// Returns `data` with any complete notification sequences removed,
/// invoking `onNotification(title, body)` once per hit in stream order.
/// OSC 9 hits report an empty title.
mutating func filter(
_ data: Data,
onNotification: (_ title: String, _ body: String) -> Void
) -> Data {
// Hot path: not mid-sequence and no ESC in the chunk — nothing to do.
if state == .text, !data.contains(0x1b) { return data }
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 ]`
} else {
out.append(byte)
}
case .esc:
if byte == UInt8(ascii: "]") {
raw = [0x1b, byte]
payload.removeAll(keepingCapacity: true)
state = .collect
} else if byte == 0x1b {
out.append(0x1b) // emit the held ESC, keep holding the new one
} else {
out.append(0x1b)
out.append(byte)
state = .text
}
case .collect:
if byte == 0x07 { // BEL terminator
finishCandidate(terminator: [0x07], into: &out, onNotification)
} else if byte == 0x1b {
state = .collectEsc
} else {
raw.append(byte)
payload.append(byte)
reclassifyCandidate(into: &out)
}
case .collectEsc:
if byte == 0x5c { // `ESC \` (ST) terminator
finishCandidate(terminator: [0x1b, 0x5c], into: &out, onNotification)
} else {
// An OSC payload cannot legally contain a bare ESC; treat the
// sequence as malformed and pass everything through verbatim.
out.append(contentsOf: raw)
out.append(0x1b)
raw.removeAll(keepingCapacity: true)
payload.removeAll(keepingCapacity: true)
if byte == 0x1b {
state = .esc // the new ESC may start a fresh sequence
} else {
out.append(byte)
state = .text
}
}
case .passOsc:
if byte == 0x07 {
out.append(byte)
state = .text
} else if byte == 0x1b {
state = .passOscEsc
} else {
out.append(byte)
}
case .passOscEsc:
if byte == 0x5c {
out.append(0x1b)
out.append(0x5c)
state = .text
} else if byte == 0x1b {
out.append(0x1b) // emit the held ESC, keep holding the new one
} else {
out.append(0x1b)
out.append(byte)
state = .passOsc
}
}
}
return Data(out)
}

/// While collecting, checks the payload against both notification prefixes
/// and the buffer ceiling; a sequence that can no longer be a notification
/// (or grew too large unfinished) is flushed verbatim and the remainder
/// streams through as a plain OSC.
private mutating func reclassifyCandidate(into out: inout [UInt8]) {
if raw.count <= Self.maxBufferedBytes,
Self.couldMatch(payload, prefix: Self.notifyPrefix)
|| Self.couldMatch(payload, prefix: Self.osc9Prefix) {
return
}
out.append(contentsOf: raw)
raw.removeAll(keepingCapacity: true)
payload.removeAll(keepingCapacity: true)
state = .passOsc
}

/// Whether `payload` is still compatible with `prefix` (either side is a
/// prefix of the other).
private static func couldMatch(_ payload: [UInt8], prefix: [UInt8]) -> Bool {
payload.count >= prefix.count
? payload.starts(with: prefix)
: prefix.starts(with: payload)
}

/// A candidate sequence reached its terminator: strip + report a
/// notification hit, or replay the original bytes for anything else
/// (including a too-short candidate like `ESC ] 9 BEL`).
private mutating func finishCandidate(
terminator: [UInt8],
into out: inout [UInt8],
_ onNotification: (_ title: String, _ body: String) -> Void
) {
defer {
raw.removeAll(keepingCapacity: true)
payload.removeAll(keepingCapacity: true)
state = .text
}
if payload.starts(with: Self.notifyPrefix) {
let rest = payload.dropFirst(Self.notifyPrefix.count)
if let separator = rest.firstIndex(of: UInt8(ascii: ";")) {
onNotification(
Self.decode(rest[..<separator]),
Self.decode(rest[rest.index(after: separator)...])
)
} else {
// `777;notify;<title>` without a body separator: title only.
onNotification(Self.decode(rest), "")
}
} else if payload.starts(with: Self.osc9Prefix) {
onNotification("", Self.decode(payload.dropFirst(Self.osc9Prefix.count)))
} else {
out.append(contentsOf: raw)
out.append(contentsOf: terminator)
}
}

private static func decode(_ bytes: ArraySlice<UInt8>) -> String {
String(decoding: bytes, as: UTF8.self)
}
}
Loading