Skip to main content

wsx_core/runtime/
domain.rs

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// ^ [[Terminal Presentation and Latency]] Range changes must stay aligned with
468// Ghostty projection, legacy protocol defaults, daemon lease cleanup, and TUI rendering.
469#[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}