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