ee845e15b3
Add background themes and custom images Add shell command logging toggle Add UTF-8 locale guarantee for PTY Add Claude hook settings injection Add hotkey system for GUI Add glass panel styling Add search disabled state for agent panels Add zoom toggle command Add device report filtering Add entitlements for notarization Update version to 0.1.27
419 lines
15 KiB
Rust
419 lines
15 KiB
Rust
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<ErrorBody>,
|
|
},
|
|
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<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub args: Vec<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
args: Vec<String>,
|
|
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<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
args: Vec<String>,
|
|
},
|
|
SetRatios { workspace_id: WorkspaceId, node_path: Vec<u32>, ratios: Vec<f32> },
|
|
MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge },
|
|
ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec<PresetSlot> },
|
|
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<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
group_id: Option<Option<GroupId>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
unread: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
order: Option<u32>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pinned: Option<bool>,
|
|
},
|
|
CreateGroup { name: String, color: String },
|
|
SetGroup {
|
|
group_id: GroupId,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
name: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
color: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
order: Option<u32>,
|
|
},
|
|
DeleteGroup { group_id: GroupId },
|
|
SetState { surface_id: SurfaceId, state: SurfaceState },
|
|
EventLog {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
limit: Option<u32>,
|
|
},
|
|
MarkRead { target: MarkReadTarget },
|
|
ClearEvents,
|
|
SetZoom {
|
|
workspace_id: WorkspaceId,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
surface_id: Option<SurfaceId>,
|
|
},
|
|
Health,
|
|
/// Which of the given CLI candidates are actually installed on the spawn PATH.
|
|
WhichAgents { candidates: Vec<String> },
|
|
Status,
|
|
Shutdown,
|
|
GetConfig,
|
|
SetConfig {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
default_shell: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
font_family: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
font_size: Option<u16>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
theme: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
accent: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
background: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
background_image: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
log_shell_commands: Option<bool>,
|
|
},
|
|
}
|
|
|
|
/// 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<u8> },
|
|
Exit { surface_id: SurfaceId, code: i32 },
|
|
SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId },
|
|
SurfaceClosed { surface_id: SurfaceId },
|
|
LayoutChanged { workspace_id: WorkspaceId, layout: Option<LayoutNode> },
|
|
WorkspaceChanged { workspace: WorkspaceView },
|
|
WorkspaceClosed { workspace_id: WorkspaceId },
|
|
GroupsChanged { groups: Vec<Group> },
|
|
SurfaceRestarted { surface_id: SurfaceId },
|
|
State { surface_id: SurfaceId, state: SurfaceState },
|
|
Event { record: EventRecord },
|
|
EventsRead { ids: Vec<u64> },
|
|
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::<Envelope>(&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::<Envelope>(&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::<Envelope>(&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::<Envelope>(&j).unwrap(), surf);
|
|
}
|
|
}
|