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/// Bytes < 0x20 (control chars), DEL (0x7F), `\`, and bytes >= 0x80 are
163/// `\NNN` octal-escaped. tmux itself only escapes < 0x20 and `\`, but
164/// extending the escape set to include 0x7F+ guarantees correct
165/// round-tripping through UTF-8 strings without altering the wire
166/// semantics for any printable ASCII data.
167#[must_use]
168pub fn octal_escape(bytes: &[u8]) -> String {
169    let mut output = String::with_capacity(bytes.len());
170    for &byte in bytes {
171        if (b' '..0x7F).contains(&byte) && byte != b'\\' {
172            output.push(byte as char);
173        } else {
174            output.push('\\');
175            output.push(char::from(b'0' + ((byte >> 6) & 0x7)));
176            output.push(char::from(b'0' + ((byte >> 3) & 0x7)));
177            output.push(char::from(b'0' + (byte & 0x7)));
178        }
179    }
180    output
181}
182
183#[cfg(test)]
184mod tests {
185    use super::{
186        format_exit_line, format_extended_output_line, format_guard_line, format_output_line,
187        octal_escape, ControlGuardKind, ControlMode,
188    };
189
190    #[test]
191    fn count_two_selects_control_control_mode() {
192        assert_eq!(ControlMode::from_count(0), ControlMode::Plain);
193        assert_eq!(ControlMode::from_count(1), ControlMode::Plain);
194        assert_eq!(ControlMode::from_count(2), ControlMode::ControlControl);
195        assert_eq!(ControlMode::from_count(3), ControlMode::ControlControl);
196    }
197
198    #[test]
199    fn octal_escape_matches_tmux_rules_for_control_bytes() {
200        assert_eq!(octal_escape(b"abc"), "abc");
201        assert_eq!(octal_escape(b"a\nb"), "a\\012b");
202        assert_eq!(octal_escape(b"\\\0"), "\\134\\000");
203        assert_eq!(octal_escape(b" "), " ");
204        assert_eq!(octal_escape(b"~"), "~");
205        // DEL and high bytes are octal-escaped for safe UTF-8 round-tripping.
206        assert_eq!(octal_escape(b"\x7f"), "\\177");
207        assert_eq!(octal_escape(b"\x80"), "\\200");
208        assert_eq!(octal_escape(b"\xff"), "\\377");
209        // All printable ASCII passes through literally.
210        for byte in b' '..b'\x7f' {
211            if byte == b'\\' {
212                continue;
213            }
214            let escaped = octal_escape(&[byte]);
215            assert_eq!(
216                escaped.len(),
217                1,
218                "byte {byte:#04x} should be literal, got {escaped:?}"
219            );
220        }
221    }
222
223    #[test]
224    fn guard_and_output_lines_are_newline_terminated() {
225        assert_eq!(
226            format_guard_line(ControlGuardKind::Begin, 10, 22, 1),
227            "%begin 10 22 1\n"
228        );
229        assert_eq!(format_output_line(7, b"hi\n"), "%output %7 hi\\012\n");
230        assert_eq!(
231            format_extended_output_line(7, 15, b"hi"),
232            "%extended-output %7 15 : hi\n"
233        );
234        assert_eq!(format_exit_line(None), "%exit\n");
235        assert_eq!(
236            format_exit_line(Some("too far behind")),
237            "%exit too far behind\n"
238        );
239    }
240}