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
215fn default_attached() -> bool {
216    true
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220pub struct AgentInfo {
221    pub id: AgentInstanceId,
222    pub provider: String,
223    pub state: AgentState,
224    #[serde(default = "default_attached")]
225    pub attached: bool,
226    #[serde(default)]
227    pub conversation_id: Option<String>,
228    #[serde(default)]
229    pub session_ref: Option<AgentSessionRef>,
230    pub capabilities: AgentCapabilities,
231    pub source: String,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct Pane {
236    pub id: PaneId,
237    pub terminal_id: TerminalId,
238    pub session_id: SessionId,
239    pub label: String,
240    pub agent: Option<AgentInfo>,
241    pub exited: bool,
242    pub revision: u64,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct PanePorts {
247    pub pane_id: PaneId,
248    pub tcp: Vec<u16>,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252pub struct PaneActivity {
253    pub pane_id: PaneId,
254    pub foreground_job: bool,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct Snapshot {
259    pub protocol: u32,
260    pub epoch: u64,
261    pub revision: u64,
262    pub projects: Vec<Project>,
263    pub worktrees: Vec<Worktree>,
264    pub sessions: Vec<Session>,
265    pub panes: Vec<Pane>,
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub listening_ports: Vec<PanePorts>,
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub pane_activity: Vec<PaneActivity>,
270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
271    pub plugin_sidecars: Vec<PluginSidecarDescriptor>,
272    #[serde(default)]
273    pub capabilities: Capabilities,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
277#[serde(default)]
278pub struct Capabilities {
279    pub pane_splits: bool,
280    pub plugins: bool,
281    pub plugin_views: bool,
282    pub worktree_review_available: bool,
283    pub agent_reports: bool,
284    pub agent_session_restore: bool,
285    pub resume_shell_fallback: bool,
286    pub listening_ports: bool,
287    pub foreground_jobs: bool,
288    pub process_restore: bool,
289    pub lifecycle_coordination: bool,
290    pub version_coordination: bool,
291    pub daemon_revision_coordination: bool,
292    pub live_handoff: bool,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
296#[serde(rename_all = "snake_case")]
297pub enum DaemonPhase {
298    #[default]
299    Ready,
300    ReplacementPending,
301    Stopping,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct DaemonLifecycle {
306    pub protocol: u32,
307    pub epoch: u64,
308    pub binary_id: String,
309    #[serde(default)]
310    pub version: String,
311    #[serde(default)]
312    pub daemon_revision: u32,
313    pub started_unix_ms: u64,
314    pub phase: DaemonPhase,
315    pub live_runtimes: usize,
316    pub active_clients: usize,
317    #[serde(default)]
318    pub active_tuis: usize,
319    pub recovered_from_backup: bool,
320    #[serde(default)]
321    pub replacement_target: Option<String>,
322    #[serde(default)]
323    pub replacement_target_version: String,
324    #[serde(default)]
325    pub replacement_blockers: Vec<ReplacementBlocker>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct TuiClientPresence {
330    pub instance_id: u64,
331    pub version: String,
332    pub target_binary_id: String,
333    #[serde(default)]
334    pub target_daemon_revision: u32,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "snake_case")]
339pub enum ReplacementDisposition {
340    Deferred,
341    Stopping,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum ReplacementBlocker {
347    OtherTui,
348    WorkingAgent,
349    ListenerScanPending,
350    ForegroundJob,
351    ListeningPort,
352    LegacyDaemon,
353    PendingTarget,
354    HandoffUnavailable,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
358pub struct CellModifiers {
359    pub bold: bool,
360    pub italic: bool,
361    pub underline: bool,
362    pub inverse: bool,
363    pub dim: bool,
364    pub strike: bool,
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
368#[serde(rename_all = "snake_case")]
369pub enum CellWidth {
370    #[default]
371    Narrow,
372    Wide,
373    SpacerHead,
374    SpacerTail,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Default)]
378pub struct Cell {
379    pub symbol: String,
380    pub fg: Option<[u8; 3]>,
381    pub bg: Option<[u8; 3]>,
382    pub modifiers: CellModifiers,
383    pub width: CellWidth,
384}
385
386impl CellModifiers {
387    fn bits(self) -> u8 {
388        u8::from(self.bold)
389            | (u8::from(self.italic) << 1)
390            | (u8::from(self.underline) << 2)
391            | (u8::from(self.inverse) << 3)
392            | (u8::from(self.dim) << 4)
393            | (u8::from(self.strike) << 5)
394    }
395
396    fn from_bits(bits: u8) -> Result<Self, &'static str> {
397        if bits & !0x3f != 0 {
398            return Err("terminal cell modifier bits are invalid");
399        }
400        Ok(Self {
401            bold: bits & 1 != 0,
402            italic: bits & 2 != 0,
403            underline: bits & 4 != 0,
404            inverse: bits & 8 != 0,
405            dim: bits & 16 != 0,
406            strike: bits & 32 != 0,
407        })
408    }
409}
410
411impl CellWidth {
412    fn code(self) -> u8 {
413        match self {
414            Self::Narrow => 0,
415            Self::Wide => 1,
416            Self::SpacerHead => 2,
417            Self::SpacerTail => 3,
418        }
419    }
420
421    fn from_code(code: u8) -> Result<Self, &'static str> {
422        match code {
423            0 => Ok(Self::Narrow),
424            1 => Ok(Self::Wide),
425            2 => Ok(Self::SpacerHead),
426            3 => Ok(Self::SpacerTail),
427            _ => Err("terminal cell width code is invalid"),
428        }
429    }
430}
431
432impl Serialize for Cell {
433    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
434    where
435        S: Serializer,
436    {
437        let mut tuple = serializer.serialize_tuple(5)?;
438        tuple.serialize_element(&self.symbol)?;
439        tuple.serialize_element(&self.fg)?;
440        tuple.serialize_element(&self.bg)?;
441        tuple.serialize_element(&self.modifiers.bits())?;
442        tuple.serialize_element(&self.width.code())?;
443        tuple.end()
444    }
445}
446
447impl<'de> Deserialize<'de> for Cell {
448    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
449    where
450        D: Deserializer<'de>,
451    {
452        let (symbol, fg, bg, modifier_bits, width_code) =
453            <(String, Option<[u8; 3]>, Option<[u8; 3]>, u8, u8)>::deserialize(deserializer)?;
454        Ok(Self {
455            symbol,
456            fg,
457            bg,
458            modifiers: CellModifiers::from_bits(modifier_bits).map_err(serde::de::Error::custom)?,
459            width: CellWidth::from_code(width_code).map_err(serde::de::Error::custom)?,
460        })
461    }
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
465pub struct Cursor {
466    pub x: u16,
467    pub y: u16,
468    pub visible: bool,
469    pub blinking: bool,
470    pub shape: u8,
471}
472
473// ^ [[Terminal Presentation and Latency]] Range changes must stay aligned with
474// Ghostty projection, legacy protocol defaults, daemon lease cleanup, and TUI rendering.
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
476pub struct TerminalSelectionRange {
477    pub row: u16,
478    pub start_col: u16,
479    pub end_col: u16,
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483pub struct TerminalFrame {
484    pub pane_id: PaneId,
485    pub terminal_id: TerminalId,
486    pub revision: u64,
487    pub cols: u16,
488    pub rows: u16,
489    pub cells: Vec<Cell>,
490    pub cursor: Cursor,
491    #[serde(default)]
492    pub selection: Vec<TerminalSelectionRange>,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496pub struct TerminalRowPatch {
497    pub row: u16,
498    pub cells: Vec<Cell>,
499}
500
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
503pub enum TerminalUpdate {
504    Full(TerminalFrame),
505    Patch {
506        pane_id: PaneId,
507        terminal_id: TerminalId,
508        base_revision: u64,
509        revision: u64,
510        cols: u16,
511        rows: u16,
512        changed_rows: Vec<TerminalRowPatch>,
513        cursor: Cursor,
514        #[serde(default)]
515        selection: Vec<TerminalSelectionRange>,
516    },
517}
518
519impl TerminalUpdate {
520    pub fn identity(&self) -> (PaneId, TerminalId) {
521        match self {
522            Self::Full(frame) => (frame.pane_id, frame.terminal_id),
523            Self::Patch {
524                pane_id,
525                terminal_id,
526                ..
527            } => (*pane_id, *terminal_id),
528        }
529    }
530
531    pub fn revision(&self) -> u64 {
532        match self {
533            Self::Full(frame) => frame.revision,
534            Self::Patch { revision, .. } => *revision,
535        }
536    }
537
538    pub fn apply_to(self, frame: &mut Option<TerminalFrame>) -> Result<(), &'static str> {
539        let (
540            pane_id,
541            terminal_id,
542            base_revision,
543            revision,
544            cols,
545            rows,
546            changed_rows,
547            cursor,
548            selection,
549        ) = match self {
550            Self::Full(full) => {
551                if full.rows == 0
552                    || full.cols == 0
553                    || full.cells.len() != usize::from(full.cols) * usize::from(full.rows)
554                    || !valid_terminal_selection(&full.selection, full.rows, full.cols)
555                {
556                    return Err("terminal full frame dimensions are invalid");
557                }
558                *frame = Some(full);
559                return Ok(());
560            }
561            Self::Patch {
562                pane_id,
563                terminal_id,
564                base_revision,
565                revision,
566                cols,
567                rows,
568                changed_rows,
569                cursor,
570                selection,
571            } => (
572                pane_id,
573                terminal_id,
574                base_revision,
575                revision,
576                cols,
577                rows,
578                changed_rows,
579                cursor,
580                selection,
581            ),
582        };
583        if rows == 0
584            || cols == 0
585            || changed_rows.len() > usize::from(rows)
586            || !valid_terminal_selection(&selection, rows, cols)
587        {
588            return Err("terminal patch dimensions are invalid");
589        }
590        let current = frame.as_mut().ok_or("terminal patch has no baseline")?;
591        if current.pane_id != pane_id
592            || current.terminal_id != terminal_id
593            || current.revision != base_revision
594            || current.cols != cols
595            || current.rows != rows
596            || current.cells.len() != usize::from(cols) * usize::from(rows)
597        {
598            return Err("terminal patch baseline does not match");
599        }
600        for patch in changed_rows {
601            if patch.row >= rows || patch.cells.len() != usize::from(cols) {
602                return Err("terminal patch row is invalid");
603            }
604            let start = usize::from(patch.row) * usize::from(cols);
605            current.cells[start..start + usize::from(cols)].clone_from_slice(&patch.cells);
606        }
607        current.revision = revision;
608        current.cursor = cursor;
609        current.selection = selection;
610        Ok(())
611    }
612}
613
614fn valid_terminal_selection(selection: &[TerminalSelectionRange], rows: u16, cols: u16) -> bool {
615    selection.len() <= usize::from(rows)
616        && selection.iter().all(|range| {
617            range.row < rows && range.start_col <= range.end_col && range.end_col < cols
618        })
619        && selection.windows(2).all(|pair| pair[0].row < pair[1].row)
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(rename_all = "snake_case")]
624pub enum KeyCode {
625    Text,
626    Enter,
627    Backspace,
628    Tab,
629    Escape,
630    Insert,
631    Delete,
632    Home,
633    End,
634    PageUp,
635    PageDown,
636    Left,
637    Right,
638    Up,
639    Down,
640    Function(u8),
641}
642
643#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
644pub struct KeyEvent {
645    pub code: KeyCode,
646    pub text: String,
647    pub shift: bool,
648    pub control: bool,
649    pub alt: bool,
650    pub super_key: bool,
651    pub repeat: bool,
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
655#[serde(rename_all = "snake_case")]
656pub enum MouseAction {
657    Press,
658    Release,
659    Motion,
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
663#[serde(rename_all = "snake_case")]
664pub enum MouseButton {
665    Left,
666    Middle,
667    Right,
668    WheelUp,
669    WheelDown,
670    WheelLeft,
671    WheelRight,
672    None,
673}
674
675#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
676pub struct MouseEvent {
677    pub action: MouseAction,
678    pub button: MouseButton,
679    pub x: u16,
680    pub y: u16,
681    #[serde(default = "default_true")]
682    pub in_bounds: bool,
683    pub shift: bool,
684    pub control: bool,
685    pub alt: bool,
686    pub super_key: bool,
687}
688
689const fn default_true() -> bool {
690    true
691}
692
693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
694#[serde(rename_all = "snake_case")]
695pub enum PluginSurface {
696    TerminalRight,
697}
698
699#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
700pub struct PluginSidecarSpec {
701    pub surface: PluginSurface,
702    pub priority: i32,
703    pub minimum_columns: u16,
704    pub preferred_width: u16,
705    pub refresh_ms: u64,
706}
707
708#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
709pub struct PluginSidecarDescriptor {
710    pub plugin_id: String,
711    pub title: String,
712    pub spec: PluginSidecarSpec,
713}
714
715#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
716#[serde(rename_all = "snake_case")]
717pub enum PluginTone {
718    #[default]
719    Normal,
720    Muted,
721    Accent,
722    Success,
723    Warning,
724    Error,
725}
726
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
728pub struct PluginViewRow {
729    pub badge: String,
730    pub primary: String,
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub secondary: Option<String>,
733    pub value: String,
734    #[serde(default)]
735    pub tone: PluginTone,
736}
737
738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
739pub struct PluginViewPayload {
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub empty: Option<String>,
742    #[serde(default)]
743    pub rows: Vec<PluginViewRow>,
744    #[serde(default, skip_serializing_if = "is_zero")]
745    pub remaining: usize,
746}
747
748fn is_zero(value: &usize) -> bool {
749    *value == 0
750}
751
752#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
753pub struct PluginSidecarView {
754    pub plugin_id: String,
755    pub title: String,
756    pub epoch: u64,
757    pub pane_id: PaneId,
758    pub worktree_id: WorktreeId,
759    pub generation: u64,
760    pub payload: PluginViewPayload,
761}
762
763#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
764pub struct PluginManifest {
765    pub api_version: u32,
766    pub id: String,
767    pub name: String,
768    pub command: Vec<String>,
769    pub events: Vec<String>,
770    pub enabled: bool,
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub sidecar: Option<PluginSidecarSpec>,
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub worktree_review: Option<super::ReviewSpec>,
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn legacy_plugin_manifest_without_sidecar_deserializes() {
783        let manifest: PluginManifest = serde_json::from_str(
784            r#"{"api_version":1,"id":"legacy","name":"Legacy","command":["/plugin"],"events":["session.created"],"enabled":true}"#,
785        )
786        .unwrap();
787        assert!(manifest.sidecar.is_none());
788        assert!(manifest.worktree_review.is_none());
789    }
790
791    #[test]
792    fn legacy_snapshot_without_plugin_sidecars_deserializes() {
793        let snapshot: Snapshot = serde_json::from_str(
794            r#"{"protocol":11,"epoch":1,"revision":2,"projects":[],"worktrees":[],"sessions":[],"panes":[],"capabilities":{}}"#,
795        )
796        .unwrap();
797        assert!(snapshot.plugin_sidecars.is_empty());
798        assert!(!snapshot.capabilities.plugin_views);
799    }
800
801    #[test]
802    fn old_project_json_without_activity_deserializes() {
803        let project: Project =
804            serde_json::from_str(r#"{"id":1,"path":"/repo","name":"repo","revision":2}"#).unwrap();
805        assert_eq!(project.last_agent_active_unix_ms, None);
806        assert_eq!(project.last_terminal_active_unix_ms, None);
807    }
808
809    #[test]
810    fn legacy_agent_info_defaults_to_attached() {
811        let agent: AgentInfo = serde_json::from_str(
812            r#"{"id":1,"provider":"pi","state":"idle","capabilities":{},"source":"adapter"}"#,
813        )
814        .unwrap();
815        assert!(agent.attached);
816    }
817
818    #[test]
819    fn split_and_remove_preserve_pane_identity() {
820        let mut layout = PaneLayout::Leaf { pane_id: PaneId(1) };
821        assert!(layout.split(PaneId(1), PaneId(2), SplitAxis::Vertical));
822        let mut panes = Vec::new();
823        layout.panes(&mut panes);
824        assert_eq!(panes, vec![PaneId(1), PaneId(2)]);
825        assert!(layout.remove(PaneId(1)));
826        assert_eq!(layout, PaneLayout::Leaf { pane_id: PaneId(2) });
827    }
828
829    #[test]
830    fn terminal_cell_compact_wire_round_trips_width_and_style() {
831        let cell = Cell {
832            symbol: "界".into(),
833            fg: Some([1, 2, 3]),
834            bg: Some([4, 5, 6]),
835            modifiers: CellModifiers {
836                bold: true,
837                underline: true,
838                ..CellModifiers::default()
839            },
840            width: CellWidth::Wide,
841        };
842        let encoded = serde_json::to_string(&cell).unwrap();
843        assert_eq!(encoded, r#"["界",[1,2,3],[4,5,6],5,1]"#);
844        assert_eq!(serde_json::from_str::<Cell>(&encoded).unwrap(), cell);
845        assert!(serde_json::from_str::<Cell>(r#"["x",null,null,64,0]"#).is_err());
846        assert!(serde_json::from_str::<Cell>(r#"["x",null,null,0,4]"#).is_err());
847    }
848
849    #[test]
850    fn terminal_full_frame_rejects_invalid_cell_count() {
851        let mut baseline = None;
852        assert!(TerminalUpdate::Full(TerminalFrame {
853            pane_id: PaneId(1),
854            terminal_id: TerminalId(2),
855            revision: 1,
856            cols: 2,
857            rows: 2,
858            cells: vec![Cell::default(); 3],
859            cursor: Cursor {
860                x: 0,
861                y: 0,
862                visible: false,
863                blinking: false,
864                shape: 0,
865            },
866            selection: Vec::new(),
867        })
868        .apply_to(&mut baseline)
869        .is_err());
870        assert!(baseline.is_none());
871
872        assert!(TerminalUpdate::Full(TerminalFrame {
873            pane_id: PaneId(1),
874            terminal_id: TerminalId(2),
875            revision: 1,
876            cols: 2,
877            rows: 2,
878            cells: vec![Cell::default(); 4],
879            cursor: Cursor {
880                x: 0,
881                y: 0,
882                visible: false,
883                blinking: false,
884                shape: 0,
885            },
886            selection: vec![TerminalSelectionRange {
887                row: 0,
888                start_col: 0,
889                end_col: 2,
890            }],
891        })
892        .apply_to(&mut baseline)
893        .is_err());
894        assert!(baseline.is_none());
895    }
896
897    #[test]
898    fn terminal_patch_requires_and_updates_the_exact_baseline() {
899        let cursor = Cursor {
900            x: 0,
901            y: 0,
902            visible: true,
903            blinking: false,
904            shape: 0,
905        };
906        let mut frame = Some(TerminalFrame {
907            pane_id: PaneId(1),
908            terminal_id: TerminalId(2),
909            revision: 3,
910            cols: 2,
911            rows: 2,
912            cells: vec![Cell::default(); 4],
913            cursor,
914            selection: Vec::new(),
915        });
916        TerminalUpdate::Patch {
917            pane_id: PaneId(1),
918            terminal_id: TerminalId(2),
919            base_revision: 3,
920            revision: 4,
921            cols: 2,
922            rows: 2,
923            changed_rows: vec![TerminalRowPatch {
924                row: 1,
925                cells: vec![
926                    Cell {
927                        symbol: "x".into(),
928                        ..Cell::default()
929                    },
930                    Cell::default(),
931                ],
932            }],
933            cursor: Cursor {
934                x: 1,
935                y: 1,
936                ..cursor
937            },
938            selection: vec![TerminalSelectionRange {
939                row: 1,
940                start_col: 0,
941                end_col: 1,
942            }],
943        }
944        .apply_to(&mut frame)
945        .unwrap();
946        let frame = frame.unwrap();
947        assert_eq!(frame.revision, 4);
948        assert_eq!(frame.cells[2].symbol, "x");
949        assert_eq!((frame.cursor.x, frame.cursor.y), (1, 1));
950        assert_eq!(frame.selection.len(), 1);
951
952        let mut frame = Some(frame);
953        assert!(TerminalUpdate::Patch {
954            pane_id: PaneId(1),
955            terminal_id: TerminalId(2),
956            base_revision: 3,
957            revision: 5,
958            cols: 2,
959            rows: 2,
960            changed_rows: vec![],
961            cursor,
962            selection: Vec::new(),
963        }
964        .apply_to(&mut frame)
965        .is_err());
966    }
967}