aboutsummaryrefslogtreecommitdiff
path: root/src/app
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 /src/app
parent86adf88351e17c6a11cac253714b169eac0db233 (diff)
feat: add agent start for server-owned terminals
Diffstat (limited to 'src/app')
-rw-r--r--src/app/agents.rs240
-rw-r--r--src/app/api.rs16
-rw-r--r--src/app/creation.rs5
3 files changed, 258 insertions, 3 deletions
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),