aboutsummaryrefslogtreecommitdiff
path: root/src
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
commit86adf88351e17c6a11cac253714b169eac0db233 (patch)
tree883d32b0126e0b69449e0e9c0a7017d279c23935 /src
parentd2aa8865911ded97a67054fb2392a1c12fae018c (diff)
feat: add terminal identity and agent cli facade
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs8
-rw-r--r--src/api/schema.rs67
-rw-r--r--src/app/agents.rs213
-rw-r--r--src/app/api.rs147
-rw-r--r--src/app/creation.rs1
-rw-r--r--src/app/mod.rs101
-rw-r--r--src/app/terminal_targets.rs165
-rw-r--r--src/cli.rs198
-rw-r--r--src/main.rs6
-rw-r--r--src/persist/restore.rs3
-rw-r--r--src/terminal/id.rs29
-rw-r--r--src/terminal/mod.rs3
-rw-r--r--src/workspace.rs12
-rw-r--r--src/workspace/tab.rs12
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(&params.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(&params.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(&params.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(&params.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),
+ })
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
index 6aee92ba..205ea660 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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)