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 | 86adf88351e17c6a11cac253714b169eac0db233 (patch) | |
| tree | 883d32b0126e0b69449e0e9c0a7017d279c23935 /src/app | |
| parent | d2aa8865911ded97a67054fb2392a1c12fae018c (diff) | |
feat: add terminal identity and agent cli facade
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/agents.rs | 213 | ||||
| -rw-r--r-- | src/app/api.rs | 147 | ||||
| -rw-r--r-- | src/app/creation.rs | 1 | ||||
| -rw-r--r-- | src/app/mod.rs | 101 | ||||
| -rw-r--r-- | src/app/terminal_targets.rs | 165 |
5 files changed, 627 insertions, 0 deletions
diff --git a/src/app/agents.rs b/src/app/agents.rs new file mode 100644 index 00000000..88f7ec97 --- /dev/null +++ b/src/app/agents.rs @@ -0,0 +1,213 @@ +use super::{terminal_targets::TerminalTargetError, App, Mode}; + +impl App { + pub(super) fn collect_agent_infos(&self) -> Vec<crate::api::schema::AgentInfo> { + self.state + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, ws)| { + ws.tabs.iter().flat_map(move |tab| { + tab.layout + .pane_ids() + .into_iter() + .filter_map(move |pane_id| self.agent_info(ws_idx, pane_id)) + }) + }) + .filter(|agent| agent.name.is_some() || agent.agent.is_some()) + .collect() + } + + pub(super) fn agent_info_for_target( + &self, + target: &str, + ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> { + let resolved = self.resolve_terminal_target(target)?; + self.agent_info(resolved.ws_idx, resolved.pane_id) + .ok_or_else(|| TerminalTargetError::NotFound { + target: target.to_string(), + }) + } + + pub(super) fn focus_agent_target( + &mut self, + target: &str, + ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> { + let resolved = self.resolve_terminal_target(target)?; + self.state.switch_workspace(resolved.ws_idx); + self.state.switch_tab(resolved.tab_idx); + if let Some(tab) = self + .state + .workspaces + .get_mut(resolved.ws_idx) + .and_then(|ws| ws.tabs.get_mut(resolved.tab_idx)) + { + tab.layout.focus_pane(resolved.pane_id); + } + self.state.mode = Mode::Terminal; + self.agent_info(resolved.ws_idx, resolved.pane_id) + .ok_or_else(|| TerminalTargetError::NotFound { + target: target.to_string(), + }) + } + + pub(super) fn rename_agent_target( + &mut self, + target: &str, + name: Option<String>, + ) -> Result<crate::api::schema::AgentInfo, AgentRenameError> { + let resolved = self + .resolve_terminal_target(target) + .map_err(AgentRenameError::Target)?; + let normalized_name = name.and_then(|name| { + let trimmed = name.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }); + + if let Some(name) = normalized_name.as_deref() { + let conflicts = self.agent_name_conflicts(name, &resolved.terminal_id); + if !conflicts.is_empty() { + return Err(AgentRenameError::DuplicateName { + name: name.to_string(), + candidates: conflicts, + }); + } + } + + let Some(ws) = self.state.workspaces.get_mut(resolved.ws_idx) else { + return Err(AgentRenameError::Target(TerminalTargetError::NotFound { + target: target.to_string(), + })); + }; + let Some(pane_state) = ws.pane_state_mut(resolved.pane_id) else { + return Err(AgentRenameError::Target(TerminalTargetError::NotFound { + target: target.to_string(), + })); + }; + match normalized_name { + Some(name) => pane_state.set_manual_label(name), + None => pane_state.clear_manual_label(), + } + self.state.mark_session_dirty(); + self.agent_info(resolved.ws_idx, resolved.pane_id) + .ok_or_else(|| { + AgentRenameError::Target(TerminalTargetError::NotFound { + target: target.to_string(), + }) + }) + } + + pub(super) fn agent_target_error_body( + &self, + err: TerminalTargetError, + ) -> crate::api::schema::ErrorBody { + match err { + TerminalTargetError::NotFound { target } => crate::api::schema::ErrorBody { + code: "agent_not_found".into(), + message: format!("agent target {target} not found"), + }, + TerminalTargetError::Ambiguous { target, candidates } => { + crate::api::schema::ErrorBody { + code: "agent_target_ambiguous".into(), + message: format!( + "agent target {target} is ambiguous; 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_rename_error_body( + &self, + err: AgentRenameError, + ) -> crate::api::schema::ErrorBody { + match err { + AgentRenameError::Target(err) => self.agent_target_error_body(err), + AgentRenameError::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("; ") + ), + }, + } + } + + fn agent_info( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<crate::api::schema::AgentInfo> { + let pane = self.pane_info(ws_idx, pane_id)?; + Some(crate::api::schema::AgentInfo { + terminal_id: pane.terminal_id, + name: pane.label, + agent: pane.agent, + agent_status: pane.agent_status, + workspace_id: pane.workspace_id, + tab_id: pane.tab_id, + pane_id: pane.pane_id, + focused: pane.focused, + cwd: pane.cwd, + revision: pane.revision, + }) + } + + fn agent_name_conflicts( + &self, + name: &str, + except_terminal_id: &str, + ) -> Vec<crate::api::schema::AgentInfo> { + self.state + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, ws)| { + ws.tabs.iter().flat_map(move |tab| { + tab.layout + .pane_ids() + .into_iter() + .filter_map(move |pane_id| { + let pane_state = ws.pane_state(pane_id)?; + (pane_state.manual_label.as_deref() == Some(name)) + .then(|| self.agent_info(ws_idx, pane_id))? + }) + }) + }) + .filter(|agent| agent.terminal_id != except_terminal_id) + .collect() + } +} + +pub(super) enum AgentRenameError { + Target(TerminalTargetError), + DuplicateName { + name: String, + candidates: Vec<crate::api::schema::AgentInfo>, + }, +} diff --git a/src/app/api.rs b/src/app/api.rs index 5f959365..666e8a20 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -828,6 +828,153 @@ impl App { result: ResponseResult::Ok {}, } } + Method::AgentList(_) => SuccessResponse { + id: request.id, + result: ResponseResult::AgentList { + agents: self.collect_agent_infos(), + }, + }, + Method::AgentGet(target) => { + let agent = match self.agent_info_for_target(&target.target) { + Ok(agent) => agent, + Err(err) => { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: self.agent_target_error_body(err), + }) + .unwrap(); + } + }; + SuccessResponse { + id: request.id, + result: ResponseResult::AgentInfo { agent }, + } + } + Method::AgentFocus(target) => { + let agent = match self.focus_agent_target(&target.target) { + Ok(agent) => agent, + Err(err) => { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: self.agent_target_error_body(err), + }) + .unwrap(); + } + }; + SuccessResponse { + id: request.id, + result: ResponseResult::AgentInfo { agent }, + } + } + Method::AgentRename(params) => { + let agent = match self.rename_agent_target(¶ms.target, params.name) { + Ok(agent) => agent, + Err(err) => { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: self.agent_rename_error_body(err), + }) + .unwrap(); + } + }; + SuccessResponse { + id: request.id, + result: ResponseResult::AgentInfo { agent }, + } + } + Method::AgentRead(params) => { + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: self.agent_target_error_body(err), + }) + .unwrap(); + } + }; + let Some((pane, workspace_id)) = + self.lookup_runtime(resolved.ws_idx, resolved.pane_id) + else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "agent_not_found".into(), + message: format!("agent target {} not found", params.target), + }, + }) + .unwrap(); + }; + let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; + let text = match params.format { + ReadFormat::Text => match params.source { + ReadSource::Visible => pane.visible_text(), + ReadSource::Recent => pane.recent_text(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), + }, + ReadFormat::Ansi => match params.source { + ReadSource::Visible => pane.visible_ansi(), + ReadSource::Recent => pane.recent_ansi(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), + }, + }; + SuccessResponse { + id: request.id, + result: ResponseResult::PaneRead { + read: PaneReadResult { + pane_id: self + .public_pane_id(resolved.ws_idx, resolved.pane_id) + .unwrap_or_else(|| params.target.clone()), + workspace_id, + tab_id: self + .public_tab_id(resolved.ws_idx, resolved.tab_idx) + .unwrap(), + source: params.source, + format: params.format, + text, + revision: 0, + truncated: false, + }, + }, + } + } + Method::AgentSend(params) => { + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: self.agent_target_error_body(err), + }) + .unwrap(); + } + }; + let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) + else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "agent_not_found".into(), + message: format!("agent target {} not found", params.target), + }, + }) + .unwrap(); + }; + if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "agent_send_failed".into(), + message: err.to_string(), + }, + }) + .unwrap(); + } + SuccessResponse { + id: request.id, + result: ResponseResult::Ok {}, + } + } Method::PaneSplit(params) => { let Some((ws_idx, target_pane_id)) = self.parse_pane_id(¶ms.target_pane_id) else { diff --git a/src/app/creation.rs b/src/app/creation.rs index a2758d7a..f1ee41d8 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -239,6 +239,7 @@ impl App { .is_some_and(|focused| focused == pane_id); Some(crate::api::schema::PaneInfo { pane_id: self.public_pane_id(ws_idx, pane_id)?, + terminal_id: ws.terminal_id(pane_id)?.to_string(), workspace_id: self.public_workspace_id(ws_idx), tab_id: self.public_tab_id(ws_idx, tab_idx)?, focused, diff --git a/src/app/mod.rs b/src/app/mod.rs index d9f666ed..d37bcb5d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ //! - `input.rs` — key/mouse → action translation pub(crate) mod actions; +mod agents; mod api; mod api_helpers; mod config_io; @@ -14,6 +15,7 @@ mod input; mod runtime; mod session; pub mod state; +mod terminal_targets; mod theme_sync; use std::collections::{HashMap, HashSet}; @@ -1512,6 +1514,8 @@ mod tests { assert_eq!(tab.workspace_id, workspace.workspace_id); assert_eq!(root_pane.workspace_id, workspace.workspace_id); assert_eq!(root_pane.tab_id, tab.tab_id); + assert!(root_pane.terminal_id.starts_with("term_")); + assert_ne!(root_pane.terminal_id, root_pane.pane_id); } #[test] @@ -1624,6 +1628,103 @@ mod tests { .is_none()); } + #[test] + fn terminal_target_resolves_terminal_id() { + let mut app = test_app(); + let workspace = Workspace::test_new("terminal-target-id"); + let pane = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane).unwrap().to_string(); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + + let resolved = app.resolve_terminal_target(&terminal_id).unwrap(); + + assert_eq!(resolved.ws_idx, 0); + assert_eq!(resolved.pane_id, pane); + assert_eq!(resolved.terminal_id, terminal_id); + } + + #[test] + fn terminal_target_resolves_legacy_pane_id() { + let mut app = test_app(); + let workspace = Workspace::test_new("terminal-target-pane"); + let pane = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane).unwrap().to_string(); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.public_pane_id(0, pane).unwrap(); + + let resolved = app.resolve_terminal_target(&pane_id).unwrap(); + + assert_eq!(resolved.pane_id, pane); + assert_eq!(resolved.terminal_id, terminal_id); + } + + #[test] + fn terminal_target_resolves_unique_manual_name() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("terminal-target-name"); + let pane = workspace.tabs[0].root_pane; + workspace.pane_state_mut(pane).unwrap().manual_label = Some("reviewer".into()); + let terminal_id = workspace.terminal_id(pane).unwrap().to_string(); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + + let resolved = app.resolve_terminal_target("reviewer").unwrap(); + + assert_eq!(resolved.pane_id, pane); + assert_eq!(resolved.terminal_id, terminal_id); + } + + #[test] + fn terminal_target_reports_missing_target() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("terminal-target-missing")]; + app.state.active = Some(0); + app.state.selected = 0; + + let err = app.resolve_terminal_target("missing-agent").unwrap_err(); + + assert_eq!( + err, + crate::app::terminal_targets::TerminalTargetError::NotFound { + target: "missing-agent".into() + } + ); + } + + #[test] + fn terminal_target_reports_ambiguous_duplicate_manual_name() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("terminal-target-ambiguous"); + let first = workspace.tabs[0].root_pane; + let second = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.pane_state_mut(first).unwrap().manual_label = Some("worker".into()); + workspace.pane_state_mut(second).unwrap().manual_label = Some("worker".into()); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + + let err = app.resolve_terminal_target("worker").unwrap_err(); + + let crate::app::terminal_targets::TerminalTargetError::Ambiguous { target, candidates } = + err + else { + panic!("expected ambiguous terminal target"); + }; + assert_eq!(target, "worker"); + assert_eq!(candidates.len(), 2); + assert!(candidates.iter().all(|candidate| { + candidate.terminal_id.starts_with("term_") + && candidate.pane_id.starts_with(&app.state.workspaces[0].id) + && candidate.workspace_id == app.state.workspaces[0].id + && candidate.cwd.is_some() + })); + } + #[tokio::test] async fn pane_split_request_targets_pane_in_background_tab() { let _guard = config_env_lock().lock().unwrap(); diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs new file mode 100644 index 00000000..cf90717d --- /dev/null +++ b/src/app/terminal_targets.rs @@ -0,0 +1,165 @@ +use super::{api_helpers::pane_agent_status, App}; + +// Staged for #00f: the agent CLI will use this resolver once #00e provides terminal ids. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TerminalTarget { + pub ws_idx: usize, + pub tab_idx: usize, + pub pane_id: crate::layout::PaneId, + pub terminal_id: String, +} + +// Staged for #00f: ambiguity details are produced before the agent facade consumes them. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TerminalTargetCandidate { + pub terminal_id: String, + pub pane_id: String, + pub workspace_id: String, + pub tab_id: String, + pub cwd: Option<String>, + pub agent_status: crate::api::schema::AgentStatus, +} + +// Staged for #00f: target errors are specified now and surfaced by agent commands next. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TerminalTargetError { + NotFound { + target: String, + }, + Ambiguous { + target: String, + candidates: Vec<TerminalTargetCandidate>, + }, +} + +impl App { + // Staged for #00f: current pane APIs stay pane-targeted while agent APIs will call this. + #[allow(dead_code)] + pub(crate) fn resolve_terminal_target( + &self, + target: &str, + ) -> Result<TerminalTarget, TerminalTargetError> { + let terminal_matches: Vec<_> = self + .terminal_targets() + .into_iter() + .filter(|candidate| candidate.terminal_id == target) + .collect(); + if let Some(resolved) = self.single_terminal_match(target, terminal_matches)? { + return Ok(resolved); + } + + if let Some((ws_idx, pane_id)) = self.parse_pane_id(target) { + if let Some(resolved) = self.terminal_target_for_pane(ws_idx, pane_id) { + return Ok(resolved); + } + } + + let name_matches: Vec<_> = self + .terminal_targets() + .into_iter() + .filter(|candidate| { + self.state.workspaces[candidate.ws_idx] + .pane_state(candidate.pane_id) + .and_then(|pane| pane.manual_label.as_deref()) + .is_some_and(|label| label == target) + }) + .collect(); + if let Some(resolved) = self.single_terminal_match(target, name_matches)? { + return Ok(resolved); + } + + Err(TerminalTargetError::NotFound { + target: target.to_string(), + }) + } + + // Staged for #00f with resolve_terminal_target. + #[allow(dead_code)] + fn single_terminal_match( + &self, + target: &str, + matches: Vec<TerminalTarget>, + ) -> Result<Option<TerminalTarget>, TerminalTargetError> { + match matches.len() { + 0 => Ok(None), + 1 => Ok(matches.into_iter().next()), + _ => Err(TerminalTargetError::Ambiguous { + target: target.to_string(), + candidates: matches + .into_iter() + .filter_map(|candidate| { + self.terminal_target_candidate(candidate.ws_idx, candidate.pane_id) + }) + .collect(), + }), + } + } + + // Staged for #00f with resolve_terminal_target. + #[allow(dead_code)] + fn terminal_targets(&self) -> Vec<TerminalTarget> { + self.state + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, ws)| { + ws.tabs.iter().enumerate().flat_map(move |(tab_idx, tab)| { + tab.layout + .pane_ids() + .into_iter() + .filter_map(move |pane_id| { + tab.terminal_id(pane_id).map(|terminal_id| TerminalTarget { + ws_idx, + tab_idx, + pane_id, + terminal_id: terminal_id.to_string(), + }) + }) + }) + }) + .collect() + } + + // Staged for #00f with resolve_terminal_target. + #[allow(dead_code)] + fn terminal_target_for_pane( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<TerminalTarget> { + let ws = self.state.workspaces.get(ws_idx)?; + let tab_idx = ws.find_tab_index_for_pane(pane_id)?; + let terminal_id = ws.terminal_id(pane_id)?.to_string(); + Some(TerminalTarget { + ws_idx, + tab_idx, + pane_id, + terminal_id, + }) + } + + // Staged for #00f with resolve_terminal_target. + #[allow(dead_code)] + fn terminal_target_candidate( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<TerminalTargetCandidate> { + let ws = self.state.workspaces.get(ws_idx)?; + let tab_idx = ws.find_tab_index_for_pane(pane_id)?; + let pane = ws.pane_state(pane_id)?; + Some(TerminalTargetCandidate { + terminal_id: ws.terminal_id(pane_id)?.to_string(), + pane_id: self.public_pane_id(ws_idx, pane_id)?, + workspace_id: self.public_workspace_id(ws_idx), + tab_id: self.public_tab_id(ws_idx, tab_idx)?, + cwd: ws.tabs[tab_idx] + .cwd_for_pane(pane_id) + .map(|cwd| cwd.display().to_string()), + agent_status: pane_agent_status(pane.state, pane.seen), + }) + } +} |
