aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
-rw-r--r--src/app/actions.rs75
-rw-r--r--src/app/agent_resume.rs9
-rw-r--r--src/app/agents.rs12
-rw-r--r--src/app/api.rs166
-rw-r--r--src/app/api/agents.rs6
-rw-r--r--src/app/api/env.rs61
-rw-r--r--src/app/api/layouts.rs758
-rw-r--r--src/app/api/panes.rs386
-rw-r--r--src/app/api/plugins/context.rs379
-rw-r--r--src/app/api/plugins/env.rs19
-rw-r--r--src/app/api/plugins/manifest.rs539
-rw-r--r--src/app/api/plugins/mod.rs2704
-rw-r--r--src/app/api/plugins/panes.rs284
-rw-r--r--src/app/api/plugins/runtime.rs271
-rw-r--r--src/app/api/tabs.rs16
-rw-r--r--src/app/api/workspaces.rs59
-rw-r--r--src/app/api/worktrees.rs114
-rw-r--r--src/app/creation.rs13
-rw-r--r--src/app/ids.rs20
-rw-r--r--src/app/input/mod.rs12
-rw-r--r--src/app/input/navigate.rs91
-rw-r--r--src/app/mod.rs37
-rw-r--r--src/app/state.rs22
-rw-r--r--src/cli.rs75
-rw-r--r--src/cli/agent.rs25
-rw-r--r--src/cli/pane.rs118
-rw-r--r--src/cli/plugin.rs1694
-rw-r--r--src/cli/tab.rs21
-rw-r--r--src/cli/workspace.rs27
-rw-r--r--src/client/mod.rs26
-rw-r--r--src/config/keybinds.rs3
-rw-r--r--src/detect/manifest/tests.rs24
-rw-r--r--src/detect/manifest_update.rs16
-rw-r--r--src/events.rs9
-rw-r--r--src/integration/mod.rs16
-rw-r--r--src/pane.rs80
-rw-r--r--src/persist.rs2
-rw-r--r--src/persist/plugin_registry.rs237
-rw-r--r--src/persist/restore.rs24
-rw-r--r--src/protocol/wire.rs19
-rw-r--r--src/server/headless.rs67
-rw-r--r--src/server/notifications.rs2
-rw-r--r--src/terminal/runtime.rs22
-rw-r--r--src/ui/mobile.rs2
-rw-r--r--src/ui/sidebar.rs2
-rw-r--r--src/workspace.rs222
-rw-r--r--src/workspace/tab.rs111
63 files changed, 11347 insertions, 1702 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,
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 23b886d9..a7d99f35 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1349,6 +1349,15 @@ impl AppState {
.collect()
}
+ pub(crate) fn pane_ids_for_workspace(&self, ws_idx: usize) -> Vec<PaneId> {
+ self.workspaces
+ .get(ws_idx)
+ .into_iter()
+ .flat_map(|ws| &ws.tabs)
+ .flat_map(|tab| tab.layout.pane_ids())
+ .collect()
+ }
+
pub(crate) fn terminal_ids_for_tab(
&self,
ws_idx: usize,
@@ -1363,6 +1372,14 @@ impl AppState {
.collect()
}
+ pub(crate) fn pane_ids_for_tab(&self, ws_idx: usize, tab_idx: usize) -> Vec<PaneId> {
+ self.workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.get(tab_idx))
+ .map(|tab| tab.layout.pane_ids())
+ .unwrap_or_default()
+ }
+
pub(crate) fn terminal_id_for_pane(
&self,
ws_idx: usize,
@@ -1395,6 +1412,15 @@ impl AppState {
}
}
+ pub(crate) fn remove_plugin_pane_records(
+ &mut self,
+ pane_ids: impl IntoIterator<Item = PaneId>,
+ ) {
+ for pane_id in pane_ids {
+ self.plugin_panes.remove(&pane_id);
+ }
+ }
+
pub fn close_selected_workspace(&mut self) {
if self.workspaces.is_empty() {
return;
@@ -1422,12 +1448,15 @@ impl AppState {
.unwrap_or_else(|| vec![self.selected]);
let mut terminal_ids = Vec::new();
+ let mut pane_ids = Vec::new();
for idx in &close_indices {
terminal_ids.extend(self.terminal_ids_for_workspace(*idx));
+ pane_ids.extend(self.pane_ids_for_workspace(*idx));
if let Some(workspace_id) = self.workspaces.get(*idx).map(|ws| ws.id.clone()) {
crate::logging::workspace_closed(&workspace_id);
}
}
+ self.remove_plugin_pane_records(pane_ids);
for idx in close_indices.iter().rev() {
self.workspaces.remove(*idx);
}
@@ -1761,9 +1790,14 @@ impl AppState {
.and_then(|(i, pane_id)| self.terminal_id_for_pane(i, pane_id))
.into_iter()
.collect::<Vec<_>>();
+ let pane_ids = active
+ .and_then(|i| self.workspaces.get(i).and_then(|ws| ws.focused_pane_id()))
+ .into_iter()
+ .collect::<Vec<_>>();
let should_close_workspace = active
.and_then(|i| self.workspaces.get_mut(i))
.is_some_and(|ws| ws.close_focused());
+ self.remove_plugin_pane_records(pane_ids);
if should_close_workspace {
if let Some(active) = active {
self.selected = active;
@@ -1810,12 +1844,18 @@ impl AppState {
.get(ws_idx)
.map(|ws| self.terminal_ids_for_tab(ws_idx, ws.active_tab))
.unwrap_or_default();
+ let pane_ids = self
+ .workspaces
+ .get(ws_idx)
+ .map(|ws| self.pane_ids_for_tab(ws_idx, ws.active_tab))
+ .unwrap_or_default();
let Some(ws) = self.workspaces.get_mut(ws_idx) else {
return false;
};
let workspace_id = ws.id.clone();
let closing_tab_id = format!("{}:{}", workspace_id, ws.active_tab + 1);
ws.close_active_tab();
+ self.remove_plugin_pane_records(pane_ids);
self.remove_unattached_terminal_ids(terminal_ids);
crate::logging::tab_closed(&workspace_id, &closing_tab_id);
self.tab_scroll_follow_active = true;
@@ -2502,6 +2542,7 @@ impl AppState {
}
AppEvent::WorktreeAddFinished(_) => Vec::new(),
AppEvent::WorktreeRemoveFinished(_) => Vec::new(),
+ AppEvent::PluginCommandFinished { .. } => Vec::new(),
}
}
@@ -2791,6 +2832,7 @@ impl AppState {
fn handle_pane_died(&mut self, pane_id: PaneId) {
self.pending_agent_notifications.remove(&pane_id);
+ self.plugin_panes.remove(&pane_id);
let ws_idx = self
.workspaces
.iter()
@@ -3161,10 +3203,10 @@ mod tests {
0,
crate::terminal_theme::TerminalTheme::default(),
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
+ &crate::pane::PaneLaunchEnv::default(),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
- None,
)
.unwrap();
@@ -4875,11 +4917,19 @@ mod tests {
#[test]
fn close_pane_removes_from_workspace() {
let mut state = app_with_workspaces(&["test"]);
- state.workspaces[0].test_split(Direction::Horizontal);
+ let closed = state.workspaces[0].test_split(Direction::Horizontal);
assert_eq!(state.workspaces[0].panes.len(), 2);
+ state.plugin_panes.insert(
+ closed,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ },
+ );
state.close_pane();
assert_eq!(state.workspaces[0].panes.len(), 1);
+ assert!(!state.plugin_panes.contains_key(&closed));
}
#[test]
@@ -4935,22 +4985,37 @@ mod tests {
state.workspaces[0].switch_tab(tab_idx);
let pane_id = state.workspaces[0].tabs[tab_idx].root_pane;
let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap();
+ state.plugin_panes.insert(
+ pane_id,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ },
+ );
state.close_tab();
assert!(!state.terminals.contains_key(&terminal_id));
+ assert!(!state.plugin_panes.contains_key(&pane_id));
}
#[test]
fn close_workspace_removes_unattached_terminal_states() {
let mut state = app_with_workspaces(&["one", "two"]);
- let terminal_id = state
- .terminal_id_for_pane(0, state.workspaces[0].tabs[0].root_pane)
- .unwrap();
+ let pane_id = state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap();
+ state.plugin_panes.insert(
+ pane_id,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ },
+ );
state.close_selected_workspace();
assert!(!state.terminals.contains_key(&terminal_id));
+ assert!(!state.plugin_panes.contains_key(&pane_id));
}
#[test]
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs
index cf691e33..6a24487f 100644
--- a/src/app/agent_resume.rs
+++ b/src/app/agent_resume.rs
@@ -223,9 +223,12 @@ impl App {
);
return false;
};
- let public_pane_id = self
+ let Some(launch_env) = self
.find_pane(pane_id)
- .and_then(|(ws_idx, _)| self.public_pane_id(ws_idx, pane_id));
+ .and_then(|(ws_idx, _)| self.pane_launch_env(ws_idx, pane_id, Vec::new()))
+ else {
+ return false;
+ };
let runtime = match crate::terminal::TerminalRuntime::spawn(
pane_id,
@@ -235,10 +238,10 @@ impl App {
self.state.pane_scrollback_limit_bytes,
host_terminal_theme,
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
+ &launch_env,
self.event_tx.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
- public_pane_id.as_deref(),
) {
Ok(runtime) => runtime,
Err(err) => {
diff --git a/src/app/agents.rs b/src/app/agents.rs
index ea828eae..262bb0e4 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -97,6 +97,7 @@ impl App {
pub(super) fn start_agent(
&mut self,
params: AgentStartParams,
+ extra_env: Vec<(String, String)>,
) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> {
let name = params.name.trim().to_string();
if name.is_empty() {
@@ -145,6 +146,7 @@ impl App {
params.split.unwrap_or(SplitDirection::Right),
cwd,
&argv,
+ extra_env,
focus,
)?
} else if let Some(workspace_id) = params.workspace_id {
@@ -161,10 +163,11 @@ impl App {
params.split.unwrap_or(SplitDirection::Right),
cwd,
&argv,
+ extra_env,
focus,
)?
} else if self.state.workspaces.is_empty() {
- self.spawn_agent_workspace(cwd, rows, cols, &argv, focus)?
+ self.spawn_agent_workspace(cwd, rows, cols, &argv, extra_env, focus)?
} else {
let ws_idx = self.state.active.unwrap_or(0);
let tab_idx = self.state.workspaces[ws_idx].active_tab;
@@ -175,6 +178,7 @@ impl App {
params.split.unwrap_or(SplitDirection::Right),
cwd,
&argv,
+ extra_env,
focus,
)?
};
@@ -314,9 +318,10 @@ impl App {
rows: u16,
cols: u16,
argv: &[String],
+ extra_env: Vec<(String, String)>,
focus: bool,
) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> {
- let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command(
+ let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command_with_extra_env(
cwd,
rows,
cols,
@@ -326,6 +331,7 @@ impl App {
self.event_tx.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
+ extra_env,
)
.map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?;
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
@@ -350,6 +356,7 @@ impl App {
split: SplitDirection,
cwd: PathBuf,
argv: &[String],
+ extra_env: Vec<(String, String)>,
focus: bool,
) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> {
let (rows, cols) = self.state.estimate_pane_size();
@@ -370,6 +377,7 @@ impl App {
cols,
Some(cwd),
argv,
+ extra_env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
focus,
diff --git a/src/app/api.rs b/src/app/api.rs
index 3a7276c4..6dc48953 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -2,8 +2,11 @@ use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
mod agents;
+mod env;
mod integrations;
+mod layouts;
mod panes;
+pub(crate) mod plugins;
mod responses;
mod tabs;
mod workspaces;
@@ -56,6 +59,37 @@ impl App {
return;
}
+ if let AppEvent::PluginCommandFinished {
+ log_id,
+ finished_unix_ms,
+ exit_code,
+ stdout,
+ stderr,
+ error,
+ } = ev
+ {
+ self.state.plugin_commands_in_flight =
+ self.state.plugin_commands_in_flight.saturating_sub(1);
+ if let Some(log) = self
+ .state
+ .plugin_command_logs
+ .iter_mut()
+ .find(|log| log.log_id == log_id)
+ {
+ log.finished_unix_ms = Some(finished_unix_ms);
+ log.exit_code = exit_code;
+ log.stdout = Some(stdout);
+ log.stderr = Some(stderr);
+ log.error = error;
+ log.status = if log.error.is_none() && log.exit_code == Some(0) {
+ crate::api::schema::PluginCommandStatus::Succeeded
+ } else {
+ crate::api::schema::PluginCommandStatus::Failed
+ };
+ }
+ return;
+ }
+
if let AppEvent::WorktreeAddFinished(result) = ev {
self.handle_worktree_add_finished(result);
return;
@@ -327,7 +361,6 @@ impl App {
let Some((ws_idx, pane_state)) = self.find_pane(pane_id) else {
return false;
};
- let public_pane_id = self.public_pane_id(ws_idx, pane_id);
let terminal_id = pane_state.attached_terminal_id.clone();
let Some(terminal) = self.state.terminals.get(&terminal_id) else {
return false;
@@ -339,6 +372,9 @@ impl App {
.get(&terminal_id)
.map(|runtime| runtime.current_size())
.unwrap_or_else(|| self.state.estimate_pane_size());
+ let Some(launch_env) = self.pane_launch_env(ws_idx, pane_id, Vec::new()) else {
+ return false;
+ };
let runtime = match crate::terminal::TerminalRuntime::spawn(
pane_id,
rows,
@@ -347,10 +383,10 @@ impl App {
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
+ &launch_env,
self.event_tx.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
- public_pane_id.as_deref(),
) {
Ok(runtime) => runtime,
Err(err) => {
@@ -373,7 +409,7 @@ impl App {
true
}
- pub(crate) fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) {
+ pub(crate) fn emit_pane_state_update(&mut self, update: &crate::app::actions::PaneStateUpdate) {
let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else {
return;
};
@@ -573,7 +609,8 @@ impl App {
}
}
- pub(super) fn emit_event(&self, event: crate::api::schema::EventEnvelope) {
+ pub(super) fn emit_event(&mut self, event: crate::api::schema::EventEnvelope) {
+ self.run_plugin_event_hooks(&event);
self.event_hub.push(event);
}
@@ -716,6 +753,15 @@ impl App {
Method::NotificationShow(params) => {
return self.handle_notification_show(request.id, params);
}
+ Method::ClientWindowTitleSet(_) | Method::ClientWindowTitleClear(_) => {
+ return responses::encode_success(
+ request.id,
+ ResponseResult::ClientWindowTitle {
+ changed: false,
+ reason: crate::api::schema::ClientWindowTitleReason::NoForegroundClient,
+ },
+ );
+ }
Method::WorkspaceList(_) => return self.handle_workspace_list(request.id),
Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target),
Method::WorkspaceCreate(params) => {
@@ -757,6 +803,11 @@ impl App {
Method::PaneMove(params) => return self.handle_pane_move(request.id, params),
Method::PaneZoom(params) => return self.handle_pane_zoom(request.id, params),
Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params),
+ Method::PaneProcessInfo(params) => {
+ return self.handle_pane_process_info(request.id, params);
+ }
+ Method::LayoutExport(params) => return self.handle_layout_export(request.id, params),
+ Method::LayoutApply(params) => return self.handle_layout_apply(request.id, params),
Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params),
Method::PaneEdges(params) => return self.handle_pane_edges(request.id, params),
Method::PaneFocusDirection(params) => {
@@ -764,6 +815,7 @@ impl App {
}
Method::PaneResize(params) => return self.handle_pane_resize(request.id, params),
Method::PaneList(params) => return self.handle_pane_list(request.id, params),
+ Method::PaneCurrent(params) => return self.handle_pane_current(request.id, params),
Method::PaneGet(target) => return self.handle_pane_get(request.id, target),
Method::PaneRename(params) => return self.handle_pane_rename(request.id, params),
Method::PaneRead(params) => return self.handle_pane_read(request.id, params),
@@ -794,6 +846,39 @@ impl App {
Method::IntegrationUninstall(params) => {
return self.handle_integration_uninstall(request.id, params);
}
+ Method::PluginLink(params) => {
+ return self.handle_plugin_link(request.id, params);
+ }
+ Method::PluginList(params) => {
+ return self.handle_plugin_list(request.id, params);
+ }
+ Method::PluginUnlink(params) => {
+ return self.handle_plugin_unlink(request.id, params);
+ }
+ Method::PluginEnable(params) => {
+ return self.handle_plugin_enable(request.id, params);
+ }
+ Method::PluginDisable(params) => {
+ return self.handle_plugin_disable(request.id, params);
+ }
+ Method::PluginActionList(params) => {
+ return self.handle_plugin_action_list(request.id, params);
+ }
+ Method::PluginActionInvoke(params) => {
+ return self.handle_plugin_action_invoke(request.id, params);
+ }
+ Method::PluginLogList(params) => {
+ return self.handle_plugin_log_list(request.id, params);
+ }
+ Method::PluginPaneOpen(params) => {
+ return self.handle_plugin_pane_open(request.id, params);
+ }
+ Method::PluginPaneFocus(params) => {
+ return self.handle_plugin_pane_focus(request.id, params);
+ }
+ Method::PluginPaneClose(params) => {
+ return self.handle_plugin_pane_close(request.id, params);
+ }
_ => {
return responses::encode_error(
request.id,
@@ -1186,6 +1271,75 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn pane_process_info_returns_response_for_existing_pane() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("process-info")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ app.terminal_runtimes.insert(terminal_id, runtime);
+ let target = app.public_pane_id(0, pane_id).unwrap();
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "process_info".into(),
+ method: crate::api::schema::Method::PaneProcessInfo(
+ crate::api::schema::PaneProcessInfoParams {
+ pane_id: Some(target.clone()),
+ },
+ ),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+
+ assert_eq!(response["result"]["type"], "pane_process_info");
+ assert_eq!(response["result"]["process_info"]["pane_id"], target);
+ }
+
+ #[test]
+ fn client_window_title_api_reports_no_foreground_client_in_app_mode() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+
+ let set = app.handle_api_request(crate::api::schema::Request {
+ id: "title_set".into(),
+ method: crate::api::schema::Method::ClientWindowTitleSet(
+ crate::api::schema::ClientWindowTitleSetParams {
+ title: "plugin review".into(),
+ },
+ ),
+ });
+ let set: serde_json::Value = serde_json::from_str(&set).unwrap();
+ assert_eq!(set["result"]["type"], "client_window_title");
+ assert_eq!(set["result"]["changed"], false);
+ assert_eq!(set["result"]["reason"], "no_foreground_client");
+
+ let clear = app.handle_api_request(crate::api::schema::Request {
+ id: "title_clear".into(),
+ method: crate::api::schema::Method::ClientWindowTitleClear(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ });
+ let clear: serde_json::Value = serde_json::from_str(&clear).unwrap();
+ assert_eq!(clear["result"]["type"], "client_window_title");
+ assert_eq!(clear["result"]["reason"], "no_foreground_client");
+ }
+
#[cfg(unix)]
#[tokio::test]
async fn herdr_toast_context_uses_live_root_runtime_cwd_label() {
@@ -1236,10 +1390,10 @@ mod tests {
0,
crate::terminal_theme::TerminalTheme::default(),
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
+ &crate::pane::PaneLaunchEnv::default(),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
- None,
)
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
@@ -1328,10 +1482,10 @@ mod tests {
0,
crate::terminal_theme::TerminalTheme::default(),
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
+ &crate::pane::PaneLaunchEnv::default(),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
- None,
)
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs
index 471b3052..7293823f 100644
--- a/src/app/api/agents.rs
+++ b/src/app/api/agents.rs
@@ -46,7 +46,11 @@ impl App {
}
pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String {
- let (agent, argv) = match self.start_agent(params) {
+ let extra_env = match super::env::normalize_launch_env(params.env.clone()) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ let (agent, argv) = match self.start_agent(params, extra_env) {
Ok(started) => started,
Err(err) => return encode_error_body(id, self.agent_start_error_body(err)),
};
diff --git a/src/app/api/env.rs b/src/app/api/env.rs
new file mode 100644
index 00000000..db421f63
--- /dev/null
+++ b/src/app/api/env.rs
@@ -0,0 +1,61 @@
+use std::collections::HashMap;
+
+pub(super) fn normalize_launch_env(
+ env: HashMap<String, String>,
+) -> Result<Vec<(String, String)>, (String, String)> {
+ let mut normalized = Vec::with_capacity(env.len());
+ for (key, value) in env {
+ if key.is_empty() {
+ return Err(("invalid_env".into(), "env key must not be empty".into()));
+ }
+ if key.contains('=') {
+ return Err((
+ "invalid_env".into(),
+ format!("env key {key} must not contain '='"),
+ ));
+ }
+ if key.contains('\0') {
+ return Err((
+ "invalid_env".into(),
+ "env key must not contain NUL bytes".into(),
+ ));
+ }
+ if value.contains('\0') {
+ return Err((
+ "invalid_env".into(),
+ format!("env value for {key} must not contain NUL bytes"),
+ ));
+ }
+ normalized.push((key, value));
+ }
+ normalized.sort_by(|left, right| left.0.cmp(&right.0));
+ Ok(normalized)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn normalize_launch_env_sorts_entries() {
+ let env = HashMap::from([
+ ("ZED".to_string(), "last".to_string()),
+ ("ALPHA".to_string(), "first".to_string()),
+ ]);
+
+ assert_eq!(
+ normalize_launch_env(env).unwrap(),
+ vec![
+ ("ALPHA".to_string(), "first".to_string()),
+ ("ZED".to_string(), "last".to_string()),
+ ]
+ );
+ }
+
+ #[test]
+ fn normalize_launch_env_rejects_invalid_keys() {
+ let env = HashMap::from([("BAD=KEY".to_string(), "value".to_string())]);
+
+ assert_eq!(normalize_launch_env(env).unwrap_err().0, "invalid_env");
+ }
+}
diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs
new file mode 100644
index 00000000..e397ccf8
--- /dev/null
+++ b/src/app/api/layouts.rs
@@ -0,0 +1,758 @@
+use std::path::PathBuf;
+
+use ratatui::layout::Direction;
+
+use crate::api::schema::{
+ EventData, EventEnvelope, EventKind, LayoutApplyParams, LayoutDescription, LayoutExportParams,
+ LayoutNode, LayoutPane, ResponseResult, SplitDirection,
+};
+use crate::app::{App, Mode};
+use crate::layout::{Node, PaneId};
+use crate::workspace::NewPane;
+
+use super::responses::{encode_error, encode_success};
+
+const MAX_LAYOUT_PANES: usize = 24;
+const MAX_LAYOUT_DEPTH: usize = 16;
+
+impl App {
+ pub(super) fn handle_layout_export(
+ &mut self,
+ id: String,
+ params: LayoutExportParams,
+ ) -> String {
+ let Some((ws_idx, tab_idx)) = self.resolve_layout_export_target(&params) else {
+ return encode_error(id, "layout_not_found", "layout target not found");
+ };
+ let Some(layout) = self.layout_description(ws_idx, tab_idx) else {
+ return encode_error(id, "layout_not_found", "layout unavailable");
+ };
+
+ encode_success(id, ResponseResult::LayoutExport { layout })
+ }
+
+ pub(super) fn handle_layout_apply(&mut self, id: String, params: LayoutApplyParams) -> String {
+ let replace_target = match params.tab_id.as_deref() {
+ Some(tab_id) => match self.parse_tab_id(tab_id) {
+ Some(target) => Some(target),
+ None => {
+ return encode_error(id, "tab_not_found", format!("tab {tab_id} not found"))
+ }
+ },
+ None => None,
+ };
+ if replace_target.is_some() && params.workspace_id.is_some() {
+ return encode_error(
+ id,
+ "invalid_target",
+ "use either tab_id or workspace_id, not both",
+ );
+ }
+
+ let ws_idx = if let Some((ws_idx, _)) = replace_target {
+ ws_idx
+ } else if let Some(workspace_id) = params.workspace_id.as_deref() {
+ let Some(ws_idx) = self.parse_workspace_id(workspace_id) else {
+ return encode_error(
+ id,
+ "workspace_not_found",
+ format!("workspace {workspace_id} not found"),
+ );
+ };
+ ws_idx
+ } else if let Some(active) = self.state.active {
+ active
+ } else {
+ return encode_error(id, "workspace_not_found", "no active workspace");
+ };
+ if let Err(message) = validate_layout_tree(&params.root) {
+ return encode_error(id, "invalid_layout", message);
+ }
+
+ let replacement_label = params.tab_label.clone().or_else(|| {
+ let (_, tab_idx) = replace_target?;
+ self.state
+ .workspaces
+ .get(ws_idx)?
+ .tabs
+ .get(tab_idx)?
+ .custom_name
+ .clone()
+ });
+ let replace_was_active = replace_target.is_some_and(|(target_ws, target_tab)| {
+ self.state.active == Some(target_ws)
+ && self
+ .state
+ .workspaces
+ .get(target_ws)
+ .is_some_and(|ws| ws.active_tab_index() == target_tab)
+ });
+ let root_leaf = first_layout_leaf(&params.root);
+ let first_cwd = self.layout_root_cwd(ws_idx, replace_target, root_leaf);
+ let (rows, cols) = self.state.estimate_pane_size();
+ let default_shell = self.state.default_shell.clone();
+ let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
+ let host_terminal_theme = self.state.host_terminal_theme;
+ let extra_env = match super::env::normalize_launch_env(root_leaf.env.clone()) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ let command = match layout_command(root_leaf) {
+ Ok(command) => command,
+ Err(message) => return encode_error(id, "invalid_layout", message),
+ };
+
+ let created = {
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return encode_error(id, "workspace_not_found", "workspace not found");
+ };
+ if let Some(argv) = command.as_deref() {
+ ws.create_tab_argv_command(
+ rows,
+ cols,
+ first_cwd,
+ argv,
+ extra_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ )
+ } else {
+ ws.create_tab(
+ rows,
+ cols,
+ first_cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
+ extra_env,
+ )
+ }
+ };
+
+ let (new_tab_idx, terminal, runtime) = match created {
+ Ok(result) => result,
+ Err(err) => return encode_error(id, "layout_apply_failed", err.to_string()),
+ };
+ let new_root_pane = self.state.workspaces[ws_idx].tabs[new_tab_idx].root_pane;
+ self.terminal_runtimes.insert(terminal.id.clone(), runtime);
+ self.state.remove_alias_shadowed_by_new_pane(new_root_pane);
+ self.state.terminals.insert(terminal.id.clone(), terminal);
+ if let Some(label) = replacement_label {
+ self.state.workspaces[ws_idx].tabs[new_tab_idx].set_custom_name(label);
+ }
+ self.apply_layout_pane_label(ws_idx, new_root_pane, root_leaf);
+
+ if let Err(message) = self.apply_layout_node_to_pane(ws_idx, new_root_pane, &params.root) {
+ self.rollback_layout_tab(ws_idx, new_root_pane);
+ return encode_error(id, "layout_apply_failed", message);
+ }
+
+ if let Some((target_ws_idx, target_tab_idx)) = replace_target {
+ let closed_tab_id = self
+ .public_tab_id(target_ws_idx, target_tab_idx)
+ .unwrap_or_else(|| {
+ format!(
+ "{}:{}",
+ self.public_workspace_id(target_ws_idx),
+ target_tab_idx + 1
+ )
+ });
+ let terminal_ids = self
+ .state
+ .terminal_ids_for_tab(target_ws_idx, target_tab_idx);
+ let Some(ws) = self.state.workspaces.get_mut(target_ws_idx) else {
+ return encode_error(id, "tab_not_found", "tab not found");
+ };
+ if ws.close_tab(target_tab_idx) {
+ self.state.remove_unattached_terminal_ids(terminal_ids);
+ self.shutdown_detached_terminal_runtimes();
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabClosed,
+ data: EventData::TabClosed {
+ tab_id: closed_tab_id,
+ workspace_id: self.public_workspace_id(target_ws_idx),
+ },
+ });
+ }
+ }
+
+ let Some(new_tab_idx) = self.state.workspaces[ws_idx]
+ .tabs
+ .iter()
+ .position(|tab| tab.root_pane == new_root_pane)
+ else {
+ return encode_error(id, "layout_apply_failed", "new layout tab disappeared");
+ };
+
+ if params.focus || replace_was_active {
+ self.state.switch_workspace_tab(ws_idx, new_tab_idx);
+ self.state.mode = Mode::Terminal;
+ }
+ self.schedule_session_save();
+ if let Some(tab) = self.tab_info(ws_idx, new_tab_idx) {
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabCreated,
+ data: EventData::TabCreated { tab },
+ });
+ }
+ for pane_id in self.state.workspaces[ws_idx].tabs[new_tab_idx]
+ .layout
+ .pane_ids()
+ {
+ if let Some(pane) = self.pane_info(ws_idx, pane_id) {
+ self.emit_event(EventEnvelope {
+ event: EventKind::PaneCreated,
+ data: EventData::PaneCreated { pane },
+ });
+ }
+ }
+
+ let Some(layout) = self.layout_description(ws_idx, new_tab_idx) else {
+ return encode_error(id, "layout_apply_failed", "new layout unavailable");
+ };
+ encode_success(id, ResponseResult::LayoutApply { layout })
+ }
+
+ fn resolve_layout_export_target(&self, params: &LayoutExportParams) -> Option<(usize, usize)> {
+ match (params.tab_id.as_deref(), params.pane_id.as_deref()) {
+ (Some(_), Some(_)) => None,
+ (Some(tab_id), None) => self.parse_tab_id(tab_id),
+ (None, Some(pane_id)) => {
+ let (ws_idx, pane_id) = self.parse_pane_id(pane_id)?;
+ let tab_idx = self
+ .state
+ .workspaces
+ .get(ws_idx)?
+ .find_tab_index_for_pane(pane_id)?;
+ Some((ws_idx, tab_idx))
+ }
+ (None, None) => {
+ let ws_idx = self.state.active?;
+ let tab_idx = self.state.workspaces.get(ws_idx)?.active_tab_index();
+ Some((ws_idx, tab_idx))
+ }
+ }
+ }
+
+ fn layout_description(&self, ws_idx: usize, tab_idx: usize) -> Option<LayoutDescription> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ Some(LayoutDescription {
+ workspace_id: self.public_workspace_id(ws_idx),
+ tab_id: self.public_tab_id(ws_idx, tab_idx)?,
+ zoomed: tab.zoomed,
+ focused_pane_id: self.public_pane_id(ws_idx, tab.layout.focused())?,
+ root: self.layout_node_description(ws_idx, tab_idx, tab.layout.root())?,
+ })
+ }
+
+ fn layout_node_description(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ node: &Node,
+ ) -> Option<LayoutNode> {
+ match node {
+ Node::Pane(pane_id) => Some(LayoutNode::Pane {
+ pane: self.layout_pane_description(ws_idx, tab_idx, *pane_id)?,
+ }),
+ Node::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => Some(LayoutNode::Split {
+ direction: match direction {
+ Direction::Horizontal => SplitDirection::Right,
+ Direction::Vertical => SplitDirection::Down,
+ },
+ ratio: *ratio,
+ first: Box::new(self.layout_node_description(ws_idx, tab_idx, first)?),
+ second: Box::new(self.layout_node_description(ws_idx, tab_idx, second)?),
+ }),
+ }
+ }
+
+ fn layout_pane_description(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ pane_id: PaneId,
+ ) -> Option<LayoutPane> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ let terminal_id = tab.terminal_id(pane_id)?;
+ let terminal = self.state.terminals.get(terminal_id);
+ Some(LayoutPane {
+ pane_id: Some(self.public_pane_id(ws_idx, pane_id)?),
+ label: terminal.and_then(|terminal| terminal.manual_label.clone()),
+ cwd: tab
+ .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes)
+ .map(|cwd| cwd.display().to_string()),
+ command: terminal.and_then(|terminal| terminal.launch_argv.clone()),
+ env: Default::default(),
+ })
+ }
+
+ fn layout_root_cwd(
+ &self,
+ ws_idx: usize,
+ replace_target: Option<(usize, usize)>,
+ pane: &LayoutPane,
+ ) -> PathBuf {
+ if let Some(cwd) = pane.cwd.as_ref() {
+ return PathBuf::from(cwd);
+ }
+ let follow_cwd = replace_target.and_then(|(_, tab_idx)| {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ tab.cwd_for_pane(
+ tab.layout.focused(),
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ });
+ self.resolve_new_terminal_cwd(follow_cwd.or_else(|| {
+ self.state
+ .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
+ .and_then(|runtime| runtime.cwd())
+ }))
+ }
+
+ fn apply_layout_node_to_pane(
+ &mut self,
+ ws_idx: usize,
+ pane_id: PaneId,
+ node: &LayoutNode,
+ ) -> Result<(), String> {
+ match node {
+ LayoutNode::Pane { pane } => {
+ self.apply_layout_pane_label(ws_idx, pane_id, pane);
+ Ok(())
+ }
+ LayoutNode::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => {
+ let second_leaf = first_layout_leaf(second);
+ let new_pane = self.layout_split_pane(
+ ws_idx,
+ pane_id,
+ direction.clone(),
+ *ratio,
+ second_leaf,
+ )?;
+ self.apply_layout_node_to_pane(ws_idx, pane_id, first)?;
+ self.apply_layout_node_to_pane(ws_idx, new_pane, second)
+ }
+ }
+ }
+
+ fn layout_split_pane(
+ &mut self,
+ ws_idx: usize,
+ target_pane_id: PaneId,
+ direction: SplitDirection,
+ ratio: f32,
+ pane: &LayoutPane,
+ ) -> Result<PaneId, String> {
+ let (rows, cols) = self.state.estimate_pane_size();
+ let default_shell = self.state.default_shell.clone();
+ let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
+ let host_terminal_theme = self.state.host_terminal_theme;
+ let cwd = pane.cwd.as_ref().map(PathBuf::from).or_else(|| {
+ self.state.workspaces.get(ws_idx).and_then(|ws| {
+ let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?;
+ ws.tabs.get(tab_idx)?.cwd_for_pane(
+ target_pane_id,
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ })
+ });
+ let extra_env = super::env::normalize_launch_env(pane.env.clone())
+ .map_err(|(_, message)| message.to_string())?;
+ let direction = match direction {
+ SplitDirection::Right => Direction::Horizontal,
+ SplitDirection::Down => Direction::Vertical,
+ };
+ let command = layout_command(pane)?;
+ let result = {
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return Err("workspace not found".into());
+ };
+ if let Some(argv) = command.as_deref() {
+ ws.split_pane_argv_command_with_ratio(
+ target_pane_id,
+ direction,
+ ratio,
+ rows,
+ cols,
+ cwd,
+ argv,
+ extra_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ false,
+ )
+ } else {
+ ws.split_pane_with_ratio(
+ target_pane_id,
+ direction,
+ ratio,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
+ extra_env,
+ false,
+ )
+ }
+ };
+ let (_, new_pane) = result
+ .ok_or_else(|| "pane not found".to_string())?
+ .map_err(|err| err.to_string())?;
+ let new_pane_id = new_pane.pane_id;
+ self.attach_new_layout_pane(new_pane);
+ self.apply_layout_pane_label(ws_idx, new_pane_id, pane);
+ Ok(new_pane_id)
+ }
+
+ fn attach_new_layout_pane(&mut self, new_pane: NewPane) {
+ self.terminal_runtimes
+ .insert(new_pane.terminal.id.clone(), new_pane.runtime);
+ self.state
+ .remove_alias_shadowed_by_new_pane(new_pane.pane_id);
+ self.state
+ .terminals
+ .insert(new_pane.terminal.id.clone(), new_pane.terminal);
+ }
+
+ fn apply_layout_pane_label(&mut self, ws_idx: usize, pane_id: PaneId, pane: &LayoutPane) {
+ let Some(label) = pane
+ .label
+ .as_ref()
+ .map(|label| label.trim())
+ .filter(|label| !label.is_empty())
+ else {
+ return;
+ };
+ let Some(terminal_id) = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.terminal_id(pane_id))
+ .cloned()
+ else {
+ return;
+ };
+ if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
+ terminal.set_manual_label(label.to_string());
+ }
+ }
+
+ fn rollback_layout_tab(&mut self, ws_idx: usize, root_pane: PaneId) {
+ let Some(tab_idx) = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.iter().position(|tab| tab.root_pane == root_pane))
+ else {
+ return;
+ };
+ let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx);
+ if self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .is_some_and(|ws| ws.close_tab(tab_idx))
+ {
+ self.state.remove_unattached_terminal_ids(terminal_ids);
+ self.shutdown_detached_terminal_runtimes();
+ }
+ }
+}
+
+fn first_layout_leaf(node: &LayoutNode) -> &LayoutPane {
+ match node {
+ LayoutNode::Pane { pane } => pane,
+ LayoutNode::Split { first, .. } => first_layout_leaf(first),
+ }
+}
+
+fn layout_command(pane: &LayoutPane) -> Result<Option<Vec<String>>, String> {
+ match pane.command.as_ref() {
+ Some(command) if command.is_empty() => Err("pane command must not be empty".into()),
+ Some(command) => Ok(Some(command.clone())),
+ None => Ok(None),
+ }
+}
+
+fn validate_layout_tree(root: &LayoutNode) -> Result<(), String> {
+ let mut stats = LayoutTreeStats {
+ panes: 0,
+ max_depth: 0,
+ };
+ validate_layout_node(root, 1, &mut stats)?;
+ if stats.panes > MAX_LAYOUT_PANES {
+ return Err(format!(
+ "layout has {} panes; maximum is {}",
+ stats.panes, MAX_LAYOUT_PANES
+ ));
+ }
+ if stats.max_depth > MAX_LAYOUT_DEPTH {
+ return Err(format!(
+ "layout depth is {}; maximum is {}",
+ stats.max_depth, MAX_LAYOUT_DEPTH
+ ));
+ }
+ Ok(())
+}
+
+struct LayoutTreeStats {
+ panes: usize,
+ max_depth: usize,
+}
+
+fn validate_layout_node(
+ node: &LayoutNode,
+ depth: usize,
+ stats: &mut LayoutTreeStats,
+) -> Result<(), String> {
+ stats.max_depth = stats.max_depth.max(depth);
+ if depth > MAX_LAYOUT_DEPTH {
+ return Err(format!(
+ "layout depth is {}; maximum is {}",
+ depth, MAX_LAYOUT_DEPTH
+ ));
+ }
+ match node {
+ LayoutNode::Pane { pane } => {
+ stats.panes += 1;
+ if stats.panes > MAX_LAYOUT_PANES {
+ return Err(format!("layout has more than {} panes", MAX_LAYOUT_PANES));
+ }
+ layout_command(pane)?;
+ super::env::normalize_launch_env(pane.env.clone())
+ .map_err(|(_, message)| message.to_string())?;
+ Ok(())
+ }
+ LayoutNode::Split {
+ first,
+ second,
+ ratio,
+ ..
+ } => {
+ if !ratio.is_finite() {
+ return Err("split ratio must be finite".into());
+ }
+ validate_layout_node(first, depth + 1, stats)?;
+ validate_layout_node(second, depth + 1, stats)
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ api::schema::{ErrorResponse, ResponseResult, SuccessResponse},
+ config::Config,
+ workspace::Workspace,
+ };
+
+ fn app_with_workspace() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("layout")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.ensure_test_terminals();
+ app
+ }
+
+ #[test]
+ fn layout_export_returns_portable_tree() {
+ let mut app = app_with_workspace();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(Direction::Horizontal);
+ app.state.ensure_test_terminals();
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ app.state.workspaces[0].tabs[0]
+ .layout
+ .set_ratio_at(&[], 0.65);
+ let right_terminal_id = app.state.workspaces[0].tabs[0]
+ .terminal_id(right)
+ .cloned()
+ .unwrap();
+ app.state
+ .terminals
+ .get_mut(&right_terminal_id)
+ .unwrap()
+ .set_manual_label("tests".into());
+
+ let response = app.handle_layout_export(
+ "req".into(),
+ LayoutExportParams {
+ tab_id: None,
+ pane_id: None,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::LayoutExport { layout } = success.result else {
+ panic!("expected layout export response");
+ };
+ assert_eq!(layout.workspace_id, app.public_workspace_id(0));
+ assert_eq!(layout.focused_pane_id, app.public_pane_id(0, root).unwrap());
+ let LayoutNode::Split {
+ direction,
+ ratio,
+ second,
+ ..
+ } = layout.root
+ else {
+ panic!("expected split layout root");
+ };
+ assert_eq!(direction, SplitDirection::Right);
+ assert!((ratio - 0.65).abs() < f32::EPSILON);
+ let LayoutNode::Pane { pane } = *second else {
+ panic!("expected second pane");
+ };
+ assert_eq!(pane.label.as_deref(), Some("tests"));
+ assert_eq!(pane.pane_id, Some(app.public_pane_id(0, right).unwrap()));
+ }
+
+ #[tokio::test]
+ async fn layout_apply_replaces_tab_with_requested_tree() {
+ let mut app = app_with_workspace();
+ let original_tab_id = app.public_tab_id(0, 0).unwrap();
+
+ let response = app.handle_layout_apply(
+ "req".into(),
+ LayoutApplyParams {
+ workspace_id: None,
+ tab_id: Some(original_tab_id),
+ tab_label: Some("dev".into()),
+ focus: true,
+ root: LayoutNode::Split {
+ direction: SplitDirection::Right,
+ ratio: 0.7,
+ first: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("editor".into()),
+ ..Default::default()
+ },
+ }),
+ second: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("tests".into()),
+ command: Some(vec!["sh".into(), "-c".into(), "true".into()]),
+ env: std::collections::HashMap::from([(
+ "HERDR_ROLE".into(),
+ "tests".into(),
+ )]),
+ ..Default::default()
+ },
+ }),
+ },
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::LayoutApply { layout } = success.result else {
+ panic!("expected layout apply response");
+ };
+ assert_eq!(app.state.workspaces[0].tabs.len(), 1);
+ assert_eq!(app.state.workspaces[0].tabs[0].display_name(), "dev");
+ let LayoutNode::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } = layout.root
+ else {
+ panic!("expected split layout root");
+ };
+ assert_eq!(direction, SplitDirection::Right);
+ assert!((ratio - 0.7).abs() < f32::EPSILON);
+ let LayoutNode::Pane { pane: first_pane } = *first else {
+ panic!("expected first pane");
+ };
+ let LayoutNode::Pane { pane: second_pane } = *second else {
+ panic!("expected second pane");
+ };
+ assert_eq!(first_pane.label.as_deref(), Some("editor"));
+ assert_eq!(second_pane.label.as_deref(), Some("tests"));
+ assert_eq!(
+ second_pane.command,
+ Some(vec!["sh".into(), "-c".into(), "true".into()])
+ );
+ }
+
+ #[tokio::test]
+ async fn layout_apply_rejects_invalid_deep_leaf_without_creating_tab() {
+ let mut app = app_with_workspace();
+ let original_tab_count = app.state.workspaces[0].tabs.len();
+
+ let response = app.handle_layout_apply(
+ "req".into(),
+ LayoutApplyParams {
+ workspace_id: Some(app.public_workspace_id(0)),
+ tab_id: None,
+ tab_label: Some("bad".into()),
+ focus: false,
+ root: LayoutNode::Split {
+ direction: SplitDirection::Right,
+ ratio: 0.5,
+ first: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("editor".into()),
+ ..Default::default()
+ },
+ }),
+ second: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ command: Some(Vec::new()),
+ ..Default::default()
+ },
+ }),
+ },
+ },
+ );
+
+ let error: ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "invalid_layout");
+ assert_eq!(app.state.workspaces[0].tabs.len(), original_tab_count);
+ }
+
+ #[test]
+ fn layout_validation_rejects_too_many_panes() {
+ let mut root = LayoutNode::Pane {
+ pane: LayoutPane::default(),
+ };
+ for _ in 0..MAX_LAYOUT_PANES {
+ root = LayoutNode::Split {
+ direction: SplitDirection::Right,
+ ratio: 0.5,
+ first: Box::new(root),
+ second: Box::new(LayoutNode::Pane {
+ pane: LayoutPane::default(),
+ }),
+ };
+ }
+
+ let err = validate_layout_tree(&root).unwrap_err();
+ assert!(err.contains("maximum"));
+ }
+}
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 7ef4ed3c..2d994dca 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -1,16 +1,17 @@
use bytes::Bytes;
use crate::api::schema::{
- EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneDirection,
- PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, PaneFocusDirectionReason,
- PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect,
- PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination, PaneMoveParams,
- PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult, PaneReadParams,
- PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams,
- PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason,
- PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
- PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams,
- PaneZoomReason, PaneZoomResult, ReadFormat, ReadSource, ResponseResult,
+ EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCurrentParams,
+ PaneDirection, PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams,
+ PaneFocusDirectionReason, PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams,
+ PaneLayoutRect, PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination,
+ PaneMoveParams, PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult,
+ PaneProcessInfo, PaneProcessInfoParams, PaneProcessInfoProcess, PaneReadParams, PaneReadResult,
+ PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams,
+ PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult,
+ PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams,
+ PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason,
+ PaneZoomResult, ReadFormat, ReadSource, ResponseResult,
};
use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason};
use crate::app::{App, Mode};
@@ -22,6 +23,10 @@ use super::super::api_helpers::{
};
use super::responses::{encode_error, encode_success};
+const METADATA_SOURCE_MAX_CHARS: usize = 80;
+const METADATA_TTL_MIN_MS: u64 = 1;
+const METADATA_TTL_MAX_MS: u64 = 86_400_000;
+
impl App {
pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String {
let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() {
@@ -40,6 +45,10 @@ impl App {
let Some((ws_idx, target_pane_id)) = target else {
return encode_error(id, "pane_not_found", "pane not found");
};
+ let extra_env = match super::env::normalize_launch_env(params.env) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
let (rows, cols) = self.state.estimate_pane_size();
let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| {
let follow_cwd = self.state.workspaces.get(ws_idx).and_then(|ws| {
@@ -75,6 +84,7 @@ impl App {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ extra_env,
params.focus,
),
None => ws.split_pane(
@@ -86,6 +96,7 @@ impl App {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ extra_env,
params.focus,
),
};
@@ -124,6 +135,21 @@ impl App {
}
}
+ pub(super) fn handle_pane_current(&mut self, id: String, params: PaneCurrentParams) -> String {
+ let target = match params.caller_pane_id.as_deref() {
+ Some(caller_pane_id) => self.parse_pane_id(caller_pane_id),
+ None => self.resolve_optional_pane(None),
+ };
+ let Some((ws_idx, pane_id)) = target else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(pane) = self.pane_info(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+
+ encode_success(id, ResponseResult::PaneCurrent { pane })
+ }
+
pub(super) fn handle_pane_get(&mut self, id: String, target: PaneTarget) -> String {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
return pane_not_found(id, &target.pane_id);
@@ -152,6 +178,54 @@ impl App {
encode_success(id, ResponseResult::PaneLayout { layout })
}
+ pub(super) fn handle_pane_process_info(
+ &mut self,
+ id: String,
+ params: PaneProcessInfoParams,
+ ) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some((runtime, _workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let shell_pid = runtime.child_pid();
+ let foreground_job = shell_pid.and_then(crate::detect::foreground_job);
+ let foreground_process_group_id = foreground_job.as_ref().map(|job| job.process_group_id);
+ let foreground_processes = foreground_job
+ .map(|job| {
+ job.processes
+ .into_iter()
+ .map(|process| PaneProcessInfoProcess {
+ pid: process.pid,
+ name: process.name,
+ argv0: process.argv0,
+ argv: process.argv,
+ cmdline: process.cmdline,
+ cwd: crate::platform::process_cwd(process.pid)
+ .map(|cwd| cwd.display().to_string()),
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ encode_success(
+ id,
+ ResponseResult::PaneProcessInfo {
+ process_info: PaneProcessInfo {
+ pane_id: public_pane_id,
+ shell_pid,
+ foreground_process_group_id,
+ tty: None,
+ foreground_processes,
+ },
+ },
+ )
+ }
+
pub(super) fn handle_pane_neighbor(
&mut self,
id: String,
@@ -910,6 +984,7 @@ impl App {
event: EventKind::WorkspaceClosed,
data: EventData::WorkspaceClosed {
workspace_id: closed_workspace_id.clone(),
+ workspace: None,
},
});
}
@@ -1184,29 +1259,26 @@ impl App {
},
None => None,
};
- let Some(source) = normalize_optional_text(Some(params.source)) else {
- return encode_error(id, "invalid_metadata_request", "missing metadata source");
+ let source = match normalize_metadata_source(params.source) {
+ Ok(source) => source,
+ Err(message) => return encode_error(id, "invalid_metadata_source", message),
};
let raw_title_set = params.title.is_some();
let raw_display_agent_set = params.display_agent.is_some();
let raw_custom_status_set = params.custom_status.is_some();
let raw_state_labels_set = !params.state_labels.is_empty();
- let ttl = params.ttl_ms.map(std::time::Duration::from_millis);
+ let ttl = match normalize_metadata_ttl(params.ttl_ms) {
+ Ok(ttl) => ttl,
+ Err(message) => return encode_error(id, "invalid_metadata_ttl", message),
+ };
let title = normalize_presentation_text(params.title);
let display_agent = normalize_presentation_text(params.display_agent);
let custom_status = normalize_custom_status(params.custom_status);
let applies_to_source = match params.applies_to_source {
- Some(applies_to_source) => {
- let Some(applies_to_source) = normalize_optional_text(Some(applies_to_source))
- else {
- return encode_error(
- id,
- "invalid_metadata_request",
- "missing metadata authority source",
- );
- };
- Some(applies_to_source)
- }
+ Some(applies_to_source) => match normalize_metadata_source(applies_to_source) {
+ Ok(applies_to_source) => Some(applies_to_source),
+ Err(message) => return encode_error(id, "invalid_metadata_source", message),
+ },
None => None,
};
let state_labels = match normalize_state_labels(params.state_labels) {
@@ -1353,29 +1425,39 @@ impl App {
}
pub(super) fn handle_pane_close(&mut self, id: String, target: PaneTarget) -> String {
+ match self.close_pane(id.clone(), &target) {
+ Ok(()) => encode_success(id, ResponseResult::Ok {}),
+ Err(response) => response,
+ }
+ }
+
+ /// Close a pane; `Err` carries the encoded error response.
+ pub(super) fn close_pane(&mut self, id: String, target: &PaneTarget) -> Result<(), String> {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
- return pane_not_found(id, &target.pane_id);
+ return Err(pane_not_found(id, &target.pane_id));
};
let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else {
- return pane_not_found(id, &target.pane_id);
+ return Err(pane_not_found(id, &target.pane_id));
};
let workspace_id = self.public_workspace_id(ws_idx);
if self.state.close_pane_would_close_workspace(ws_idx, pane_id)
&& self.state.confirm_implicit_worktree_group_close(ws_idx)
{
- return encode_error(
+ return Err(encode_error(
id,
"confirmation_required",
"closing this pane would close a worktree group",
- );
+ ));
}
+ let workspace_snapshot = self.workspace_info(ws_idx);
let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id);
let should_close_workspace = {
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
- return pane_not_found(id, &target.pane_id);
+ return Err(pane_not_found(id, &target.pane_id));
};
ws.close_pane(pane_id)
};
+ self.state.plugin_panes.remove(&pane_id);
if should_close_workspace {
self.state.selected = ws_idx;
self.state.close_selected_workspace();
@@ -1389,7 +1471,10 @@ impl App {
});
self.emit_event(EventEnvelope {
event: EventKind::WorkspaceClosed,
- data: EventData::WorkspaceClosed { workspace_id },
+ data: EventData::WorkspaceClosed {
+ workspace_id,
+ workspace: Some(workspace_snapshot),
+ },
});
} else {
self.state.remove_unattached_terminal_ids(terminal_id);
@@ -1404,7 +1489,7 @@ impl App {
});
}
- encode_success(id, ResponseResult::Ok {})
+ Ok(())
}
pub(super) fn handle_pane_send_keys(
@@ -1432,9 +1517,39 @@ impl App {
}
}
-fn normalize_optional_text(value: Option<String>) -> Option<String> {
- let value = value?.trim().to_string();
- (!value.is_empty()).then_some(value)
+fn normalize_metadata_source(value: String) -> Result<String, &'static str> {
+ let value = value.trim();
+ if value.is_empty() {
+ return Err("metadata source must not be empty");
+ }
+ if value.chars().count() > METADATA_SOURCE_MAX_CHARS {
+ return Err("metadata source must be 80 characters or fewer");
+ }
+ if !value.chars().all(metadata_source_char_is_valid) {
+ return Err(
+ "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen",
+ );
+ }
+ Ok(value.to_string())
+}
+
+fn metadata_source_char_is_valid(ch: char) -> bool {
+ ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-')
+}
+
+fn normalize_metadata_ttl(
+ ttl_ms: Option<u64>,
+) -> Result<Option<std::time::Duration>, &'static str> {
+ let Some(ttl_ms) = ttl_ms else {
+ return Ok(None);
+ };
+ if ttl_ms < METADATA_TTL_MIN_MS {
+ return Err("metadata ttl_ms must be at least 1");
+ }
+ if ttl_ms > METADATA_TTL_MAX_MS {
+ return Err("metadata ttl_ms must be 86400000 or less");
+ }
+ Ok(Some(std::time::Duration::from_millis(ttl_ms)))
}
fn normalize_presentation_text(value: Option<String>) -> Option<String> {
@@ -1659,11 +1774,51 @@ fn invalid_agent(id: String) -> String {
mod tests {
use super::*;
use crate::{
- api::schema::{SplitDirection, SuccessResponse},
+ api::schema::{ErrorResponse, SplitDirection, SuccessResponse},
config::Config,
workspace::Workspace,
};
+ fn app_with_test_workspace() -> (App, String) {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("metadata")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let public_pane_id = app.public_pane_id(0, pane_id).unwrap();
+ (app, public_pane_id)
+ }
+
+ fn metadata_params(pane_id: String) -> PaneReportMetadataParams {
+ PaneReportMetadataParams {
+ pane_id,
+ source: "user:metadata.test-1".into(),
+ agent: None,
+ applies_to_source: None,
+ title: None,
+ display_agent: None,
+ custom_status: Some("activity".into()),
+ state_labels: std::collections::HashMap::new(),
+ clear_title: false,
+ clear_display_agent: false,
+ clear_custom_status: false,
+ clear_state_labels: false,
+ seq: None,
+ ttl_ms: None,
+ }
+ }
+
+ fn metadata_error_code(response: &str) -> String {
+ let response: ErrorResponse = serde_json::from_str(response).unwrap();
+ response.error.code
+ }
+
fn app_with_linked_worktree() -> App {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
@@ -1674,6 +1829,7 @@ mod tests {
crate::api::EventHub::default(),
);
app.state.workspaces = vec![Workspace::test_new("issue")];
+ app.state.ensure_test_terminals();
app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
key: "repo-key".into(),
label: "herdr".into(),
@@ -1722,6 +1878,106 @@ mod tests {
}
#[test]
+ fn api_pane_current_prefers_caller_pane_id() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.ensure_test_terminals();
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams {
+ caller_pane_id: Some(right_public.clone()),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneCurrent { pane } = success.result else {
+ panic!("expected pane current response");
+ };
+ assert_eq!(pane.pane_id, right_public);
+ assert!(!pane.focused);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ assert_ne!(pane.pane_id, root_public);
+ }
+
+ #[test]
+ fn api_pane_current_falls_back_to_focused_pane() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams::default(),
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneCurrent { pane } = success.result else {
+ panic!("expected pane current response");
+ };
+ assert_eq!(pane.pane_id, root_public);
+ assert!(pane.focused);
+ }
+
+ #[test]
+ fn api_pane_current_dispatches_through_socket_request() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::PaneCurrent(
+ crate::api::schema::PaneCurrentParams::default(),
+ ),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneCurrent { pane } = success.result else {
+ panic!("expected pane current response");
+ };
+ assert_eq!(pane.pane_id, root_public);
+ }
+
+ #[test]
+ fn api_pane_current_reports_invalid_caller_pane_id() {
+ let mut app = app_with_linked_worktree();
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams {
+ caller_pane_id: Some("missing".into()),
+ },
+ );
+
+ assert_eq!(metadata_error_code(&response), "pane_not_found");
+ }
+
+ #[test]
+ fn api_pane_current_reports_no_active_pane() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = None;
+
+ let response = app.handle_pane_current(
+ "req".into(),
+ crate::api::schema::PaneCurrentParams::default(),
+ );
+
+ assert_eq!(metadata_error_code(&response), "pane_not_found");
+ }
+
+ #[test]
fn api_pane_swap_explicit_source_and_target_preserves_focus_and_returns_layout() {
let mut app = app_with_linked_worktree();
let source = app.state.workspaces[0].tabs[0].root_pane;
@@ -2825,4 +3081,64 @@ mod tests {
assert_eq!(focus.focused_pane_id, Some(root_public));
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
}
+
+ #[test]
+ fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut params = metadata_params(pane_id);
+ params.ttl_ms = Some(METADATA_TTL_MAX_MS);
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.id, "req");
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_invalid_source_shape() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ for source in ["", "user metadata", "user/metadata", "user:\u{7f}metadata"] {
+ let mut params = metadata_params(pane_id.clone());
+ params.source = source.into();
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_source");
+ }
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_long_source() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut params = metadata_params(pane_id);
+ params.source = "a".repeat(METADATA_SOURCE_MAX_CHARS + 1);
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_source");
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_invalid_applies_to_source() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut params = metadata_params(pane_id);
+ params.applies_to_source = Some("herdr source".into());
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_source");
+ }
+
+ #[test]
+ fn pane_report_metadata_rejects_ttl_outside_supported_range() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ for ttl_ms in [0, METADATA_TTL_MAX_MS + 1] {
+ let mut params = metadata_params(pane_id.clone());
+ params.ttl_ms = Some(ttl_ms);
+
+ let response = app.handle_pane_report_metadata("req".into(), params);
+
+ assert_eq!(metadata_error_code(&response), "invalid_metadata_ttl");
+ }
+ }
}
diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs
new file mode 100644
index 00000000..881020b8
--- /dev/null
+++ b/src/app/api/plugins/context.rs
@@ -0,0 +1,379 @@
+use crate::api::schema::{EventData, PluginInvocationContext};
+use crate::app::App;
+
+impl App {
+ pub(super) fn merge_plugin_context(
+ &self,
+ provided: Option<PluginInvocationContext>,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ let mut context = self.current_plugin_context(correlation_id);
+ if let Some(provided) = provided {
+ context.workspace_id = provided.workspace_id.or(context.workspace_id);
+ context.workspace_label = provided.workspace_label.or(context.workspace_label);
+ context.workspace_cwd = provided.workspace_cwd.or(context.workspace_cwd);
+ context.worktree = provided.worktree.or(context.worktree);
+ context.tab_id = provided.tab_id.or(context.tab_id);
+ context.tab_label = provided.tab_label.or(context.tab_label);
+ context.focused_pane_id = provided.focused_pane_id.or(context.focused_pane_id);
+ context.focused_pane_cwd = provided.focused_pane_cwd.or(context.focused_pane_cwd);
+ context.focused_pane_agent = provided.focused_pane_agent.or(context.focused_pane_agent);
+ context.focused_pane_status =
+ provided.focused_pane_status.or(context.focused_pane_status);
+ context.selected_text = provided.selected_text.or(context.selected_text);
+ context.invocation_source = provided.invocation_source.or(context.invocation_source);
+ context.correlation_id = provided.correlation_id.or(context.correlation_id);
+ context.clicked_url = provided.clicked_url.or(context.clicked_url);
+ context.link_handler_id = provided.link_handler_id.or(context.link_handler_id);
+ }
+ context
+ }
+
+ pub(super) fn current_plugin_context(&self, correlation_id: &str) -> PluginInvocationContext {
+ let Some(ws_idx) = self.state.active else {
+ return empty_plugin_context(correlation_id);
+ };
+ self.plugin_context_for_workspace(ws_idx, correlation_id)
+ }
+
+ pub(super) fn plugin_context_for_event(
+ &self,
+ event: &crate::api::schema::EventEnvelope,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ match &event.data {
+ EventData::WorkspaceCreated { workspace }
+ | EventData::WorkspaceUpdated { workspace }
+ | EventData::WorktreeCreated { workspace, .. }
+ | EventData::WorktreeOpened { workspace, .. } => {
+ self.plugin_context_for_workspace_info(workspace, correlation_id)
+ }
+ EventData::WorkspaceClosed {
+ workspace_id,
+ workspace,
+ } => workspace
+ .as_ref()
+ .map(|workspace| self.plugin_context_for_workspace_info(workspace, correlation_id))
+ .unwrap_or_else(|| {
+ self.plugin_context_for_workspace_id(workspace_id, correlation_id)
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context
+ })
+ }),
+ EventData::WorkspaceRenamed { workspace_id, .. }
+ | EventData::WorkspaceFocused { workspace_id }
+ | EventData::WorktreeRemoved { workspace_id, .. } => self
+ .plugin_context_for_workspace_id(workspace_id, correlation_id)
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context
+ }),
+ EventData::TabCreated { tab } => self.plugin_context_for_tab_info(tab, correlation_id),
+ EventData::TabClosed {
+ tab_id,
+ workspace_id,
+ } => {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context.tab_id = Some(tab_id.clone());
+ context
+ }
+ EventData::TabRenamed {
+ tab_id,
+ workspace_id,
+ ..
+ }
+ | EventData::TabFocused {
+ tab_id,
+ workspace_id,
+ } => self
+ .plugin_context_for_tab_id(tab_id, correlation_id)
+ .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id))
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context.tab_id = Some(tab_id.clone());
+ context
+ }),
+ EventData::PaneCreated { pane } => {
+ self.plugin_context_for_pane_info(pane, correlation_id)
+ }
+ EventData::PaneMoved { pane, .. } => {
+ self.plugin_context_for_pane_info(pane.as_ref(), correlation_id)
+ }
+ EventData::PaneClosed {
+ pane_id,
+ workspace_id,
+ } => {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context.focused_pane_id = Some(pane_id.clone());
+ context
+ }
+ EventData::PaneFocused {
+ pane_id,
+ workspace_id,
+ }
+ | EventData::PaneOutputChanged {
+ pane_id,
+ workspace_id,
+ ..
+ }
+ | EventData::PaneExited {
+ pane_id,
+ workspace_id,
+ }
+ | EventData::PaneAgentDetected {
+ pane_id,
+ workspace_id,
+ ..
+ }
+ | EventData::PaneAgentStatusChanged {
+ pane_id,
+ workspace_id,
+ ..
+ } => self
+ .plugin_context_for_public_pane_id(pane_id, correlation_id)
+ .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id))
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace_id.clone());
+ context.focused_pane_id = Some(pane_id.clone());
+ context
+ }),
+ }
+ }
+
+ fn plugin_context_for_workspace_id(
+ &self,
+ workspace_id: &str,
+ correlation_id: &str,
+ ) -> Option<PluginInvocationContext> {
+ let ws_idx = self
+ .state
+ .workspaces
+ .iter()
+ .enumerate()
+ .find_map(|(idx, _)| (self.public_workspace_id(idx) == workspace_id).then_some(idx))?;
+ Some(self.plugin_context_for_workspace(ws_idx, correlation_id))
+ }
+
+ fn plugin_context_for_workspace_info(
+ &self,
+ workspace: &crate::api::schema::WorkspaceInfo,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ self.plugin_context_for_workspace_id(&workspace.workspace_id, correlation_id)
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(workspace.workspace_id.clone());
+ context.workspace_label = Some(workspace.label.clone());
+ context.workspace_cwd = workspace
+ .worktree
+ .as_ref()
+ .map(|worktree| worktree.checkout_path.clone());
+ context.worktree = workspace.worktree.clone();
+ context.tab_id = Some(workspace.active_tab_id.clone());
+ context
+ })
+ }
+
+ fn plugin_context_for_tab_id(
+ &self,
+ tab_id: &str,
+ correlation_id: &str,
+ ) -> Option<PluginInvocationContext> {
+ let (ws_idx, tab_idx) = self.parse_tab_id(tab_id)?;
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let workspace = self.workspace_info(ws_idx);
+ let tab = ws.tabs.get(tab_idx)?;
+ let pane_id = tab.layout.focused();
+ let focused_pane = self.pane_info(ws_idx, pane_id);
+ Some(self.plugin_context_from_parts(
+ ws_idx,
+ workspace,
+ self.public_tab_id(ws_idx, tab_idx),
+ Some(tab.display_name()),
+ focused_pane,
+ correlation_id,
+ ))
+ }
+
+ fn plugin_context_for_tab_info(
+ &self,
+ tab: &crate::api::schema::TabInfo,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ self.plugin_context_for_tab_id(&tab.tab_id, correlation_id)
+ .or_else(|| self.plugin_context_for_workspace_id(&tab.workspace_id, correlation_id))
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(tab.workspace_id.clone());
+ context.tab_id = Some(tab.tab_id.clone());
+ context.tab_label = Some(tab.label.clone());
+ context
+ })
+ }
+
+ fn plugin_context_for_public_pane_id(
+ &self,
+ pane_id: &str,
+ correlation_id: &str,
+ ) -> Option<PluginInvocationContext> {
+ let (ws_idx, pane_id) = self.parse_pane_id(pane_id)?;
+ Some(self.plugin_context_for_pane(ws_idx, pane_id, correlation_id))
+ }
+
+ fn plugin_context_for_pane_info(
+ &self,
+ pane: &crate::api::schema::PaneInfo,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ self.plugin_context_for_public_pane_id(&pane.pane_id, correlation_id)
+ .or_else(|| self.plugin_context_for_workspace_id(&pane.workspace_id, correlation_id))
+ .unwrap_or_else(|| {
+ let mut context = empty_plugin_context(correlation_id);
+ context.workspace_id = Some(pane.workspace_id.clone());
+ context.tab_id = Some(pane.tab_id.clone());
+ context.focused_pane_id = Some(pane.pane_id.clone());
+ context.focused_pane_cwd = pane.cwd.clone();
+ context.focused_pane_agent = pane.agent.clone();
+ context.focused_pane_status = Some(pane.agent_status);
+ context
+ })
+ }
+
+ pub(super) fn plugin_context_for_workspace(
+ &self,
+ ws_idx: usize,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return empty_plugin_context(correlation_id);
+ };
+ let workspace = self.workspace_info(ws_idx);
+ let tab_idx = ws.active_tab_index();
+ let tab_id = self.public_tab_id(ws_idx, tab_idx);
+ let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name());
+ let focused_pane = ws
+ .focused_pane_id()
+ .and_then(|pane_id| self.pane_info(ws_idx, pane_id));
+ self.plugin_context_from_parts(
+ ws_idx,
+ workspace,
+ tab_id,
+ tab_label,
+ focused_pane,
+ correlation_id,
+ )
+ }
+
+ pub(super) fn plugin_context_for_pane(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ let ws = &self.state.workspaces[ws_idx];
+ let workspace = self.workspace_info(ws_idx);
+ let tab_idx = ws
+ .find_tab_index_for_pane(pane_id)
+ .unwrap_or_else(|| ws.active_tab_index());
+ let tab_id = self.public_tab_id(ws_idx, tab_idx);
+ let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name());
+ let focused_pane = self.pane_info(ws_idx, pane_id);
+ self.plugin_context_from_parts(
+ ws_idx,
+ workspace,
+ tab_id,
+ tab_label,
+ focused_pane,
+ correlation_id,
+ )
+ }
+
+ fn plugin_context_from_parts(
+ &self,
+ ws_idx: usize,
+ workspace: crate::api::schema::WorkspaceInfo,
+ tab_id: Option<String>,
+ tab_label: Option<String>,
+ focused_pane: Option<crate::api::schema::PaneInfo>,
+ correlation_id: &str,
+ ) -> PluginInvocationContext {
+ let workspace_cwd = focused_pane
+ .as_ref()
+ .and_then(|pane| pane.cwd.clone())
+ .or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string()));
+ let selected_text = focused_pane
+ .as_ref()
+ .and_then(|pane| self.parse_pane_id(&pane.pane_id))
+ .and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id));
+ PluginInvocationContext {
+ workspace_id: Some(workspace.workspace_id),
+ workspace_label: Some(workspace.label),
+ workspace_cwd,
+ worktree: workspace.worktree,
+ tab_id,
+ tab_label,
+ focused_pane_id: focused_pane.as_ref().map(|pane| pane.pane_id.clone()),
+ focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()),
+ focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()),
+ focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status),
+ selected_text,
+ invocation_source: Some("api".to_string()),
+ correlation_id: Some(correlation_id.to_string()),
+ clicked_url: None,
+ link_handler_id: None,
+ }
+ }
+
+ fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option<String> {
+ let selection = self.state.selection.as_ref()?;
+ if selection.pane_id != pane_id || !selection.is_visible() {
+ return None;
+ }
+ let terminal_id = self
+ .state
+ .workspaces
+ .iter()
+ .find_map(|workspace| workspace.terminal_id(pane_id))?;
+ self.terminal_runtimes
+ .get(terminal_id)
+ .and_then(|runtime| runtime.extract_selection(selection))
+ .filter(|text| !text.is_empty())
+ }
+
+ fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf {
+ self.state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| {
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ })
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()))
+ }
+}
+
+fn empty_plugin_context(correlation_id: &str) -> PluginInvocationContext {
+ PluginInvocationContext {
+ workspace_id: None,
+ workspace_label: None,
+ workspace_cwd: None,
+ worktree: None,
+ tab_id: None,
+ tab_label: None,
+ focused_pane_id: None,
+ focused_pane_cwd: None,
+ focused_pane_agent: None,
+ focused_pane_status: None,
+ selected_text: None,
+ invocation_source: Some("api".to_string()),
+ correlation_id: Some(correlation_id.to_string()),
+ clicked_url: None,
+ link_handler_id: None,
+ }
+}
diff --git a/src/app/api/plugins/env.rs b/src/app/api/plugins/env.rs
new file mode 100644
index 00000000..13644a19
--- /dev/null
+++ b/src/app/api/plugins/env.rs
@@ -0,0 +1,19 @@
+use crate::api::schema::InstalledPluginInfo;
+
+pub(super) fn plugin_path_env(plugin: &InstalledPluginInfo) -> Vec<(String, String)> {
+ let component = crate::api::schema::plugin_managed_path_component(&plugin.plugin_id);
+ let config_dir = crate::config::config_dir().join("plugins").join(&component);
+ let state_dir = crate::config::state_dir().join("plugins").join(component);
+
+ vec![
+ ("HERDR_PLUGIN_ROOT".to_string(), plugin.plugin_root.clone()),
+ (
+ "HERDR_PLUGIN_CONFIG_DIR".to_string(),
+ config_dir.display().to_string(),
+ ),
+ (
+ "HERDR_PLUGIN_STATE_DIR".to_string(),
+ state_dir.display().to_string(),
+ ),
+ ]
+}
diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs
new file mode 100644
index 00000000..92799aae
--- /dev/null
+++ b/src/app/api/plugins/manifest.rs
@@ -0,0 +1,539 @@
+use crate::api::schema::{
+ InstalledPluginInfo, PluginManifestAction, PluginManifestBuild, PluginManifestEventHook,
+ PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform,
+ PluginSourceInfo, PluginSourceKind,
+};
+
+const PLUGIN_ID_MAX_CHARS: usize = 120;
+const PLUGIN_ACTION_ID_MAX_CHARS: usize = 120;
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifest {
+ id: String,
+ name: String,
+ version: String,
+ #[serde(default)]
+ description: Option<String>,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ #[serde(default)]
+ build: Vec<RawPluginManifestBuild>,
+ #[serde(default)]
+ actions: Vec<RawPluginManifestAction>,
+ #[serde(default)]
+ events: Vec<RawPluginManifestEventHook>,
+ #[serde(default)]
+ panes: Vec<RawPluginManifestPane>,
+ #[serde(default)]
+ link_handlers: Vec<RawPluginManifestLinkHandler>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestBuild {
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ command: Vec<String>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestAction {
+ id: String,
+ title: String,
+ #[serde(default)]
+ description: Option<String>,
+ #[serde(default)]
+ contexts: Vec<crate::api::schema::PluginActionContext>,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ command: Vec<String>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestEventHook {
+ on: String,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ command: Vec<String>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestPane {
+ id: String,
+ title: String,
+ #[serde(default)]
+ description: Option<String>,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ #[serde(default)]
+ placement: PluginPanePlacement,
+ command: Vec<String>,
+}
+
+#[derive(serde::Deserialize)]
+struct RawPluginManifestLinkHandler {
+ id: String,
+ title: String,
+ pattern: String,
+ action: String,
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+}
+
+/// Raw string platform value from the manifest, validated before conversion.
+#[derive(serde::Deserialize)]
+#[serde(try_from = "String")]
+struct RawPlatform(PluginPlatform);
+
+impl TryFrom<String> for RawPlatform {
+ type Error = String;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ match value.as_str() {
+ "linux" => Ok(RawPlatform(PluginPlatform::Linux)),
+ "macos" => Ok(RawPlatform(PluginPlatform::Macos)),
+ "windows" => Ok(RawPlatform(PluginPlatform::Windows)),
+ other => Err(format!(
+ "invalid_plugin_platform: unknown platform '{other}'"
+ )),
+ }
+ }
+}
+
+pub(crate) fn load_plugin_manifest(
+ path: &str,
+ enabled: bool,
+) -> Result<InstalledPluginInfo, (&'static str, String)> {
+ let path = std::path::PathBuf::from(path);
+ let manifest_path = if path.is_dir() {
+ path.join("herdr-plugin.toml")
+ } else {
+ path
+ };
+ let manifest_path = manifest_path
+ .canonicalize()
+ .map_err(|err| ("plugin_manifest_not_found", err.to_string()))?;
+ let plugin_root = manifest_path
+ .parent()
+ .ok_or_else(|| {
+ (
+ "invalid_plugin_manifest_path",
+ "manifest path has no parent directory".to_string(),
+ )
+ })?
+ .to_path_buf();
+ let content = std::fs::read_to_string(&manifest_path)
+ .map_err(|err| ("plugin_manifest_read_failed", err.to_string()))?;
+ let raw: RawPluginManifest = toml::from_str(&content)
+ .map_err(|err| ("plugin_manifest_parse_failed", err.to_string()))?;
+ let plugin_id = normalize_plugin_id(&raw.id)
+ .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?;
+ let name = non_empty_trimmed(&raw.name, "invalid_plugin_name", "plugin name is required")?;
+ let version = non_empty_trimmed(
+ &raw.version,
+ "invalid_plugin_version",
+ "plugin version is required",
+ )?;
+ let description = raw
+ .description
+ .map(|description| description.trim().to_string())
+ .filter(|description| !description.is_empty());
+ let platforms = normalize_platforms(raw.platforms)?;
+ let build = raw
+ .build
+ .into_iter()
+ .map(normalize_manifest_build)
+ .collect::<Result<Vec<_>, _>>()?;
+ let mut actions = raw
+ .actions
+ .into_iter()
+ .map(normalize_manifest_action)
+ .collect::<Result<Vec<_>, _>>()?;
+ reject_duplicate_action_ids(&actions)?;
+ actions.sort_by(|a, b| a.id.cmp(&b.id));
+ let mut events = raw
+ .events
+ .into_iter()
+ .map(normalize_manifest_event)
+ .collect::<Result<Vec<_>, _>>()?;
+ events.sort_by(|a, b| a.on.cmp(&b.on).then_with(|| a.command.cmp(&b.command)));
+ let mut panes = raw
+ .panes
+ .into_iter()
+ .map(normalize_manifest_pane)
+ .collect::<Result<Vec<_>, _>>()?;
+ reject_duplicate_pane_ids(&panes)?;
+ panes.sort_by(|a, b| a.id.cmp(&b.id));
+ let link_handlers = raw
+ .link_handlers
+ .into_iter()
+ .map(normalize_manifest_link_handler)
+ .collect::<Result<Vec<_>, _>>()?;
+ reject_duplicate_link_handler_ids(&link_handlers)?;
+ validate_link_handler_actions(&link_handlers, &actions)?;
+
+ let mut warnings = validate_event_names(&events);
+ if platforms.is_none() {
+ warnings.push("manifest does not declare platforms; platform support unknown".to_string());
+ }
+
+ Ok(InstalledPluginInfo {
+ plugin_id,
+ name,
+ version,
+ description,
+ manifest_path: manifest_path.display().to_string(),
+ plugin_root: plugin_root.display().to_string(),
+ enabled,
+ platforms,
+ build,
+ actions,
+ events,
+ panes,
+ link_handlers,
+ source: Default::default(),
+ warnings,
+ })
+}
+
+fn normalize_manifest_build(
+ build: RawPluginManifestBuild,
+) -> Result<PluginManifestBuild, (&'static str, String)> {
+ let platforms = normalize_platforms(build.platforms)?;
+ let command = normalize_command(build.command)?;
+ Ok(PluginManifestBuild { platforms, command })
+}
+
+pub(super) fn normalize_plugin_source(
+ plugin: &InstalledPluginInfo,
+ source: PluginSourceInfo,
+) -> Result<PluginSourceInfo, (&'static str, String)> {
+ if source.kind == PluginSourceKind::Local {
+ return Ok(source);
+ }
+ let Some(managed_path) = source.managed_path.as_deref() else {
+ return Err((
+ "invalid_plugin_source",
+ "GitHub plugin source requires managed_path".to_string(),
+ ));
+ };
+ let managed_path = std::path::PathBuf::from(managed_path)
+ .canonicalize()
+ .map_err(|err| ("invalid_plugin_source", err.to_string()))?;
+ let plugin_root = std::path::PathBuf::from(&plugin.plugin_root)
+ .canonicalize()
+ .map_err(|err| ("invalid_plugin_source", err.to_string()))?;
+ let expected = crate::session::data_dir()
+ .join("plugins")
+ .join("github")
+ .join(crate::api::schema::plugin_managed_path_component(
+ &plugin.plugin_id,
+ ))
+ .canonicalize()
+ .map_err(|err| ("invalid_plugin_source", err.to_string()))?;
+ if managed_path != expected {
+ return Err((
+ "invalid_plugin_source",
+ "GitHub plugin managed_path does not match the plugin id".to_string(),
+ ));
+ }
+ if !plugin_root.starts_with(&managed_path) {
+ return Err((
+ "invalid_plugin_source",
+ "plugin manifest is not inside the managed checkout".to_string(),
+ ));
+ }
+ Ok(source)
+}
+
+fn reject_duplicate_action_ids(
+ actions: &[PluginManifestAction],
+) -> Result<(), (&'static str, String)> {
+ let mut seen = std::collections::HashSet::new();
+ for action in actions {
+ if !seen.insert(action.id.as_str()) {
+ return Err((
+ "duplicate_plugin_action_id",
+ format!("duplicate action id '{}'", action.id),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_event_names(events: &[crate::api::schema::PluginManifestEventHook]) -> Vec<String> {
+ let known = crate::api::schema::plugin_hook_event_names();
+ events
+ .iter()
+ .filter(|hook| !known.contains(&hook.on.as_str()))
+ .map(|hook| format!("unknown event '{}'", hook.on))
+ .collect()
+}
+
+fn reject_duplicate_pane_ids(panes: &[PluginManifestPane]) -> Result<(), (&'static str, String)> {
+ let mut seen = std::collections::HashSet::new();
+ for pane in panes {
+ if !seen.insert(pane.id.as_str()) {
+ return Err((
+ "duplicate_plugin_pane_id",
+ format!("duplicate pane id '{}'", pane.id),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn reject_duplicate_link_handler_ids(
+ handlers: &[PluginManifestLinkHandler],
+) -> Result<(), (&'static str, String)> {
+ let mut seen = std::collections::HashSet::new();
+ for handler in handlers {
+ if !seen.insert(handler.id.as_str()) {
+ return Err((
+ "duplicate_plugin_link_handler_id",
+ format!("duplicate link handler id '{}'", handler.id),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_link_handler_actions(
+ handlers: &[PluginManifestLinkHandler],
+ actions: &[PluginManifestAction],
+) -> Result<(), (&'static str, String)> {
+ for handler in handlers {
+ if !actions.iter().any(|action| action.id == handler.action) {
+ return Err((
+ "invalid_plugin_link_handler_action",
+ format!(
+ "link handler '{}' references unknown action '{}'",
+ handler.id, handler.action
+ ),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn normalize_manifest_action(
+ action: RawPluginManifestAction,
+) -> Result<PluginManifestAction, (&'static str, String)> {
+ let id = normalize_action_id(&action.id)
+ .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?;
+ let title = non_empty_trimmed(
+ &action.title,
+ "invalid_plugin_action_title",
+ "action title is required",
+ )?;
+ let description = action
+ .description
+ .map(|description| description.trim().to_string())
+ .filter(|description| !description.is_empty());
+ let platforms = normalize_platforms(action.platforms)?;
+ let command = normalize_command(action.command)?;
+ Ok(PluginManifestAction {
+ id,
+ title,
+ description,
+ contexts: action.contexts,
+ platforms,
+ command,
+ })
+}
+
+fn normalize_manifest_pane(
+ pane: RawPluginManifestPane,
+) -> Result<PluginManifestPane, (&'static str, String)> {
+ let id = normalize_action_id(&pane.id)
+ .ok_or_else(|| ("invalid_plugin_pane_id", "invalid pane id".to_string()))?;
+ let title = non_empty_trimmed(
+ &pane.title,
+ "invalid_plugin_pane_title",
+ "pane title is required",
+ )?;
+ let description = pane
+ .description
+ .map(|description| description.trim().to_string())
+ .filter(|description| !description.is_empty());
+ let platforms = normalize_platforms(pane.platforms)?;
+ let command = normalize_command(pane.command)?;
+ Ok(PluginManifestPane {
+ id,
+ title,
+ description,
+ platforms,
+ placement: pane.placement,
+ command,
+ })
+}
+
+fn normalize_manifest_event(
+ event: RawPluginManifestEventHook,
+) -> Result<PluginManifestEventHook, (&'static str, String)> {
+ let on = non_empty_trimmed(&event.on, "invalid_plugin_event", "event name is required")?;
+ let platforms = normalize_platforms(event.platforms)?;
+ let command = normalize_command(event.command)?;
+ Ok(PluginManifestEventHook {
+ on,
+ platforms,
+ command,
+ })
+}
+
+fn normalize_manifest_link_handler(
+ handler: RawPluginManifestLinkHandler,
+) -> Result<PluginManifestLinkHandler, (&'static str, String)> {
+ let id = normalize_action_id(&handler.id).ok_or_else(|| {
+ (
+ "invalid_plugin_link_handler_id",
+ "invalid link handler id".to_string(),
+ )
+ })?;
+ let title = non_empty_trimmed(
+ &handler.title,
+ "invalid_plugin_link_handler_title",
+ "link handler title is required",
+ )?;
+ let pattern = non_empty_trimmed(
+ &handler.pattern,
+ "invalid_plugin_link_handler_pattern",
+ "link handler pattern is required",
+ )?;
+ regex::Regex::new(&pattern)
+ .map_err(|err| ("invalid_plugin_link_handler_pattern", err.to_string()))?;
+ let action = normalize_action_id(&handler.action).ok_or_else(|| {
+ (
+ "invalid_plugin_link_handler_action",
+ "invalid link handler action".to_string(),
+ )
+ })?;
+ let platforms = normalize_platforms(handler.platforms)?;
+ Ok(PluginManifestLinkHandler {
+ id,
+ title,
+ pattern,
+ action,
+ platforms,
+ })
+}
+
+fn normalize_platforms(
+ raw: Option<Vec<RawPlatform>>,
+) -> Result<Option<Vec<PluginPlatform>>, (&'static str, String)> {
+ match raw {
+ None => Ok(None),
+ Some(list) if list.is_empty() => Err((
+ "invalid_plugin_platform",
+ "platforms must not be an empty array; omit the field to leave platforms undeclared"
+ .to_string(),
+ )),
+ Some(list) => Ok(Some(list.into_iter().map(|p| p.0).collect())),
+ }
+}
+
+/// Returns the platform the current binary was compiled for.
+fn current_platform() -> PluginPlatform {
+ if cfg!(target_os = "linux") {
+ PluginPlatform::Linux
+ } else if cfg!(target_os = "macos") {
+ PluginPlatform::Macos
+ } else {
+ PluginPlatform::Windows
+ }
+}
+
+/// Resolve the effective platforms for an action or event: use the item's own
+/// platforms if declared, otherwise inherit from the plugin-level platforms.
+/// Returns a reference to whichever `Option<Vec<PluginPlatform>>` applies.
+pub(super) fn effective_platforms<'a>(
+ item_platforms: &'a Option<Vec<PluginPlatform>>,
+ plugin_platforms: &'a Option<Vec<PluginPlatform>>,
+) -> &'a Option<Vec<PluginPlatform>> {
+ if item_platforms.is_some() {
+ item_platforms
+ } else {
+ plugin_platforms
+ }
+}
+
+pub(super) fn ensure_platform_supported(
+ platforms: &Option<Vec<PluginPlatform>>,
+ subject: &str,
+) -> Result<(), (&'static str, String)> {
+ if let Some(platforms) = platforms {
+ let host = current_platform();
+ if !platforms.contains(&host) {
+ return Err((
+ "platform_unsupported",
+ format!(
+ "{subject} does not support the current platform ({})",
+ platform_name(host)
+ ),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn platform_name(p: PluginPlatform) -> &'static str {
+ match p {
+ PluginPlatform::Linux => "linux",
+ PluginPlatform::Macos => "macos",
+ PluginPlatform::Windows => "windows",
+ }
+}
+
+fn normalize_command(command: Vec<String>) -> Result<Vec<String>, (&'static str, String)> {
+ let command = command
+ .into_iter()
+ .map(|arg| arg.trim().to_string())
+ .collect::<Vec<_>>();
+ if command.is_empty() || command.iter().any(|arg| arg.is_empty()) {
+ return Err((
+ "invalid_plugin_command",
+ "command must contain non-empty argv strings".to_string(),
+ ));
+ }
+ Ok(command)
+}
+
+fn non_empty_trimmed(
+ value: &str,
+ code: &'static str,
+ message: &'static str,
+) -> Result<String, (&'static str, String)> {
+ let value = value.trim().to_string();
+ if value.is_empty() {
+ Err((code, message.to_string()))
+ } else {
+ Ok(value)
+ }
+}
+
+pub(super) fn normalize_plugin_id(value: &str) -> Option<String> {
+ normalize_identifier(value, PLUGIN_ID_MAX_CHARS)
+}
+
+pub(super) fn normalize_action_id(value: &str) -> Option<String> {
+ normalize_local_identifier(value, PLUGIN_ACTION_ID_MAX_CHARS)
+}
+
+fn normalize_identifier(value: &str, max_chars: usize) -> Option<String> {
+ let value = value.trim();
+ (!value.is_empty()
+ && value.chars().count() <= max_chars
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'_' | b'-')))
+ .then(|| value.to_string())
+}
+
+fn normalize_local_identifier(value: &str, max_chars: usize) -> Option<String> {
+ let value = value.trim();
+ (!value.is_empty()
+ && value.chars().count() <= max_chars
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-')))
+ .then(|| value.to_string())
+}
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
new file mode 100644
index 00000000..3304a256
--- /dev/null
+++ b/src/app/api/plugins/mod.rs
@@ -0,0 +1,2704 @@
+mod context;
+mod env;
+mod manifest;
+mod panes;
+mod runtime;
+
+use super::responses::{encode_error, encode_success};
+use crate::api::schema::{
+ InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams,
+ PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction,
+ PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo,
+ PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams,
+ ResponseResult,
+};
+use crate::app::App;
+use manifest::{
+ effective_platforms, ensure_platform_supported, normalize_action_id, normalize_plugin_id,
+ normalize_plugin_source,
+};
+
+#[cfg(test)]
+use crate::api::schema::{PluginCommandStatus, PluginInvocationContext};
+pub(crate) use manifest::load_plugin_manifest;
+#[cfg(test)]
+use runtime::{read_capped_plugin_output, MAX_PLUGIN_COMMANDS_IN_FLIGHT};
+
+impl App {
+ pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String {
+ let mut plugin = match load_plugin_manifest(&params.path, params.enabled) {
+ Ok(plugin) => plugin,
+ Err((code, message)) => return encode_error(id, code, message),
+ };
+ if let Some(source) = params.source {
+ match normalize_plugin_source(&plugin, source) {
+ Ok(source) => plugin.source = source,
+ Err((code, message)) => return encode_error(id, code, message),
+ }
+ }
+ let previous = self.state.installed_plugins.get(&plugin.plugin_id).cloned();
+ self.state
+ .installed_plugins
+ .insert(plugin.plugin_id.clone(), plugin.clone());
+ if let Err(err) = self.save_plugin_registry() {
+ match previous {
+ Some(previous) => {
+ self.state
+ .installed_plugins
+ .insert(previous.plugin_id.clone(), previous);
+ }
+ None => {
+ self.state.installed_plugins.remove(&plugin.plugin_id);
+ }
+ }
+ return encode_error(id, "plugin_registry_save_failed", err.to_string());
+ }
+ encode_success(id, ResponseResult::PluginLinked { plugin })
+ }
+
+ pub(super) fn handle_plugin_list(&mut self, id: String, params: PluginListParams) -> String {
+ let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) {
+ Ok(plugin_id) => plugin_id,
+ Err(response) => return response,
+ };
+ let mut plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .filter(|plugin| {
+ plugin_id
+ .as_deref()
+ .is_none_or(|plugin_id| plugin.plugin_id == plugin_id)
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id));
+ encode_success(id, ResponseResult::PluginList { plugins })
+ }
+
+ pub(super) fn handle_plugin_unlink(
+ &mut self,
+ id: String,
+ params: PluginUnlinkParams,
+ ) -> String {
+ let Some(plugin_id) = normalize_plugin_id(&params.plugin_id) else {
+ return invalid_plugin_id(id);
+ };
+ let previous = self.state.installed_plugins.remove(&plugin_id);
+ let removed = previous.is_some();
+ let previous_panes = if removed {
+ Some(self.state.plugin_panes.clone())
+ } else {
+ None
+ };
+ if removed {
+ // Drop plugin_panes records for this plugin (panes keep running).
+ self.state
+ .plugin_panes
+ .retain(|_, record| record.plugin_id != plugin_id);
+ if let Err(err) = self.save_plugin_registry() {
+ if let Some(previous) = previous {
+ self.state
+ .installed_plugins
+ .insert(plugin_id.clone(), previous);
+ }
+ if let Some(previous_panes) = previous_panes {
+ self.state.plugin_panes = previous_panes;
+ }
+ return encode_error(id, "plugin_registry_save_failed", err.to_string());
+ }
+ }
+ encode_success(id, ResponseResult::PluginUnlinked { plugin_id, removed })
+ }
+
+ pub(super) fn handle_plugin_enable(
+ &mut self,
+ id: String,
+ params: PluginSetEnabledParams,
+ ) -> String {
+ self.set_plugin_enabled(id, params.plugin_id, true)
+ }
+
+ pub(super) fn handle_plugin_disable(
+ &mut self,
+ id: String,
+ params: PluginSetEnabledParams,
+ ) -> String {
+ self.set_plugin_enabled(id, params.plugin_id, false)
+ }
+
+ pub(super) fn handle_plugin_action_list(
+ &mut self,
+ id: String,
+ params: PluginActionListParams,
+ ) -> String {
+ let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) {
+ Ok(plugin_id) => plugin_id,
+ Err(response) => return response,
+ };
+ let mut actions = manifest_actions(&self.state.installed_plugins)
+ .filter(|action| {
+ plugin_id
+ .as_deref()
+ .is_none_or(|plugin_id| action.plugin_id == plugin_id)
+ })
+ .collect::<Vec<_>>();
+ actions.sort_by_key(|action| action.qualified_id());
+ encode_success(id, ResponseResult::PluginActionList { actions })
+ }
+
+ pub(super) fn handle_plugin_action_invoke(
+ &mut self,
+ id: String,
+ params: PluginActionInvokeParams,
+ ) -> String {
+ let (plugin, action) =
+ match self.find_plugin_action(params.plugin_id.as_deref(), &params.action_id) {
+ Ok(pair) => pair,
+ Err((code, message)) => return encode_error(id, code, message),
+ };
+ if !plugin.enabled {
+ return encode_error(
+ id,
+ "plugin_disabled",
+ format!("plugin {} is disabled", plugin.plugin_id),
+ );
+ }
+ if let Err((code, message)) = ensure_platform_supported(
+ effective_platforms(&action.platforms, &plugin.platforms),
+ &format!("action '{}'", action.qualified_id()),
+ ) {
+ return encode_error(id, code, message);
+ }
+ let context = self.merge_plugin_context(params.context, &id);
+ let log = match self.start_plugin_command(
+ &plugin,
+ Some(action.action_id.clone()),
+ None,
+ action.command.clone(),
+ &context,
+ None,
+ ) {
+ Ok(log) => log,
+ Err((code, message)) => return encode_error(id, code, message),
+ };
+ encode_success(
+ id,
+ ResponseResult::PluginActionInvoked {
+ action,
+ context,
+ log,
+ },
+ )
+ }
+
+ pub(crate) fn invoke_plugin_action_from_keybind(
+ &mut self,
+ action_id: String,
+ ) -> Result<(), String> {
+ let (plugin, action) = self
+ .find_plugin_action(None, &action_id)
+ .map_err(|(_, message)| message)?;
+ if !plugin.enabled {
+ return Err(format!("plugin {} is disabled", plugin.plugin_id));
+ }
+ ensure_platform_supported(
+ effective_platforms(&action.platforms, &plugin.platforms),
+ &action.qualified_id(),
+ )
+ .map_err(|(_, message)| message)?;
+ let mut context = self.current_plugin_context("keybinding");
+ context.invocation_source = Some("keybinding".to_string());
+ self.start_plugin_command(
+ &plugin,
+ Some(action.action_id),
+ None,
+ action.command,
+ &context,
+ None,
+ )
+ .map(|_| ())
+ .map_err(|(_, message)| message)
+ }
+
+ pub(crate) fn invoke_plugin_link_handler_for_url(
+ &mut self,
+ url: &str,
+ pane_id: crate::layout::PaneId,
+ ) -> Result<bool, String> {
+ let Some((plugin, handler)) = self.find_plugin_link_handler(url) else {
+ return Ok(false);
+ };
+ if ensure_platform_supported(
+ &effective_platforms(&handler.platforms, &plugin.platforms).clone(),
+ &handler.id,
+ )
+ .is_err()
+ {
+ return Ok(false);
+ }
+ let action = plugin
+ .actions
+ .iter()
+ .find(|action| action.id == handler.action)
+ .cloned()
+ .ok_or_else(|| {
+ format!(
+ "plugin {} link handler {} references missing action {}",
+ plugin.plugin_id, handler.id, handler.action
+ )
+ })?;
+ ensure_platform_supported(
+ &effective_platforms(&action.platforms, &plugin.platforms).clone(),
+ &action.id,
+ )
+ .map_err(|(_, message)| message)?;
+ let Some(ws_idx) = self.state.active else {
+ return Ok(false);
+ };
+ let mut context = self.plugin_context_for_pane(ws_idx, pane_id, "link_click");
+ context.invocation_source = Some("link_click".to_string());
+ context.clicked_url = Some(url.to_string());
+ context.link_handler_id = Some(handler.id);
+ self.start_plugin_command(
+ &plugin,
+ Some(action.id),
+ None,
+ action.command,
+ &context,
+ None,
+ )
+ .map(|_| true)
+ .map_err(|(_, message)| message)
+ }
+
+ pub(super) fn handle_plugin_log_list(
+ &mut self,
+ id: String,
+ params: PluginLogListParams,
+ ) -> String {
+ let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) {
+ Ok(plugin_id) => plugin_id,
+ Err(response) => return response,
+ };
+ let limit = params.limit.unwrap_or(50).clamp(1, 200);
+ let mut logs = self
+ .state
+ .plugin_command_logs
+ .iter()
+ .filter(|log| {
+ plugin_id
+ .as_deref()
+ .is_none_or(|plugin_id| log.plugin_id == plugin_id)
+ })
+ .rev()
+ .take(limit)
+ .cloned()
+ .collect::<Vec<_>>();
+ logs.reverse();
+ encode_success(id, ResponseResult::PluginLogList { logs })
+ }
+
+ pub(super) fn handle_plugin_pane_open(
+ &mut self,
+ id: String,
+ params: PluginPaneOpenParams,
+ ) -> String {
+ let Some(plugin_id) = normalize_plugin_id(&params.plugin_id) else {
+ return invalid_plugin_id(id);
+ };
+ let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else {
+ return encode_error(id, "plugin_not_found", "plugin not found");
+ };
+ if !plugin_manifest_available(&plugin) {
+ return encode_error(
+ id,
+ "plugin_manifest_unavailable",
+ format!("plugin {plugin_id} manifest is unavailable"),
+ );
+ }
+ if !plugin.enabled {
+ return encode_error(
+ id,
+ "plugin_disabled",
+ format!("plugin {plugin_id} is disabled"),
+ );
+ }
+ let Some(entrypoint) = normalize_action_id(&params.entrypoint) else {
+ return encode_error(id, "invalid_plugin_entrypoint", "invalid entrypoint id");
+ };
+ let Some(pane) = plugin
+ .panes
+ .iter()
+ .find(|pane| pane.id == entrypoint)
+ .cloned()
+ else {
+ return encode_error(
+ id,
+ "plugin_pane_not_found",
+ format!("plugin pane entrypoint '{entrypoint}' not found"),
+ );
+ };
+ if let Err((code, message)) = ensure_platform_supported(
+ effective_platforms(&pane.platforms, &plugin.platforms),
+ "plugin pane",
+ ) {
+ return encode_error(id, code, message);
+ }
+ let placement = params.placement.unwrap_or(pane.placement);
+ match placement {
+ PluginPanePlacement::Overlay => {
+ if params.workspace_id.is_some()
+ || params.target_pane_id.is_some()
+ || params.direction.is_some()
+ {
+ return encode_error(
+ id,
+ "invalid_params",
+ "overlay plugin panes target the active pane",
+ );
+ }
+ }
+ PluginPanePlacement::Split | PluginPanePlacement::Zoomed => {
+ if params.workspace_id.is_some() {
+ return encode_error(
+ id,
+ "invalid_params",
+ "split and zoomed plugin panes target an existing pane; use target_pane_id",
+ );
+ }
+ }
+ PluginPanePlacement::Tab => {
+ if params.target_pane_id.is_some() || params.direction.is_some() {
+ return encode_error(
+ id,
+ "invalid_params",
+ "tab plugin panes support workspace_id but not target_pane_id or direction",
+ );
+ }
+ }
+ }
+
+ match placement {
+ PluginPanePlacement::Overlay => {
+ self.open_plugin_overlay_pane(id, params, &plugin, pane)
+ }
+ PluginPanePlacement::Split | PluginPanePlacement::Zoomed => {
+ self.open_plugin_split_pane(id, params, &plugin, pane, placement)
+ }
+ PluginPanePlacement::Tab => self.open_plugin_tab(id, params, &plugin, pane),
+ }
+ }
+
+ pub(super) fn handle_plugin_pane_focus(
+ &mut self,
+ id: String,
+ params: PluginPaneFocusParams,
+ ) -> String {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ };
+ if !self.state.plugin_panes.contains_key(&pane_id) {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ }
+ self.state.focus_pane_in_workspace(ws_idx, pane_id);
+ self.state.mode = crate::app::Mode::Terminal;
+ let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ };
+ let Some(pane) = self.pane_info(ws_idx, pane_id) else {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ };
+ encode_success(
+ id,
+ ResponseResult::PluginPaneFocused {
+ plugin_pane: PluginPaneInfo {
+ plugin_id: record.plugin_id,
+ entrypoint: record.entrypoint,
+ pane,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_plugin_pane_close(
+ &mut self,
+ id: String,
+ params: PluginPaneCloseParams,
+ ) -> String {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ };
+ if !self.state.plugin_panes.contains_key(&pane_id) {
+ return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
+ }
+ let pane_id = params.pane_id;
+ if let Err(response) = self.close_pane(
+ id.clone(),
+ &crate::api::schema::PaneTarget {
+ pane_id: pane_id.clone(),
+ },
+ ) {
+ return response;
+ }
+ encode_success(id, ResponseResult::PluginPaneClosed { pane_id })
+ }
+
+ fn find_plugin_action(
+ &self,
+ plugin_id: Option<&str>,
+ action_id: &str,
+ ) -> Result<(crate::api::schema::InstalledPluginInfo, PluginActionInfo), (&'static str, String)>
+ {
+ if let Some(plugin_id) = plugin_id {
+ let plugin_id = normalize_plugin_id(plugin_id)
+ .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?;
+ let action_id = normalize_action_id(action_id)
+ .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?;
+ let plugin = self
+ .state
+ .installed_plugins
+ .get(&plugin_id)
+ .ok_or_else(|| ("plugin_not_found", "plugin not found".to_string()))?
+ .clone();
+ if !plugin_manifest_available(&plugin) {
+ return Err((
+ "plugin_manifest_unavailable",
+ format!("plugin {plugin_id} manifest is unavailable"),
+ ));
+ }
+ let action_info = plugin
+ .actions
+ .iter()
+ .find(|a| a.id == action_id)
+ .map(|a| manifest_action_info(&plugin_id, a))
+ .ok_or_else(|| {
+ (
+ "plugin_action_not_found",
+ "plugin action not found".to_string(),
+ )
+ })?;
+ return Ok((plugin, action_info));
+ }
+
+ let action_id = action_id.trim();
+ let matches = manifest_actions(&self.state.installed_plugins)
+ .filter(|action| action.action_id == action_id || action.qualified_id() == action_id)
+ .collect::<Vec<_>>();
+ match matches.as_slice() {
+ [action] => {
+ let plugin = self
+ .state
+ .installed_plugins
+ .get(&action.plugin_id)
+ .cloned()
+ .ok_or_else(|| ("plugin_not_found", "plugin not found".to_string()))?;
+ Ok((plugin, action.clone()))
+ }
+ [] => Err((
+ "plugin_action_not_found",
+ "plugin action not found".to_string(),
+ )),
+ _ => Err((
+ "ambiguous_plugin_action",
+ "plugin action id matches more than one action; include plugin_id".to_string(),
+ )),
+ }
+ }
+
+ fn find_plugin_link_handler(
+ &self,
+ url: &str,
+ ) -> Option<(InstalledPluginInfo, PluginManifestLinkHandler)> {
+ let mut plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .filter(|plugin| plugin.enabled && plugin_manifest_available(plugin))
+ .cloned()
+ .collect::<Vec<_>>();
+ plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id));
+ for plugin in plugins {
+ for handler in &plugin.link_handlers {
+ if ensure_platform_supported(
+ &effective_platforms(&handler.platforms, &plugin.platforms).clone(),
+ &handler.id,
+ )
+ .is_err()
+ {
+ continue;
+ }
+ let Some(action) = plugin
+ .actions
+ .iter()
+ .find(|action| action.id == handler.action)
+ else {
+ continue;
+ };
+ if ensure_platform_supported(
+ &effective_platforms(&action.platforms, &plugin.platforms).clone(),
+ &action.id,
+ )
+ .is_err()
+ {
+ continue;
+ }
+ let Ok(regex) = regex::Regex::new(&handler.pattern) else {
+ continue;
+ };
+ if regex.is_match(url) {
+ return Some((plugin.clone(), handler.clone()));
+ }
+ }
+ }
+ None
+ }
+
+ fn set_plugin_enabled(&mut self, id: String, plugin_id: String, enabled: bool) -> String {
+ let Some(plugin_id) = normalize_plugin_id(&plugin_id) else {
+ return invalid_plugin_id(id);
+ };
+ let Some(plugin) = self.state.installed_plugins.get_mut(&plugin_id) else {
+ return encode_error(id, "plugin_not_found", "plugin not found");
+ };
+ let previous_enabled = plugin.enabled;
+ plugin.enabled = enabled;
+ if let Err(err) = self.save_plugin_registry() {
+ if let Some(plugin) = self.state.installed_plugins.get_mut(&plugin_id) {
+ plugin.enabled = previous_enabled;
+ }
+ return encode_error(id, "plugin_registry_save_failed", err.to_string());
+ }
+ let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else {
+ return encode_error(id, "plugin_not_found", "plugin not found");
+ };
+ if enabled {
+ encode_success(id, ResponseResult::PluginEnabled { plugin })
+ } else {
+ encode_success(id, ResponseResult::PluginDisabled { plugin })
+ }
+ }
+
+ pub(crate) fn save_plugin_registry(&self) -> std::io::Result<()> {
+ if self.no_session {
+ return Ok(());
+ }
+ let plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .cloned()
+ .collect::<Vec<_>>();
+ crate::persist::plugin_registry::save(&plugins)
+ }
+}
+
+fn invalid_plugin_id(id: String) -> String {
+ encode_error(
+ id,
+ "invalid_plugin_id",
+ "plugin id must be non-empty, <= 120 characters, and contain only ASCII letters, digits, colon, dot, underscore, or hyphen",
+ )
+}
+
+/// Normalize an optional plugin id filter; `Err` carries the encoded
+/// `invalid_plugin_id` error response.
+fn normalize_optional_plugin_id(
+ id: &str,
+ plugin_id: Option<String>,
+) -> Result<Option<String>, String> {
+ match plugin_id {
+ Some(plugin_id) => match normalize_plugin_id(&plugin_id) {
+ Some(plugin_id) => Ok(Some(plugin_id)),
+ None => Err(invalid_plugin_id(id.to_string())),
+ },
+ None => Ok(None),
+ }
+}
+
+fn plugin_manifest_available(plugin: &InstalledPluginInfo) -> bool {
+ !plugin.warnings.iter().any(|warning| {
+ warning.starts_with(crate::persist::plugin_registry::MANIFEST_UNAVAILABLE_WARNING_PREFIX)
+ })
+}
+
+fn manifest_action_info(plugin_id: &str, action: &PluginManifestAction) -> PluginActionInfo {
+ PluginActionInfo {
+ plugin_id: plugin_id.to_string(),
+ action_id: action.id.clone(),
+ title: action.title.clone(),
+ description: action.description.clone(),
+ contexts: action.contexts.clone(),
+ command: action.command.clone(),
+ platforms: action.platforms.clone(),
+ }
+}
+
+fn manifest_actions(
+ plugins: &crate::app::state::InstalledPluginRegistry,
+) -> impl Iterator<Item = PluginActionInfo> + '_ {
+ plugins
+ .values()
+ .filter(|plugin| plugin_manifest_available(plugin))
+ .flat_map(|plugin| {
+ plugin
+ .actions
+ .iter()
+ .map(|action| manifest_action_info(&plugin.plugin_id, action))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::schema::{
+ Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
+ };
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ fn test_app() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ )
+ }
+
+ fn response_result(response: &str) -> ResponseResult {
+ serde_json::from_str::<SuccessResponse>(response)
+ .expect("success response")
+ .result
+ }
+
+ fn unique_temp_path(name: &str) -> std::path::PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
+ }
+
+ fn write_manifest(root: &std::path::Path) -> std::path::PathBuf {
+ std::fs::create_dir_all(root).unwrap();
+ let manifest = root.join("herdr-plugin.toml");
+ std::fs::write(
+ &manifest,
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.1.0"
+description = "Prepare new worktrees"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["bun", "install"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap worktree"
+contexts = ["workspace"]
+command = ["bun", "run", "bootstrap.ts"]
+
+[[events]]
+on = "worktree.created"
+command = ["bun", "run", "bootstrap.ts"]
+
+[[panes]]
+id = "board"
+title = "Worktree board"
+command = ["bun", "run", "board.ts"]
+
+[[link_handlers]]
+id = "github-pr"
+title = "Open GitHub PR"
+pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$"
+action = "bootstrap"
+"#,
+ )
+ .unwrap();
+ manifest
+ }
+
+ fn write_manifest_content(root: &std::path::Path, content: &str) -> std::path::PathBuf {
+ std::fs::create_dir_all(root).unwrap();
+ let manifest = root.join("herdr-plugin.toml");
+ std::fs::write(&manifest, content).unwrap();
+ manifest
+ }
+
+ fn link_manifest(app: &mut App, root: &std::path::Path) {
+ let result = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ assert!(
+ result.contains("plugin_linked"),
+ "expected plugin_linked: {result}"
+ );
+ }
+
+ #[test]
+ fn plugin_link_lists_and_unlinks_manifest() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-link");
+ write_manifest(&root);
+
+ let link = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ let ResponseResult::PluginLinked { plugin } = response_result(&link) else {
+ panic!("expected plugin linked response: {link}");
+ };
+ assert_eq!(plugin.plugin_id, "example.worktree-bootstrap");
+ assert_eq!(plugin.name, "Worktree Bootstrap");
+ assert_eq!(plugin.version, "0.1.0");
+ assert_eq!(plugin.plugin_root, root.display().to_string());
+ assert!(plugin.enabled);
+ assert_eq!(plugin.build.len(), 1);
+ assert_eq!(plugin.build[0].command, ["bun", "install"]);
+ assert_eq!(plugin.actions.len(), 1);
+ assert_eq!(plugin.actions[0].id, "bootstrap");
+ assert_eq!(plugin.actions[0].command, ["bun", "run", "bootstrap.ts"]);
+ assert_eq!(plugin.events.len(), 1);
+ assert_eq!(plugin.events[0].on, "worktree.created");
+ assert_eq!(plugin.panes.len(), 1);
+ assert_eq!(plugin.panes[0].id, "board");
+ assert_eq!(plugin.panes[0].placement, PluginPanePlacement::Overlay);
+ assert_eq!(plugin.link_handlers.len(), 1);
+ assert_eq!(plugin.link_handlers[0].id, "github-pr");
+ assert_eq!(plugin.link_handlers[0].action, "bootstrap");
+
+ let list = app.handle_api_request(Request {
+ id: "list".into(),
+ method: Method::PluginList(PluginListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginList { plugins } = response_result(&list) else {
+ panic!("expected plugin list response: {list}");
+ };
+ assert_eq!(plugins.len(), 1);
+ assert_eq!(plugins[0].plugin_id, "example.worktree-bootstrap");
+
+ let unlink = app.handle_api_request(Request {
+ id: "unlink".into(),
+ method: Method::PluginUnlink(PluginUnlinkParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ });
+ assert!(matches!(
+ response_result(&unlink),
+ ResponseResult::PluginUnlinked {
+ plugin_id,
+ removed: true
+ } if plugin_id == "example.worktree-bootstrap"
+ ));
+
+ let list = app.handle_api_request(Request {
+ id: "list-empty".into(),
+ method: Method::PluginList(PluginListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginList { plugins } = response_result(&list) else {
+ panic!("expected plugin list response: {list}");
+ };
+ assert!(plugins.is_empty());
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_link_rejects_invalid_github_source_path() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-invalid-source");
+ write_manifest(&root);
+
+ let response = app.handle_api_request(Request {
+ id: "link-invalid-source".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: Some(PluginSourceInfo {
+ kind: PluginSourceKind::Github,
+ owner: Some("ogulcancelik".into()),
+ repo: Some("herdr-plugin-examples".into()),
+ subdir: Some("worktree-bootstrap".into()),
+ requested_ref: None,
+ resolved_commit: Some("abc123".into()),
+ managed_path: Some(root.display().to_string()),
+ installed_unix_ms: Some(42),
+ }),
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(value["error"]["code"], "invalid_plugin_source");
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn link_rejects_duplicate_action_ids() {
+ let root = unique_temp_path("plugin-duplicate-action");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.duplicate"
+name = "Duplicate"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["echo", "a"]
+
+[[actions]]
+id = "run"
+title = "Run again"
+command = ["echo", "b"]
+"#,
+ );
+
+ let result = load_plugin_manifest(&root.display().to_string(), true);
+ assert!(matches!(result, Err(("duplicate_plugin_action_id", _))));
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn link_rejects_dotted_action_ids() {
+ let root = unique_temp_path("plugin-dotted-action");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.dotted-action"
+name = "Dotted Action"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "build.release"
+title = "Build"
+command = ["echo", "build"]
+"#,
+ );
+
+ let result = load_plugin_manifest(&root.display().to_string(), true);
+ assert!(matches!(result, Err(("invalid_plugin_action_id", _))));
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn link_rejects_duplicate_pane_ids() {
+ let root = unique_temp_path("plugin-duplicate-pane");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.duplicate-pane"
+name = "Duplicate Pane"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[panes]]
+id = "ui"
+title = "UI"
+command = ["echo", "a"]
+
+[[panes]]
+id = "ui"
+title = "UI again"
+command = ["echo", "b"]
+"#,
+ );
+
+ let result = load_plugin_manifest(&root.display().to_string(), true);
+ assert!(matches!(result, Err(("duplicate_plugin_pane_id", _))));
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn smoke_fixture_manifest_loads() {
+ let plugin = load_plugin_manifest("tests/fixtures/plugin-smoke", true)
+ .expect("smoke fixture should load");
+ assert_eq!(plugin.plugin_id, "example.smoke");
+ assert_eq!(plugin.actions.len(), 1);
+ assert_eq!(plugin.events.len(), 1);
+ assert_eq!(plugin.panes.len(), 1);
+ assert!(plugin.warnings.is_empty());
+ }
+
+ #[test]
+ fn plugin_command_output_reader_caps_and_marks_truncation() {
+ let output = read_capped_plugin_output("abcdef".as_bytes(), 3);
+
+ assert_eq!(output, "abc\n[herdr truncated plugin output after 3 bytes]");
+ }
+
+ #[test]
+ fn plugin_enable_disable_updates_registry_state() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-enable-disable");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+
+ let disabled = app.handle_api_request(Request {
+ id: "disable".into(),
+ method: Method::PluginDisable(PluginSetEnabledParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ });
+ let ResponseResult::PluginDisabled { plugin } = response_result(&disabled) else {
+ panic!("expected disabled response: {disabled}");
+ };
+ assert!(!plugin.enabled);
+
+ let enabled = app.handle_api_request(Request {
+ id: "enable".into(),
+ method: Method::PluginEnable(PluginSetEnabledParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ });
+ let ResponseResult::PluginEnabled { plugin } = response_result(&enabled) else {
+ panic!("expected enabled response: {enabled}");
+ };
+ assert!(plugin.enabled);
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_pane_open_requires_installed_plugin() {
+ let mut app = test_app();
+ let response = app.handle_api_request(Request {
+ id: "pane-open".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.missing".into(),
+ entrypoint: "ui".into(),
+ placement: Some(PluginPanePlacement::Split),
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: false,
+ env: std::collections::HashMap::new(),
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(value["error"]["code"], "plugin_not_found");
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() {
+ let mut app = test_app();
+ let mut workspace = crate::workspace::Workspace::test_new("plugin-target");
+ workspace.custom_name = None;
+ let root_pane = workspace.tabs[0].root_pane;
+ let root_terminal = workspace.terminal_id(root_pane).cloned().unwrap();
+ app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+ app.state.terminals.get_mut(&root_terminal).unwrap().cwd = "/tmp".into();
+ let target_public_pane_id = app.public_pane_id(0, root_pane).unwrap();
+
+ let root = unique_temp_path("plugin-pane-open");
+ let capture = root.join("capture.txt");
+ write_manifest_content(
+ &root,
+ &format!(
+ r#"
+id = "example.pane"
+name = "Pane Plugin"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[panes]]
+id = "board"
+title = "Plugin Board"
+command = ["sh", "-c", "printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \"$PWD\" \"$HERDR_PLUGIN_ID\" \"$HERDR_PLUGIN_ENTRYPOINT_ID\" \"$HERDR_WORKSPACE_ID\" \"$HERDR_PANE_ID\" \"$HERDR_BIN_PATH\" \"$HERDR_PLUGIN_CONTEXT_JSON\" > {}"]
+"#,
+ capture.display()
+ ),
+ );
+ link_manifest(&mut app, &root);
+
+ let open = app.handle_api_request(Request {
+ id: "pane-open".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ placement: Some(PluginPanePlacement::Overlay),
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: true,
+ env: std::collections::HashMap::from([
+ ("HERDR_PLUGIN_ID".to_string(), "spoofed-plugin".to_string()),
+ (
+ "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(),
+ "spoofed-entrypoint".to_string(),
+ ),
+ (
+ "HERDR_PLUGIN_CONTEXT_JSON".to_string(),
+ "{\"spoofed\":true}".to_string(),
+ ),
+ (
+ "HERDR_BIN_PATH".to_string(),
+ "/tmp/spoofed-herdr".to_string(),
+ ),
+ ]),
+ }),
+ });
+ let ResponseResult::PluginPaneOpened { plugin_pane } = response_result(&open) else {
+ panic!("expected plugin pane opened response: {open}");
+ };
+ assert_eq!(plugin_pane.plugin_id, "example.pane");
+ assert_eq!(plugin_pane.entrypoint, "board");
+ assert_eq!(plugin_pane.pane.label.as_deref(), Some("Plugin Board"));
+ let Some((_, opened_pane_id)) = app.parse_pane_id(&plugin_pane.pane.pane_id) else {
+ panic!("opened pane id should parse");
+ };
+ assert!(app.state.plugin_panes.contains_key(&opened_pane_id));
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while !capture.exists() && std::time::Instant::now() < deadline {
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ let text = std::fs::read_to_string(&capture).expect("plugin pane command should write env");
+ let mut lines = text.lines();
+ assert_eq!(lines.next(), Some(root.display().to_string().as_str()));
+ assert_eq!(lines.next(), Some("example.pane"));
+ assert_eq!(lines.next(), Some("board"));
+ assert_eq!(lines.next(), Some(plugin_pane.pane.workspace_id.as_str()));
+ assert_eq!(lines.next(), Some(plugin_pane.pane.pane_id.as_str()));
+ let bin_path = lines.next().expect("bin path");
+ assert_ne!(bin_path, "/tmp/spoofed-herdr");
+ assert_eq!(
+ bin_path,
+ std::env::current_exe()
+ .expect("current exe should resolve")
+ .display()
+ .to_string()
+ );
+ let context: PluginInvocationContext =
+ serde_json::from_str(lines.next().expect("context json")).unwrap();
+ assert_eq!(
+ context.workspace_id.as_deref(),
+ Some(plugin_pane.pane.workspace_id.as_str())
+ );
+ assert_eq!(
+ context.focused_pane_id.as_deref(),
+ Some(target_public_pane_id.as_str())
+ );
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn plugin_pane_open_injects_plugin_paths_and_protects_overrides() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-path-env")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+ let root = unique_temp_path("plugin-pane-path-env");
+ let capture = root.join("capture.txt");
+ write_manifest_content(
+ &root,
+ &format!(
+ r#"
+id = "example.path-env"
+name = "Path Env"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[panes]]
+id = "board"
+title = "Plugin Board"
+command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUGIN_CONFIG_DIR\" \"$HERDR_PLUGIN_STATE_DIR\" > {}"]
+"#,
+ capture.display()
+ ),
+ );
+ link_manifest(&mut app, &root);
+
+ let open = app.handle_api_request(Request {
+ id: "pane-open".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.path-env".into(),
+ entrypoint: "board".into(),
+ placement: Some(PluginPanePlacement::Overlay),
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: true,
+ env: std::collections::HashMap::from([
+ (
+ "HERDR_PLUGIN_ROOT".to_string(),
+ "/tmp/spoofed-root".to_string(),
+ ),
+ (
+ "HERDR_PLUGIN_CONFIG_DIR".to_string(),
+ "/tmp/spoofed-config".to_string(),
+ ),
+ (
+ "HERDR_PLUGIN_STATE_DIR".to_string(),
+ "/tmp/spoofed-state".to_string(),
+ ),
+ ]),
+ }),
+ });
+ let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else {
+ panic!("expected plugin pane opened response: {open}");
+ };
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while !capture.exists() && std::time::Instant::now() < deadline {
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ let text = std::fs::read_to_string(&capture).expect("plugin pane command should write env");
+ let mut lines = text.lines();
+ assert_eq!(lines.next(), Some(root.display().to_string().as_str()));
+ assert_eq!(
+ lines.next(),
+ Some(
+ crate::config::config_dir()
+ .join("plugins")
+ .join(crate::api::schema::plugin_managed_path_component(
+ "example.path-env"
+ ))
+ .display()
+ .to_string()
+ .as_str()
+ )
+ );
+ assert_eq!(
+ lines.next(),
+ Some(
+ crate::config::state_dir()
+ .join("plugins")
+ .join(crate::api::schema::plugin_managed_path_component(
+ "example.path-env"
+ ))
+ .display()
+ .to_string()
+ .as_str()
+ )
+ );
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn plugin_pane_open_tab_emits_tab_created_before_pane_created() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ event_hub.clone(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-tab")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+
+ let root = unique_temp_path("plugin-pane-tab-events");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.tab"
+name = "Tab Plugin"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[panes]]
+id = "board"
+title = "Plugin Board"
+placement = "tab"
+command = ["sh", "-c", "sleep 1"]
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let open = app.handle_api_request(Request {
+ id: "pane-open-tab".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.tab".into(),
+ entrypoint: "board".into(),
+ placement: None,
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: true,
+ env: std::collections::HashMap::new(),
+ }),
+ });
+ let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else {
+ panic!("expected plugin pane opened response: {open}");
+ };
+
+ let events = event_hub
+ .events_after(0)
+ .into_iter()
+ .map(|(_, event)| event.event)
+ .collect::<Vec<_>>();
+ let tab_created = events
+ .iter()
+ .position(|event| *event == crate::api::schema::EventKind::TabCreated)
+ .expect("tab.created should be emitted");
+ let pane_created = events
+ .iter()
+ .position(|event| *event == crate::api::schema::EventKind::PaneCreated)
+ .expect("pane.created should be emitted");
+ assert!(tab_created < pane_created);
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn manifest_action_list_and_invoke_with_context() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-action-list");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+
+ let list = app.handle_api_request(Request {
+ id: "list".into(),
+ method: Method::PluginActionList(PluginActionListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginActionList { actions } = response_result(&list) else {
+ panic!("expected plugin action list: {list}");
+ };
+ assert_eq!(actions.len(), 1);
+ assert_eq!(
+ actions[0].qualified_id(),
+ "example.worktree-bootstrap.bootstrap"
+ );
+ assert_eq!(actions[0].command, ["bun", "run", "bootstrap.ts"]);
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ action_id: "bootstrap".into(),
+ context: Some(PluginInvocationContext {
+ workspace_id: Some("1".into()),
+ workspace_label: None,
+ workspace_cwd: None,
+ worktree: None,
+ tab_id: None,
+ tab_label: None,
+ focused_pane_id: None,
+ focused_pane_cwd: None,
+ focused_pane_agent: None,
+ focused_pane_status: None,
+ selected_text: None,
+ invocation_source: Some("test".into()),
+ correlation_id: Some("external-correlation".into()),
+ clicked_url: None,
+ link_handler_id: None,
+ }),
+ }),
+ });
+ let ResponseResult::PluginActionInvoked {
+ action,
+ context,
+ log,
+ } = response_result(&invoke)
+ else {
+ panic!("expected plugin action invocation: {invoke}");
+ };
+ assert_eq!(
+ action.qualified_id(),
+ "example.worktree-bootstrap.bootstrap"
+ );
+ assert_eq!(action.command, ["bun", "run", "bootstrap.ts"]);
+ assert_eq!(log.plugin_id, "example.worktree-bootstrap");
+ assert_eq!(log.action_id.as_deref(), Some("bootstrap"));
+ assert_eq!(context.workspace_id.as_deref(), Some("1"));
+ assert_eq!(context.invocation_source.as_deref(), Some("test"));
+ assert_eq!(
+ context.correlation_id.as_deref(),
+ Some("external-correlation")
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn stale_registry_entries_are_visible_but_not_runnable() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-stale-registry");
+ write_manifest(&root);
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ let _ = std::fs::remove_dir_all(&root);
+ let reloaded =
+ crate::persist::plugin_registry::reload_manifests(vec![plugin], |path, enabled| {
+ load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg)
+ });
+ assert_eq!(reloaded.len(), 1);
+ assert!(reloaded[0].warnings.iter().any(|warning| warning
+ .starts_with(crate::persist::plugin_registry::MANIFEST_UNAVAILABLE_WARNING_PREFIX)));
+ app.state
+ .installed_plugins
+ .insert(reloaded[0].plugin_id.clone(), reloaded[0].clone());
+
+ let list = app.handle_api_request(Request {
+ id: "plugin-list".into(),
+ method: Method::PluginList(PluginListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginList { plugins } = response_result(&list) else {
+ panic!("expected plugin list: {list}");
+ };
+ assert_eq!(plugins.len(), 1);
+
+ let actions = app.handle_api_request(Request {
+ id: "action-list".into(),
+ method: Method::PluginActionList(PluginActionListParams { plugin_id: None }),
+ });
+ let ResponseResult::PluginActionList { actions } = response_result(&actions) else {
+ panic!("expected action list: {actions}");
+ };
+ assert!(actions.is_empty());
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ action_id: "bootstrap".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(value["error"]["code"], "plugin_manifest_unavailable");
+
+ let pane = app.handle_api_request(Request {
+ id: "pane-open".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ entrypoint: "board".into(),
+ placement: None,
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: true,
+ env: std::collections::HashMap::new(),
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&pane).unwrap();
+ assert_eq!(value["error"]["code"], "plugin_manifest_unavailable");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn manifest_action_invoke_runs_command_and_captures_log() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-action-runner");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.runner"
+name = "Runner"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_ACTION_ID\""]
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-runner".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.runner".into()),
+ action_id: "run".into(),
+ context: None,
+ }),
+ });
+ let ResponseResult::PluginActionInvoked { log, .. } = response_result(&invoke) else {
+ panic!("expected plugin action invocation: {invoke}");
+ };
+ assert_eq!(log.status, PluginCommandStatus::Running);
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while std::time::Instant::now() < deadline {
+ app.drain_all_internal_events();
+ if app.state.plugin_command_logs.iter().any(|entry| {
+ entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running
+ }) {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+
+ let logs = app.handle_api_request(Request {
+ id: "logs".into(),
+ method: Method::PluginLogList(PluginLogListParams {
+ plugin_id: Some("example.runner".into()),
+ limit: Some(10),
+ }),
+ });
+ let ResponseResult::PluginLogList { logs } = response_result(&logs) else {
+ panic!("expected plugin logs: {logs}");
+ };
+ let finished = logs
+ .iter()
+ .find(|entry| entry.log_id == log.log_id)
+ .expect("log should exist");
+ assert_eq!(finished.status, PluginCommandStatus::Succeeded);
+ assert_eq!(finished.stdout.as_deref(), Some("run"));
+ assert_eq!(finished.exit_code, Some(0));
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn manifest_action_invoke_injects_plugin_paths() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-action-path-env");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.action-paths"
+name = "Action Paths"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUGIN_CONFIG_DIR\" \"$HERDR_PLUGIN_STATE_DIR\""]
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-runner".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.action-paths".into()),
+ action_id: "run".into(),
+ context: None,
+ }),
+ });
+ let ResponseResult::PluginActionInvoked { log, .. } = response_result(&invoke) else {
+ panic!("expected plugin action invocation: {invoke}");
+ };
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while std::time::Instant::now() < deadline {
+ app.drain_all_internal_events();
+ if app.state.plugin_command_logs.iter().any(|entry| {
+ entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running
+ }) {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+
+ let logs = app.handle_api_request(Request {
+ id: "logs".into(),
+ method: Method::PluginLogList(PluginLogListParams {
+ plugin_id: Some("example.action-paths".into()),
+ limit: Some(10),
+ }),
+ });
+ let ResponseResult::PluginLogList { logs } = response_result(&logs) else {
+ panic!("expected plugin logs: {logs}");
+ };
+ let finished = logs
+ .iter()
+ .find(|entry| entry.log_id == log.log_id)
+ .expect("log should exist");
+ assert_eq!(finished.status, PluginCommandStatus::Succeeded);
+ let mut lines = finished.stdout.as_deref().unwrap_or_default().lines();
+ assert_eq!(lines.next(), Some(root.display().to_string().as_str()));
+ assert_eq!(
+ lines.next(),
+ Some(
+ crate::config::config_dir()
+ .join("plugins")
+ .join(crate::api::schema::plugin_managed_path_component(
+ "example.action-paths"
+ ))
+ .display()
+ .to_string()
+ .as_str()
+ )
+ );
+ assert_eq!(
+ lines.next(),
+ Some(
+ crate::config::state_dir()
+ .join("plugins")
+ .join(crate::api::schema::plugin_managed_path_component(
+ "example.action-paths"
+ ))
+ .display()
+ .to_string()
+ .as_str()
+ )
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[tokio::test]
+ async fn current_plugin_context_includes_selected_text_for_focused_pane() {
+ let mut app = test_app();
+ let workspace = crate::workspace::Workspace::test_new("plugin-selection");
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
+ app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+ app.terminal_runtimes.insert(
+ terminal_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"),
+ );
+ app.state.selection = Some(crate::selection::Selection::range(pane_id, 0, 0, 4, None));
+
+ let context = app.current_plugin_context("selection-test");
+
+ assert_eq!(context.selected_text.as_deref(), Some("hello"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn event_hooks_use_event_target_context() {
+ let mut app = test_app();
+ app.state.workspaces = vec![
+ crate::workspace::Workspace::test_new("active"),
+ crate::workspace::Workspace::test_new("event-target"),
+ ];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let active_workspace_id = app.public_workspace_id(0);
+ let target_workspace = app.workspace_info(1);
+
+ let root = unique_temp_path("plugin-event-context");
+ let capture = root.join("context.json");
+ write_manifest_content(
+ &root,
+ &format!(
+ r#"
+id = "example.event-context"
+name = "Event Context"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[events]]
+on = "worktree.created"
+command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_CONTEXT_JSON\" > {}"]
+"#,
+ capture.display()
+ ),
+ );
+ link_manifest(&mut app, &root);
+
+ app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::WorktreeCreated,
+ data: crate::api::schema::EventData::WorktreeCreated {
+ workspace: target_workspace.clone(),
+ worktree: crate::api::schema::WorktreeInfo {
+ path: "/tmp/repo".into(),
+ branch: Some("feature".into()),
+ is_bare: false,
+ is_detached: false,
+ is_prunable: false,
+ is_linked_worktree: true,
+ open_workspace_id: Some(target_workspace.workspace_id.clone()),
+ label: "feature".into(),
+ },
+ },
+ });
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while !capture.exists() && std::time::Instant::now() < deadline {
+ app.drain_all_internal_events();
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ let context: PluginInvocationContext =
+ serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap();
+ assert_eq!(
+ context.workspace_id.as_deref(),
+ Some(target_workspace.workspace_id.as_str())
+ );
+ assert_ne!(
+ context.workspace_id.as_deref(),
+ Some(active_workspace_id.as_str())
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_command_limit_rejects_and_logs() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-command-limit");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+ app.state.plugin_commands_in_flight = MAX_PLUGIN_COMMANDS_IN_FLIGHT;
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-limit".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ action_id: "bootstrap".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(value["error"]["code"], "plugin_command_limit_reached");
+ let log = app
+ .state
+ .plugin_command_logs
+ .last()
+ .expect("rejected command should be logged");
+ assert_eq!(log.status, PluginCommandStatus::Failed);
+ assert!(log
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("maximum concurrent plugin commands")));
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn closed_event_context_uses_closed_target_ids() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("closed-events")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let active_pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let active_public_pane_id = app.public_pane_id(0, active_pane_id).unwrap();
+ let workspace_id = app.public_workspace_id(0);
+ let closed_tab_id = format!("{workspace_id}:t99");
+ let closed_pane_id = format!("{workspace_id}:p99");
+
+ let tab_context = app.plugin_context_for_event(
+ &crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::TabClosed,
+ data: crate::api::schema::EventData::TabClosed {
+ tab_id: closed_tab_id.clone(),
+ workspace_id: workspace_id.clone(),
+ },
+ },
+ "tab.closed",
+ );
+ assert_eq!(
+ tab_context.workspace_id.as_deref(),
+ Some(workspace_id.as_str())
+ );
+ assert_eq!(tab_context.tab_id.as_deref(), Some(closed_tab_id.as_str()));
+ assert_eq!(tab_context.focused_pane_id, None);
+
+ let pane_context = app.plugin_context_for_event(
+ &crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneClosed,
+ data: crate::api::schema::EventData::PaneClosed {
+ pane_id: closed_pane_id.clone(),
+ workspace_id: workspace_id.clone(),
+ },
+ },
+ "pane.closed",
+ );
+ assert_eq!(
+ pane_context.workspace_id.as_deref(),
+ Some(workspace_id.as_str())
+ );
+ assert_eq!(
+ pane_context.focused_pane_id.as_deref(),
+ Some(closed_pane_id.as_str())
+ );
+ assert_ne!(
+ pane_context.focused_pane_id.as_deref(),
+ Some(active_public_pane_id.as_str())
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn plugin_link_handler_invokes_action_with_clicked_url_context() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("link-handler")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let root = unique_temp_path("plugin-link-handler");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.links"
+name = "Links"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "open"
+title = "Open link"
+command = ["sh", "-c", "printf '%s|%s' \"$HERDR_PLUGIN_LINK_HANDLER_ID\" \"$HERDR_PLUGIN_CLICKED_URL\""]
+
+[[link_handlers]]
+id = "github-issue"
+title = "Open GitHub issue"
+pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$"
+action = "open"
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let handled = app
+ .invoke_plugin_link_handler_for_url(
+ "https://github.com/ogulcancelik/herdr/issues/398",
+ pane_id,
+ )
+ .expect("link handler should invoke");
+ assert!(handled);
+ let log = app
+ .state
+ .plugin_command_logs
+ .last()
+ .expect("plugin command log should be recorded")
+ .clone();
+
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while std::time::Instant::now() < deadline {
+ app.drain_all_internal_events();
+ if app.state.plugin_command_logs.iter().any(|entry| {
+ entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running
+ }) {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+
+ let finished = app
+ .state
+ .plugin_command_logs
+ .iter()
+ .find(|entry| entry.log_id == log.log_id)
+ .expect("log should exist");
+ assert_eq!(finished.status, PluginCommandStatus::Succeeded);
+ assert_eq!(finished.action_id.as_deref(), Some("open"));
+ assert_eq!(
+ finished.stdout.as_deref(),
+ Some("github-issue|https://github.com/ogulcancelik/herdr/issues/398")
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_link_handlers_keep_manifest_order_for_overlapping_patterns() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-link-handler-order");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.link-order"
+name = "Link Order"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "specific"
+title = "Specific"
+command = ["true"]
+
+[[actions]]
+id = "generic"
+title = "Generic"
+command = ["true"]
+
+[[link_handlers]]
+id = "z-specific"
+title = "Specific GitHub issue"
+pattern = "^https://github\\.com/[^/]+/[^/]+/issues/[0-9]+$"
+action = "specific"
+
+[[link_handlers]]
+id = "a-generic"
+title = "Generic GitHub"
+pattern = "^https://github\\.com/"
+action = "generic"
+"#,
+ );
+ link_manifest(&mut app, &root);
+
+ let (_plugin, handler) = app
+ .find_plugin_link_handler("https://github.com/ogulcancelik/herdr/issues/398")
+ .expect("handler should match");
+ assert_eq!(handler.id, "z-specific");
+ assert_eq!(handler.action, "specific");
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_link_rejects_invalid_link_handler_pattern() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-bad-link-handler-pattern");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.bad-links"
+name = "Bad Links"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "open"
+title = "Open link"
+command = ["true"]
+
+[[link_handlers]]
+id = "bad"
+title = "Bad"
+pattern = "["
+action = "open"
+"#,
+ );
+
+ let response = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ value["error"]["code"],
+ "invalid_plugin_link_handler_pattern"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_link_rejects_link_handler_unknown_action() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-bad-link-handler-action");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.bad-link-action"
+name = "Bad Link Action"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "open"
+title = "Open link"
+command = ["true"]
+
+[[link_handlers]]
+id = "github"
+title = "GitHub"
+pattern = "^https://github\\.com/"
+action = "missing"
+"#,
+ );
+
+ let response = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(value["error"]["code"], "invalid_plugin_link_handler_action");
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn manifest_action_invoke_builds_default_workspace_context() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
+ app.state.workspaces[0].identity_cwd = "/tmp/issue".into();
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.workspaces[0].custom_name = Some("Plugin Work".into());
+ app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: "/repo/herdr-issue".into(),
+ is_linked_worktree: true,
+ });
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let pane_public = app.public_pane_id(0, pane_id).unwrap();
+ let tab_public = app.public_tab_id(0, 0).unwrap();
+ let workspace_public = app.public_workspace_id(0);
+ let _ = app.handle_pane_report_agent(
+ "report".into(),
+ crate::api::schema::PaneReportAgentParams {
+ pane_id: pane_public.clone(),
+ source: "test".into(),
+ agent: "codex".into(),
+ state: crate::api::schema::PaneAgentState::Working,
+ message: None,
+ custom_status: None,
+ seq: None,
+ agent_session_id: None,
+ agent_session_path: None,
+ },
+ );
+
+ let root = unique_temp_path("plugin-action-context");
+ // write a manifest with a "show" action in pane context
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.context"
+name = "Context"
+version = "0.1.0"
+
+[[actions]]
+id = "show"
+title = "Show Context"
+contexts = ["pane"]
+command = ["show-ctx"]
+"#,
+ )
+ .unwrap();
+ link_manifest(&mut app, &root);
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-context".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.context".into()),
+ action_id: "show".into(),
+ context: None,
+ }),
+ });
+
+ let ResponseResult::PluginActionInvoked { context, .. } = response_result(&invoke) else {
+ panic!("expected plugin action invocation: {invoke}");
+ };
+ assert_eq!(
+ context.workspace_id.as_deref(),
+ Some(workspace_public.as_str())
+ );
+ assert_eq!(context.workspace_label.as_deref(), Some("Plugin Work"));
+ assert_eq!(context.workspace_cwd.as_deref(), Some("/tmp/issue"));
+ assert_eq!(context.tab_id.as_deref(), Some(tab_public.as_str()));
+ assert_eq!(context.tab_label.as_deref(), Some("1"));
+ assert_eq!(
+ context.focused_pane_id.as_deref(),
+ Some(pane_public.as_str())
+ );
+ assert_eq!(context.focused_pane_cwd.as_deref(), Some("/tmp/issue"));
+ assert_eq!(context.focused_pane_agent.as_deref(), Some("codex"));
+ assert_eq!(
+ context.focused_pane_status,
+ Some(crate::api::schema::AgentStatus::Working)
+ );
+ assert_eq!(context.invocation_source.as_deref(), Some("api"));
+ assert_eq!(context.correlation_id.as_deref(), Some("invoke-context"));
+ let worktree = context.worktree.as_ref().unwrap();
+ assert_eq!(worktree.repo_key, "repo-key");
+ assert_eq!(worktree.repo_name, "herdr");
+ assert_eq!(worktree.repo_root, "/repo/herdr");
+ assert_eq!(worktree.checkout_path, "/repo/herdr-issue");
+ assert!(worktree.is_linked_worktree);
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn manifest_action_invoke_returns_plugin_disabled_when_disabled() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-disabled");
+ write_manifest(&root);
+
+ // link as disabled
+ let link_result = app.handle_api_request(Request {
+ id: "link-disabled".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: false,
+ source: None,
+ }),
+ });
+ assert!(
+ link_result.contains("plugin_linked"),
+ "expected plugin_linked: {link_result}"
+ );
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke-disabled".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ action_id: "bootstrap".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(value["error"]["code"], "plugin_disabled");
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ fn write_manifest_with_bad_event(root: &std::path::Path) -> std::path::PathBuf {
+ std::fs::create_dir_all(root).unwrap();
+ let manifest = root.join("herdr-plugin.toml");
+ std::fs::write(
+ &manifest,
+ r#"
+id = "example.bad-event"
+name = "Bad Event Plugin"
+version = "0.1.0"
+
+[[events]]
+on = "worktree.craeted"
+command = ["sh", "-c", "echo hi"]
+
+[[events]]
+on = "pane.output_changed"
+command = ["sh", "-c", "echo too noisy"]
+
+[[events]]
+on = "worktree.created"
+command = ["sh", "-c", "echo ok"]
+"#,
+ )
+ .unwrap();
+ manifest
+ }
+
+ #[test]
+ fn link_with_unknown_event_name_succeeds_with_warning() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-bad-event");
+ write_manifest_with_bad_event(&root);
+
+ let link = app.handle_api_request(Request {
+ id: "link-bad-event".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+
+ let ResponseResult::PluginLinked { plugin } = response_result(&link) else {
+ panic!("expected plugin_linked: {link}");
+ };
+ assert_eq!(plugin.plugin_id, "example.bad-event");
+ assert!(
+ plugin
+ .warnings
+ .iter()
+ .any(|w| w.contains("worktree.craeted")),
+ "expected warning for misspelled event, got: {:?}",
+ plugin.warnings
+ );
+ // The correctly named event produces no extra warning
+ assert_eq!(
+ plugin
+ .warnings
+ .iter()
+ .filter(|w| w.contains("worktree.created"))
+ .count(),
+ 0
+ );
+ assert!(
+ plugin
+ .warnings
+ .iter()
+ .any(|w| w.contains("pane.output_changed")),
+ "expected warning for unemitted output-change hook, got: {:?}",
+ plugin.warnings
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn output_changed_event_hooks_do_not_run_even_if_event_is_emitted() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-output-hook");
+ write_manifest_with_bad_event(&root);
+ link_manifest(&mut app, &root);
+
+ app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneOutputChanged,
+ data: crate::api::schema::EventData::PaneOutputChanged {
+ pane_id: "w1-1".into(),
+ workspace_id: "w1".into(),
+ revision: 1,
+ },
+ });
+
+ assert!(
+ app.state.plugin_command_logs.is_empty(),
+ "pane.output_changed hooks should be warning-only until hook semantics exist"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn unlink_removes_plugin_pane_records_for_that_plugin() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-unlink-panes");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+
+ // Manually insert plugin_pane records as if pane.open was called.
+ let pane_a = crate::layout::PaneId::from_raw(1001u32);
+ let pane_b = crate::layout::PaneId::from_raw(1002u32);
+ let pane_other = crate::layout::PaneId::from_raw(1003u32);
+ app.state.plugin_panes.insert(
+ pane_a,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.worktree-bootstrap".into(),
+ entrypoint: "main".into(),
+ },
+ );
+ app.state.plugin_panes.insert(
+ pane_b,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.worktree-bootstrap".into(),
+ entrypoint: "side".into(),
+ },
+ );
+ app.state.plugin_panes.insert(
+ pane_other,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "other.plugin".into(),
+ entrypoint: "other".into(),
+ },
+ );
+
+ let unlink = app.handle_api_request(Request {
+ id: "unlink-panes".into(),
+ method: Method::PluginUnlink(PluginUnlinkParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ });
+ assert!(matches!(
+ response_result(&unlink),
+ ResponseResult::PluginUnlinked { removed: true, .. }
+ ));
+
+ // plugin_panes records for the unlinked plugin are gone
+ assert!(!app.state.plugin_panes.contains_key(&pane_a));
+ assert!(!app.state.plugin_panes.contains_key(&pane_b));
+ // other plugin's pane record survives
+ assert!(app.state.plugin_panes.contains_key(&pane_other));
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_pane_record_survives_pane_move() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-move")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let public_pane_id = app.public_pane_id(0, pane_id).unwrap();
+ app.state.plugin_panes.insert(
+ pane_id,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ },
+ );
+
+ let response = app.handle_api_request(Request {
+ id: "move".into(),
+ method: Method::PaneMove(crate::api::schema::PaneMoveParams {
+ pane_id: public_pane_id,
+ destination: crate::api::schema::PaneMoveDestination::NewTab {
+ workspace_id: None,
+ label: Some("moved".into()),
+ },
+ focus: true,
+ }),
+ });
+ let ResponseResult::PaneMove { move_result } = response_result(&response) else {
+ panic!("expected pane move: {response}");
+ };
+ assert!(app.state.plugin_panes.contains_key(&pane_id));
+
+ let focus = app.handle_api_request(Request {
+ id: "focus".into(),
+ method: Method::PluginPaneFocus(PluginPaneFocusParams {
+ pane_id: move_result.pane.pane_id.clone(),
+ }),
+ });
+ let ResponseResult::PluginPaneFocused { plugin_pane } = response_result(&focus) else {
+ panic!("expected plugin pane focus: {focus}");
+ };
+ assert_eq!(plugin_pane.plugin_id, "example.pane");
+ assert_eq!(plugin_pane.entrypoint, "board");
+ assert_eq!(plugin_pane.pane.pane_id, move_result.pane.pane_id);
+ }
+
+ #[test]
+ fn pane_exit_removes_plugin_pane_record() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-exit")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ app.state.plugin_panes.insert(
+ pane_id,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ },
+ );
+
+ app.handle_internal_event(crate::events::AppEvent::PaneDied { pane_id });
+
+ assert!(!app.state.plugin_panes.contains_key(&pane_id));
+ }
+
+ #[test]
+ fn registry_round_trip_via_explicit_path() {
+ let root = unique_temp_path("plugin-registry-rt");
+ write_manifest(&root);
+
+ let registry_dir = unique_temp_path("registry-dir");
+ std::fs::create_dir_all(&registry_dir).unwrap();
+ let registry_path = registry_dir.join("plugins.json");
+
+ // link via load_plugin_manifest + save_to_path (simulating what the App does)
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ let plugins = vec![plugin.clone()];
+ crate::persist::plugin_registry::save_to_path(&registry_path, &plugins).unwrap();
+ assert!(registry_path.exists());
+
+ // load back and verify
+ let loaded = crate::persist::plugin_registry::load_from_path(&registry_path);
+ assert_eq!(loaded.len(), 1);
+ assert_eq!(loaded[0].plugin_id, "example.worktree-bootstrap");
+ assert_eq!(loaded[0].name, "Worktree Bootstrap");
+
+ // reload_manifests with real manifest still on disk → fresh parse succeeds
+ let reloaded =
+ crate::persist::plugin_registry::reload_manifests(loaded, |path, enabled| {
+ load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg)
+ });
+ assert_eq!(reloaded.len(), 1);
+ assert!(reloaded[0].warnings.is_empty());
+ assert_eq!(reloaded[0].version, "0.1.0");
+
+ let _ = std::fs::remove_dir_all(root);
+ let _ = std::fs::remove_dir_all(registry_dir);
+ }
+
+ #[test]
+ fn reload_manifests_keeps_entry_with_warning_when_manifest_gone() {
+ let root = unique_temp_path("plugin-missing-manifest");
+ write_manifest(&root);
+
+ // load_plugin_manifest resolves the absolute path via canonicalize()
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ let stored_manifest_path = plugin.manifest_path.clone();
+
+ // Now delete the manifest
+ let _ = std::fs::remove_dir_all(&root);
+
+ // Simulate registry load + reload
+ let entries = vec![plugin];
+ let reloaded =
+ crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| {
+ load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg)
+ });
+
+ assert_eq!(reloaded.len(), 1);
+ assert_eq!(reloaded[0].plugin_id, "example.worktree-bootstrap");
+ assert!(!reloaded[0].warnings.is_empty(), "expected load warning");
+ // The stored manifest_path is preserved so the entry is still identifiable
+ assert_eq!(reloaded[0].manifest_path, stored_manifest_path);
+ }
+
+ // ── Platform compatibility tests ─────────────────────────────────────────
+
+ #[test]
+ fn manifest_with_platforms_parses_correctly() {
+ let root = unique_temp_path("plugin-platforms");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.platforms"
+name = "Platforms"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["./run.sh"]
+
+[[actions]]
+id = "run-win"
+title = "Run Windows"
+platforms = ["windows"]
+command = ["run.bat"]
+"#,
+ )
+ .unwrap();
+
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ use crate::api::schema::PluginPlatform;
+ assert_eq!(
+ plugin.platforms,
+ Some(vec![PluginPlatform::Linux, PluginPlatform::Macos])
+ );
+ // Action without own platforms inherits from plugin level
+ let run = plugin.actions.iter().find(|a| a.id == "run").unwrap();
+ assert!(run.platforms.is_none());
+ // Action with own platforms has them set
+ let run_win = plugin.actions.iter().find(|a| a.id == "run-win").unwrap();
+ assert_eq!(run_win.platforms, Some(vec![PluginPlatform::Windows]));
+ // No missing-platforms warning because platforms is declared
+ assert!(
+ plugin.warnings.is_empty(),
+ "expected no warnings: {:?}",
+ plugin.warnings
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn effective_platform_resolution_inherits_from_plugin() {
+ use crate::api::schema::PluginPlatform;
+ let plugin_platforms = Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]);
+ let no_override: Option<Vec<PluginPlatform>> = None;
+ let action_override = Some(vec![PluginPlatform::Windows]);
+
+ // No action-level platforms → inherit from plugin
+ assert_eq!(
+ effective_platforms(&no_override, &plugin_platforms),
+ &plugin_platforms
+ );
+ // Action-level platforms → use action's own list
+ assert_eq!(
+ effective_platforms(&action_override, &plugin_platforms),
+ &action_override
+ );
+ // Both None → None (undeclared)
+ assert_eq!(effective_platforms(&no_override, &no_override), &None);
+ }
+
+ #[test]
+ fn invoke_on_unsupported_platform_returns_error() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-platform-reject");
+ std::fs::create_dir_all(&root).unwrap();
+
+ // Declare only platforms that are NOT the current build target so the
+ // invoke is guaranteed to be rejected regardless of which OS this runs on.
+ let excluded_platforms = if cfg!(target_os = "linux") {
+ r#"platforms = ["macos", "windows"]"#
+ } else if cfg!(target_os = "macos") {
+ r#"platforms = ["linux", "windows"]"#
+ } else {
+ r#"platforms = ["linux", "macos"]"#
+ };
+
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ format!(
+ r#"
+id = "example.reject"
+name = "Reject"
+version = "0.1.0"
+{excluded_platforms}
+
+[[actions]]
+id = "act"
+title = "Act"
+command = ["act"]
+"#
+ ),
+ )
+ .unwrap();
+
+ let link = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ assert!(link.contains("plugin_linked"), "link failed: {link}");
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.reject".into()),
+ action_id: "act".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(
+ value["error"]["code"], "platform_unsupported",
+ "expected platform_unsupported error: {invoke}"
+ );
+ assert!(
+ invoke.contains("example.reject.act"),
+ "error message should name the action: {invoke}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn invoke_with_action_platform_override_uses_action_platforms() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-platform-action-override");
+ std::fs::create_dir_all(&root).unwrap();
+
+ // Plugin declares all platforms; action declares only the non-current platforms.
+ let excluded_platforms = if cfg!(target_os = "linux") {
+ r#"platforms = ["macos", "windows"]"#
+ } else if cfg!(target_os = "macos") {
+ r#"platforms = ["linux", "windows"]"#
+ } else {
+ r#"platforms = ["linux", "macos"]"#
+ };
+
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ format!(
+ r#"
+id = "example.override"
+name = "Override"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "act"
+title = "Act"
+{excluded_platforms}
+command = ["act"]
+"#
+ ),
+ )
+ .unwrap();
+
+ let link = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ assert!(link.contains("plugin_linked"), "link failed: {link}");
+
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.override".into()),
+ action_id: "act".into(),
+ context: None,
+ }),
+ });
+ let value: serde_json::Value = serde_json::from_str(&invoke).unwrap();
+ assert_eq!(
+ value["error"]["code"], "platform_unsupported",
+ "expected platform_unsupported for action override: {invoke}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn invoke_with_undeclared_platforms_succeeds() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-platform-undeclared");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.nodecl"
+name = "No Decl"
+version = "0.1.0"
+
+[[actions]]
+id = "act"
+title = "Act"
+command = ["act"]
+"#,
+ )
+ .unwrap();
+
+ let link = app.handle_api_request(Request {
+ id: "link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: root.display().to_string(),
+ enabled: true,
+ source: None,
+ }),
+ });
+ let ResponseResult::PluginLinked { plugin } = response_result(&link) else {
+ panic!("expected plugin_linked: {link}");
+ };
+ // Should get the missing-platforms warning
+ assert!(
+ plugin
+ .warnings
+ .iter()
+ .any(|w| w.contains("does not declare platforms")),
+ "expected missing-platforms warning: {:?}",
+ plugin.warnings
+ );
+
+ // Invoke should succeed regardless (local dev allowance)
+ let invoke = app.handle_api_request(Request {
+ id: "invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.nodecl".into()),
+ action_id: "act".into(),
+ context: None,
+ }),
+ });
+ assert!(
+ invoke.contains("plugin_action_invoked"),
+ "expected success for undeclared platforms: {invoke}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn link_with_invalid_platform_string_fails() {
+ let root = unique_temp_path("plugin-bad-platform");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.badplatform"
+name = "Bad Platform"
+version = "0.1.0"
+platforms = ["linux", "beos"]
+
+[[actions]]
+id = "act"
+title = "Act"
+command = ["act"]
+"#,
+ )
+ .unwrap();
+
+ let result = load_plugin_manifest(&root.display().to_string(), true);
+ assert!(result.is_err(), "expected parse error for unknown platform");
+ let (_, msg) = result.unwrap_err();
+ assert!(
+ msg.contains("beos") || msg.contains("platform"),
+ "error message should mention the bad platform: {msg}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn registry_round_trip_preserves_platforms() {
+ let root = unique_temp_path("plugin-platform-rt");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(
+ root.join("herdr-plugin.toml"),
+ r#"
+id = "example.platform-rt"
+name = "Platform RT"
+version = "0.1.0"
+platforms = ["linux", "macos"]
+
+[[actions]]
+id = "act"
+title = "Act"
+platforms = ["windows"]
+command = ["act.exe"]
+"#,
+ )
+ .unwrap();
+
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+ use crate::api::schema::PluginPlatform;
+ assert_eq!(
+ plugin.platforms,
+ Some(vec![PluginPlatform::Linux, PluginPlatform::Macos])
+ );
+ assert_eq!(
+ plugin.actions[0].platforms,
+ Some(vec![PluginPlatform::Windows])
+ );
+
+ let registry_dir = unique_temp_path("platform-rt-registry");
+ std::fs::create_dir_all(&registry_dir).unwrap();
+ let registry_path = registry_dir.join("plugins.json");
+ crate::persist::plugin_registry::save_to_path(&registry_path, &[plugin]).unwrap();
+
+ let loaded = crate::persist::plugin_registry::load_from_path(&registry_path);
+ assert_eq!(
+ loaded[0].platforms,
+ Some(vec![PluginPlatform::Linux, PluginPlatform::Macos])
+ );
+ assert_eq!(
+ loaded[0].actions[0].platforms,
+ Some(vec![PluginPlatform::Windows])
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+ let _ = std::fs::remove_dir_all(registry_dir);
+ }
+}
diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs
new file mode 100644
index 00000000..22173cbf
--- /dev/null
+++ b/src/app/api/plugins/panes.rs
@@ -0,0 +1,284 @@
+use ratatui::layout::Direction;
+
+use super::super::responses::{encode_error, encode_success};
+use crate::api::schema::{
+ InstalledPluginInfo, PluginInvocationContext, PluginManifestPane, PluginPaneInfo,
+ PluginPaneOpenParams, PluginPanePlacement, ResponseResult,
+};
+use crate::app::App;
+
+impl App {
+ pub(super) fn open_plugin_overlay_pane(
+ &mut self,
+ id: String,
+ params: PluginPaneOpenParams,
+ plugin: &InstalledPluginInfo,
+ pane: PluginManifestPane,
+ ) -> String {
+ let context = self.current_plugin_context("plugin-pane");
+ let extra_env =
+ match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd));
+ let (ws_idx, new_pane) =
+ match self.spawn_overlay_argv_command(&pane.command, cwd, extra_env, Vec::new()) {
+ Ok(result) => result,
+ Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()),
+ };
+ self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane)
+ }
+
+ pub(super) fn open_plugin_split_pane(
+ &mut self,
+ id: String,
+ params: PluginPaneOpenParams,
+ plugin: &InstalledPluginInfo,
+ pane: PluginManifestPane,
+ placement: PluginPanePlacement,
+ ) -> String {
+ let target_pane_id = params
+ .target_pane_id
+ .clone()
+ .or_else(|| self.current_public_pane_id());
+ let Some(target_pane_id) = target_pane_id else {
+ return encode_error(id, "no_active_pane", "no active pane");
+ };
+ let Some((ws_idx, target_pane)) = self.parse_pane_id(&target_pane_id) else {
+ return encode_error(
+ id,
+ "pane_not_found",
+ format!("pane {target_pane_id} not found"),
+ );
+ };
+ let context = self.plugin_context_for_pane(ws_idx, target_pane, "plugin-pane");
+ let extra_env =
+ match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ let direction = match params
+ .direction
+ .unwrap_or(crate::api::schema::SplitDirection::Right)
+ {
+ crate::api::schema::SplitDirection::Right => Direction::Horizontal,
+ crate::api::schema::SplitDirection::Down => Direction::Vertical,
+ };
+ let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd));
+ let (rows, cols) = self.state.estimate_pane_size();
+ let previous_focus = self.state.current_pane_focus_target();
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return encode_error(id, "workspace_not_found", "workspace not found");
+ };
+ let result = ws.split_pane_argv_command(
+ target_pane,
+ direction,
+ rows.max(4),
+ cols.max(10),
+ cwd,
+ &pane.command,
+ extra_env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ params.focus || placement == PluginPanePlacement::Zoomed,
+ );
+ let (tab_idx, new_pane) = match result {
+ Some(Ok(result)) => result,
+ Some(Err(err)) => return encode_error(id, "plugin_pane_open_failed", err.to_string()),
+ None => {
+ return encode_error(
+ id,
+ "pane_not_found",
+ format!("pane {target_pane_id} not found"),
+ )
+ }
+ };
+ if params.focus || placement == PluginPanePlacement::Zoomed {
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state
+ .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id);
+ self.state.mode = crate::app::Mode::Terminal;
+ }
+ if placement == PluginPanePlacement::Zoomed {
+ if let Some(tab) = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ tab.zoomed = true;
+ }
+ }
+ self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane)
+ }
+
+ pub(super) fn open_plugin_tab(
+ &mut self,
+ id: String,
+ params: PluginPaneOpenParams,
+ plugin: &InstalledPluginInfo,
+ pane: PluginManifestPane,
+ ) -> String {
+ let ws_idx = match params.workspace_id.as_deref() {
+ Some(workspace_id) => match self.parse_workspace_id(workspace_id) {
+ Some(ws_idx) => ws_idx,
+ None => return encode_error(id, "workspace_not_found", "workspace not found"),
+ },
+ None => match self.state.active {
+ Some(ws_idx) => ws_idx,
+ None => return encode_error(id, "no_active_workspace", "no active workspace"),
+ },
+ };
+ let cwd = self.plugin_pane_cwd(plugin, params.cwd);
+ let context = self.plugin_context_for_workspace(ws_idx, "plugin-pane");
+ let extra_env =
+ match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ let (rows, cols) = self.state.estimate_pane_size();
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return encode_error(id, "workspace_not_found", "workspace not found");
+ };
+ let (tab_idx, terminal, runtime) = match ws.create_tab_argv_command(
+ rows.max(4),
+ cols.max(10),
+ cwd,
+ &pane.command,
+ extra_env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ ) {
+ Ok(result) => result,
+ Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()),
+ };
+ let pane_id = ws.tabs[tab_idx].root_pane;
+ if params.focus {
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state.mode = crate::app::Mode::Terminal;
+ }
+ let new_pane = crate::workspace::NewPane {
+ pane_id,
+ terminal,
+ runtime,
+ };
+ self.finish_plugin_pane_open(
+ id,
+ ws_idx,
+ Some(tab_idx),
+ new_pane,
+ plugin.plugin_id.clone(),
+ pane,
+ )
+ }
+
+ fn plugin_pane_launch_env(
+ &self,
+ plugin: &InstalledPluginInfo,
+ entrypoint: &str,
+ env: std::collections::HashMap<String, String>,
+ context: &PluginInvocationContext,
+ ) -> Result<Vec<(String, String)>, (String, String)> {
+ let mut env = super::super::env::normalize_launch_env(env)?;
+ let context_json = serde_json::to_string(&context)
+ .map_err(|err| ("invalid_plugin_context".to_string(), err.to_string()))?;
+ env.retain(|(key, _)| !plugin_pane_protected_env_key(key));
+ env.extend(super::env::plugin_path_env(plugin));
+ env.push(("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()));
+ env.push((
+ "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(),
+ entrypoint.to_string(),
+ ));
+ env.push(("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json));
+ if let Ok(current_exe) = std::env::current_exe() {
+ env.push((
+ "HERDR_BIN_PATH".to_string(),
+ current_exe.display().to_string(),
+ ));
+ }
+ Ok(env)
+ }
+
+ fn finish_plugin_pane_open(
+ &mut self,
+ id: String,
+ ws_idx: usize,
+ created_tab_idx: Option<usize>,
+ new_pane: crate::workspace::NewPane,
+ plugin_id: String,
+ pane_manifest: PluginManifestPane,
+ ) -> String {
+ let entrypoint = pane_manifest.id.clone();
+ let mut terminal = new_pane.terminal;
+ terminal.set_manual_label(pane_manifest.title.clone());
+ let terminal_id = terminal.id.clone();
+ self.terminal_runtimes
+ .insert(terminal_id.clone(), new_pane.runtime);
+ self.state
+ .remove_alias_shadowed_by_new_pane(new_pane.pane_id);
+ self.state.terminals.insert(terminal_id, terminal);
+ self.state.plugin_panes.insert(
+ new_pane.pane_id,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: plugin_id.clone(),
+ entrypoint: entrypoint.clone(),
+ },
+ );
+ if let Some(tab_idx) = created_tab_idx {
+ if let Some(tab) = self.tab_info(ws_idx, tab_idx) {
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::TabCreated,
+ data: crate::api::schema::EventData::TabCreated { tab },
+ });
+ }
+ }
+ self.schedule_session_save();
+ let Some(pane) = self.pane_info(ws_idx, new_pane.pane_id) else {
+ return encode_error(id, "plugin_pane_open_failed", "plugin pane disappeared");
+ };
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneCreated,
+ data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() },
+ });
+ encode_success(
+ id,
+ ResponseResult::PluginPaneOpened {
+ plugin_pane: PluginPaneInfo {
+ plugin_id,
+ entrypoint,
+ pane,
+ },
+ },
+ )
+ }
+
+ fn plugin_pane_cwd(
+ &self,
+ plugin: &InstalledPluginInfo,
+ override_cwd: Option<String>,
+ ) -> std::path::PathBuf {
+ override_cwd
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::PathBuf::from(&plugin.plugin_root))
+ }
+
+ fn current_public_pane_id(&self) -> Option<String> {
+ let ws_idx = self.state.active?;
+ let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?;
+ self.public_pane_id(ws_idx, pane_id)
+ }
+}
+
+fn plugin_pane_protected_env_key(key: &str) -> bool {
+ matches!(
+ key,
+ "HERDR_PLUGIN_ID"
+ | "HERDR_PLUGIN_ROOT"
+ | "HERDR_PLUGIN_CONFIG_DIR"
+ | "HERDR_PLUGIN_STATE_DIR"
+ | "HERDR_PLUGIN_ENTRYPOINT_ID"
+ | "HERDR_PLUGIN_CONTEXT_JSON"
+ | "HERDR_BIN_PATH"
+ )
+}
diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs
new file mode 100644
index 00000000..c6955bb4
--- /dev/null
+++ b/src/app/api/plugins/runtime.rs
@@ -0,0 +1,271 @@
+use std::io::Read;
+use std::process::{Command, Stdio};
+
+use super::manifest::{effective_platforms, ensure_platform_supported};
+use super::plugin_manifest_available;
+use crate::api::schema::{
+ InstalledPluginInfo, PluginCommandLogInfo, PluginCommandStatus, PluginInvocationContext,
+};
+use crate::app::App;
+
+const PLUGIN_COMMAND_OUTPUT_MAX_BYTES: usize = 64 * 1024;
+pub(super) const MAX_PLUGIN_COMMANDS_IN_FLIGHT: usize = 32;
+const PLUGIN_COMMAND_LOG_LIMIT: usize = 200;
+
+impl App {
+ pub(super) fn start_plugin_command(
+ &mut self,
+ plugin: &InstalledPluginInfo,
+ action_id: Option<String>,
+ event: Option<String>,
+ command: Vec<String>,
+ context: &PluginInvocationContext,
+ event_json: Option<String>,
+ ) -> Result<PluginCommandLogInfo, (&'static str, String)> {
+ let Some(program) = command.first().cloned() else {
+ return Err((
+ "invalid_plugin_command",
+ "command must not be empty".to_string(),
+ ));
+ };
+ let args = command.iter().skip(1).cloned().collect::<Vec<_>>();
+ let context_json = serde_json::to_string(context)
+ .map_err(|err| ("invalid_plugin_context", err.to_string()))?;
+ let log_id = format!("plugin-log-{}", self.state.next_plugin_command_log_id);
+ self.state.next_plugin_command_log_id += 1;
+ let started_unix_ms = current_unix_ms();
+ let mut env = super::env::plugin_path_env(plugin);
+ env.extend([
+ (
+ crate::api::SOCKET_PATH_ENV_VAR.to_string(),
+ crate::api::socket_path().display().to_string(),
+ ),
+ ("HERDR_ENV".to_string(), "1".to_string()),
+ ("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()),
+ ("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json),
+ ]);
+ if let Ok(current_exe) = std::env::current_exe() {
+ env.push((
+ "HERDR_BIN_PATH".to_string(),
+ current_exe.display().to_string(),
+ ));
+ }
+ if let Some(action_id) = action_id.as_ref() {
+ env.push(("HERDR_PLUGIN_ACTION_ID".to_string(), action_id.clone()));
+ }
+ if let Some(event) = event.as_ref() {
+ env.push(("HERDR_PLUGIN_EVENT".to_string(), event.clone()));
+ }
+ if let Some(event_json) = event_json {
+ env.push(("HERDR_PLUGIN_EVENT_JSON".to_string(), event_json));
+ }
+ if let Some(workspace_id) = context.workspace_id.as_ref() {
+ env.push(("HERDR_WORKSPACE_ID".to_string(), workspace_id.clone()));
+ }
+ if let Some(tab_id) = context.tab_id.as_ref() {
+ env.push(("HERDR_TAB_ID".to_string(), tab_id.clone()));
+ }
+ if let Some(pane_id) = context.focused_pane_id.as_ref() {
+ env.push(("HERDR_PANE_ID".to_string(), pane_id.clone()));
+ }
+ if let Some(clicked_url) = context.clicked_url.as_ref() {
+ env.push(("HERDR_PLUGIN_CLICKED_URL".to_string(), clicked_url.clone()));
+ }
+ if let Some(link_handler_id) = context.link_handler_id.as_ref() {
+ env.push((
+ "HERDR_PLUGIN_LINK_HANDLER_ID".to_string(),
+ link_handler_id.clone(),
+ ));
+ }
+ if self.state.plugin_commands_in_flight >= MAX_PLUGIN_COMMANDS_IN_FLIGHT {
+ let message = format!(
+ "maximum concurrent plugin commands reached ({MAX_PLUGIN_COMMANDS_IN_FLIGHT})"
+ );
+ let log = PluginCommandLogInfo {
+ log_id,
+ plugin_id: plugin.plugin_id.clone(),
+ action_id,
+ event,
+ command,
+ status: PluginCommandStatus::Failed,
+ started_unix_ms,
+ finished_unix_ms: Some(started_unix_ms),
+ exit_code: None,
+ stdout: Some(String::new()),
+ stderr: Some(String::new()),
+ error: Some(message.clone()),
+ };
+ self.push_plugin_command_log(log);
+ return Err(("plugin_command_limit_reached", message));
+ }
+ let plugin_root = std::path::PathBuf::from(&plugin.plugin_root);
+ let log = PluginCommandLogInfo {
+ log_id: log_id.clone(),
+ plugin_id: plugin.plugin_id.clone(),
+ action_id,
+ event,
+ command: command.clone(),
+ status: PluginCommandStatus::Running,
+ started_unix_ms,
+ finished_unix_ms: None,
+ exit_code: None,
+ stdout: None,
+ stderr: None,
+ error: None,
+ };
+ self.push_plugin_command_log(log.clone());
+ self.state.plugin_commands_in_flight += 1;
+ let event_tx = self.event_tx.clone();
+ std::thread::spawn(move || {
+ let child = Command::new(&program)
+ .args(args)
+ .current_dir(plugin_root)
+ .envs(env)
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn();
+ let finished = match child {
+ Ok(mut child) => {
+ let stdout = child.stdout.take();
+ let stderr = child.stderr.take();
+ let stdout_reader = stdout.map(|stdout| {
+ std::thread::spawn(move || {
+ read_capped_plugin_output(stdout, PLUGIN_COMMAND_OUTPUT_MAX_BYTES)
+ })
+ });
+ let stderr_reader = stderr.map(|stderr| {
+ std::thread::spawn(move || {
+ read_capped_plugin_output(stderr, PLUGIN_COMMAND_OUTPUT_MAX_BYTES)
+ })
+ });
+ match child.wait() {
+ Ok(status) => crate::events::AppEvent::PluginCommandFinished {
+ log_id,
+ finished_unix_ms: current_unix_ms(),
+ exit_code: status.code(),
+ stdout: stdout_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default(),
+ stderr: stderr_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default(),
+ error: None,
+ },
+ Err(err) => crate::events::AppEvent::PluginCommandFinished {
+ log_id,
+ finished_unix_ms: current_unix_ms(),
+ exit_code: None,
+ stdout: stdout_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default(),
+ stderr: stderr_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default(),
+ error: Some(err.to_string()),
+ },
+ }
+ }
+ Err(err) => crate::events::AppEvent::PluginCommandFinished {
+ log_id,
+ finished_unix_ms: current_unix_ms(),
+ exit_code: None,
+ stdout: String::new(),
+ stderr: String::new(),
+ error: Some(err.to_string()),
+ },
+ };
+ let _ = event_tx.blocking_send(finished);
+ });
+ Ok(log)
+ }
+
+ pub(crate) fn run_plugin_event_hooks(&mut self, event: &crate::api::schema::EventEnvelope) {
+ let event_name = event.event.dot_name();
+ if !crate::api::schema::PLUGIN_HOOK_EVENT_KINDS.contains(&event.event) {
+ return;
+ }
+ let plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .filter(|plugin| {
+ plugin.enabled
+ && plugin_manifest_available(plugin)
+ && plugin.events.iter().any(|hook| hook.on == event_name)
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ if plugins.is_empty() {
+ return;
+ }
+ let event_json = serde_json::to_string(event).ok();
+ let context = self.plugin_context_for_event(event, event_name);
+ for plugin in plugins {
+ for hook in plugin.events.clone() {
+ if hook.on != event_name {
+ continue;
+ }
+ if ensure_platform_supported(
+ &effective_platforms(&hook.platforms, &plugin.platforms).clone(),
+ event_name,
+ )
+ .is_err()
+ {
+ continue;
+ }
+ let _ = self.start_plugin_command(
+ &plugin,
+ None,
+ Some(event_name.to_string()),
+ hook.command.clone(),
+ &context,
+ event_json.clone(),
+ );
+ }
+ }
+ }
+
+ fn push_plugin_command_log(&mut self, log: PluginCommandLogInfo) {
+ self.state.plugin_command_logs.push(log);
+ if self.state.plugin_command_logs.len() > PLUGIN_COMMAND_LOG_LIMIT {
+ let extra = self.state.plugin_command_logs.len() - PLUGIN_COMMAND_LOG_LIMIT;
+ self.state.plugin_command_logs.drain(0..extra);
+ }
+ }
+}
+
+fn current_unix_ms() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
+ .unwrap_or(0)
+}
+
+pub(super) fn read_capped_plugin_output(mut reader: impl Read, cap: usize) -> String {
+ let mut kept = Vec::with_capacity(cap.min(8192));
+ let mut buf = [0u8; 8192];
+ let mut truncated = false;
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ let remaining = cap.saturating_sub(kept.len());
+ if remaining > 0 {
+ kept.extend_from_slice(&buf[..n.min(remaining)]);
+ }
+ if n > remaining {
+ truncated = true;
+ }
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(_) => break,
+ }
+ }
+ let mut output = String::from_utf8_lossy(&kept).into_owned();
+ if truncated {
+ output.push_str(&format!(
+ "\n[herdr truncated plugin output after {cap} bytes]"
+ ));
+ }
+ output
+}
diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs
index 7c7ad9d7..c88a00d1 100644
--- a/src/app/api/tabs.rs
+++ b/src/app/api/tabs.rs
@@ -52,6 +52,7 @@ impl App {
cwd,
focus,
label,
+ env,
} = params;
let ws_idx = if let Some(workspace_id) = workspace_id {
let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
@@ -74,6 +75,10 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
+ let extra_env = match super::env::normalize_launch_env(env) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
let result = self
.state
.workspaces
@@ -87,6 +92,7 @@ impl App {
scrollback_limit_bytes,
host_terminal_theme,
crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
+ extra_env,
)
});
match result {
@@ -195,6 +201,13 @@ impl App {
};
let workspace_id = self.public_workspace_id(ws_idx);
let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx);
+ let pane_ids = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.get(tab_idx))
+ .map(|tab| tab.layout.pane_ids())
+ .unwrap_or_default();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return tab_not_found(id, &target.tab_id);
};
@@ -212,6 +225,9 @@ impl App {
format!("tab {} could not be closed", target.tab_id),
);
}
+ for pane_id in pane_ids {
+ self.state.plugin_panes.remove(&pane_id);
+ }
self.state.remove_unattached_terminal_ids(terminal_ids);
self.shutdown_detached_terminal_runtimes();
self.schedule_session_save();
diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs
index e1966adf..cdfd4ec5 100644
--- a/src/app/api/workspaces.rs
+++ b/src/app/api/workspaces.rs
@@ -51,7 +51,11 @@ impl App {
.and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx));
self.resolve_new_terminal_cwd(follow_cwd)
});
- match self.create_workspace_with_options(cwd, params.focus) {
+ let extra_env = match super::env::normalize_launch_env(params.env) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ match self.create_workspace_with_launch_env(cwd, params.focus, extra_env) {
Ok(index) => {
if let Some(label) = params.label {
if let Some(workspace) = self.state.workspaces.get_mut(index) {
@@ -147,12 +151,30 @@ impl App {
return workspace_not_found(id, &target.workspace_id);
}
let workspace_id = self.public_workspace_id(index);
+ let workspace = self.workspace_info(index);
+ let pane_ids = self
+ .state
+ .workspaces
+ .get(index)
+ .map(|ws| {
+ ws.tabs
+ .iter()
+ .flat_map(|tab| tab.layout.pane_ids())
+ .collect::<Vec<_>>()
+ })
+ .unwrap_or_default();
self.state.selected = index;
self.state.close_selected_workspace();
+ for pane_id in pane_ids {
+ self.state.plugin_panes.remove(&pane_id);
+ }
self.shutdown_detached_terminal_runtimes();
self.emit_event(EventEnvelope {
event: EventKind::WorkspaceClosed,
- data: EventData::WorkspaceClosed { workspace_id },
+ data: EventData::WorkspaceClosed {
+ workspace_id,
+ workspace: Some(workspace),
+ },
});
encode_success(id, ResponseResult::Ok {})
@@ -208,4 +230,37 @@ mod tests {
assert_eq!(app.state.request_remove_linked_worktree, None);
assert!(app.state.workspaces.is_empty());
}
+
+ #[test]
+ fn api_workspace_close_event_includes_final_worktree_snapshot() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
+ app.state.workspaces = app_with_linked_worktree().state.workspaces;
+ let workspace_id = app.state.workspaces[0].id.clone();
+
+ let response = app.handle_workspace_close(
+ "req".into(),
+ WorkspaceTarget {
+ workspace_id: workspace_id.clone(),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.id, "req");
+ let events = event_hub.events_after(0);
+ assert!(events.iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorkspaceClosed {
+ workspace_id: closed_id,
+ workspace: Some(workspace),
+ } if closed_id == &workspace_id
+ && workspace
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| worktree.is_linked_worktree)
+ )
+ }));
+ }
}
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs
index 573771e2..648b571a 100644
--- a/src/app/api/worktrees.rs
+++ b/src/app/api/worktrees.rs
@@ -148,6 +148,13 @@ impl App {
let worktree = self
.worktree_info_for_checkout(&source, ws_idx)
.expect("created worktree workspace should have worktree info");
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorktreeCreated,
+ data: EventData::WorktreeCreated {
+ workspace: self.workspace_info(ws_idx),
+ worktree: worktree.clone(),
+ },
+ });
encode_success(
id,
ResponseResult::WorktreeCreated {
@@ -244,6 +251,15 @@ impl App {
}
let tab_idx = self.state.workspaces[ws_idx].active_tab;
+ let worktree = self.worktree_info_for_entry(&source, entry);
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorktreeOpened,
+ data: EventData::WorktreeOpened {
+ workspace: self.workspace_info(ws_idx),
+ worktree: worktree.clone(),
+ already_open: already_open.is_some(),
+ },
+ });
encode_success(
id,
ResponseResult::WorktreeOpened {
@@ -254,7 +270,7 @@ impl App {
root_pane: self
.root_pane_info(ws_idx, tab_idx)
.expect("opened worktree workspace should have an active root pane"),
- worktree: self.worktree_info_for_entry(&source, entry),
+ worktree,
already_open: already_open.is_some(),
},
)
@@ -313,6 +329,8 @@ impl App {
&space.checkout_path,
params.force,
);
+ let workspace_snapshot = self.workspace_info(ws_idx);
+ let worktree = self.worktree_info_for_membership(&space, None);
if let Err(err) = crate::worktree::run_worktree_command(&command) {
let code = if !params.force && crate::worktree::is_dirty_worktree_remove_error(&err) {
"dirty_worktree_requires_force"
@@ -337,9 +355,18 @@ impl App {
event: EventKind::WorkspaceClosed,
data: EventData::WorkspaceClosed {
workspace_id: workspace_id.clone(),
+ workspace: Some(workspace_snapshot),
},
});
}
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorktreeRemoved,
+ data: EventData::WorktreeRemoved {
+ workspace_id: workspace_id.clone(),
+ worktree,
+ forced: params.force,
+ },
+ });
encode_success(
id,
@@ -747,6 +774,25 @@ impl App {
})
}
+ fn worktree_info_for_membership(
+ &self,
+ membership: &crate::workspace::WorktreeSpaceMembership,
+ open_workspace_id: Option<String>,
+ ) -> WorktreeInfo {
+ let branch = crate::workspace::git_branch(&membership.checkout_path);
+ let is_detached = branch.is_none();
+ WorktreeInfo {
+ path: membership.checkout_path.display().to_string(),
+ branch,
+ is_bare: false,
+ is_detached,
+ is_prunable: false,
+ is_linked_worktree: membership.is_linked_worktree,
+ open_workspace_id,
+ label: membership.label.clone(),
+ }
+ }
+
fn open_workspace_idx_for_checkout(&self, checkout_path: &Path) -> Option<usize> {
let canonical_checkout = crate::worktree::canonical_or_original(checkout_path);
let checkout_key = canonical_checkout.display().to_string();
@@ -777,7 +823,7 @@ impl App {
})
}
- fn emit_workspace_open_events(&self, ws_idx: usize) {
+ fn emit_workspace_open_events(&mut self, ws_idx: usize) {
let workspace_info = self.workspace_info(ws_idx);
let Some(tab) = self.tab_info(ws_idx, 0) else {
return;
@@ -801,7 +847,7 @@ impl App {
});
}
- fn emit_workspace_updated(&self, ws_idx: usize) {
+ fn emit_workspace_updated(&mut self, ws_idx: usize) {
self.emit_event(EventEnvelope {
event: EventKind::WorkspaceUpdated,
data: EventData::WorkspaceUpdated {
@@ -940,7 +986,14 @@ mod tests {
async fn api_worktree_create_opens_workspace_and_marks_membership() {
let repo = create_committed_repo("api-worktree-create-repo");
let worktree_root = unique_temp_path("api-worktree-create-root");
- let mut app = app_with_parent(&repo);
+ let event_hub = crate::api::EventHub::default();
+ let mut app = test_app_with_event_hub(event_hub.clone());
+ let mut parent = Workspace::test_new("main");
+ parent.identity_cwd = repo.clone();
+ app.state.workspaces = vec![parent];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
app.state.worktree_directory = worktree_root.clone();
let response = app.handle_api_request(Request {
@@ -980,6 +1033,17 @@ mod tests {
.is_linked_worktree
);
assert!(workspace.worktree.unwrap().is_linked_worktree);
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorktreeCreated {
+ workspace: event_workspace,
+ worktree: event_worktree,
+ } if event_workspace.workspace_id == workspace.workspace_id
+ && event_worktree.branch.as_deref() == Some("worktree/api-create")
+ && event_worktree.is_linked_worktree
+ )
+ }));
for (_, runtime) in app.terminal_runtimes.drain() {
runtime.shutdown();
@@ -1140,7 +1204,11 @@ mod tests {
let subdir = checkout.join("nested");
std::fs::create_dir_all(&subdir).unwrap();
- let mut app = app_with_parent(&repo);
+ let event_hub = crate::api::EventHub::default();
+ let mut app = test_app_with_event_hub(event_hub.clone());
+ let mut parent = Workspace::test_new("main");
+ parent.identity_cwd = repo.clone();
+ app.state.workspaces = vec![parent];
let mut child = Workspace::test_new("child");
child.identity_cwd = subdir;
app.state.workspaces.push(child);
@@ -1175,6 +1243,19 @@ mod tests {
.unwrap()
.is_linked_worktree
);
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorktreeOpened {
+ workspace: event_workspace,
+ worktree: event_worktree,
+ already_open,
+ } if event_workspace.workspace_id == workspace.workspace_id
+ && event_worktree.branch.as_deref() == Some("worktree/api-open")
+ && event_worktree.is_linked_worktree
+ && *already_open
+ )
+ }));
let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
crate::worktree::run_worktree_command(&remove).unwrap();
@@ -1578,7 +1659,28 @@ mod tests {
assert!(event_hub.events_after(0).iter().any(|(_, event)| {
matches!(
&event.data,
- EventData::WorkspaceClosed { workspace_id } if workspace_id == &child_id
+ EventData::WorkspaceClosed {
+ workspace_id,
+ workspace: Some(workspace),
+ } if workspace_id == &child_id
+ && workspace
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| worktree.is_linked_worktree)
+ )
+ }));
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorktreeRemoved {
+ workspace_id,
+ worktree,
+ forced,
+ } if workspace_id == &child_id
+ && worktree.branch.as_deref() == Some("worktree/api-remove-event")
+ && worktree.is_linked_worktree
+ && worktree.open_workspace_id.is_none()
+ && !forced
)
}));
diff --git a/src/app/creation.rs b/src/app/creation.rs
index 7be324e5..a45dae03 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -112,6 +112,7 @@ impl App {
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
+ Vec::new(),
)?;
let root_pane = ws.tabs[idx].root_pane;
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
@@ -140,8 +141,17 @@ impl App {
initial_cwd: PathBuf,
focus: bool,
) -> std::io::Result<usize> {
+ self.create_workspace_with_launch_env(initial_cwd, focus, Vec::new())
+ }
+
+ pub(crate) fn create_workspace_with_launch_env(
+ &mut self,
+ initial_cwd: PathBuf,
+ focus: bool,
+ extra_env: Vec<(String, String)>,
+ ) -> std::io::Result<usize> {
let (rows, cols) = self.state.estimate_pane_size();
- let (ws, terminal, runtime) = Workspace::new(
+ let (ws, terminal, runtime) = Workspace::new_with_extra_env(
initial_cwd,
rows,
cols,
@@ -151,6 +161,7 @@ impl App {
self.event_tx.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
+ extra_env,
)?;
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
self.state.terminals.insert(terminal.id.clone(), terminal);
diff --git a/src/app/ids.rs b/src/app/ids.rs
index 5e20c388..42cf47a8 100644
--- a/src/app/ids.rs
+++ b/src/app/ids.rs
@@ -40,6 +40,26 @@ impl App {
))
}
+ pub(super) fn pane_launch_env(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ extra_env: Vec<(String, String)>,
+ ) -> Option<crate::pane::PaneLaunchEnv> {
+ let workspace_id = self.public_workspace_id(ws_idx);
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
+ let tab_id = self.public_tab_id(ws_idx, tab_idx)?;
+ let pane_id = self.public_pane_id(ws_idx, pane_id)?;
+ Some(
+ crate::pane::PaneLaunchEnv::from_extra(extra_env).with_identity(
+ workspace_id,
+ tab_id,
+ pane_id,
+ ),
+ )
+ }
+
pub(super) fn parse_workspace_id(&self, id: &str) -> Option<usize> {
self.state
.workspaces
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 1b5dfefe..339f7293 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -341,6 +341,13 @@ impl App {
};
self.last_pane_click = None;
+ match self.invoke_plugin_link_handler_for_url(&url, info.id) {
+ Ok(true) => return true,
+ Ok(false) => {}
+ Err(err) => {
+ tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler");
+ }
+ }
if let Err(err) = crate::platform::open_url(&url) {
tracing::warn!(err = %err, url = %url, "failed to open pane URL");
}
@@ -514,6 +521,7 @@ impl AppState {
self.pane_scrollback_limit_bytes,
self.host_terminal_theme,
crate::pane::PaneShellConfig::new(&self.default_shell, self.shell_mode),
+ Vec::new(),
) {
let new_id = new_pane.pane_id;
terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime);
@@ -622,7 +630,9 @@ fn wait_for_file(path: &std::path::Path) -> String {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline {
if let Ok(content) = std::fs::read_to_string(path) {
- return content;
+ if !content.is_empty() {
+ return content;
+ }
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index b27b5cb9..7072f659 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -140,6 +140,9 @@ impl App {
crate::config::CustomCommandAction::Pane => {
self.spawn_pane_command(&binding.command, Vec::new())
}
+ crate::config::CustomCommandAction::PluginAction => self
+ .invoke_plugin_action_from_keybind(binding.command.clone())
+ .map_err(std::io::Error::other),
};
match result {
Ok(()) => finish_custom_command_context(&mut self.state, context, previous_mode),
@@ -317,7 +320,7 @@ impl App {
new_cols,
cwd,
command,
- &env,
+ env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
)?;
@@ -355,6 +358,92 @@ impl App {
self.state.mode = Mode::Terminal;
Ok(())
}
+
+ pub(crate) fn spawn_overlay_argv_command(
+ &mut self,
+ argv: &[String],
+ cwd: Option<std::path::PathBuf>,
+ extra_env: Vec<(String, String)>,
+ temp_files: Vec<std::path::PathBuf>,
+ ) -> std::io::Result<(usize, crate::workspace::NewPane)> {
+ let Some(ws_idx) = self.state.active else {
+ return Err(std::io::Error::other("no active workspace"));
+ };
+ let previous_focus_target = self.state.current_pane_focus_target();
+ let (rows, cols) = self.state.estimate_pane_size();
+ let new_rows = rows.max(4);
+ let new_cols = cols.max(10);
+
+ let ws = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
+ let previous_focus = ws
+ .focused_pane_id()
+ .ok_or_else(|| std::io::Error::other("no focused pane"))?;
+ let cwd = cwd.or_else(|| {
+ ws.active_tab().and_then(|tab| {
+ tab.cwd_for_pane(
+ previous_focus,
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ })
+ });
+
+ let (tab_idx, new_pane, workspace_id) = {
+ let ws = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
+ let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
+ let result = ws.split_pane_argv_command(
+ previous_focus,
+ Direction::Horizontal,
+ new_rows,
+ new_cols,
+ cwd,
+ argv,
+ extra_env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ true,
+ );
+ let (tab_idx, new_pane) = match result {
+ Some(Ok(result)) => result,
+ Some(Err(err)) => return Err(err),
+ None => return Err(std::io::Error::other("focused pane disappeared")),
+ };
+ ws.tabs
+ .get_mut(tab_idx)
+ .ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))?
+ .zoomed = true;
+ self.overlay_panes.insert(
+ new_pane.pane_id,
+ super::super::OverlayPaneState {
+ ws_idx,
+ tab_idx,
+ previous_focus,
+ previous_zoomed,
+ temp_files,
+ },
+ );
+ (tab_idx, new_pane, ws.id.clone())
+ };
+
+ let new_focus_target = crate::app::state::PaneFocusTarget {
+ workspace_id,
+ pane_id: new_pane.pane_id,
+ };
+ if previous_focus_target.as_ref() != Some(&new_focus_target) {
+ self.state.previous_pane_focus = previous_focus_target;
+ }
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state.mode = Mode::Terminal;
+ Ok((ws_idx, new_pane))
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 5447a2de..356ecbab 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -56,6 +56,13 @@ use crate::events::AppEvent;
pub use state::{AppState, Mode, ToastKind, ViewState};
+pub(crate) fn load_plugin_manifest(
+ path: &str,
+ enabled: bool,
+) -> Result<crate::api::schema::InstalledPluginInfo, (&'static str, String)> {
+ api::plugins::load_plugin_manifest(path, enabled)
+}
+
/// Full application: AppState + runtime concerns (event channels, async I/O).
#[derive(Debug, Clone)]
pub(crate) struct OverlayPaneState {
@@ -132,7 +139,7 @@ pub(crate) const APP_EVENT_DRAIN_LIMIT: usize = 64;
pub(crate) enum LoopEvent {
Timer,
Internal(AppEvent),
- Api(crate::api::ApiRequestMessage),
+ Api(Box<crate::api::ApiRequestMessage>),
RawInput(crate::raw_input::RawInputEvent),
InputClosed,
RenderRequested,
@@ -183,6 +190,20 @@ fn auto_updates_enabled(no_session: bool) -> bool {
!no_session && !cfg!(debug_assertions)
}
+fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry {
+ if no_session {
+ return std::collections::HashMap::new();
+ }
+ let entries = crate::persist::plugin_registry::load();
+ let entries = crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| {
+ crate::app::api::plugins::load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg)
+ });
+ entries
+ .into_iter()
+ .map(|plugin| (plugin.plugin_id.clone(), plugin))
+ .collect()
+}
+
fn agent_panel_scope_from_config(
scope: crate::config::AgentPanelScopeConfig,
) -> state::AgentPanelScope {
@@ -542,6 +563,11 @@ impl App {
agent_manifest_summaries,
agent_manifest_update_status: crate::detect::manifest_update::load_status(),
integration_install_messages: Vec::new(),
+ installed_plugins: load_plugin_registry(no_session),
+ plugin_panes: std::collections::HashMap::new(),
+ plugin_command_logs: Vec::new(),
+ next_plugin_command_log_id: 1,
+ plugin_commands_in_flight: 0,
global_menu: state::MenuListState::new(0),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
session_dirty: false,
@@ -895,7 +921,7 @@ impl App {
let input_rx = self.input_rx.as_mut();
tokio::select! {
maybe_api = self.api_rx.recv() => match maybe_api {
- Some(msg) => LoopEvent::Api(msg),
+ Some(msg) => LoopEvent::Api(Box::new(msg)),
None => LoopEvent::Timer,
},
maybe_ev = self.event_rx.recv() => match maybe_ev {
@@ -918,7 +944,7 @@ impl App {
needs_render = true;
}
LoopEvent::Api(msg) => {
- if self.handle_api_request_message(msg) {
+ if self.handle_api_request_message(*msg) {
needs_render = true;
}
}
@@ -3202,6 +3228,7 @@ mod tests {
ratio: None,
cwd: None,
focus: false,
+ env: Default::default(),
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
@@ -3281,6 +3308,7 @@ mod tests {
ratio: None,
cwd: None,
focus: true,
+ env: Default::default(),
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
@@ -3326,6 +3354,7 @@ mod tests {
ratio: Some(0.333),
cwd: None,
focus: false,
+ env: Default::default(),
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
@@ -3371,6 +3400,7 @@ mod tests {
ratio: None,
cwd: None,
focus: false,
+ env: Default::default(),
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
@@ -3412,6 +3442,7 @@ mod tests {
split: Some(crate::api::schema::SplitDirection::Right),
focus: true,
argv: vec![exiting_test_command().into()],
+ env: Default::default(),
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
diff --git a/src/app/state.rs b/src/app/state.rs
index ae4295bd..ddf5c39c 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -7,6 +7,15 @@ use crate::detect::AgentState;
use crate::layout::{PaneId, PaneInfo, SplitBorder};
use crate::selection::Selection;
+pub(crate) type InstalledPluginRegistry =
+ std::collections::HashMap<String, crate::api::schema::InstalledPluginInfo>;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct PluginPaneRecord {
+ pub plugin_id: String,
+ pub entrypoint: String,
+}
+
// ---------------------------------------------------------------------------
// Selection autoscroll types
// ---------------------------------------------------------------------------
@@ -1384,6 +1393,14 @@ pub struct AppState {
pub agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus,
/// Result messages from the latest integration install action.
pub integration_install_messages: Vec<String>,
+ /// Installed or linked plugins known to this running Herdr instance.
+ pub(crate) installed_plugins: InstalledPluginRegistry,
+ /// Pane ids opened through the plugin pane API.
+ pub(crate) plugin_panes: std::collections::HashMap<PaneId, PluginPaneRecord>,
+ /// Recent plugin action/event command executions.
+ pub(crate) plugin_command_logs: Vec<crate::api::schema::PluginCommandLogInfo>,
+ pub(crate) next_plugin_command_log_id: u64,
+ pub(crate) plugin_commands_in_flight: usize,
/// Highlight state for the bottom-right global launcher menu.
pub global_menu: MenuListState,
/// Resolved host terminal default colors for theming embedded panes.
@@ -1714,6 +1731,11 @@ impl AppState {
agent_manifest_update_status:
crate::detect::manifest_update::ManifestUpdateStatus::default(),
integration_install_messages: Vec::new(),
+ installed_plugins: std::collections::HashMap::new(),
+ plugin_panes: std::collections::HashMap::new(),
+ plugin_command_logs: Vec::new(),
+ next_plugin_command_log_id: 1,
+ plugin_commands_in_flight: 0,
global_menu: MenuListState::new(0),
host_terminal_theme: TerminalTheme::default(),
session_dirty: false,
diff --git a/src/cli.rs b/src/cli.rs
index 276462b7..3facba8b 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -4,20 +4,34 @@ use serde::Serialize;
use crate::api::client::{ApiClient, ApiClientError};
use crate::api::schema::{
- AgentStatus, Method, OutputMatch, PaneAgentState, PaneWaitForOutputParams, ReadFormat,
- ReadSource, Request, SplitDirection, Subscription,
+ AgentStatus, ClientWindowTitleSetParams, EmptyParams, Method, OutputMatch, PaneAgentState,
+ PaneWaitForOutputParams, ReadFormat, ReadSource, Request, SplitDirection, Subscription,
};
mod agent;
mod integration;
mod notification;
mod pane;
+mod plugin;
mod server;
mod status;
mod tab;
mod workspace;
mod worktree;
+pub(crate) fn parse_env_assignment(raw: &str) -> Result<(String, String), String> {
+ let Some((key, value)) = raw.split_once('=') else {
+ return Err("env must use KEY=VALUE".into());
+ };
+ if key.is_empty() {
+ return Err("env key must not be empty".into());
+ }
+ if key.contains('\0') || value.contains('\0') {
+ return Err("env must not contain NUL bytes".into());
+ }
+ Ok((key.to_string(), value.to_string()))
+}
+
pub enum CommandOutcome {
Handled(i32),
NotCli,
@@ -45,6 +59,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
"agent" => agent::run_agent_command(&args[2..])?,
"terminal" => run_terminal_command(&args[2..])?,
"pane" => pane::run_pane_command(&args[2..])?,
+ "plugin" => plugin::run_plugin_command(&args[2..])?,
"wait" => run_wait_command(&args[2..])?,
"integration" => integration::run_integration_command(&args[2..])?,
"session" => run_session_command(&args[2..])?,
@@ -305,6 +320,7 @@ fn run_terminal_command(args: &[String]) -> std::io::Result<i32> {
match subcommand {
"attach" => terminal_attach(&args[1..]),
+ "title" => terminal_title(&args[1..]),
"help" | "--help" | "-h" => {
print_terminal_help();
Ok(0)
@@ -458,6 +474,43 @@ fn terminal_attach(args: &[String]) -> std::io::Result<i32> {
Ok(0)
}
+fn terminal_title(args: &[String]) -> std::io::Result<i32> {
+ match args.first().map(|arg| arg.as_str()) {
+ Some("set") => {
+ if args.len() != 2 {
+ eprintln!("usage: herdr terminal title set <title>");
+ return Ok(2);
+ }
+ print_response(&send_request(&Request {
+ id: "cli:terminal:title:set".into(),
+ method: Method::ClientWindowTitleSet(ClientWindowTitleSetParams {
+ title: args[1].clone(),
+ }),
+ })?)
+ }
+ Some("clear") => {
+ if args.len() != 1 {
+ eprintln!("usage: herdr terminal title clear");
+ return Ok(2);
+ }
+ print_response(&send_request(&Request {
+ id: "cli:terminal:title:clear".into(),
+ method: Method::ClientWindowTitleClear(EmptyParams::default()),
+ })?)
+ }
+ Some("help" | "--help" | "-h") => {
+ eprintln!("usage: herdr terminal title set <title>");
+ eprintln!(" herdr terminal title clear");
+ Ok(0)
+ }
+ _ => {
+ eprintln!("usage: herdr terminal title set <title>");
+ eprintln!(" herdr terminal title clear");
+ Ok(2)
+ }
+ }
+}
+
pub(super) fn parse_attach_target(args: &[String], usage: &str) -> Result<(String, bool), i32> {
let Some(target) = args.first() else {
eprintln!("{usage}");
@@ -861,6 +914,8 @@ fn print_config_help() {
fn print_terminal_help() {
eprintln!("herdr terminal commands:");
eprintln!(" herdr terminal attach <terminal_id> [--takeover]");
+ eprintln!(" herdr terminal title set <title>");
+ eprintln!(" herdr terminal title clear");
eprintln!(" detach from direct attach with ctrl+b q; send literal ctrl+b with ctrl+b ctrl+b");
}
@@ -948,4 +1003,20 @@ mod tests {
super::ChannelSetInstallAction::RunSelfUpdate
);
}
+
+ #[test]
+ fn parse_env_assignment_accepts_empty_values() {
+ assert_eq!(
+ super::parse_env_assignment("HERDR_ROLE=").unwrap(),
+ ("HERDR_ROLE".to_string(), String::new())
+ );
+ }
+
+ #[test]
+ fn parse_env_assignment_requires_key_value_separator() {
+ assert_eq!(
+ super::parse_env_assignment("HERDR_ROLE").unwrap_err(),
+ "env must use KEY=VALUE"
+ );
+ }
}
diff --git a/src/cli/agent.rs b/src/cli/agent.rs
index 8c3c907b..80148046 100644
--- a/src/cli/agent.rs
+++ b/src/cli/agent.rs
@@ -1,3 +1,5 @@
+use std::collections::HashMap;
+
use crate::api::schema::{
AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus,
AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, Subscription,
@@ -267,12 +269,12 @@ fn matched_rule_region_preview<'a>(
fn agent_start(args: &[String]) -> std::io::Result<i32> {
let Some(name) = args.first() else {
- eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
+ eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...>");
return Ok(2);
};
let Some(separator) = args.iter().position(|arg| arg == "--") else {
- eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
+ eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...>");
return Ok(2);
};
if separator == args.len() - 1 {
@@ -285,6 +287,7 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
let mut tab_id = None;
let mut split = None;
let mut focus = false;
+ let mut env = HashMap::new();
let mut index = 1;
while index < separator {
@@ -329,6 +332,21 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
focus = false;
index += 1;
}
+ "--env" => {
+ let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
+ eprintln!("missing value for --env");
+ return Ok(2);
+ };
+ let (key, value) = match super::parse_env_assignment(value) {
+ Ok(pair) => pair,
+ Err(err) => {
+ eprintln!("{err}");
+ return Ok(2);
+ }
+ };
+ env.insert(key, value);
+ index += 2;
+ }
other => {
eprintln!("unknown option: {other}");
return Ok(2);
@@ -346,6 +364,7 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
split,
focus,
argv: args[separator + 1..].to_vec(),
+ env,
}),
})?)
}
@@ -656,7 +675,7 @@ fn print_agent_help() {
eprintln!(" herdr agent focus <target>");
eprintln!(" herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]");
eprintln!(" herdr agent attach <target> [--takeover]");
- eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
+ eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...>");
eprintln!(" herdr agent explain <target> [--json]");
eprintln!(" herdr agent explain --file PATH --agent LABEL [--json]");
eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids");
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index 5f0b42f2..2e5ce485 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -1,10 +1,11 @@
use crate::api::schema::{
- Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams,
- PaneListParams, PaneMoveDestination, PaneMoveParams, PaneNeighborParams, PaneReadParams,
- PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams,
- PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams,
- PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams,
- ReadFormat, ReadSource, Request, SplitDirection,
+ Method, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams,
+ PaneLayoutParams, PaneListParams, PaneMoveDestination, PaneMoveParams, PaneNeighborParams,
+ PaneProcessInfoParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams,
+ PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams,
+ PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
+ PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request,
+ SplitDirection,
};
pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
@@ -15,8 +16,10 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
match subcommand {
"list" => pane_list(&args[1..]),
+ "current" => pane_current(&args[1..]),
"get" => pane_get(&args[1..]),
"layout" => pane_layout(&args[1..]),
+ "process-info" => pane_process_info(&args[1..]),
"neighbor" => pane_neighbor(&args[1..]),
"edges" => pane_edges(&args[1..]),
"focus" => pane_focus(&args[1..]),
@@ -91,6 +94,49 @@ fn pane_get(args: &[String]) -> std::io::Result<i32> {
})?)
}
+fn pane_current(args: &[String]) -> std::io::Result<i32> {
+ let env_pane_id = std::env::var("HERDR_PANE_ID")
+ .ok()
+ .filter(|value| !value.trim().is_empty());
+ let caller_pane_id = match parse_pane_current_args(args, env_pane_id.as_deref()) {
+ Ok(caller_pane_id) => caller_pane_id,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:current".into(),
+ method: Method::PaneCurrent(PaneCurrentParams { caller_pane_id }),
+ })?)
+}
+
+fn parse_pane_current_args(
+ args: &[String],
+ env_pane_id: Option<&str>,
+) -> Result<Option<String>, String> {
+ let mut caller_pane_id = env_pane_id.map(super::normalize_pane_id);
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --pane".into());
+ };
+ caller_pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ "--current" => {
+ caller_pane_id = env_pane_id.map(super::normalize_pane_id);
+ index += 1;
+ }
+ other => return Err(format!("unknown option: {other}")),
+ }
+ }
+ Ok(caller_pane_id)
+}
+
fn pane_layout(args: &[String]) -> std::io::Result<i32> {
let pane_id = match parse_optional_current_pane_args(args) {
Ok(pane_id) => pane_id,
@@ -106,6 +152,21 @@ fn pane_layout(args: &[String]) -> std::io::Result<i32> {
})?)
}
+fn pane_process_info(args: &[String]) -> std::io::Result<i32> {
+ let pane_id = match parse_optional_current_pane_args(args) {
+ Ok(pane_id) => pane_id,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:process_info".into(),
+ method: Method::PaneProcessInfo(PaneProcessInfoParams { pane_id }),
+ })?)
+}
+
fn pane_edges(args: &[String]) -> std::io::Result<i32> {
let pane_id = match parse_optional_current_pane_args(args) {
Ok(pane_id) => pane_id,
@@ -483,6 +544,7 @@ fn pane_split(args: &[String]) -> std::io::Result<i32> {
}
fn parse_pane_split_args(args: &[String]) -> Result<PaneSplitParams, String> {
+ let mut env = std::collections::HashMap::new();
let mut pane_id = None;
let mut direction = None;
let mut ratio = None;
@@ -546,13 +608,21 @@ fn parse_pane_split_args(args: &[String]) -> Result<PaneSplitParams, String> {
focus = false;
index += 1;
}
+ "--env" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --env".into());
+ };
+ let (key, value) = super::parse_env_assignment(value)?;
+ env.insert(key, value);
+ index += 2;
+ }
other => return Err(format!("unknown option: {other}")),
}
}
let Some(direction) = direction else {
return Err(
- "usage: herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]"
+ "usage: herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--focus] [--no-focus]"
.into(),
);
};
@@ -564,6 +634,7 @@ fn parse_pane_split_args(args: &[String]) -> Result<PaneSplitParams, String> {
ratio,
cwd,
focus,
+ env,
})
}
@@ -1348,8 +1419,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> {
fn print_pane_help() {
eprintln!("herdr pane commands:");
eprintln!(" herdr pane list [--workspace <workspace_id>]");
+ eprintln!(" herdr pane current [--pane ID|--current]");
eprintln!(" herdr pane get <pane_id>");
eprintln!(" herdr pane layout [--pane ID|--current]");
+ eprintln!(" herdr pane process-info [--pane ID|--current]");
eprintln!(" herdr pane neighbor --direction left|right|up|down [--pane ID|--current]");
eprintln!(" herdr pane edges [--pane ID|--current]");
eprintln!(" herdr pane focus --direction left|right|up|down [--pane ID|--current]");
@@ -1360,7 +1433,7 @@ fn print_pane_help() {
eprintln!(" herdr pane rename <pane_id> <label>|--clear");
eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
eprintln!(
- " herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]"
+ " herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--focus] [--no-focus]"
);
eprintln!(" herdr pane swap --direction left|right|up|down [--pane ID|--current]");
eprintln!(" herdr pane swap --source-pane ID --target-pane ID");
@@ -1419,6 +1492,35 @@ mod tests {
}
#[test]
+ fn parse_pane_current_args_uses_env_pane_by_default() {
+ let pane_id = parse_pane_current_args(&args(&[]), Some("issue-1")).unwrap();
+
+ assert_eq!(pane_id, Some("issue-1".into()));
+ }
+
+ #[test]
+ fn parse_pane_current_args_accepts_explicit_pane() {
+ let pane_id =
+ parse_pane_current_args(&args(&["--pane", "issue-2"]), Some("issue-1")).unwrap();
+
+ assert_eq!(pane_id, Some("issue-2".into()));
+ }
+
+ #[test]
+ fn parse_pane_current_args_current_keeps_env_pane() {
+ let pane_id = parse_pane_current_args(&args(&["--current"]), Some("issue-1")).unwrap();
+
+ assert_eq!(pane_id, Some("issue-1".into()));
+ }
+
+ #[test]
+ fn parse_pane_current_args_without_env_falls_back_to_focused_pane() {
+ let pane_id = parse_pane_current_args(&args(&[]), None).unwrap();
+
+ assert_eq!(pane_id, None);
+ }
+
+ #[test]
fn parse_pane_swap_args_accepts_directional_current() {
let params = parse_pane_swap_args(&args(&["--direction", "right"])).unwrap();
diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs
new file mode 100644
index 00000000..081a27bd
--- /dev/null
+++ b/src/cli/plugin.rs
@@ -0,0 +1,1694 @@
+use std::collections::HashMap;
+use std::fmt;
+use std::io::{self, IsTerminal, Read, Write};
+use std::path::{Path, PathBuf};
+use std::process::{Command, ExitStatus, Stdio};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use crate::api::schema::{
+ InstalledPluginInfo, Method, PluginActionInvokeParams, PluginActionListParams,
+ PluginInvocationContext, PluginLinkParams, PluginListParams, PluginLogListParams,
+ PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneOpenParams, PluginPanePlacement,
+ PluginPlatform, PluginSetEnabledParams, PluginSourceInfo, PluginSourceKind, PluginUnlinkParams,
+ Request, ResponseResult, SplitDirection, SuccessResponse,
+};
+
+const PLUGIN_BUILD_OUTPUT_MAX_BYTES: usize = 64 * 1024;
+
+pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result<i32> {
+ let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
+ print_plugin_help();
+ return Ok(2);
+ };
+
+ match subcommand {
+ "install" => plugin_install(&args[1..]),
+ "uninstall" => plugin_uninstall(&args[1..]),
+ "link" => plugin_link(&args[1..]),
+ "list" => plugin_list(&args[1..]),
+ "unlink" => plugin_unlink(&args[1..]),
+ "enable" => plugin_set_enabled(&args[1..], true),
+ "disable" => plugin_set_enabled(&args[1..], false),
+ "action" => run_plugin_action_command(&args[1..]),
+ "log" | "logs" => plugin_log_list(&args[1..]),
+ "pane" => run_plugin_pane_command(&args[1..]),
+ "help" | "--help" | "-h" => {
+ print_plugin_help();
+ Ok(0)
+ }
+ _ => {
+ print_plugin_help();
+ Ok(2)
+ }
+ }
+}
+
+fn plugin_link(args: &[String]) -> std::io::Result<i32> {
+ let Some(path) = args.first() else {
+ eprintln!("usage: herdr plugin link <path> [--disabled]");
+ return Ok(2);
+ };
+ let path = normalize_plugin_path_arg(path)?;
+ let mut enabled = true;
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--disabled" => {
+ enabled = false;
+ index += 1;
+ }
+ "--enabled" => {
+ enabled = true;
+ index += 1;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+ print_plugin_response(Method::PluginLink(PluginLinkParams {
+ path,
+ enabled,
+ source: None,
+ }))
+}
+
+fn plugin_list(args: &[String]) -> std::io::Result<i32> {
+ let mut plugin_id = None;
+ let mut json = false;
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--json" => {
+ json = true;
+ index += 1;
+ }
+ "--plugin" => {
+ let Some(value) = required_value(args, &mut index, "--plugin") else {
+ return Ok(2);
+ };
+ plugin_id = Some(value);
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+ let params = PluginListParams { plugin_id };
+ let response = match super::send_request(&Request {
+ id: "cli:plugin".into(),
+ method: Method::PluginList(params.clone()),
+ }) {
+ Ok(response) => response,
+ Err(err) if is_connection_error(&err) => offline_plugin_list_response(&params)?,
+ Err(err) => return Err(err),
+ };
+ if json {
+ return super::print_response(&response);
+ }
+ print_plugin_list_human(&response)
+}
+
+fn plugin_unlink(args: &[String]) -> std::io::Result<i32> {
+ let Some(plugin_id) = args.first() else {
+ eprintln!("usage: herdr plugin unlink <plugin_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr plugin unlink <plugin_id>");
+ return Ok(2);
+ }
+ print_plugin_response(Method::PluginUnlink(PluginUnlinkParams {
+ plugin_id: plugin_id.clone(),
+ }))
+}
+
+fn plugin_install(args: &[String]) -> std::io::Result<i32> {
+ let Some(source_arg) = args.first() else {
+ eprintln!("usage: herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes]");
+ return Ok(2);
+ };
+ let source = match GithubPluginSource::parse(source_arg) {
+ Ok(source) => source,
+ Err(err) => {
+ eprintln!("{err}");
+ return Ok(2);
+ }
+ };
+ let mut requested_ref = None;
+ let mut yes = false;
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--ref" => {
+ let Some(value) = required_value(args, &mut index, "--ref") else {
+ return Ok(2);
+ };
+ requested_ref = Some(value);
+ }
+ "--yes" | "-y" => {
+ yes = true;
+ index += 1;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ if !yes && !io::stdin().is_terminal() {
+ eprintln!("remote plugin install requires --yes when stdin is not interactive");
+ return Ok(2);
+ }
+
+ let temp_root = create_plugin_temp_dir("install")?;
+ let checkout = temp_root.join("checkout");
+ let install_result = (|| {
+ git_checkout(&source, requested_ref.as_deref(), &checkout)?;
+ let resolved_commit = git_output(&checkout, ["rev-parse", "HEAD"])?;
+ let manifest_root = source.manifest_root(&checkout);
+ let preview_plugin = load_cli_plugin_manifest(&manifest_root, true)?;
+ let existing = installed_plugin_info(&preview_plugin.plugin_id)?;
+ ensure_replacement_allowed(&preview_plugin, existing.as_ref())?;
+
+ let mut source_info =
+ source.to_source_info(requested_ref, resolved_commit, None, current_unix_ms());
+ print_install_preview(&preview_plugin, &source_info, existing.as_ref());
+ if !yes && !confirm("Install this plugin?")? {
+ eprintln!("plugin install cancelled");
+ return Ok(0);
+ }
+ if let Err(err) = run_plugin_build_commands(&preview_plugin, &manifest_root) {
+ eprintln!("{err}");
+ return Ok(1);
+ }
+ let post_build_plugin = load_cli_plugin_manifest(&manifest_root, true)?;
+ ensure_manifest_unchanged_after_build(&preview_plugin, &post_build_plugin)?;
+
+ let final_checkout = managed_checkout_path(&preview_plugin.plugin_id);
+ let backup_checkout = temp_root.join("previous-checkout");
+ let mut backup_moved = false;
+ if final_checkout.exists() {
+ std::fs::rename(&final_checkout, &backup_checkout)?;
+ backup_moved = true;
+ }
+ let install_attempt = (|| {
+ if let Some(parent) = final_checkout.parent() {
+ std::fs::create_dir_all(parent).map_err(InstallFailure::Rollback)?;
+ }
+ std::fs::rename(&checkout, &final_checkout).map_err(InstallFailure::Rollback)?;
+
+ source_info.managed_path = Some(final_checkout.display().to_string());
+ let final_manifest_root = source.manifest_root(&final_checkout);
+ let mut plugin = load_cli_plugin_manifest(&final_manifest_root, true)
+ .map_err(InstallFailure::Rollback)?;
+ plugin.source = source_info.clone();
+ register_installed_plugin(plugin.clone(), source_info.clone())?;
+ Ok::<InstalledPluginInfo, InstallFailure>(plugin)
+ })();
+ let plugin = match install_attempt {
+ Ok(plugin) => plugin,
+ Err(InstallFailure::Rollback(err)) => {
+ let _ = std::fs::remove_dir_all(&final_checkout);
+ if backup_moved && backup_checkout.exists() {
+ let _ = std::fs::rename(&backup_checkout, &final_checkout);
+ }
+ return Err(err);
+ }
+ Err(InstallFailure::KeepCheckout(err)) => return Err(err),
+ };
+ println!("Installed {} from {}.", plugin.plugin_id, source.display());
+ Ok(0)
+ })();
+ let _ = std::fs::remove_dir_all(&temp_root);
+ install_result
+}
+
+fn plugin_uninstall(args: &[String]) -> std::io::Result<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr plugin uninstall <plugin_id|owner/repo[/subdir...]>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr plugin uninstall <plugin_id|owner/repo[/subdir...]>");
+ return Ok(2);
+ }
+
+ let (plugin_id, existing) = match GithubPluginSource::parse(target) {
+ Ok(source) => {
+ let existing = installed_plugin_by_github_source(&source)?;
+ let Some(existing) = existing else {
+ eprintln!("plugin not installed: {}", source.display());
+ return Ok(1);
+ };
+ (existing.plugin_id.clone(), Some(existing))
+ }
+ Err(_) => {
+ let existing = match live_installed_plugin_info(target) {
+ Ok(plugin) => plugin,
+ Err(err) if is_connection_error(&err) => registry_plugin_info(target),
+ Err(err) => return Err(err),
+ };
+ (target.clone(), existing)
+ }
+ };
+
+ match super::send_request(&Request {
+ id: "cli:plugin".into(),
+ method: Method::PluginUnlink(PluginUnlinkParams {
+ plugin_id: plugin_id.clone(),
+ }),
+ }) {
+ Ok(response) => {
+ if response.get("error").is_some() {
+ return super::print_response(&response);
+ }
+ if response["result"]["removed"].as_bool() == Some(false) {
+ eprintln!("plugin not installed: {target}");
+ return Ok(1);
+ }
+ }
+ Err(err) if is_connection_error(&err) => {
+ let mut plugins = crate::persist::plugin_registry::load();
+ let before = plugins.len();
+ plugins.retain(|plugin| plugin.plugin_id != plugin_id);
+ if before == plugins.len() {
+ eprintln!("plugin not installed: {target}");
+ return Ok(1);
+ }
+ crate::persist::plugin_registry::save(&plugins)?;
+ }
+ Err(err) => return Err(err),
+ }
+
+ if let Some(plugin) = existing.as_ref() {
+ remove_managed_plugin_files(plugin)?;
+ }
+ println!("Uninstalled {plugin_id}.");
+ Ok(0)
+}
+
+fn plugin_set_enabled(args: &[String], enabled: bool) -> std::io::Result<i32> {
+ let Some(plugin_id) = args.first() else {
+ eprintln!(
+ "usage: herdr plugin {} <plugin_id>",
+ if enabled { "enable" } else { "disable" }
+ );
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!(
+ "usage: herdr plugin {} <plugin_id>",
+ if enabled { "enable" } else { "disable" }
+ );
+ return Ok(2);
+ }
+ let params = PluginSetEnabledParams {
+ plugin_id: plugin_id.clone(),
+ };
+ if enabled {
+ print_plugin_response(Method::PluginEnable(params))
+ } else {
+ print_plugin_response(Method::PluginDisable(params))
+ }
+}
+
+fn plugin_log_list(args: &[String]) -> std::io::Result<i32> {
+ let mut plugin_id = None;
+ let mut limit = None;
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "list" if index == 0 => index += 1,
+ "--plugin" => {
+ let Some(value) = required_value(args, &mut index, "--plugin") else {
+ return Ok(2);
+ };
+ plugin_id = Some(value);
+ }
+ "--limit" => {
+ let Some(raw) = required_value(args, &mut index, "--limit") else {
+ return Ok(2);
+ };
+ let Ok(parsed) = raw.parse::<usize>() else {
+ eprintln!("invalid --limit value: {raw}");
+ return Ok(2);
+ };
+ limit = Some(parsed);
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+ print_plugin_response(Method::PluginLogList(PluginLogListParams {
+ plugin_id,
+ limit,
+ }))
+}
+
+fn run_plugin_action_command(args: &[String]) -> std::io::Result<i32> {
+ let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
+ print_plugin_action_help();
+ return Ok(2);
+ };
+
+ match subcommand {
+ "list" => plugin_action_list(&args[1..]),
+ "invoke" => plugin_action_invoke(&args[1..]),
+ "help" | "--help" | "-h" => {
+ print_plugin_action_help();
+ Ok(0)
+ }
+ _ => {
+ print_plugin_action_help();
+ Ok(2)
+ }
+ }
+}
+
+fn plugin_action_list(args: &[String]) -> std::io::Result<i32> {
+ let mut plugin_id = None;
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--plugin" => {
+ let Some(value) = required_value(args, &mut index, "--plugin") else {
+ return Ok(2);
+ };
+ plugin_id = Some(value);
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ print_plugin_response(Method::PluginActionList(PluginActionListParams {
+ plugin_id,
+ }))
+}
+
+fn plugin_action_invoke(args: &[String]) -> std::io::Result<i32> {
+ let Some(action_id) = args.first() else {
+ eprintln!("usage: herdr plugin action invoke <action_id> [--plugin ID]");
+ return Ok(2);
+ };
+ let mut plugin_id = None;
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--plugin" => {
+ let Some(value) = required_value(args, &mut index, "--plugin") else {
+ return Ok(2);
+ };
+ plugin_id = Some(value);
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ print_plugin_response(Method::PluginActionInvoke(PluginActionInvokeParams {
+ action_id: action_id.clone(),
+ plugin_id,
+ context: Some(PluginInvocationContext {
+ workspace_id: None,
+ workspace_label: None,
+ workspace_cwd: None,
+ worktree: None,
+ tab_id: None,
+ tab_label: None,
+ focused_pane_id: None,
+ focused_pane_cwd: None,
+ focused_pane_agent: None,
+ focused_pane_status: None,
+ selected_text: None,
+ invocation_source: Some("cli".into()),
+ correlation_id: None,
+ clicked_url: None,
+ link_handler_id: None,
+ }),
+ }))
+}
+
+fn run_plugin_pane_command(args: &[String]) -> std::io::Result<i32> {
+ let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
+ print_plugin_pane_help();
+ return Ok(2);
+ };
+
+ match subcommand {
+ "open" => plugin_pane_open(&args[1..]),
+ "focus" => plugin_pane_focus(&args[1..]),
+ "close" => plugin_pane_close(&args[1..]),
+ "help" | "--help" | "-h" => {
+ print_plugin_pane_help();
+ Ok(0)
+ }
+ _ => {
+ print_plugin_pane_help();
+ Ok(2)
+ }
+ }
+}
+
+fn plugin_pane_open(args: &[String]) -> std::io::Result<i32> {
+ let mut plugin_id = None;
+ let mut entrypoint = None;
+ let mut placement = None;
+ let mut workspace_id = None;
+ let mut target_pane_id = None;
+ let mut direction = None;
+ let mut cwd = None;
+ let mut focus = true;
+ let mut env = HashMap::new();
+
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--plugin" => {
+ let Some(value) = required_value(args, &mut index, "--plugin") else {
+ return Ok(2);
+ };
+ plugin_id = Some(value);
+ }
+ "--entrypoint" => {
+ let Some(value) = required_value(args, &mut index, "--entrypoint") else {
+ return Ok(2);
+ };
+ entrypoint = Some(value);
+ }
+ "--placement" => {
+ let Some(value) = required_value(args, &mut index, "--placement") else {
+ return Ok(2);
+ };
+ let Some(parsed) = parse_pane_placement(&value) else {
+ return Ok(2);
+ };
+ placement = Some(parsed);
+ }
+ "--workspace" => {
+ let Some(value) = required_value(args, &mut index, "--workspace") else {
+ return Ok(2);
+ };
+ workspace_id = Some(value);
+ }
+ "--target-pane" => {
+ let Some(value) = required_value(args, &mut index, "--target-pane") else {
+ return Ok(2);
+ };
+ target_pane_id = Some(value);
+ }
+ "--direction" => {
+ let Some(value) = required_value(args, &mut index, "--direction") else {
+ return Ok(2);
+ };
+ let Some(parsed) = parse_split_direction(&value) else {
+ return Ok(2);
+ };
+ direction = Some(parsed);
+ }
+ "--cwd" => {
+ let Some(value) = required_value(args, &mut index, "--cwd") else {
+ return Ok(2);
+ };
+ cwd = Some(value);
+ }
+ "--env" => {
+ let Some(value) = required_value(args, &mut index, "--env") else {
+ return Ok(2);
+ };
+ let (key, value) = match super::parse_env_assignment(&value) {
+ Ok(pair) => pair,
+ Err(err) => {
+ eprintln!("{err}");
+ return Ok(2);
+ }
+ };
+ env.insert(key, value);
+ }
+ "--focus" => {
+ focus = true;
+ index += 1;
+ }
+ "--no-focus" => {
+ focus = false;
+ index += 1;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ let Some(plugin_id) = plugin_id else {
+ eprintln!("missing required --plugin");
+ return Ok(2);
+ };
+ let Some(entrypoint) = entrypoint else {
+ eprintln!("missing required --entrypoint");
+ return Ok(2);
+ };
+
+ print_plugin_response(Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id,
+ entrypoint,
+ placement,
+ workspace_id,
+ target_pane_id,
+ direction,
+ cwd,
+ focus,
+ env,
+ }))
+}
+
+fn plugin_pane_focus(args: &[String]) -> std::io::Result<i32> {
+ let Some(pane_id) = args.first() else {
+ eprintln!("usage: herdr plugin pane focus <pane_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr plugin pane focus <pane_id>");
+ return Ok(2);
+ }
+ print_plugin_response(Method::PluginPaneFocus(PluginPaneFocusParams {
+ pane_id: super::normalize_pane_id(pane_id),
+ }))
+}
+
+fn plugin_pane_close(args: &[String]) -> std::io::Result<i32> {
+ let Some(pane_id) = args.first() else {
+ eprintln!("usage: herdr plugin pane close <pane_id>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr plugin pane close <pane_id>");
+ return Ok(2);
+ }
+ print_plugin_response(Method::PluginPaneClose(PluginPaneCloseParams {
+ pane_id: super::normalize_pane_id(pane_id),
+ }))
+}
+
+fn required_value(args: &[String], index: &mut usize, flag: &str) -> Option<String> {
+ let Some(value) = args.get(*index + 1) else {
+ eprintln!("missing value for {flag}");
+ return None;
+ };
+ *index += 2;
+ Some(value.clone())
+}
+
+fn parse_pane_placement(value: &str) -> Option<PluginPanePlacement> {
+ match value {
+ "overlay" => Some(PluginPanePlacement::Overlay),
+ "split" => Some(PluginPanePlacement::Split),
+ "tab" => Some(PluginPanePlacement::Tab),
+ "zoomed" | "fullscreen" => Some(PluginPanePlacement::Zoomed),
+ _ => {
+ eprintln!("invalid pane placement: {value}");
+ None
+ }
+ }
+}
+
+fn parse_split_direction(value: &str) -> Option<SplitDirection> {
+ match value {
+ "right" => Some(SplitDirection::Right),
+ "down" => Some(SplitDirection::Down),
+ _ => {
+ eprintln!("invalid split direction: {value}");
+ None
+ }
+ }
+}
+
+fn normalize_plugin_path_arg(value: &str) -> std::io::Result<String> {
+ let path = crate::worktree::expand_tilde_path(value);
+ let absolute = if path.is_absolute() {
+ path
+ } else {
+ std::env::current_dir()?.join(path)
+ };
+ Ok(absolute.display().to_string())
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct GithubPluginSource {
+ owner: String,
+ repo: String,
+ subdir: Option<String>,
+}
+
+impl GithubPluginSource {
+ fn parse(value: &str) -> Result<Self, String> {
+ if value.starts_with("http://")
+ || value.starts_with("https://")
+ || value.starts_with("git@")
+ || value.contains(':')
+ {
+ return Err("plugin install v1 accepts only owner/repo[/subdir] shorthand".into());
+ }
+ let parts = value.split('/').collect::<Vec<_>>();
+ if parts.len() < 2 {
+ return Err("usage: herdr plugin install <owner>/<repo>[/subdir...]".into());
+ }
+ let owner = parts[0];
+ let repo = parts[1];
+ validate_github_segment("owner", owner)?;
+ validate_github_segment("repo", repo)?;
+ let subdir_parts = &parts[2..];
+ for part in subdir_parts {
+ validate_subdir_segment(part)?;
+ }
+ let subdir = if subdir_parts.is_empty() {
+ None
+ } else {
+ Some(subdir_parts.join("/"))
+ };
+ Ok(Self {
+ owner: owner.to_string(),
+ repo: repo.to_string(),
+ subdir,
+ })
+ }
+
+ fn remote_url(&self) -> String {
+ format!("https://github.com/{}/{}.git", self.owner, self.repo)
+ }
+
+ fn display(&self) -> String {
+ match &self.subdir {
+ Some(subdir) => format!("{}/{}/{}", self.owner, self.repo, subdir),
+ None => format!("{}/{}", self.owner, self.repo),
+ }
+ }
+
+ fn manifest_root(&self, checkout: &Path) -> PathBuf {
+ match &self.subdir {
+ Some(subdir) => checkout.join(subdir),
+ None => checkout.to_path_buf(),
+ }
+ }
+
+ fn to_source_info(
+ &self,
+ requested_ref: Option<String>,
+ resolved_commit: String,
+ managed_path: Option<String>,
+ installed_unix_ms: u64,
+ ) -> PluginSourceInfo {
+ PluginSourceInfo {
+ kind: PluginSourceKind::Github,
+ owner: Some(self.owner.clone()),
+ repo: Some(self.repo.clone()),
+ subdir: self.subdir.clone(),
+ requested_ref,
+ resolved_commit: Some(resolved_commit),
+ managed_path,
+ installed_unix_ms: Some(installed_unix_ms),
+ }
+ }
+}
+
+fn ensure_replacement_allowed(
+ plugin: &InstalledPluginInfo,
+ existing: Option<&InstalledPluginInfo>,
+) -> std::io::Result<()> {
+ let Some(existing) = existing else {
+ return Ok(());
+ };
+ if existing.source.kind != PluginSourceKind::Github {
+ return Err(std::io::Error::other(format!(
+ "plugin {} is already linked from a local path; uninstall/unlink it before installing from GitHub",
+ plugin.plugin_id
+ )));
+ }
+ Ok(())
+}
+
+fn validate_github_segment(label: &str, value: &str) -> Result<(), String> {
+ if value.is_empty() {
+ return Err(format!("GitHub {label} must not be empty"));
+ }
+ if value == "." || value == ".." {
+ return Err(format!("GitHub {label} is invalid: {value}"));
+ }
+ if !value
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
+ {
+ return Err(format!(
+ "GitHub {label} contains invalid characters: {value}"
+ ));
+ }
+ Ok(())
+}
+
+fn validate_subdir_segment(value: &str) -> Result<(), String> {
+ if value.is_empty() || value == "." || value == ".." {
+ return Err(format!("invalid plugin subdir segment: {value}"));
+ }
+ if value.contains('\\') || value.contains('\0') {
+ return Err(format!("invalid plugin subdir segment: {value}"));
+ }
+ Ok(())
+}
+
+fn git_checkout(
+ source: &GithubPluginSource,
+ requested_ref: Option<&str>,
+ checkout: &Path,
+) -> std::io::Result<()> {
+ std::fs::create_dir_all(checkout)?;
+ run_git(Some(checkout), ["init"])?;
+ run_git(
+ Some(checkout),
+ ["remote", "add", "origin", &source.remote_url()],
+ )?;
+ match requested_ref {
+ Some(reference) => {
+ run_git(
+ Some(checkout),
+ ["fetch", "--depth", "1", "origin", reference],
+ )?;
+ }
+ None => {
+ run_git(Some(checkout), ["fetch", "--depth", "1", "origin", "HEAD"])?;
+ }
+ }
+ run_git(Some(checkout), ["checkout", "--detach", "FETCH_HEAD"])?;
+ Ok(())
+}
+
+fn run_git<const N: usize>(cwd: Option<&Path>, args: [&str; N]) -> std::io::Result<()> {
+ let mut command = Command::new("git");
+ command.args(args);
+ if let Some(cwd) = cwd {
+ command.current_dir(cwd);
+ }
+ command.stdin(Stdio::null());
+ let output = command.output()?;
+ if output.status.success() {
+ return Ok(());
+ }
+ Err(std::io::Error::other(command_failure_message(
+ "git", &output,
+ )))
+}
+
+fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> std::io::Result<String> {
+ let output = Command::new("git")
+ .args(args)
+ .current_dir(cwd)
+ .stdin(Stdio::null())
+ .output()?;
+ if output.status.success() {
+ return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
+ }
+ Err(std::io::Error::other(command_failure_message(
+ "git", &output,
+ )))
+}
+
+fn command_failure_message(program: &str, output: &std::process::Output) -> String {
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ if stderr.is_empty() {
+ format!("{program} failed with status {}", output.status)
+ } else {
+ format!("{program} failed with status {}: {stderr}", output.status)
+ }
+}
+
+fn load_cli_plugin_manifest(path: &Path, enabled: bool) -> std::io::Result<InstalledPluginInfo> {
+ crate::app::load_plugin_manifest(&path.display().to_string(), enabled)
+ .map_err(|(_, message)| std::io::Error::other(message))
+}
+
+fn register_installed_plugin(
+ plugin: InstalledPluginInfo,
+ source: PluginSourceInfo,
+) -> Result<(), InstallFailure> {
+ let request = Request {
+ id: "cli:plugin".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: plugin.manifest_path.clone(),
+ enabled: plugin.enabled,
+ source: Some(source.clone()),
+ }),
+ };
+ match super::send_request(&request) {
+ Ok(response) => {
+ if response.get("error").is_some() {
+ return Err(InstallFailure::Rollback(std::io::Error::other(
+ serde_json::to_string(&response).unwrap(),
+ )));
+ }
+ if let Err(err) =
+ verify_plugin_link_source_response(response, &plugin.plugin_id, &source)
+ {
+ let unlink = super::send_request(&Request {
+ id: "cli:plugin".into(),
+ method: Method::PluginUnlink(PluginUnlinkParams {
+ plugin_id: plugin.plugin_id.clone(),
+ }),
+ });
+ match unlink {
+ Ok(response) if response.get("error").is_none() => {
+ return Err(InstallFailure::Rollback(err));
+ }
+ Ok(response) => {
+ return Err(InstallFailure::KeepCheckout(std::io::Error::other(
+ format!(
+ "{err}; failed to undo incompatible plugin registration: {}",
+ serde_json::to_string(&response).unwrap()
+ ),
+ )));
+ }
+ Err(unlink_err) => {
+ return Err(InstallFailure::KeepCheckout(std::io::Error::other(
+ format!(
+ "{err}; failed to undo incompatible plugin registration: {unlink_err}"
+ ),
+ )));
+ }
+ }
+ }
+ Ok(())
+ }
+ Err(err) if is_connection_error(&err) => {
+ let mut plugins = crate::persist::plugin_registry::load();
+ plugins.retain(|entry| entry.plugin_id != plugin.plugin_id);
+ plugins.push(plugin);
+ crate::persist::plugin_registry::save(&plugins).map_err(InstallFailure::Rollback)
+ }
+ Err(err) => Err(InstallFailure::Rollback(err)),
+ }
+}
+
+#[derive(Debug)]
+enum InstallFailure {
+ Rollback(std::io::Error),
+ KeepCheckout(std::io::Error),
+}
+
+fn verify_plugin_link_source_response(
+ response: serde_json::Value,
+ plugin_id: &str,
+ expected: &PluginSourceInfo,
+) -> std::io::Result<()> {
+ let parsed: SuccessResponse =
+ serde_json::from_value(response).map_err(std::io::Error::other)?;
+ let ResponseResult::PluginLinked { plugin } = parsed.result else {
+ return Err(std::io::Error::other("expected plugin_linked response"));
+ };
+ if plugin.plugin_id != plugin_id
+ || plugin.source.kind != PluginSourceKind::Github
+ || plugin.source.owner != expected.owner
+ || plugin.source.repo != expected.repo
+ || plugin.source.subdir != expected.subdir
+ || plugin.source.requested_ref != expected.requested_ref
+ || plugin.source.resolved_commit != expected.resolved_commit
+ || plugin.source.managed_path != expected.managed_path
+ {
+ return Err(std::io::Error::other(
+ "running Herdr server did not persist GitHub plugin source metadata",
+ ));
+ }
+ Ok(())
+}
+
+fn installed_plugin_info(plugin_id: &str) -> std::io::Result<Option<InstalledPluginInfo>> {
+ match live_installed_plugin_info(plugin_id) {
+ Ok(plugin) => Ok(plugin),
+ Err(err) if is_connection_error(&err) => Ok(registry_plugin_info(plugin_id)),
+ Err(err) => Err(err),
+ }
+}
+
+fn live_installed_plugin_info(plugin_id: &str) -> std::io::Result<Option<InstalledPluginInfo>> {
+ let response = super::send_request(&Request {
+ id: "cli:plugin".into(),
+ method: Method::PluginList(PluginListParams {
+ plugin_id: Some(plugin_id.to_string()),
+ }),
+ })?;
+ if response.get("error").is_some() {
+ return Err(std::io::Error::other(
+ serde_json::to_string(&response).unwrap(),
+ ));
+ }
+ plugin_info_from_list_response(response)
+}
+
+fn installed_plugin_by_github_source(
+ source: &GithubPluginSource,
+) -> std::io::Result<Option<InstalledPluginInfo>> {
+ let plugins = match live_installed_plugins() {
+ Ok(plugins) => plugins,
+ Err(err) if is_connection_error(&err) => registry_plugins(),
+ Err(err) => return Err(err),
+ };
+ Ok(plugin_by_github_source(plugins, source))
+}
+
+fn live_installed_plugins() -> std::io::Result<Vec<InstalledPluginInfo>> {
+ let response = super::send_request(&Request {
+ id: "cli:plugin".into(),
+ method: Method::PluginList(PluginListParams { plugin_id: None }),
+ })?;
+ if response.get("error").is_some() {
+ return Err(std::io::Error::other(
+ serde_json::to_string(&response).unwrap(),
+ ));
+ }
+ plugin_list_from_response(response)
+}
+
+fn registry_plugin_info(plugin_id: &str) -> Option<InstalledPluginInfo> {
+ registry_plugins()
+ .into_iter()
+ .find(|plugin| plugin.plugin_id == plugin_id)
+}
+
+fn registry_plugins() -> Vec<InstalledPluginInfo> {
+ crate::persist::plugin_registry::load()
+}
+
+fn plugin_info_from_list_response(
+ response: serde_json::Value,
+) -> std::io::Result<Option<InstalledPluginInfo>> {
+ let mut plugins = plugin_list_from_response(response)?;
+ Ok(plugins.pop())
+}
+
+fn plugin_list_from_response(
+ response: serde_json::Value,
+) -> std::io::Result<Vec<InstalledPluginInfo>> {
+ let parsed: SuccessResponse =
+ serde_json::from_value(response).map_err(std::io::Error::other)?;
+ let ResponseResult::PluginList { mut plugins } = parsed.result else {
+ return Err(std::io::Error::other("expected plugin_list response"));
+ };
+ plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id));
+ Ok(plugins)
+}
+
+fn plugin_by_github_source(
+ plugins: impl IntoIterator<Item = InstalledPluginInfo>,
+ source: &GithubPluginSource,
+) -> Option<InstalledPluginInfo> {
+ plugins
+ .into_iter()
+ .find(|plugin| plugin_matches_github_source(plugin, source))
+}
+
+fn plugin_matches_github_source(plugin: &InstalledPluginInfo, source: &GithubPluginSource) -> bool {
+ plugin.source.kind == PluginSourceKind::Github
+ && plugin.source.owner.as_deref() == Some(source.owner.as_str())
+ && plugin.source.repo.as_deref() == Some(source.repo.as_str())
+ && plugin.source.subdir.as_deref() == source.subdir.as_deref()
+}
+
+fn offline_plugin_list_response(params: &PluginListParams) -> std::io::Result<serde_json::Value> {
+ let entries = crate::persist::plugin_registry::load();
+ let mut plugins =
+ crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| {
+ crate::app::load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg)
+ })
+ .into_iter()
+ .filter(|plugin| {
+ params
+ .plugin_id
+ .as_deref()
+ .is_none_or(|plugin_id| plugin.plugin_id == plugin_id)
+ })
+ .collect::<Vec<_>>();
+ plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id));
+ serde_json::to_value(SuccessResponse {
+ id: "cli:plugin".into(),
+ result: ResponseResult::PluginList { plugins },
+ })
+ .map_err(std::io::Error::other)
+}
+
+fn print_plugin_list_human(response: &serde_json::Value) -> std::io::Result<i32> {
+ if response.get("error").is_some() {
+ return super::print_response(response);
+ }
+ let parsed: SuccessResponse =
+ serde_json::from_value(response.clone()).map_err(std::io::Error::other)?;
+ let ResponseResult::PluginList { plugins } = parsed.result else {
+ return super::print_response(response);
+ };
+ if plugins.is_empty() {
+ println!("No plugins installed.");
+ return Ok(0);
+ }
+ println!(
+ "{} plugin{} installed:",
+ plugins.len(),
+ if plugins.len() == 1 { "" } else { "s" }
+ );
+ for plugin in plugins {
+ let enabled = if plugin.enabled {
+ "enabled"
+ } else {
+ "disabled"
+ };
+ let warning = if plugin.warnings.is_empty() {
+ String::new()
+ } else {
+ format!("; {} warning(s)", plugin.warnings.len())
+ };
+ println!(
+ "- {} ({}) {} [{}{}]",
+ plugin.plugin_id,
+ plugin.name,
+ enabled,
+ source_display(&plugin),
+ warning
+ );
+ for warning in plugin.warnings {
+ println!(" warning: {warning}");
+ }
+ }
+ Ok(0)
+}
+
+fn source_display(plugin: &InstalledPluginInfo) -> String {
+ match plugin.source.kind {
+ PluginSourceKind::Github => {
+ let owner = plugin.source.owner.as_deref().unwrap_or("unknown");
+ let repo = plugin.source.repo.as_deref().unwrap_or("unknown");
+ let subdir = plugin
+ .source
+ .subdir
+ .as_deref()
+ .map(|subdir| format!("/{subdir}"))
+ .unwrap_or_default();
+ let reference = plugin
+ .source
+ .requested_ref
+ .as_deref()
+ .or(plugin.source.resolved_commit.as_deref())
+ .unwrap_or("unknown");
+ format!("github:{owner}/{repo}{subdir}@{reference}")
+ }
+ PluginSourceKind::Local => format!("local:{}", plugin.plugin_root),
+ }
+}
+
+fn print_install_preview(
+ plugin: &InstalledPluginInfo,
+ source: &PluginSourceInfo,
+ existing: Option<&InstalledPluginInfo>,
+) {
+ eprintln!("Plugin install preview:");
+ eprintln!(" id: {}", plugin.plugin_id);
+ eprintln!(" name: {}", plugin.name);
+ eprintln!(" version: {}", plugin.version);
+ if let (Some(owner), Some(repo)) = (&source.owner, &source.repo) {
+ let subdir = source
+ .subdir
+ .as_deref()
+ .map(|subdir| format!("/{subdir}"))
+ .unwrap_or_default();
+ eprintln!(" source: {owner}/{repo}{subdir}");
+ }
+ if let Some(reference) = &source.requested_ref {
+ eprintln!(" ref: {reference}");
+ }
+ if let Some(commit) = &source.resolved_commit {
+ eprintln!(" commit: {commit}");
+ }
+ eprintln!(" actions: {}", plugin.actions.len());
+ eprintln!(" events: {}", plugin.events.len());
+ eprintln!(" panes: {}", plugin.panes.len());
+ eprintln!(" link handlers: {}", plugin.link_handlers.len());
+ eprintln!(" build commands: {}", plugin.build.len());
+ for build in &plugin.build {
+ let support = if build_platform_supported(&build.platforms, &plugin.platforms) {
+ String::new()
+ } else {
+ format!(
+ " (skipped on {})",
+ plugin_platform_name(current_plugin_platform())
+ )
+ };
+ eprintln!(" build{}: {}", support, build.command.join(" "));
+ }
+ for action in &plugin.actions {
+ eprintln!(" action {}: {}", action.id, action.command.join(" "));
+ }
+ for event in &plugin.events {
+ eprintln!(" event {}: {}", event.on, event.command.join(" "));
+ }
+ for pane in &plugin.panes {
+ eprintln!(" pane {}: {}", pane.id, pane.command.join(" "));
+ }
+ for warning in &plugin.warnings {
+ eprintln!(" warning: {warning}");
+ }
+ if let Some(existing) = existing {
+ eprintln!(
+ " replaces: {} from {}",
+ existing.plugin_id,
+ source_display(existing)
+ );
+ }
+}
+
+fn run_plugin_build_commands(
+ plugin: &InstalledPluginInfo,
+ manifest_root: &Path,
+) -> Result<(), Box<PluginBuildFailure>> {
+ let total = plugin.build.len();
+ for (index, build) in plugin.build.iter().enumerate() {
+ if !build_platform_supported(&build.platforms, &plugin.platforms) {
+ continue;
+ }
+ run_plugin_build_command(
+ &plugin.plugin_id,
+ index + 1,
+ total,
+ manifest_root,
+ &build.command,
+ )?;
+ }
+ Ok(())
+}
+
+fn ensure_manifest_unchanged_after_build(
+ before: &InstalledPluginInfo,
+ after: &InstalledPluginInfo,
+) -> io::Result<()> {
+ if before == after {
+ return Ok(());
+ }
+ Err(io::Error::other(
+ "plugin build changed herdr-plugin.toml after install preview; aborting install",
+ ))
+}
+
+fn run_plugin_build_command(
+ plugin_id: &str,
+ build_index: usize,
+ build_total: usize,
+ cwd: &Path,
+ command: &[String],
+) -> Result<(), Box<PluginBuildFailure>> {
+ let context = plugin_build_context(plugin_id, build_index, build_total, cwd, command);
+ let Some(program) = command.first() else {
+ return Err(Box::new(PluginBuildFailure {
+ context,
+ kind: PluginBuildFailureKind::Start {
+ error: io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "build command must not be empty",
+ ),
+ },
+ }));
+ };
+ let mut child = Command::new(program);
+ child
+ .args(command.iter().skip(1))
+ .current_dir(cwd)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ scrub_herdr_runtime_env(&mut child);
+
+ let mut child = match child.spawn() {
+ Ok(child) => child,
+ Err(err) => {
+ return Err(Box::new(PluginBuildFailure {
+ context,
+ kind: PluginBuildFailureKind::Start { error: err },
+ }));
+ }
+ };
+ let stdout = child.stdout.take();
+ let stderr = child.stderr.take();
+ let stdout_reader = stdout.map(|stdout| {
+ std::thread::spawn(move || read_tail_capped_output(stdout, PLUGIN_BUILD_OUTPUT_MAX_BYTES))
+ });
+ let stderr_reader = stderr.map(|stderr| {
+ std::thread::spawn(move || read_tail_capped_output(stderr, PLUGIN_BUILD_OUTPUT_MAX_BYTES))
+ });
+ let status = child.wait().map_err(|error| {
+ Box::new(PluginBuildFailure {
+ context: context.clone(),
+ kind: PluginBuildFailureKind::Wait { error },
+ })
+ })?;
+ let stdout = stdout_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default();
+ let stderr = stderr_reader
+ .and_then(|reader| reader.join().ok())
+ .unwrap_or_default();
+ if status.success() {
+ return Ok(());
+ }
+ Err(Box::new(PluginBuildFailure {
+ context,
+ kind: PluginBuildFailureKind::Exit {
+ status,
+ stdout,
+ stderr,
+ },
+ }))
+}
+
+fn plugin_build_context(
+ plugin_id: &str,
+ build_index: usize,
+ build_total: usize,
+ cwd: &Path,
+ command: &[String],
+) -> PluginBuildContext {
+ PluginBuildContext {
+ plugin_id: plugin_id.to_string(),
+ build_index,
+ build_total,
+ cwd: cwd.display().to_string(),
+ command: command.to_vec(),
+ }
+}
+
+#[derive(Debug, Default)]
+struct CappedOutput {
+ text: String,
+ truncated: bool,
+}
+
+#[derive(Debug, Clone)]
+struct PluginBuildContext {
+ plugin_id: String,
+ build_index: usize,
+ build_total: usize,
+ cwd: String,
+ command: Vec<String>,
+}
+
+#[derive(Debug)]
+struct PluginBuildFailure {
+ context: PluginBuildContext,
+ kind: PluginBuildFailureKind,
+}
+
+#[derive(Debug)]
+enum PluginBuildFailureKind {
+ Start {
+ error: io::Error,
+ },
+ Wait {
+ error: io::Error,
+ },
+ Exit {
+ status: ExitStatus,
+ stdout: CappedOutput,
+ stderr: CappedOutput,
+ },
+}
+
+impl fmt::Display for PluginBuildFailure {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ writeln!(f, "error: plugin build failed")?;
+ write_build_context(f, &self.context)?;
+ match &self.kind {
+ PluginBuildFailureKind::Start { error } => {
+ writeln!(f, " error: failed to start: {error}")?;
+ }
+ PluginBuildFailureKind::Wait { error } => {
+ writeln!(f, " error: failed to wait for command: {error}")?;
+ }
+ PluginBuildFailureKind::Exit {
+ status,
+ stdout,
+ stderr,
+ } => {
+ writeln!(f, " status: {status}")?;
+ write_output_section(f, "stderr", stderr)?;
+ write_output_section(f, "stdout", stdout)?;
+ }
+ }
+ writeln!(f)?;
+ write!(f, "Plugin was not installed.")
+ }
+}
+
+fn write_build_context(f: &mut fmt::Formatter<'_>, context: &PluginBuildContext) -> fmt::Result {
+ writeln!(f, " plugin: {}", context.plugin_id)?;
+ writeln!(
+ f,
+ " build: {}/{}",
+ context.build_index, context.build_total
+ )?;
+ writeln!(f, " cwd: {}", context.cwd)?;
+ writeln!(f, " command: {}", context.command.join(" "))
+}
+
+fn write_output_section(
+ f: &mut fmt::Formatter<'_>,
+ label: &str,
+ output: &CappedOutput,
+) -> fmt::Result {
+ let text = output.text.trim_end();
+ if text.is_empty() {
+ return Ok(());
+ }
+ writeln!(f)?;
+ if output.truncated {
+ writeln!(
+ f,
+ "{label}: showing last {PLUGIN_BUILD_OUTPUT_MAX_BYTES} bytes; earlier output omitted"
+ )?;
+ } else {
+ writeln!(f, "{label}:")?;
+ }
+ writeln!(f, "{text}")
+}
+
+fn read_tail_capped_output(mut reader: impl Read, cap: usize) -> CappedOutput {
+ let mut out = Vec::new();
+ let mut buf = [0u8; 8192];
+ let mut truncated = false;
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ out.extend_from_slice(&buf[..n]);
+ if out.len() > cap {
+ let excess = out.len() - cap;
+ out.drain(0..excess);
+ truncated = true;
+ }
+ }
+ Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
+ Err(_) => break,
+ }
+ }
+ CappedOutput {
+ text: String::from_utf8_lossy(&out).to_string(),
+ truncated,
+ }
+}
+
+fn scrub_herdr_runtime_env(command: &mut Command) {
+ for key in [
+ crate::api::SOCKET_PATH_ENV_VAR,
+ crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR,
+ crate::session::SESSION_ENV_VAR,
+ "HERDR_BIN_PATH",
+ "HERDR_ENV",
+ "HERDR_WORKSPACE_ID",
+ "HERDR_TAB_ID",
+ "HERDR_PANE_ID",
+ ] {
+ command.env_remove(key);
+ }
+ for (key, _) in std::env::vars_os() {
+ if key.to_string_lossy().starts_with("HERDR_PLUGIN_") {
+ command.env_remove(key);
+ }
+ }
+}
+
+fn build_platform_supported(
+ platforms: &Option<Vec<PluginPlatform>>,
+ plugin_platforms: &Option<Vec<PluginPlatform>>,
+) -> bool {
+ platforms
+ .as_ref()
+ .or(plugin_platforms.as_ref())
+ .is_none_or(|platforms| platforms.contains(&current_plugin_platform()))
+}
+
+fn current_plugin_platform() -> PluginPlatform {
+ if cfg!(target_os = "linux") {
+ PluginPlatform::Linux
+ } else if cfg!(target_os = "macos") {
+ PluginPlatform::Macos
+ } else {
+ PluginPlatform::Windows
+ }
+}
+
+fn plugin_platform_name(platform: PluginPlatform) -> &'static str {
+ match platform {
+ PluginPlatform::Linux => "linux",
+ PluginPlatform::Macos => "macos",
+ PluginPlatform::Windows => "windows",
+ }
+}
+
+fn confirm(prompt: &str) -> std::io::Result<bool> {
+ eprint!("{prompt} [y/N] ");
+ io::stderr().flush()?;
+ let mut line = String::new();
+ io::stdin().read_line(&mut line)?;
+ Ok(matches!(line.trim(), "y" | "Y" | "yes" | "YES" | "Yes"))
+}
+
+fn create_plugin_temp_dir(label: &str) -> std::io::Result<PathBuf> {
+ let path = managed_plugins_dir().join(format!(
+ ".tmp-{label}-{}-{}",
+ std::process::id(),
+ current_unix_ms()
+ ));
+ std::fs::create_dir_all(&path)?;
+ Ok(path)
+}
+
+fn managed_plugins_dir() -> PathBuf {
+ crate::session::data_dir().join("plugins")
+}
+
+fn managed_checkout_path(plugin_id: &str) -> PathBuf {
+ managed_plugins_dir()
+ .join("github")
+ .join(crate::api::schema::plugin_managed_path_component(plugin_id))
+}
+
+fn remove_managed_plugin_files(plugin: &InstalledPluginInfo) -> std::io::Result<()> {
+ if plugin.source.kind != PluginSourceKind::Github {
+ return Ok(());
+ }
+ let Some(path) = plugin.source.managed_path.as_deref() else {
+ return Ok(());
+ };
+ let path = PathBuf::from(path);
+ if !path.exists() {
+ return Ok(());
+ }
+ if !is_expected_managed_path(plugin, &path) {
+ return Err(std::io::Error::other(format!(
+ "refusing to delete unmanaged plugin path: {}",
+ path.display()
+ )));
+ }
+ std::fs::remove_dir_all(path)
+}
+
+fn is_expected_managed_path(plugin: &InstalledPluginInfo, path: &Path) -> bool {
+ let Ok(path) = path.canonicalize() else {
+ return false;
+ };
+ let expected = managed_checkout_path(&plugin.plugin_id);
+ let Ok(expected) = expected.canonicalize() else {
+ return false;
+ };
+ path == expected
+}
+
+fn current_unix_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_millis() as u64)
+ .unwrap_or(0)
+}
+
+fn is_connection_error(err: &std::io::Error) -> bool {
+ matches!(
+ err.kind(),
+ std::io::ErrorKind::NotFound
+ | std::io::ErrorKind::ConnectionRefused
+ | std::io::ErrorKind::ConnectionAborted
+ | std::io::ErrorKind::ConnectionReset
+ | std::io::ErrorKind::BrokenPipe
+ )
+}
+
+fn print_plugin_response(method: Method) -> std::io::Result<i32> {
+ super::print_response(&super::send_request(&Request {
+ id: "cli:plugin".into(),
+ method,
+ })?)
+}
+
+fn print_plugin_help() {
+ eprintln!("herdr plugin commands:");
+ eprintln!(" herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes]");
+ eprintln!(" herdr plugin uninstall <plugin_id|owner/repo[/subdir...]>");
+ eprintln!(" herdr plugin link <path> [--disabled]");
+ eprintln!(" herdr plugin list [--plugin ID] [--json]");
+ eprintln!(" herdr plugin unlink <plugin_id>");
+ eprintln!(" herdr plugin enable <plugin_id>");
+ eprintln!(" herdr plugin disable <plugin_id>");
+ eprintln!(" herdr plugin action <list|invoke>");
+ eprintln!(" herdr plugin log list [--plugin ID] [--limit N]");
+ eprintln!(" herdr plugin pane <open|focus|close>");
+}
+
+fn print_plugin_action_help() {
+ eprintln!("herdr plugin action commands:");
+ eprintln!(" herdr plugin action list [--plugin ID]");
+ eprintln!(" herdr plugin action invoke <action_id> [--plugin ID]");
+}
+
+fn print_plugin_pane_help() {
+ eprintln!("herdr plugin pane commands:");
+ eprintln!(" herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|split|tab|zoomed] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus]");
+ eprintln!(" herdr plugin pane focus <pane_id>");
+ eprintln!(" herdr plugin pane close <pane_id>");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn github_plugin(
+ id: &str,
+ owner: &str,
+ repo: &str,
+ subdir: Option<&str>,
+ ) -> InstalledPluginInfo {
+ InstalledPluginInfo {
+ plugin_id: id.to_string(),
+ name: "Test Plugin".to_string(),
+ version: "0.1.0".to_string(),
+ description: None,
+ manifest_path: format!("/tmp/{id}/herdr-plugin.toml"),
+ plugin_root: format!("/tmp/{id}"),
+ enabled: true,
+ platforms: None,
+ build: vec![],
+ actions: vec![],
+ events: vec![],
+ panes: vec![],
+ link_handlers: vec![],
+ source: PluginSourceInfo {
+ kind: PluginSourceKind::Github,
+ owner: Some(owner.to_string()),
+ repo: Some(repo.to_string()),
+ subdir: subdir.map(str::to_string),
+ requested_ref: None,
+ resolved_commit: Some("abc123".to_string()),
+ managed_path: Some(format!("/tmp/herdr/plugins/{id}")),
+ installed_unix_ms: Some(42),
+ },
+ warnings: vec![],
+ }
+ }
+
+ #[test]
+ fn github_plugin_source_parses_root_repo() {
+ let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap();
+ assert_eq!(source.owner, "ogulcancelik");
+ assert_eq!(source.repo, "herdr-plugin-examples");
+ assert_eq!(source.subdir, None);
+ assert_eq!(
+ source.remote_url(),
+ "https://github.com/ogulcancelik/herdr-plugin-examples.git"
+ );
+ }
+
+ #[test]
+ fn github_plugin_source_parses_subdir() {
+ let source =
+ GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples/worktree-bootstrap")
+ .unwrap();
+ assert_eq!(source.owner, "ogulcancelik");
+ assert_eq!(source.repo, "herdr-plugin-examples");
+ assert_eq!(source.subdir.as_deref(), Some("worktree-bootstrap"));
+ }
+
+ #[test]
+ fn github_plugin_source_rejects_non_shorthand_sources() {
+ for source in [
+ "https://github.com/ogulcancelik/herdr-plugin-examples",
+ "git@github.com:ogulcancelik/herdr-plugin-examples.git",
+ "ogulcancelik",
+ "ogulcancelik/herdr-plugin-examples/../bad",
+ "ogulcancelik/herdr-plugin-examples//bad",
+ ] {
+ assert!(
+ GithubPluginSource::parse(source).is_err(),
+ "{source} should be rejected"
+ );
+ }
+ }
+
+ #[test]
+ fn github_source_lookup_matches_installed_plugin_source() {
+ let source =
+ GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples/agent-telegram-notify")
+ .unwrap();
+ let plugins = vec![
+ github_plugin(
+ "examples.github-link-preview",
+ "ogulcancelik",
+ "herdr-plugin-examples",
+ Some("github-link-preview"),
+ ),
+ github_plugin(
+ "examples.agent-telegram-notify",
+ "ogulcancelik",
+ "herdr-plugin-examples",
+ Some("agent-telegram-notify"),
+ ),
+ ];
+
+ let plugin = plugin_by_github_source(plugins, &source).unwrap();
+ assert_eq!(plugin.plugin_id, "examples.agent-telegram-notify");
+ }
+
+ #[test]
+ fn github_source_lookup_requires_exact_subdir() {
+ let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap();
+ let plugins = vec![github_plugin(
+ "examples.agent-telegram-notify",
+ "ogulcancelik",
+ "herdr-plugin-examples",
+ Some("agent-telegram-notify"),
+ )];
+
+ assert!(plugin_by_github_source(plugins, &source).is_none());
+ }
+
+ #[test]
+ fn github_source_lookup_ignores_local_plugins() {
+ let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap();
+ let mut plugin = github_plugin(
+ "examples.local",
+ "ogulcancelik",
+ "herdr-plugin-examples",
+ None,
+ );
+ plugin.source = PluginSourceInfo::default();
+
+ assert!(plugin_by_github_source([plugin], &source).is_none());
+ }
+}
diff --git a/src/cli/tab.rs b/src/cli/tab.rs
index 3fcc0647..955a1ba1 100644
--- a/src/cli/tab.rs
+++ b/src/cli/tab.rs
@@ -1,3 +1,5 @@
+use std::collections::HashMap;
+
use crate::api::schema::{
Method, Request, TabCreateParams, TabListParams, TabRenameParams, TabTarget,
};
@@ -58,6 +60,7 @@ fn tab_create(args: &[String]) -> std::io::Result<i32> {
let mut cwd = None;
let mut focus = false;
let mut label = None;
+ let mut env = HashMap::new();
let mut index = 0;
while index < args.len() {
@@ -94,6 +97,21 @@ fn tab_create(args: &[String]) -> std::io::Result<i32> {
focus = false;
index += 1;
}
+ "--env" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --env");
+ return Ok(2);
+ };
+ let (key, value) = match super::parse_env_assignment(value) {
+ Ok(pair) => pair,
+ Err(err) => {
+ eprintln!("{err}");
+ return Ok(2);
+ }
+ };
+ env.insert(key, value);
+ index += 2;
+ }
other => {
eprintln!("unknown option: {other}");
return Ok(2);
@@ -108,6 +126,7 @@ fn tab_create(args: &[String]) -> std::io::Result<i32> {
cwd,
focus,
label,
+ env,
}),
})?)
}
@@ -185,7 +204,7 @@ fn print_tab_help() {
eprintln!("herdr tab commands:");
eprintln!(" herdr tab list [--workspace <workspace_id>]");
eprintln!(
- " herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--focus] [--no-focus]"
+ " herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus]"
);
eprintln!(" herdr tab get <tab_id>");
eprintln!(" herdr tab focus <tab_id>");
diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs
index ae29792c..8149d55d 100644
--- a/src/cli/workspace.rs
+++ b/src/cli/workspace.rs
@@ -1,3 +1,5 @@
+use std::collections::HashMap;
+
use crate::api::schema::{
EmptyParams, Method, Request, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget,
};
@@ -42,6 +44,7 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> {
let mut cwd = None;
let mut focus = false;
let mut label = None;
+ let mut env = HashMap::new();
let mut index = 0;
while index < args.len() {
@@ -70,6 +73,21 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> {
focus = false;
index += 1;
}
+ "--env" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --env");
+ return Ok(2);
+ };
+ let (key, value) = match super::parse_env_assignment(value) {
+ Ok(pair) => pair,
+ Err(err) => {
+ eprintln!("{err}");
+ return Ok(2);
+ }
+ };
+ env.insert(key, value);
+ index += 2;
+ }
other => {
eprintln!("unknown option: {other}");
return Ok(2);
@@ -79,7 +97,12 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> {
super::print_response(&super::send_request(&Request {
id: "cli:workspace:create".into(),
- method: Method::WorkspaceCreate(WorkspaceCreateParams { cwd, focus, label }),
+ method: Method::WorkspaceCreate(WorkspaceCreateParams {
+ cwd,
+ focus,
+ label,
+ env,
+ }),
})?)
}
@@ -155,7 +178,7 @@ fn workspace_close(args: &[String]) -> std::io::Result<i32> {
fn print_workspace_help() {
eprintln!("herdr workspace commands:");
eprintln!(" herdr workspace list");
- eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--focus] [--no-focus]");
+ eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus]");
eprintln!(" herdr workspace get <workspace_id>");
eprintln!(" herdr workspace focus <workspace_id>");
eprintln!(" herdr workspace rename <workspace_id> <label>");
diff --git a/src/client/mod.rs b/src/client/mod.rs
index e4daf82d..b7fd212d 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -984,6 +984,10 @@ async fn run_client_loop(
forward_clipboard(&data);
let _ = io::stdout().flush();
}
+ ServerMessage::WindowTitle { title } => {
+ write_window_title(title.as_deref());
+ let _ = io::stdout().flush();
+ }
ServerMessage::ReloadSoundConfig => {
reload_local_client_config(
&mut state.sound_config,
@@ -1208,6 +1212,19 @@ fn forward_clipboard(data: &str) {
crate::selection::write_osc52_bytes(&bytes);
}
+fn window_title_osc(title: Option<&str>) -> Vec<u8> {
+ let title = title.unwrap_or("herdr");
+ let safe_title = title
+ .chars()
+ .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}'))
+ .collect::<String>();
+ format!("\x1b]0;{safe_title}\x07").into_bytes()
+}
+
+fn write_window_title(title: Option<&str>) {
+ let _ = io::stdout().write_all(&window_title_osc(title));
+}
+
// ---------------------------------------------------------------------------
// Frame output
// ---------------------------------------------------------------------------
@@ -1905,4 +1922,13 @@ mod tests {
std::env::remove_var("SSH_CONNECTION");
}
}
+
+ #[test]
+ fn window_title_osc_strips_terminators_and_defaults_to_herdr() {
+ assert_eq!(
+ window_title_osc(Some("herdr\x1b api\u{7}\u{9c}")),
+ b"\x1b]0;herdr api\x07"
+ );
+ assert_eq!(window_title_osc(None), b"\x1b]0;herdr\x07");
+ }
}
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index cc054995..92f13bf8 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -51,6 +51,7 @@ pub enum CommandKeybindType {
#[default]
Shell,
Pane,
+ PluginAction,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -82,6 +83,7 @@ impl Default for CommandKeybindConfig {
pub enum CustomCommandAction {
Shell,
Pane,
+ PluginAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -541,6 +543,7 @@ impl Config {
let action = match command.action_type {
CommandKeybindType::Shell => CustomCommandAction::Shell,
CommandKeybindType::Pane => CustomCommandAction::Pane,
+ CommandKeybindType::PluginAction => CustomCommandAction::PluginAction,
};
let label = bindings.label().unwrap_or_else(|| "unset".to_string());
keybinds.custom_commands.push(CustomCommandKeybind {
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs
index fd2ef15b..8811777b 100644
--- a/src/detect/manifest/tests.rs
+++ b/src/detect/manifest/tests.rs
@@ -156,7 +156,7 @@ line_regex = ["^exact line$"]
#[test]
fn remote_manifest_loads_between_local_override_and_bundled() {
with_manifest_dirs("remote-source", || {
- write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "remote-ready"));
let explain = explain(Agent::Codex, "remote-ready");
@@ -165,10 +165,10 @@ fn remote_manifest_loads_between_local_override_and_bundled() {
explain.source,
Some(ManifestSource::Remote { .. })
));
- assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3"));
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.5"));
assert_eq!(
explain.cached_remote_version.as_deref(),
- Some("2026.06.10.3")
+ Some("2026.06.10.5")
);
});
}
@@ -176,7 +176,7 @@ fn remote_manifest_loads_between_local_override_and_bundled() {
#[test]
fn fallback_explain_preserves_active_manifest_version() {
with_manifest_dirs("fallback-version", || {
- write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "remote-ready"));
let explain = explain(Agent::Codex, "ordinary prompt text");
@@ -185,7 +185,7 @@ fn fallback_explain_preserves_active_manifest_version() {
explain.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
);
- assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3"));
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.5"));
assert!(matches!(
explain.source,
Some(ManifestSource::Remote { .. })
@@ -216,7 +216,7 @@ fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() {
#[test]
fn local_override_shadows_cached_remote_manifest() {
with_manifest_dirs("local-shadows-remote", || {
- write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "remote-ready"));
write_local_codex(&local_manifest("idle", "local-ready"));
let explain = explain(Agent::Codex, "local-ready");
@@ -226,7 +226,7 @@ fn local_override_shadows_cached_remote_manifest() {
assert!(explain.local_override_shadowing_remote);
assert_eq!(
explain.cached_remote_version.as_deref(),
- Some("2026.06.10.3")
+ Some("2026.06.10.5")
);
});
}
@@ -234,7 +234,7 @@ fn local_override_shadows_cached_remote_manifest() {
#[test]
fn invalid_local_override_falls_back_to_cached_remote_manifest() {
with_manifest_dirs("invalid-local-remote-fallback", || {
- write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "remote-ready"));
write_local_codex("id = ");
let explain = explain(Agent::Codex, "remote-ready");
@@ -251,7 +251,7 @@ fn invalid_local_override_falls_back_to_cached_remote_manifest() {
#[test]
fn detection_uses_cached_manifest_until_explicit_reload() {
with_manifest_dirs("cache-boundary", || {
- write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "cached-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.5", "blocked", "cached-ready"));
let cached = explain(Agent::Codex, "cached-ready");
assert_eq!(cached.state, AgentState::Blocked);
@@ -261,7 +261,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
Some("test")
);
- write_remote_codex_without_reload(&remote_manifest("2026.06.10.4", "working", "new-ready"));
+ write_remote_codex_without_reload(&remote_manifest("2026.06.10.6", "working", "new-ready"));
let unchanged = explain(Agent::Codex, "new-ready");
assert_eq!(unchanged.state, AgentState::Idle);
@@ -271,7 +271,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
);
assert_eq!(
unchanged.cached_remote_version.as_deref(),
- Some("2026.06.10.3")
+ Some("2026.06.10.5")
);
reload_manifests();
@@ -280,7 +280,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
assert_eq!(reloaded.state, AgentState::Working);
assert_eq!(
reloaded.cached_remote_version.as_deref(),
- Some("2026.06.10.4")
+ Some("2026.06.10.6")
);
assert_eq!(
reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()),
diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs
index f82b4864..a1537fab 100644
--- a/src/detect/manifest_update.rs
+++ b/src/detect/manifest_update.rs
@@ -614,7 +614,7 @@ contains = ["{contains}"]
#[test]
fn process_agent_manifest_commits_newer_manifest_atomically() {
with_state_dir("commit-newer", || {
- let content = remote_manifest("2026.06.10.3", "ready");
+ let content = remote_manifest("2026.06.10.5", "ready");
let commit = process_agent_manifest(Agent::Codex, &content, 1)
.unwrap()
.unwrap();
@@ -622,7 +622,7 @@ contains = ["{contains}"]
assert_eq!(commit.agent, Agent::Codex);
assert_eq!(
commit.version,
- ManifestVersion::parse("2026.06.10.3").unwrap()
+ ManifestVersion::parse("2026.06.10.5").unwrap()
);
assert_eq!(
fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
@@ -652,7 +652,7 @@ path = "codex.toml"
.unwrap();
fs::write(
web_dir.join("codex.toml"),
- remote_manifest("2026.06.10.3", "auto-update-ready"),
+ remote_manifest("2026.06.10.5", "auto-update-ready"),
)
.unwrap();
std::env::set_var(
@@ -693,10 +693,10 @@ path = "codex.toml"
#[test]
fn process_agent_manifest_rejects_downgrade_and_keeps_cached_manifest() {
with_state_dir("reject-downgrade", || {
- let current = remote_manifest("2026.06.10.4", "current");
+ let current = remote_manifest("2026.06.10.5", "current");
process_agent_manifest(Agent::Codex, &current, 1).unwrap();
- let older = remote_manifest("2026.06.10.3", "older");
+ let older = remote_manifest("2026.06.10.4", "older");
assert!(process_agent_manifest(Agent::Codex, &older, 2).is_err());
assert_eq!(
fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
@@ -708,10 +708,10 @@ path = "codex.toml"
#[test]
fn process_agent_manifest_rejects_equal_version_content_change() {
with_state_dir("reject-equal-change", || {
- let current = remote_manifest("2026.06.10.3", "current");
+ let current = remote_manifest("2026.06.10.5", "current");
process_agent_manifest(Agent::Codex, &current, 1).unwrap();
- let changed = remote_manifest("2026.06.10.3", "changed");
+ let changed = remote_manifest("2026.06.10.5", "changed");
assert!(process_agent_manifest(Agent::Codex, &changed, 2).is_err());
assert_eq!(
fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
@@ -723,7 +723,7 @@ path = "codex.toml"
#[test]
fn process_agent_manifest_skips_same_version_same_content() {
with_state_dir("skip-same", || {
- let current = remote_manifest("2026.06.10.3", "current");
+ let current = remote_manifest("2026.06.10.5", "current");
process_agent_manifest(Agent::Codex, &current, 1).unwrap();
let result = process_agent_manifest(Agent::Codex, &current, 2).unwrap();
diff --git a/src/events.rs b/src/events.rs
index 07660c71..6185b8a7 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -111,6 +111,15 @@ pub enum AppEvent {
results: Vec<WorkspaceGitStatus>,
cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
},
+ /// A plugin action or event command finished.
+ PluginCommandFinished {
+ log_id: String,
+ finished_unix_ms: u64,
+ exit_code: Option<i32>,
+ stdout: String,
+ stderr: String,
+ error: Option<String>,
+ },
/// Background `git worktree add` completed.
WorktreeAddFinished(WorktreeAddResult),
/// Background `git worktree remove` completed.
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 5d6b8e7e..d483e503 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -7,9 +7,9 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
use portable_pty::CommandBuilder;
use serde_json::{json, Map, Value};
-use crate::layout::PaneId;
-
pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID";
+pub(crate) const HERDR_TAB_ID_ENV_VAR: &str = "HERDR_TAB_ID";
+pub(crate) const HERDR_WORKSPACE_ID_ENV_VAR: &str = "HERDR_WORKSPACE_ID";
const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
const PI_INTEGRATION_VERSION: u32 = 2;
@@ -360,18 +360,8 @@ pub(crate) struct HermesUninstallResult {
pub updated_config: bool,
}
-pub(crate) fn apply_pane_env(
- cmd: &mut CommandBuilder,
- pane_id: PaneId,
- public_pane_id: Option<&str>,
-) {
+pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) {
cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path());
- cmd.env(
- HERDR_PANE_ID_ENV_VAR,
- public_pane_id
- .map(str::to_string)
- .unwrap_or_else(|| format!("p_{}", pane_id.raw())),
- );
}
pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:";
diff --git a/src/pane.rs b/src/pane.rs
index 601334d9..7cb3e12b 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -59,6 +59,58 @@ fn apply_pane_terminal_env(cmd: &mut CommandBuilder) {
cmd.env("COLORTERM", PANE_COLORTERM);
}
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub(crate) struct PaneLaunchEnv {
+ extra: Vec<(String, String)>,
+ identity: Option<PaneLaunchIdentity>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct PaneLaunchIdentity {
+ workspace_id: String,
+ tab_id: String,
+ pane_id: String,
+}
+
+impl PaneLaunchEnv {
+ pub(crate) fn from_extra(extra: Vec<(String, String)>) -> Self {
+ Self {
+ extra,
+ identity: None,
+ }
+ }
+
+ pub(crate) fn with_identity(
+ mut self,
+ workspace_id: String,
+ tab_id: String,
+ pane_id: String,
+ ) -> Self {
+ self.identity = Some(PaneLaunchIdentity {
+ workspace_id,
+ tab_id,
+ pane_id,
+ });
+ self
+ }
+}
+
+fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) {
+ for (key, value) in &launch_env.extra {
+ cmd.env(key, value);
+ }
+ cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
+ crate::integration::apply_pane_base_env(cmd);
+ if let Some(identity) = &launch_env.identity {
+ cmd.env(
+ crate::integration::HERDR_WORKSPACE_ID_ENV_VAR,
+ &identity.workspace_id,
+ );
+ cmd.env(crate::integration::HERDR_TAB_ID_ENV_VAR, &identity.tab_id);
+ cmd.env(crate::integration::HERDR_PANE_ID_ENV_VAR, &identity.pane_id);
+ }
+}
+
#[derive(Debug, Clone, Copy)]
struct PendingAgentRelease {
agent: Agent,
@@ -1320,10 +1372,10 @@ impl PaneRuntime {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: PaneShellConfig<'_>,
+ launch_env: &PaneLaunchEnv,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<Self> {
Self::spawn_with_initial_history(
pane_id,
@@ -1333,11 +1385,11 @@ impl PaneRuntime {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ launch_env,
None,
events,
render_notify,
render_dirty,
- public_pane_id,
)
}
@@ -1351,17 +1403,16 @@ impl PaneRuntime {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: PaneShellConfig<'_>,
+ launch_env: &PaneLaunchEnv,
initial_history_ansi: Option<&str>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<Self> {
let mut cmd = pane_shell_command_builder(shell_config)?;
cmd.cwd(cwd);
- cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
apply_pane_terminal_env(&mut cmd);
- crate::integration::apply_pane_env(&mut cmd, pane_id, public_pane_id);
+ apply_pane_launch_env(&mut cmd, launch_env);
Self::spawn_command_builder(
pane_id,
rows,
@@ -1388,24 +1439,19 @@ impl PaneRuntime {
cols: u16,
cwd: std::path::PathBuf,
command: &str,
- extra_env: &[(String, String)],
+ launch_env: &PaneLaunchEnv,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<Self> {
let mut cmd = CommandBuilder::new("/bin/sh");
cmd.arg("-c");
cmd.arg(command);
cmd.cwd(cwd);
- cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
apply_pane_terminal_env(&mut cmd);
- crate::integration::apply_pane_env(&mut cmd, pane_id, public_pane_id);
- for (key, value) in extra_env {
- cmd.env(key, value);
- }
+ apply_pane_launch_env(&mut cmd, launch_env);
Self::spawn_command_builder(
pane_id,
rows,
@@ -1427,12 +1473,12 @@ impl PaneRuntime {
cols: u16,
cwd: std::path::PathBuf,
argv: &[String],
+ launch_env: &PaneLaunchEnv,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<Self> {
let Some((program, args)) = argv.split_first() else {
return Err(std::io::Error::new(
@@ -1445,9 +1491,8 @@ impl PaneRuntime {
cmd.arg(arg);
}
cmd.cwd(cwd);
- cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
apply_pane_terminal_env(&mut cmd);
- crate::integration::apply_pane_env(&mut cmd, pane_id, public_pane_id);
+ apply_pane_launch_env(&mut cmd, launch_env);
Self::spawn_command_builder(
pane_id,
rows,
@@ -2425,6 +2470,11 @@ impl PaneRuntime {
crate::platform::process_cwd(pid)
}
+ pub fn child_pid(&self) -> Option<u32> {
+ let pid = self.child_pid.load(Ordering::Acquire);
+ (pid > 0).then_some(pid)
+ }
+
/// Get the current working directory of the process group controlling the pane PTY.
pub fn foreground_cwd(&self) -> Option<std::path::PathBuf> {
#[cfg(unix)]
diff --git a/src/persist.rs b/src/persist.rs
index aff76bfe..822af263 100644
--- a/src/persist.rs
+++ b/src/persist.rs
@@ -2,8 +2,10 @@
//!
//! Stored at `~/.config/herdr/session.json`.
//! Optional pane screen history is stored separately at `session-history.json`.
+//! Installed plugins are persisted separately at `plugins.json`.
mod io;
+pub mod plugin_registry;
mod restore;
mod snapshot;
diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs
new file mode 100644
index 00000000..19db842a
--- /dev/null
+++ b/src/persist/plugin_registry.rs
@@ -0,0 +1,237 @@
+use std::path::{Path, PathBuf};
+
+use tracing::warn;
+
+use crate::api::schema::InstalledPluginInfo;
+
+pub const MANIFEST_UNAVAILABLE_WARNING_PREFIX: &str = "manifest unavailable: ";
+
+fn registry_path() -> PathBuf {
+ crate::session::data_dir().join("plugins.json")
+}
+
+fn save_json_to_path<T: serde::Serialize + ?Sized>(path: &Path, value: &T) -> std::io::Result<()> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let json = serde_json::to_string_pretty(value)?;
+ let tmp_path = path.with_extension("json.tmp");
+ std::fs::write(&tmp_path, &json)?;
+ if let Err(err) = std::fs::rename(&tmp_path, path) {
+ let _ = std::fs::remove_file(&tmp_path);
+ return Err(err);
+ }
+ Ok(())
+}
+
+/// Atomically write `plugins.json` next to `session.json`.
+pub fn save(plugins: &[InstalledPluginInfo]) -> std::io::Result<()> {
+ let path = registry_path();
+ save_to_path(&path, plugins)
+}
+
+pub fn save_to_path(path: &Path, plugins: &[InstalledPluginInfo]) -> std::io::Result<()> {
+ save_json_to_path(path, plugins)
+}
+
+/// Load `plugins.json`. Returns an empty vec on any failure so a corrupt or
+/// missing file never blocks server startup.
+pub fn load() -> Vec<InstalledPluginInfo> {
+ load_from_path(&registry_path())
+}
+
+pub fn load_from_path(path: &Path) -> Vec<InstalledPluginInfo> {
+ if !path.exists() {
+ return Vec::new();
+ }
+ let content = match std::fs::read_to_string(path) {
+ Ok(c) => c,
+ Err(err) => {
+ warn!(path = %path.display(), err = %err, "failed to read plugin registry");
+ return Vec::new();
+ }
+ };
+ match serde_json::from_str::<Vec<InstalledPluginInfo>>(&content) {
+ Ok(entries) => entries,
+ Err(err) => {
+ warn!(path = %path.display(), err = %err, "failed to parse plugin registry, starting with empty registry");
+ Vec::new()
+ }
+ }
+}
+
+/// Re-read each entry's manifest from disk using the provided reload function.
+///
+/// If the manifest parses successfully, replace cached fields but keep the
+/// stored `enabled` flag. If the file is gone or unparseable, keep the stored
+/// entry and append a warning so `plugin.list` surfaces it.
+pub fn reload_manifests(
+ mut entries: Vec<InstalledPluginInfo>,
+ reload_fn: impl Fn(&str, bool) -> Result<InstalledPluginInfo, String>,
+) -> Vec<InstalledPluginInfo> {
+ for entry in &mut entries {
+ entry.warnings.clear();
+ match reload_fn(&entry.manifest_path, entry.enabled) {
+ Ok(mut fresh) => {
+ fresh.enabled = entry.enabled;
+ fresh.source = entry.source.clone();
+ *entry = fresh;
+ }
+ Err(warn_msg) => {
+ entry
+ .warnings
+ .push(format!("{MANIFEST_UNAVAILABLE_WARNING_PREFIX}{warn_msg}"));
+ }
+ }
+ }
+ entries
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn temp_registry_path(name: &str) -> PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir()
+ .join(format!(
+ "herdr-registry-{name}-{}-{nanos}",
+ std::process::id()
+ ))
+ .join("plugins.json")
+ }
+
+ fn sample_plugin(id: &str) -> InstalledPluginInfo {
+ InstalledPluginInfo {
+ plugin_id: id.to_string(),
+ name: "Test Plugin".to_string(),
+ version: "0.1.0".to_string(),
+ description: None,
+ manifest_path: format!("/tmp/{id}/herdr-plugin.toml"),
+ plugin_root: format!("/tmp/{id}"),
+ enabled: true,
+ platforms: None,
+ build: vec![],
+ actions: vec![],
+ events: vec![],
+ panes: vec![],
+ link_handlers: vec![],
+ source: Default::default(),
+ warnings: vec![],
+ }
+ }
+
+ #[test]
+ fn save_and_load_roundtrip() {
+ let path = temp_registry_path("roundtrip");
+ let plugins = vec![sample_plugin("example.a"), sample_plugin("example.b")];
+
+ save_to_path(&path, &plugins).unwrap();
+
+ let loaded = load_from_path(&path);
+ assert_eq!(loaded.len(), 2);
+ let ids: Vec<_> = loaded.iter().map(|p| p.plugin_id.as_str()).collect();
+ assert!(ids.contains(&"example.a"));
+ assert!(ids.contains(&"example.b"));
+ }
+
+ #[test]
+ fn missing_file_returns_empty() {
+ let path = temp_registry_path("missing");
+ let loaded = load_from_path(&path);
+ assert!(loaded.is_empty());
+ }
+
+ #[test]
+ fn corrupt_file_returns_empty_without_panic() {
+ let path = temp_registry_path("corrupt");
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).unwrap();
+ }
+ std::fs::write(&path, b"this is not valid json {{{{").unwrap();
+
+ let loaded = load_from_path(&path);
+ assert!(loaded.is_empty());
+ }
+
+ #[test]
+ fn reload_manifests_keeps_entry_with_warning_on_missing_manifest() {
+ let entry = sample_plugin("example.missing");
+ let entries = vec![entry];
+
+ let result = reload_manifests(entries, |path, _enabled| {
+ Err(format!("manifest not found at {path}"))
+ });
+
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].plugin_id, "example.missing");
+ assert!(!result[0].warnings.is_empty());
+ assert!(result[0].warnings[0].contains("manifest not found"));
+ }
+
+ #[test]
+ fn reload_manifests_uses_fresh_parse_and_keeps_enabled_flag() {
+ let mut entry = sample_plugin("example.reload");
+ entry.enabled = false;
+ entry.source = crate::api::schema::PluginSourceInfo {
+ kind: crate::api::schema::PluginSourceKind::Github,
+ owner: Some("ogulcancelik".into()),
+ repo: Some("herdr-plugin-examples".into()),
+ subdir: Some("worktree-bootstrap".into()),
+ requested_ref: Some("main".into()),
+ resolved_commit: Some("abc123".into()),
+ managed_path: Some("/tmp/herdr/plugins/github/example.reload".into()),
+ installed_unix_ms: Some(42),
+ };
+
+ let result = reload_manifests(vec![entry], |_path, _enabled| {
+ Ok(InstalledPluginInfo {
+ plugin_id: "example.reload".to_string(),
+ name: "Fresh Name".to_string(),
+ version: "0.2.0".to_string(),
+ description: Some("refreshed".to_string()),
+ manifest_path: "/tmp/example.reload/herdr-plugin.toml".to_string(),
+ plugin_root: "/tmp/example.reload".to_string(),
+ enabled: true, // caller would pass stored enabled; fresh parse returns true
+ platforms: None,
+ build: vec![],
+ actions: vec![],
+ events: vec![],
+ panes: vec![],
+ link_handlers: vec![],
+ source: Default::default(),
+ warnings: vec![],
+ })
+ });
+
+ assert_eq!(result[0].name, "Fresh Name");
+ assert_eq!(result[0].version, "0.2.0");
+ // enabled preserved from stored entry
+ assert!(!result[0].enabled);
+ assert_eq!(
+ result[0].source.kind,
+ crate::api::schema::PluginSourceKind::Github
+ );
+ assert_eq!(result[0].source.owner.as_deref(), Some("ogulcancelik"));
+ assert!(result[0].warnings.is_empty());
+ }
+
+ #[test]
+ fn atomic_write_temp_file_is_cleaned_up_on_rename_failure() {
+ // Write to a path whose parent does not yet exist, then verify the
+ // tmp file is removed when the write fails mid-way. Here we just
+ // confirm a successful write leaves no .tmp file behind.
+ let path = temp_registry_path("cleanup");
+ save_to_path(&path, &[sample_plugin("example.cleanup")]).unwrap();
+
+ let tmp = path.with_extension("json.tmp");
+ assert!(
+ !tmp.exists(),
+ "tmp file should be cleaned up after successful rename"
+ );
+ assert!(path.exists());
+ }
+}
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index ca1748e0..229d565d 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -10,7 +10,7 @@ use tracing::{error, warn};
use crate::detect::AgentState;
use crate::events::AppEvent;
use crate::layout::{Node, PaneId, TileLayout};
-use crate::pane::PaneState;
+use crate::pane::{PaneLaunchEnv, PaneState};
use crate::terminal::{TerminalId, TerminalRuntime, TerminalState};
use crate::workspace::Workspace;
@@ -354,10 +354,12 @@ fn restore_workspace(
let mut failed_imports = 0;
for (idx, tab_snap) in snap.tabs.iter().enumerate() {
+ let tab_number = snap.public_tab_numbers.get(idx).copied().unwrap_or(idx + 1);
let (restored_tab, tab_failed_imports) = restore_tab(
tab_snap,
history.and_then(|history| history.tabs.get(idx)),
- idx + 1,
+ tab_number,
+ &workspace_id,
rows,
cols,
runtime_context,
@@ -438,6 +440,7 @@ fn restore_tab(
snap: &TabSnapshot,
history: Option<&TabHistorySnapshot>,
number: usize,
+ workspace_id: &str,
rows: u16,
cols: u16,
runtime_context: &RestoreRuntimeContext<'_>,
@@ -502,6 +505,19 @@ fn restore_tab(
let public_pane_id = old_pane_id
.and_then(|old_id| public_pane_ids_by_old_raw.get(&old_id))
.map(String::as_str);
+ let launch_env = public_pane_id
+ .map(|pane_id| {
+ PaneLaunchEnv::from_extra(Vec::new()).with_identity(
+ workspace_id.to_string(),
+ format!(
+ "{}:t{}",
+ workspace_id,
+ crate::workspace::encode_public_number(number)
+ ),
+ pane_id.to_string(),
+ )
+ })
+ .unwrap_or_default();
let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id));
let was_imported = imported_runtime.is_some();
let pending_native_agent_restore = if was_imported {
@@ -570,11 +586,11 @@ fn restore_tab(
runtime_context.scrollback_limit_bytes,
crate::terminal_theme::TerminalTheme::default(),
runtime_context.shell_config,
+ &launch_env,
startup.initial_history_ansi,
runtime_context.events.clone(),
runtime_context.render_notify.clone(),
runtime_context.render_dirty.clone(),
- public_pane_id,
)
}
@@ -588,11 +604,11 @@ fn restore_tab(
runtime_context.scrollback_limit_bytes,
crate::terminal_theme::TerminalTheme::default(),
runtime_context.shell_config,
+ &launch_env,
startup.initial_history_ansi,
runtime_context.events.clone(),
runtime_context.render_notify.clone(),
runtime_context.render_dirty.clone(),
- public_pane_id,
)
}
};
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 03492547..79c051b7 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -628,6 +628,12 @@ pub enum ServerMessage {
data: String,
},
+ /// Set the foreground client's outer terminal window title.
+ WindowTitle {
+ /// Sanitized title to write with OSC 0. `None` restores Herdr's default title.
+ title: Option<String>,
+ },
+
/// Client-local runtime config changed on disk; refresh it without reconnecting.
ReloadSoundConfig,
@@ -929,7 +935,7 @@ mod tests {
}
#[test]
- fn client_message_wire_tags_preserve_protocol_13_order() {
+ fn client_message_wire_tags_preserve_protocol_14_order() {
fn tag(msg: &ClientMessage) -> u8 {
*bincode::serde::encode_to_vec(msg, bincode::config::standard())
.unwrap()
@@ -1279,6 +1285,17 @@ mod tests {
}
#[test]
+ fn server_window_title_roundtrip() {
+ for title in [Some("herdr api".to_owned()), None] {
+ let msg = ServerMessage::WindowTitle { title };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+ }
+
+ #[test]
fn server_graphics_roundtrip() {
let msg = ServerMessage::Graphics {
bytes: b"\x1b_Ga=d,d=A,q=2;\x1b\\".to_vec(),
diff --git a/src/server/headless.rs b/src/server/headless.rs
index ed81b015..750e9250 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -100,7 +100,7 @@ fn non_empty_body(value: &str) -> Option<String> {
enum LoopEvent {
Timer,
Internal(AppEvent),
- Api(api::ApiRequestMessage),
+ Api(Box<api::ApiRequestMessage>),
ServerEvent(ServerEvent),
RenderRequested,
}
@@ -637,7 +637,7 @@ impl HeadlessServer {
let event = {
tokio::select! {
maybe_api = self.app.api_rx.recv() => match maybe_api {
- Some(msg) => LoopEvent::Api(msg),
+ Some(msg) => LoopEvent::Api(Box::new(msg)),
None => LoopEvent::Timer,
},
maybe_ev = self.app.event_rx.recv() => match maybe_ev {
@@ -662,7 +662,7 @@ impl HeadlessServer {
}
}
LoopEvent::Api(msg) => {
- if self.handle_api_request_with_shutdown_check(msg) {
+ if self.handle_api_request_with_shutdown_check(*msg) {
needs_render = true;
needs_full_render = true;
}
@@ -1603,6 +1603,39 @@ impl HeadlessServer {
.unwrap_or_else(|_| "{}".to_string())
}
+ fn handle_client_window_title_api(&mut self, id: String, title: Option<String>) -> String {
+ use api::schema::{ClientWindowTitleReason, ResponseResult};
+
+ let title = match title {
+ Some(title) => match sanitize_window_title_text(&title, 200) {
+ Some(title) => Some(title),
+ None => {
+ return serde_json::to_string(&api::schema::ErrorResponse {
+ id,
+ error: api::schema::ErrorBody {
+ code: "invalid_params".into(),
+ message: "window title is empty".into(),
+ },
+ })
+ .unwrap_or_else(|_| "{}".to_string());
+ }
+ },
+ None => None,
+ };
+ let set_title = title.is_some();
+ let changed = self.send_to_foreground_client(ServerMessage::WindowTitle { title });
+ let reason = match (changed, set_title) {
+ (true, true) => ClientWindowTitleReason::Set,
+ (true, false) => ClientWindowTitleReason::Cleared,
+ (false, _) => ClientWindowTitleReason::NoForegroundClient,
+ };
+ serde_json::to_string(&api::schema::SuccessResponse {
+ id,
+ result: ResponseResult::ClientWindowTitle { changed, reason },
+ })
+ .unwrap_or_else(|_| "{}".to_string())
+ }
+
fn forward_api_notification_sound(&mut self, sound: api::schema::NotificationShowSound) {
let Some(sound) = sound.to_sound() else {
return;
@@ -2511,6 +2544,23 @@ impl HeadlessServer {
return true;
}
+ match &msg.request.method {
+ api::schema::Method::ClientWindowTitleSet(params) => {
+ let response = self.handle_client_window_title_api(
+ msg.request.id.clone(),
+ Some(params.title.clone()),
+ );
+ let _ = msg.respond_to.send(response);
+ return true;
+ }
+ api::schema::Method::ClientWindowTitleClear(_) => {
+ let response = self.handle_client_window_title_api(msg.request.id.clone(), None);
+ let _ = msg.respond_to.send(response);
+ return true;
+ }
+ _ => {}
+ }
+
let mut changed = api::request_changes_ui(&msg.request);
let skip_default_workspace = matches!(
&msg.request.method,
@@ -3544,6 +3594,17 @@ fn sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> {
(!sanitized.is_empty()).then_some(sanitized)
}
+fn sanitize_window_title_text(value: &str, max_chars: usize) -> Option<String> {
+ let sanitized = value
+ .chars()
+ .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}') && !ch.is_control())
+ .take(max_chars)
+ .collect::<String>()
+ .trim()
+ .to_string();
+ (!sanitized.is_empty()).then_some(sanitized)
+}
+
fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) {
let without_keybindings = diagnostics
.iter()
diff --git a/src/server/notifications.rs b/src/server/notifications.rs
index 63bfe944..79ff63ca 100644
--- a/src/server/notifications.rs
+++ b/src/server/notifications.rs
@@ -121,10 +121,10 @@ mod tests {
0,
crate::terminal_theme::TerminalTheme::default(),
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
+ &crate::pane::PaneLaunchEnv::default(),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
- None,
)
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index c4bf91e1..5e4f778e 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -85,10 +85,10 @@ impl TerminalRuntime {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ launch_env: &crate::pane::PaneLaunchEnv,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<Self> {
crate::pane::PaneRuntime::spawn(
pane_id,
@@ -98,10 +98,10 @@ impl TerminalRuntime {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ launch_env,
events,
render_notify,
render_dirty,
- public_pane_id,
)
.map(Self)
}
@@ -116,11 +116,11 @@ impl TerminalRuntime {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ launch_env: &crate::pane::PaneLaunchEnv,
initial_history_ansi: Option<&str>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<Self> {
crate::pane::PaneRuntime::spawn_with_initial_history(
pane_id,
@@ -130,11 +130,11 @@ impl TerminalRuntime {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ launch_env,
initial_history_ansi,
events,
render_notify,
render_dirty,
- public_pane_id,
)
.map(Self)
}
@@ -147,13 +147,12 @@ impl TerminalRuntime {
cols: u16,
cwd: std::path::PathBuf,
command: &str,
- extra_env: &[(String, String)],
+ launch_env: &crate::pane::PaneLaunchEnv,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<Self> {
crate::pane::PaneRuntime::spawn_shell_command(
pane_id,
@@ -161,13 +160,12 @@ impl TerminalRuntime {
cols,
cwd,
command,
- extra_env,
+ launch_env,
scrollback_limit_bytes,
host_terminal_theme,
events,
render_notify,
render_dirty,
- public_pane_id,
)
.map(Self)
}
@@ -178,12 +176,12 @@ impl TerminalRuntime {
cols: u16,
cwd: std::path::PathBuf,
argv: &[String],
+ launch_env: &crate::pane::PaneLaunchEnv,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<Self> {
crate::pane::PaneRuntime::spawn_argv_command(
pane_id,
@@ -191,12 +189,12 @@ impl TerminalRuntime {
cols,
cwd,
argv,
+ launch_env,
scrollback_limit_bytes,
host_terminal_theme,
events,
render_notify,
render_dirty,
- public_pane_id,
)
.map(Self)
}
@@ -412,6 +410,10 @@ impl TerminalRuntime {
self.0.foreground_cwd()
}
+ pub fn child_pid(&self) -> Option<u32> {
+ self.0.child_pid()
+ }
+
pub(crate) fn current_size(&self) -> (u16, u16) {
self.0.current_size()
}
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index 33fd4443..b8f23073 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -1007,10 +1007,10 @@ mod tests {
0,
crate::terminal_theme::TerminalTheme::default(),
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
+ &crate::pane::PaneLaunchEnv::default(),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
- None,
)
.unwrap();
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 293eeb24..0704f4a2 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -1330,10 +1330,10 @@ mod tests {
0,
crate::terminal_theme::TerminalTheme::default(),
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
+ &crate::pane::PaneLaunchEnv::default(),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
- None,
)
.unwrap();
diff --git a/src/workspace.rs b/src/workspace.rs
index b734008f..c4c91785 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -11,7 +11,7 @@ use crate::events::AppEvent;
use crate::layout::PaneId;
#[cfg(test)]
use crate::layout::TileLayout;
-use crate::pane::PaneState;
+use crate::pane::{PaneLaunchEnv, PaneState};
use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState};
mod aggregate;
@@ -26,7 +26,7 @@ pub use self::{
derive_label_from_cwd, git_branch, git_space_metadata, git_status_cache_key,
GitSpaceMetadata, GitStatusCacheEntry,
},
- tab::Tab,
+ tab::{NewPane, Tab},
};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -113,6 +113,10 @@ fn public_pane_id_for_number(workspace_id: &str, pane_number: usize) -> String {
format!("{workspace_id}:p{}", encode_public_number(pane_number))
}
+fn public_tab_id_for_number(workspace_id: &str, tab_number: usize) -> String {
+ format!("{workspace_id}:t{}", encode_public_number(tab_number))
+}
+
pub(crate) fn reserve_workspace_ids(workspaces: &[Workspace]) {
let Some(next) = workspaces
.iter()
@@ -222,6 +226,9 @@ impl Workspace {
}
}
+ // Test modules construct workspaces through the default constructor; production paths
+ // use the env-aware variant so pane identity env is always explicit.
+ #[cfg_attr(not(test), allow(dead_code))]
pub fn new(
initial_cwd: PathBuf,
rows: u16,
@@ -233,6 +240,33 @@ impl Workspace {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
+ Self::new_with_extra_env(
+ initial_cwd,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ shell_config,
+ events,
+ render_notify,
+ render_dirty,
+ Vec::new(),
+ )
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn new_with_extra_env(
+ initial_cwd: PathBuf,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ shell_config: crate::pane::PaneShellConfig<'_>,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ extra_env: Vec<(String, String)>,
+ ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
Self::new_with_tab(
initial_cwd,
rows,
@@ -244,9 +278,12 @@ impl Workspace {
render_notify,
render_dirty,
None,
+ extra_env,
)
}
+ // Kept for tests that do not need launch-env customization.
+ #[allow(dead_code)]
pub fn new_argv_command(
initial_cwd: PathBuf,
rows: u16,
@@ -258,6 +295,33 @@ impl Workspace {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
+ Self::new_argv_command_with_extra_env(
+ initial_cwd,
+ rows,
+ cols,
+ argv,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ Vec::new(),
+ )
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn new_argv_command_with_extra_env(
+ initial_cwd: PathBuf,
+ rows: u16,
+ cols: u16,
+ argv: &[String],
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ extra_env: Vec<(String, String)>,
+ ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
Self::new_with_tab(
initial_cwd,
rows,
@@ -269,6 +333,7 @@ impl Workspace {
render_notify,
render_dirty,
Some(argv),
+ extra_env,
)
}
@@ -284,9 +349,14 @@ impl Workspace {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
argv: Option<&[String]>,
+ extra_env: Vec<(String, String)>,
) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
let id = generate_workspace_id();
- let root_public_pane_id = public_pane_id_for_number(&id, 1);
+ let launch_env = PaneLaunchEnv::from_extra(extra_env).with_identity(
+ id.clone(),
+ public_tab_id_for_number(&id, 1),
+ public_pane_id_for_number(&id, 1),
+ );
let (tab, terminal, runtime) = if let Some(argv) = argv {
Tab::new_argv_command(
1,
@@ -296,10 +366,10 @@ impl Workspace {
argv,
scrollback_limit_bytes,
host_terminal_theme,
+ &launch_env,
events,
render_notify,
render_dirty,
- Some(&root_public_pane_id),
)?
} else {
Tab::new(
@@ -310,10 +380,10 @@ impl Workspace {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ &launch_env,
events,
render_notify,
render_dirty,
- Some(&root_public_pane_id),
)?
};
let mut public_pane_numbers = HashMap::new();
@@ -375,6 +445,7 @@ impl Workspace {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ extra_env: Vec<(String, String)>,
) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> {
self.create_tab_with_runtime(
rows,
@@ -384,6 +455,29 @@ impl Workspace {
host_terminal_theme,
shell_config,
None,
+ extra_env,
+ )
+ }
+
+ pub fn create_tab_argv_command(
+ &mut self,
+ rows: u16,
+ cols: u16,
+ cwd: PathBuf,
+ argv: &[String],
+ extra_env: Vec<(String, String)>,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> {
+ self.create_tab_with_runtime(
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
+ Some(argv),
+ extra_env,
)
}
@@ -396,11 +490,12 @@ impl Workspace {
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
argv: Option<&[String]>,
+ extra_env: Vec<(String, String)>,
) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> {
let number = self.next_public_tab_number;
self.next_public_tab_number += 1;
let pane_number = self.next_public_pane_number;
- let public_pane_id = public_pane_id_for_number(&self.id, pane_number);
+ let launch_env = self.launch_env_for_new_pane(number, pane_number, extra_env);
let events = self
.active_tab()
.map(|tab| tab.events.clone())
@@ -423,10 +518,10 @@ impl Workspace {
argv,
scrollback_limit_bytes,
host_terminal_theme,
+ &launch_env,
events,
render_notify,
render_dirty,
- Some(&public_pane_id),
)?
} else {
Tab::new(
@@ -437,10 +532,10 @@ impl Workspace {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ &launch_env,
events,
render_notify,
render_dirty,
- Some(&public_pane_id),
)?
};
self.register_new_pane_with_number(tab.root_pane, pane_number);
@@ -502,9 +597,14 @@ impl Workspace {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ extra_env: Vec<(String, String)>,
) -> std::io::Result<crate::workspace::tab::NewPane> {
let pane_number = self.next_public_pane_number;
- let public_pane_id = public_pane_id_for_number(&self.id, pane_number);
+ let tab_number = self
+ .active_tab()
+ .map(|tab| tab.number)
+ .expect("workspace must always have at least one tab");
+ let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env);
let new_pane = self
.active_tab_mut()
.expect("workspace must always have at least one tab")
@@ -516,7 +616,7 @@ impl Workspace {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
- Some(&public_pane_id),
+ &launch_env,
)?;
self.register_new_pane_with_number(new_pane.pane_id, pane_number);
Ok(new_pane)
@@ -530,12 +630,16 @@ impl Workspace {
cols: u16,
cwd: Option<PathBuf>,
command: &str,
- extra_env: &[(String, String)],
+ extra_env: Vec<(String, String)>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
) -> std::io::Result<crate::workspace::tab::NewPane> {
let pane_number = self.next_public_pane_number;
- let public_pane_id = public_pane_id_for_number(&self.id, pane_number);
+ let tab_number = self
+ .active_tab()
+ .map(|tab| tab.number)
+ .expect("workspace must always have at least one tab");
+ let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env);
let new_pane = self
.active_tab_mut()
.expect("workspace must always have at least one tab")
@@ -545,10 +649,9 @@ impl Workspace {
cols,
cwd,
command,
- extra_env,
+ &launch_env,
scrollback_limit_bytes,
host_terminal_theme,
- Some(&public_pane_id),
)?;
self.register_new_pane_with_number(new_pane.pane_id, pane_number);
Ok(new_pane)
@@ -564,6 +667,7 @@ impl Workspace {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ extra_env: Vec<(String, String)>,
focus_new_pane: bool,
) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
self.split_pane_with_runtime(
@@ -576,6 +680,7 @@ impl Workspace {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ extra_env,
focus_new_pane,
None,
)
@@ -593,6 +698,7 @@ impl Workspace {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ extra_env: Vec<(String, String)>,
focus_new_pane: bool,
) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
self.split_pane_with_runtime(
@@ -605,6 +711,7 @@ impl Workspace {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ extra_env,
focus_new_pane,
None,
)
@@ -619,6 +726,7 @@ impl Workspace {
cols: u16,
cwd: Option<PathBuf>,
argv: &[String],
+ extra_env: Vec<(String, String)>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
focus_new_pane: bool,
@@ -633,6 +741,38 @@ impl Workspace {
scrollback_limit_bytes,
host_terminal_theme,
crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
+ extra_env,
+ focus_new_pane,
+ Some(argv),
+ )
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn split_pane_argv_command_with_ratio(
+ &mut self,
+ pane_id: PaneId,
+ direction: Direction,
+ ratio: f32,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ argv: &[String],
+ extra_env: Vec<(String, String)>,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ focus_new_pane: bool,
+ ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
+ self.split_pane_with_runtime(
+ pane_id,
+ direction,
+ Some(ratio),
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
+ extra_env,
focus_new_pane,
Some(argv),
)
@@ -650,26 +790,41 @@ impl Workspace {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ extra_env: Vec<(String, String)>,
focus_new_pane: bool,
argv: Option<&[String]>,
) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
let tab_idx = self.find_tab_index_for_pane(pane_id)?;
let pane_number = self.next_public_pane_number;
- let public_pane_id = public_pane_id_for_number(&self.id, pane_number);
+ let tab_number = self.tabs[tab_idx].number;
+ let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env);
let tab = &mut self.tabs[tab_idx];
let previous_focus = tab.layout.focused();
tab.layout.focus_pane(pane_id);
let new_pane = match if let Some(argv) = argv {
- tab.split_focused_argv_command(
- direction,
- rows,
- cols,
- cwd,
- argv,
- scrollback_limit_bytes,
- host_terminal_theme,
- Some(&public_pane_id),
- )
+ match ratio {
+ Some(ratio) => tab.split_focused_argv_command_with_ratio(
+ direction,
+ ratio,
+ rows,
+ cols,
+ cwd,
+ argv,
+ &launch_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ ),
+ None => tab.split_focused_argv_command(
+ direction,
+ rows,
+ cols,
+ cwd,
+ argv,
+ &launch_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ ),
+ }
} else {
match ratio {
Some(ratio) => tab.split_focused_with_ratio(
@@ -681,7 +836,7 @@ impl Workspace {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
- Some(&public_pane_id),
+ &launch_env,
),
None => tab.split_focused(
direction,
@@ -691,7 +846,7 @@ impl Workspace {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
- Some(&public_pane_id),
+ &launch_env,
),
}
} {
@@ -836,6 +991,19 @@ impl Workspace {
self.public_pane_numbers.get(&pane_id).copied()
}
+ fn launch_env_for_new_pane(
+ &self,
+ tab_number: usize,
+ pane_number: usize,
+ extra_env: Vec<(String, String)>,
+ ) -> PaneLaunchEnv {
+ PaneLaunchEnv::from_extra(extra_env).with_identity(
+ self.id.clone(),
+ public_tab_id_for_number(&self.id, tab_number),
+ public_pane_id_for_number(&self.id, pane_number),
+ )
+ }
+
pub fn public_tab_number(&self, tab_idx: usize) -> Option<usize> {
self.tabs.get(tab_idx).map(|tab| tab.number)
}
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
index 0d9e6fdb..480bc329 100644
--- a/src/workspace/tab.rs
+++ b/src/workspace/tab.rs
@@ -8,7 +8,7 @@ use tokio::sync::{mpsc, Notify};
use crate::events::AppEvent;
use crate::layout::{Node, PaneId, TileLayout};
-use crate::pane::PaneState;
+use crate::pane::{PaneLaunchEnv, PaneState};
use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState};
pub(crate) type DetachedPane = (PaneId, TerminalId);
@@ -27,10 +27,11 @@ pub struct NewPane {
enum SplitCommand<'a> {
Shell {
command: &'a str,
- extra_env: &'a [(String, String)],
+ launch_env: &'a PaneLaunchEnv,
},
Argv {
argv: &'a [String],
+ launch_env: &'a PaneLaunchEnv,
},
}
@@ -59,10 +60,10 @@ impl Tab {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ launch_env: &PaneLaunchEnv,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
Self::new_with_runtime(
number,
@@ -72,11 +73,11 @@ impl Tab {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ launch_env,
events,
render_notify,
render_dirty,
None,
- public_pane_id,
)
}
@@ -88,10 +89,10 @@ impl Tab {
argv: &[String],
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ launch_env: &PaneLaunchEnv,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- public_pane_id: Option<&str>,
) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
Self::new_with_runtime(
number,
@@ -101,11 +102,11 @@ impl Tab {
scrollback_limit_bytes,
host_terminal_theme,
crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
+ launch_env,
events,
render_notify,
render_dirty,
Some(argv),
- public_pane_id,
)
}
@@ -118,11 +119,11 @@ impl Tab {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ launch_env: &PaneLaunchEnv,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
argv: Option<&[String]>,
- public_pane_id: Option<&str>,
) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
let (layout, root_id) = TileLayout::new();
let runtime = if let Some(argv) = argv {
@@ -132,12 +133,12 @@ impl Tab {
cols,
initial_cwd.clone(),
argv,
+ launch_env,
scrollback_limit_bytes,
host_terminal_theme,
events.clone(),
render_notify.clone(),
render_dirty.clone(),
- public_pane_id,
)?
} else {
TerminalRuntime::spawn(
@@ -148,10 +149,10 @@ impl Tab {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ launch_env,
events.clone(),
render_notify.clone(),
render_dirty.clone(),
- public_pane_id,
)?
};
@@ -207,7 +208,7 @@ impl Tab {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
- public_pane_id: Option<&str>,
+ launch_env: &PaneLaunchEnv,
) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
@@ -218,8 +219,8 @@ impl Tab {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ launch_env,
None,
- public_pane_id,
)
}
@@ -233,7 +234,7 @@ impl Tab {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
- public_pane_id: Option<&str>,
+ launch_env: &PaneLaunchEnv,
) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
@@ -244,8 +245,8 @@ impl Tab {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ launch_env,
None,
- public_pane_id,
)
}
@@ -256,10 +257,9 @@ impl Tab {
cols: u16,
cwd: Option<PathBuf>,
command: &str,
- extra_env: &[(String, String)],
+ launch_env: &PaneLaunchEnv,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- public_pane_id: Option<&str>,
) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
@@ -270,8 +270,11 @@ impl Tab {
scrollback_limit_bytes,
host_terminal_theme,
crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
- Some(SplitCommand::Shell { command, extra_env }),
- public_pane_id,
+ launch_env,
+ Some(SplitCommand::Shell {
+ command,
+ launch_env,
+ }),
)
}
@@ -282,9 +285,9 @@ impl Tab {
cols: u16,
cwd: Option<PathBuf>,
argv: &[String],
+ launch_env: &PaneLaunchEnv,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- public_pane_id: Option<&str>,
) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
@@ -295,8 +298,34 @@ impl Tab {
scrollback_limit_bytes,
host_terminal_theme,
crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
- Some(SplitCommand::Argv { argv }),
- public_pane_id,
+ launch_env,
+ Some(SplitCommand::Argv { argv, launch_env }),
+ )
+ }
+
+ pub fn split_focused_argv_command_with_ratio(
+ &mut self,
+ direction: Direction,
+ ratio: f32,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ argv: &[String],
+ launch_env: &PaneLaunchEnv,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ ) -> std::io::Result<NewPane> {
+ self.split_focused_with_runtime(
+ direction,
+ Some(ratio),
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
+ launch_env,
+ Some(SplitCommand::Argv { argv, launch_env }),
)
}
@@ -310,8 +339,8 @@ impl Tab {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
shell_config: crate::pane::PaneShellConfig<'_>,
+ launch_env: &PaneLaunchEnv,
command: Option<SplitCommand<'_>>,
- public_pane_id: Option<&str>,
) -> std::io::Result<NewPane> {
let previous_focus = self.layout.focused();
let new_id = match ratio {
@@ -320,40 +349,40 @@ impl Tab {
};
let actual_cwd =
cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- let launch_argv = if let Some(SplitCommand::Argv { argv }) = &command {
+ let launch_argv = if let Some(SplitCommand::Argv { argv, .. }) = &command {
Some((*argv).to_vec())
} else {
None
};
let runtime = match command {
- Some(SplitCommand::Shell { command, extra_env }) => {
- TerminalRuntime::spawn_shell_command(
- new_id,
- rows,
- cols,
- actual_cwd.clone(),
- command,
- extra_env,
- scrollback_limit_bytes,
- host_terminal_theme,
- self.events.clone(),
- self.render_notify.clone(),
- self.render_dirty.clone(),
- public_pane_id,
- )
- }
- Some(SplitCommand::Argv { argv }) => TerminalRuntime::spawn_argv_command(
+ Some(SplitCommand::Shell {
+ command,
+ launch_env,
+ }) => TerminalRuntime::spawn_shell_command(
+ new_id,
+ rows,
+ cols,
+ actual_cwd.clone(),
+ command,
+ launch_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ self.events.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ ),
+ Some(SplitCommand::Argv { argv, launch_env }) => TerminalRuntime::spawn_argv_command(
new_id,
rows,
cols,
actual_cwd.clone(),
argv,
+ launch_env,
scrollback_limit_bytes,
host_terminal_theme,
self.events.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
- public_pane_id,
),
None => TerminalRuntime::spawn(
new_id,
@@ -363,10 +392,10 @@ impl Tab {
scrollback_limit_bytes,
host_terminal_theme,
shell_config,
+ launch_env,
self.events.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
- public_pane_id,
),
};
let runtime = match runtime {