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 | |
| parent | d2aa8865911ded97a67054fb2392a1c12fae018c (diff) | |
feat: add terminal identity and agent cli facade
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 8 | ||||
| -rw-r--r-- | src/api/schema.rs | 67 | ||||
| -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 | ||||
| -rw-r--r-- | src/cli.rs | 198 | ||||
| -rw-r--r-- | src/main.rs | 6 | ||||
| -rw-r--r-- | src/persist/restore.rs | 3 | ||||
| -rw-r--r-- | src/terminal/id.rs | 29 | ||||
| -rw-r--r-- | src/terminal/mod.rs | 3 | ||||
| -rw-r--r-- | src/workspace.rs | 12 | ||||
| -rw-r--r-- | src/workspace/tab.rs | 12 |
14 files changed, 959 insertions, 6 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index f125b156..9a4134ab 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -39,6 +39,8 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::TabFocus(_) | Method::TabRename(_) | Method::TabClose(_) + | Method::AgentRename(_) + | Method::AgentFocus(_) | Method::PaneSplit(_) | Method::PaneRename(_) | Method::PaneReportAgent(_) @@ -399,6 +401,12 @@ fn api_method_name(method: &Method) -> &'static str { Method::TabFocus(_) => "tab.focus", Method::TabRename(_) => "tab.rename", Method::TabClose(_) => "tab.close", + Method::AgentList(_) => "agent.list", + Method::AgentGet(_) => "agent.get", + Method::AgentRead(_) => "agent.read", + Method::AgentSend(_) => "agent.send", + Method::AgentRename(_) => "agent.rename", + Method::AgentFocus(_) => "agent.focus", 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 bed8415a..e6d24aaf 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -40,6 +40,18 @@ pub enum Method { TabRename(TabRenameParams), #[serde(rename = "tab.close")] TabClose(TabTarget), + #[serde(rename = "agent.list")] + AgentList(EmptyParams), + #[serde(rename = "agent.get")] + AgentGet(AgentTarget), + #[serde(rename = "agent.read")] + AgentRead(AgentReadParams), + #[serde(rename = "agent.send")] + AgentSend(AgentSendParams), + #[serde(rename = "agent.rename")] + AgentRename(AgentRenameParams), + #[serde(rename = "agent.focus")] + AgentFocus(AgentTarget), #[serde(rename = "pane.split")] PaneSplit(PaneSplitParams), #[serde(rename = "pane.list")] @@ -138,6 +150,36 @@ pub struct TabRenameParams { } #[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 PaneSplitParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option<String>, @@ -478,6 +520,12 @@ pub enum ResponseResult { TabList { tabs: Vec<TabInfo>, }, + AgentInfo { + agent: AgentInfo, + }, + AgentList { + agents: Vec<AgentInfo>, + }, PaneInfo { pane: PaneInfo, }, @@ -536,8 +584,26 @@ pub struct TabInfo { } #[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>, + pub agent_status: AgentStatus, + 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>, + pub revision: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneInfo { pub pane_id: String, + pub terminal_id: String, pub workspace_id: String, pub tab_id: String, pub focused: bool, @@ -1028,6 +1094,7 @@ mod tests { }, 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, 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), + }) + } +} @@ -6,12 +6,12 @@ use serde::Serialize; use crate::api; use crate::api::schema::{ - AgentStatus, 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, 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 { @@ -34,6 +34,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { "status" => run_status_command(&args[2..])?, "workspace" => run_workspace_command(&args[2..])?, "tab" => run_tab_command(&args[2..])?, + "agent" => run_agent_command(&args[2..])?, "pane" => run_pane_command(&args[2..])?, "wait" => run_wait_command(&args[2..])?, "integration" => run_integration_command(&args[2..])?, @@ -265,6 +266,30 @@ fn run_tab_command(args: &[String]) -> std::io::Result<i32> { } } +fn run_agent_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_agent_help(); + return Ok(2); + }; + + match subcommand { + "list" => agent_list(&args[1..]), + "get" => agent_get(&args[1..]), + "read" => agent_read(&args[1..]), + "send" => agent_send(&args[1..]), + "rename" => agent_rename(&args[1..]), + "focus" => agent_focus(&args[1..]), + "help" | "--help" | "-h" => { + print_agent_help(); + Ok(0) + } + _ => { + print_agent_help(); + Ok(2) + } + } +} + fn run_pane_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_pane_help(); @@ -746,6 +771,156 @@ fn tab_close(args: &[String]) -> std::io::Result<i32> { })?) } +fn agent_list(args: &[String]) -> std::io::Result<i32> { + if !args.is_empty() { + eprintln!("usage: herdr agent list"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:agent:list".into(), + method: Method::AgentList(EmptyParams::default()), + })?) +} + +fn agent_get(args: &[String]) -> std::io::Result<i32> { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent get <target>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr agent get <target>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:agent:get".into(), + method: Method::AgentGet(AgentTarget { + target: target.clone(), + }), + })?) +} + +fn agent_focus(args: &[String]) -> std::io::Result<i32> { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent focus <target>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr agent focus <target>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:agent:focus".into(), + method: Method::AgentFocus(AgentTarget { + target: target.clone(), + }), + })?) +} + +fn agent_rename(args: &[String]) -> std::io::Result<i32> { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent rename <target> <name>|--clear"); + return Ok(2); + }; + if args.len() < 2 { + eprintln!("usage: herdr agent rename <target> <name>|--clear"); + return Ok(2); + } + let name = if args.len() == 2 && args[1] == "--clear" { + None + } else { + Some(args[1..].join(" ")) + }; + + print_response(&send_request(&Request { + id: "cli:agent:rename".into(), + method: Method::AgentRename(AgentRenameParams { + target: target.clone(), + name, + }), + })?) +} + +fn agent_send(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr agent send <target> <text>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:agent:send".into(), + method: Method::AgentSend(AgentSendParams { + target: args[0].clone(), + text: args[1..].join(" "), + }), + })?) +} + +fn agent_read(args: &[String]) -> std::io::Result<i32> { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); + return Ok(2); + }; + + let mut source = ReadSource::Recent; + let mut lines = None; + let mut format = ReadFormat::Text; + let mut strip_ansi = true; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = parse_read_source(value)?; + index += 2; + } + "--lines" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --lines"); + return Ok(2); + }; + lines = Some(parse_u32_flag("--lines", value)?); + index += 2; + } + "--format" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --format"); + return Ok(2); + }; + format = parse_read_format(value)?; + strip_ansi = !matches!(format, ReadFormat::Ansi); + index += 2; + } + "--ansi" => { + format = ReadFormat::Ansi; + strip_ansi = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + print_response(&send_request(&Request { + id: "cli:agent:read".into(), + method: Method::AgentRead(AgentReadParams { + target: target.clone(), + source, + lines, + format, + strip_ansi, + }), + })?) +} + fn pane_list(args: &[String]) -> std::io::Result<i32> { let mut workspace_id = None; @@ -1628,6 +1803,17 @@ fn print_tab_help() { eprintln!(" herdr tab close <tab_id>"); } +fn print_agent_help() { + eprintln!("herdr agent commands:"); + eprintln!(" herdr agent list"); + eprintln!(" herdr agent get <target>"); + eprintln!(" herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); + eprintln!(" herdr agent send <target> <text>"); + eprintln!(" herdr agent rename <target> <name>|--clear"); + eprintln!(" herdr agent focus <target>"); + eprintln!(" targets accept terminal ids, unique agent names, and legacy pane ids"); +} + fn print_pane_help() { eprintln!("herdr pane commands:"); eprintln!(" herdr pane list [--workspace <workspace_id>]"); diff --git a/src/main.rs b/src/main.rs index c852c33b..441f3588 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,7 @@ mod selection; mod server; mod session; mod sound; +mod terminal; mod terminal_notify; mod terminal_theme; mod ui; @@ -280,6 +281,7 @@ fn main() -> io::Result<()> { println!(" herdr server reload-config"); println!(" herdr workspace <subcommand> ..."); println!(" herdr tab <subcommand> ..."); + println!(" herdr agent <subcommand> ..."); println!(" herdr pane <subcommand> ..."); println!(" herdr wait <subcommand> ..."); println!(" herdr session <subcommand> ..."); @@ -307,6 +309,10 @@ fn main() -> io::Result<()> { ), ("herdr tab <subcommand>", "Tab helpers over the socket API"), ( + "herdr agent <subcommand>", + "Agent/terminal helpers over the socket API", + ), + ( "herdr pane <subcommand>", "Pane control helpers over the socket API", ), diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 7d623173..1e10df6d 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -111,6 +111,7 @@ fn restore_tab( let mut panes = HashMap::new(); let mut pane_cwds = HashMap::new(); + let mut terminal_ids = HashMap::new(); let mut runtimes = HashMap::new(); for id in &pane_ids { let saved_cwd = reverse_id_map @@ -157,6 +158,7 @@ fn restore_tab( pane_state.manual_label = saved_label; panes.insert(*id, pane_state); pane_cwds.insert(*id, cwd.clone()); + terminal_ids.insert(*id, crate::terminal::TerminalId::alloc()); runtimes.insert(*id, runtime); } Err(e) => { @@ -198,6 +200,7 @@ fn restore_tab( layout, panes, pane_cwds, + terminal_ids, runtimes, zoomed: snap.zoomed, events, diff --git a/src/terminal/id.rs b/src/terminal/id.rs new file mode 100644 index 00000000..53ef6786 --- /dev/null +++ b/src/terminal/id.rs @@ -0,0 +1,29 @@ +use std::fmt; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Opaque identity for a server-owned terminal. +/// +/// During the pane-backed transition this is stored one-to-one beside panes, +/// but callers must not derive it from a pane id or layout position. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct TerminalId(String); + +static NEXT_TERMINAL_ID: AtomicU64 = AtomicU64::new(1); + +impl TerminalId { + pub fn alloc() -> Self { + let micros = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_micros()) + .unwrap_or(0); + let counter = NEXT_TERMINAL_ID.fetch_add(1, Ordering::Relaxed); + Self(format!("term_{micros:x}{counter:x}")) + } +} + +impl fmt::Display for TerminalId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs new file mode 100644 index 00000000..489f7876 --- /dev/null +++ b/src/terminal/mod.rs @@ -0,0 +1,3 @@ +mod id; + +pub use id::TerminalId; diff --git a/src/workspace.rs b/src/workspace.rs index de030a80..46ad14fa 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -13,6 +13,7 @@ use crate::layout::PaneId; #[cfg(test)] use crate::layout::TileLayout; use crate::pane::{PaneRuntime, PaneState}; +use crate::terminal::TerminalId; mod aggregate; mod git; @@ -418,6 +419,10 @@ impl Workspace { self.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) } + pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> { + self.tabs.iter().find_map(|tab| tab.terminal_id(pane_id)) + } + pub fn focused_pane_id(&self) -> Option<PaneId> { self.active_tab().map(|tab| tab.layout.focused()) } @@ -497,6 +502,8 @@ impl Workspace { panes.insert(root_id, PaneState::new()); let mut pane_cwds = HashMap::new(); pane_cwds.insert(root_id, identity_cwd.clone()); + let mut terminal_ids = HashMap::new(); + terminal_ids.insert(root_id, TerminalId::alloc()); let tab = Tab { custom_name: None, number: 1, @@ -504,6 +511,7 @@ impl Workspace { layout, panes, pane_cwds, + terminal_ids, runtimes: HashMap::new(), zoomed: false, events, @@ -531,6 +539,7 @@ impl Workspace { tab.panes.insert(new_id, PaneState::new()); let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); tab.pane_cwds.insert(new_id, cwd); + tab.terminal_ids.insert(new_id, TerminalId::alloc()); self.register_new_pane(new_id); new_id } @@ -545,6 +554,8 @@ impl Workspace { let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); let mut pane_cwds = HashMap::new(); pane_cwds.insert(root_id, cwd); + let mut terminal_ids = HashMap::new(); + terminal_ids.insert(root_id, TerminalId::alloc()); let tab = Tab { custom_name: name.map(str::to_string), number: self.tabs.len() + 1, @@ -552,6 +563,7 @@ impl Workspace { layout, panes, pane_cwds, + terminal_ids, runtimes: HashMap::new(), zoomed: false, events, diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index f8b31eb5..6f3177be 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -9,6 +9,7 @@ use tokio::sync::{mpsc, Notify}; use crate::events::AppEvent; use crate::layout::{PaneId, TileLayout}; use crate::pane::{PaneRuntime, PaneState}; +use crate::terminal::TerminalId; pub struct Tab { pub custom_name: Option<String>, @@ -20,6 +21,8 @@ pub struct Tab { pub panes: HashMap<PaneId, PaneState>, /// Best-effort cwd cache per pane. Live runtime cwd wins when available. pub pane_cwds: HashMap<PaneId, PathBuf>, + /// One-to-one terminal identity for each pane-backed PTY during the transition. + pub terminal_ids: HashMap<PaneId, TerminalId>, /// Pane runtimes — only present in production (empty in tests). pub runtimes: HashMap<PaneId, PaneRuntime>, pub zoomed: bool, @@ -66,6 +69,8 @@ impl Tab { panes.insert(root_id, PaneState::new()); let mut pane_cwds = HashMap::new(); pane_cwds.insert(root_id, initial_cwd); + let mut terminal_ids = HashMap::new(); + terminal_ids.insert(root_id, TerminalId::alloc()); let mut runtimes = HashMap::new(); runtimes.insert(root_id, runtime); @@ -76,6 +81,7 @@ impl Tab { layout, panes, pane_cwds, + terminal_ids, runtimes, zoomed: false, events, @@ -182,6 +188,7 @@ impl Tab { }; self.panes.insert(new_id, PaneState::new()); self.pane_cwds.insert(new_id, actual_cwd); + self.terminal_ids.insert(new_id, TerminalId::alloc()); self.runtimes.insert(new_id, runtime); self.zoomed = false; Ok(new_id) @@ -218,6 +225,7 @@ impl Tab { self.panes.remove(&pane_id); self.pane_cwds.remove(&pane_id); + self.terminal_ids.remove(&pane_id); let runtime = self.runtimes.remove(&pane_id); self.zoomed = false; if let Some(next_root) = next_root { @@ -237,6 +245,10 @@ impl Tab { self.runtimes.get(&self.layout.focused()) } + pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> { + self.terminal_ids.get(&pane_id) + } + pub fn cwd_for_pane(&self, pane_id: PaneId) -> Option<PathBuf> { self.runtimes .get(&pane_id) |
