Skip to main content

strop_ui_protocol/
message.rs

1//! Protocol envelopes (0056 AR09 §8): versioned handshake, admitted
2//! actions, acknowledgements with explicit outcomes, semantic
3//! snapshots/deltas, host effects, resynchronization and shutdown.
4//!
5//! Envelopes are distinct from LSP's — only the Content-Length byte
6//! convention is shared ([`crate::frame`]). Every type here is pure
7//! data; the engine mapping lives in the backend, the application
8//! rules in [`crate::client`].
9
10use serde::{Deserialize, Serialize};
11use strop_core::frontend_input::{Input, Key};
12use strop_core::id::{BufferRevision, DocumentId};
13
14/// The only wire version this build speaks.
15pub const PROTOCOL_VERSION: u32 = 1;
16
17/// The state an action claims to be based on: the backend incarnation
18/// and the newest view generation the client has applied.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub struct BaseStamp {
21    pub incarnation: u64,
22    pub generation: u64,
23}
24
25/// Who the client is, for the handshake record.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ClientInfo {
28    pub name: String,
29    pub version: String,
30}
31
32/// What the client can absorb. Everything defaults to withheld: a
33/// client that cannot accept clipboard writes never receives one.
34#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub struct ClientCapabilities {
36    pub clipboard_write: bool,
37}
38
39/// Who/what the backend is. `incarnation` is unique per backend
40/// process: publications and actions key on it so state from a previous
41/// backend can never resolve against a restarted one.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct BackendInfo {
44    pub name: String,
45    pub version: String,
46    pub build: Option<String>,
47    pub incarnation: u64,
48}
49
50/// The backend's hard bounds (AR06 conventions), restated on the wire
51/// so the client never has to guess them.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ActionLimits {
54    pub max_frame_bytes: usize,
55    pub max_pending_requests: usize,
56    pub max_viewport_cells: u32,
57}
58
59/// The currently-implemented TUI families this backend serves (AR09 §8:
60/// only real families are advertised).
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ServerCapabilities {
63    pub terminals: bool,
64    pub workspace_search: bool,
65    pub filesystem: bool,
66    pub clipboard_write: bool,
67}
68
69/// One admitted action — a one-to-one mirror of the engine's admitted
70/// input surface (`AppEvent`'s client-initiated subset). The protocol
71/// adds no commands of its own.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(tag = "action", content = "data", rename_all = "snake_case")]
74pub enum AdmittedAction {
75    /// Physical/logical input; the engine selects the owner.
76    Input(Input),
77    /// Already-normalized semantic input (scripted/editor commands).
78    EditorKey(Key),
79    /// Bracketed paste: one text payload, never a key stream.
80    Paste(String),
81    /// Terminal resized.
82    Resize { columns: u16, rows: u16 },
83    /// ctrl-c: the quit intent; the editor's policy decides.
84    QuitIntent,
85    /// Focus change.
86    Focus(bool),
87}
88
89/// Terminal-cell viewport geometry (wire mirror of the engine's
90/// `ViewGeometry`; frontends convert at their edge).
91#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92pub struct Geometry {
93    pub columns: u16,
94    pub rows: u16,
95}
96
97/// One rectangle in terminal cells (wire mirror of `CellRect`).
98#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99pub struct Rect {
100    pub x: u16,
101    pub y: u16,
102    pub width: u16,
103    pub height: u16,
104}
105
106/// Declared bounds of a pane window (wire mirror of the engine's AR03
107/// `WindowBounds`): every state is explicit; there is no empty success.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum ViewBounds {
111    Complete,
112    Partial,
113    Loading,
114    Stale,
115    Error,
116}
117
118/// One pane's semantic window, keyed by document identity + the
119/// revision the window was prepared against.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct PaneSnapshot {
122    pub document: DocumentId,
123    pub revision: BufferRevision,
124    pub bounds: ViewBounds,
125    pub cursor: usize,
126    pub view_top: usize,
127    pub hscroll: usize,
128    pub terminal_input: bool,
129    pub overlays: bool,
130    pub rect: Rect,
131    pub budget: Rect,
132    /// The text window: lines starting at `window_top`, bounded by the
133    /// pane's row budget and the document's length.
134    pub window_top: usize,
135    pub lines: Vec<String>,
136}
137
138/// A full semantic view: the prepared panes plus the same logical state
139/// observation the headless driver pins (`state_json`).
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct ViewSnapshot {
142    pub generation: u64,
143    pub geometry: Geometry,
144    pub active_pane: usize,
145    pub panes: Vec<PaneSnapshot>,
146    pub state: serde_json::Value,
147}
148
149/// A sparse view update valid only against `base`. `panes` is aligned
150/// with the base snapshot's pane order; a changed pane set always ships
151/// as a full snapshot instead.
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct ViewDelta {
154    pub base: u64,
155    pub generation: u64,
156    pub geometry: Option<Geometry>,
157    pub active_pane: Option<usize>,
158    pub panes: Vec<PaneDelta>,
159    pub state: Option<serde_json::Value>,
160}
161
162/// One pane slot's delta.
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum PaneDelta {
166    Unchanged,
167    Changed(PaneSnapshot),
168}
169
170/// The outcome of one acknowledged request.
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(tag = "outcome", rename_all = "snake_case")]
173pub enum AckOutcome {
174    /// Applied; `applied` is the backend's monotone action counter and
175    /// `generation` the view generation after application.
176    Applied { applied: u64, generation: u64 },
177    /// Refused with a typed reason; nothing was applied.
178    Refused { refusal: Refusal },
179}
180
181/// A typed refusal (AR09: explicit outcomes, never silent divergence).
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
183#[serde(tag = "reason", rename_all = "snake_case")]
184pub enum Refusal {
185    /// The action's base predates the newest publication: the client
186    /// missed a delta and must resync before acting.
187    #[error("stale base generation; the current generation is {current}")]
188    StaleGeneration { current: u64 },
189    /// The action claims a generation the backend has not reached.
190    #[error("future base generation; the current generation is {current}")]
191    FutureGeneration { current: u64 },
192    /// The action names a different backend incarnation.
193    #[error("wrong backend incarnation; the current incarnation is {current}")]
194    WrongIncarnation { current: u64 },
195    /// A declared bound was exceeded (viewport cells, frame bytes).
196    #[error("bound exceeded: {message}")]
197    Limit { message: String },
198    /// The admitted engine path itself reported an I/O failure.
199    #[error("engine: {message}")]
200    Engine { message: String },
201    /// The backend is shutting down; no further actions are admitted.
202    #[error("backend is closing")]
203    Closed,
204}
205
206/// A host effect the backend requests of the client (AR08): the client
207/// answers with [`EffectOutcome`]. The engine stages OSC52 clipboard
208/// payloads for its frontend; over the protocol the client is it.
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210#[serde(tag = "kind", rename_all = "snake_case")]
211pub enum EffectRequest {
212    ClipboardWrite { text: String },
213}
214
215/// The client's answer to an [`EffectRequest`].
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(tag = "outcome", rename_all = "snake_case")]
218pub enum EffectOutcome {
219    Applied,
220    Refused { reason: String },
221}
222
223/// Why the session ended.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum ShutdownReason {
227    /// The client sent `shutdown`.
228    Requested,
229    /// The editor quit through its own grammar (`:q`, quit intent).
230    Quit,
231    /// The client link closed or died.
232    Disconnect,
233    /// Frame-level corruption poisoned the stream.
234    ProtocolViolation,
235}
236
237/// A protocol-level failure, independent of any admitted action.
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
239#[serde(tag = "kind", rename_all = "snake_case")]
240pub enum ProtocolError {
241    /// The offered protocol version is not spoken here.
242    #[error("protocol version {offered} is not supported (supported: {supported})")]
243    Version { supported: u32, offered: u32 },
244    /// A well-framed body that is not a valid client message.
245    #[error("undecodable message: {message}")]
246    Decode { message: String },
247    /// Frame-level corruption; the stream is poisoned and closes.
248    #[error("frame violation: {message}")]
249    Frame { message: String },
250    /// A message that does not belong at this point (e.g. actions
251    /// before the handshake, or a second hello).
252    #[error("unexpected message: {message}")]
253    Unexpected { message: String },
254}
255
256/// Client → backend.
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258#[serde(tag = "type", rename_all = "snake_case")]
259pub enum ClientMessage {
260    /// The first and only pre-handshake message.
261    Hello {
262        protocol: u32,
263        client: ClientInfo,
264        capabilities: ClientCapabilities,
265    },
266    /// Admitted actions, applied in order, on the stated base. One
267    /// sequence number covers the batch; the acknowledgement carries
268    /// the post-application generation.
269    Act {
270        seq: u64,
271        base: BaseStamp,
272        actions: Vec<AdmittedAction>,
273    },
274    /// Viewport interest: the geometry preparation runs against. The
275    /// engine sees the same resize a TUI would deliver.
276    Viewport { seq: u64, columns: u16, rows: u16 },
277    /// Request a complete current snapshot — the only recovery from a
278    /// dropped delta or a poisoned client.
279    Resync { seq: u64 },
280    /// Answer a host effect request.
281    EffectResult { id: u64, outcome: EffectOutcome },
282    /// Authorized orderly shutdown: the backend finishes background
283    /// work, publishes `bye` and exits.
284    Shutdown { seq: u64 },
285}
286
287/// Backend → client.
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289#[serde(tag = "type", rename_all = "snake_case")]
290pub enum ServerMessage {
291    /// The handshake answer, carrying identity, bounds and the real
292    /// capability set.
293    Welcome {
294        protocol: u32,
295        backend: BackendInfo,
296        limits: ActionLimits,
297        capabilities: ServerCapabilities,
298    },
299    /// The acknowledgement of one sequenced request.
300    Ack { seq: u64, outcome: AckOutcome },
301    /// A complete semantic view; valid against any prior state.
302    Snapshot {
303        incarnation: u64,
304        view: ViewSnapshot,
305    },
306    /// A sparse update valid only against its `base` generation.
307    Delta { incarnation: u64, delta: ViewDelta },
308    /// A host effect request (AR08).
309    Effect { id: u64, effect: EffectRequest },
310    /// A protocol-level failure. `seq` names the offending request when
311    /// one could be identified.
312    Error {
313        seq: Option<u64>,
314        error: ProtocolError,
315    },
316    /// The final message: the backend is exiting.
317    Bye { reason: ShutdownReason },
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    /// The envelope round-trip pins the wire shape: envelopes stay
325    /// distinct typed data, never skip-garbage JSON sniffing.
326    #[test]
327    fn envelopes_round_trip() {
328        let messages = [
329            ClientMessage::Hello {
330                protocol: PROTOCOL_VERSION,
331                client: ClientInfo {
332                    name: "driver".into(),
333                    version: "0.1".into(),
334                },
335                capabilities: ClientCapabilities {
336                    clipboard_write: true,
337                },
338            },
339            ClientMessage::Act {
340                seq: 7,
341                base: BaseStamp {
342                    incarnation: 42,
343                    generation: 3,
344                },
345                actions: vec![
346                    AdmittedAction::Input(Input::Text("héllo".into())),
347                    AdmittedAction::EditorKey(Key::Enter),
348                    AdmittedAction::Resize {
349                        columns: 120,
350                        rows: 40,
351                    },
352                    AdmittedAction::QuitIntent,
353                ],
354            },
355            ClientMessage::Resync { seq: 8 },
356            ClientMessage::EffectResult {
357                id: 1,
358                outcome: EffectOutcome::Refused {
359                    reason: "no clipboard".into(),
360                },
361            },
362            ClientMessage::Shutdown { seq: 9 },
363        ];
364        for message in messages {
365            let bytes = serde_json::to_vec(&message).unwrap();
366            assert_eq!(
367                serde_json::from_slice::<ClientMessage>(&bytes).unwrap(),
368                message
369            );
370        }
371    }
372
373    #[test]
374    fn server_envelopes_round_trip() {
375        let messages = [
376            ServerMessage::Ack {
377                seq: 1,
378                outcome: AckOutcome::Applied {
379                    applied: 5,
380                    generation: 9,
381                },
382            },
383            ServerMessage::Ack {
384                seq: 2,
385                outcome: AckOutcome::Refused {
386                    refusal: Refusal::StaleGeneration { current: 11 },
387                },
388            },
389            ServerMessage::Effect {
390                id: 0,
391                effect: EffectRequest::ClipboardWrite {
392                    text: "payload".into(),
393                },
394            },
395            ServerMessage::Error {
396                seq: None,
397                error: ProtocolError::Version {
398                    supported: 1,
399                    offered: 99,
400                },
401            },
402            ServerMessage::Bye {
403                reason: ShutdownReason::ProtocolViolation,
404            },
405        ];
406        for message in messages {
407            let bytes = serde_json::to_vec(&message).unwrap();
408            assert_eq!(
409                serde_json::from_slice::<ServerMessage>(&bytes).unwrap(),
410                message
411            );
412        }
413    }
414
415    /// An unknown envelope type is a decode failure, not a guess.
416    #[test]
417    fn unknown_envelopes_fail_decoding() {
418        assert!(serde_json::from_slice::<ClientMessage>(br#"{"type":"teleport"}"#).is_err());
419        assert!(serde_json::from_slice::<ServerMessage>(br#"{"type":"teleport"}"#).is_err());
420    }
421}