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