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