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
12pub 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");
20pub 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}