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