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}