use serde::{Deserialize, Serialize}; use crate::event::{EventRecord, MarkReadTarget}; use crate::ids::{GroupId, SurfaceId, WorkspaceId}; use crate::layout::LayoutNode; use crate::status::SurfaceState; use crate::workspace::{Group, WorkspaceView}; /// Wire envelope. `kind` is the serde tag. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Envelope { Req { id: u64, cmd: Cmd, }, Res { id: u64, ok: bool, #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] data: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] error: Option, }, Evt(Evt), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ErrorBody { pub code: String, pub msg: String, } /// Direction a split grows the new neighbor. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SplitDir { Right, Down, } /// Edge of a target leaf to drop a moved panel against. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Edge { Left, Right, Top, Bottom, } /// One panel slot when applying a preset. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PresetSlot { #[serde(default, skip_serializing_if = "Option::is_none")] pub command: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub args: Vec, } /// Client → daemon commands. The active subset for M0+M1. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "cmd", content = "args", rename_all = "snake_case")] pub enum Cmd { Open { path: String }, NewSurface { workspace_id: WorkspaceId, #[serde(default, skip_serializing_if = "Option::is_none")] command: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] args: Vec, cols: u16, rows: u16, }, Input { surface_id: SurfaceId, /// base64-encoded keyboard bytes. bytes: String, }, Resize { surface_id: SurfaceId, cols: u16, rows: u16 }, Attach { surface_id: SurfaceId }, Detach { surface_id: SurfaceId }, Focus { surface_id: SurfaceId }, Close { surface_id: SurfaceId }, SplitSurface { surface_id: SurfaceId, dir: SplitDir, #[serde(default, skip_serializing_if = "Option::is_none")] command: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] args: Vec, }, SetRatios { workspace_id: WorkspaceId, node_path: Vec, ratios: Vec }, MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge }, ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec }, RestartSurface { surface_id: SurfaceId, #[serde(default)] resume: bool, }, CloseWorkspace { workspace_id: WorkspaceId }, SetWorkspaceMeta { workspace_id: WorkspaceId, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] group_id: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] unread: Option, #[serde(default, skip_serializing_if = "Option::is_none")] order: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pinned: Option, }, CreateGroup { name: String, color: String }, SetGroup { group_id: GroupId, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] color: Option, #[serde(default, skip_serializing_if = "Option::is_none")] order: Option, }, DeleteGroup { group_id: GroupId }, SetState { surface_id: SurfaceId, state: SurfaceState }, EventLog { #[serde(default, skip_serializing_if = "Option::is_none")] limit: Option, }, MarkRead { target: MarkReadTarget }, ClearEvents, SetZoom { workspace_id: WorkspaceId, #[serde(default, skip_serializing_if = "Option::is_none")] surface_id: Option, }, Health, /// Which of the given CLI candidates are actually installed on the spawn PATH. WhichAgents { candidates: Vec }, Status, Shutdown, GetConfig, SetConfig { #[serde(default, skip_serializing_if = "Option::is_none")] default_shell: Option, #[serde(default, skip_serializing_if = "Option::is_none")] font_family: Option, #[serde(default, skip_serializing_if = "Option::is_none")] font_size: Option, #[serde(default, skip_serializing_if = "Option::is_none")] theme: Option, #[serde(default, skip_serializing_if = "Option::is_none")] accent: Option, #[serde(default, skip_serializing_if = "Option::is_none")] background: Option, #[serde(default, skip_serializing_if = "Option::is_none")] background_image: Option, #[serde(default, skip_serializing_if = "Option::is_none")] log_shell_commands: Option, }, } /// Daemon → subscribers push events. The active subset for M0+M1. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "evt", content = "data", rename_all = "snake_case")] pub enum Evt { Output { surface_id: SurfaceId, bytes: Vec }, Exit { surface_id: SurfaceId, code: i32 }, SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId }, SurfaceClosed { surface_id: SurfaceId }, LayoutChanged { workspace_id: WorkspaceId, layout: Option }, WorkspaceChanged { workspace: WorkspaceView }, WorkspaceClosed { workspace_id: WorkspaceId }, GroupsChanged { groups: Vec }, SurfaceRestarted { surface_id: SurfaceId }, State { surface_id: SurfaceId, state: SurfaceState }, Event { record: EventRecord }, EventsRead { ids: Vec }, EventsCleared, ConfigChanged { config: crate::config_view::ConfigView }, } #[cfg(test)] mod tests { use super::*; use crate::ids::{SurfaceId, WorkspaceId}; #[test] fn req_round_trips_through_json() { let env = Envelope::Req { id: 42, cmd: Cmd::Focus { surface_id: SurfaceId("s_8f3".into()) }, }; let json = serde_json::to_string(&env).unwrap(); let back: Envelope = serde_json::from_str(&json).unwrap(); assert_eq!(env, back); } #[test] fn res_ok_and_err_serialize_distinctly() { let ok = Envelope::Res { id: 1, ok: true, data: serde_json::json!({"workspace_id":"w_1"}), error: None }; let err = Envelope::Res { id: 2, ok: false, data: serde_json::Value::Null, error: Some(ErrorBody { code: "NOT_FOUND".into(), msg: "no surface".into() }) }; assert!(serde_json::to_string(&ok).unwrap().contains("\"ok\":true")); assert!(serde_json::to_string(&err).unwrap().contains("NOT_FOUND")); } #[test] fn evt_output_carries_workspace_scoped_surface() { let evt = Envelope::Evt(Evt::Output { surface_id: SurfaceId("s_1".into()), bytes: vec![104, 105], }); let json = serde_json::to_string(&evt).unwrap(); let back: Envelope = serde_json::from_str(&json).unwrap(); assert_eq!(evt, back); } #[test] fn new_surface_defaults_cmd_to_none() { let json = r#"{"kind":"req","id":7,"cmd":{"cmd":"new_surface","args":{"workspace_id":"w_1","cols":80,"rows":24}}}"#; let env: Envelope = serde_json::from_str(json).unwrap(); match env { Envelope::Req { cmd: Cmd::NewSurface { command, args, .. }, .. } => { assert!(command.is_none()); assert!(args.is_empty()); } _ => panic!("wrong variant"), } } #[test] fn split_surface_serializes() { let env = Envelope::Req { id: 1, cmd: Cmd::SplitSurface { surface_id: SurfaceId("s_1".into()), dir: SplitDir::Right, command: None, args: vec![], }, }; let j = serde_json::to_string(&env).unwrap(); assert!(j.contains("split_surface")); assert!(j.contains(r#""dir":"right""#)); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, env); } #[test] fn apply_preset_round_trips() { let env = Envelope::Req { id: 2, cmd: Cmd::ApplyPreset { workspace_id: WorkspaceId("w_1".into()), preset_id: "2x2".into(), slots: vec![ PresetSlot { command: Some("claude".into()), args: vec![] }, PresetSlot { command: None, args: vec![] }, ], }, }; let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); assert_eq!(back, env); } #[test] fn set_ratios_round_trips() { let env = Envelope::Req { id: 3, cmd: Cmd::SetRatios { workspace_id: WorkspaceId("w_1".into()), node_path: vec![0, 1], ratios: vec![0.3, 0.7], }, }; let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); assert_eq!(back, env); } #[test] fn layout_changed_event_round_trips() { let evt = Envelope::Evt(Evt::LayoutChanged { workspace_id: WorkspaceId("w_1".into()), layout: Some(crate::layout::LayoutNode::leaf(SurfaceId("s_1".into()))), }); let back: Envelope = serde_json::from_str(&serde_json::to_string(&evt).unwrap()).unwrap(); assert_eq!(back, evt); } #[test] fn set_state_round_trips() { let env = Envelope::Req { id: 1, cmd: Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Done }, }; let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); assert_eq!(back, env); } #[test] fn state_event_round_trips() { let evt = Envelope::Evt(Evt::State { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Wait }); let j = serde_json::to_string(&evt).unwrap(); assert!(j.contains(r#""evt":"state""#)); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, evt); } #[test] fn event_log_cmd_round_trips() { let env = Envelope::Req { id: 1, cmd: Cmd::EventLog { limit: Some(50) } }; let j = serde_json::to_string(&env).unwrap(); assert!(j.contains(r#""cmd":"event_log""#)); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, env); } #[test] fn mark_read_cmd_round_trips() { let env = Envelope::Req { id: 2, cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::All }, }; let j = serde_json::to_string(&env).unwrap(); assert!(j.contains(r#""cmd":"mark_read""#)); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, env); } #[test] fn event_evt_round_trips() { let evt = Envelope::Evt(Evt::Event { record: crate::event::EventRecord { id: 3, surface_id: SurfaceId("s_1".into()), workspace_id: WorkspaceId("w_1".into()), workspace_name: "p".into(), agent_label: None, kind: crate::event::EventKind::Done, ts: 1, read: false, }, }); let j = serde_json::to_string(&evt).unwrap(); assert!(j.contains(r#""evt":"event""#)); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, evt); } #[test] fn events_read_evt_round_trips() { let evt = Envelope::Evt(Evt::EventsRead { ids: vec![1, 2, 3] }); let j = serde_json::to_string(&evt).unwrap(); assert!(j.contains(r#""evt":"events_read""#)); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, evt); } #[test] fn set_zoom_cmd_round_trips() { let z = Envelope::Req { id: 1, cmd: Cmd::SetZoom { workspace_id: WorkspaceId("w_1".into()), surface_id: Some(SurfaceId("s_1".into())) } }; let j = serde_json::to_string(&z).unwrap(); assert!(j.contains(r#""cmd":"set_zoom""#)); assert_eq!(serde_json::from_str::(&j).unwrap(), z); let unz = Envelope::Req { id: 2, cmd: Cmd::SetZoom { workspace_id: WorkspaceId("w_1".into()), surface_id: None } }; assert_eq!(serde_json::from_str::(&serde_json::to_string(&unz).unwrap()).unwrap(), unz); } #[test] fn health_cmd_round_trips() { let env = Envelope::Req { id: 1, cmd: Cmd::Health }; let j = serde_json::to_string(&env).unwrap(); assert!(j.contains(r#""cmd":"health""#)); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, env); } #[test] fn restart_surface_resume_defaults_false_and_round_trips() { // Legacy frame without `resume` decodes to false. let legacy = r#"{"kind":"req","id":5,"cmd":{"cmd":"restart_surface","args":{"surface_id":"s_1"}}}"#; let env: Envelope = serde_json::from_str(legacy).unwrap(); match env { Envelope::Req { cmd: Cmd::RestartSurface { resume, .. }, .. } => assert!(!resume), _ => panic!("wrong variant"), } // resume=true round-trips. let e = Envelope::Req { id: 6, cmd: Cmd::RestartSurface { surface_id: SurfaceId("s_1".into()), resume: true } }; let back: Envelope = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap(); assert_eq!(back, e); } #[test] fn event_log_cmd_no_limit_round_trips() { let env = Envelope::Req { id: 9, cmd: Cmd::EventLog { limit: None } }; let j = serde_json::to_string(&env).unwrap(); assert!(j.contains(r#""cmd":"event_log""#)); assert!(j.contains(r#""args":{}"#), "no-limit serializes to empty args, got: {j}"); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, env); } #[test] fn mark_read_cmd_ids_and_surface_round_trip() { let ids = Envelope::Req { id: 10, cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::Ids(vec![1, 2]) } }; let j = serde_json::to_string(&ids).unwrap(); assert!(j.contains(r#""target":"ids""#)); assert_eq!(serde_json::from_str::(&j).unwrap(), ids); let surf = Envelope::Req { id: 11, cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::Surface(SurfaceId("s_3".into())) } }; let j = serde_json::to_string(&surf).unwrap(); assert!(j.contains(r#""target":"surface""#)); assert_eq!(serde_json::from_str::(&j).unwrap(), surf); } }