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 = 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}