diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-17 01:41:16 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-17 01:41:16 +0300 |
| commit | e4de58b37f58d59d146158239ed218a8accd5c3c (patch) | |
| tree | 6bb35b8c9aaec969110ff51a58814dd8099617c0 /src/app | |
| parent | 86adf88351e17c6a11cac253714b169eac0db233 (diff) | |
feat: add agent start for server-owned terminals
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/agents.rs | 240 | ||||
| -rw-r--r-- | src/app/api.rs | 16 | ||||
| -rw-r--r-- | src/app/creation.rs | 5 |
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(¶ms.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), |
