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