aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 01:41:16 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 01:41:16 +0300
commite4de58b37f58d59d146158239ed218a8accd5c3c (patch)
tree6bb35b8c9aaec969110ff51a58814dd8099617c0
parent86adf88351e17c6a11cac253714b169eac0db233 (diff)
feat: add agent start for server-owned terminals
-rw-r--r--SOCKET_API.md5
-rw-r--r--src/api/mod.rs2
-rw-r--r--src/api/schema.rs22
-rw-r--r--src/app/agents.rs240
-rw-r--r--src/app/api.rs16
-rw-r--r--src/app/creation.rs5
-rw-r--r--src/cli.rs100
-rw-r--r--src/pane.rs40
-rw-r--r--src/workspace.rs204
-rw-r--r--src/workspace/tab.rs142
-rw-r--r--tests/api_ping.rs55
-rw-r--r--tests/cli_wrapper.rs57
12 files changed, 841 insertions, 47 deletions
diff --git a/SOCKET_API.md b/SOCKET_API.md
index fe6aed2d..b21fba6f 100644
--- a/SOCKET_API.md
+++ b/SOCKET_API.md
@@ -231,6 +231,7 @@ for backward compatibility, requests also accept the older positional forms like
| `agent.send` | send literal text to one terminal-backed agent | `ok` |
| `agent.rename` | set or clear the unique agent/terminal name | `agent_info` |
| `agent.focus` | show the terminal-backed agent in the TUI | `agent_info` |
+| `agent.start` | start a named terminal-backed agent from argv | `agent_started` |
| `pane.list` | list panes, optionally filtered by workspace | `pane_list` |
| `pane.get` | inspect one pane | `pane_info` |
| `pane.rename` | set or clear a manual pane label | `pane_info` |
@@ -997,9 +998,10 @@ herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N]
herdr agent send <target> <text>
herdr agent rename <target> <name>|--clear
herdr agent focus <target>
+herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>
```
-agent targets accept terminal ids, unique agent names, and legacy pane ids. duplicate names fail clearly for `agent.*`; `pane.rename` can still create duplicate pane labels for backward compatibility.
+agent targets accept terminal ids, unique agent names, and legacy pane ids. duplicate names fail clearly for `agent.*`; `pane.rename` can still create duplicate pane labels for backward compatibility. `agent start` treats everything after `--` as argv and does not require agent-specific launch logic.
pane commands:
@@ -1038,6 +1040,7 @@ herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [
- `agent list` shows named panes and detected/reported agents as terminal-backed agents
- `agent focus` switches the TUI workspace/tab/pane focus; it is not direct terminal attach
- `agent rename` enforces unique active names; use `pane rename` only when duplicate labels are intentional
+- `agent start <name> -- <argv...>` starts a named pane-backed terminal and returns `result.agent` plus `result.argv`
- `pane split` keeps focus where it is by default; pass `--focus` to switch to the new pane
- `pane read` prints **text**, not json
- `pane read --format ansi` and `pane read --ansi` print a rendered ANSI snapshot with colors/styles preserved
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 9a4134ab..4893c295 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -41,6 +41,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::TabClose(_)
| Method::AgentRename(_)
| Method::AgentFocus(_)
+ | Method::AgentStart(_)
| Method::PaneSplit(_)
| Method::PaneRename(_)
| Method::PaneReportAgent(_)
@@ -407,6 +408,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::AgentSend(_) => "agent.send",
Method::AgentRename(_) => "agent.rename",
Method::AgentFocus(_) => "agent.focus",
+ Method::AgentStart(_) => "agent.start",
Method::PaneSplit(_) => "pane.split",
Method::PaneList(_) => "pane.list",
Method::PaneGet(_) => "pane.get",
diff --git a/src/api/schema.rs b/src/api/schema.rs
index e6d24aaf..a82070e0 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -52,6 +52,8 @@ pub enum Method {
AgentRename(AgentRenameParams),
#[serde(rename = "agent.focus")]
AgentFocus(AgentTarget),
+ #[serde(rename = "agent.start")]
+ AgentStart(AgentStartParams),
#[serde(rename = "pane.split")]
PaneSplit(PaneSplitParams),
#[serde(rename = "pane.list")]
@@ -180,6 +182,22 @@ pub struct AgentRenameParams {
}
#[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, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSplitParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
@@ -523,6 +541,10 @@ pub enum ResponseResult {
AgentInfo {
agent: AgentInfo,
},
+ AgentStarted {
+ agent: AgentInfo,
+ argv: Vec<String>,
+ },
AgentList {
agents: Vec<AgentInfo>,
},
diff --git a/src/app/agents.rs b/src/app/agents.rs
index 88f7ec97..35e26c42 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -1,4 +1,7 @@
+use std::path::PathBuf;
+
use super::{terminal_targets::TerminalTargetError, App, Mode};
+use crate::api::schema::{AgentStartParams, SplitDirection};
impl App {
pub(super) fn collect_agent_infos(&self) -> Vec<crate::api::schema::AgentInfo> {
@@ -97,6 +100,154 @@ impl App {
})
}
+ pub(super) fn start_agent(
+ &mut self,
+ params: AgentStartParams,
+ ) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> {
+ let name = params.name.trim().to_string();
+ if name.is_empty() {
+ return Err(AgentStartError::InvalidName);
+ }
+ if params.argv.is_empty() {
+ return Err(AgentStartError::EmptyArgv);
+ }
+ let conflicts = self.agent_name_conflicts(&name, "");
+ if !conflicts.is_empty() {
+ return Err(AgentStartError::DuplicateName {
+ name,
+ candidates: conflicts,
+ });
+ }
+
+ let cwd = params
+ .cwd
+ .map(PathBuf::from)
+ .or_else(|| std::env::current_dir().ok())
+ .unwrap_or_else(|| PathBuf::from("/"));
+ let argv = params.argv;
+ let focus = params.focus;
+ let (rows, cols) = self.state.estimate_pane_size();
+
+ let (ws_idx, tab_idx, pane_id) = if let Some(tab_id) = params.tab_id {
+ let (ws_idx, tab_idx) =
+ self.parse_tab_id(&tab_id)
+ .ok_or_else(|| AgentStartError::TargetNotFound {
+ target: tab_id.clone(),
+ })?;
+ if let Some(workspace_id) = params.workspace_id.as_deref() {
+ let requested_ws_idx = self.parse_workspace_id(workspace_id).ok_or_else(|| {
+ AgentStartError::TargetNotFound {
+ target: workspace_id.to_string(),
+ }
+ })?;
+ if requested_ws_idx != ws_idx {
+ return Err(AgentStartError::PlacementConflict);
+ }
+ }
+ let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused();
+ self.spawn_agent_split(
+ ws_idx,
+ target_pane,
+ params.split.unwrap_or(SplitDirection::Right),
+ cwd,
+ &argv,
+ focus,
+ )?
+ } else if let Some(workspace_id) = params.workspace_id {
+ let ws_idx = self.parse_workspace_id(&workspace_id).ok_or_else(|| {
+ AgentStartError::TargetNotFound {
+ target: workspace_id.clone(),
+ }
+ })?;
+ let tab_idx = self.state.workspaces[ws_idx].active_tab;
+ let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused();
+ self.spawn_agent_split(
+ ws_idx,
+ target_pane,
+ params.split.unwrap_or(SplitDirection::Right),
+ cwd,
+ &argv,
+ focus,
+ )?
+ } else if self.state.workspaces.is_empty() {
+ self.spawn_agent_workspace(cwd, rows, cols, &argv, focus)?
+ } else {
+ let ws_idx = self.state.active.unwrap_or(0);
+ let tab_idx = self.state.workspaces[ws_idx].active_tab;
+ let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused();
+ self.spawn_agent_split(
+ ws_idx,
+ target_pane,
+ params.split.unwrap_or(SplitDirection::Right),
+ cwd,
+ &argv,
+ focus,
+ )?
+ };
+
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return Err(AgentStartError::SpawnFailed("workspace disappeared".into()));
+ };
+ let Some(pane_state) = ws.pane_state_mut(pane_id) else {
+ return Err(AgentStartError::SpawnFailed("pane disappeared".into()));
+ };
+ pane_state.set_manual_label(name);
+ self.state.mark_session_dirty();
+
+ let agent = self
+ .agent_info(ws_idx, pane_id)
+ .ok_or_else(|| AgentStartError::SpawnFailed("agent disappeared".into()))?;
+ debug_assert_eq!(agent.tab_id, self.public_tab_id(ws_idx, tab_idx).unwrap());
+ Ok((agent, argv))
+ }
+
+ pub(super) fn agent_start_error_body(
+ &self,
+ err: AgentStartError,
+ ) -> crate::api::schema::ErrorBody {
+ match err {
+ AgentStartError::InvalidName => crate::api::schema::ErrorBody {
+ code: "invalid_agent_name".into(),
+ message: "agent name must not be empty".into(),
+ },
+ AgentStartError::EmptyArgv => crate::api::schema::ErrorBody {
+ code: "invalid_agent_argv".into(),
+ message: "agent start argv must not be empty".into(),
+ },
+ AgentStartError::TargetNotFound { target } => crate::api::schema::ErrorBody {
+ code: "agent_placement_not_found".into(),
+ message: format!("agent placement target {target} not found"),
+ },
+ AgentStartError::PlacementConflict => crate::api::schema::ErrorBody {
+ code: "agent_placement_conflict".into(),
+ message: "--tab must belong to --workspace".into(),
+ },
+ AgentStartError::SpawnFailed(message) => crate::api::schema::ErrorBody {
+ code: "agent_start_failed".into(),
+ message,
+ },
+ AgentStartError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody {
+ code: "agent_name_taken".into(),
+ message: format!(
+ "agent name {name} is already used; candidates: {}",
+ candidates
+ .into_iter()
+ .map(|candidate| format!(
+ "terminal_id={} pane_id={} workspace_id={} tab_id={} cwd={} status={:?}",
+ candidate.terminal_id,
+ candidate.pane_id,
+ candidate.workspace_id,
+ candidate.tab_id,
+ candidate.cwd.unwrap_or_else(|| "unknown".into()),
+ candidate.agent_status,
+ ))
+ .collect::<Vec<_>>()
+ .join("; ")
+ ),
+ },
+ }
+ }
+
pub(super) fn agent_target_error_body(
&self,
err: TerminalTargetError,
@@ -158,6 +309,81 @@ impl App {
}
}
+ fn spawn_agent_workspace(
+ &mut self,
+ cwd: PathBuf,
+ rows: u16,
+ cols: u16,
+ argv: &[String],
+ focus: bool,
+ ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> {
+ let ws = crate::workspace::Workspace::new_argv_command(
+ cwd,
+ rows,
+ cols,
+ argv,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ self.event_tx.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ )
+ .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?;
+ self.state.workspaces.push(ws);
+ let ws_idx = self.state.workspaces.len() - 1;
+ if focus || self.state.active.is_none() {
+ self.state.switch_workspace(ws_idx);
+ self.state.mode = Mode::Terminal;
+ }
+ self.schedule_session_save();
+ let pane_id = self.state.workspaces[ws_idx].tabs[0].root_pane;
+ Ok((ws_idx, 0, pane_id))
+ }
+
+ fn spawn_agent_split(
+ &mut self,
+ ws_idx: usize,
+ target_pane: crate::layout::PaneId,
+ split: SplitDirection,
+ cwd: PathBuf,
+ argv: &[String],
+ focus: bool,
+ ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> {
+ let (rows, cols) = self.state.estimate_pane_size();
+ let direction = match split {
+ SplitDirection::Right => ratatui::layout::Direction::Horizontal,
+ SplitDirection::Down => ratatui::layout::Direction::Vertical,
+ };
+ let result = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| {
+ ws.split_pane_argv_command(
+ target_pane,
+ direction,
+ rows,
+ cols,
+ Some(cwd),
+ argv,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ focus,
+ )
+ })
+ .ok_or_else(|| AgentStartError::TargetNotFound {
+ target: target_pane.raw().to_string(),
+ })?
+ .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?;
+ if focus {
+ self.state.switch_workspace(ws_idx);
+ self.state.switch_tab(result.0);
+ self.state.mode = Mode::Terminal;
+ }
+ self.schedule_session_save();
+ Ok((ws_idx, result.0, result.1))
+ }
+
fn agent_info(
&self,
ws_idx: usize,
@@ -204,6 +430,20 @@ impl App {
}
}
+pub(super) enum AgentStartError {
+ InvalidName,
+ EmptyArgv,
+ TargetNotFound {
+ target: String,
+ },
+ PlacementConflict,
+ SpawnFailed(String),
+ DuplicateName {
+ name: String,
+ candidates: Vec<crate::api::schema::AgentInfo>,
+ },
+}
+
pub(super) enum AgentRenameError {
Target(TerminalTargetError),
DuplicateName {
diff --git a/src/app/api.rs b/src/app/api.rs
index 666e8a20..c6fa88fa 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -882,6 +882,22 @@ impl App {
result: ResponseResult::AgentInfo { agent },
}
}
+ Method::AgentStart(params) => {
+ let (agent, argv) = match self.start_agent(params) {
+ Ok(started) => started,
+ Err(err) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: self.agent_start_error_body(err),
+ })
+ .unwrap();
+ }
+ };
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::AgentStarted { agent, argv },
+ }
+ }
Method::AgentRead(params) => {
let resolved = match self.resolve_terminal_target(&params.target) {
Ok(resolved) => resolved,
diff --git a/src/app/creation.rs b/src/app/creation.rs
index f1ee41d8..6e557066 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -230,7 +230,6 @@ impl App {
) -> Option<crate::api::schema::PaneInfo> {
let ws = self.state.workspaces.get(ws_idx)?;
let pane = ws.pane_state(pane_id)?;
- let runtime = ws.runtime(pane_id);
let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
let focused = self.state.active == Some(ws_idx)
&& ws.active_tab == tab_idx
@@ -243,8 +242,8 @@ impl App {
workspace_id: self.public_workspace_id(ws_idx),
tab_id: self.public_tab_id(ws_idx, tab_idx)?,
focused,
- cwd: runtime
- .and_then(|rt| rt.cwd())
+ cwd: ws.tabs[tab_idx]
+ .cwd_for_pane(pane_id)
.map(|cwd| cwd.display().to_string()),
label: pane.manual_label.clone(),
agent: pane.effective_agent_label().map(str::to_string),
diff --git a/src/cli.rs b/src/cli.rs
index 205ea660..5d9dbb04 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,12 +6,13 @@ use serde::Serialize;
use crate::api;
use crate::api::schema::{
- AgentReadParams, AgentRenameParams, AgentSendParams, AgentStatus, AgentTarget, EmptyParams,
- IntegrationTarget, Method, OutputMatch, PaneAgentState, PaneListParams, PaneReadParams,
- PaneRenameParams, PaneReportAgentParams, PaneSendInputParams, PaneSendKeysParams,
- PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams, PingParams,
- ReadFormat, ReadSource, Request, SplitDirection, Subscription, TabCreateParams, TabListParams,
- TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget,
+ AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus,
+ AgentTarget, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneAgentState,
+ PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, PaneSendInputParams,
+ PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams,
+ PingParams, ReadFormat, ReadSource, Request, SplitDirection, Subscription, TabCreateParams,
+ TabListParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams,
+ WorkspaceTarget,
};
pub enum CommandOutcome {
@@ -279,6 +280,7 @@ fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
"send" => agent_send(&args[1..]),
"rename" => agent_rename(&args[1..]),
"focus" => agent_focus(&args[1..]),
+ "start" => agent_start(&args[1..]),
"help" | "--help" | "-h" => {
print_agent_help();
Ok(0)
@@ -771,6 +773,91 @@ fn tab_close(args: &[String]) -> std::io::Result<i32> {
})?)
}
+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...>");
+ 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...>");
+ return Ok(2);
+ };
+ if separator == args.len() - 1 {
+ eprintln!("agent start requires argv after --");
+ return Ok(2);
+ }
+
+ let mut cwd = None;
+ let mut workspace_id = None;
+ let mut tab_id = None;
+ let mut split = None;
+ let mut focus = false;
+
+ let mut index = 1;
+ while index < separator {
+ match args[index].as_str() {
+ "--cwd" => {
+ let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
+ eprintln!("missing value for --cwd");
+ return Ok(2);
+ };
+ cwd = Some(value.clone());
+ index += 2;
+ }
+ "--workspace" => {
+ let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
+ eprintln!("missing value for --workspace");
+ return Ok(2);
+ };
+ workspace_id = Some(normalize_workspace_id(value));
+ index += 2;
+ }
+ "--tab" => {
+ let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
+ eprintln!("missing value for --tab");
+ return Ok(2);
+ };
+ tab_id = Some(normalize_tab_id(value));
+ index += 2;
+ }
+ "--split" => {
+ let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
+ eprintln!("missing value for --split");
+ return Ok(2);
+ };
+ split = Some(parse_split_direction(value)?);
+ index += 2;
+ }
+ "--focus" => {
+ focus = true;
+ index += 1;
+ }
+ "--no-focus" => {
+ focus = false;
+ index += 1;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ print_response(&send_request(&Request {
+ id: "cli:agent:start".into(),
+ method: Method::AgentStart(AgentStartParams {
+ name: name.clone(),
+ cwd,
+ workspace_id,
+ tab_id,
+ split,
+ focus,
+ argv: args[separator + 1..].to_vec(),
+ }),
+ })?)
+}
+
fn agent_list(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr agent list");
@@ -1811,6 +1898,7 @@ fn print_agent_help() {
eprintln!(" herdr agent send <target> <text>");
eprintln!(" herdr agent rename <target> <name>|--clear");
eprintln!(" herdr agent focus <target>");
+ eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
eprintln!(" targets accept terminal ids, unique agent names, and legacy pane ids");
}
diff --git a/src/pane.rs b/src/pane.rs
index 272229fa..b5886933 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -328,6 +328,46 @@ impl PaneRuntime {
)
}
+ pub fn spawn_argv_command(
+ pane_id: PaneId,
+ rows: u16,
+ cols: u16,
+ cwd: std::path::PathBuf,
+ 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>,
+ ) -> std::io::Result<Self> {
+ let Some((program, args)) = argv.split_first() else {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "argv must not be empty",
+ ));
+ };
+ let mut cmd = CommandBuilder::new(program);
+ for arg in args {
+ 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);
+ Self::spawn_command_builder(
+ pane_id,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ cmd,
+ "failed to spawn argv command pane",
+ )
+ }
+
fn spawn_command_builder(
pane_id: PaneId,
rows: u16,
diff --git a/src/workspace.rs b/src/workspace.rs
index 46ad14fa..59b3e35d 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -91,9 +91,8 @@ impl Workspace {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
- let tab = Tab::new(
- 1,
- initial_cwd.clone(),
+ Self::new_with_tab(
+ initial_cwd,
rows,
cols,
scrollback_limit_bytes,
@@ -101,7 +100,72 @@ impl Workspace {
events,
render_notify,
render_dirty,
- )?;
+ None,
+ )
+ }
+
+ pub fn new_argv_command(
+ 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>,
+ ) -> std::io::Result<Self> {
+ Self::new_with_tab(
+ initial_cwd,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ Some(argv),
+ )
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn new_with_tab(
+ initial_cwd: PathBuf,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ argv: Option<&[String]>,
+ ) -> std::io::Result<Self> {
+ let tab = if let Some(argv) = argv {
+ Tab::new_argv_command(
+ 1,
+ initial_cwd.clone(),
+ rows,
+ cols,
+ argv,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ )?
+ } else {
+ Tab::new(
+ 1,
+ initial_cwd.clone(),
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ )?
+ };
let mut public_pane_numbers = HashMap::new();
public_pane_numbers.insert(tab.root_pane, 1);
Ok(Self {
@@ -152,6 +216,25 @@ impl Workspace {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
) -> std::io::Result<usize> {
+ self.create_tab_with_runtime(
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ None,
+ )
+ }
+
+ fn create_tab_with_runtime(
+ &mut self,
+ rows: u16,
+ cols: u16,
+ cwd: PathBuf,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ argv: Option<&[String]>,
+ ) -> std::io::Result<usize> {
let number = self.tabs.len() + 1;
let events = self
.active_tab()
@@ -166,17 +249,32 @@ impl Workspace {
.map(|tab| tab.render_dirty.clone())
.expect("workspace must always have at least one tab");
- let tab = Tab::new(
- number,
- cwd,
- rows,
- cols,
- scrollback_limit_bytes,
- host_terminal_theme,
- events,
- render_notify,
- render_dirty,
- )?;
+ let tab = if let Some(argv) = argv {
+ Tab::new_argv_command(
+ number,
+ cwd,
+ rows,
+ cols,
+ argv,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ )?
+ } else {
+ Tab::new(
+ number,
+ cwd,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ )?
+ };
self.register_new_pane(tab.root_pane);
self.tabs.push(tab);
Ok(self.tabs.len() - 1)
@@ -264,18 +362,82 @@ impl Workspace {
host_terminal_theme: crate::terminal_theme::TerminalTheme,
focus_new_pane: bool,
) -> Option<std::io::Result<(usize, PaneId)>> {
- let tab_idx = self.find_tab_index_for_pane(pane_id)?;
- let tab = &mut self.tabs[tab_idx];
- let previous_focus = tab.layout.focused();
- tab.layout.focus_pane(pane_id);
- let new_id = match tab.split_focused(
+ self.split_pane_with_runtime(
+ pane_id,
+ direction,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ focus_new_pane,
+ None,
+ )
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn split_pane_argv_command(
+ &mut self,
+ pane_id: PaneId,
+ direction: Direction,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ argv: &[String],
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ focus_new_pane: bool,
+ ) -> Option<std::io::Result<(usize, PaneId)>> {
+ self.split_pane_with_runtime(
+ pane_id,
direction,
rows,
cols,
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- ) {
+ focus_new_pane,
+ Some(argv),
+ )
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn split_pane_with_runtime(
+ &mut self,
+ pane_id: PaneId,
+ direction: Direction,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ focus_new_pane: bool,
+ argv: Option<&[String]>,
+ ) -> Option<std::io::Result<(usize, PaneId)>> {
+ let tab_idx = self.find_tab_index_for_pane(pane_id)?;
+ let tab = &mut self.tabs[tab_idx];
+ let previous_focus = tab.layout.focused();
+ tab.layout.focus_pane(pane_id);
+ let new_id = match if let Some(argv) = argv {
+ tab.split_focused_argv_command(
+ direction,
+ rows,
+ cols,
+ cwd,
+ argv,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ )
+ } else {
+ tab.split_focused(
+ direction,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ )
+ } {
Ok(new_id) => new_id,
Err(err) => {
tab.layout.focus_pane(previous_focus);
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
index 6f3177be..d809f944 100644
--- a/src/workspace/tab.rs
+++ b/src/workspace/tab.rs
@@ -11,6 +11,16 @@ use crate::layout::{PaneId, TileLayout};
use crate::pane::{PaneRuntime, PaneState};
use crate::terminal::TerminalId;
+enum SplitCommand<'a> {
+ Shell {
+ command: &'a str,
+ extra_env: &'a [(String, String)],
+ },
+ Argv {
+ argv: &'a [String],
+ },
+}
+
pub struct Tab {
pub custom_name: Option<String>,
pub number: usize,
@@ -52,18 +62,86 @@ impl Tab {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
- let (layout, root_id) = TileLayout::new();
- let runtime = PaneRuntime::spawn(
- root_id,
+ Self::new_with_runtime(
+ number,
+ initial_cwd,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ None,
+ )
+ }
+
+ pub fn new_argv_command(
+ number: usize,
+ 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>,
+ ) -> std::io::Result<Self> {
+ Self::new_with_runtime(
+ number,
+ initial_cwd,
rows,
cols,
- initial_cwd.clone(),
scrollback_limit_bytes,
host_terminal_theme,
- events.clone(),
- render_notify.clone(),
- render_dirty.clone(),
- )?;
+ events,
+ render_notify,
+ render_dirty,
+ Some(argv),
+ )
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn new_with_runtime(
+ number: usize,
+ initial_cwd: PathBuf,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ argv: Option<&[String]>,
+ ) -> std::io::Result<Self> {
+ let (layout, root_id) = TileLayout::new();
+ let runtime = if let Some(argv) = argv {
+ PaneRuntime::spawn_argv_command(
+ root_id,
+ rows,
+ cols,
+ initial_cwd.clone(),
+ argv,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )?
+ } else {
+ PaneRuntime::spawn(
+ root_id,
+ rows,
+ cols,
+ initial_cwd.clone(),
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )?
+ };
let mut panes = HashMap::new();
panes.insert(root_id, PaneState::new());
@@ -142,7 +220,28 @@ impl Tab {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- Some((command, extra_env)),
+ Some(SplitCommand::Shell { command, extra_env }),
+ )
+ }
+
+ pub fn split_focused_argv_command(
+ &mut self,
+ direction: Direction,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ argv: &[String],
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ ) -> std::io::Result<PaneId> {
+ self.split_focused_with_runtime(
+ direction,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ Some(SplitCommand::Argv { argv }),
)
}
@@ -154,13 +253,13 @@ impl Tab {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- command: Option<(&str, &[(String, String)])>,
+ command: Option<SplitCommand<'_>>,
) -> std::io::Result<PaneId> {
let new_id = self.layout.split_focused(direction);
let actual_cwd =
cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- let runtime = if let Some((command, extra_env)) = command {
- PaneRuntime::spawn_shell_command(
+ let runtime = match command {
+ Some(SplitCommand::Shell { command, extra_env }) => PaneRuntime::spawn_shell_command(
new_id,
rows,
cols,
@@ -172,19 +271,30 @@ impl Tab {
self.events.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
- )?
- } else {
- PaneRuntime::spawn(
+ )?,
+ Some(SplitCommand::Argv { argv }) => PaneRuntime::spawn_argv_command(
new_id,
rows,
cols,
actual_cwd.clone(),
+ argv,
scrollback_limit_bytes,
host_terminal_theme,
self.events.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
- )?
+ )?,
+ None => PaneRuntime::spawn(
+ new_id,
+ rows,
+ cols,
+ actual_cwd.clone(),
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ self.events.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ )?,
};
self.panes.insert(new_id, PaneState::new());
self.pane_cwds.insert(new_id, actual_cwd);
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index a2e6ef76..4300b54e 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -551,6 +551,61 @@ fn tab_methods_round_trip_over_socket() {
#[cfg(not(target_os = "macos"))]
#[test]
+fn agent_start_creates_named_terminal_over_socket() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+
+ let child = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let started = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_start","method":"agent.start","params":{{"name":"main","cwd":"{}","argv":["/bin/sh","-c","printf agent-start-ok; sleep 2"]}}}}"#,
+ base.display()
+ ),
+ );
+ assert_eq!(started["result"]["type"], "agent_started");
+ assert_eq!(started["result"]["agent"]["name"], "main");
+ assert_eq!(
+ started["result"]["agent"]["cwd"],
+ base.display().to_string()
+ );
+ assert_eq!(started["result"]["argv"][0], "/bin/sh");
+ let terminal_id = started["result"]["agent"]["terminal_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let listed = send_request(
+ &socket_path,
+ r#"{"id":"agent_start_list","method":"agent.list","params":{}}"#,
+ );
+ let agents = listed["result"]["agents"].as_array().unwrap();
+ assert_eq!(agents.len(), 1);
+ assert_eq!(agents[0]["terminal_id"], terminal_id);
+ assert_eq!(agents[0]["name"], "main");
+
+ let duplicate = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_start_duplicate","method":"agent.start","params":{{"name":"main","cwd":"{}","argv":["/bin/sh","-c","true"]}}}}"#,
+ base.display()
+ ),
+ );
+ assert_eq!(duplicate["error"]["code"], "agent_name_taken");
+ assert!(duplicate["error"]["message"]
+ .as_str()
+ .unwrap()
+ .contains(&terminal_id));
+
+ cleanup_spawned_herdr(child, base);
+}
+
+#[test]
fn agent_methods_round_trip_over_socket() {
let _lock = test_lock();
let base = unique_test_dir();
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 4302687e..0cf60c5d 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -1220,6 +1220,63 @@ fn tab_management_commands_work() {
}
#[test]
+fn agent_start_command_works() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let started = run_cli_json(
+ &socket_path,
+ &[
+ "agent",
+ "start",
+ "main",
+ "--cwd",
+ base.to_str().unwrap(),
+ "--",
+ "/bin/sh",
+ "-c",
+ "printf cli-agent-start-ok; sleep 2",
+ ],
+ );
+ assert_eq!(started["result"]["type"], "agent_started");
+ assert_eq!(started["result"]["agent"]["name"], "main");
+ assert_eq!(started["result"]["argv"][0], "/bin/sh");
+ let terminal_id = started["result"]["agent"]["terminal_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let listed = run_cli_json(&socket_path, &["agent", "list"]);
+ assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id);
+ assert_eq!(listed["result"]["agents"][0]["name"], "main");
+
+ let duplicate = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "start",
+ "main",
+ "--cwd",
+ base.to_str().unwrap(),
+ "--",
+ "/bin/sh",
+ "-c",
+ "true",
+ ],
+ );
+ assert!(!duplicate.status.success());
+ let duplicate_json: serde_json::Value = serde_json::from_slice(&duplicate.stderr).unwrap();
+ assert_eq!(duplicate_json["error"]["code"], "agent_name_taken");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
fn agent_commands_work() {
let base = unique_test_dir();
let config_home = base.join("config");