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 = 14;
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 = 4;
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    ConversationCreate {
299        session_id: SessionId,
300        provider: String,
301        expected_revision: u64,
302    },
303    ConversationSend {
304        conversation_id: ConversationId,
305        mode: ConversationInputMode,
306        text: String,
307        #[serde(default)]
308        attachments: Vec<ConversationAttachment>,
309    },
310    ConversationAbort {
311        conversation_id: ConversationId,
312    },
313    ConversationEvents {
314        conversation_id: ConversationId,
315        after_cursor: u64,
316        limit: u16,
317    },
318    ConversationRespond {
319        conversation_id: ConversationId,
320        interaction_id: String,
321        response: ConversationInteractionResponse,
322    },
323    ConversationSetModel {
324        conversation_id: ConversationId,
325        provider: String,
326        model: String,
327        thinking_level: String,
328    },
329    ConversationCommands {
330        conversation_id: ConversationId,
331    },
332    PluginList,
333    PluginReload,
334    PluginReviewCancel {
335        request_id: String,
336    },
337    PluginReview {
338        plugin_id: String,
339        worktree_id: WorktreeId,
340        request_id: String,
341        comparison: super::ReviewComparison,
342        limits: super::ReviewLimits,
343        operation: super::ReviewOperation,
344    },
345    PluginRender {
346        plugin_id: String,
347        pane_id: PaneId,
348        columns: u16,
349        rows: u16,
350        generation: u64,
351    },
352    LifecycleStatus,
353    PrepareReplacement {
354        target_binary_id: String,
355        #[serde(default)]
356        target_daemon_revision: u32,
357    },
358    Shutdown,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(tag = "type", content = "data", rename_all = "snake_case")]
363pub enum Event {
364    Changed {
365        revision: u64,
366        entity: String,
367        id: u64,
368    },
369    Exited {
370        revision: u64,
371        pane_id: PaneId,
372    },
373    ResyncRequired {
374        revision: u64,
375    },
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379pub struct ApiError {
380    pub code: String,
381    pub message: String,
382}
383
384impl ApiError {
385    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
386        Self {
387            code: code.into(),
388            message: message.into(),
389        }
390    }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(tag = "type", content = "data", rename_all = "snake_case")]
395pub enum Response {
396    Hello {
397        protocol: u32,
398        epoch: u64,
399        #[serde(default)]
400        capabilities: Capabilities,
401    },
402    Snapshot(Snapshot),
403    View {
404        snapshot: Snapshot,
405        frames: Vec<TerminalFrame>,
406    },
407    Events {
408        revision: u64,
409        events: Vec<Event>,
410    },
411    ConversationEvents {
412        conversation_id: ConversationId,
413        next_cursor: u64,
414        events: Vec<ConversationEvent>,
415    },
416    ConversationCommands(Vec<ConversationCommand>),
417    Plugins(Vec<PluginManifest>),
418    PluginView(PluginSidecarView),
419    PluginReview {
420        epoch: u64,
421        response: super::ReviewResponse,
422    },
423    Lifecycle(DaemonLifecycle),
424    Replacement {
425        disposition: ReplacementDisposition,
426        live_runtimes: usize,
427        #[serde(default)]
428        daemon_version: String,
429        #[serde(default)]
430        target_version: String,
431        #[serde(default)]
432        blockers: Vec<ReplacementBlocker>,
433        #[serde(default)]
434        use_current_daemon: bool,
435    },
436    Created {
437        revision: u64,
438        id: u64,
439    },
440    Ack {
441        revision: u64,
442    },
443    Error(ApiError),
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(tag = "type", content = "data", rename_all = "snake_case")]
448pub enum TerminalClientMessage {
449    Key(KeyEvent),
450    Paste(String),
451    Mouse(MouseEvent),
452    Input(Vec<u8>),
453    Resize { rows: u16, cols: u16 },
454    Heartbeat,
455    Resync,
456    Detach,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460#[serde(tag = "type", content = "data", rename_all = "snake_case")]
461pub enum TerminalServerMessage {
462    Update(TerminalUpdate),
463    ClipboardWrite(Vec<u8>),
464    Error(ApiError),
465    Exited,
466}
467
468pub fn encode_line<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
469    let mut bytes = serde_json::to_vec(value)?;
470    bytes.push(b'\n');
471    Ok(bytes)
472}
473
474pub fn default_socket_path() -> PathBuf {
475    if let Some(path) = std::env::var_os("WSX_SOCKET") {
476        return PathBuf::from(path);
477    }
478    let root = std::env::var_os("XDG_STATE_HOME")
479        .map(PathBuf::from)
480        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))
481        .unwrap_or_else(std::env::temp_dir);
482    root.join("wsx/wsx.sock")
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn protocol_is_tagged_and_line_delimited() {
491        let bytes = encode_line(&Request::Snapshot).unwrap();
492        assert_eq!(bytes.last(), Some(&b'\n'));
493        assert!(String::from_utf8(bytes).unwrap().contains("snapshot"));
494    }
495
496    #[test]
497    fn unknown_methods_including_recent_clear_are_rejected() {
498        for method in ["unknown_method", "project_recent_clear"] {
499            let json = format!(r#"{{"method":"{method}","params":{{}}}}"#);
500            assert!(serde_json::from_str::<Request>(&json).is_err(), "{method}");
501        }
502    }
503
504    #[test]
505    fn legacy_hello_defaults_capabilities_added_by_newer_protocols() {
506        let response = serde_json::from_str::<Response>(
507            r#"{"type":"hello","data":{"protocol":3,"epoch":1,"capabilities":{"pane_splits":true,"plugins":true,"agent_reports":true,"process_restore":false}}}"#,
508        )
509        .unwrap();
510
511        let Response::Hello { capabilities, .. } = response else {
512            panic!("expected hello response");
513        };
514        assert!(capabilities.pane_splits);
515        assert!(!capabilities.plugin_views);
516        assert!(!capabilities.agent_session_restore);
517        assert!(!capabilities.resume_shell_fallback);
518        assert!(!capabilities.listening_ports);
519        assert!(!capabilities.foreground_jobs);
520        assert!(!capabilities.lifecycle_coordination);
521        assert!(!capabilities.daemon_revision_coordination);
522        assert!(!capabilities.conversations);
523    }
524
525    #[test]
526    fn wsx_versions_and_builds_have_numeric_precedence() {
527        assert_eq!(
528            compare_wsx_versions("0.21.0", "0.20.9"),
529            Some(std::cmp::Ordering::Greater)
530        );
531        assert_eq!(
532            compare_binary_identities("0.21.0:1:2:3:20", "0.21.0:4:5:6:10"),
533            Some(std::cmp::Ordering::Greater)
534        );
535        assert_eq!(
536            compare_wsx_versions("0.21.0-beta.2", "0.21.0-beta.11"),
537            Some(std::cmp::Ordering::Less)
538        );
539        assert_eq!(
540            compare_wsx_versions("0.21.0-rc.1", "0.21.0"),
541            Some(std::cmp::Ordering::Less)
542        );
543        assert_eq!(
544            compare_wsx_versions("0.21.0+build.2", "0.21.0+build.1"),
545            Some(std::cmp::Ordering::Equal)
546        );
547        assert_eq!(
548            compare_wsx_versions("0.21.0-alpha.beta", "0.21.0-alpha.rc"),
549            Some(std::cmp::Ordering::Less)
550        );
551        assert_eq!(
552            compare_wsx_versions("0.21.0-alpha10", "0.21.0-alpha2"),
553            Some(std::cmp::Ordering::Less)
554        );
555        assert_eq!(compare_wsx_versions("0.21.0-01", "0.21.0"), None);
556        assert_eq!(compare_wsx_versions("", "0.21.0"), None);
557        assert_eq!(compare_wsx_versions("0.21.0", ""), None);
558        for malformed in [
559            "",
560            "malformed",
561            "0.21.0:1:2:3",
562            "0.21.0:1:2:3:10:5",
563            "0.21.0:1:2:3:not-hex",
564        ] {
565            assert_eq!(
566                compare_binary_identities(malformed, "0.21.0:1:2:3:10"),
567                None,
568                "{malformed:?} must not be a binary identity"
569            );
570        }
571    }
572
573    #[test]
574    fn legacy_lifecycle_requests_and_responses_default_version_fields() {
575        let poll = serde_json::from_str::<Request>(
576            r#"{"method":"poll","params":{"after_revision":7,"timeout_ms":1000}}"#,
577        )
578        .unwrap();
579        assert!(matches!(poll, Request::Poll { tui: None, .. }));
580
581        let replacement = serde_json::from_str::<Request>(
582            r#"{"method":"prepare_replacement","params":{"target_binary_id":"0.20.0:1:2:3:4"}}"#,
583        )
584        .unwrap();
585        assert!(matches!(replacement, Request::PrepareReplacement { .. }));
586
587        let lifecycle = serde_json::from_str::<Response>(
588            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"}}"#,
589        )
590        .unwrap();
591        assert!(matches!(
592            lifecycle,
593            Response::Lifecycle(DaemonLifecycle {
594                binary_id,
595                version,
596                daemon_revision: 0,
597                started_unix_ms: 11,
598                active_tuis: 0,
599                replacement_target,
600                replacement_target_version,
601                replacement_blockers,
602                ..
603            }) if binary_id == "0.20.0:1:2:3:4"
604                && version.is_empty()
605                && replacement_target.as_deref() == Some("0.21.0:1:2:3:4")
606                && replacement_target_version.is_empty()
607                && replacement_blockers.is_empty()
608        ));
609
610        let response = serde_json::from_str::<Response>(
611            r#"{"type":"replacement","data":{"disposition":"deferred","live_runtimes":2}}"#,
612        )
613        .unwrap();
614        assert!(matches!(
615            response,
616            Response::Replacement {
617                daemon_version,
618                target_version,
619                blockers,
620                use_current_daemon: false,
621                ..
622            } if daemon_version.is_empty() && target_version.is_empty() && blockers.is_empty()
623        ));
624    }
625
626    #[test]
627    fn lifecycle_control_is_additive_and_tagged() {
628        let request = Request::PrepareReplacement {
629            target_binary_id: "0.22.0:1:2:3:4".into(),
630            target_daemon_revision: DAEMON_REVISION,
631        };
632        let encoded = serde_json::to_string(&request).unwrap();
633        assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), request);
634
635        let response = Response::Lifecycle(DaemonLifecycle {
636            protocol: PROTOCOL_VERSION,
637            epoch: 7,
638            binary_id: "0.21.0:1:2:3:4".into(),
639            version: "0.21.0".into(),
640            daemon_revision: DAEMON_REVISION,
641            started_unix_ms: 11,
642            phase: DaemonPhase::ReplacementPending,
643            live_runtimes: 2,
644            active_clients: 1,
645            active_tuis: 1,
646            recovered_from_backup: false,
647            replacement_target: Some("0.22.0:1:2:3:4".into()),
648            replacement_target_version: "0.22.0".into(),
649            replacement_blockers: vec![ReplacementBlocker::WorkingAgent],
650        });
651        let encoded = serde_json::to_string(&response).unwrap();
652        assert_eq!(
653            serde_json::from_str::<Response>(&encoded).unwrap(),
654            response
655        );
656    }
657
658    #[test]
659    fn legacy_snapshot_defaults_missing_foreground_job_metadata() {
660        let response = serde_json::from_str::<Response>(
661            r#"{"type":"snapshot","data":{"protocol":8,"epoch":1,"revision":1,"projects":[],"worktrees":[],"sessions":[],"panes":[],"capabilities":{}}}"#,
662        )
663        .unwrap();
664
665        let Response::Snapshot(snapshot) = response else {
666            panic!("expected snapshot response");
667        };
668        assert!(snapshot.pane_activity.is_empty());
669        assert!(snapshot.conversations.is_empty());
670        assert!(!snapshot.capabilities.foreground_jobs);
671    }
672
673    #[test]
674    fn legacy_agent_report_defaults_missing_session_reference() {
675        let request = serde_json::from_str::<Request>(
676            r#"{"method":"agent_report","params":{"pane_id":1,"provider":"pi","state":"idle","conversation_id":"legacy","capabilities":{}}}"#,
677        )
678        .unwrap();
679
680        let Request::AgentReport {
681            session_ref,
682            runtime_generation,
683            capabilities,
684            ..
685        } = request
686        else {
687            panic!("expected agent report request");
688        };
689        assert_eq!(session_ref, None);
690        assert_eq!(runtime_generation, None);
691        assert!(!capabilities.escape_interrupts);
692    }
693
694    #[test]
695    fn legacy_session_create_defaults_missing_initial_input() {
696        let request = serde_json::from_str::<Request>(
697            r#"{"method":"session_create","params":{"worktree_id":1,"label":"legacy","command":[],"rows":24,"cols":80}}"#,
698        )
699        .unwrap();
700
701        let Request::SessionCreate { initial_input, .. } = request else {
702            panic!("expected session create request");
703        };
704        assert_eq!(initial_input, None);
705    }
706
707    #[test]
708    fn conversation_send_defaults_missing_attachments() {
709        let request = serde_json::from_str::<Request>(
710            r#"{"method":"conversation_send","params":{"conversation_id":1,"mode":"prompt","text":"hello"}}"#,
711        )
712        .unwrap();
713
714        let Request::ConversationSend { attachments, .. } = request else {
715            panic!("expected conversation send request");
716        };
717        assert!(attachments.is_empty());
718    }
719
720    #[test]
721    fn conversation_events_are_typed_and_cursored() {
722        let response = Response::ConversationEvents {
723            conversation_id: ConversationId(1),
724            next_cursor: 4,
725            events: vec![ConversationEvent {
726                cursor: 4,
727                conversation_id: ConversationId(1),
728                occurred_unix_ms: 9,
729                event: ConversationEventKind::Settled,
730            }],
731        };
732        let encoded = serde_json::to_string(&response).unwrap();
733        assert_eq!(
734            serde_json::from_str::<Response>(&encoded).unwrap(),
735            response
736        );
737    }
738
739    #[test]
740    fn clipboard_write_is_a_distinct_ephemeral_stream_message() {
741        let bytes =
742            encode_line(&TerminalServerMessage::ClipboardWrite(b"copied".to_vec())).unwrap();
743        assert_eq!(
744            String::from_utf8(bytes).unwrap(),
745            "{\"type\":\"clipboard_write\",\"data\":[99,111,112,105,101,100]}\n"
746        );
747    }
748
749    #[test]
750    fn legacy_terminal_wire_defaults_selection_and_pointer_bounds() {
751        let full = serde_json::from_str::<TerminalUpdate>(
752            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}}}"#,
753        )
754        .unwrap();
755        let TerminalUpdate::Full(full) = full else {
756            panic!("expected full terminal update");
757        };
758        assert!(full.selection.is_empty());
759
760        let patch = serde_json::from_str::<TerminalUpdate>(
761            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}}}"#,
762        )
763        .unwrap();
764        let TerminalUpdate::Patch { selection, .. } = patch else {
765            panic!("expected terminal patch");
766        };
767        assert!(selection.is_empty());
768
769        let mouse = serde_json::from_str::<MouseEvent>(
770            r#"{"action":"release","button":"left","x":0,"y":0,"shift":false,"control":false,"alt":false,"super_key":false}"#,
771        )
772        .unwrap();
773        assert!(mouse.in_bounds);
774    }
775
776    #[test]
777    fn full_terminal_baseline_stays_within_compact_size_budget() {
778        let cell = Cell {
779            symbol: " ".into(),
780            fg: Some([220, 220, 220]),
781            bg: Some([8, 9, 11]),
782            ..Cell::default()
783        };
784        let message = TerminalServerMessage::Update(TerminalUpdate::Full(TerminalFrame {
785            pane_id: PaneId(1),
786            terminal_id: TerminalId(2),
787            revision: 1,
788            cols: 120,
789            rows: 40,
790            cells: vec![cell; 120 * 40],
791            cursor: Cursor {
792                x: 0,
793                y: 0,
794                visible: true,
795                blinking: false,
796                shape: 0,
797            },
798            selection: Vec::new(),
799        }));
800        let bytes = encode_line(&message).unwrap();
801        assert!(
802            bytes.len() < 256 * 1024,
803            "baseline was {} bytes",
804            bytes.len()
805        );
806    }
807}