diff options
Diffstat (limited to 'src/api/schema')
| -rw-r--r-- | src/api/schema/agents.rs | 87 | ||||
| -rw-r--r-- | src/api/schema/common.rs | 128 | ||||
| -rw-r--r-- | src/api/schema/events.rs | 474 | ||||
| -rw-r--r-- | src/api/schema/integrations.rs | 38 | ||||
| -rw-r--r-- | src/api/schema/panes.rs | 110 | ||||
| -rw-r--r-- | src/api/schema/plugins.rs | 343 | ||||
| -rw-r--r-- | src/api/schema/response.rs | 248 | ||||
| -rw-r--r-- | src/api/schema/server.rs | 19 | ||||
| -rw-r--r-- | src/api/schema/tabs.rs | 42 | ||||
| -rw-r--r-- | src/api/schema/tests.rs | 975 | ||||
| -rw-r--r-- | src/api/schema/workspaces.rs | 46 | ||||
| -rw-r--r-- | src/api/schema/worktrees.rs | 74 |
12 files changed, 2576 insertions, 8 deletions
diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs new file mode 100644 index 00000000..644211d4 --- /dev/null +++ b/src/api/schema/agents.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::{AgentStatus, ReadFormat, ReadSource, SplitDirection}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentReadParams { + pub target: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option<u32>, + #[serde(default)] + pub format: ReadFormat, + #[serde(default = "super::common::default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSendParams { + pub target: String, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentRenameParams { + pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentStartParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub split: Option<SplitDirection>, + #[serde(default)] + pub focus: bool, + pub argv: Vec<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentInfo { + pub terminal_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_agent: Option<String>, + pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "super::is_false")] + pub screen_detection_skipped: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub state_labels: HashMap<String, String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session: Option<AgentSessionInfo>, + pub workspace_id: String, + pub tab_id: String, + pub pane_id: String, + pub focused: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground_cwd: Option<String>, + pub revision: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSessionInfo { + pub source: String, + pub agent: String, + pub kind: crate::agent_resume::AgentSessionRefKind, + pub value: String, +} diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs new file mode 100644 index 00000000..cb423683 --- /dev/null +++ b/src/api/schema/common.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct EmptyParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceTarget { + pub workspace_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneTarget { + pub pane_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabTarget { + pub tab_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentTarget { + pub target: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientWindowTitleSetParams { + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitDirection { + Right, + Down, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReadSource { + Visible, + Recent, + RecentUnwrapped, + Detection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ReadFormat { + #[default] + Text, + Ansi, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotificationShowParams { + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option<crate::config::ToastHerdrPosition>, + #[serde(default, skip_serializing_if = "NotificationShowSound::is_none")] + pub sound: NotificationShowSound, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum NotificationShowSound { + #[default] + None, + Done, + Request, +} + +impl NotificationShowSound { + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + pub fn to_sound(self) -> Option<crate::sound::Sound> { + match self { + Self::None => None, + Self::Done => Some(crate::sound::Sound::Done), + Self::Request => Some(crate::sound::Sound::Request), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationShowReason { + Shown, + Disabled, + RateLimited, + NoForegroundClient, + Busy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientWindowTitleReason { + Set, + Cleared, + NoForegroundClient, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneAgentState { + Idle, + Working, + Blocked, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentStatus { + Idle, + Working, + Blocked, + Done, + Unknown, +} + +pub(crate) fn default_true() -> bool { + true +} diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs new file mode 100644 index 00000000..353f75b9 --- /dev/null +++ b/src/api/schema/events.rs @@ -0,0 +1,474 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::{AgentStatus, ReadSource}; +use super::panes::{PaneInfo, PaneReadResult}; +use super::tabs::TabInfo; +use super::workspaces::WorkspaceInfo; +use super::worktrees::WorktreeInfo; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsSubscribeParams { + pub subscriptions: Vec<Subscription>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Subscription { + #[serde(rename = "workspace.created")] + WorkspaceCreated {}, + #[serde(rename = "workspace.updated")] + WorkspaceUpdated {}, + #[serde(rename = "workspace.renamed")] + WorkspaceRenamed {}, + #[serde(rename = "workspace.closed")] + WorkspaceClosed {}, + #[serde(rename = "workspace.focused")] + WorkspaceFocused {}, + #[serde(rename = "worktree.created")] + WorktreeCreated {}, + #[serde(rename = "worktree.opened")] + WorktreeOpened {}, + #[serde(rename = "worktree.removed")] + WorktreeRemoved {}, + #[serde(rename = "tab.created")] + TabCreated {}, + #[serde(rename = "tab.closed")] + TabClosed {}, + #[serde(rename = "tab.focused")] + TabFocused {}, + #[serde(rename = "tab.renamed")] + TabRenamed {}, + #[serde(rename = "pane.created")] + PaneCreated {}, + #[serde(rename = "pane.closed")] + PaneClosed {}, + #[serde(rename = "pane.focused")] + PaneFocused {}, + #[serde(rename = "pane.moved")] + PaneMoved {}, + #[serde(rename = "pane.exited")] + PaneExited {}, + #[serde(rename = "pane.agent_detected")] + PaneAgentDetected {}, + #[serde(rename = "pane.output_matched")] + PaneOutputMatched { + pane_id: String, + source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + lines: Option<u32>, + r#match: OutputMatch, + #[serde(default = "super::common::default_true")] + strip_ansi: bool, + }, + #[serde(rename = "pane.agent_status_changed")] + PaneAgentStatusChanged { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_status: Option<AgentStatus>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsWaitParams { + pub match_event: EventMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneWaitForOutputParams { + pub pane_id: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option<u32>, + pub r#match: OutputMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, + #[serde(default = "super::common::default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OutputMatch { + Substring { value: String }, + Regex { value: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum EventMatch { + WorkspaceCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + WorkspaceUpdated { + workspace_id: String, + }, + WorkspaceClosed { + workspace_id: String, + }, + WorkspaceRenamed { + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option<String>, + }, + WorkspaceFocused { + workspace_id: String, + }, + TabCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + TabClosed { + tab_id: String, + }, + TabRenamed { + tab_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option<String>, + }, + TabFocused { + tab_id: String, + }, + PaneCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + PaneClosed { + pane_id: String, + }, + PaneFocused { + pane_id: String, + }, + PaneMoved { + pane_id: String, + }, + PaneOutputChanged { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + min_revision: Option<u64>, + }, + PaneExited { + pane_id: String, + }, + PaneAgentDetected { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + }, + PaneAgentStatusChanged { + pane_id: String, + agent_status: AgentStatus, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + WorkspaceCreated, + WorkspaceUpdated, + WorkspaceClosed, + WorkspaceRenamed, + WorkspaceFocused, + WorktreeCreated, + WorktreeOpened, + WorktreeRemoved, + TabCreated, + TabClosed, + TabRenamed, + TabFocused, + PaneCreated, + PaneClosed, + PaneFocused, + PaneMoved, + PaneOutputChanged, + PaneExited, + PaneAgentDetected, + PaneAgentStatusChanged, +} + +impl EventKind { + pub fn dot_name(self) -> &'static str { + match self { + EventKind::WorkspaceCreated => "workspace.created", + EventKind::WorkspaceUpdated => "workspace.updated", + EventKind::WorkspaceClosed => "workspace.closed", + EventKind::WorkspaceRenamed => "workspace.renamed", + EventKind::WorkspaceFocused => "workspace.focused", + EventKind::WorktreeCreated => "worktree.created", + EventKind::WorktreeOpened => "worktree.opened", + EventKind::WorktreeRemoved => "worktree.removed", + EventKind::TabCreated => "tab.created", + EventKind::TabClosed => "tab.closed", + EventKind::TabRenamed => "tab.renamed", + EventKind::TabFocused => "tab.focused", + EventKind::PaneCreated => "pane.created", + EventKind::PaneClosed => "pane.closed", + EventKind::PaneFocused => "pane.focused", + EventKind::PaneMoved => "pane.moved", + EventKind::PaneOutputChanged => "pane.output_changed", + EventKind::PaneExited => "pane.exited", + EventKind::PaneAgentDetected => "pane.agent_detected", + EventKind::PaneAgentStatusChanged => "pane.agent_status_changed", + } + } +} + +#[cfg(test)] +pub const KNOWN_EVENT_KINDS: &[EventKind] = &[ + EventKind::WorkspaceCreated, + EventKind::WorkspaceUpdated, + EventKind::WorkspaceClosed, + EventKind::WorkspaceRenamed, + EventKind::WorkspaceFocused, + EventKind::WorktreeCreated, + EventKind::WorktreeOpened, + EventKind::WorktreeRemoved, + EventKind::TabCreated, + EventKind::TabClosed, + EventKind::TabRenamed, + EventKind::TabFocused, + EventKind::PaneCreated, + EventKind::PaneClosed, + EventKind::PaneFocused, + EventKind::PaneMoved, + EventKind::PaneOutputChanged, + EventKind::PaneExited, + EventKind::PaneAgentDetected, + EventKind::PaneAgentStatusChanged, +]; + +pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[ + EventKind::WorkspaceCreated, + EventKind::WorkspaceUpdated, + EventKind::WorkspaceClosed, + EventKind::WorkspaceRenamed, + EventKind::WorkspaceFocused, + EventKind::WorktreeCreated, + EventKind::WorktreeOpened, + EventKind::WorktreeRemoved, + EventKind::TabCreated, + EventKind::TabClosed, + EventKind::TabRenamed, + EventKind::TabFocused, + EventKind::PaneCreated, + EventKind::PaneClosed, + EventKind::PaneFocused, + EventKind::PaneMoved, + EventKind::PaneExited, + EventKind::PaneAgentDetected, + EventKind::PaneAgentStatusChanged, +]; + +#[cfg(test)] +pub fn known_event_names() -> Vec<&'static str> { + KNOWN_EVENT_KINDS + .iter() + .copied() + .map(EventKind::dot_name) + .collect() +} + +/// Event names that manifest `[[events]] on` hooks can reference. This is +/// intentionally narrower than `EventKind` until high-volume output-change hook +/// semantics are implemented. +pub fn plugin_hook_event_names() -> Vec<&'static str> { + PLUGIN_HOOK_EVENT_KINDS + .iter() + .copied() + .map(EventKind::dot_name) + .collect() +} + +#[cfg(test)] +mod known_event_name_tests { + use super::*; + + #[test] + fn known_event_names_stay_in_sync_with_event_kind() { + let mut from_kind = KNOWN_EVENT_KINDS + .iter() + .map(|kind| kind.dot_name()) + .collect::<Vec<_>>(); + from_kind.sort_unstable(); + let mut known = known_event_names(); + known.sort_unstable(); + assert_eq!( + from_kind, known, + "known_event_names() out of sync with EventKind" + ); + } + + #[test] + fn plugin_hook_event_names_exclude_unemitted_output_change() { + let names = plugin_hook_event_names(); + assert!(!names.contains(&"pane.output_changed")); + assert!(names.contains(&"pane.moved")); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventEnvelope { + pub event: EventKind, + pub data: EventData, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SubscriptionEventKind { + #[serde(rename = "pane.output_matched")] + PaneOutputMatched, + #[serde(rename = "pane.agent_status_changed")] + PaneAgentStatusChanged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubscriptionEventEnvelope { + pub event: SubscriptionEventKind, + pub data: SubscriptionEventData, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SubscriptionEventData { + PaneOutputMatched(PaneOutputMatchedEvent), + PaneAgentStatusChanged(PaneAgentStatusChangedEvent), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneOutputMatchedEvent { + pub pane_id: String, + pub matched_line: String, + pub read: PaneReadResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneAgentStatusChangedEvent { + pub pane_id: String, + pub workspace_id: String, + pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_agent: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub state_labels: HashMap<String, String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EventData { + WorkspaceCreated { + workspace: WorkspaceInfo, + }, + WorkspaceUpdated { + workspace: WorkspaceInfo, + }, + WorkspaceClosed { + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace: Option<WorkspaceInfo>, + }, + WorkspaceRenamed { + workspace_id: String, + label: String, + }, + WorkspaceFocused { + workspace_id: String, + }, + WorktreeCreated { + workspace: WorkspaceInfo, + worktree: WorktreeInfo, + }, + WorktreeOpened { + workspace: WorkspaceInfo, + worktree: WorktreeInfo, + already_open: bool, + }, + WorktreeRemoved { + workspace_id: String, + worktree: WorktreeInfo, + forced: bool, + }, + TabCreated { + tab: TabInfo, + }, + TabClosed { + tab_id: String, + workspace_id: String, + }, + TabRenamed { + tab_id: String, + workspace_id: String, + label: String, + }, + TabFocused { + tab_id: String, + workspace_id: String, + }, + PaneCreated { + pane: PaneInfo, + }, + PaneClosed { + pane_id: String, + workspace_id: String, + }, + PaneFocused { + pane_id: String, + workspace_id: String, + }, + PaneMoved { + previous_pane_id: String, + previous_workspace_id: String, + previous_tab_id: String, + pane: Box<PaneInfo>, + #[serde(default, skip_serializing_if = "Option::is_none")] + created_workspace: Option<WorkspaceInfo>, + #[serde(default, skip_serializing_if = "Option::is_none")] + created_tab: Option<TabInfo>, + #[serde(default, skip_serializing_if = "Option::is_none")] + closed_workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + closed_tab_id: Option<String>, + }, + PaneOutputChanged { + pane_id: String, + workspace_id: String, + revision: u64, + }, + PaneExited { + pane_id: String, + workspace_id: String, + }, + PaneAgentDetected { + pane_id: String, + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + }, + PaneAgentStatusChanged { + pane_id: String, + workspace_id: String, + agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + custom_status: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + state_labels: HashMap<String, String>, + }, +} diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs new file mode 100644 index 00000000..49b27f96 --- /dev/null +++ b/src/api/schema/integrations.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntegrationInstallParams { + pub target: IntegrationTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntegrationUninstallParams { + pub target: IntegrationTarget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IntegrationTarget { + Pi, + Omp, + Claude, + Codex, + Copilot, + Droid, + Kimi, + Opencode, + Kilo, + Hermes, + Qodercli, + Cursor, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntegrationInstallResult { + pub messages: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntegrationUninstallResult { + pub messages: Vec<String>, +} diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index de330993..638829b0 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -2,7 +2,8 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::{AgentSessionInfo, AgentStatus, PaneAgentState, ReadFormat, ReadSource}; +use super::agents::AgentSessionInfo; +use super::common::{AgentStatus, PaneAgentState, ReadFormat, ReadSource, SplitDirection}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PaneSplitParams { @@ -17,13 +18,8 @@ pub struct PaneSplitParams { pub cwd: Option<String>, #[serde(default)] pub focus: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SplitDirection { - Right, - Down, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -103,6 +99,71 @@ pub struct PaneLayoutParams { pub pane_id: Option<String>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneProcessInfoParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct LayoutExportParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayoutApplyParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_label: Option<String>, + #[serde(default)] + pub focus: bool, + pub root: LayoutNode, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayoutDescription { + pub workspace_id: String, + pub tab_id: String, + pub zoomed: bool, + pub focused_pane_id: String, + pub root: LayoutNode, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LayoutNode { + Pane { + #[serde(flatten)] + pane: LayoutPane, + }, + Split { + direction: SplitDirection, + ratio: f32, + first: Box<LayoutNode>, + second: Box<LayoutNode>, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct LayoutPane { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option<Vec<String>>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneNeighborParams { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -138,6 +199,12 @@ pub struct PaneListParams { pub workspace_id: Option<String>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneCurrentParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caller_pane_id: Option<String>, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneRenameParams { pub pane_id: String, @@ -286,6 +353,33 @@ pub struct PaneInfo { pub revision: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneProcessInfo { + pub pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shell_pid: Option<u32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground_process_group_id: Option<u32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tty: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub foreground_processes: Vec<PaneProcessInfoProcess>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneProcessInfoProcess { + pub pid: u32, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub argv0: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub argv: Option<Vec<String>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cmdline: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PaneSwapResult { pub changed: bool, diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs new file mode 100644 index 00000000..535733ec --- /dev/null +++ b/src/api/schema/plugins.rs @@ -0,0 +1,343 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::AgentStatus; +use super::common::SplitDirection; +use super::panes::PaneInfo; +use super::workspaces::WorkspaceWorktreeInfo; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginLinkParams { + pub path: String, + #[serde(default = "super::common::default_true")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option<PluginSourceInfo>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginUnlinkParams { + pub plugin_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginSetEnabledParams { + pub plugin_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledPluginInfo { + pub plugin_id: String, + pub name: String, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + pub manifest_path: String, + pub plugin_root: String, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub build: Vec<PluginManifestBuild>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<PluginManifestAction>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec<PluginManifestEventHook>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub panes: Vec<PluginManifestPane>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub link_handlers: Vec<PluginManifestLinkHandler>, + #[serde(default)] + pub source: PluginSourceInfo, + /// Warnings collected at link time or on registry load (e.g. unknown event names, + /// missing manifest file). Non-fatal — the entry is kept and surfaced by plugin.list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginSourceInfo { + #[serde(default)] + pub kind: PluginSourceKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subdir: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_ref: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_commit: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub managed_path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub installed_unix_ms: Option<u64>, +} + +impl Default for PluginSourceInfo { + fn default() -> Self { + Self { + kind: PluginSourceKind::Local, + owner: None, + repo: None, + subdir: None, + requested_ref: None, + resolved_commit: None, + managed_path: None, + installed_unix_ms: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginSourceKind { + #[default] + Local, + Github, +} + +pub(crate) fn plugin_managed_path_component(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '_' + } + }) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestBuild { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + pub command: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestAction { + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub contexts: Vec<PluginActionContext>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + pub command: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestEventHook { + pub on: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + pub command: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestPane { + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, + #[serde(default)] + pub placement: PluginPanePlacement, + pub command: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifestLinkHandler { + pub id: String, + pub title: String, + pub pattern: String, + pub action: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginActionListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginLogListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option<usize>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginActionInvokeParams { + pub action_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option<PluginInvocationContext>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginCommandLogInfo { + pub log_id: String, + pub plugin_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event: Option<String>, + pub command: Vec<String>, + pub status: PluginCommandStatus, + pub started_unix_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_unix_ms: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option<i32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stdout: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stderr: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option<String>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginCommandStatus { + Running, + Succeeded, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginPlatform { + Linux, + Macos, + Windows, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginActionContext { + Global, + Workspace, + Tab, + Pane, + Selection, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginInvocationContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_label: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option<WorkspaceWorktreeInfo>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_label: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_status: Option<AgentStatus>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_text: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invocation_source: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub correlation_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clicked_url: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_handler_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginActionInfo { + pub plugin_id: String, + pub action_id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub contexts: Vec<PluginActionContext>, + pub command: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platforms: Option<Vec<PluginPlatform>>, +} + +impl PluginActionInfo { + pub fn qualified_id(&self) -> String { + format!("{}.{}", self.plugin_id, self.action_id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginPaneOpenParams { + pub plugin_id: String, + pub entrypoint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placement: Option<PluginPanePlacement>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction: Option<SplitDirection>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginPanePlacement { + #[default] + Overlay, + Split, + Tab, + Zoomed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginPaneFocusParams { + pub pane_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginPaneCloseParams { + pub pane_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginPaneInfo { + pub plugin_id: String, + pub entrypoint: String, + pub pane: PaneInfo, +} diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs new file mode 100644 index 00000000..51ea0d36 --- /dev/null +++ b/src/api/schema/response.rs @@ -0,0 +1,248 @@ +use serde::{Deserialize, Serialize}; + +use super::agents::AgentInfo; +use super::common::{ClientWindowTitleReason, NotificationShowReason}; +use super::events::EventEnvelope; +use super::integrations::{ + IntegrationInstallResult, IntegrationTarget, IntegrationUninstallResult, +}; +use super::panes::{ + LayoutDescription, PaneEdgesResult, PaneFocusDirectionResult, PaneInfo, PaneLayoutSnapshot, + PaneMoveResult, PaneNeighborResult, PaneProcessInfo, PaneReadResult, PaneResizeResult, + PaneSwapResult, PaneZoomResult, +}; +use super::plugins::{ + InstalledPluginInfo, PluginActionInfo, PluginCommandLogInfo, PluginInvocationContext, + PluginPaneInfo, +}; +use super::server::ServerCapabilities; +use super::tabs::TabInfo; +use super::workspaces::WorkspaceInfo; +use super::worktrees::{WorktreeInfo, WorktreeSourceInfo}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SuccessResponse { + pub id: String, + pub result: ResponseResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorResponse { + pub id: String, + pub error: ErrorBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseResult { + Pong { + version: String, + protocol: u32, + #[serde(default)] + capabilities: Option<ServerCapabilities>, + }, + WorkspaceInfo { + workspace: WorkspaceInfo, + }, + WorkspaceCreated { + workspace: WorkspaceInfo, + tab: TabInfo, + root_pane: PaneInfo, + }, + WorkspaceList { + workspaces: Vec<WorkspaceInfo>, + }, + WorktreeList { + source: WorktreeSourceInfo, + worktrees: Vec<WorktreeInfo>, + }, + WorktreeCreated { + workspace: WorkspaceInfo, + tab: TabInfo, + root_pane: PaneInfo, + worktree: WorktreeInfo, + }, + WorktreeOpened { + workspace: WorkspaceInfo, + tab: TabInfo, + root_pane: PaneInfo, + worktree: WorktreeInfo, + already_open: bool, + }, + WorktreeRemoved { + workspace_id: String, + path: String, + forced: bool, + }, + TabInfo { + tab: TabInfo, + }, + TabCreated { + tab: TabInfo, + root_pane: PaneInfo, + }, + TabList { + tabs: Vec<TabInfo>, + }, + AgentInfo { + agent: AgentInfo, + }, + AgentStarted { + agent: AgentInfo, + argv: Vec<String>, + }, + AgentList { + agents: Vec<AgentInfo>, + }, + PaneInfo { + pane: PaneInfo, + }, + PaneList { + panes: Vec<PaneInfo>, + }, + PaneCurrent { + pane: PaneInfo, + }, + PaneSwap { + swap: PaneSwapResult, + }, + PaneMove { + move_result: PaneMoveResult, + }, + PaneZoom { + zoom: PaneZoomResult, + }, + PaneLayout { + layout: PaneLayoutSnapshot, + }, + PaneProcessInfo { + process_info: PaneProcessInfo, + }, + LayoutExport { + layout: LayoutDescription, + }, + LayoutApply { + layout: LayoutDescription, + }, + PaneNeighbor { + neighbor: PaneNeighborResult, + }, + PaneEdges { + edges: PaneEdgesResult, + }, + PaneFocusDirection { + focus: PaneFocusDirectionResult, + }, + PaneResize { + resize: PaneResizeResult, + }, + PaneRead { + read: PaneReadResult, + }, + AgentExplain { + explain: serde_json::Value, + }, + SubscriptionStarted {}, + WaitMatched { + event: EventEnvelope, + }, + OutputMatched { + pane_id: String, + revision: u64, + matched_line: Option<String>, + read: PaneReadResult, + }, + NotificationShow { + shown: bool, + reason: NotificationShowReason, + }, + ClientWindowTitle { + changed: bool, + reason: ClientWindowTitleReason, + }, + IntegrationInstall { + target: IntegrationTarget, + details: IntegrationInstallResult, + }, + IntegrationUninstall { + target: IntegrationTarget, + details: IntegrationUninstallResult, + }, + AgentManifestReload { + manifests: Vec<AgentManifestInfo>, + }, + AgentManifestStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + last_check_unix: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_result: Option<String>, + manifests: Vec<AgentManifestInfo>, + }, + PluginLinked { + plugin: InstalledPluginInfo, + }, + PluginList { + plugins: Vec<InstalledPluginInfo>, + }, + PluginUnlinked { + plugin_id: String, + removed: bool, + }, + PluginEnabled { + plugin: InstalledPluginInfo, + }, + PluginDisabled { + plugin: InstalledPluginInfo, + }, + PluginActionList { + actions: Vec<PluginActionInfo>, + }, + PluginActionInvoked { + action: PluginActionInfo, + context: PluginInvocationContext, + log: PluginCommandLogInfo, + }, + PluginLogList { + logs: Vec<PluginCommandLogInfo>, + }, + PluginPaneOpened { + plugin_pane: PluginPaneInfo, + }, + PluginPaneFocused { + plugin_pane: PluginPaneInfo, + }, + PluginPaneClosed { + pane_id: String, + }, + ConfigReload { + status: crate::config::ConfigReloadStatus, + diagnostics: Vec<String>, + }, + Ok {}, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentManifestInfo { + pub agent: String, + pub source: String, + pub source_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_version: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_remote_version: Option<String>, + pub local_override_shadowing_remote: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_update_result: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_update_error: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_last_checked_unix: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option<String>, +} diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs new file mode 100644 index 00000000..59ce8686 --- /dev/null +++ b/src/api/schema/server.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PingParams {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerLiveHandoffParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub import_exe: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_protocol: Option<u32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_version: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerCapabilities { + pub live_handoff: bool, +} diff --git a/src/api/schema/tabs.rs b/src/api/schema/tabs.rs new file mode 100644 index 00000000..4be94c3b --- /dev/null +++ b/src/api/schema/tabs.rs @@ -0,0 +1,42 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::AgentStatus; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TabListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabRenameParams { + pub tab_id: String, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabInfo { + pub tab_id: String, + pub workspace_id: String, + pub number: usize, + pub label: String, + pub focused: bool, + pub pane_count: usize, + pub agent_status: AgentStatus, +} diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs new file mode 100644 index 00000000..c1713972 --- /dev/null +++ b/src/api/schema/tests.rs @@ -0,0 +1,975 @@ +use std::collections::HashMap; + +use super::*; + +#[test] +fn request_round_trips_for_pane_read() { + let request = Request { + id: "req_1".into(), + method: Method::PaneRead(PaneReadParams { + pane_id: "p_1".into(), + source: ReadSource::Recent, + lines: Some(80), + format: ReadFormat::Text, + strip_ansi: true, + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_report_agent() { + let request = Request { + id: "req_hook".into(), + method: Method::PaneReportAgent(PaneReportAgentParams { + pane_id: "1-1".into(), + source: "herdr:pi".into(), + agent: "pi".into(), + state: PaneAgentState::Working, + message: Some("thinking".into()), + custom_status: Some("indexing".into()), + seq: Some(42), + agent_session_id: Some("pi-session".into()), + agent_session_path: Some("/tmp/pi-session.jsonl".into()), + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_report_agent_session() { + let request = Request { + id: "req_session".into(), + method: Method::PaneReportAgentSession(PaneReportAgentSessionParams { + pane_id: "1-1".into(), + source: "herdr:claude".into(), + agent: "claude".into(), + seq: Some(42), + agent_session_id: Some("claude-session".into()), + agent_session_path: None, + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_report_metadata() { + let request = Request { + id: "req_metadata".into(), + method: Method::PaneReportMetadata(PaneReportMetadataParams { + pane_id: "1-1".into(), + source: "user:claude-title".into(), + agent: Some("claude".into()), + applies_to_source: Some("herdr:claude".into()), + title: Some("Refactor auth".into()), + display_agent: Some("Claude auth".into()), + custom_status: Some("refactor auth".into()), + state_labels: HashMap::from([("working".into(), "deep in the mines".into())]), + clear_title: false, + clear_display_agent: false, + clear_custom_status: false, + clear_state_labels: false, + seq: Some(42), + ttl_ms: Some(3_600_000), + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_clear_agent_authority() { + let request = Request { + id: "req_clear".into(), + method: Method::PaneClearAgentAuthority(PaneClearAgentAuthorityParams { + pane_id: "1-1".into(), + source: Some("herdr:pi".into()), + seq: Some(42), + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_pane_release_agent() { + let request = Request { + id: "req_release".into(), + method: Method::PaneReleaseAgent(PaneReleaseAgentParams { + pane_id: "1-1".into(), + source: "herdr:pi".into(), + agent: "pi".into(), + seq: Some(42), + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_uses_dot_method_names() { + let request = Request { + id: "req_1".into(), + method: Method::WorkspaceCreate(WorkspaceCreateParams { + cwd: Some("/tmp".into()), + focus: true, + label: Some("api".into()), + env: Default::default(), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "workspace.create"); +} + +#[test] +fn request_round_trips_for_server_stop() { + let request = Request { + id: "req_stop".into(), + method: Method::ServerStop(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.stop"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_server_reload_config() { + let request = Request { + id: "req_reload".into(), + method: Method::ServerReloadConfig(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.reload_config"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_server_reload_agent_manifests() { + let request = Request { + id: "req_reload_agent_manifests".into(), + method: Method::ServerReloadAgentManifests(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.reload_agent_manifests"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_server_agent_manifests() { + let request = Request { + id: "req_agent_manifests".into(), + method: Method::ServerAgentManifests(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.agent_manifests"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn request_round_trips_for_agent_explain() { + let request = Request { + id: "req_agent_explain".into(), + method: Method::AgentExplain(AgentTarget { + target: "agent-1".into(), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "agent.explain"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn notification_show_request_parses() { + let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}}"#; + let request: Request = serde_json::from_str(json).unwrap(); + let Method::NotificationShow(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!(params.title, "build failed"); + assert_eq!(params.body.as_deref(), Some("api workspace")); + assert_eq!( + params.position, + Some(crate::config::ToastHerdrPosition::TopLeft) + ); + assert_eq!(params.sound, NotificationShowSound::Request); +} + +#[test] +fn notification_show_sound_defaults_to_none() { + let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed"}}"#; + let request: Request = serde_json::from_str(json).unwrap(); + let Method::NotificationShow(params) = request.method else { + panic!("wrong method parsed"); + }; + + assert_eq!(params.sound, NotificationShowSound::None); +} + +#[test] +fn client_window_title_requests_round_trip() { + let set = Request { + id: "req_title_set".into(), + method: Method::ClientWindowTitleSet(ClientWindowTitleSetParams { + title: "herdr api".into(), + }), + }; + let json = serde_json::to_value(&set).unwrap(); + assert_eq!(json["method"], "client.window_title.set"); + assert_eq!(json["params"]["title"], "herdr api"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, set); + + let clear = Request { + id: "req_title_clear".into(), + method: Method::ClientWindowTitleClear(EmptyParams::default()), + }; + let json = serde_json::to_value(&clear).unwrap(); + assert_eq!(json["method"], "client.window_title.clear"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, clear); +} + +#[test] +fn unknown_method_is_rejected() { + let json = r#"{"id":"req_1","method":"nope","params":{}}"#; + let err = serde_json::from_str::<Request>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown variant")); +} + +#[test] +fn missing_required_params_are_rejected() { + let json = r#"{"id":"req_1","method":"pane.send_text","params":{"pane_id":"p_1"}}"#; + let err = serde_json::from_str::<Request>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("text")); +} + +#[test] +fn pane_send_input_defaults_to_empty_text_and_keys() { + let json = r#" + { + "id": "req_1", + "method": "pane.send_input", + "params": { + "pane_id": "p_1" + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::PaneSendInput(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!(params.pane_id, "p_1"); + assert!(params.text.is_empty()); + assert!(params.keys.is_empty()); +} + +#[test] +fn pane_wait_for_output_defaults_strip_ansi_to_true() { + let json = r#" + { + "id": "req_1", + "method": "pane.wait_for_output", + "params": { + "pane_id": "p_1", + "source": "recent", + "match": { "type": "substring", "value": "ready" } + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::PaneWaitForOutput(params) = request.method else { + panic!("wrong method parsed"); + }; + assert!(params.strip_ansi); +} + +#[test] +fn pane_read_defaults_to_text_format() { + let json = r#" + { + "id": "req_1", + "method": "pane.read", + "params": { + "pane_id": "p_1", + "source": "visible" + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::PaneRead(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!(params.format, ReadFormat::Text); +} + +#[test] +fn pane_current_request_round_trips() { + let request = Request { + id: "req_current".into(), + method: Method::PaneCurrent(PaneCurrentParams { + caller_pane_id: Some("w1-1".into()), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "pane.current"); + assert_eq!(json["params"]["caller_pane_id"], "w1-1"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn pane_process_info_request_round_trips() { + let request = Request { + id: "req_process_info".into(), + method: Method::PaneProcessInfo(PaneProcessInfoParams { + pane_id: Some("w1-1".into()), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "pane.process_info"); + assert_eq!(json["params"]["pane_id"], "w1-1"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} + +#[test] +fn event_envelope_round_trips() { + let event = EventEnvelope { + event: EventKind::PaneOutputChanged, + data: EventData::PaneOutputChanged { + pane_id: "p_1".into(), + workspace_id: "w_1".into(), + revision: 42, + }, + }; + + let json = serde_json::to_string(&event).unwrap(); + let restored: EventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); +} + +#[test] +fn subscribe_request_parses_parameterized_subscriptions() { + let json = r#" + { + "id": "sub_1", + "method": "events.subscribe", + "params": { + "subscriptions": [ + { + "type": "pane.output_matched", + "pane_id": "p_1_1", + "source": "recent", + "lines": 200, + "match": { "type": "substring", "value": "auth: received" } + }, + { + "type": "pane.agent_status_changed", + "pane_id": "p_1_1", + "agent_status": "done" + } + ] + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::EventsSubscribe(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!(params.subscriptions.len(), 2); + assert!(matches!( + ¶ms.subscriptions[0], + Subscription::PaneOutputMatched { + pane_id, + source: ReadSource::Recent, + lines: Some(200), + r#match: OutputMatch::Substring { value }, + strip_ansi: true, + } if pane_id == "p_1_1" && value == "auth: received" + )); + assert!(matches!( + ¶ms.subscriptions[1], + Subscription::PaneAgentStatusChanged { + pane_id, + agent_status: Some(AgentStatus::Done), + } if pane_id == "p_1_1" + )); +} + +#[test] +fn subscription_event_envelope_round_trips() { + let event = SubscriptionEventEnvelope { + event: SubscriptionEventKind::PaneOutputMatched, + data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent { + pane_id: "p_1_1".into(), + matched_line: "auth: received".into(), + read: PaneReadResult { + pane_id: "p_1_1".into(), + workspace_id: "w_1".into(), + tab_id: "t_1_1".into(), + source: ReadSource::Recent, + format: ReadFormat::Text, + text: "auth: received\n".into(), + revision: 0, + truncated: false, + }, + }), + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"event\":\"pane.output_matched\"")); + let restored: SubscriptionEventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); +} + +#[test] +fn success_response_round_trips() { + let response = SuccessResponse { + id: "req_1".into(), + result: ResponseResult::Pong { + version: "0.1.2".into(), + protocol: 6, + capabilities: Some(ServerCapabilities { live_handoff: true }), + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn worktree_request_and_response_round_trip() { + let request = Request { + id: "req_worktree".into(), + method: Method::WorktreeCreate(WorktreeCreateParams { + workspace_id: Some("1".into()), + branch: Some("worktree/api".into()), + base: Some("HEAD".into()), + focus: true, + ..WorktreeCreateParams::default() + }), + }; + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); + + let response = SuccessResponse { + id: "req_worktree".into(), + result: ResponseResult::WorktreeCreated { + workspace: WorkspaceInfo { + workspace_id: "w_1".into(), + number: 2, + label: "herdr".into(), + focused: true, + pane_count: 1, + tab_count: 1, + active_tab_id: "w_1:1".into(), + agent_status: AgentStatus::Unknown, + worktree: Some(WorkspaceWorktreeInfo { + repo_key: "/repo/herdr/.git".into(), + repo_name: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/worktrees/herdr/worktree-api".into(), + is_linked_worktree: true, + }), + }, + tab: TabInfo { + tab_id: "w_1:1".into(), + workspace_id: "w_1".into(), + number: 1, + label: "herdr".into(), + focused: true, + pane_count: 1, + agent_status: AgentStatus::Unknown, + }, + root_pane: PaneInfo { + pane_id: "w_1-1".into(), + terminal_id: "term_1".into(), + workspace_id: "w_1".into(), + tab_id: "w_1:1".into(), + focused: true, + cwd: Some("/worktrees/herdr/worktree-api".into()), + foreground_cwd: None, + label: None, + agent: None, + title: None, + display_agent: None, + agent_status: AgentStatus::Unknown, + custom_status: None, + state_labels: HashMap::new(), + agent_session: None, + revision: 0, + }, + worktree: WorktreeInfo { + path: "/worktrees/herdr/worktree-api".into(), + branch: Some("worktree/api".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: Some("w_1".into()), + label: "herdr".into(), + }, + }, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"type\":\"worktree_created\"")); + assert!(json.contains("\"worktree\"")); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn worktree_lifecycle_events_round_trip() { + let subscription = Request { + id: "sub_worktrees".into(), + method: Method::EventsSubscribe(EventsSubscribeParams { + subscriptions: vec![ + Subscription::WorktreeCreated {}, + Subscription::WorktreeOpened {}, + Subscription::WorktreeRemoved {}, + ], + }), + }; + let json = serde_json::to_string(&subscription).unwrap(); + assert!(json.contains("\"type\":\"worktree.created\"")); + assert!(json.contains("\"type\":\"worktree.opened\"")); + assert!(json.contains("\"type\":\"worktree.removed\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, subscription); + + let workspace = WorkspaceInfo { + workspace_id: "w_2".into(), + number: 2, + label: "herdr".into(), + focused: true, + pane_count: 1, + tab_count: 1, + active_tab_id: "w_2:1".into(), + agent_status: AgentStatus::Unknown, + worktree: Some(WorkspaceWorktreeInfo { + repo_key: "/repo/herdr/.git".into(), + repo_name: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/worktrees/herdr/worktree-api".into(), + is_linked_worktree: true, + }), + }; + let worktree = WorktreeInfo { + path: "/worktrees/herdr/worktree-api".into(), + branch: Some("worktree/api".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: Some("w_2".into()), + label: "herdr".into(), + }; + + for event in [ + EventEnvelope { + event: EventKind::WorktreeCreated, + data: EventData::WorktreeCreated { + workspace: workspace.clone(), + worktree: worktree.clone(), + }, + }, + EventEnvelope { + event: EventKind::WorktreeOpened, + data: EventData::WorktreeOpened { + workspace: workspace.clone(), + worktree: worktree.clone(), + already_open: false, + }, + }, + EventEnvelope { + event: EventKind::WorktreeRemoved, + data: EventData::WorktreeRemoved { + workspace_id: "w_2".into(), + worktree: WorktreeInfo { + open_workspace_id: None, + ..worktree.clone() + }, + forced: false, + }, + }, + EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { + workspace_id: "w_2".into(), + workspace: Some(workspace.clone()), + }, + }, + ] { + let json = serde_json::to_string(&event).unwrap(); + let restored: EventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); + } +} + +#[test] +fn plugin_link_list_unlink_round_trip() { + let link = Request { + id: "plugin_link".into(), + method: Method::PluginLink(PluginLinkParams { + path: "/plugins/worktree-bootstrap".into(), + enabled: true, + source: None, + }), + }; + let json = serde_json::to_string(&link).unwrap(); + assert!(json.contains("\"method\":\"plugin.link\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, link); + + let list = Request { + id: "plugin_list".into(), + method: Method::PluginList(PluginListParams { + plugin_id: Some("example.worktree-bootstrap".into()), + }), + }; + let json = serde_json::to_string(&list).unwrap(); + assert!(json.contains("\"method\":\"plugin.list\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, list); + + let unlink = Request { + id: "plugin_unlink".into(), + method: Method::PluginUnlink(PluginUnlinkParams { + plugin_id: "example.worktree-bootstrap".into(), + }), + }; + let json = serde_json::to_string(&unlink).unwrap(); + assert!(json.contains("\"method\":\"plugin.unlink\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, unlink); + + let plugin = InstalledPluginInfo { + plugin_id: "example.worktree-bootstrap".into(), + name: "Worktree Bootstrap".into(), + version: "0.1.0".into(), + description: Some("Prepare new worktrees".into()), + manifest_path: "/plugins/worktree-bootstrap/herdr-plugin.toml".into(), + plugin_root: "/plugins/worktree-bootstrap".into(), + enabled: true, + platforms: None, + build: vec![PluginManifestBuild { + platforms: None, + command: vec!["bun".into(), "install".into()], + }], + actions: vec![PluginManifestAction { + id: "bootstrap".into(), + title: "Bootstrap worktree".into(), + description: None, + contexts: vec![PluginActionContext::Workspace], + platforms: None, + command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()], + }], + events: vec![PluginManifestEventHook { + on: "worktree.created".into(), + platforms: None, + command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()], + }], + panes: vec![PluginManifestPane { + id: "board".into(), + title: "Board".into(), + description: None, + platforms: None, + placement: PluginPanePlacement::Overlay, + command: vec!["bun".into(), "run".into(), "board.ts".into()], + }], + link_handlers: vec![PluginManifestLinkHandler { + id: "github-pr".into(), + title: "Open GitHub PR".into(), + pattern: "^https://github.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(), + action: "bootstrap".into(), + platforms: None, + }], + source: Default::default(), + warnings: vec![], + }; + + for response in [ + SuccessResponse { + id: "plugin_link".into(), + result: ResponseResult::PluginLinked { + plugin: plugin.clone(), + }, + }, + SuccessResponse { + id: "plugin_list".into(), + result: ResponseResult::PluginList { + plugins: vec![plugin.clone()], + }, + }, + SuccessResponse { + id: "plugin_unlink".into(), + result: ResponseResult::PluginUnlinked { + plugin_id: plugin.plugin_id.clone(), + removed: true, + }, + }, + ] { + let json = serde_json::to_string(&response).unwrap(); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); + } +} + +#[test] +fn layout_export_apply_round_trip() { + let root = LayoutNode::Split { + direction: SplitDirection::Right, + ratio: 0.6, + first: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("editor".into()), + cwd: Some("/repo".into()), + ..Default::default() + }, + }), + second: Box::new(LayoutNode::Pane { + pane: LayoutPane { + label: Some("tests".into()), + command: Some(vec!["sh".into(), "-c".into(), "just test".into()]), + env: HashMap::from([("HERDR_ROLE".into(), "tests".into())]), + ..Default::default() + }, + }), + }; + + let export = Request { + id: "layout_export".into(), + method: Method::LayoutExport(LayoutExportParams { + tab_id: Some("w1:1".into()), + pane_id: None, + }), + }; + let json = serde_json::to_string(&export).unwrap(); + assert!(json.contains("\"method\":\"layout.export\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, export); + + let apply = Request { + id: "layout_apply".into(), + method: Method::LayoutApply(LayoutApplyParams { + workspace_id: Some("w1".into()), + tab_id: None, + tab_label: Some("dev".into()), + focus: true, + root: root.clone(), + }), + }; + let json = serde_json::to_string(&apply).unwrap(); + assert!(json.contains("\"method\":\"layout.apply\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, apply); + + let response = SuccessResponse { + id: "layout_export".into(), + result: ResponseResult::LayoutExport { + layout: LayoutDescription { + workspace_id: "w1".into(), + tab_id: "w1:1".into(), + zoomed: false, + focused_pane_id: "w1-1".into(), + root, + }, + }, + }; + let json = serde_json::to_string(&response).unwrap(); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn create_response_round_trips_with_root_pane() { + let response = SuccessResponse { + id: "req_2".into(), + result: ResponseResult::TabCreated { + tab: TabInfo { + tab_id: "w_1:2".into(), + workspace_id: "w_1".into(), + number: 2, + label: "review".into(), + focused: false, + pane_count: 1, + agent_status: AgentStatus::Unknown, + }, + root_pane: PaneInfo { + pane_id: "w_1-3".into(), + terminal_id: "term_example".into(), + workspace_id: "w_1".into(), + tab_id: "w_1:2".into(), + focused: false, + cwd: Some("/tmp/review".into()), + foreground_cwd: None, + label: None, + agent: None, + title: None, + display_agent: None, + agent_status: AgentStatus::Unknown, + custom_status: None, + state_labels: HashMap::new(), + agent_session: None, + revision: 0, + }, + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"type\":\"tab_created\"")); + assert!(json.contains("\"root_pane\"")); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn error_response_round_trips() { + let response = ErrorResponse { + id: "req_1".into(), + error: ErrorBody { + code: "pane_not_found".into(), + message: "pane p_1 not found".into(), + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + let restored: ErrorResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + +#[test] +fn event_wait_parses_typed_match() { + let json = r#" + { + "id": "req_9", + "method": "events.wait", + "params": { + "match_event": { + "event": "pane_agent_status_changed", + "pane_id": "p_1", + "agent_status": "done" + }, + "timeout_ms": 30000 + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::EventsWait(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!( + params.match_event, + EventMatch::PaneAgentStatusChanged { + pane_id: "p_1".into(), + agent_status: AgentStatus::Done, + } + ); +} + +#[test] +fn plugin_action_list_and_invoke_round_trips() { + let list = Request { + id: "req_plugin_action_list".into(), + method: Method::PluginActionList(PluginActionListParams { + plugin_id: Some("example.issue-flow".into()), + }), + }; + let json = serde_json::to_value(&list).unwrap(); + assert_eq!(json["method"], "plugin.action.list"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, list); + + let invoke = Request { + id: "req_plugin_action_invoke".into(), + method: Method::PluginActionInvoke(PluginActionInvokeParams { + plugin_id: Some("example.issue-flow".into()), + action_id: "assign-issue".into(), + context: None, + }), + }; + let json = serde_json::to_value(&invoke).unwrap(); + assert_eq!(json["method"], "plugin.action.invoke"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, invoke); + + let action_info = PluginActionInfo { + plugin_id: "example.issue-flow".into(), + action_id: "assign-issue".into(), + title: "Assign Issue".into(), + description: Some("Open the issue assignment UI".into()), + contexts: vec![PluginActionContext::Workspace, PluginActionContext::Pane], + command: vec!["assign".into(), "--issue".into()], + platforms: Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]), + }; + assert_eq!( + action_info.qualified_id(), + "example.issue-flow.assign-issue" + ); + let json = serde_json::to_string(&action_info).unwrap(); + let restored: PluginActionInfo = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, action_info); +} + +#[test] +fn plugin_pane_open_request_round_trips() { + let request = Request { + id: "req_plugin_pane".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.board".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Zoomed), + workspace_id: None, + target_pane_id: Some("1-1".into()), + direction: Some(SplitDirection::Right), + cwd: Some("/tmp".into()), + focus: true, + env: [("HERDR_ROLE".to_string(), "board".to_string())].into(), + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "plugin.pane.open"); + assert_eq!(json["params"]["env"]["HERDR_ROLE"], "board"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); +} diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs new file mode 100644 index 00000000..d15cf09b --- /dev/null +++ b/src/api/schema/workspaces.rs @@ -0,0 +1,46 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::common::AgentStatus; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap<String, String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceRenameParams { + pub workspace_id: String, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceInfo { + pub workspace_id: String, + pub number: usize, + pub label: String, + pub focused: bool, + pub pane_count: usize, + pub tab_count: usize, + pub active_tab_id: String, + pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option<WorkspaceWorktreeInfo>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceWorktreeInfo { + pub repo_key: String, + pub repo_name: String, + pub repo_root: String, + pub checkout_path: String, + pub is_linked_worktree: bool, +} diff --git a/src/api/schema/worktrees.rs b/src/api/schema/worktrees.rs new file mode 100644 index 00000000..f480edb1 --- /dev/null +++ b/src/api/schema/worktrees.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WorktreeListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WorktreeCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WorktreeOpenParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeRemoveParams { + pub workspace_id: String, + #[serde(default)] + pub force: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeSourceInfo { + pub repo_key: String, + pub repo_name: String, + pub repo_root: String, + pub source_checkout_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_workspace_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeInfo { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option<String>, + pub is_bare: bool, + pub is_detached: bool, + pub is_prunable: bool, + pub is_linked_worktree: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_workspace_id: Option<String>, + pub label: String, +} |
