Skip to main content

rmux_proto/
control.rs

1//! tmux-compatible control-mode text protocol helpers.
2
3use serde::{Deserialize, Serialize};
4
5/// tmux-compatible control-mode transport flavor negotiated over the detached
6/// bincode RPC channel.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8pub enum ControlMode {
9    /// Plain `-C` control mode.
10    Plain,
11    /// `-CC` control-control mode with DCS wrapping.
12    ControlControl,
13}
14
15impl ControlMode {
16    /// Returns the tmux top-level `-C` count as parsed by Clap.
17    #[must_use]
18    pub const fn from_count(count: u8) -> Self {
19        if count >= 2 {
20            Self::ControlControl
21        } else {
22            Self::Plain
23        }
24    }
25
26    /// Returns `true` when the client requested tmux control-control mode.
27    #[must_use]
28    pub const fn is_control_control(self) -> bool {
29        matches!(self, Self::ControlControl)
30    }
31}
32
33/// Low watermark for buffered control-mode output.
34pub const CONTROL_BUFFER_LOW: usize = 512;
35/// High watermark for buffered control-mode output.
36pub const CONTROL_BUFFER_HIGH: usize = 8192;
37/// Minimum control-mode write chunk tmux attempts before stopping.
38pub const CONTROL_WRITE_MINIMUM: usize = 32;
39/// Maximum age for queued control-mode pane output before disconnecting.
40pub const CONTROL_MAXIMUM_AGE_MS: u64 = 300_000;
41/// Startup prefix for control-control mode.
42pub const CONTROL_CONTROL_START: &str = "\u{1b}P1000p";
43/// Shutdown suffix for control-control mode.
44pub const CONTROL_CONTROL_END: &str = "\u{1b}\\";
45/// Private in-band marker used by Windows rmux clients to represent stdin EOF.
46///
47/// Windows named pipes do not provide a Unix-style write-half close while the
48/// same client handle keeps reading server output. This marker is consumed by
49/// the rmux server before command parsing and is never emitted as user output.
50pub const CONTROL_STDIN_EOF_MARKER: &str = "\0rmux-control-eof";
51
52/// Detached upgrade request that switches a connection into tmux-compatible
53/// control mode while leaving the underlying RPC framing unchanged.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
55pub struct ClientTerminalContext {
56    /// Explicit terminal feature names contributed by top-level `-2` and `-T`.
57    #[serde(default)]
58    pub terminal_features: Vec<String>,
59    /// Whether the invoking client should be treated as UTF-8 capable.
60    #[serde(default)]
61    pub utf8: bool,
62}
63
64/// Detached upgrade request that switches a connection into tmux-compatible
65/// control mode while leaving the underlying RPC framing unchanged.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct ControlModeRequest {
68    /// The requested control-mode flavor.
69    pub mode: ControlMode,
70    /// Terminal/runtime hints captured from the invoking client.
71    #[serde(default)]
72    pub client_terminal: ClientTerminalContext,
73}
74
75/// Detached upgrade response acknowledging entry into control mode.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub struct ControlModeResponse {
78    /// The accepted control-mode flavor.
79    pub mode: ControlMode,
80}
81
82/// Guard kind for `%begin`, `%end`, and `%error`.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ControlGuardKind {
85    /// `%begin`
86    Begin,
87    /// `%end`
88    End,
89    /// `%error`
90    Error,
91}
92
93impl ControlGuardKind {
94    /// Returns the tmux control-guard keyword.
95    #[must_use]
96    pub const fn as_str(self) -> &'static str {
97        match self {
98            Self::Begin => "begin",
99            Self::End => "end",
100            Self::Error => "error",
101        }
102    }
103}
104
105/// Formats a tmux-compatible guard line.
106#[must_use]
107pub fn format_guard_line(
108    kind: ControlGuardKind,
109    time_secs: i64,
110    command_number: u64,
111    flags: u8,
112) -> String {
113    format!(
114        "%{} {} {} {}\n",
115        kind.as_str(),
116        time_secs,
117        command_number,
118        flags
119    )
120}
121
122/// Formats a tmux-compatible `%output` line for pane bytes.
123#[must_use]
124pub fn format_output_line(pane_id: u32, bytes: &[u8]) -> String {
125    format!("%output %{} {}\n", pane_id, octal_escape(bytes))
126}
127
128/// Formats a tmux-compatible `%extended-output` line for pane bytes.
129#[must_use]
130pub fn format_extended_output_line(pane_id: u32, age_ms: u64, bytes: &[u8]) -> String {
131    format!(
132        "%extended-output %{} {} : {}\n",
133        pane_id,
134        age_ms,
135        octal_escape(bytes)
136    )
137}
138
139/// Formats a tmux-compatible `%pause` line.
140#[must_use]
141pub fn format_pause_line(pane_id: u32) -> String {
142    format!("%pause %{}\n", pane_id)
143}
144
145/// Formats a tmux-compatible `%continue` line.
146#[must_use]
147pub fn format_continue_line(pane_id: u32) -> String {
148    format!("%continue %{}\n", pane_id)
149}
150
151/// Formats a tmux-compatible `%exit` line.
152#[must_use]
153pub fn format_exit_line(reason: Option<&str>) -> String {
154    match reason {
155        Some(reason) if !reason.is_empty() => format!("%exit {reason}\n"),
156        _ => "%exit\n".to_owned(),
157    }
158}
159
160/// Formats a tmux-compatible control-mode data payload.
161///
162/// ASCII control bytes, DEL, and `\` are `\NNN` octal-escaped. Valid UTF-8
163/// text is left intact so clients that expect tmux-style Unicode output do
164/// not see every non-ASCII byte expanded into octal sequences. Invalid UTF-8
165/// bytes are escaped one byte at a time.
166#[must_use]
167pub fn octal_escape(bytes: &[u8]) -> String {
168    let mut output = String::with_capacity(bytes.len());
169    let mut offset = 0;
170    while offset < bytes.len() {
171        match std::str::from_utf8(&bytes[offset..]) {
172            Ok(valid) => {
173                push_escaped_text(&mut output, valid);
174                break;
175            }
176            Err(error) if error.valid_up_to() > 0 => {
177                let valid_end = offset + error.valid_up_to();
178                let valid = std::str::from_utf8(&bytes[offset..valid_end])
179                    .expect("valid_up_to must describe valid UTF-8");
180                push_escaped_text(&mut output, valid);
181                offset = valid_end;
182            }
183            Err(error) => {
184                let invalid_len = error.error_len().unwrap_or(1);
185                for &byte in &bytes[offset..offset + invalid_len] {
186                    push_octal_escape(&mut output, byte);
187                }
188                offset += invalid_len;
189            }
190        }
191    }
192    output
193}
194
195fn push_escaped_text(output: &mut String, text: &str) {
196    for character in text.chars() {
197        if character.is_ascii() {
198            let byte = character as u8;
199            if needs_octal_escape(byte) {
200                push_octal_escape(output, byte);
201            } else {
202                output.push(character);
203            }
204        } else {
205            output.push(character);
206        }
207    }
208}
209
210const fn needs_octal_escape(byte: u8) -> bool {
211    byte < b' ' || byte == b'\\' || byte == 0x7F
212}
213
214fn push_octal_escape(output: &mut String, byte: u8) {
215    output.push('\\');
216    output.push(char::from(b'0' + ((byte >> 6) & 0x7)));
217    output.push(char::from(b'0' + ((byte >> 3) & 0x7)));
218    output.push(char::from(b'0' + (byte & 0x7)));
219}
220
221#[cfg(test)]
222mod tests {
223    use super::{
224        format_exit_line, format_extended_output_line, format_guard_line, format_output_line,
225        octal_escape, ControlGuardKind, ControlMode,
226    };
227
228    #[test]
229    fn count_two_selects_control_control_mode() {
230        assert_eq!(ControlMode::from_count(0), ControlMode::Plain);
231        assert_eq!(ControlMode::from_count(1), ControlMode::Plain);
232        assert_eq!(ControlMode::from_count(2), ControlMode::ControlControl);
233        assert_eq!(ControlMode::from_count(3), ControlMode::ControlControl);
234    }
235
236    #[test]
237    fn octal_escape_matches_tmux_rules_for_control_bytes() {
238        assert_eq!(octal_escape(b"abc"), "abc");
239        assert_eq!(octal_escape(b"a\nb"), "a\\012b");
240        assert_eq!(octal_escape(b"\\\0"), "\\134\\000");
241        assert_eq!(octal_escape(b" "), " ");
242        assert_eq!(octal_escape(b"~"), "~");
243        // DEL is escaped; valid UTF-8 non-ASCII is left intact.
244        assert_eq!(octal_escape(b"\x7f"), "\\177");
245        assert_eq!(octal_escape("é".as_bytes()), "é");
246        assert_eq!(octal_escape("hello 👋".as_bytes()), "hello 👋");
247        // Invalid UTF-8 still round-trips as octal bytes.
248        assert_eq!(octal_escape(b"\x80"), "\\200");
249        assert_eq!(octal_escape(b"\xff"), "\\377");
250        // All printable ASCII passes through literally.
251        for byte in b' '..b'\x7f' {
252            if byte == b'\\' {
253                continue;
254            }
255            let escaped = octal_escape(&[byte]);
256            assert_eq!(
257                escaped.len(),
258                1,
259                "byte {byte:#04x} should be literal, got {escaped:?}"
260            );
261        }
262    }
263
264    #[test]
265    fn guard_and_output_lines_are_newline_terminated() {
266        assert_eq!(
267            format_guard_line(ControlGuardKind::Begin, 10, 22, 1),
268            "%begin 10 22 1\n"
269        );
270        assert_eq!(format_output_line(7, b"hi\n"), "%output %7 hi\\012\n");
271        assert_eq!(
272            format_extended_output_line(7, 15, b"hi"),
273            "%extended-output %7 15 : hi\n"
274        );
275        assert_eq!(format_exit_line(None), "%exit\n");
276        assert_eq!(
277            format_exit_line(Some("too far behind")),
278            "%exit too far behind\n"
279        );
280    }
281}