Skip to main content

wsx_core/runtime/
protocol.rs

1use super::domain::*;
2use serde::{Deserialize, Serialize};
3#[cfg(unix)]
4use std::os::unix::fs::MetadataExt;
5use std::{
6    cmp::Ordering,
7    io,
8    path::{Path, PathBuf},
9    time::UNIX_EPOCH,
10};
11
12// ^ [[Terminal Stream Protocol v3]] Wire-version history and compatibility boundaries.
13pub const PROTOCOL_VERSION: u32 = 15;
14pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
15pub const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
16pub const WSX_PANE_ID_ENV: &str = "WSX_PANE_ID";
17pub const WSX_RUNTIME_GENERATION_ENV: &str = "WSX_RUNTIME_GENERATION";
18pub const WSX_PLUGIN_VIEW_ENV: &str = "WSX_PLUGIN_VIEW_JSON";
19pub const WSX_VERSION: &str = env!("CARGO_PKG_VERSION");
20// ^ Bump only when daemon-owned runtime behavior changes. UI-only releases reuse wsxd.
21pub const DAEMON_REVISION: u32 = 5;
22
23pub fn compare_wsx_versions(left: &str, right: &str) -> Option<Ordering> {
24    let left = parse_wsx_version(left)?;
25    let right = parse_wsx_version(right)?;
26    let core = left.core.cmp(&right.core);
27    if core != Ordering::Equal {
28        return Some(core);
29    }
30    match (left.prerelease, right.prerelease) {
31        (None, None) => Some(Ordering::Equal),
32        (None, Some(_)) => Some(Ordering::Greater),
33        (Some(_), None) => Some(Ordering::Less),
34        (Some(left), Some(right)) => compare_prerelease(left, right),
35    }
36}
37
38pub fn binary_identity_version(identity: &str) -> Option<&str> {
39    binary_identity_parts(identity).map(|(version, _)| version)
40}
41
42pub fn compare_binary_identities(left: &str, right: &str) -> Option<Ordering> {
43    let (left_version, left_modified) = binary_identity_parts(left)?;
44    let (right_version, right_modified) = binary_identity_parts(right)?;
45    let version = compare_wsx_versions(left_version, right_version)?;
46    if version != Ordering::Equal {
47        return Some(version);
48    }
49    Some(left_modified.cmp(&right_modified))
50}
51
52fn binary_identity_parts(identity: &str) -> Option<(&str, u128)> {
53    let fields = identity.split(':').collect::<Vec<_>>();
54    let expected_fields = if cfg!(unix) { 5 } else { 3 };
55    if fields.len() != expected_fields || parse_wsx_version(fields[0]).is_none() {
56        return None;
57    }
58    let mut values = fields[1..]
59        .iter()
60        .map(|value| u128::from_str_radix(value, 16));
61    let modified = values.next_back()?.ok()?;
62    values
63        .all(|value| value.is_ok())
64        .then_some((fields[0], modified))
65}
66
67struct ParsedVersion<'a> {
68    core: (u64, u64, u64),
69    prerelease: Option<&'a str>,
70}
71
72fn parse_wsx_version(version: &str) -> Option<ParsedVersion<'_>> {
73    let (precedence, build) = version
74        .split_once('+')
75        .map_or((version, None), |(precedence, build)| {
76            (precedence, Some(build))
77        });
78    if build.is_some_and(|build| !valid_identifiers(build, false)) {
79        return None;
80    }
81    let (core, prerelease) = precedence
82        .split_once('-')
83        .map_or((precedence, None), |(core, prerelease)| {
84            (core, Some(prerelease))
85        });
86    if prerelease.is_some_and(|value| !valid_identifiers(value, true)) {
87        return None;
88    }
89    let mut parts = core.split('.');
90    let core = (
91        parse_core_number(parts.next()?)?,
92        parse_core_number(parts.next()?)?,
93        parse_core_number(parts.next()?)?,
94    );
95    if parts.next().is_some() {
96        return None;
97    }
98    Some(ParsedVersion { core, prerelease })
99}
100
101fn parse_core_number(value: &str) -> Option<u64> {
102    if value.is_empty() || (value.len() > 1 && value.starts_with('0')) {
103        return None;
104    }
105    value.parse().ok()
106}
107
108fn valid_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool {
109    !value.is_empty()
110        && value.split('.').all(|identifier| {
111            !identifier.is_empty()
112                && identifier
113                    .bytes()
114                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
115                && !(reject_numeric_leading_zero
116                    && identifier.len() > 1
117                    && identifier.starts_with('0')
118                    && identifier.bytes().all(|byte| byte.is_ascii_digit()))
119        })
120}
121
122fn compare_prerelease(left: &str, right: &str) -> Option<Ordering> {
123    let mut left = left.split('.');
124    let mut right = right.split('.');
125    loop {
126        match (left.next(), right.next()) {
127            (Some(left), Some(right)) => {
128                let ordering = match (
129                    left.bytes().all(|byte| byte.is_ascii_digit()),
130                    right.bytes().all(|byte| byte.is_ascii_digit()),
131                ) {
132                    (true, true) => parse_core_number(left)?.cmp(&parse_core_number(right)?),
133                    (true, false) => Ordering::Less,
134                    (false, true) => Ordering::Greater,
135                    (false, false) => left.cmp(right),
136                };
137                if ordering != Ordering::Equal {
138                    return Some(ordering);
139                }
140            }
141            (Some(_), None) => return Some(Ordering::Greater),
142            (None, Some(_)) => return Some(Ordering::Less),
143            (None, None) => return Some(Ordering::Equal),
144        }
145    }
146}
147
148pub fn binary_identity(path: &Path) -> io::Result<String> {
149    let path = path.canonicalize()?;
150    let metadata = path.metadata()?;
151    let modified = metadata
152        .modified()?
153        .duration_since(UNIX_EPOCH)
154        .unwrap_or_default()
155        .as_nanos();
156    #[cfg(unix)]
157    return Ok(format!(
158        "{}:{:x}:{:x}:{:x}:{modified:x}",
159        env!("CARGO_PKG_VERSION"),
160        metadata.dev(),
161        metadata.ino(),
162        metadata.len()
163    ));
164    #[cfg(not(unix))]
165    Ok(format!(
166        "{}:{:x}:{modified:x}",
167        env!("CARGO_PKG_VERSION"),
168        metadata.len()
169    ))
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(tag = "method", content = "params", rename_all = "snake_case")]
174pub enum Request {
175    Hello {
176        protocol: u32,
177    },
178    Snapshot,
179    Poll {
180        after_revision: u64,
181        timeout_ms: u64,
182        #[serde(default)]
183        tui: Option<TuiClientPresence>,
184    },
185    SynchronizeProjects {
186        projects: Vec<ProjectSpec>,
187    },
188    SessionCreate {
189        worktree_id: WorktreeId,
190        label: String,
191        command: Vec<String>,
192        #[serde(default)]
193        initial_input: Option<String>,
194        rows: u16,
195        cols: u16,
196    },
197    SessionRename {
198        session_id: SessionId,
199        label: String,
200        expected_revision: u64,
201    },
202    SessionReorder {
203        session_id: SessionId,
204        target_session_id: SessionId,
205        placement: SessionPlacement,
206        expected_revision: u64,
207    },
208    SessionClose {
209        session_id: SessionId,
210        expected_revision: u64,
211    },
212    PaneSplit {
213        session_id: SessionId,
214        target: PaneId,
215        axis: SplitAxis,
216        label: String,
217        command: Vec<String>,
218        #[serde(default)]
219        initial_input: Option<String>,
220        rows: u16,
221        cols: u16,
222        expected_revision: u64,
223    },
224    PaneFocus {
225        session_id: SessionId,
226        pane_id: PaneId,
227    },
228    PaneClose {
229        pane_id: PaneId,
230        expected_revision: u64,
231    },
232    TerminalAcquire {
233        pane_id: PaneId,
234        client_id: u64,
235        takeover: bool,
236    },
237    TerminalRelease {
238        pane_id: PaneId,
239        client_id: u64,
240    },
241    TerminalHeartbeat {
242        pane_id: PaneId,
243        client_id: u64,
244    },
245    TerminalSubscribe {
246        pane_id: PaneId,
247        client_id: u64,
248        takeover: bool,
249        rows: u16,
250        cols: u16,
251    },
252    TerminalInput {
253        pane_id: PaneId,
254        client_id: u64,
255        bytes: Vec<u8>,
256    },
257    TerminalKey {
258        pane_id: PaneId,
259        client_id: u64,
260        key: KeyEvent,
261    },
262    TerminalPaste {
263        pane_id: PaneId,
264        client_id: u64,
265        text: String,
266    },
267    TerminalMouse {
268        pane_id: PaneId,
269        client_id: u64,
270        mouse: MouseEvent,
271    },
272    TerminalResize {
273        pane_id: PaneId,
274        client_id: u64,
275        rows: u16,
276        cols: u16,
277    },
278    View {
279        pane_ids: Vec<PaneId>,
280    },
281    AgentReport {
282        pane_id: PaneId,
283        #[serde(default)]
284        runtime_generation: Option<String>,
285        provider: String,
286        state: AgentState,
287        #[serde(default)]
288        conversation_id: Option<String>,
289        #[serde(default)]
290        session_ref: Option<AgentSessionRef>,
291        capabilities: AgentCapabilities,
292    },
293    AgentClear {
294        pane_id: PaneId,
295        runtime_generation: String,
296        next_runtime_generation: String,
297    },
298    PluginList,
299    PluginReload,
300    PluginReviewCancel {
301        request_id: String,
302    },
303    PluginReview {
304        plugin_id: String,
305        worktree_id: WorktreeId,
306        request_id: String,
307        comparison: super::ReviewComparison,
308        limits: super::ReviewLimits,
309        operation: super::ReviewOperation,
310    },
311    PluginRender {
312        plugin_id: String,
313        pane_id: PaneId,
314        columns: u16,
315        rows: u16,
316        generation: u64,
317    },
318    LifecycleStatus,
319    PrepareReplacement {
320        target_binary_id: String,
321        #[serde(default)]
322        target_daemon_revision: u32,
323    },
324    PrepareHandoff {
325        target_binary_id: String,
326        target_version: String,
327        target_protocol: u32,
328        target_daemon_revision: u32,
329        executable: PathBuf,
330    },
331    Shutdown,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(tag = "type", content = "data", rename_all = "snake_case")]
336pub enum Event {
337    Changed {
338        revision: u64,
339        entity: String,
340        id: u64,
341    },
342    Exited {
343        revision: u64,
344        pane_id: PaneId,
345    },
346    ResyncRequired {
347        revision: u64,
348    },
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct ApiError {
353    pub code: String,
354    pub message: String,
355}
356
357impl ApiError {
358    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
359        Self {
360            code: code.into(),
361            message: message.into(),
362        }
363    }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(tag = "type", content = "data", rename_all = "snake_case")]
368pub enum Response {
369    Hello {
370        protocol: u32,
371        epoch: u64,
372        #[serde(default)]
373        capabilities: Capabilities,
374    },
375    Snapshot(Snapshot),
376    View {
377        snapshot: Snapshot,
378        frames: Vec<TerminalFrame>,
379    },
380    Events {
381        revision: u64,
382        events: Vec<Event>,
383    },
384    Plugins(Vec<PluginManifest>),
385    PluginView(PluginSidecarView),
386    PluginReview {
387        epoch: u64,
388        response: super::ReviewResponse,
389    },
390    Lifecycle(DaemonLifecycle),
391    Replacement {
392        disposition: ReplacementDisposition,
393        live_runtimes: usize,
394        #[serde(default)]
395        daemon_version: String,
396        #[serde(default)]
397        target_version: String,
398        #[serde(default)]
399        blockers: Vec<ReplacementBlocker>,
400        #[serde(default)]
401        use_current_daemon: bool,
402    },
403    Created {
404        revision: u64,
405        id: u64,
406    },
407    Ack {
408        revision: u64,
409    },
410    Error(ApiError),
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414#[serde(tag = "type", content = "data", rename_all = "snake_case")]
415pub enum TerminalClientMessage {
416    Key(KeyEvent),
417    Paste(String),
418    Mouse(MouseEvent),
419    Input(Vec<u8>),
420    Resize { rows: u16, cols: u16 },
421    Heartbeat,
422    Resync,
423    Detach,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(tag = "type", content = "data", rename_all = "snake_case")]
428pub enum TerminalServerMessage {
429    Update(TerminalUpdate),
430    ClipboardWrite(Vec<u8>),
431    Error(ApiError),
432    Exited,
433}
434
435pub fn encode_line<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
436    let mut bytes = serde_json::to_vec(value)?;
437    bytes.push(b'\n');
438    Ok(bytes)
439}
440
441pub fn default_socket_path() -> PathBuf {
442    if let Some(path) = std::env::var_os("WSX_SOCKET") {
443        return PathBuf::from(path);
444    }
445    let root = std::env::var_os("XDG_STATE_HOME")
446        .map(PathBuf::from)
447        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))
448        .unwrap_or_else(std::env::temp_dir);
449    root.join("wsx/wsx.sock")
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn protocol_is_tagged_and_line_delimited() {
458        let bytes = encode_line(&Request::Snapshot).unwrap();
459        assert_eq!(bytes.last(), Some(&b'\n'));
460        assert!(String::from_utf8(bytes).unwrap().contains("snapshot"));
461    }
462
463    #[test]
464    fn unknown_methods_including_recent_clear_are_rejected() {
465        for method in ["unknown_method", "project_recent_clear"] {
466            let json = format!(r#"{{"method":"{method}","params":{{}}}}"#);
467            assert!(serde_json::from_str::<Request>(&json).is_err(), "{method}");
468        }
469    }
470
471    #[test]
472    fn legacy_hello_defaults_capabilities_added_by_newer_protocols() {
473        let response = serde_json::from_str::<Response>(
474            r#"{"type":"hello","data":{"protocol":3,"epoch":1,"capabilities":{"pane_splits":true,"plugins":true,"agent_reports":true,"process_restore":false}}}"#,
475        )
476        .unwrap();
477
478        let Response::Hello { capabilities, .. } = response else {
479            panic!("expected hello response");
480        };
481        assert!(capabilities.pane_splits);
482        assert!(!capabilities.plugin_views);
483        assert!(!capabilities.agent_session_restore);
484        assert!(!capabilities.resume_shell_fallback);
485        assert!(!capabilities.listening_ports);
486        assert!(!capabilities.foreground_jobs);
487        assert!(!capabilities.lifecycle_coordination);
488        assert!(!capabilities.daemon_revision_coordination);
489    }
490
491    #[test]
492    fn wsx_versions_and_builds_have_numeric_precedence() {
493        assert_eq!(
494            compare_wsx_versions("0.21.0", "0.20.9"),
495            Some(std::cmp::Ordering::Greater)
496        );
497        assert_eq!(
498            compare_binary_identities("0.21.0:1:2:3:20", "0.21.0:4:5:6:10"),
499            Some(std::cmp::Ordering::Greater)
500        );
501        assert_eq!(
502            compare_wsx_versions("0.21.0-beta.2", "0.21.0-beta.11"),
503            Some(std::cmp::Ordering::Less)
504        );
505        assert_eq!(
506            compare_wsx_versions("0.21.0-rc.1", "0.21.0"),
507            Some(std::cmp::Ordering::Less)
508        );
509        assert_eq!(
510            compare_wsx_versions("0.21.0+build.2", "0.21.0+build.1"),
511            Some(std::cmp::Ordering::Equal)
512        );
513        assert_eq!(
514            compare_wsx_versions("0.21.0-alpha.beta", "0.21.0-alpha.rc"),
515            Some(std::cmp::Ordering::Less)
516        );
517        assert_eq!(
518            compare_wsx_versions("0.21.0-alpha10", "0.21.0-alpha2"),
519            Some(std::cmp::Ordering::Less)
520        );
521        assert_eq!(compare_wsx_versions("0.21.0-01", "0.21.0"), None);
522        assert_eq!(compare_wsx_versions("", "0.21.0"), None);
523        assert_eq!(compare_wsx_versions("0.21.0", ""), None);
524        for malformed in [
525            "",
526            "malformed",
527            "0.21.0:1:2:3",
528            "0.21.0:1:2:3:10:5",
529            "0.21.0:1:2:3:not-hex",
530        ] {
531            assert_eq!(
532                compare_binary_identities(malformed, "0.21.0:1:2:3:10"),
533                None,
534                "{malformed:?} must not be a binary identity"
535            );
536        }
537    }
538
539    #[test]
540    fn legacy_lifecycle_requests_and_responses_default_version_fields() {
541        let poll = serde_json::from_str::<Request>(
542            r#"{"method":"poll","params":{"after_revision":7,"timeout_ms":1000}}"#,
543        )
544        .unwrap();
545        assert!(matches!(poll, Request::Poll { tui: None, .. }));
546
547        let replacement = serde_json::from_str::<Request>(
548            r#"{"method":"prepare_replacement","params":{"target_binary_id":"0.20.0:1:2:3:4"}}"#,
549        )
550        .unwrap();
551        assert!(matches!(replacement, Request::PrepareReplacement { .. }));
552
553        let lifecycle = serde_json::from_str::<Response>(
554            r#"{"type":"lifecycle","data":{"protocol":8,"epoch":7,"binary_id":"0.20.0:1:2:3:4","started_unix_ms":11,"phase":"replacement_pending","live_runtimes":2,"active_clients":1,"recovered_from_backup":false,"replacement_target":"0.21.0:1:2:3:4"}}"#,
555        )
556        .unwrap();
557        assert!(matches!(
558            lifecycle,
559            Response::Lifecycle(DaemonLifecycle {
560                binary_id,
561                version,
562                daemon_revision: 0,
563                started_unix_ms: 11,
564                active_tuis: 0,
565                replacement_target,
566                replacement_target_version,
567                replacement_blockers,
568                ..
569            }) if binary_id == "0.20.0:1:2:3:4"
570                && version.is_empty()
571                && replacement_target.as_deref() == Some("0.21.0:1:2:3:4")
572                && replacement_target_version.is_empty()
573                && replacement_blockers.is_empty()
574        ));
575
576        let response = serde_json::from_str::<Response>(
577            r#"{"type":"replacement","data":{"disposition":"deferred","live_runtimes":2}}"#,
578        )
579        .unwrap();
580        assert!(matches!(
581            response,
582            Response::Replacement {
583                daemon_version,
584                target_version,
585                blockers,
586                use_current_daemon: false,
587                ..
588            } if daemon_version.is_empty() && target_version.is_empty() && blockers.is_empty()
589        ));
590    }
591
592    #[test]
593    fn lifecycle_control_is_additive_and_tagged() {
594        let request = Request::PrepareReplacement {
595            target_binary_id: "0.22.0:1:2:3:4".into(),
596            target_daemon_revision: DAEMON_REVISION,
597        };
598        let encoded = serde_json::to_string(&request).unwrap();
599        assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), request);
600
601        let handoff = Request::PrepareHandoff {
602            target_binary_id: "0.25.0:1:2:3:4".into(),
603            target_version: "0.25.0".into(),
604            target_protocol: PROTOCOL_VERSION,
605            target_daemon_revision: DAEMON_REVISION,
606            executable: "/usr/local/bin/wsxd".into(),
607        };
608        let encoded = serde_json::to_string(&handoff).unwrap();
609        assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), handoff);
610
611        let response = Response::Lifecycle(DaemonLifecycle {
612            protocol: PROTOCOL_VERSION,
613            epoch: 7,
614            binary_id: "0.21.0:1:2:3:4".into(),
615            version: "0.21.0".into(),
616            daemon_revision: DAEMON_REVISION,
617            started_unix_ms: 11,
618            phase: DaemonPhase::ReplacementPending,
619            live_runtimes: 2,
620            active_clients: 1,
621            active_tuis: 1,
622            recovered_from_backup: false,
623            replacement_target: Some("0.22.0:1:2:3:4".into()),
624            replacement_target_version: "0.22.0".into(),
625            replacement_blockers: vec![ReplacementBlocker::WorkingAgent],
626        });
627        let encoded = serde_json::to_string(&response).unwrap();
628        assert_eq!(
629            serde_json::from_str::<Response>(&encoded).unwrap(),
630            response
631        );
632    }
633
634    #[test]
635    fn legacy_snapshot_defaults_missing_foreground_job_metadata() {
636        let response = serde_json::from_str::<Response>(
637            r#"{"type":"snapshot","data":{"protocol":8,"epoch":1,"revision":1,"projects":[],"worktrees":[],"sessions":[],"panes":[],"capabilities":{}}}"#,
638        )
639        .unwrap();
640
641        let Response::Snapshot(snapshot) = response else {
642            panic!("expected snapshot response");
643        };
644        assert!(snapshot.pane_activity.is_empty());
645        assert!(!snapshot.capabilities.foreground_jobs);
646        assert!(!snapshot.capabilities.live_handoff);
647    }
648
649    #[test]
650    fn legacy_agent_report_defaults_missing_session_reference() {
651        let request = serde_json::from_str::<Request>(
652            r#"{"method":"agent_report","params":{"pane_id":1,"provider":"pi","state":"idle","conversation_id":"legacy","capabilities":{}}}"#,
653        )
654        .unwrap();
655
656        let Request::AgentReport {
657            session_ref,
658            runtime_generation,
659            capabilities,
660            ..
661        } = request
662        else {
663            panic!("expected agent report request");
664        };
665        assert_eq!(session_ref, None);
666        assert_eq!(runtime_generation, None);
667        assert!(!capabilities.escape_interrupts);
668    }
669
670    #[test]
671    fn legacy_session_create_defaults_missing_initial_input() {
672        let request = serde_json::from_str::<Request>(
673            r#"{"method":"session_create","params":{"worktree_id":1,"label":"legacy","command":[],"rows":24,"cols":80}}"#,
674        )
675        .unwrap();
676
677        let Request::SessionCreate { initial_input, .. } = request else {
678            panic!("expected session create request");
679        };
680        assert_eq!(initial_input, None);
681    }
682
683    #[test]
684    fn clipboard_write_is_a_distinct_ephemeral_stream_message() {
685        let bytes =
686            encode_line(&TerminalServerMessage::ClipboardWrite(b"copied".to_vec())).unwrap();
687        assert_eq!(
688            String::from_utf8(bytes).unwrap(),
689            "{\"type\":\"clipboard_write\",\"data\":[99,111,112,105,101,100]}\n"
690        );
691    }
692
693    #[test]
694    fn legacy_terminal_wire_defaults_selection_and_pointer_bounds() {
695        let full = serde_json::from_str::<TerminalUpdate>(
696            r#"{"kind":"full","data":{"pane_id":1,"terminal_id":2,"revision":3,"cols":1,"rows":1,"cells":[["x",null,null,0,0]],"cursor":{"x":0,"y":0,"visible":false,"blinking":false,"shape":0}}}"#,
697        )
698        .unwrap();
699        let TerminalUpdate::Full(full) = full else {
700            panic!("expected full terminal update");
701        };
702        assert!(full.selection.is_empty());
703
704        let patch = serde_json::from_str::<TerminalUpdate>(
705            r#"{"kind":"patch","data":{"pane_id":1,"terminal_id":2,"base_revision":3,"revision":4,"cols":1,"rows":1,"changed_rows":[],"cursor":{"x":0,"y":0,"visible":false,"blinking":false,"shape":0}}}"#,
706        )
707        .unwrap();
708        let TerminalUpdate::Patch { selection, .. } = patch else {
709            panic!("expected terminal patch");
710        };
711        assert!(selection.is_empty());
712
713        let mouse = serde_json::from_str::<MouseEvent>(
714            r#"{"action":"release","button":"left","x":0,"y":0,"shift":false,"control":false,"alt":false,"super_key":false}"#,
715        )
716        .unwrap();
717        assert!(mouse.in_bounds);
718    }
719
720    #[test]
721    fn full_terminal_baseline_stays_within_compact_size_budget() {
722        let cell = Cell {
723            symbol: " ".into(),
724            fg: Some([220, 220, 220]),
725            bg: Some([8, 9, 11]),
726            ..Cell::default()
727        };
728        let message = TerminalServerMessage::Update(TerminalUpdate::Full(TerminalFrame {
729            pane_id: PaneId(1),
730            terminal_id: TerminalId(2),
731            revision: 1,
732            cols: 120,
733            rows: 40,
734            cells: vec![cell; 120 * 40],
735            cursor: Cursor {
736                x: 0,
737                y: 0,
738                visible: true,
739                blinking: false,
740                shape: 0,
741            },
742            selection: Vec::new(),
743        }));
744        let bytes = encode_line(&message).unwrap();
745        assert!(
746            bytes.len() < 256 * 1024,
747            "baseline was {} bytes",
748            bytes.len()
749        );
750    }
751}