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