1use serde::{ser::SerializeTuple, Deserialize, Deserializer, Serialize, Serializer};
2use std::path::PathBuf;
3
4macro_rules! id_type {
5 ($name:ident) => {
6 #[derive(
7 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
8 )]
9 #[serde(transparent)]
10 pub struct $name(pub u64);
11 impl std::fmt::Display for $name {
12 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13 self.0.fmt(f)
14 }
15 }
16 impl std::str::FromStr for $name {
17 type Err = std::num::ParseIntError;
18 fn from_str(value: &str) -> Result<Self, Self::Err> {
19 value.parse().map(Self)
20 }
21 }
22 };
23}
24
25id_type!(ProjectId);
26id_type!(WorktreeId);
27id_type!(SessionId);
28id_type!(PaneId);
29id_type!(TerminalId);
30id_type!(AgentInstanceId);
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct ProjectSpec {
34 pub path: PathBuf,
35 pub name: String,
36 pub worktrees: Vec<WorktreeSpec>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct WorktreeSpec {
41 pub path: PathBuf,
42 pub branch: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Project {
47 pub id: ProjectId,
48 pub path: PathBuf,
49 pub name: String,
50 pub revision: u64,
51 #[serde(default)]
52 pub last_agent_active_unix_ms: Option<u64>,
53 #[serde(default)]
54 pub last_terminal_active_unix_ms: Option<u64>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Worktree {
59 pub id: WorktreeId,
60 pub project_id: ProjectId,
61 pub path: PathBuf,
62 pub branch: String,
63 pub revision: u64,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum SplitAxis {
69 Horizontal,
70 Vertical,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "kind", rename_all = "snake_case")]
75pub enum PaneLayout {
76 Leaf {
77 pane_id: PaneId,
78 },
79 Split {
80 axis: SplitAxis,
81 ratio_millis: u16,
82 first: Box<PaneLayout>,
83 second: Box<PaneLayout>,
84 },
85}
86
87impl PaneLayout {
88 pub fn panes(&self, output: &mut Vec<PaneId>) {
89 match self {
90 Self::Leaf { pane_id } => output.push(*pane_id),
91 Self::Split { first, second, .. } => {
92 first.panes(output);
93 second.panes(output);
94 }
95 }
96 }
97
98 pub fn split(&mut self, target: PaneId, pane_id: PaneId, axis: SplitAxis) -> bool {
99 match self {
100 Self::Leaf { pane_id: current } if *current == target => {
101 *self = Self::Split {
102 axis,
103 ratio_millis: 500,
104 first: Box::new(Self::Leaf { pane_id: target }),
105 second: Box::new(Self::Leaf { pane_id }),
106 };
107 true
108 }
109 Self::Split { first, second, .. } => {
110 first.split(target, pane_id, axis) || second.split(target, pane_id, axis)
111 }
112 Self::Leaf { .. } => false,
113 }
114 }
115
116 pub fn remove(&mut self, target: PaneId) -> bool {
117 match self {
118 Self::Leaf { .. } => false,
119 Self::Split { first, second, .. } => {
120 if matches!(first.as_ref(), Self::Leaf { pane_id } if *pane_id == target) {
121 *self = (**second).clone();
122 true
123 } else if matches!(second.as_ref(), Self::Leaf { pane_id } if *pane_id == target) {
124 *self = (**first).clone();
125 true
126 } else {
127 first.remove(target) || second.remove(target)
128 }
129 }
130 }
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum SessionPlacement {
137 Before,
138 After,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct Session {
143 pub id: SessionId,
144 pub worktree_id: WorktreeId,
145 pub label: String,
146 pub primary_pane: PaneId,
147 pub focused_pane: PaneId,
148 pub panes: Vec<PaneId>,
149 pub layout: PaneLayout,
150 pub revision: u64,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
154#[serde(rename_all = "snake_case")]
155pub enum AgentState {
156 #[default]
157 Unknown,
158 Idle,
159 Working,
160 Blocked,
161 Done,
162 Error,
163}
164
165const MAX_AGENT_SESSION_ID_BYTES: usize = 512;
166const MAX_AGENT_SESSION_PATH_BYTES: usize = 4096;
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum AgentSessionRefKind {
171 Id,
172 Path,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
176pub struct AgentSessionRef {
177 pub kind: AgentSessionRefKind,
178 pub value: String,
179}
180
181impl AgentSessionRef {
182 pub fn id(value: impl Into<String>) -> Option<Self> {
183 let value = value.into();
184 (valid_agent_session_value(&value, MAX_AGENT_SESSION_ID_BYTES) && !value.starts_with('-'))
185 .then_some(Self {
186 kind: AgentSessionRefKind::Id,
187 value,
188 })
189 }
190
191 pub fn path(value: impl Into<String>) -> Option<Self> {
192 let value = value.into();
193 (valid_agent_session_value(&value, MAX_AGENT_SESSION_PATH_BYTES)
194 && PathBuf::from(&value).is_absolute())
195 .then_some(Self {
196 kind: AgentSessionRefKind::Path,
197 value,
198 })
199 }
200}
201
202fn valid_agent_session_value(value: &str, max_bytes: usize) -> bool {
203 !value.is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control)
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
207#[serde(default)]
208pub struct AgentCapabilities {
209 pub prompt: bool,
210 pub resume: bool,
211 pub lifecycle: bool,
212 pub escape_interrupts: bool,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct AgentInfo {
217 pub id: AgentInstanceId,
218 pub provider: String,
219 pub state: AgentState,
220 #[serde(default)]
221 pub conversation_id: Option<String>,
222 #[serde(default)]
223 pub session_ref: Option<AgentSessionRef>,
224 pub capabilities: AgentCapabilities,
225 pub source: String,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct Pane {
230 pub id: PaneId,
231 pub terminal_id: TerminalId,
232 pub session_id: SessionId,
233 pub label: String,
234 pub agent: Option<AgentInfo>,
235 pub exited: bool,
236 pub revision: u64,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct PanePorts {
241 pub pane_id: PaneId,
242 pub tcp: Vec<u16>,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246pub struct PaneActivity {
247 pub pane_id: PaneId,
248 pub foreground_job: bool,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct Snapshot {
253 pub protocol: u32,
254 pub epoch: u64,
255 pub revision: u64,
256 pub projects: Vec<Project>,
257 pub worktrees: Vec<Worktree>,
258 pub sessions: Vec<Session>,
259 pub panes: Vec<Pane>,
260 #[serde(default, skip_serializing_if = "Vec::is_empty")]
261 pub listening_ports: Vec<PanePorts>,
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub pane_activity: Vec<PaneActivity>,
264 #[serde(default, skip_serializing_if = "Vec::is_empty")]
265 pub plugin_sidecars: Vec<PluginSidecarDescriptor>,
266 #[serde(default)]
267 pub capabilities: Capabilities,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
271#[serde(default)]
272pub struct Capabilities {
273 pub pane_splits: bool,
274 pub plugins: bool,
275 pub plugin_views: bool,
276 pub worktree_review_available: bool,
277 pub agent_reports: bool,
278 pub agent_session_restore: bool,
279 pub resume_shell_fallback: bool,
280 pub listening_ports: bool,
281 pub foreground_jobs: bool,
282 pub process_restore: bool,
283 pub lifecycle_coordination: bool,
284 pub version_coordination: bool,
285 pub daemon_revision_coordination: bool,
286 pub live_handoff: bool,
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
290#[serde(rename_all = "snake_case")]
291pub enum DaemonPhase {
292 #[default]
293 Ready,
294 ReplacementPending,
295 Stopping,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct DaemonLifecycle {
300 pub protocol: u32,
301 pub epoch: u64,
302 pub binary_id: String,
303 #[serde(default)]
304 pub version: String,
305 #[serde(default)]
306 pub daemon_revision: u32,
307 pub started_unix_ms: u64,
308 pub phase: DaemonPhase,
309 pub live_runtimes: usize,
310 pub active_clients: usize,
311 #[serde(default)]
312 pub active_tuis: usize,
313 pub recovered_from_backup: bool,
314 #[serde(default)]
315 pub replacement_target: Option<String>,
316 #[serde(default)]
317 pub replacement_target_version: String,
318 #[serde(default)]
319 pub replacement_blockers: Vec<ReplacementBlocker>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323pub struct TuiClientPresence {
324 pub instance_id: u64,
325 pub version: String,
326 pub target_binary_id: String,
327 #[serde(default)]
328 pub target_daemon_revision: u32,
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum ReplacementDisposition {
334 Deferred,
335 Stopping,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum ReplacementBlocker {
341 OtherTui,
342 WorkingAgent,
343 ListenerScanPending,
344 ForegroundJob,
345 ListeningPort,
346 LegacyDaemon,
347 PendingTarget,
348 HandoffUnavailable,
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
352pub struct CellModifiers {
353 pub bold: bool,
354 pub italic: bool,
355 pub underline: bool,
356 pub inverse: bool,
357 pub dim: bool,
358 pub strike: bool,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
362#[serde(rename_all = "snake_case")]
363pub enum CellWidth {
364 #[default]
365 Narrow,
366 Wide,
367 SpacerHead,
368 SpacerTail,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Default)]
372pub struct Cell {
373 pub symbol: String,
374 pub fg: Option<[u8; 3]>,
375 pub bg: Option<[u8; 3]>,
376 pub modifiers: CellModifiers,
377 pub width: CellWidth,
378}
379
380impl CellModifiers {
381 fn bits(self) -> u8 {
382 u8::from(self.bold)
383 | (u8::from(self.italic) << 1)
384 | (u8::from(self.underline) << 2)
385 | (u8::from(self.inverse) << 3)
386 | (u8::from(self.dim) << 4)
387 | (u8::from(self.strike) << 5)
388 }
389
390 fn from_bits(bits: u8) -> Result<Self, &'static str> {
391 if bits & !0x3f != 0 {
392 return Err("terminal cell modifier bits are invalid");
393 }
394 Ok(Self {
395 bold: bits & 1 != 0,
396 italic: bits & 2 != 0,
397 underline: bits & 4 != 0,
398 inverse: bits & 8 != 0,
399 dim: bits & 16 != 0,
400 strike: bits & 32 != 0,
401 })
402 }
403}
404
405impl CellWidth {
406 fn code(self) -> u8 {
407 match self {
408 Self::Narrow => 0,
409 Self::Wide => 1,
410 Self::SpacerHead => 2,
411 Self::SpacerTail => 3,
412 }
413 }
414
415 fn from_code(code: u8) -> Result<Self, &'static str> {
416 match code {
417 0 => Ok(Self::Narrow),
418 1 => Ok(Self::Wide),
419 2 => Ok(Self::SpacerHead),
420 3 => Ok(Self::SpacerTail),
421 _ => Err("terminal cell width code is invalid"),
422 }
423 }
424}
425
426impl Serialize for Cell {
427 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
428 where
429 S: Serializer,
430 {
431 let mut tuple = serializer.serialize_tuple(5)?;
432 tuple.serialize_element(&self.symbol)?;
433 tuple.serialize_element(&self.fg)?;
434 tuple.serialize_element(&self.bg)?;
435 tuple.serialize_element(&self.modifiers.bits())?;
436 tuple.serialize_element(&self.width.code())?;
437 tuple.end()
438 }
439}
440
441impl<'de> Deserialize<'de> for Cell {
442 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
443 where
444 D: Deserializer<'de>,
445 {
446 let (symbol, fg, bg, modifier_bits, width_code) =
447 <(String, Option<[u8; 3]>, Option<[u8; 3]>, u8, u8)>::deserialize(deserializer)?;
448 Ok(Self {
449 symbol,
450 fg,
451 bg,
452 modifiers: CellModifiers::from_bits(modifier_bits).map_err(serde::de::Error::custom)?,
453 width: CellWidth::from_code(width_code).map_err(serde::de::Error::custom)?,
454 })
455 }
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
459pub struct Cursor {
460 pub x: u16,
461 pub y: u16,
462 pub visible: bool,
463 pub blinking: bool,
464 pub shape: u8,
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
470pub struct TerminalSelectionRange {
471 pub row: u16,
472 pub start_col: u16,
473 pub end_col: u16,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub struct TerminalFrame {
478 pub pane_id: PaneId,
479 pub terminal_id: TerminalId,
480 pub revision: u64,
481 pub cols: u16,
482 pub rows: u16,
483 pub cells: Vec<Cell>,
484 pub cursor: Cursor,
485 #[serde(default)]
486 pub selection: Vec<TerminalSelectionRange>,
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
490pub struct TerminalRowPatch {
491 pub row: u16,
492 pub cells: Vec<Cell>,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
497pub enum TerminalUpdate {
498 Full(TerminalFrame),
499 Patch {
500 pane_id: PaneId,
501 terminal_id: TerminalId,
502 base_revision: u64,
503 revision: u64,
504 cols: u16,
505 rows: u16,
506 changed_rows: Vec<TerminalRowPatch>,
507 cursor: Cursor,
508 #[serde(default)]
509 selection: Vec<TerminalSelectionRange>,
510 },
511}
512
513impl TerminalUpdate {
514 pub fn identity(&self) -> (PaneId, TerminalId) {
515 match self {
516 Self::Full(frame) => (frame.pane_id, frame.terminal_id),
517 Self::Patch {
518 pane_id,
519 terminal_id,
520 ..
521 } => (*pane_id, *terminal_id),
522 }
523 }
524
525 pub fn revision(&self) -> u64 {
526 match self {
527 Self::Full(frame) => frame.revision,
528 Self::Patch { revision, .. } => *revision,
529 }
530 }
531
532 pub fn apply_to(self, frame: &mut Option<TerminalFrame>) -> Result<(), &'static str> {
533 let (
534 pane_id,
535 terminal_id,
536 base_revision,
537 revision,
538 cols,
539 rows,
540 changed_rows,
541 cursor,
542 selection,
543 ) = match self {
544 Self::Full(full) => {
545 if full.rows == 0
546 || full.cols == 0
547 || full.cells.len() != usize::from(full.cols) * usize::from(full.rows)
548 || !valid_terminal_selection(&full.selection, full.rows, full.cols)
549 {
550 return Err("terminal full frame dimensions are invalid");
551 }
552 *frame = Some(full);
553 return Ok(());
554 }
555 Self::Patch {
556 pane_id,
557 terminal_id,
558 base_revision,
559 revision,
560 cols,
561 rows,
562 changed_rows,
563 cursor,
564 selection,
565 } => (
566 pane_id,
567 terminal_id,
568 base_revision,
569 revision,
570 cols,
571 rows,
572 changed_rows,
573 cursor,
574 selection,
575 ),
576 };
577 if rows == 0
578 || cols == 0
579 || changed_rows.len() > usize::from(rows)
580 || !valid_terminal_selection(&selection, rows, cols)
581 {
582 return Err("terminal patch dimensions are invalid");
583 }
584 let current = frame.as_mut().ok_or("terminal patch has no baseline")?;
585 if current.pane_id != pane_id
586 || current.terminal_id != terminal_id
587 || current.revision != base_revision
588 || current.cols != cols
589 || current.rows != rows
590 || current.cells.len() != usize::from(cols) * usize::from(rows)
591 {
592 return Err("terminal patch baseline does not match");
593 }
594 for patch in changed_rows {
595 if patch.row >= rows || patch.cells.len() != usize::from(cols) {
596 return Err("terminal patch row is invalid");
597 }
598 let start = usize::from(patch.row) * usize::from(cols);
599 current.cells[start..start + usize::from(cols)].clone_from_slice(&patch.cells);
600 }
601 current.revision = revision;
602 current.cursor = cursor;
603 current.selection = selection;
604 Ok(())
605 }
606}
607
608fn valid_terminal_selection(selection: &[TerminalSelectionRange], rows: u16, cols: u16) -> bool {
609 selection.len() <= usize::from(rows)
610 && selection.iter().all(|range| {
611 range.row < rows && range.start_col <= range.end_col && range.end_col < cols
612 })
613 && selection.windows(2).all(|pair| pair[0].row < pair[1].row)
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
617#[serde(rename_all = "snake_case")]
618pub enum KeyCode {
619 Text,
620 Enter,
621 Backspace,
622 Tab,
623 Escape,
624 Insert,
625 Delete,
626 Home,
627 End,
628 PageUp,
629 PageDown,
630 Left,
631 Right,
632 Up,
633 Down,
634 Function(u8),
635}
636
637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
638pub struct KeyEvent {
639 pub code: KeyCode,
640 pub text: String,
641 pub shift: bool,
642 pub control: bool,
643 pub alt: bool,
644 pub super_key: bool,
645 pub repeat: bool,
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(rename_all = "snake_case")]
650pub enum MouseAction {
651 Press,
652 Release,
653 Motion,
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
657#[serde(rename_all = "snake_case")]
658pub enum MouseButton {
659 Left,
660 Middle,
661 Right,
662 WheelUp,
663 WheelDown,
664 WheelLeft,
665 WheelRight,
666 None,
667}
668
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
670pub struct MouseEvent {
671 pub action: MouseAction,
672 pub button: MouseButton,
673 pub x: u16,
674 pub y: u16,
675 #[serde(default = "default_true")]
676 pub in_bounds: bool,
677 pub shift: bool,
678 pub control: bool,
679 pub alt: bool,
680 pub super_key: bool,
681}
682
683const fn default_true() -> bool {
684 true
685}
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
688#[serde(rename_all = "snake_case")]
689pub enum PluginSurface {
690 TerminalRight,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694pub struct PluginSidecarSpec {
695 pub surface: PluginSurface,
696 pub priority: i32,
697 pub minimum_columns: u16,
698 pub preferred_width: u16,
699 pub refresh_ms: u64,
700}
701
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703pub struct PluginSidecarDescriptor {
704 pub plugin_id: String,
705 pub title: String,
706 pub spec: PluginSidecarSpec,
707}
708
709#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
710#[serde(rename_all = "snake_case")]
711pub enum PluginTone {
712 #[default]
713 Normal,
714 Muted,
715 Accent,
716 Success,
717 Warning,
718 Error,
719}
720
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722pub struct PluginViewRow {
723 pub badge: String,
724 pub primary: String,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub secondary: Option<String>,
727 pub value: String,
728 #[serde(default)]
729 pub tone: PluginTone,
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
733pub struct PluginViewPayload {
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub empty: Option<String>,
736 #[serde(default)]
737 pub rows: Vec<PluginViewRow>,
738 #[serde(default, skip_serializing_if = "is_zero")]
739 pub remaining: usize,
740}
741
742fn is_zero(value: &usize) -> bool {
743 *value == 0
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
747pub struct PluginSidecarView {
748 pub plugin_id: String,
749 pub title: String,
750 pub epoch: u64,
751 pub pane_id: PaneId,
752 pub worktree_id: WorktreeId,
753 pub generation: u64,
754 pub payload: PluginViewPayload,
755}
756
757#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
758pub struct PluginManifest {
759 pub api_version: u32,
760 pub id: String,
761 pub name: String,
762 pub command: Vec<String>,
763 pub events: Vec<String>,
764 pub enabled: bool,
765 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub sidecar: Option<PluginSidecarSpec>,
767 #[serde(default, skip_serializing_if = "Option::is_none")]
768 pub worktree_review: Option<super::ReviewSpec>,
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774
775 #[test]
776 fn legacy_plugin_manifest_without_sidecar_deserializes() {
777 let manifest: PluginManifest = serde_json::from_str(
778 r#"{"api_version":1,"id":"legacy","name":"Legacy","command":["/plugin"],"events":["session.created"],"enabled":true}"#,
779 )
780 .unwrap();
781 assert!(manifest.sidecar.is_none());
782 assert!(manifest.worktree_review.is_none());
783 }
784
785 #[test]
786 fn legacy_snapshot_without_plugin_sidecars_deserializes() {
787 let snapshot: Snapshot = serde_json::from_str(
788 r#"{"protocol":11,"epoch":1,"revision":2,"projects":[],"worktrees":[],"sessions":[],"panes":[],"capabilities":{}}"#,
789 )
790 .unwrap();
791 assert!(snapshot.plugin_sidecars.is_empty());
792 assert!(!snapshot.capabilities.plugin_views);
793 }
794
795 #[test]
796 fn old_project_json_without_activity_deserializes() {
797 let project: Project =
798 serde_json::from_str(r#"{"id":1,"path":"/repo","name":"repo","revision":2}"#).unwrap();
799 assert_eq!(project.last_agent_active_unix_ms, None);
800 assert_eq!(project.last_terminal_active_unix_ms, None);
801 }
802
803 #[test]
804 fn split_and_remove_preserve_pane_identity() {
805 let mut layout = PaneLayout::Leaf { pane_id: PaneId(1) };
806 assert!(layout.split(PaneId(1), PaneId(2), SplitAxis::Vertical));
807 let mut panes = Vec::new();
808 layout.panes(&mut panes);
809 assert_eq!(panes, vec![PaneId(1), PaneId(2)]);
810 assert!(layout.remove(PaneId(1)));
811 assert_eq!(layout, PaneLayout::Leaf { pane_id: PaneId(2) });
812 }
813
814 #[test]
815 fn terminal_cell_compact_wire_round_trips_width_and_style() {
816 let cell = Cell {
817 symbol: "界".into(),
818 fg: Some([1, 2, 3]),
819 bg: Some([4, 5, 6]),
820 modifiers: CellModifiers {
821 bold: true,
822 underline: true,
823 ..CellModifiers::default()
824 },
825 width: CellWidth::Wide,
826 };
827 let encoded = serde_json::to_string(&cell).unwrap();
828 assert_eq!(encoded, r#"["界",[1,2,3],[4,5,6],5,1]"#);
829 assert_eq!(serde_json::from_str::<Cell>(&encoded).unwrap(), cell);
830 assert!(serde_json::from_str::<Cell>(r#"["x",null,null,64,0]"#).is_err());
831 assert!(serde_json::from_str::<Cell>(r#"["x",null,null,0,4]"#).is_err());
832 }
833
834 #[test]
835 fn terminal_full_frame_rejects_invalid_cell_count() {
836 let mut baseline = None;
837 assert!(TerminalUpdate::Full(TerminalFrame {
838 pane_id: PaneId(1),
839 terminal_id: TerminalId(2),
840 revision: 1,
841 cols: 2,
842 rows: 2,
843 cells: vec![Cell::default(); 3],
844 cursor: Cursor {
845 x: 0,
846 y: 0,
847 visible: false,
848 blinking: false,
849 shape: 0,
850 },
851 selection: Vec::new(),
852 })
853 .apply_to(&mut baseline)
854 .is_err());
855 assert!(baseline.is_none());
856
857 assert!(TerminalUpdate::Full(TerminalFrame {
858 pane_id: PaneId(1),
859 terminal_id: TerminalId(2),
860 revision: 1,
861 cols: 2,
862 rows: 2,
863 cells: vec![Cell::default(); 4],
864 cursor: Cursor {
865 x: 0,
866 y: 0,
867 visible: false,
868 blinking: false,
869 shape: 0,
870 },
871 selection: vec![TerminalSelectionRange {
872 row: 0,
873 start_col: 0,
874 end_col: 2,
875 }],
876 })
877 .apply_to(&mut baseline)
878 .is_err());
879 assert!(baseline.is_none());
880 }
881
882 #[test]
883 fn terminal_patch_requires_and_updates_the_exact_baseline() {
884 let cursor = Cursor {
885 x: 0,
886 y: 0,
887 visible: true,
888 blinking: false,
889 shape: 0,
890 };
891 let mut frame = Some(TerminalFrame {
892 pane_id: PaneId(1),
893 terminal_id: TerminalId(2),
894 revision: 3,
895 cols: 2,
896 rows: 2,
897 cells: vec![Cell::default(); 4],
898 cursor,
899 selection: Vec::new(),
900 });
901 TerminalUpdate::Patch {
902 pane_id: PaneId(1),
903 terminal_id: TerminalId(2),
904 base_revision: 3,
905 revision: 4,
906 cols: 2,
907 rows: 2,
908 changed_rows: vec![TerminalRowPatch {
909 row: 1,
910 cells: vec![
911 Cell {
912 symbol: "x".into(),
913 ..Cell::default()
914 },
915 Cell::default(),
916 ],
917 }],
918 cursor: Cursor {
919 x: 1,
920 y: 1,
921 ..cursor
922 },
923 selection: vec![TerminalSelectionRange {
924 row: 1,
925 start_col: 0,
926 end_col: 1,
927 }],
928 }
929 .apply_to(&mut frame)
930 .unwrap();
931 let frame = frame.unwrap();
932 assert_eq!(frame.revision, 4);
933 assert_eq!(frame.cells[2].symbol, "x");
934 assert_eq!((frame.cursor.x, frame.cursor.y), (1, 1));
935 assert_eq!(frame.selection.len(), 1);
936
937 let mut frame = Some(frame);
938 assert!(TerminalUpdate::Patch {
939 pane_id: PaneId(1),
940 terminal_id: TerminalId(2),
941 base_revision: 3,
942 revision: 5,
943 cols: 2,
944 rows: 2,
945 changed_rows: vec![],
946 cursor,
947 selection: Vec::new(),
948 }
949 .apply_to(&mut frame)
950 .is_err());
951 }
952}