summaryrefslogtreecommitdiff
path: root/src/api
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 02:43:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 03:47:07 +0300
commitfbd20ad6e488fb3b9efc1612fdffee6e672976c1 (patch)
treed21daf93241786f8aa6fde23b41cdb9e15182b79 /src/api
parentd35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (diff)
feat: add plugin v1 system
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
Diffstat (limited to 'src/api')
-rw-r--r--src/api/mod.rs5
-rw-r--r--src/api/schema.rs1534
-rw-r--r--src/api/schema/agents.rs87
-rw-r--r--src/api/schema/common.rs128
-rw-r--r--src/api/schema/events.rs474
-rw-r--r--src/api/schema/integrations.rs38
-rw-r--r--src/api/schema/panes.rs110
-rw-r--r--src/api/schema/plugins.rs343
-rw-r--r--src/api/schema/response.rs248
-rw-r--r--src/api/schema/server.rs19
-rw-r--r--src/api/schema/tabs.rs42
-rw-r--r--src/api/schema/tests.rs975
-rw-r--r--src/api/schema/workspaces.rs46
-rw-r--r--src/api/schema/worktrees.rs74
-rw-r--r--src/api/server.rs17
-rw-r--r--src/api/subscriptions.rs12
16 files changed, 2669 insertions, 1483 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 4832ac56..62247aa7 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -35,6 +35,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::TabFocus(_)
| Method::TabRename(_)
| Method::TabClose(_)
+ | Method::LayoutApply(_)
| Method::AgentRename(_)
| Method::AgentFocus(_)
| Method::AgentStart(_)
@@ -51,6 +52,10 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::PaneClearAgentAuthority(_)
| Method::PaneReleaseAgent(_)
| Method::PaneClose(_)
+ | Method::PluginActionInvoke(_)
+ | Method::PluginPaneOpen(_)
+ | Method::PluginPaneFocus(_)
+ | Method::PluginPaneClose(_)
)
}
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 512c030f..b2d6f62e 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -1,10 +1,28 @@
-use std::collections::HashMap;
-
use serde::{Deserialize, Serialize};
+pub mod agents;
+pub mod common;
+pub mod events;
+pub mod integrations;
pub mod panes;
-
+pub mod plugins;
+pub mod response;
+pub mod server;
+pub mod tabs;
+pub mod workspaces;
+pub mod worktrees;
+
+pub use agents::*;
+pub use common::*;
+pub use events::*;
+pub use integrations::*;
pub use panes::*;
+pub use plugins::*;
+pub use response::*;
+pub use server::*;
+pub use tabs::*;
+pub use workspaces::*;
+pub use worktrees::*;
fn is_false(value: &bool) -> bool {
!*value
@@ -19,6 +37,9 @@ pub struct Request {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "method", content = "params")]
+// Request enums are short-lived wire values; keeping variants direct preserves
+// the simple serde shape and avoids boxing churn across every caller.
+#[allow(clippy::large_enum_variant)]
pub enum Method {
#[serde(rename = "ping")]
Ping(PingParams),
@@ -34,6 +55,10 @@ pub enum Method {
ServerReloadAgentManifests(EmptyParams),
#[serde(rename = "notification.show")]
NotificationShow(NotificationShowParams),
+ #[serde(rename = "client.window_title.set")]
+ ClientWindowTitleSet(ClientWindowTitleSetParams),
+ #[serde(rename = "client.window_title.clear")]
+ ClientWindowTitleClear(EmptyParams),
#[serde(rename = "workspace.create")]
WorkspaceCreate(WorkspaceCreateParams),
#[serde(rename = "workspace.list")]
@@ -92,6 +117,12 @@ pub enum Method {
PaneZoom(PaneZoomParams),
#[serde(rename = "pane.layout")]
PaneLayout(PaneLayoutParams),
+ #[serde(rename = "pane.process_info")]
+ PaneProcessInfo(PaneProcessInfoParams),
+ #[serde(rename = "layout.export")]
+ LayoutExport(LayoutExportParams),
+ #[serde(rename = "layout.apply")]
+ LayoutApply(LayoutApplyParams),
#[serde(rename = "pane.neighbor")]
PaneNeighbor(PaneNeighborParams),
#[serde(rename = "pane.edges")]
@@ -102,6 +133,8 @@ pub enum Method {
PaneResize(PaneResizeParams),
#[serde(rename = "pane.list")]
PaneList(PaneListParams),
+ #[serde(rename = "pane.current")]
+ PaneCurrent(PaneCurrentParams),
#[serde(rename = "pane.get")]
PaneGet(PaneTarget),
#[serde(rename = "pane.rename")]
@@ -136,1478 +169,29 @@ pub enum Method {
IntegrationInstall(IntegrationInstallParams),
#[serde(rename = "integration.uninstall")]
IntegrationUninstall(IntegrationUninstallParams),
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
-pub struct EmptyParams {}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
-pub struct PingParams {}
-
-#[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, 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 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>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct WorkspaceRenameParams {
- pub workspace_id: String,
- pub label: String,
-}
-
-#[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 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>,
-}
-
-#[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 AgentTarget {
- pub target: String,
-}
-
-#[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 = "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>,
-}
-
-#[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, 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 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 = "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 = "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 = "default_true")]
- pub strip_ansi: bool,
-}
-
-#[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)]
-#[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,
- TabCreated,
- TabClosed,
- TabRenamed,
- TabFocused,
- PaneCreated,
- PaneClosed,
- PaneFocused,
- PaneMoved,
- PaneOutputChanged,
- PaneExited,
- PaneAgentDetected,
- PaneAgentStatusChanged,
-}
-
-#[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, Eq, Serialize, Deserialize)]
-pub struct ServerCapabilities {
- pub live_handoff: bool,
-}
-
-#[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>,
- },
- PaneSwap {
- swap: PaneSwapResult,
- },
- PaneMove {
- move_result: PaneMoveResult,
- },
- PaneZoom {
- zoom: PaneZoomResult,
- },
- PaneLayout {
- layout: PaneLayoutSnapshot,
- },
- 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,
- },
- 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>,
- },
- 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>,
-}
-
-#[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,
-}
-
-#[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,
-}
-
-#[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,
-}
-
-#[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 = "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,
-}
-
-#[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>,
-}
-
-#[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,
- },
- WorkspaceRenamed {
- workspace_id: String,
- label: String,
- },
- WorkspaceFocused {
- workspace_id: String,
- },
- 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>,
- },
-}
-
-#[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,
-}
-
-fn default_true() -> bool {
- true
+ #[serde(rename = "plugin.link")]
+ PluginLink(PluginLinkParams),
+ #[serde(rename = "plugin.list")]
+ PluginList(PluginListParams),
+ #[serde(rename = "plugin.unlink")]
+ PluginUnlink(PluginUnlinkParams),
+ #[serde(rename = "plugin.enable")]
+ PluginEnable(PluginSetEnabledParams),
+ #[serde(rename = "plugin.disable")]
+ PluginDisable(PluginSetEnabledParams),
+ #[serde(rename = "plugin.action.list")]
+ PluginActionList(PluginActionListParams),
+ #[serde(rename = "plugin.action.invoke")]
+ PluginActionInvoke(PluginActionInvokeParams),
+ #[serde(rename = "plugin.log.list")]
+ PluginLogList(PluginLogListParams),
+ #[serde(rename = "plugin.pane.open")]
+ PluginPaneOpen(PluginPaneOpenParams),
+ #[serde(rename = "plugin.pane.focus")]
+ PluginPaneFocus(PluginPaneFocusParams),
+ #[serde(rename = "plugin.pane.close")]
+ PluginPaneClose(PluginPaneCloseParams),
}
#[cfg(test)]
-mod tests {
- 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: "w1:p1".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: "w1:p1".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: "w1:p1".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: "w1:p1".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: "w1:p1".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()),
- }),
- };
-
- 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 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 event_envelope_round_trips() {
- let event = EventEnvelope {
- event: EventKind::PaneOutputChanged,
- data: EventData::PaneOutputChanged {
- pane_id: "w1:p1".into(),
- workspace_id: "w1".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!(
- &params.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!(
- &params.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: "w1:p1".into(),
- matched_line: "auth: received".into(),
- read: PaneReadResult {
- pane_id: "w1:p1".into(),
- workspace_id: "w1".into(),
- tab_id: "w1:t1".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: "w1".into(),
- number: 2,
- label: "herdr".into(),
- focused: true,
- pane_count: 1,
- tab_count: 1,
- active_tab_id: "w1:t1".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: "w1:t1".into(),
- workspace_id: "w1".into(),
- number: 1,
- label: "herdr".into(),
- focused: true,
- pane_count: 1,
- agent_status: AgentStatus::Unknown,
- },
- root_pane: PaneInfo {
- pane_id: "w1:p1".into(),
- terminal_id: "term_1".into(),
- workspace_id: "w1".into(),
- tab_id: "w1:t1".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("w1".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 create_response_round_trips_with_root_pane() {
- let response = SuccessResponse {
- id: "req_2".into(),
- result: ResponseResult::TabCreated {
- tab: TabInfo {
- tab_id: "w1:t2".into(),
- workspace_id: "w1".into(),
- number: 2,
- label: "review".into(),
- focused: false,
- pane_count: 1,
- agent_status: AgentStatus::Unknown,
- },
- root_pane: PaneInfo {
- pane_id: "w1:p3".into(),
- terminal_id: "term_example".into(),
- workspace_id: "w1".into(),
- tab_id: "w1:t2".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,
- }
- );
- }
-}
+mod tests;
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!(
+ &params.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!(
+ &params.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,
+}
diff --git a/src/api/server.rs b/src/api/server.rs
index 5650b2d0..e9e2b01b 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -279,6 +279,8 @@ fn api_method_name(method: &Method) -> &'static str {
Method::ServerAgentManifests(_) => "server.agent_manifests",
Method::ServerReloadAgentManifests(_) => "server.reload_agent_manifests",
Method::NotificationShow(_) => "notification.show",
+ Method::ClientWindowTitleSet(_) => "client.window_title.set",
+ Method::ClientWindowTitleClear(_) => "client.window_title.clear",
Method::WorkspaceCreate(_) => "workspace.create",
Method::WorkspaceList(_) => "workspace.list",
Method::WorkspaceGet(_) => "workspace.get",
@@ -308,11 +310,15 @@ fn api_method_name(method: &Method) -> &'static str {
Method::PaneMove(_) => "pane.move",
Method::PaneZoom(_) => "pane.zoom",
Method::PaneLayout(_) => "pane.layout",
+ Method::PaneProcessInfo(_) => "pane.process_info",
+ Method::LayoutExport(_) => "layout.export",
+ Method::LayoutApply(_) => "layout.apply",
Method::PaneNeighbor(_) => "pane.neighbor",
Method::PaneEdges(_) => "pane.edges",
Method::PaneFocusDirection(_) => "pane.focus_direction",
Method::PaneResize(_) => "pane.resize",
Method::PaneList(_) => "pane.list",
+ Method::PaneCurrent(_) => "pane.current",
Method::PaneGet(_) => "pane.get",
Method::PaneRename(_) => "pane.rename",
Method::PaneSendText(_) => "pane.send_text",
@@ -330,6 +336,17 @@ fn api_method_name(method: &Method) -> &'static str {
Method::PaneWaitForOutput(_) => "pane.wait_for_output",
Method::IntegrationInstall(_) => "integration.install",
Method::IntegrationUninstall(_) => "integration.uninstall",
+ Method::PluginLink(_) => "plugin.link",
+ Method::PluginList(_) => "plugin.list",
+ Method::PluginUnlink(_) => "plugin.unlink",
+ Method::PluginEnable(_) => "plugin.enable",
+ Method::PluginDisable(_) => "plugin.disable",
+ Method::PluginActionList(_) => "plugin.action.list",
+ Method::PluginActionInvoke(_) => "plugin.action.invoke",
+ Method::PluginLogList(_) => "plugin.log.list",
+ Method::PluginPaneOpen(_) => "plugin.pane.open",
+ Method::PluginPaneFocus(_) => "plugin.pane.focus",
+ Method::PluginPaneClose(_) => "plugin.pane.close",
}
}
diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs
index e8e362fe..d71ea24d 100644
--- a/src/api/subscriptions.rs
+++ b/src/api/subscriptions.rs
@@ -128,6 +128,18 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceFocused,
last_sequence: 0,
})),
+ Subscription::WorktreeCreated {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::WorktreeCreated,
+ last_sequence: 0,
+ })),
+ Subscription::WorktreeOpened {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::WorktreeOpened,
+ last_sequence: 0,
+ })),
+ Subscription::WorktreeRemoved {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::WorktreeRemoved,
+ last_sequence: 0,
+ })),
Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::TabCreated,
last_sequence: 0,