Skip to main content

rmux_sdk/events/
types.rs

1//! SDK pane event vocabulary.
2//!
3//! The [`PaneEvent`] enum models the tmux-compatible control-mode line
4//! vocabulary the rmux daemon emits to attached `-C`/`-CC` clients. The
5//! types here are *inert* DTOs: they do not parse the wire bytes, hold
6//! channel handles, or run state machines. They exist so SDK consumers can
7//! receive a typed projection of the daemon's control-mode stream without
8//! pulling in `rmux-core`, `rmux-server`, `rmux-client`, or `rmux-pty`.
9//!
10//! ## Output sequencing semantics
11//!
12//! The order in which a daemon emits these events is intentionally
13//! observable, because `rmux-server`'s control loop in
14//! `crates/rmux-server/src/control.rs` matches the tmux compatibility
15//! contract. Consumers that resequence events MUST preserve these rules:
16//!
17//! * **Command stdout flushes before `%end`/`%error`.** When an active
18//!   command block resolves, any [`PaneCommandSummary::stdout`]
19//!   bytes are written into the output queue *before* the trailing
20//!   [`%end` or `%error` guard line](PaneCommandSummary). The guard line
21//!   carries the same `command_number`/`timestamp` as the matching
22//!   `%begin`. Any [`PaneEvent::Output`] or [`PaneEvent::ExtendedOutput`]
23//!   that has already arrived for a non-paused pane is also drained ahead
24//!   of the guard line so the transcript respects causal order.
25//! * **Notifications and exits defer until active command blocks close.**
26//!   While a `%begin`/`%end` block is in flight, any
27//!   [`PaneEvent::Notification`] or [`PaneEvent::Exit`] the server would
28//!   normally emit is queued and replayed only after the command block
29//!   has completed. A deferred [`PaneEvent::Exit`] additionally waits for
30//!   all queued notifications to flush, so the final transcript ends with
31//!   `%message` lines, then `%exit`.
32//! * **EOF and empty input emit a bare `%exit`.** When the client closes
33//!   stdin (or sends an empty line), the server emits
34//!   [`PaneEvent::Exit`] with [`PaneExitReason::Bare`] — this is the
35//!   `%exit` line with no reason text and no preceding guard tuple, and
36//!   it is the canonical way the control transcript terminates.
37//! * **Lag precedes the matching disconnect.** A [`PaneEvent::Lag`]
38//!   indicates the per-pane broadcast receiver skipped frames before
39//!   those frames reached the control output queue. When an SDK timeline
40//!   records the following transport teardown, the trailing
41//!   [`PaneEvent::Disconnect`] carries
42//!   [`PaneDisconnectReason::TooFarBehind`] and the same `pane_id` in its
43//!   optional attribution field. This is distinct from the daemon's aged
44//!   output-queue path, which writes `%exit too far behind` after a queued
45//!   output block waits past the tmux-compatible maximum age and has no
46//!   reliable pane attribution.
47//!
48//! These rules are exercised by the JSON/bincode roundtrip tests in
49//! `crates/rmux-sdk/tests/events.rs`, which cover every variant including
50//! raw byte payloads and [`PaneId`] identity fields.
51
52use serde::{Deserialize, Serialize};
53
54use crate::types::PaneId;
55
56/// Tmux-compatible control-mode pane event vocabulary surfaced by the SDK.
57///
58/// The enum is externally tagged for serde, so the JSON projection of
59/// each variant is `{"<kebab-case-tag>": {...}}` (or `"<tag>"` for unit
60/// variants). External tagging is what `bincode` supports natively, so
61/// the same encoding round-trips through both `serde_json` and
62/// `bincode`.
63///
64/// Marked `#[non_exhaustive]` because the daemon vocabulary is a moving
65/// target — added variants must not break downstream pattern matches.
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "kebab-case")]
68#[non_exhaustive]
69pub enum PaneEvent {
70    /// `%output %<pane> <octal-bytes>` pane stdout payload.
71    ///
72    /// `bytes` carries the *decoded* pane bytes (the SDK transport layer
73    /// reverses the `\NNN` octal escaping done on the wire). The payload is
74    /// arbitrary binary, including NUL and bytes outside ASCII; consumers
75    /// must not assume UTF-8.
76    Output {
77        /// Originating pane identity (`%N`).
78        pane_id: PaneId,
79        /// Raw decoded pane bytes.
80        bytes: Vec<u8>,
81    },
82    /// `%extended-output %<pane> <age_ms> : <octal-bytes>` pane payload with
83    /// the queue residency age the daemon recorded for the chunk.
84    ///
85    /// The `age_ms` value comes from the daemon's
86    /// `Instant::now().duration_since(received_at)` measurement at flush
87    /// time and is bounded to fit in `u64` milliseconds.
88    ExtendedOutput {
89        /// Originating pane identity (`%N`).
90        pane_id: PaneId,
91        /// Milliseconds the chunk waited in the daemon output queue.
92        age_ms: u64,
93        /// Raw decoded pane bytes.
94        bytes: Vec<u8>,
95    },
96    /// `%pause %<pane>` — the daemon paused emitting output for this pane
97    /// because the buffered control-mode bytes crossed the high watermark.
98    Pause {
99        /// Paused pane identity.
100        pane_id: PaneId,
101    },
102    /// `%continue %<pane>` — the daemon resumed emitting output for this
103    /// pane after the buffered byte count fell back below the low
104    /// watermark.
105    Continue {
106        /// Resumed pane identity.
107        pane_id: PaneId,
108    },
109    /// Internal lag signal: the broadcast channel feeding `pane_id`
110    /// skipped frames before the SDK could observe them.
111    ///
112    /// Broadcast lag is terminal for this subscription. If the same SDK
113    /// timeline also records the transport close, this event MUST precede
114    /// a matching [`PaneEvent::Disconnect`] with
115    /// [`PaneDisconnectReason::TooFarBehind`] and
116    /// `pane_id: Some(<same pane>)`. A plain `%exit too far behind` line
117    /// without a preceding `Lag` belongs to the aged output-queue path
118    /// instead.
119    Lag {
120        /// Pane whose broadcast channel lagged.
121        pane_id: PaneId,
122    },
123    /// Connection-level disconnect with a structured reason.
124    ///
125    /// Disconnect is distinct from [`PaneEvent::Exit`]: an `Exit` carries a
126    /// human-readable `%exit` reason emitted by the server, while a
127    /// `Disconnect` is the SDK's projection of the transport teardown that
128    /// follows. The two are emitted in pairs for graceful exits and as a
129    /// single `Disconnect` for ungraceful socket loss. `pane_id` is set
130    /// only when the disconnect can be attributed to a specific pane, such
131    /// as the `Lag`/`TooFarBehind` pair.
132    Disconnect {
133        /// Pane that caused the disconnect, when the daemon can identify
134        /// one.
135        #[serde(default)]
136        pane_id: Option<PaneId>,
137        /// Structured disconnect reason.
138        reason: PaneDisconnectReason,
139    },
140    /// `%exit [reason]` — the daemon is closing the control-mode session.
141    ///
142    /// Reason text is empty for the bare `%exit` form emitted on EOF and
143    /// empty-input close paths; see [`PaneExitReason`].
144    Exit {
145        /// Structured `%exit` reason.
146        reason: PaneExitReason,
147    },
148    /// A pane has closed (its underlying process exited or it was killed).
149    ///
150    /// On the wire the daemon broadcasts pane lifecycle events through the
151    /// `%pane-mode-changed`/`%window-pane-changed` notification family;
152    /// the SDK projects pane termination into this dedicated variant so
153    /// consumers can release per-pane resources without parsing
154    /// notification text.
155    Close {
156        /// Closed pane identity.
157        pane_id: PaneId,
158    },
159    /// A write or session mutation was refused because the client is
160    /// read-only or otherwise lacks permission.
161    ///
162    /// Mirrors the `client is read-only` server-side refusal in
163    /// `crates/rmux-server/src/server_access.rs` and the `read-only`
164    /// client-flag tracked in `crates/rmux-server/src/client_flags.rs`.
165    PermissionDenied {
166        /// Pane the refusal targeted, when the operation was pane-scoped.
167        pane_id: Option<PaneId>,
168        /// Permission scope that produced the refusal.
169        scope: PanePermissionScope,
170        /// Human-readable refusal message recorded by the daemon.
171        reason: String,
172    },
173    /// `%message <text>` daemon notification (and the broader
174    /// `%notification`-style line family).
175    Notification(PaneNotification),
176    /// Summary of a `%begin`/`%end`/`%error` command block.
177    CommandSummary(PaneCommandSummary),
178}
179
180/// Structured reason carried by [`PaneEvent::Disconnect`].
181///
182/// Marked `#[non_exhaustive]` so new transport-level disconnect causes can
183/// be modelled without breaking downstream pattern matches. Externally
184/// tagged for serde, so the encoding round-trips through both
185/// `serde_json` and `bincode`.
186#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
187#[serde(rename_all = "kebab-case")]
188#[non_exhaustive]
189pub enum PaneDisconnectReason {
190    /// The stream became too far behind to continue.
191    ///
192    /// With the `pane_id` field set on [`PaneEvent::Disconnect`], this
193    /// follows a preceding [`PaneEvent::Lag`] for the same pane. With no
194    /// pane attribution, this can represent the daemon's aged output-queue
195    /// path that writes `%exit too far behind` before closing.
196    TooFarBehind,
197    /// The daemon is shutting down gracefully.
198    ServerShutdown,
199    /// The deferred control-mode notification queue exceeded its bound.
200    NotificationOverflow,
201    /// The transport closed without a `%exit` line (raw socket loss).
202    TransportClosed,
203    /// Any other disconnect reason carried verbatim.
204    Other {
205        /// Human-readable reason text.
206        reason: String,
207    },
208}
209
210/// Structured reason carried by [`PaneEvent::Exit`].
211///
212/// `Bare` denotes the canonical `%exit\n` form emitted on EOF / empty
213/// input. `WithReason` carries the trailing reason text from
214/// `%exit <reason>\n`; the daemon uses this for graceful operator-driven
215/// closes (e.g. `%exit server shutting down`). Externally tagged for
216/// serde, so the encoding round-trips through both `serde_json` and
217/// `bincode`.
218#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
219#[serde(rename_all = "kebab-case")]
220#[non_exhaustive]
221pub enum PaneExitReason {
222    /// Bare `%exit\n` line. Emitted on EOF and on empty-input close.
223    Bare,
224    /// `%exit <reason>\n` line carrying daemon-supplied reason text.
225    WithReason {
226        /// Trailing reason text from the wire form.
227        reason: String,
228    },
229}
230
231/// Permission scope that produced a [`PaneEvent::PermissionDenied`].
232///
233/// Marked `#[non_exhaustive]` so additional permission categories (such as
234/// per-window or per-client policies) can be added without breaking
235/// downstream pattern matches.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
237#[serde(rename_all = "kebab-case")]
238#[non_exhaustive]
239pub enum PanePermissionScope {
240    /// Client carries the `read-only` flag and may not mutate state.
241    ReadOnlyClient,
242    /// Some other permission scope refused the operation. This keeps the
243    /// SDK vocabulary honest until the daemon exposes more precise public
244    /// permission categories.
245    Other,
246}
247
248/// `%message`-style notification carried by [`PaneEvent::Notification`].
249///
250/// `text` is the already-decoded human-readable message text (the SDK
251/// transport layer reverses the daemon's `encode_paste_bytes` escaping).
252/// `pane_id` is set for pane-scoped notifications and omitted for
253/// session/server-scoped lines such as `%sessions-changed`.
254#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
255pub struct PaneNotification {
256    /// Pane the notification is scoped to, when applicable.
257    #[serde(default)]
258    pub pane_id: Option<PaneId>,
259    /// Decoded notification text.
260    #[serde(default)]
261    pub text: String,
262}
263
264impl PaneNotification {
265    /// Creates a session-scoped notification with no pane context.
266    #[must_use]
267    pub fn new(text: impl Into<String>) -> Self {
268        Self {
269            pane_id: None,
270            text: text.into(),
271        }
272    }
273
274    /// Creates a pane-scoped notification.
275    #[must_use]
276    pub fn for_pane(pane_id: PaneId, text: impl Into<String>) -> Self {
277        Self {
278            pane_id: Some(pane_id),
279            text: text.into(),
280        }
281    }
282}
283
284/// Status of a completed control-mode command block.
285///
286/// The daemon's trailing guard line is either `%end` or `%error`; command
287/// output and error text have already been flushed as stdout bytes before
288/// that guard line.
289#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
290#[serde(rename_all = "kebab-case")]
291#[non_exhaustive]
292pub enum PaneCommandStatus {
293    /// The trailing guard line was `%end`.
294    #[default]
295    End,
296    /// The trailing guard line was `%error`.
297    Error,
298}
299
300/// Summary of a `%begin`/`%end`/`%error` control-mode command block.
301///
302/// `timestamp`, `command_number`, and `flags` mirror the guard tuple
303/// emitted by the daemon's
304/// [`format_guard_line`](rmux_proto::format_guard_line). `status`
305/// identifies the trailing guard kind. `stdout` carries every decoded byte
306/// the daemon flushed *before* that guard line, including parse or command
307/// error text for `%error` blocks.
308#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
309pub struct PaneCommandSummary {
310    /// Unix epoch seconds reported by `%begin`.
311    pub timestamp: i64,
312    /// Monotonic command number inside the control session.
313    pub command_number: u64,
314    /// `flags` byte from the guard tuple. Always `1` for the v1 daemon.
315    pub flags: u8,
316    /// Trailing guard status: `%end` or `%error`.
317    #[serde(default)]
318    pub status: PaneCommandStatus,
319    /// Decoded command stdout, captured between `%begin` and the trailing
320    /// guard line. May be empty for commands with no output. For `%error`,
321    /// this also includes daemon-written error text.
322    #[serde(default)]
323    pub stdout: Vec<u8>,
324}
325
326impl PaneCommandSummary {
327    /// Creates a successful command summary with the supplied stdout.
328    #[must_use]
329    pub fn success(timestamp: i64, command_number: u64, flags: u8, stdout: Vec<u8>) -> Self {
330        Self {
331            timestamp,
332            command_number,
333            flags,
334            status: PaneCommandStatus::End,
335            stdout,
336        }
337    }
338
339    /// Creates a failed command summary whose trailing guard was `%error`.
340    #[must_use]
341    pub fn failure(timestamp: i64, command_number: u64, flags: u8, stdout: Vec<u8>) -> Self {
342        Self {
343            timestamp,
344            command_number,
345            flags,
346            status: PaneCommandStatus::Error,
347            stdout,
348        }
349    }
350
351    /// Returns `true` when the trailing guard line was `%end`.
352    #[must_use]
353    pub fn is_success(&self) -> bool {
354        self.status == PaneCommandStatus::End
355    }
356}