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#[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}