diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 20:19:54 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 20:22:57 +0300 |
| commit | 866aaf740e10fcfce169aaf81380ea0257fbe272 (patch) | |
| tree | f815392b7c6b24a7c0de14e655bd635656383813 /src | |
| parent | ca579c402d5668560ca13059f82c03178be18b2a (diff) | |
refactor: separate server client boundaries
Diffstat (limited to 'src')
69 files changed, 6074 insertions, 5105 deletions
diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 00000000..7929f937 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,245 @@ +use std::fmt; +use std::io::{self, BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::Duration; + +use serde::de::DeserializeOwned; + +use crate::api::schema::{ + ErrorResponse, EventsSubscribeParams, Method, PingParams, Request, ResponseResult, + SubscriptionEventEnvelope, SuccessResponse, +}; + +/// API connection target resolved by clients at the process edge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionTarget { + LocalSession(Option<String>), + SocketPath(PathBuf), +} + +impl ConnectionTarget { + fn socket_path(&self) -> PathBuf { + match self { + Self::LocalSession(None) => crate::api::socket_path(), + Self::LocalSession(Some(name)) => crate::session::api_socket_path_for(Some(name)), + Self::SocketPath(path) => path.clone(), + } + } +} + +/// Reusable client for Herdr's newline-delimited JSON API. +#[derive(Debug, Clone)] +pub struct ApiClient { + target: ConnectionTarget, +} + +impl ApiClient { + pub fn local() -> Self { + Self::for_target(ConnectionTarget::LocalSession(None)) + } + + pub fn for_target(target: ConnectionTarget) -> Self { + Self { target } + } + + pub fn socket_path(&self) -> PathBuf { + self.target.socket_path() + } + + pub fn request(&self, request: Request) -> Result<SuccessResponse, ApiClientError> { + let value = self.request_value(&request)?; + parse_response_value(value) + } + + pub fn request_value(&self, request: &Request) -> Result<serde_json::Value, ApiClientError> { + let mut stream = self.connect()?; + write_request(&mut stream, request)?; + + let mut reader = BufReader::new(stream); + read_json_line(&mut reader) + } + + pub fn request_value_with_timeout( + &self, + request: &Request, + timeout: Duration, + ) -> Result<serde_json::Value, ApiClientError> { + let mut stream = self.connect()?; + stream.set_write_timeout(Some(timeout))?; + stream.set_read_timeout(Some(timeout))?; + write_request(&mut stream, request)?; + + let mut reader = BufReader::new(stream); + read_json_line(&mut reader) + } + + #[allow(dead_code)] // Kept as the typed subscription API; CLI wait paths use subscribe_value to preserve raw ack errors. + pub fn subscribe( + &self, + id: impl Into<String>, + params: EventsSubscribeParams, + read_timeout: Option<Duration>, + ) -> Result<(SuccessResponse, EventStream), ApiClientError> { + let request = Request { + id: id.into(), + method: Method::EventsSubscribe(params), + }; + let (ack, stream) = self.subscribe_value(&request, read_timeout)?; + Ok((parse_response_value(ack)?, stream)) + } + + pub fn subscribe_value( + &self, + request: &Request, + read_timeout: Option<Duration>, + ) -> Result<(serde_json::Value, EventStream), ApiClientError> { + let mut stream = self.connect()?; + write_request(&mut stream, request)?; + if let Some(timeout) = read_timeout { + stream.set_read_timeout(Some(timeout))?; + } + + let mut reader = BufReader::new(stream); + let ack = read_json_line(&mut reader)?; + Ok((ack, EventStream { reader })) + } + + pub fn status(&self) -> Result<crate::api::RuntimeStatus, ApiClientError> { + let response = self.request(Request { + id: "api-client:status".into(), + method: Method::Ping(PingParams::default()), + })?; + match response.result { + ResponseResult::Pong { version, protocol } => Ok(crate::api::RuntimeStatus { + version: Some(version), + protocol: Some(protocol), + }), + result => Err(ApiClientError::UnexpectedResult(format!("{result:?}"))), + } + } + + fn connect(&self) -> io::Result<UnixStream> { + UnixStream::connect(self.socket_path()) + } +} + +pub struct EventStream { + reader: BufReader<UnixStream>, +} + +impl EventStream { + pub fn next_value(&mut self) -> Result<Option<serde_json::Value>, ApiClientError> { + read_optional_json_line(&mut self.reader) + } + + pub fn next_event(&mut self) -> Result<Option<SubscriptionEventEnvelope>, ApiClientError> { + self.next_value()? + .map(serde_json::from_value) + .transpose() + .map_err(ApiClientError::Json) + } +} + +#[derive(Debug)] +pub enum ApiClientError { + Io(io::Error), + Json(serde_json::Error), + ErrorResponse(ErrorResponse), + EmptyResponse, + UnexpectedResult(String), +} + +impl fmt::Display for ApiClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "{err}"), + Self::Json(err) => write!(f, "{err}"), + Self::ErrorResponse(response) => write!(f, "{}", response.error.message), + Self::EmptyResponse => write!(f, "empty api response"), + Self::UnexpectedResult(result) => write!(f, "unexpected api result: {result}"), + } + } +} + +impl std::error::Error for ApiClientError {} + +impl From<io::Error> for ApiClientError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +impl From<serde_json::Error> for ApiClientError { + fn from(err: serde_json::Error) -> Self { + Self::Json(err) + } +} + +fn write_request(stream: &mut UnixStream, request: &Request) -> Result<(), ApiClientError> { + stream.write_all(serde_json::to_string(request)?.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush()?; + Ok(()) +} + +fn read_json_line<T: DeserializeOwned>( + reader: &mut BufReader<UnixStream>, +) -> Result<T, ApiClientError> { + let mut line = String::new(); + let read = reader.read_line(&mut line)?; + if read == 0 || line.trim().is_empty() { + return Err(ApiClientError::EmptyResponse); + } + serde_json::from_str(&line).map_err(ApiClientError::Json) +} + +fn read_optional_json_line<T: DeserializeOwned>( + reader: &mut BufReader<UnixStream>, +) -> Result<Option<T>, ApiClientError> { + let mut line = String::new(); + let read = reader.read_line(&mut line)?; + if read == 0 { + return Ok(None); + } + if line.trim().is_empty() { + return Err(ApiClientError::EmptyResponse); + } + serde_json::from_str(&line) + .map(Some) + .map_err(ApiClientError::Json) +} + +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum WireResponse { + Success(Box<SuccessResponse>), + Error(ErrorResponse), +} + +pub(crate) fn parse_response_value( + value: serde_json::Value, +) -> Result<SuccessResponse, ApiClientError> { + match serde_json::from_value(value)? { + WireResponse::Success(response) => Ok(*response), + WireResponse::Error(response) => Err(ApiClientError::ErrorResponse(response)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_session_target_resolves_named_session_socket() { + let client = ApiClient::for_target(ConnectionTarget::LocalSession(Some("work".into()))); + assert!(client.socket_path().ends_with("sessions/work/herdr.sock")); + } + + #[test] + fn socket_path_target_uses_explicit_path() { + let path = PathBuf::from("/tmp/herdr-test.sock"); + let client = ApiClient::for_target(ConnectionTarget::SocketPath(path.clone())); + assert_eq!(client.socket_path(), path); + } +} diff --git a/src/api/event_hub.rs b/src/api/event_hub.rs new file mode 100644 index 00000000..b66a2157 --- /dev/null +++ b/src/api/event_hub.rs @@ -0,0 +1,39 @@ +#[derive(Clone, Default)] +pub struct EventHub { + inner: std::sync::Arc<std::sync::Mutex<EventHubState>>, +} + +#[derive(Default)] +struct EventHubState { + next_sequence: u64, + events: Vec<(u64, crate::api::schema::EventEnvelope)>, +} + +impl EventHub { + const MAX_EVENTS: usize = 512; + + pub fn push(&self, event: crate::api::schema::EventEnvelope) { + let Ok(mut state) = self.inner.lock() else { + return; + }; + state.next_sequence += 1; + let sequence = state.next_sequence; + state.events.push((sequence, event)); + let overflow = state.events.len().saturating_sub(Self::MAX_EVENTS); + if overflow > 0 { + state.events.drain(0..overflow); + } + } + + pub fn events_after(&self, sequence: u64) -> Vec<(u64, crate::api::schema::EventEnvelope)> { + let Ok(state) = self.inner.lock() else { + return Vec::new(); + }; + state + .events + .iter() + .filter(|(event_sequence, _)| *event_sequence > sequence) + .cloned() + .collect() + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 4893c295..34b37138 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,32 +1,23 @@ +pub mod client; +mod event_hub; pub mod schema; +mod server; +mod status; +mod subscriptions; +mod wait; -use std::fs; -use std::io::{self, BufRead, BufReader, Read, Write}; -use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; +pub use event_hub::EventHub; +pub use server::start_server; +pub use status::{read_runtime_status_at, RuntimeStatus}; -use tracing::{debug, error, info, warn}; +use std::path::PathBuf; -use regex::Regex; use tokio::sync::mpsc; -use crate::api::schema::{ - ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, Request, - ResponseResult, Subscription, SubscriptionEventData, SubscriptionEventEnvelope, - SubscriptionEventKind, SuccessResponse, -}; +use crate::api::schema::{Method, Request}; pub const SOCKET_PATH_ENV_VAR: &str = "HERDR_SOCKET_PATH"; -const SOCKET_PERMISSION_MODE: u32 = 0o600; -const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100); -const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); -const INITIAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5); - pub(crate) fn request_changes_ui(request: &Request) -> bool { matches!( &request.method, @@ -58,1331 +49,6 @@ pub struct ApiRequestMessage { pub type ApiRequestSender = mpsc::UnboundedSender<ApiRequestMessage>; -#[derive(Clone, Default)] -pub struct EventHub { - inner: std::sync::Arc<std::sync::Mutex<EventHubState>>, -} - -#[derive(Default)] -struct EventHubState { - next_sequence: u64, - events: Vec<(u64, crate::api::schema::EventEnvelope)>, -} - -impl EventHub { - const MAX_EVENTS: usize = 512; - - pub fn push(&self, event: crate::api::schema::EventEnvelope) { - let Ok(mut state) = self.inner.lock() else { - return; - }; - state.next_sequence += 1; - let sequence = state.next_sequence; - state.events.push((sequence, event)); - let overflow = state.events.len().saturating_sub(Self::MAX_EVENTS); - if overflow > 0 { - state.events.drain(0..overflow); - } - } - - pub fn events_after(&self, sequence: u64) -> Vec<(u64, crate::api::schema::EventEnvelope)> { - let Ok(state) = self.inner.lock() else { - return Vec::new(); - }; - state - .events - .iter() - .filter(|(event_sequence, _)| *event_sequence > sequence) - .cloned() - .collect() - } -} - pub fn socket_path() -> PathBuf { crate::session::active_api_socket_path() } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeStatus { - pub version: Option<String>, - pub protocol: Option<u32>, -} - -pub fn read_runtime_status_at( - socket_path: &Path, - timeout: Duration, -) -> io::Result<Option<RuntimeStatus>> { - if !socket_path.exists() { - return Ok(None); - } - - let mut stream = match UnixStream::connect(socket_path) { - Ok(stream) => stream, - Err(err) - if matches!( - err.kind(), - io::ErrorKind::ConnectionRefused - | io::ErrorKind::NotFound - | io::ErrorKind::TimedOut - ) => - { - return Ok(None); - } - Err(err) => return Err(err), - }; - - stream.set_write_timeout(Some(timeout))?; - stream.set_read_timeout(Some(timeout))?; - - let request = Request { - id: "runtime:status".into(), - method: Method::Ping(crate::api::schema::PingParams::default()), - }; - stream.write_all(serde_json::to_string(&request)?.as_bytes())?; - stream.write_all(b"\n")?; - stream.flush()?; - - let mut reader = BufReader::new(stream); - let mut line = String::new(); - let read = reader.read_line(&mut line)?; - if read == 0 || line.trim().is_empty() { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "empty server status response", - )); - } - - let response: serde_json::Value = serde_json::from_str(&line).map_err(io::Error::other)?; - if response.get("error").is_some() { - return Err(io::Error::other(format!( - "server status request failed: {response}" - ))); - } - - let result = &response["result"]; - Ok(Some(RuntimeStatus { - version: result - .get("version") - .and_then(|value| value.as_str()) - .map(str::to_owned), - protocol: result - .get("protocol") - .and_then(|value| value.as_u64()) - .and_then(|value| u32::try_from(value).ok()), - })) -} - -pub struct ServerHandle { - _thread: std::thread::JoinHandle<()>, - path: PathBuf, - running: Arc<AtomicBool>, -} - -impl Drop for ServerHandle { - fn drop(&mut self) { - self.running.store(false, Ordering::Relaxed); - - if let Err(err) = fs::remove_file(&self.path) { - if err.kind() != std::io::ErrorKind::NotFound { - warn!(path = %self.path.display(), err = %err, "failed to remove api socket on shutdown"); - } - } - } -} - -pub fn start_server( - api_tx: ApiRequestSender, - event_hub: EventHub, -) -> std::io::Result<ServerHandle> { - let path = socket_path(); - prepare_socket_path(&path)?; - - let listener = UnixListener::bind(&path)?; - restrict_socket_permissions(&path)?; - info!(path = %path.display(), "api server listening"); - - let running = Arc::new(AtomicBool::new(true)); - let listener_running = Arc::clone(&running); - let thread = std::thread::spawn(move || { - for stream in listener.incoming() { - match stream { - Ok(stream) => { - let api_tx = api_tx.clone(); - let event_hub = event_hub.clone(); - let connection_running = Arc::clone(&listener_running); - std::thread::spawn(move || { - if let Err(err) = - handle_connection(stream, &api_tx, &event_hub, &connection_running) - { - warn!(err = %err, "api connection failed"); - } - }); - } - Err(err) => { - error!(err = %err, "api listener accept failed"); - break; - } - } - } - debug!("api server thread exiting"); - }); - - Ok(ServerHandle { - _thread: thread, - path, - running, - }) -} - -fn prepare_socket_path(path: &Path) -> std::io::Result<()> { - crate::ipc::prepare_socket_path(path, |path| { - format!( - "herdr is already running (socket busy at {})", - path.display() - ) - }) -} - -fn restrict_socket_permissions(path: &Path) -> std::io::Result<()> { - crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) -} - -fn handle_connection( - mut stream: UnixStream, - api_tx: &ApiRequestSender, - event_hub: &EventHub, - running: &Arc<AtomicBool>, -) -> std::io::Result<()> { - stream.set_read_timeout(Some(INITIAL_REQUEST_TIMEOUT))?; - stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT))?; - - let mut line = String::new(); - { - let mut reader = BufReader::new(&stream); - let read = reader.read_line(&mut line)?; - if read == 0 { - return Ok(()); - } - } - - stream.set_read_timeout(None)?; - - let line = line.trim(); - if line.is_empty() { - return Ok(()); - } - - let request = match serde_json::from_str::<Request>(line) { - Ok(request) => request, - Err(err) => { - write_json_line_allow_disconnect( - &mut stream, - &ErrorResponse { - id: String::new(), - error: ErrorBody { - code: "invalid_request".into(), - message: format!("invalid request: {err}"), - }, - }, - )?; - return Ok(()); - } - }; - - let request_id = request.id.clone(); - let method = api_method_name(&request.method); - let changes_ui = request_changes_ui(&request); - crate::logging::api_request_started(&request_id, method, changes_ui); - - match request.method { - Method::EventsSubscribe(params) => { - let result = stream_subscriptions( - stream, - request_id.clone(), - params, - api_tx, - event_hub, - running, - ); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - "stream_closed", - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result - } - Method::PaneWaitForOutput(params) => { - let Some(response) = - wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)? - else { - crate::logging::api_request_completed( - &request_id, - method, - "client_disconnected", - changes_ui, - ); - return Ok(()); - }; - let result = write_text_line_allow_disconnect(&mut stream, &response); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - api_response_outcome(&response), - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result - } - method_body => { - let response = handle_request( - Request { - id: request_id.clone(), - method: method_body, - }, - api_tx, - ); - let result = write_text_line_allow_disconnect(&mut stream, &response); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - api_response_outcome(&response), - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result - } - } -} - -fn handle_request(request: Request, api_tx: &ApiRequestSender) -> String { - match request.method { - Method::Ping(_) => serde_json::to_string(&SuccessResponse { - id: request.id, - result: ResponseResult::Pong { - version: env!("CARGO_PKG_VERSION").into(), - protocol: crate::server::protocol::PROTOCOL_VERSION, - }, - }) - .unwrap_or_else(|_| { - r#"{"id":"","error":{"code":"internal_error","message":"failed to encode response"}}"# - .to_string() - }), - _ => dispatch_to_app(request, api_tx), - } -} - -fn api_method_name(method: &Method) -> &'static str { - match method { - Method::Ping(_) => "ping", - Method::ServerStop(_) => "server.stop", - Method::ServerReloadConfig(_) => "server.reload_config", - Method::WorkspaceCreate(_) => "workspace.create", - Method::WorkspaceList(_) => "workspace.list", - Method::WorkspaceGet(_) => "workspace.get", - Method::WorkspaceFocus(_) => "workspace.focus", - Method::WorkspaceRename(_) => "workspace.rename", - Method::WorkspaceClose(_) => "workspace.close", - Method::TabCreate(_) => "tab.create", - Method::TabList(_) => "tab.list", - Method::TabGet(_) => "tab.get", - 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::AgentStart(_) => "agent.start", - Method::PaneSplit(_) => "pane.split", - Method::PaneList(_) => "pane.list", - Method::PaneGet(_) => "pane.get", - Method::PaneRename(_) => "pane.rename", - Method::PaneSendText(_) => "pane.send_text", - Method::PaneSendKeys(_) => "pane.send_keys", - Method::PaneSendInput(_) => "pane.send_input", - Method::PaneRead(_) => "pane.read", - Method::PaneReportAgent(_) => "pane.report_agent", - Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority", - Method::PaneReleaseAgent(_) => "pane.release_agent", - Method::PaneClose(_) => "pane.close", - Method::EventsSubscribe(_) => "events.subscribe", - Method::EventsWait(_) => "events.wait", - Method::PaneWaitForOutput(_) => "pane.wait_for_output", - Method::IntegrationInstall(_) => "integration.install", - Method::IntegrationUninstall(_) => "integration.uninstall", - } -} - -fn api_response_outcome(response: &str) -> &'static str { - let Ok(value) = serde_json::from_str::<serde_json::Value>(response) else { - return "error"; - }; - - match value - .get("error") - .and_then(|error| error.get("code")) - .and_then(|code| code.as_str()) - { - Some("timeout") => "timeout", - Some(_) => "error", - None => "ok", - } -} - -fn output_match_read_source( - source: &crate::api::schema::ReadSource, -) -> crate::api::schema::ReadSource { - match source { - crate::api::schema::ReadSource::Recent => crate::api::schema::ReadSource::RecentUnwrapped, - other => *other, - } -} - -fn wait_for_output( - request_id: String, - params: crate::api::schema::PaneWaitForOutputParams, - stream: &mut UnixStream, - api_tx: &ApiRequestSender, - running: &Arc<AtomicBool>, -) -> std::io::Result<Option<String>> { - crate::logging::api_wait_started(&request_id, ¶ms.pane_id, params.timeout_ms); - let deadline = params - .timeout_ms - .map(|ms| std::time::Instant::now() + std::time::Duration::from_millis(ms)); - - let regex = match ¶ms.r#match { - crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) { - Ok(regex) => Some(regex), - Err(err) => { - return Ok(Some( - serde_json::to_string(&ErrorResponse { - id: request_id, - error: ErrorBody { - code: "invalid_regex".into(), - message: err.to_string(), - }, - }) - .unwrap(), - )); - } - }, - crate::api::schema::OutputMatch::Substring { .. } => None, - }; - - loop { - if should_stop_connection(stream, running)? { - crate::logging::api_wait_completed(&request_id, ¶ms.pane_id, "client_disconnected"); - return Ok(None); - } - - let read_request = Request { - id: format!("{request_id}:read"), - method: Method::PaneRead(crate::api::schema::PaneReadParams { - pane_id: params.pane_id.clone(), - source: output_match_read_source(¶ms.source), - lines: params.lines, - format: crate::api::schema::ReadFormat::Text, - strip_ansi: params.strip_ansi, - }), - }; - let response = - dispatch_to_app_with_timeout(read_request, api_tx, Some(APP_RESPONSE_TIMEOUT)); - let Ok(value) = serde_json::from_str::<serde_json::Value>(&response) else { - return Ok(Some(response)); - }; - if value.get("error").is_some() { - let mut value = value; - value["id"] = serde_json::Value::String(request_id.clone()); - return Ok(Some(serde_json::to_string(&value).unwrap())); - } - - let read_value = value["result"]["read"].clone(); - let Ok(read) = serde_json::from_value::<crate::api::schema::PaneReadResult>(read_value) - else { - return Ok(Some( - serde_json::to_string(&ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane read result".into(), - }, - }) - .unwrap(), - )); - }; - - let matched_line = match_output(&read.text, ¶ms.r#match, regex.as_ref()); - if matched_line.is_some() { - let revision = read.revision; - crate::logging::api_wait_completed(&request_id, ¶ms.pane_id, "matched"); - return Ok(Some( - serde_json::to_string(&SuccessResponse { - id: request_id, - result: ResponseResult::OutputMatched { - pane_id: params.pane_id, - revision, - matched_line, - read, - }, - }) - .unwrap(), - )); - } - - if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { - crate::logging::api_wait_timed_out(&request_id, ¶ms.pane_id); - return Ok(Some( - serde_json::to_string(&ErrorResponse { - id: request_id, - error: ErrorBody { - code: "timeout".into(), - message: "timed out waiting for output match".into(), - }, - }) - .unwrap(), - )); - } - - std::thread::sleep(CONNECTION_POLL_INTERVAL); - } -} - -fn stream_subscriptions( - mut stream: UnixStream, - request_id: String, - params: crate::api::schema::EventsSubscribeParams, - api_tx: &ApiRequestSender, - event_hub: &EventHub, - running: &Arc<AtomicBool>, -) -> std::io::Result<()> { - let mut subscriptions = Vec::with_capacity(params.subscriptions.len()); - for (index, subscription) in params.subscriptions.into_iter().enumerate() { - let active = - match ActiveSubscription::new(subscription, &request_id, index, api_tx, event_hub) { - Ok(active) => active, - Err(response) => { - if let Err(err) = write_json_line(&mut stream, &response) { - if is_connection_closed_error(&err) { - return Ok(()); - } - return Err(err); - } - return Ok(()); - } - }; - subscriptions.push(active); - } - - if let Err(err) = write_json_line( - &mut stream, - &SuccessResponse { - id: request_id, - result: ResponseResult::SubscriptionStarted {}, - }, - ) { - if is_connection_closed_error(&err) { - return Ok(()); - } - return Err(err); - } - - loop { - if should_stop_connection(&mut stream, running)? { - return Ok(()); - } - - for subscription in &mut subscriptions { - if let Some(event) = subscription.poll(api_tx, event_hub) { - if let Err(err) = write_json_line(&mut stream, &event) { - if is_connection_closed_error(&err) { - return Ok(()); - } - return Err(err); - } - } - } - std::thread::sleep(CONNECTION_POLL_INTERVAL); - } -} - -fn write_text_line(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { - stream.write_all(value.as_bytes())?; - stream.write_all(b"\n")?; - stream.flush() -} - -fn write_text_line_allow_disconnect(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { - match write_text_line(stream, value) { - Err(err) if is_connection_closed_error(&err) => Ok(()), - result => result, - } -} - -fn write_json_line<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> std::io::Result<()> { - let encoded = serde_json::to_string(value) - .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; - write_text_line(stream, &encoded) -} - -fn write_json_line_allow_disconnect<T: serde::Serialize>( - stream: &mut UnixStream, - value: &T, -) -> std::io::Result<()> { - let encoded = serde_json::to_string(value) - .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; - write_text_line_allow_disconnect(stream, &encoded) -} - -fn should_stop_connection( - stream: &mut UnixStream, - running: &Arc<AtomicBool>, -) -> std::io::Result<bool> { - if !running.load(Ordering::Relaxed) { - return Ok(true); - } - - probe_stream_closed(stream) -} - -fn probe_stream_closed(stream: &mut UnixStream) -> std::io::Result<bool> { - stream.set_nonblocking(true)?; - let mut probe = [0u8; 1]; - let status = match stream.read(&mut probe) { - Ok(0) => Ok(true), - Ok(_) => Ok(true), - Err(err) - if matches!( - err.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted - ) => - { - Ok(false) - } - Err(err) if is_connection_closed_error(&err) => Ok(true), - Err(err) => Err(err), - }; - stream.set_nonblocking(false)?; - status -} - -fn is_connection_closed_error(err: &std::io::Error) -> bool { - matches!( - err.kind(), - std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::NotConnected - | std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::WriteZero - ) -} - -fn match_output( - text: &str, - matcher: &crate::api::schema::OutputMatch, - regex: Option<&Regex>, -) -> Option<String> { - match matcher { - crate::api::schema::OutputMatch::Substring { value } => text - .lines() - .find(|line| line.contains(value)) - .map(|line| line.to_string()), - crate::api::schema::OutputMatch::Regex { .. } => regex.and_then(|re| { - text.lines() - .find(|line| re.is_match(line)) - .map(|line| line.to_string()) - }), - } -} - -struct ActiveOutputMatchedSubscription { - pane_id: String, - source: crate::api::schema::ReadSource, - lines: Option<u32>, - matcher: crate::api::schema::OutputMatch, - regex: Option<Regex>, - strip_ansi: bool, - currently_matching: bool, - request_prefix: String, -} - -struct ActiveAgentStatusChangedSubscription { - pane_id: String, - status_filter: Option<crate::api::schema::AgentStatus>, - last_status: Option<crate::api::schema::AgentStatus>, - request_prefix: String, -} - -struct ActiveEventSubscription { - event_kind: crate::api::schema::EventKind, - last_sequence: u64, -} - -enum ActiveSubscription { - Event(ActiveEventSubscription), - OutputMatched(ActiveOutputMatchedSubscription), - AgentStatusChanged(ActiveAgentStatusChangedSubscription), -} - -impl ActiveSubscription { - fn new( - subscription: Subscription, - request_id: &str, - index: usize, - api_tx: &ApiRequestSender, - _event_hub: &EventHub, - ) -> Result<Self, ErrorResponse> { - match subscription { - Subscription::WorkspaceCreated {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::WorkspaceCreated, - last_sequence: 0, - })), - Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::WorkspaceClosed, - last_sequence: 0, - })), - Subscription::WorkspaceFocused {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::WorkspaceFocused, - last_sequence: 0, - })), - Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::TabCreated, - last_sequence: 0, - })), - Subscription::TabClosed {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::TabClosed, - last_sequence: 0, - })), - Subscription::TabFocused {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::TabFocused, - last_sequence: 0, - })), - Subscription::TabRenamed {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::TabRenamed, - last_sequence: 0, - })), - Subscription::PaneCreated {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneCreated, - last_sequence: 0, - })), - Subscription::PaneClosed {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneClosed, - last_sequence: 0, - })), - Subscription::PaneFocused {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneFocused, - last_sequence: 0, - })), - Subscription::PaneExited {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneExited, - last_sequence: 0, - })), - Subscription::PaneAgentDetected {} => Ok(Self::Event(ActiveEventSubscription { - event_kind: crate::api::schema::EventKind::PaneAgentDetected, - last_sequence: 0, - })), - Subscription::PaneOutputMatched { - pane_id, - source, - lines, - r#match, - strip_ansi, - } => { - let regex = match &r#match { - crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) { - Ok(regex) => Some(regex), - Err(err) => { - return Err(ErrorResponse { - id: request_id.to_string(), - error: ErrorBody { - code: "invalid_regex".into(), - message: err.to_string(), - }, - }); - } - }, - crate::api::schema::OutputMatch::Substring { .. } => None, - }; - - let probe = pane_read( - format!("{request_id}:sub:{index}:probe"), - &pane_id, - source, - lines, - strip_ansi, - api_tx, - ); - probe?; - - Ok(Self::OutputMatched(ActiveOutputMatchedSubscription { - pane_id, - source, - lines, - matcher: r#match, - regex, - strip_ansi, - currently_matching: false, - request_prefix: format!("{request_id}:sub:{index}"), - })) - } - Subscription::PaneAgentStatusChanged { - pane_id, - agent_status, - } => { - let probe = pane_get(format!("{request_id}:sub:{index}:probe"), &pane_id, api_tx)?; - - Ok(Self::AgentStatusChanged( - ActiveAgentStatusChangedSubscription { - pane_id, - status_filter: agent_status, - last_status: Some(probe.agent_status), - request_prefix: format!("{request_id}:sub:{index}"), - }, - )) - } - } - } - - fn poll( - &mut self, - api_tx: &ApiRequestSender, - event_hub: &EventHub, - ) -> Option<serde_json::Value> { - match self { - Self::Event(subscription) => subscription.poll(event_hub), - Self::OutputMatched(subscription) => { - serde_json::to_value(subscription.poll(api_tx)?).ok() - } - Self::AgentStatusChanged(subscription) => { - serde_json::to_value(subscription.poll(api_tx)?).ok() - } - } - } -} - -impl ActiveEventSubscription { - fn poll(&mut self, event_hub: &EventHub) -> Option<serde_json::Value> { - for (sequence, event) in event_hub.events_after(self.last_sequence) { - self.last_sequence = sequence; - if event.event == self.event_kind { - return serde_json::to_value(event).ok(); - } - } - None - } -} - -impl ActiveOutputMatchedSubscription { - fn poll(&mut self, api_tx: &ApiRequestSender) -> Option<SubscriptionEventEnvelope> { - let read = pane_read( - format!("{}:read", self.request_prefix), - &self.pane_id, - output_match_read_source(&self.source), - self.lines, - self.strip_ansi, - api_tx, - ) - .ok()?; - - let matched_line = match_output(&read.text, &self.matcher, self.regex.as_ref()); - match matched_line { - Some(matched_line) => { - if self.currently_matching { - return None; - } - self.currently_matching = true; - Some(SubscriptionEventEnvelope { - event: SubscriptionEventKind::PaneOutputMatched, - data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent { - pane_id: self.pane_id.clone(), - matched_line, - read, - }), - }) - } - None => { - self.currently_matching = false; - None - } - } - } -} - -impl ActiveAgentStatusChangedSubscription { - fn poll(&mut self, api_tx: &ApiRequestSender) -> Option<SubscriptionEventEnvelope> { - let pane = pane_get( - format!("{}:pane", self.request_prefix), - &self.pane_id, - api_tx, - ) - .ok()?; - let current_status = pane.agent_status; - let previous_status = self.last_status.replace(current_status); - if previous_status.is_none() || previous_status == Some(current_status) { - return None; - } - if self - .status_filter - .is_some_and(|wanted| wanted != current_status) - { - return None; - } - - Some(SubscriptionEventEnvelope { - event: SubscriptionEventKind::PaneAgentStatusChanged, - data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent { - pane_id: pane.pane_id, - workspace_id: pane.workspace_id, - agent_status: current_status, - agent: pane.agent, - custom_status: pane.custom_status, - }), - }) - } -} - -fn pane_read( - request_id: String, - pane_id: &str, - source: crate::api::schema::ReadSource, - lines: Option<u32>, - strip_ansi: bool, - api_tx: &ApiRequestSender, -) -> Result<crate::api::schema::PaneReadResult, ErrorResponse> { - let response = dispatch_to_app_with_timeout( - Request { - id: request_id.clone(), - method: Method::PaneRead(crate::api::schema::PaneReadParams { - pane_id: pane_id.to_string(), - source, - lines, - format: crate::api::schema::ReadFormat::Text, - strip_ansi, - }), - }, - api_tx, - Some(APP_RESPONSE_TIMEOUT), - ); - let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse { - id: request_id.clone(), - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane read response".into(), - }, - })?; - if value.get("error").is_some() { - return serde_json::from_value(value).map_err(|_| ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane read error".into(), - }, - }); - } - serde_json::from_value(value["result"]["read"].clone()).map_err(|_| ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane read result".into(), - }, - }) -} - -fn pane_get( - request_id: String, - pane_id: &str, - api_tx: &ApiRequestSender, -) -> Result<crate::api::schema::PaneInfo, ErrorResponse> { - let response = dispatch_to_app_with_timeout( - Request { - id: request_id.clone(), - method: Method::PaneGet(crate::api::schema::PaneTarget { - pane_id: pane_id.to_string(), - }), - }, - api_tx, - Some(APP_RESPONSE_TIMEOUT), - ); - let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse { - id: request_id.clone(), - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane get response".into(), - }, - })?; - if value.get("error").is_some() { - return serde_json::from_value(value).map_err(|_| ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane get error".into(), - }, - }); - } - serde_json::from_value(value["result"]["pane"].clone()).map_err(|_| ErrorResponse { - id: request_id, - error: ErrorBody { - code: "internal_error".into(), - message: "failed to decode pane get result".into(), - }, - }) -} - -fn dispatch_to_app(request: Request, api_tx: &ApiRequestSender) -> String { - dispatch_to_app_with_timeout(request, api_tx, None) -} - -fn dispatch_to_app_with_timeout( - request: Request, - api_tx: &ApiRequestSender, - timeout: Option<Duration>, -) -> String { - let request_id = request.id.clone(); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - if let Err(err) = api_tx.send(ApiRequestMessage { - request, - respond_to, - }) { - return error_response_json( - request_id, - "server_unavailable", - format!("failed to dispatch request: {err}"), - ); - } - - let response = match timeout { - Some(timeout) => response_rx.recv_timeout(timeout).map_err(|err| match err { - std::sync::mpsc::RecvTimeoutError::Timeout => std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!( - "timed out waiting for app response after {} ms", - timeout.as_millis() - ), - ), - std::sync::mpsc::RecvTimeoutError::Disconnected => std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "app response channel closed", - ), - }), - None => response_rx - .recv() - .map_err(|err| std::io::Error::new(std::io::ErrorKind::BrokenPipe, err)), - }; - - match response { - Ok(response) => response, - Err(err) => error_response_json( - request_id, - "server_unavailable", - format!("request handling failed: {err}"), - ), - } -} - -fn error_response_json(id: String, code: &str, message: String) -> String { - serde_json::to_string(&ErrorResponse { - id, - error: ErrorBody { - code: code.into(), - message, - }, - }) - .unwrap_or_else(|_| { - r#"{"id":"","error":{"code":"internal_error","message":"failed to encode error response"}}"# - .to_string() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::os::unix::fs::PermissionsExt; - use std::sync::{Mutex, OnceLock}; - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - fn unique_test_path(name: &str) -> PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) - } - - fn read_line(stream: &mut UnixStream) -> String { - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - line - } - - #[test] - fn socket_path_prefers_explicit_env_override() { - let _guard = env_lock().lock().unwrap(); - let unique = format!("/tmp/herdr-test-{}.sock", std::process::id()); - std::env::remove_var(crate::session::SESSION_ENV_VAR); - crate::session::clear_explicit_session_for_test(); - std::env::set_var(SOCKET_PATH_ENV_VAR, &unique); - assert_eq!(socket_path(), PathBuf::from(&unique)); - std::env::remove_var(SOCKET_PATH_ENV_VAR); - } - - #[test] - fn socket_path_defaults_to_config_dir_even_when_xdg_runtime_dir_is_set() { - let _guard = env_lock().lock().unwrap(); - let config_home = unique_test_path("socket-default-config-home"); - let runtime_dir = unique_test_path("socket-default-runtime"); - std::env::remove_var(SOCKET_PATH_ENV_VAR); - std::env::remove_var(crate::session::SESSION_ENV_VAR); - crate::session::clear_explicit_session_for_test(); - std::env::set_var("XDG_CONFIG_HOME", &config_home); - std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir); - - let expected = config_home - .join(crate::config::app_dir_name()) - .join("herdr.sock"); - assert_eq!(socket_path(), expected); - - std::env::remove_var("XDG_CONFIG_HOME"); - std::env::remove_var("XDG_RUNTIME_DIR"); - } - - #[test] - fn socket_path_uses_named_session_dir() { - let _guard = env_lock().lock().unwrap(); - let config_home = unique_test_path("socket-named-config-home"); - std::env::remove_var(SOCKET_PATH_ENV_VAR); - crate::session::clear_explicit_session_for_test(); - std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); - std::env::set_var("XDG_CONFIG_HOME", &config_home); - - let expected = config_home - .join(crate::config::app_dir_name()) - .join("sessions") - .join("work") - .join("herdr.sock"); - assert_eq!(socket_path(), expected); - - std::env::remove_var(crate::session::SESSION_ENV_VAR); - std::env::remove_var("XDG_CONFIG_HOME"); - } - - #[test] - fn restrict_socket_permissions_sets_user_only_mode() { - let dir = unique_test_path("socket-perms"); - fs::create_dir_all(&dir).unwrap(); - let path = dir.join("api.sock"); - let _listener = UnixListener::bind(&path).unwrap(); - - restrict_socket_permissions(&path).unwrap(); - - let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, SOCKET_PERMISSION_MODE); - - drop(_listener); - let _ = fs::remove_file(&path); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn api_response_outcome_uses_top_level_error_shape() { - let ok_with_error_text = r#"{"id":"req","result":{"read":{"text":"user said \"error\": \"timeout\"","revision":1}}}"#; - assert_eq!(api_response_outcome(ok_with_error_text), "ok"); - - let timeout = r#"{"id":"req","error":{"code":"timeout","message":"timed out waiting for output match"}}"#; - assert_eq!(api_response_outcome(timeout), "timeout"); - - let generic_error = - r#"{"id":"req","error":{"code":"server_unavailable","message":"boom"}}"#; - assert_eq!(api_response_outcome(generic_error), "error"); - } - - #[test] - fn ping_request_returns_pong() { - let (tx, _rx) = mpsc::unbounded_channel(); - let response = handle_request( - Request { - id: "req_1".into(), - method: Method::Ping(crate::api::schema::PingParams::default()), - }, - &tx, - ); - - let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed.id, "req_1"); - assert!(matches!(parsed.result, ResponseResult::Pong { .. })); - } - - #[test] - fn request_dispatches_to_app_channel() { - let (tx, mut rx) = mpsc::unbounded_channel(); - let request = Request { - id: "req_2".into(), - method: Method::WorkspaceList(crate::api::schema::EmptyParams::default()), - }; - - let request_for_thread = request.clone(); - let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx)); - - let msg = rx.blocking_recv().unwrap(); - assert_eq!(msg.request.id, "req_2"); - msg.respond_to - .send( - serde_json::to_string(&SuccessResponse { - id: "req_2".into(), - result: ResponseResult::Ok {}, - }) - .unwrap(), - ) - .unwrap(); - - let response = thread.join().unwrap(); - let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed.id, "req_2"); - } - - #[test] - fn wait_for_output_stops_when_client_disconnects() { - let (api_tx, mut api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>(); - let (first_read_tx, first_read_rx) = std::sync::mpsc::channel(); - let responder = std::thread::spawn(move || { - let mut notified = false; - while let Some(msg) = api_rx.blocking_recv() { - assert!(matches!(msg.request.method, Method::PaneRead(_))); - if !notified { - first_read_tx.send(()).unwrap(); - notified = true; - } - msg.respond_to - .send( - serde_json::to_string(&SuccessResponse { - id: msg.request.id, - result: ResponseResult::PaneRead { - read: crate::api::schema::PaneReadResult { - pane_id: "pane_1".into(), - workspace_id: "ws_1".into(), - tab_id: "tab_1".into(), - source: crate::api::schema::ReadSource::RecentUnwrapped, - format: crate::api::schema::ReadFormat::Text, - text: String::new(), - revision: 0, - truncated: false, - }, - }, - }) - .unwrap(), - ) - .unwrap(); - } - }); - - let (mut client, server) = UnixStream::pair().unwrap(); - client - .write_all(br#"{"id":"req_wait","method":"pane.wait_for_output","params":{"pane_id":"pane_1","source":"recent","match":{"type":"substring","value":"never"}}}"#) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.flush().unwrap(); - - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let event_hub = EventHub::default(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let server_thread = std::thread::spawn(move || { - let result = handle_connection(server, &api_tx, &event_hub, &server_running); - done_tx.send(result).unwrap(); - }); - - first_read_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - drop(client); - - let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(result.is_ok()); - - server_thread.join().unwrap(); - drop(running); - responder.join().unwrap(); - } - - #[test] - fn subscriptions_stop_when_client_disconnects() { - let (api_tx, _api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>(); - let (mut client, server) = UnixStream::pair().unwrap(); - client - .write_all( - br#"{"id":"sub_1","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, - ) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.flush().unwrap(); - - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let event_hub = EventHub::default(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let server_thread = std::thread::spawn(move || { - let result = handle_connection(server, &api_tx, &event_hub, &server_running); - done_tx.send(result).unwrap(); - }); - - let ack = read_line(&mut client); - let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); - assert_eq!(ack["result"]["type"], "subscription_started"); - - drop(client); - - let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(result.is_ok()); - server_thread.join().unwrap(); - } - - #[test] - fn subscriptions_stop_when_server_shuts_down() { - let (api_tx, _api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>(); - let (mut client, server) = UnixStream::pair().unwrap(); - client - .write_all( - br#"{"id":"sub_2","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, - ) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.flush().unwrap(); - - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let event_hub = EventHub::default(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let server_thread = std::thread::spawn(move || { - let result = handle_connection(server, &api_tx, &event_hub, &server_running); - done_tx.send(result).unwrap(); - }); - - let ack = read_line(&mut client); - let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); - assert_eq!(ack["result"]["type"], "subscription_started"); - - running.store(false, Ordering::Relaxed); - - let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(result.is_ok()); - server_thread.join().unwrap(); - } -} diff --git a/src/api/server.rs b/src/api/server.rs new file mode 100644 index 00000000..2f9a423e --- /dev/null +++ b/src/api/server.rs @@ -0,0 +1,769 @@ +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tracing::{debug, error, info, warn}; + +use crate::api::schema::{ + ErrorBody, ErrorResponse, Method, Request, ResponseResult, SuccessResponse, +}; +use crate::api::subscriptions::ActiveSubscription; +use crate::api::wait::wait_for_output; +use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub}; + +const SOCKET_PERMISSION_MODE: u32 = 0o600; +pub(super) const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100); +pub(super) const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); +const INITIAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct ServerHandle { + _thread: std::thread::JoinHandle<()>, + path: PathBuf, + running: Arc<AtomicBool>, +} + +impl Drop for ServerHandle { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + + if let Err(err) = fs::remove_file(&self.path) { + if err.kind() != std::io::ErrorKind::NotFound { + warn!(path = %self.path.display(), err = %err, "failed to remove api socket on shutdown"); + } + } + } +} + +pub fn start_server( + api_tx: ApiRequestSender, + event_hub: EventHub, +) -> std::io::Result<ServerHandle> { + let path = socket_path(); + prepare_socket_path(&path)?; + + let listener = UnixListener::bind(&path)?; + restrict_socket_permissions(&path)?; + info!(path = %path.display(), "api server listening"); + + let running = Arc::new(AtomicBool::new(true)); + let listener_running = Arc::clone(&running); + let thread = std::thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let api_tx = api_tx.clone(); + let event_hub = event_hub.clone(); + let connection_running = Arc::clone(&listener_running); + std::thread::spawn(move || { + if let Err(err) = + handle_connection(stream, &api_tx, &event_hub, &connection_running) + { + warn!(err = %err, "api connection failed"); + } + }); + } + Err(err) => { + error!(err = %err, "api listener accept failed"); + break; + } + } + } + debug!("api server thread exiting"); + }); + + Ok(ServerHandle { + _thread: thread, + path, + running, + }) +} + +fn prepare_socket_path(path: &Path) -> std::io::Result<()> { + crate::ipc::prepare_socket_path(path, |path| { + format!( + "herdr is already running (socket busy at {})", + path.display() + ) + }) +} + +fn restrict_socket_permissions(path: &Path) -> std::io::Result<()> { + crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) +} + +fn handle_connection( + mut stream: UnixStream, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc<AtomicBool>, +) -> std::io::Result<()> { + stream.set_read_timeout(Some(INITIAL_REQUEST_TIMEOUT))?; + stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT))?; + + let mut line = String::new(); + { + let mut reader = BufReader::new(&stream); + let read = reader.read_line(&mut line)?; + if read == 0 { + return Ok(()); + } + } + + stream.set_read_timeout(None)?; + + let line = line.trim(); + if line.is_empty() { + return Ok(()); + } + + let request = match serde_json::from_str::<Request>(line) { + Ok(request) => request, + Err(err) => { + write_json_line_allow_disconnect( + &mut stream, + &ErrorResponse { + id: String::new(), + error: ErrorBody { + code: "invalid_request".into(), + message: format!("invalid request: {err}"), + }, + }, + )?; + return Ok(()); + } + }; + + let request_id = request.id.clone(); + let method = api_method_name(&request.method); + let changes_ui = request_changes_ui(&request); + crate::logging::api_request_started(&request_id, method, changes_ui); + + match request.method { + Method::EventsSubscribe(params) => { + let result = stream_subscriptions( + stream, + request_id.clone(), + params, + api_tx, + event_hub, + running, + ); + match &result { + Ok(()) => crate::logging::api_request_completed( + &request_id, + method, + "stream_closed", + changes_ui, + ), + Err(err) => { + crate::logging::api_request_failed(&request_id, method, &err.to_string()) + } + } + result + } + Method::PaneWaitForOutput(params) => { + let Some(response) = + wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)? + else { + crate::logging::api_request_completed( + &request_id, + method, + "client_disconnected", + changes_ui, + ); + return Ok(()); + }; + let result = write_text_line_allow_disconnect(&mut stream, &response); + match &result { + Ok(()) => crate::logging::api_request_completed( + &request_id, + method, + api_response_outcome(&response), + changes_ui, + ), + Err(err) => { + crate::logging::api_request_failed(&request_id, method, &err.to_string()) + } + } + result + } + method_body => { + let response = handle_request( + Request { + id: request_id.clone(), + method: method_body, + }, + api_tx, + ); + let result = write_text_line_allow_disconnect(&mut stream, &response); + match &result { + Ok(()) => crate::logging::api_request_completed( + &request_id, + method, + api_response_outcome(&response), + changes_ui, + ), + Err(err) => { + crate::logging::api_request_failed(&request_id, method, &err.to_string()) + } + } + result + } + } +} + +fn handle_request(request: Request, api_tx: &ApiRequestSender) -> String { + match request.method { + Method::Ping(_) => serde_json::to_string(&SuccessResponse { + id: request.id, + result: ResponseResult::Pong { + version: env!("CARGO_PKG_VERSION").into(), + protocol: crate::protocol::PROTOCOL_VERSION, + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"internal_error","message":"failed to encode response"}}"# + .to_string() + }), + _ => dispatch_to_app(request, api_tx), + } +} + +fn api_method_name(method: &Method) -> &'static str { + match method { + Method::Ping(_) => "ping", + Method::ServerStop(_) => "server.stop", + Method::ServerReloadConfig(_) => "server.reload_config", + Method::WorkspaceCreate(_) => "workspace.create", + Method::WorkspaceList(_) => "workspace.list", + Method::WorkspaceGet(_) => "workspace.get", + Method::WorkspaceFocus(_) => "workspace.focus", + Method::WorkspaceRename(_) => "workspace.rename", + Method::WorkspaceClose(_) => "workspace.close", + Method::TabCreate(_) => "tab.create", + Method::TabList(_) => "tab.list", + Method::TabGet(_) => "tab.get", + 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::AgentStart(_) => "agent.start", + Method::PaneSplit(_) => "pane.split", + Method::PaneList(_) => "pane.list", + Method::PaneGet(_) => "pane.get", + Method::PaneRename(_) => "pane.rename", + Method::PaneSendText(_) => "pane.send_text", + Method::PaneSendKeys(_) => "pane.send_keys", + Method::PaneSendInput(_) => "pane.send_input", + Method::PaneRead(_) => "pane.read", + Method::PaneReportAgent(_) => "pane.report_agent", + Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority", + Method::PaneReleaseAgent(_) => "pane.release_agent", + Method::PaneClose(_) => "pane.close", + Method::EventsSubscribe(_) => "events.subscribe", + Method::EventsWait(_) => "events.wait", + Method::PaneWaitForOutput(_) => "pane.wait_for_output", + Method::IntegrationInstall(_) => "integration.install", + Method::IntegrationUninstall(_) => "integration.uninstall", + } +} + +fn api_response_outcome(response: &str) -> &'static str { + let Ok(value) = serde_json::from_str::<serde_json::Value>(response) else { + return "error"; + }; + + match value + .get("error") + .and_then(|error| error.get("code")) + .and_then(|code| code.as_str()) + { + Some("timeout") => "timeout", + Some(_) => "error", + None => "ok", + } +} + +fn stream_subscriptions( + mut stream: UnixStream, + request_id: String, + params: crate::api::schema::EventsSubscribeParams, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc<AtomicBool>, +) -> std::io::Result<()> { + let mut subscriptions = Vec::with_capacity(params.subscriptions.len()); + for (index, subscription) in params.subscriptions.into_iter().enumerate() { + let active = + match ActiveSubscription::new(subscription, &request_id, index, api_tx, event_hub) { + Ok(active) => active, + Err(response) => { + if let Err(err) = write_json_line(&mut stream, &response) { + if is_connection_closed_error(&err) { + return Ok(()); + } + return Err(err); + } + return Ok(()); + } + }; + subscriptions.push(active); + } + + if let Err(err) = write_json_line( + &mut stream, + &SuccessResponse { + id: request_id, + result: ResponseResult::SubscriptionStarted {}, + }, + ) { + if is_connection_closed_error(&err) { + return Ok(()); + } + return Err(err); + } + + loop { + if should_stop_connection(&mut stream, running)? { + return Ok(()); + } + + for subscription in &mut subscriptions { + if let Some(event) = subscription.poll(api_tx, event_hub) { + if let Err(err) = write_json_line(&mut stream, &event) { + if is_connection_closed_error(&err) { + return Ok(()); + } + return Err(err); + } + } + } + std::thread::sleep(CONNECTION_POLL_INTERVAL); + } +} + +fn write_text_line(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { + stream.write_all(value.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush() +} + +fn write_text_line_allow_disconnect(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { + match write_text_line(stream, value) { + Err(err) if is_connection_closed_error(&err) => Ok(()), + result => result, + } +} + +fn write_json_line<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> std::io::Result<()> { + let encoded = serde_json::to_string(value) + .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; + write_text_line(stream, &encoded) +} + +fn write_json_line_allow_disconnect<T: serde::Serialize>( + stream: &mut UnixStream, + value: &T, +) -> std::io::Result<()> { + let encoded = serde_json::to_string(value) + .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; + write_text_line_allow_disconnect(stream, &encoded) +} + +pub(super) fn should_stop_connection( + stream: &mut UnixStream, + running: &Arc<AtomicBool>, +) -> std::io::Result<bool> { + if !running.load(Ordering::Relaxed) { + return Ok(true); + } + + probe_stream_closed(stream) +} + +fn probe_stream_closed(stream: &mut UnixStream) -> std::io::Result<bool> { + stream.set_nonblocking(true)?; + let mut probe = [0u8; 1]; + let status = match stream.read(&mut probe) { + Ok(0) => Ok(true), + Ok(_) => Ok(true), + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted + ) => + { + Ok(false) + } + Err(err) if is_connection_closed_error(&err) => Ok(true), + Err(err) => Err(err), + }; + stream.set_nonblocking(false)?; + status +} + +fn is_connection_closed_error(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::NotConnected + | std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::WriteZero + ) +} + +fn dispatch_to_app(request: Request, api_tx: &ApiRequestSender) -> String { + dispatch_to_app_with_timeout(request, api_tx, None) +} + +pub(super) fn dispatch_to_app_with_timeout( + request: Request, + api_tx: &ApiRequestSender, + timeout: Option<Duration>, +) -> String { + let request_id = request.id.clone(); + let (respond_to, response_rx) = std::sync::mpsc::channel(); + if let Err(err) = api_tx.send(ApiRequestMessage { + request, + respond_to, + }) { + return error_response_json( + request_id, + "server_unavailable", + format!("failed to dispatch request: {err}"), + ); + } + + let response = match timeout { + Some(timeout) => response_rx.recv_timeout(timeout).map_err(|err| match err { + std::sync::mpsc::RecvTimeoutError::Timeout => std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "timed out waiting for app response after {} ms", + timeout.as_millis() + ), + ), + std::sync::mpsc::RecvTimeoutError::Disconnected => std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "app response channel closed", + ), + }), + None => response_rx + .recv() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::BrokenPipe, err)), + }; + + match response { + Ok(response) => response, + Err(err) => error_response_json( + request_id, + "server_unavailable", + format!("request handling failed: {err}"), + ), + } +} + +fn error_response_json(id: String, code: &str, message: String) -> String { + serde_json::to_string(&ErrorResponse { + id, + error: ErrorBody { + code: code.into(), + message, + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"internal_error","message":"failed to encode error response"}}"# + .to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + use std::sync::{Mutex, OnceLock}; + use tokio::sync::mpsc; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn unique_test_path(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) + } + + fn read_line(stream: &mut UnixStream) -> String { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + line + } + + #[test] + fn socket_path_prefers_explicit_env_override() { + let _guard = env_lock().lock().unwrap(); + let unique = format!("/tmp/herdr-test-{}.sock", std::process::id()); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &unique); + assert_eq!(socket_path(), PathBuf::from(&unique)); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + } + + #[test] + fn socket_path_defaults_to_config_dir_even_when_xdg_runtime_dir_is_set() { + let _guard = env_lock().lock().unwrap(); + let config_home = unique_test_path("socket-default-config-home"); + let runtime_dir = unique_test_path("socket-default-runtime"); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir); + + let expected = config_home + .join(crate::config::app_dir_name()) + .join("herdr.sock"); + assert_eq!(socket_path(), expected); + + std::env::remove_var("XDG_CONFIG_HOME"); + std::env::remove_var("XDG_RUNTIME_DIR"); + } + + #[test] + fn socket_path_uses_named_session_dir() { + let _guard = env_lock().lock().unwrap(); + let config_home = unique_test_path("socket-named-config-home"); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + + let expected = config_home + .join(crate::config::app_dir_name()) + .join("sessions") + .join("work") + .join("herdr.sock"); + assert_eq!(socket_path(), expected); + + std::env::remove_var(crate::session::SESSION_ENV_VAR); + std::env::remove_var("XDG_CONFIG_HOME"); + } + + #[test] + fn restrict_socket_permissions_sets_user_only_mode() { + let dir = unique_test_path("socket-perms"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("api.sock"); + let _listener = UnixListener::bind(&path).unwrap(); + + restrict_socket_permissions(&path).unwrap(); + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, SOCKET_PERMISSION_MODE); + + drop(_listener); + let _ = fs::remove_file(&path); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn api_response_outcome_uses_top_level_error_shape() { + let ok_with_error_text = r#"{"id":"req","result":{"read":{"text":"user said \"error\": \"timeout\"","revision":1}}}"#; + assert_eq!(api_response_outcome(ok_with_error_text), "ok"); + + let timeout = r#"{"id":"req","error":{"code":"timeout","message":"timed out waiting for output match"}}"#; + assert_eq!(api_response_outcome(timeout), "timeout"); + + let generic_error = + r#"{"id":"req","error":{"code":"server_unavailable","message":"boom"}}"#; + assert_eq!(api_response_outcome(generic_error), "error"); + } + + #[test] + fn ping_request_returns_pong() { + let (tx, _rx) = mpsc::unbounded_channel(); + let response = handle_request( + Request { + id: "req_1".into(), + method: Method::Ping(crate::api::schema::PingParams::default()), + }, + &tx, + ); + + let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed.id, "req_1"); + assert!(matches!(parsed.result, ResponseResult::Pong { .. })); + } + + #[test] + fn request_dispatches_to_app_channel() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let request = Request { + id: "req_2".into(), + method: Method::WorkspaceList(crate::api::schema::EmptyParams::default()), + }; + + let request_for_thread = request.clone(); + let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx)); + + let msg = rx.blocking_recv().unwrap(); + assert_eq!(msg.request.id, "req_2"); + msg.respond_to + .send( + serde_json::to_string(&SuccessResponse { + id: "req_2".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(), + ) + .unwrap(); + + let response = thread.join().unwrap(); + let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed.id, "req_2"); + } + + #[test] + fn wait_for_output_stops_when_client_disconnects() { + let (api_tx, mut api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>(); + let (first_read_tx, first_read_rx) = std::sync::mpsc::channel(); + let responder = std::thread::spawn(move || { + let mut notified = false; + while let Some(msg) = api_rx.blocking_recv() { + assert!(matches!(msg.request.method, Method::PaneRead(_))); + if !notified { + first_read_tx.send(()).unwrap(); + notified = true; + } + msg.respond_to + .send( + serde_json::to_string(&SuccessResponse { + id: msg.request.id, + result: ResponseResult::PaneRead { + read: crate::api::schema::PaneReadResult { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + source: crate::api::schema::ReadSource::RecentUnwrapped, + format: crate::api::schema::ReadFormat::Text, + text: String::new(), + revision: 0, + truncated: false, + }, + }, + }) + .unwrap(), + ) + .unwrap(); + } + }); + + let (mut client, server) = UnixStream::pair().unwrap(); + client + .write_all(br#"{"id":"req_wait","method":"pane.wait_for_output","params":{"pane_id":"pane_1","source":"recent","match":{"type":"substring","value":"never"}}}"#) + .unwrap(); + client.write_all(b"\n").unwrap(); + client.flush().unwrap(); + + let running = Arc::new(AtomicBool::new(true)); + let server_running = Arc::clone(&running); + let event_hub = EventHub::default(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let server_thread = std::thread::spawn(move || { + let result = handle_connection(server, &api_tx, &event_hub, &server_running); + done_tx.send(result).unwrap(); + }); + + first_read_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + drop(client); + + let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(result.is_ok()); + + server_thread.join().unwrap(); + drop(running); + responder.join().unwrap(); + } + + #[test] + fn subscriptions_stop_when_client_disconnects() { + let (api_tx, _api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>(); + let (mut client, server) = UnixStream::pair().unwrap(); + client + .write_all( + br#"{"id":"sub_1","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, + ) + .unwrap(); + client.write_all(b"\n").unwrap(); + client.flush().unwrap(); + + let running = Arc::new(AtomicBool::new(true)); + let server_running = Arc::clone(&running); + let event_hub = EventHub::default(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let server_thread = std::thread::spawn(move || { + let result = handle_connection(server, &api_tx, &event_hub, &server_running); + done_tx.send(result).unwrap(); + }); + + let ack = read_line(&mut client); + let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); + assert_eq!(ack["result"]["type"], "subscription_started"); + + drop(client); + + let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(result.is_ok()); + server_thread.join().unwrap(); + } + + #[test] + fn subscriptions_stop_when_server_shuts_down() { + let (api_tx, _api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>(); + let (mut client, server) = UnixStream::pair().unwrap(); + client + .write_all( + br#"{"id":"sub_2","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, + ) + .unwrap(); + client.write_all(b"\n").unwrap(); + client.flush().unwrap(); + + let running = Arc::new(AtomicBool::new(true)); + let server_running = Arc::clone(&running); + let event_hub = EventHub::default(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let server_thread = std::thread::spawn(move || { + let result = handle_connection(server, &api_tx, &event_hub, &server_running); + done_tx.send(result).unwrap(); + }); + + let ack = read_line(&mut client); + let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); + assert_eq!(ack["result"]["type"], "subscription_started"); + + running.store(false, Ordering::Relaxed); + + let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(result.is_ok()); + server_thread.join().unwrap(); + } +} diff --git a/src/api/status.rs b/src/api/status.rs new file mode 100644 index 00000000..f58e69c0 --- /dev/null +++ b/src/api/status.rs @@ -0,0 +1,54 @@ +use std::io; +use std::path::Path; +use std::time::Duration; + +use crate::api::schema::{Method, Request, ResponseResult}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeStatus { + pub version: Option<String>, + pub protocol: Option<u32>, +} + +pub fn read_runtime_status_at( + socket_path: &Path, + timeout: Duration, +) -> io::Result<Option<RuntimeStatus>> { + if !socket_path.exists() { + return Ok(None); + } + + let client = crate::api::client::ApiClient::for_target( + crate::api::client::ConnectionTarget::SocketPath(socket_path.to_path_buf()), + ); + let request = Request { + id: "runtime:status".into(), + method: Method::Ping(crate::api::schema::PingParams::default()), + }; + let response = client + .request_value_with_timeout(&request, timeout) + .and_then(crate::api::client::parse_response_value); + let response = match response { + Ok(response) => response, + Err(crate::api::client::ApiClientError::Io(err)) + if matches!( + err.kind(), + io::ErrorKind::ConnectionRefused + | io::ErrorKind::NotFound + | io::ErrorKind::TimedOut + ) => + { + return Ok(None); + } + Err(err) => return Err(io::Error::other(err)), + }; + match response.result { + ResponseResult::Pong { version, protocol } => Ok(Some(RuntimeStatus { + version: Some(version), + protocol: Some(protocol), + })), + result => Err(io::Error::other(format!( + "server status request returned unexpected result: {result:?}" + ))), + } +} diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs new file mode 100644 index 00000000..891b743a --- /dev/null +++ b/src/api/subscriptions.rs @@ -0,0 +1,368 @@ +use regex::Regex; + +use crate::api::schema::{ + ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, Request, + Subscription, SubscriptionEventData, SubscriptionEventEnvelope, SubscriptionEventKind, +}; +use crate::api::server::{dispatch_to_app_with_timeout, APP_RESPONSE_TIMEOUT}; +use crate::api::{ApiRequestSender, EventHub}; + +pub(super) fn output_match_read_source( + source: &crate::api::schema::ReadSource, +) -> crate::api::schema::ReadSource { + match source { + crate::api::schema::ReadSource::Recent => crate::api::schema::ReadSource::RecentUnwrapped, + other => *other, + } +} + +pub(super) fn match_output( + text: &str, + matcher: &crate::api::schema::OutputMatch, + regex: Option<&Regex>, +) -> Option<String> { + match matcher { + crate::api::schema::OutputMatch::Substring { value } => text + .lines() + .find(|line| line.contains(value)) + .map(|line| line.to_string()), + crate::api::schema::OutputMatch::Regex { .. } => regex.and_then(|re| { + text.lines() + .find(|line| re.is_match(line)) + .map(|line| line.to_string()) + }), + } +} + +pub(super) struct ActiveOutputMatchedSubscription { + pane_id: String, + source: crate::api::schema::ReadSource, + lines: Option<u32>, + matcher: crate::api::schema::OutputMatch, + regex: Option<Regex>, + strip_ansi: bool, + currently_matching: bool, + request_prefix: String, +} + +pub(super) struct ActiveAgentStatusChangedSubscription { + pane_id: String, + status_filter: Option<crate::api::schema::AgentStatus>, + last_status: Option<crate::api::schema::AgentStatus>, + request_prefix: String, +} + +pub(super) struct ActiveEventSubscription { + event_kind: crate::api::schema::EventKind, + last_sequence: u64, +} + +pub(super) enum ActiveSubscription { + Event(ActiveEventSubscription), + OutputMatched(ActiveOutputMatchedSubscription), + AgentStatusChanged(ActiveAgentStatusChangedSubscription), +} + +impl ActiveSubscription { + pub(super) fn new( + subscription: Subscription, + request_id: &str, + index: usize, + api_tx: &ApiRequestSender, + _event_hub: &EventHub, + ) -> Result<Self, ErrorResponse> { + match subscription { + Subscription::WorkspaceCreated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceCreated, + last_sequence: 0, + })), + Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceClosed, + last_sequence: 0, + })), + Subscription::WorkspaceFocused {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceFocused, + last_sequence: 0, + })), + Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabCreated, + last_sequence: 0, + })), + Subscription::TabClosed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabClosed, + last_sequence: 0, + })), + Subscription::TabFocused {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabFocused, + last_sequence: 0, + })), + Subscription::TabRenamed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::TabRenamed, + last_sequence: 0, + })), + Subscription::PaneCreated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneCreated, + last_sequence: 0, + })), + Subscription::PaneClosed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneClosed, + last_sequence: 0, + })), + Subscription::PaneFocused {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneFocused, + last_sequence: 0, + })), + Subscription::PaneExited {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneExited, + last_sequence: 0, + })), + Subscription::PaneAgentDetected {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneAgentDetected, + last_sequence: 0, + })), + Subscription::PaneOutputMatched { + pane_id, + source, + lines, + r#match, + strip_ansi, + } => { + let regex = match &r#match { + crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) { + Ok(regex) => Some(regex), + Err(err) => { + return Err(ErrorResponse { + id: request_id.to_string(), + error: ErrorBody { + code: "invalid_regex".into(), + message: err.to_string(), + }, + }); + } + }, + crate::api::schema::OutputMatch::Substring { .. } => None, + }; + + let probe = pane_read( + format!("{request_id}:sub:{index}:probe"), + &pane_id, + source, + lines, + strip_ansi, + api_tx, + ); + probe?; + + Ok(Self::OutputMatched(ActiveOutputMatchedSubscription { + pane_id, + source, + lines, + matcher: r#match, + regex, + strip_ansi, + currently_matching: false, + request_prefix: format!("{request_id}:sub:{index}"), + })) + } + Subscription::PaneAgentStatusChanged { + pane_id, + agent_status, + } => { + let probe = pane_get(format!("{request_id}:sub:{index}:probe"), &pane_id, api_tx)?; + + Ok(Self::AgentStatusChanged( + ActiveAgentStatusChangedSubscription { + pane_id, + status_filter: agent_status, + last_status: Some(probe.agent_status), + request_prefix: format!("{request_id}:sub:{index}"), + }, + )) + } + } + } + + pub(super) fn poll( + &mut self, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + ) -> Option<serde_json::Value> { + match self { + Self::Event(subscription) => subscription.poll(event_hub), + Self::OutputMatched(subscription) => { + serde_json::to_value(subscription.poll(api_tx)?).ok() + } + Self::AgentStatusChanged(subscription) => { + serde_json::to_value(subscription.poll(api_tx)?).ok() + } + } + } +} + +impl ActiveEventSubscription { + fn poll(&mut self, event_hub: &EventHub) -> Option<serde_json::Value> { + for (sequence, event) in event_hub.events_after(self.last_sequence) { + self.last_sequence = sequence; + if event.event == self.event_kind { + return serde_json::to_value(event).ok(); + } + } + None + } +} + +impl ActiveOutputMatchedSubscription { + fn poll(&mut self, api_tx: &ApiRequestSender) -> Option<SubscriptionEventEnvelope> { + let read = pane_read( + format!("{}:read", self.request_prefix), + &self.pane_id, + output_match_read_source(&self.source), + self.lines, + self.strip_ansi, + api_tx, + ) + .ok()?; + + let matched_line = match_output(&read.text, &self.matcher, self.regex.as_ref()); + match matched_line { + Some(matched_line) => { + if self.currently_matching { + return None; + } + self.currently_matching = true; + Some(SubscriptionEventEnvelope { + event: SubscriptionEventKind::PaneOutputMatched, + data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent { + pane_id: self.pane_id.clone(), + matched_line, + read, + }), + }) + } + None => { + self.currently_matching = false; + None + } + } + } +} + +impl ActiveAgentStatusChangedSubscription { + fn poll(&mut self, api_tx: &ApiRequestSender) -> Option<SubscriptionEventEnvelope> { + let pane = pane_get( + format!("{}:pane", self.request_prefix), + &self.pane_id, + api_tx, + ) + .ok()?; + let current_status = pane.agent_status; + let previous_status = self.last_status.replace(current_status); + if previous_status.is_none() || previous_status == Some(current_status) { + return None; + } + if self + .status_filter + .is_some_and(|wanted| wanted != current_status) + { + return None; + } + + Some(SubscriptionEventEnvelope { + event: SubscriptionEventKind::PaneAgentStatusChanged, + data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent { + pane_id: pane.pane_id, + workspace_id: pane.workspace_id, + agent_status: current_status, + agent: pane.agent, + custom_status: pane.custom_status, + }), + }) + } +} + +fn pane_read( + request_id: String, + pane_id: &str, + source: crate::api::schema::ReadSource, + lines: Option<u32>, + strip_ansi: bool, + api_tx: &ApiRequestSender, +) -> Result<crate::api::schema::PaneReadResult, ErrorResponse> { + let response = dispatch_to_app_with_timeout( + Request { + id: request_id.clone(), + method: Method::PaneRead(crate::api::schema::PaneReadParams { + pane_id: pane_id.to_string(), + source, + lines, + format: crate::api::schema::ReadFormat::Text, + strip_ansi, + }), + }, + api_tx, + Some(APP_RESPONSE_TIMEOUT), + ); + let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse { + id: request_id.clone(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane read response".into(), + }, + })?; + if value.get("error").is_some() { + return serde_json::from_value(value).map_err(|_| ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane read error".into(), + }, + }); + } + serde_json::from_value(value["result"]["read"].clone()).map_err(|_| ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane read result".into(), + }, + }) +} + +fn pane_get( + request_id: String, + pane_id: &str, + api_tx: &ApiRequestSender, +) -> Result<crate::api::schema::PaneInfo, ErrorResponse> { + let response = dispatch_to_app_with_timeout( + Request { + id: request_id.clone(), + method: Method::PaneGet(crate::api::schema::PaneTarget { + pane_id: pane_id.to_string(), + }), + }, + api_tx, + Some(APP_RESPONSE_TIMEOUT), + ); + let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse { + id: request_id.clone(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane get response".into(), + }, + })?; + if value.get("error").is_some() { + return serde_json::from_value(value).map_err(|_| ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane get error".into(), + }, + }); + } + serde_json::from_value(value["result"]["pane"].clone()).map_err(|_| ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane get result".into(), + }, + }) +} diff --git a/src/api/wait.rs b/src/api/wait.rs new file mode 100644 index 00000000..4bce14f2 --- /dev/null +++ b/src/api/wait.rs @@ -0,0 +1,124 @@ +use std::os::unix::net::UnixStream; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use regex::Regex; + +use crate::api::schema::{ + ErrorBody, ErrorResponse, Method, Request, ResponseResult, SuccessResponse, +}; +use crate::api::server::{ + dispatch_to_app_with_timeout, should_stop_connection, APP_RESPONSE_TIMEOUT, + CONNECTION_POLL_INTERVAL, +}; +use crate::api::subscriptions::{match_output, output_match_read_source}; +use crate::api::ApiRequestSender; + +pub(super) fn wait_for_output( + request_id: String, + params: crate::api::schema::PaneWaitForOutputParams, + stream: &mut UnixStream, + api_tx: &ApiRequestSender, + running: &Arc<AtomicBool>, +) -> std::io::Result<Option<String>> { + crate::logging::api_wait_started(&request_id, ¶ms.pane_id, params.timeout_ms); + let deadline = params + .timeout_ms + .map(|ms| std::time::Instant::now() + std::time::Duration::from_millis(ms)); + + let regex = match ¶ms.r#match { + crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) { + Ok(regex) => Some(regex), + Err(err) => { + return Ok(Some( + serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "invalid_regex".into(), + message: err.to_string(), + }, + }) + .unwrap(), + )); + } + }, + crate::api::schema::OutputMatch::Substring { .. } => None, + }; + + loop { + if should_stop_connection(stream, running)? { + crate::logging::api_wait_completed(&request_id, ¶ms.pane_id, "client_disconnected"); + return Ok(None); + } + + let read_request = Request { + id: format!("{request_id}:read"), + method: Method::PaneRead(crate::api::schema::PaneReadParams { + pane_id: params.pane_id.clone(), + source: output_match_read_source(¶ms.source), + lines: params.lines, + format: crate::api::schema::ReadFormat::Text, + strip_ansi: params.strip_ansi, + }), + }; + let response = + dispatch_to_app_with_timeout(read_request, api_tx, Some(APP_RESPONSE_TIMEOUT)); + let Ok(value) = serde_json::from_str::<serde_json::Value>(&response) else { + return Ok(Some(response)); + }; + if value.get("error").is_some() { + let mut value = value; + value["id"] = serde_json::Value::String(request_id.clone()); + return Ok(Some(serde_json::to_string(&value).unwrap())); + } + + let read_value = value["result"]["read"].clone(); + let Ok(read) = serde_json::from_value::<crate::api::schema::PaneReadResult>(read_value) + else { + return Ok(Some( + serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane read result".into(), + }, + }) + .unwrap(), + )); + }; + + let matched_line = match_output(&read.text, ¶ms.r#match, regex.as_ref()); + if matched_line.is_some() { + let revision = read.revision; + crate::logging::api_wait_completed(&request_id, ¶ms.pane_id, "matched"); + return Ok(Some( + serde_json::to_string(&SuccessResponse { + id: request_id, + result: ResponseResult::OutputMatched { + pane_id: params.pane_id, + revision, + matched_line, + read, + }, + }) + .unwrap(), + )); + } + + if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { + crate::logging::api_wait_timed_out(&request_id, ¶ms.pane_id); + return Ok(Some( + serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "timeout".into(), + message: "timed out waiting for output match".into(), + }, + }) + .unwrap(), + )); + } + + std::thread::sleep(CONNECTION_POLL_INTERVAL); + } +} diff --git a/src/app/actions.rs b/src/app/actions.rs index 139f7cd4..0c722125 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -461,11 +461,11 @@ impl AppState { .any(|pane| pane.attached_terminal_id == terminal_id) }) }); - if !still_attached { - self.terminals.remove(&terminal_id); - if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { - runtime.shutdown(); - } + if !still_attached + && self.terminals.remove(&terminal_id).is_some() + && !self.terminal_runtime_shutdowns.contains(&terminal_id) + { + self.terminal_runtime_shutdowns.push(terminal_id); } } } @@ -682,7 +682,7 @@ impl AppState { self.selection_autoscroll = None; } - pub fn copy_selection(&mut self) { + pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) { let mut sel = match self.selection.take() { Some(sel) => sel, None => return, @@ -697,7 +697,7 @@ impl AppState { }; let text = self - .runtime_for_pane_in_workspace(ws_idx, sel.pane_id) + .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id) .and_then(|rt| rt.extract_selection(&sel)); if let Some(text) = text { @@ -717,7 +717,11 @@ impl AppState { // --------------------------------------------------------------------------- impl AppState { - pub fn apply_workspace_git_statuses(&mut self, results: Vec<WorkspaceGitStatus>) -> bool { + pub fn apply_workspace_git_statuses( + &mut self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + results: Vec<WorkspaceGitStatus>, + ) -> bool { let mut changed = false; for result in results { let Some(ws_idx) = self @@ -729,7 +733,7 @@ impl AppState { }; if self.workspaces[ws_idx] - .resolved_identity_cwd_from(&self.terminals, &self.terminal_runtimes) + .resolved_identity_cwd_from(&self.terminals, terminal_runtimes) .as_ref() != Some(&result.resolved_identity_cwd) { @@ -833,7 +837,7 @@ impl AppState { // dispatch; never touches AppState. AppEvent::ClipboardWrite { .. } => Vec::new(), AppEvent::GitStatusRefreshed { results } => { - self.apply_workspace_git_statuses(results); + let _ = results; Vec::new() } } @@ -1019,12 +1023,16 @@ mod tests { let first_cwd = state.workspaces[0].resolved_identity_cwd().unwrap(); let second_id = state.workspaces[1].id.clone(); - let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus { - workspace_id: first_id, - resolved_identity_cwd: first_cwd, - branch: Some("main".into()), - ahead_behind: Some((2, 1)), - }]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let changed = state.apply_workspace_git_statuses( + &terminal_runtimes, + vec![WorkspaceGitStatus { + workspace_id: first_id, + resolved_identity_cwd: first_cwd, + branch: Some("main".into()), + ahead_behind: Some((2, 1)), + }], + ); assert!(changed); assert_eq!(state.workspaces[0].branch().as_deref(), Some("main")); @@ -1040,12 +1048,16 @@ mod tests { state.workspaces[0].cached_git_branch = Some("old".into()); state.workspaces[0].cached_git_ahead_behind = Some((1, 0)); - let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"), - branch: Some("main".into()), - ahead_behind: Some((0, 1)), - }]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let changed = state.apply_workspace_git_statuses( + &terminal_runtimes, + vec![WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"), + branch: Some("main".into()), + ahead_behind: Some((0, 1)), + }], + ); assert!(!changed); assert_eq!(state.workspaces[0].branch().as_deref(), Some("old")); @@ -1060,12 +1072,16 @@ mod tests { state.workspaces[0].cached_git_branch = Some("main".into()); state.workspaces[0].cached_git_ahead_behind = Some((1, 2)); - let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: cwd, - branch: None, - ahead_behind: None, - }]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let changed = state.apply_workspace_git_statuses( + &terminal_runtimes, + vec![WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd, + branch: None, + ahead_behind: None, + }], + ); assert!(changed); assert_eq!(state.workspaces[0].branch(), None); diff --git a/src/app/agents.rs b/src/app/agents.rs index 15b93674..f36ba1ca 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -336,9 +336,7 @@ impl App { self.render_dirty.clone(), ) .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let ws_idx = self.state.workspaces.len() - 1; @@ -386,8 +384,7 @@ impl App { target: target_pane.raw().to_string(), })? .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.state - .terminal_runtimes + self.terminal_runtimes .insert(result.1.terminal.id.clone(), result.1.runtime); self.state .terminals diff --git a/src/app/api.rs b/src/app/api.rs index 6eb95f03..c8ae530a 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1,13 +1,14 @@ use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; -use super::{ - api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, - normalize_reported_agent_label, pane_agent_status, - }, - App, Mode, OverlayPaneState, ToastKind, -}; +mod agents; +mod integrations; +mod panes; +mod responses; +mod tabs; +mod workspaces; + +use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind}; use crate::events::AppEvent; impl App { @@ -20,7 +21,10 @@ impl App { if let AppEvent::GitStatusRefreshed { results } = ev { self.git_refresh_in_flight = false; self.last_git_remote_status_refresh = Instant::now(); - if self.state.apply_workspace_git_statuses(results) { + if self + .state + .apply_workspace_git_statuses(&self.terminal_runtimes, results) + { self.render_dirty.store(true, Ordering::Release); self.render_notify.notify_one(); } @@ -75,8 +79,11 @@ impl App { if let Some((pane_id, agent)) = released_agent { if pane_updates.iter().any(|update| update.pane_id == pane_id) { if let Some((ws_idx, _)) = self.find_pane(pane_id) { - if let Some(runtime) = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id) - { + if let Some(runtime) = self.state.runtime_for_pane_in_workspace( + &self.terminal_runtimes, + ws_idx, + pane_id, + ) { runtime.begin_graceful_release(agent); } } @@ -156,6 +163,7 @@ impl App { } self.sync_toast_deadline(previous_toast); + self.shutdown_detached_terminal_runtimes(); } fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) { @@ -293,12 +301,10 @@ impl App { pane_id: crate::layout::PaneId, event: crate::ghostty::FocusEvent, ) { - let Some(runtime) = self - .state - .workspaces - .get(ws_idx) - .and_then(|_| self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)) - else { + let Some(runtime) = self.state.workspaces.get(ws_idx).and_then(|_| { + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + }) else { return; }; runtime.try_send_focus_event(event); @@ -306,13 +312,7 @@ impl App { pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { self.drain_internal_events(); - use bytes::Bytes; - - use crate::api::schema::{ - ErrorBody, ErrorResponse, IntegrationInstallResult, IntegrationUninstallResult, Method, - PaneListParams, PaneReadResult, ReadFormat, ReadSource, ResponseResult, - SuccessResponse, TabListParams, - }; + use crate::api::schema::{Method, ResponseResult, SuccessResponse}; let response = match request.method { Method::ServerStop(_) => { @@ -332,1280 +332,65 @@ impl App { }, } } - Method::WorkspaceList(_) => SuccessResponse { - id: request.id, - result: ResponseResult::WorkspaceList { - workspaces: self - .state - .workspaces - .iter() - .enumerate() - .map(|(idx, _)| self.workspace_info(idx)) - .collect(), - }, - }, - Method::WorkspaceGet(target) => { - let Some(index) = self.parse_workspace_id(&target.workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - }; - let Some(_) = self.state.workspaces.get(index) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - }; - SuccessResponse { - id: request.id, - result: ResponseResult::WorkspaceInfo { - workspace: self.workspace_info(index), - }, - } - } + Method::WorkspaceList(_) => return self.handle_workspace_list(request.id), + Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target), Method::WorkspaceCreate(params) => { - let cwd = params - .cwd - .map(std::path::PathBuf::from) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); - match self.create_workspace_with_options(cwd, params.focus) { - Ok(index) => { - if let Some(label) = params.label { - if let Some(workspace) = self.state.workspaces.get_mut(index) { - workspace.set_custom_name(label); - crate::logging::workspace_renamed(&workspace.id); - } - } - let workspace = self.workspace_info(index); - let tab = self - .tab_info(index, 0) - .expect("new workspace should have an initial tab"); - let root_pane = self - .root_pane_info(index, 0) - .expect("new workspace should have an initial root pane"); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceCreated, - data: crate::api::schema::EventData::WorkspaceCreated { - workspace: workspace.clone(), - }, - }); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabCreated, - data: crate::api::schema::EventData::TabCreated { tab: tab.clone() }, - }); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneCreated, - data: crate::api::schema::EventData::PaneCreated { - pane: root_pane.clone(), - }, - }); - SuccessResponse { - id: request.id, - result: self - .workspace_created_result(index) - .expect("new workspace should produce a complete create response"), - } - } - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_create_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } + return self.handle_workspace_create(request.id, params); } Method::WorkspaceFocus(target) => { - let Some(index) = self.parse_workspace_id(&target.workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - }; - if self.state.workspaces.get(index).is_none() { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - } - self.state.switch_workspace(index); - SuccessResponse { - id: request.id, - result: ResponseResult::WorkspaceInfo { - workspace: self.workspace_info(index), - }, - } + return self.handle_workspace_focus(request.id, target) } Method::WorkspaceRename(params) => { - let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", params.workspace_id), - }, - }) - .unwrap(); - }; - let Some(ws) = self.state.workspaces.get_mut(index) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", params.workspace_id), - }, - }) - .unwrap(); - }; - ws.set_custom_name(params.label.clone()); - crate::logging::workspace_renamed(&ws.id); - self.schedule_session_save(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceRenamed, - data: crate::api::schema::EventData::WorkspaceRenamed { - workspace_id: self.public_workspace_id(index), - label: params.label, - }, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::WorkspaceInfo { - workspace: self.workspace_info(index), - }, - } + return self.handle_workspace_rename(request.id, params); } Method::WorkspaceClose(target) => { - let Some(index) = self.parse_workspace_id(&target.workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - }; - if self.state.workspaces.get(index).is_none() { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", target.workspace_id), - }, - }) - .unwrap(); - } - self.state.selected = index; - self.state.close_selected_workspace(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceClosed, - data: crate::api::schema::EventData::WorkspaceClosed { - workspace_id: target.workspace_id, - }, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::TabList(TabListParams { workspace_id }) => { - let tabs = if let Some(workspace_id) = workspace_id { - let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", workspace_id), - }, - }) - .unwrap(); - }; - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", workspace_id), - }, - }) - .unwrap(); - }; - (0..ws.tabs.len()) - .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx)) - .collect() - } else { - let mut tabs = Vec::new(); - for (ws_idx, ws) in self.state.workspaces.iter().enumerate() { - for tab_idx in 0..ws.tabs.len() { - if let Some(tab) = self.tab_info(ws_idx, tab_idx) { - tabs.push(tab); - } - } - } - tabs - }; - SuccessResponse { - id: request.id, - result: ResponseResult::TabList { tabs }, - } - } - Method::TabGet(target) => { - let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - let Some(tab) = self.tab_info(ws_idx, tab_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - SuccessResponse { - id: request.id, - result: ResponseResult::TabInfo { tab }, - } - } - Method::TabCreate(params) => { - let crate::api::schema::TabCreateParams { - workspace_id, - cwd, - focus, - label, - } = params; - let ws_idx = if let Some(workspace_id) = workspace_id { - let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: format!("workspace {} not found", workspace_id), - }, - }) - .unwrap(); - }; - ws_idx - } else if let Some(active) = self.state.active { - active - } else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "workspace_not_found".into(), - message: "no active workspace".into(), - }, - }) - .unwrap(); - }; - let cwd = cwd - .map(std::path::PathBuf::from) - .or_else(|| { - self.state - .focused_runtime_in_workspace(ws_idx) - .and_then(|rt| rt.cwd()) - }) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); - let (rows, cols) = self.state.estimate_pane_size(); - let default_shell = self.state.default_shell.clone(); - let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; - let host_terminal_theme = self.state.host_terminal_theme; - let result = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("workspace disappeared")) - .and_then(|ws| { - ws.create_tab( - rows, - cols, - cwd, - scrollback_limit_bytes, - host_terminal_theme, - &default_shell, - ) - }); - match result { - Ok((tab_idx, terminal, runtime)) => { - self.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); - self.state.terminals.insert(terminal.id.clone(), terminal); - if let Some(label) = label { - let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let tab_id = self - .public_tab_id(ws_idx, tab_idx) - .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1)); - if let Some(tab) = self - .state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - { - tab.set_custom_name(label); - crate::logging::tab_renamed(&workspace_id, &tab_id); - } - } - if focus { - self.state.switch_workspace(ws_idx); - self.state.switch_tab(tab_idx); - self.state.mode = Mode::Terminal; - } - self.schedule_session_save(); - let tab = self.tab_info(ws_idx, tab_idx).unwrap(); - let root_pane = self - .root_pane_info(ws_idx, tab_idx) - .expect("new tab should have a root pane"); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabCreated, - data: crate::api::schema::EventData::TabCreated { tab: tab.clone() }, - }); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneCreated, - data: crate::api::schema::EventData::PaneCreated { - pane: root_pane.clone(), - }, - }); - SuccessResponse { - id: request.id, - result: self - .tab_created_result(ws_idx, tab_idx) - .expect("new tab should produce a complete create response"), - } - } - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_create_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } - } - Method::TabFocus(target) => { - let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - self.state.switch_workspace(ws_idx); - self.state.switch_tab(tab_idx); - let tab = self.tab_info(ws_idx, tab_idx).unwrap(); - SuccessResponse { - id: request.id, - result: ResponseResult::TabInfo { tab }, - } - } - Method::TabRename(params) => { - let Some((ws_idx, tab_idx)) = self.parse_tab_id(¶ms.tab_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", params.tab_id), - }, - }) - .unwrap(); - }; - let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let tab_id = self - .public_tab_id(ws_idx, tab_idx) - .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1)); - let Some(tab) = self - .state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", params.tab_id), - }, - }) - .unwrap(); - }; - tab.set_custom_name(params.label.clone()); - crate::logging::tab_renamed(&workspace_id, &tab_id); - self.schedule_session_save(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabRenamed, - data: crate::api::schema::EventData::TabRenamed { - tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), - workspace_id: self.public_workspace_id(ws_idx), - label: params.label, - }, - }); - let tab = self.tab_info(ws_idx, tab_idx).unwrap(); - SuccessResponse { - id: request.id, - result: ResponseResult::TabInfo { tab }, - } - } - Method::TabClose(target) => { - let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_not_found".into(), - message: format!("tab {} not found", target.tab_id), - }, - }) - .unwrap(); - }; - if ws.tabs.len() <= 1 { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_close_failed".into(), - message: "cannot close the last tab in a workspace".into(), - }, - }) - .unwrap(); - } - if !ws.close_tab(tab_idx) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "tab_close_failed".into(), - message: format!("tab {} could not be closed", target.tab_id), - }, - }) - .unwrap(); - } - self.state.remove_unattached_terminal_ids(terminal_ids); - self.schedule_session_save(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabClosed, - data: crate::api::schema::EventData::TabClosed { - tab_id: target.tab_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - SuccessResponse { - id: request.id, - 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::AgentStart(params) => { - let (agent, argv) = match self.start_agent(params) { - Ok(started) => started, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: self.agent_start_error_body(err), - }) - .unwrap(); - } - }; - SuccessResponse { - id: request.id, - result: ResponseResult::AgentStarted { agent, argv }, - } - } - Method::AgentRead(params) => { - let resolved = match self.resolve_terminal_target(¶ms.target) { - Ok(resolved) => resolved, - 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 { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.target_pane_id), - }, - }) - .unwrap(); - }; - let (rows, cols) = self.state.estimate_pane_size(); - let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { - self.state.workspaces.get(ws_idx).and_then(|ws| { - let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; - ws.tabs.get(tab_idx)?.cwd_for_pane( - target_pane_id, - &self.state.terminals, - &self.state.terminal_runtimes, - ) - }) - }); - let default_shell = self.state.default_shell.clone(); - let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; - let host_terminal_theme = self.state.host_terminal_theme; - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.target_pane_id), - }, - }) - .unwrap(); - }; - let direction = match params.direction { - crate::api::schema::SplitDirection::Right => { - ratatui::layout::Direction::Horizontal - } - crate::api::schema::SplitDirection::Down => { - ratatui::layout::Direction::Vertical - } - }; - let (target_tab_idx, new_pane) = match ws.split_pane( - target_pane_id, - direction, - rows, - cols, - split_cwd, - scrollback_limit_bytes, - host_terminal_theme, - &default_shell, - params.focus, - ) { - Some(Ok(result)) => result, - Some(Err(err)) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_split_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - None => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.target_pane_id), - }, - }) - .unwrap(); - } - }; - if params.focus { - self.state.switch_workspace(ws_idx); - self.state.switch_tab(target_tab_idx); - self.state.mode = Mode::Terminal; - } - self.state - .terminal_runtimes - .insert(new_pane.terminal.id.clone(), new_pane.runtime); - self.state - .terminals - .insert(new_pane.terminal.id.clone(), new_pane.terminal); - self.schedule_session_save(); - let pane = self.pane_info(ws_idx, new_pane.pane_id).unwrap(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneCreated, - data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() }, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::PaneInfo { pane }, - } - } - Method::PaneList(PaneListParams { workspace_id }) => { - match self.collect_panes_for_workspace(workspace_id.as_deref()) { - Ok(panes) => SuccessResponse { - id: request.id, - result: ResponseResult::PaneList { panes }, - }, - Err((code, message)) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { code, message }, - }) - .unwrap(); - } - } - } - Method::PaneGet(target) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", target.pane_id), - }, - }) - .unwrap(); - }; - let Some(pane) = self.pane_info(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", target.pane_id), - }, - }) - .unwrap(); - }; - SuccessResponse { - id: request.id, - result: ResponseResult::PaneInfo { pane }, - } - } - Method::PaneRename(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(terminal_id) = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.terminal_id(pane_id)) - .cloned() - else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - match params.label.map(|label| label.trim().to_string()) { - Some(label) if !label.is_empty() => terminal.set_manual_label(label), - _ => terminal.clear_manual_label(), - } - self.state.mark_session_dirty(); - let pane = self.pane_info(ws_idx, pane_id).unwrap(); - SuccessResponse { - id: request.id, - result: ResponseResult::PaneInfo { pane }, - } - } - Method::PaneRead(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(tab_idx) = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.find_tab_index_for_pane(pane_id)) - else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .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: params.pane_id, - workspace_id, - tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), - source: params.source, - format: params.format, - text, - revision: 0, - truncated: false, - }, - }, - } - } + return self.handle_workspace_close(request.id, target) + } + Method::TabList(params) => return self.handle_tab_list(request.id, params), + Method::TabGet(target) => return self.handle_tab_get(request.id, target), + Method::TabCreate(params) => return self.handle_tab_create(request.id, params), + Method::TabFocus(target) => return self.handle_tab_focus(request.id, target), + Method::TabRename(params) => return self.handle_tab_rename(request.id, params), + Method::TabClose(target) => return self.handle_tab_close(request.id, target), + Method::AgentList(_) => return self.handle_agent_list(request.id), + Method::AgentGet(target) => return self.handle_agent_get(request.id, target), + Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target), + Method::AgentRename(params) => return self.handle_agent_rename(request.id, params), + Method::AgentStart(params) => return self.handle_agent_start(request.id, params), + Method::AgentRead(params) => return self.handle_agent_read(request.id, params), + Method::AgentSend(params) => return self.handle_agent_send(request.id, params), + Method::PaneSplit(params) => return self.handle_pane_split(request.id, params), + Method::PaneList(params) => return self.handle_pane_list(request.id, params), + Method::PaneGet(target) => return self.handle_pane_get(request.id, target), + Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), + Method::PaneRead(params) => return self.handle_pane_read(request.id, params), Method::PaneReportAgent(params) => { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "invalid_agent".into(), - message: "agent label must not be empty".into(), - }, - }) - .unwrap(); - }; - self.handle_internal_event(crate::events::AppEvent::HookStateReported { - pane_id, - source: params.source, - agent_label, - state: detect_state_from_api(params.state), - message: params.message, - custom_status: normalize_custom_status(params.custom_status), - seq: params.seq, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } + return self.handle_pane_report_agent(request.id, params); } Method::PaneClearAgentAuthority(params) => { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared { - pane_id, - source: params.source, - seq: params.seq, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } + return self.handle_pane_clear_agent_authority(request.id, params); } Method::PaneReleaseAgent(params) => { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "invalid_agent".into(), - message: "agent label must not be empty".into(), - }, - }) - .unwrap(); - }; - self.handle_internal_event(crate::events::AppEvent::HookAgentReleased { - pane_id, - source: params.source, - known_agent: crate::detect::parse_agent_label(&agent_label), - agent_label, - seq: params.seq, - }); - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::PaneSendText(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .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: "pane_send_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } + return self.handle_pane_release_agent(request.id, params); } + Method::PaneSendText(params) => return self.handle_pane_send_text(request.id, params), Method::PaneSendInput(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { - Ok(encoded_keys) => encoded_keys, - Err(key) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "invalid_key".into(), - message: format!("unsupported key {key}"), - }, - }) - .unwrap(); - } - }; - if !params.text.is_empty() { - let text_bytes = encode_api_text(runtime, ¶ms.text); - if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_send_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } - for bytes in encoded_keys { - if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_send_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::PaneClose(target) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", target.pane_id), - }, - }) - .unwrap(); - }; - let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); - let should_close_workspace = { - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", target.pane_id), - }, - }) - .unwrap(); - }; - ws.close_pane(pane_id) - }; - if should_close_workspace { - self.state.selected = ws_idx; - self.state.close_selected_workspace(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneClosed, - data: crate::api::schema::EventData::PaneClosed { - pane_id: target.pane_id.clone(), - workspace_id: workspace_id.clone(), - }, - }); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceClosed, - data: crate::api::schema::EventData::WorkspaceClosed { workspace_id }, - }); - } else { - self.state.remove_unattached_terminal_ids(terminal_id); - self.schedule_session_save(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneClosed, - data: crate::api::schema::EventData::PaneClosed { - pane_id: target.pane_id, - workspace_id, - }, - }); - } - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } - } - Method::PaneSendKeys(params) => { - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", params.pane_id), - }, - }) - .unwrap(); - }; - let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { - Ok(encoded_keys) => encoded_keys, - Err(key) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "invalid_key".into(), - message: format!("unsupported key {key}"), - }, - }) - .unwrap(); - } - }; - for bytes in encoded_keys { - if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_send_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - } - SuccessResponse { - id: request.id, - result: ResponseResult::Ok {}, - } + return self.handle_pane_send_input(request.id, params) } + Method::PaneClose(target) => return self.handle_pane_close(request.id, target), + Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params), Method::IntegrationInstall(params) => { - let target = params.target; - let messages = match crate::integration::install_target(target) { - Ok(messages) => messages, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "integration_install_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - }; - - SuccessResponse { - id: request.id, - result: ResponseResult::IntegrationInstall { - target, - details: IntegrationInstallResult { messages }, - }, - } + return self.handle_integration_install(request.id, params); } Method::IntegrationUninstall(params) => { - let target = params.target; - let messages = match crate::integration::uninstall_target(target) { - Ok(messages) => messages, - Err(err) => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "integration_uninstall_failed".into(), - message: err.to_string(), - }, - }) - .unwrap(); - } - }; - - SuccessResponse { - id: request.id, - result: ResponseResult::IntegrationUninstall { - target, - details: IntegrationUninstallResult { messages }, - }, - } + return self.handle_integration_uninstall(request.id, params); } _ => { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "not_implemented".into(), - message: "method not implemented yet".into(), - }, - }) - .unwrap(); + return responses::encode_error( + request.id, + "not_implemented", + "method not implemented yet", + ); } }; diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs new file mode 100644 index 00000000..4077b38c --- /dev/null +++ b/src/app/api/agents.rs @@ -0,0 +1,127 @@ +use bytes::Bytes; + +use crate::api::schema::{ + AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, PaneReadResult, ReadFormat, + ReadSource, ResponseResult, +}; +use crate::app::App; + +use super::responses::{encode_error, encode_error_body, encode_success}; + +impl App { + pub(super) fn handle_agent_list(&mut self, id: String) -> String { + encode_success( + id, + ResponseResult::AgentList { + agents: self.collect_agent_infos(), + }, + ) + } + + pub(super) fn handle_agent_get(&mut self, id: String, target: AgentTarget) -> String { + let agent = match self.agent_info_for_target(&target.target) { + Ok(agent) => agent, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentInfo { agent }) + } + + pub(super) fn handle_agent_focus(&mut self, id: String, target: AgentTarget) -> String { + let agent = match self.focus_agent_target(&target.target) { + Ok(agent) => agent, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentInfo { agent }) + } + + pub(super) fn handle_agent_rename(&mut self, id: String, params: AgentRenameParams) -> String { + let agent = match self.rename_agent_target(¶ms.target, params.name) { + Ok(agent) => agent, + Err(err) => return encode_error_body(id, self.agent_rename_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentInfo { agent }) + } + + pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String { + let (agent, argv) = match self.start_agent(params) { + Ok(started) => started, + Err(err) => return encode_error_body(id, self.agent_start_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentStarted { agent, argv }) + } + + pub(super) fn handle_agent_read( + &mut self, + id: String, + params: crate::api::schema::AgentReadParams, + ) -> String { + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + let Some((pane, workspace_id)) = self.lookup_runtime(resolved.ws_idx, resolved.pane_id) + else { + return agent_not_found(id, ¶ms.target); + }; + 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), + }, + }; + + encode_success( + id, + 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, + }, + }, + ) + } + + pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String { + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { + return agent_not_found(id, ¶ms.target); + }; + if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { + return encode_error(id, "agent_send_failed", err.to_string()); + } + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn agent_not_found(id: String, target: &str) -> String { + encode_error( + id, + "agent_not_found", + format!("agent target {target} not found"), + ) +} diff --git a/src/app/api/integrations.rs b/src/app/api/integrations.rs new file mode 100644 index 00000000..0cca8cba --- /dev/null +++ b/src/app/api/integrations.rs @@ -0,0 +1,46 @@ +use crate::api::schema::{IntegrationInstallResult, IntegrationUninstallResult, ResponseResult}; +use crate::app::App; + +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_integration_install( + &mut self, + id: String, + params: crate::api::schema::IntegrationInstallParams, + ) -> String { + let target = params.target; + let messages = match crate::integration::install_target(target) { + Ok(messages) => messages, + Err(err) => return encode_error(id, "integration_install_failed", err.to_string()), + }; + + encode_success( + id, + ResponseResult::IntegrationInstall { + target, + details: IntegrationInstallResult { messages }, + }, + ) + } + + pub(super) fn handle_integration_uninstall( + &mut self, + id: String, + params: crate::api::schema::IntegrationUninstallParams, + ) -> String { + let target = params.target; + let messages = match crate::integration::uninstall_target(target) { + Ok(messages) => messages, + Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()), + }; + + encode_success( + id, + ResponseResult::IntegrationUninstall { + target, + details: IntegrationUninstallResult { messages }, + }, + ) + } +} diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs new file mode 100644 index 00000000..b09ecb2a --- /dev/null +++ b/src/app/api/panes.rs @@ -0,0 +1,353 @@ +use bytes::Bytes; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneListParams, + PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, + PaneReportAgentParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, + PaneSplitParams, PaneTarget, ReadFormat, ReadSource, ResponseResult, +}; +use crate::app::{App, Mode}; + +use super::super::api_helpers::{ + detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, + normalize_reported_agent_label, +}; +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String { + let Some((ws_idx, target_pane_id)) = self.parse_pane_id(¶ms.target_pane_id) else { + return pane_not_found(id, ¶ms.target_pane_id); + }; + let (rows, cols) = self.state.estimate_pane_size(); + let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { + self.state.workspaces.get(ws_idx).and_then(|ws| { + let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; + ws.tabs.get(tab_idx)?.cwd_for_pane( + target_pane_id, + &self.state.terminals, + &self.terminal_runtimes, + ) + }) + }); + let default_shell = self.state.default_shell.clone(); + let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; + let host_terminal_theme = self.state.host_terminal_theme; + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return pane_not_found(id, ¶ms.target_pane_id); + }; + let direction = match params.direction { + crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal, + crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical, + }; + let (target_tab_idx, new_pane) = match ws.split_pane( + target_pane_id, + direction, + rows, + cols, + split_cwd, + scrollback_limit_bytes, + host_terminal_theme, + &default_shell, + params.focus, + ) { + Some(Ok(result)) => result, + Some(Err(err)) => return encode_error(id, "pane_split_failed", err.to_string()), + None => return pane_not_found(id, ¶ms.target_pane_id), + }; + if params.focus { + self.state.switch_workspace(ws_idx); + self.state.switch_tab(target_tab_idx); + self.state.mode = Mode::Terminal; + } + self.terminal_runtimes + .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); + self.schedule_session_save(); + let pane = self.pane_info(ws_idx, new_pane.pane_id).unwrap(); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { pane: pane.clone() }, + }); + + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_list(&mut self, id: String, params: PaneListParams) -> String { + match self.collect_panes_for_workspace(params.workspace_id.as_deref()) { + Ok(panes) => encode_success(id, ResponseResult::PaneList { panes }), + Err((code, message)) => encode_error(id, &code, message), + } + } + + pub(super) fn handle_pane_get(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_rename(&mut self, id: String, params: PaneRenameParams) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .cloned() + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + match params.label.map(|label| label.trim().to_string()) { + Some(label) if !label.is_empty() => terminal.set_manual_label(label), + _ => terminal.clear_manual_label(), + } + self.state.mark_session_dirty(); + let pane = self.pane_info(ws_idx, pane_id).unwrap(); + + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_read(&mut self, id: String, params: PaneReadParams) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(tab_idx) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.find_tab_index_for_pane(pane_id)) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + 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), + }, + }; + + encode_success( + id, + ResponseResult::PaneRead { + read: PaneReadResult { + pane_id: params.pane_id, + workspace_id, + tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), + source: params.source, + format: params.format, + text, + revision: 0, + truncated: false, + }, + }, + ) + } + + pub(super) fn handle_pane_report_agent( + &mut self, + id: String, + params: PaneReportAgentParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { + return invalid_agent(id); + }; + self.handle_internal_event(crate::events::AppEvent::HookStateReported { + pane_id, + source: params.source, + agent_label, + state: detect_state_from_api(params.state), + message: params.message, + custom_status: normalize_custom_status(params.custom_status), + seq: params.seq, + }); + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_clear_agent_authority( + &mut self, + id: String, + params: PaneClearAgentAuthorityParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared { + pane_id, + source: params.source, + seq: params.seq, + }); + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_release_agent( + &mut self, + id: String, + params: PaneReleaseAgentParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { + return invalid_agent(id); + }; + self.handle_internal_event(crate::events::AppEvent::HookAgentReleased { + pane_id, + source: params.source, + known_agent: crate::detect::parse_agent_label(&agent_label), + agent_label, + seq: params.seq, + }); + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_send_text( + &mut self, + id: String, + params: PaneSendTextParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_send_input( + &mut self, + id: String, + params: PaneSendInputParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded_keys) => encoded_keys, + Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), + }; + if !params.text.is_empty() { + let text_bytes = encode_api_text(runtime, ¶ms.text); + if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + } + for bytes in encoded_keys { + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + } + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_close(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let workspace_id = self.state.workspaces[ws_idx].id.clone(); + let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); + let should_close_workspace = { + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return pane_not_found(id, &target.pane_id); + }; + ws.close_pane(pane_id) + }; + if should_close_workspace { + self.state.selected = ws_idx; + self.state.close_selected_workspace(); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::PaneClosed, + data: EventData::PaneClosed { + pane_id: target.pane_id.clone(), + workspace_id: workspace_id.clone(), + }, + }); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { workspace_id }, + }); + } else { + self.state.remove_unattached_terminal_ids(terminal_id); + self.shutdown_detached_terminal_runtimes(); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::PaneClosed, + data: EventData::PaneClosed { + pane_id: target.pane_id, + workspace_id, + }, + }); + } + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_send_keys( + &mut self, + id: String, + params: PaneSendKeysParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded_keys) => encoded_keys, + Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), + }; + for bytes in encoded_keys { + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + } + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn pane_not_found(id: String, pane_id: &str) -> String { + encode_error(id, "pane_not_found", format!("pane {pane_id} not found")) +} + +fn invalid_agent(id: String) -> String { + encode_error(id, "invalid_agent", "agent label must not be empty") +} diff --git a/src/app/api/responses.rs b/src/app/api/responses.rs new file mode 100644 index 00000000..7c871c37 --- /dev/null +++ b/src/app/api/responses.rs @@ -0,0 +1,19 @@ +use crate::api::schema::{ErrorBody, ErrorResponse, ResponseResult, SuccessResponse}; + +pub(super) fn encode_success(id: String, result: ResponseResult) -> String { + serde_json::to_string(&SuccessResponse { id, result }).unwrap() +} + +pub(super) fn encode_error(id: String, code: &str, message: impl Into<String>) -> String { + encode_error_body( + id, + ErrorBody { + code: code.into(), + message: message.into(), + }, + ) +} + +pub(super) fn encode_error_body(id: String, error: ErrorBody) -> String { + serde_json::to_string(&ErrorResponse { id, error }).unwrap() +} diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs new file mode 100644 index 00000000..7978a17f --- /dev/null +++ b/src/app/api/tabs.rs @@ -0,0 +1,233 @@ +use std::path::PathBuf; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, ResponseResult, TabCreateParams, TabListParams, + TabRenameParams, TabTarget, +}; +use crate::app::{App, Mode}; + +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_tab_list(&mut self, id: String, params: TabListParams) -> String { + let tabs = if let Some(workspace_id) = params.workspace_id { + let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { + return workspace_not_found(id, &workspace_id); + }; + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return workspace_not_found(id, &workspace_id); + }; + (0..ws.tabs.len()) + .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx)) + .collect() + } else { + let mut tabs = Vec::new(); + for (ws_idx, ws) in self.state.workspaces.iter().enumerate() { + for tab_idx in 0..ws.tabs.len() { + if let Some(tab) = self.tab_info(ws_idx, tab_idx) { + tabs.push(tab); + } + } + } + tabs + }; + + encode_success(id, ResponseResult::TabList { tabs }) + } + + pub(super) fn handle_tab_get(&mut self, id: String, target: TabTarget) -> String { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { + return tab_not_found(id, &target.tab_id); + }; + let Some(tab) = self.tab_info(ws_idx, tab_idx) else { + return tab_not_found(id, &target.tab_id); + }; + + encode_success(id, ResponseResult::TabInfo { tab }) + } + + pub(super) fn handle_tab_create(&mut self, id: String, params: TabCreateParams) -> String { + let TabCreateParams { + workspace_id, + cwd, + focus, + label, + } = params; + let ws_idx = if let Some(workspace_id) = workspace_id { + let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { + return workspace_not_found(id, &workspace_id); + }; + ws_idx + } else if let Some(active) = self.state.active { + active + } else { + return encode_error(id, "workspace_not_found", "no active workspace"); + }; + let cwd = cwd + .map(PathBuf::from) + .or_else(|| { + self.state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + .and_then(|rt| rt.cwd()) + }) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from("/")); + let (rows, cols) = self.state.estimate_pane_size(); + let default_shell = self.state.default_shell.clone(); + let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; + let host_terminal_theme = self.state.host_terminal_theme; + let result = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("workspace disappeared")) + .and_then(|ws| { + ws.create_tab( + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + &default_shell, + ) + }); + match result { + Ok((tab_idx, terminal, runtime)) => { + self.terminal_runtimes.insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); + if let Some(label) = label { + let workspace_id = self.state.workspaces[ws_idx].id.clone(); + let tab_id = self + .public_tab_id(ws_idx, tab_idx) + .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1)); + if let Some(tab) = self + .state + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + { + tab.set_custom_name(label); + crate::logging::tab_renamed(&workspace_id, &tab_id); + } + } + if focus { + self.state.switch_workspace(ws_idx); + self.state.switch_tab(tab_idx); + self.state.mode = Mode::Terminal; + } + self.schedule_session_save(); + let tab = self.tab_info(ws_idx, tab_idx).unwrap(); + let root_pane = self + .root_pane_info(ws_idx, tab_idx) + .expect("new tab should have a root pane"); + self.emit_event(EventEnvelope { + event: EventKind::TabCreated, + data: EventData::TabCreated { tab: tab.clone() }, + }); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { + pane: root_pane.clone(), + }, + }); + encode_success( + id, + self.tab_created_result(ws_idx, tab_idx) + .expect("new tab should produce a complete create response"), + ) + } + Err(err) => encode_error(id, "tab_create_failed", err.to_string()), + } + } + + pub(super) fn handle_tab_focus(&mut self, id: String, target: TabTarget) -> String { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { + return tab_not_found(id, &target.tab_id); + }; + self.state.switch_workspace(ws_idx); + self.state.switch_tab(tab_idx); + let tab = self.tab_info(ws_idx, tab_idx).unwrap(); + + encode_success(id, ResponseResult::TabInfo { tab }) + } + + pub(super) fn handle_tab_rename(&mut self, id: String, params: TabRenameParams) -> String { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(¶ms.tab_id) else { + return tab_not_found(id, ¶ms.tab_id); + }; + let workspace_id = self.state.workspaces[ws_idx].id.clone(); + let tab_id = self + .public_tab_id(ws_idx, tab_idx) + .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1)); + let Some(tab) = self + .state + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + else { + return tab_not_found(id, ¶ms.tab_id); + }; + tab.set_custom_name(params.label.clone()); + crate::logging::tab_renamed(&workspace_id, &tab_id); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::TabRenamed, + data: EventData::TabRenamed { + tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), + workspace_id: self.public_workspace_id(ws_idx), + label: params.label, + }, + }); + let tab = self.tab_info(ws_idx, tab_idx).unwrap(); + + encode_success(id, ResponseResult::TabInfo { tab }) + } + + pub(super) fn handle_tab_close(&mut self, id: String, target: TabTarget) -> String { + let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { + return tab_not_found(id, &target.tab_id); + }; + let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return tab_not_found(id, &target.tab_id); + }; + if ws.tabs.len() <= 1 { + return encode_error( + id, + "tab_close_failed", + "cannot close the last tab in a workspace", + ); + } + if !ws.close_tab(tab_idx) { + return encode_error( + id, + "tab_close_failed", + format!("tab {} could not be closed", target.tab_id), + ); + } + self.state.remove_unattached_terminal_ids(terminal_ids); + self.shutdown_detached_terminal_runtimes(); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::TabClosed, + data: EventData::TabClosed { + tab_id: target.tab_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn workspace_not_found(id: String, workspace_id: &str) -> String { + encode_error( + id, + "workspace_not_found", + format!("workspace {workspace_id} not found"), + ) +} + +fn tab_not_found(id: String, tab_id: &str) -> String { + encode_error(id, "tab_not_found", format!("tab {tab_id} not found")) +} diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs new file mode 100644 index 00000000..2f8c1a28 --- /dev/null +++ b/src/app/api/workspaces.rs @@ -0,0 +1,168 @@ +use std::path::PathBuf; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, + WorkspaceRenameParams, WorkspaceTarget, +}; +use crate::app::App; + +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_workspace_list(&mut self, id: String) -> String { + encode_success( + id, + ResponseResult::WorkspaceList { + workspaces: self + .state + .workspaces + .iter() + .enumerate() + .map(|(idx, _)| self.workspace_info(idx)) + .collect(), + }, + ) + } + + pub(super) fn handle_workspace_get(&mut self, id: String, target: WorkspaceTarget) -> String { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { + return workspace_not_found(id, &target.workspace_id); + }; + let Some(_) = self.state.workspaces.get(index) else { + return workspace_not_found(id, &target.workspace_id); + }; + + encode_success( + id, + ResponseResult::WorkspaceInfo { + workspace: self.workspace_info(index), + }, + ) + } + + pub(super) fn handle_workspace_create( + &mut self, + id: String, + params: WorkspaceCreateParams, + ) -> String { + let cwd = params + .cwd + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from("/")); + match self.create_workspace_with_options(cwd, params.focus) { + Ok(index) => { + if let Some(label) = params.label { + if let Some(workspace) = self.state.workspaces.get_mut(index) { + workspace.set_custom_name(label); + crate::logging::workspace_renamed(&workspace.id); + } + } + let workspace = self.workspace_info(index); + let tab = self + .tab_info(index, 0) + .expect("new workspace should have an initial tab"); + let root_pane = self + .root_pane_info(index, 0) + .expect("new workspace should have an initial root pane"); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceCreated, + data: EventData::WorkspaceCreated { + workspace: workspace.clone(), + }, + }); + self.emit_event(EventEnvelope { + event: EventKind::TabCreated, + data: EventData::TabCreated { tab: tab.clone() }, + }); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { + pane: root_pane.clone(), + }, + }); + encode_success( + id, + self.workspace_created_result(index) + .expect("new workspace should produce a complete create response"), + ) + } + Err(err) => encode_error(id, "workspace_create_failed", err.to_string()), + } + } + + pub(super) fn handle_workspace_focus(&mut self, id: String, target: WorkspaceTarget) -> String { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { + return workspace_not_found(id, &target.workspace_id); + }; + if self.state.workspaces.get(index).is_none() { + return workspace_not_found(id, &target.workspace_id); + } + self.state.switch_workspace(index); + + encode_success( + id, + ResponseResult::WorkspaceInfo { + workspace: self.workspace_info(index), + }, + ) + } + + pub(super) fn handle_workspace_rename( + &mut self, + id: String, + params: WorkspaceRenameParams, + ) -> String { + let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + let Some(ws) = self.state.workspaces.get_mut(index) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + ws.set_custom_name(params.label.clone()); + crate::logging::workspace_renamed(&ws.id); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceRenamed, + data: EventData::WorkspaceRenamed { + workspace_id: self.public_workspace_id(index), + label: params.label, + }, + }); + + encode_success( + id, + ResponseResult::WorkspaceInfo { + workspace: self.workspace_info(index), + }, + ) + } + + pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { + return workspace_not_found(id, &target.workspace_id); + }; + if self.state.workspaces.get(index).is_none() { + return workspace_not_found(id, &target.workspace_id); + } + self.state.selected = index; + self.state.close_selected_workspace(); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { + workspace_id: target.workspace_id, + }, + }); + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn workspace_not_found(id: String, workspace_id: &str) -> String { + encode_error( + id, + "workspace_not_found", + format!("workspace {workspace_id} not found"), + ) +} diff --git a/src/app/creation.rs b/src/app/creation.rs index f3d8644e..de1ad128 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -11,7 +11,7 @@ impl App { self.state .workspaces .get(ws_idx)? - .resolved_identity_cwd_from(&self.state.terminals, &self.state.terminal_runtimes) + .resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) } pub(super) fn workspace_creation_source(&self) -> Option<usize> { @@ -89,9 +89,7 @@ impl App { self.state.host_terminal_theme, &self.state.default_shell, )?; - self.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); if focus { ws.switch_tab(idx); @@ -124,9 +122,7 @@ impl App { self.render_notify.clone(), self.render_dirty.clone(), )?; - self.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let idx = self.state.workspaces.len() - 1; @@ -262,11 +258,7 @@ impl App { tab_id: self.public_tab_id(ws_idx, tab_idx)?, focused, cwd: ws.tabs[tab_idx] - .cwd_for_pane( - pane_id, - &self.state.terminals, - &self.state.terminal_runtimes, - ) + .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) .map(|cwd| cwd.display().to_string()), label: terminal.manual_label.clone(), agent: terminal.effective_agent_label().map(str::to_string), @@ -281,7 +273,9 @@ impl App { ws_idx: usize, pane_id: crate::layout::PaneId, ) -> Option<(&crate::terminal::TerminalRuntime, String)> { - let runtime = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?; + let runtime = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?; Some((runtime, self.public_workspace_id(ws_idx))) } @@ -290,7 +284,8 @@ impl App { ws_idx: usize, pane_id: crate::layout::PaneId, ) -> Option<&crate::terminal::TerminalRuntime> { - self.state.runtime_for_pane_in_workspace(ws_idx, pane_id) + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) } pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo { @@ -299,7 +294,7 @@ impl App { crate::api::schema::WorkspaceInfo { workspace_id: self.public_workspace_id(index), number: index + 1, - label: ws.display_name_from(&self.state.terminals, &self.state.terminal_runtimes), + label: ws.display_name_from(&self.state.terminals, &self.terminal_runtimes), focused: self.state.active == Some(index), pane_count: ws.public_pane_numbers.len(), tab_count: ws.tabs.len(), diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 245f935b..7c2bbe03 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -70,7 +70,13 @@ impl App { } Mode::Resize => handle_resize_key(&mut self.state, key), Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event), - Mode::ContextMenu => handle_context_menu_key(&mut self.state, key_event), + Mode::ContextMenu => { + handle_context_menu_key( + &mut self.state, + &mut self.terminal_runtimes, + key_event, + ); + } Mode::Settings => self.handle_settings_key(key_event), Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event), @@ -85,7 +91,10 @@ impl App { return; } if let Some(ws_idx) = self.state.active { - if let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) { + if let Some(rt) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + { let _ = rt.send_paste(text).await; } } @@ -183,7 +192,7 @@ impl App { let previous_agent_panel_scope = self.state.agent_panel_scope; let previous_settings_section = self.state.settings.section; - if let Some(action) = self.state.handle_mouse(mouse) { + if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), @@ -232,7 +241,11 @@ impl App { // Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App impl AppState { - pub(crate) fn split_pane(&mut self, direction: Direction) { + pub(crate) fn split_pane( + &mut self, + terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, + direction: Direction, + ) { // Actual PTY spawning happens in Workspace::split_focused // which needs events channel — this is called from navigate_key // where we don't have async context, so the workspace handles it @@ -245,11 +258,7 @@ impl AppState { .and_then(|i| self.workspaces.get(i)) .and_then(|ws| { let tab = ws.active_tab()?; - tab.cwd_for_pane( - tab.layout.focused(), - &self.terminals, - &self.terminal_runtimes, - ) + tab.cwd_for_pane(tab.layout.focused(), &self.terminals, terminal_runtimes) }); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { @@ -263,8 +272,7 @@ impl AppState { &self.default_shell, ) { let new_id = new_pane.pane_id; - self.terminal_runtimes - .insert(new_pane.terminal.id.clone(), new_pane.runtime); + terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); self.terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); ws.layout.focus_pane(new_id); @@ -332,10 +340,11 @@ fn numbered_lines_bytes(count: usize) -> Vec<u8> { #[cfg(test)] fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); crate::persist::capture( &state.workspaces, &state.terminals, - &state.terminal_runtimes, + &terminal_runtimes, state.active, state.selected, state.agent_panel_scope, diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 084f44a9..868ee3d3 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -498,7 +498,12 @@ pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { } } -pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) { +pub(super) fn apply_context_menu_action( + state: &mut AppState, + terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, + menu: ContextMenuState, + idx: usize, +) { let item = menu.items().get(idx).copied(); match (menu.kind, item) { (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { @@ -554,11 +559,11 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Split vertical")) => { - state.split_pane(Direction::Horizontal); + state.split_pane(terminal_runtimes, Direction::Horizontal); state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Split horizontal")) => { - state.split_pane(Direction::Vertical); + state.split_pane(terminal_runtimes, Direction::Vertical); state.mode = Mode::Terminal; } (ContextMenuKind::Pane { .. }, Some("Zoom")) => { @@ -577,7 +582,11 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS } } -pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { +pub(crate) fn handle_context_menu_key( + state: &mut AppState, + terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, + key: KeyEvent, +) { match key.code { KeyCode::Esc => { state.context_menu = None; @@ -596,7 +605,7 @@ pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { KeyCode::Enter => { if let Some(menu) = state.context_menu.take() { let idx = menu.list.highlighted; - apply_context_menu_action(state, menu, idx); + apply_context_menu_action(state, terminal_runtimes, menu, idx); } } _ => {} diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index e3f13d77..4d49a917 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -10,6 +10,7 @@ use crate::{ }, layout::{PaneInfo, SplitBorder}, selection::Selection, + terminal::TerminalRuntimeRegistry, }; #[cfg(test)] @@ -25,7 +26,11 @@ use super::{ }; impl AppState { - pub(crate) fn handle_pane_mouse_only(&mut self, mouse: MouseEvent) { + pub(crate) fn handle_pane_mouse_only( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + mouse: MouseEvent, + ) { if self.mode != Mode::Terminal { return; } @@ -38,16 +43,20 @@ impl AppState { | MouseEventKind::ScrollDown | MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => { - self.forward_pane_reported_wheel(&info, mouse); + self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); } MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { - self.forward_pane_mouse_button(&info, mouse); + self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); } MouseEventKind::Moved => {} } } - pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { + pub(super) fn handle_mouse( + &mut self, + terminal_runtimes: &mut TerminalRuntimeRegistry, + mouse: MouseEvent, + ) -> Option<SettingsAction> { if self.mode == Mode::Onboarding { self.handle_onboarding_mouse(mouse); return None; @@ -187,7 +196,7 @@ impl AppState { let item_idx = self.context_menu_item_at(mouse.column, mouse.row); if let Some(menu) = self.context_menu.take() { if let Some(idx) = item_idx { - apply_context_menu_action(self, menu, idx); + apply_context_menu_action(self, terminal_runtimes, menu, idx); } else { leave_modal(self); } @@ -224,7 +233,7 @@ impl AppState { } if let Some((pane_id, target)) = - self.scrollbar_target_at(mouse.column, mouse.row) + self.scrollbar_target_at(terminal_runtimes, mouse.column, mouse.row) { self.focus_pane(pane_id); match target { @@ -237,7 +246,11 @@ impl AppState { }); } ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_pane_scroll_offset(pane_id, offset_from_bottom); + self.set_pane_scroll_offset( + terminal_runtimes, + pane_id, + offset_from_bottom, + ); } } if self.mode != Mode::Terminal { @@ -370,7 +383,7 @@ impl AppState { self.mode = Mode::Terminal; } - if self.forward_pane_mouse_button(&info, mouse) { + if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; return None; @@ -384,7 +397,7 @@ impl AppState { info.id, row, col, - self.pane_scroll_metrics(info.id), + self.pane_scroll_metrics(terminal_runtimes, info.id), )); } else if let Some(info) = self.view.pane_infos.iter().find(|p| { mouse.column >= p.rect.x @@ -402,13 +415,13 @@ impl AppState { MouseEventKind::Drag(MouseButton::Left) => { if self.selection.is_some() { - self.update_selection_drag(mouse.column, mouse.row); + self.update_selection_drag(terminal_runtimes, mouse.column, mouse.row); return None; } if self.drag.is_none() { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(&info, mouse) { + if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; return None; @@ -504,11 +517,16 @@ impl AppState { grab_row_offset, } => { if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( + terminal_runtimes, *pane_id, mouse.row, *grab_row_offset, ) { - self.set_pane_scroll_offset(*pane_id, offset_from_bottom); + self.set_pane_scroll_offset( + terminal_runtimes, + *pane_id, + offset_from_bottom, + ); } } DragTarget::SidebarDivider => { @@ -535,14 +553,14 @@ impl AppState { self.selection = None; self.selection_autoscroll = None; } else { - self.copy_selection(); + self.copy_selection(terminal_runtimes); } return None; } if self.drag.is_none() { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(&info, mouse) { + if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; self.workspace_press = None; @@ -597,7 +615,7 @@ impl AppState { self.selection = None; self.selection_autoscroll = None; } else { - self.copy_selection(); + self.copy_selection(terminal_runtimes); } } } @@ -607,7 +625,7 @@ impl AppState { if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_button(&info, mouse); + let _ = self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); } } @@ -622,12 +640,12 @@ impl AppState { } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if !in_sidebar && self.scroll_selection_with_wheel(mouse) => {} + if !in_sidebar && self.scroll_selection_with_wheel(terminal_runtimes, mouse) => {} MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { self.selection = None; self.selection_autoscroll = None; - self.handle_terminal_wheel(mouse); + self.handle_terminal_wheel(terminal_runtimes, mouse); } MouseEventKind::ScrollUp if in_sidebar => { @@ -1070,17 +1088,29 @@ impl AppState { self.mode = Mode::Terminal; } - pub(crate) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { + pub(crate) fn scroll_pane_up( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + pane_id: crate::layout::PaneId, + lines: usize, + ) { if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { + if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + { rt.scroll_up(lines); } } } - pub(crate) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { + pub(crate) fn scroll_pane_down( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + pane_id: crate::layout::PaneId, + lines: usize, + ) { if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { + if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + { rt.scroll_down(lines); } } @@ -1088,24 +1118,33 @@ impl AppState { pub(crate) fn pane_scroll_metrics( &self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, ) -> Option<crate::pane::ScrollMetrics> { self.active - .and_then(|i| self.runtime_for_pane_in_workspace(i, pane_id)) + .and_then(|i| self.runtime_for_pane_in_workspace(terminal_runtimes, i, pane_id)) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) } - pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { + pub(super) fn handle_terminal_wheel( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + mouse: MouseEvent, + ) { const LINES_PER_NOTCH: usize = 3; if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { self.focus_pane(info.id); - if self.forward_pane_wheel(&info, mouse) { + if self.forward_pane_wheel(terminal_runtimes, &info, mouse) { return; } match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), - MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), + MouseEventKind::ScrollUp => { + self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH) + } + MouseEventKind::ScrollDown => { + self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH) + } _ => {} } return; @@ -1114,15 +1153,19 @@ impl AppState { if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { self.focus_pane(info.id); match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), - MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), + MouseEventKind::ScrollUp => { + self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH) + } + MouseEventKind::ScrollDown => { + self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH) + } _ => {} } return; } if let Some(ws_idx) = self.active { - if let Some(rt) = self.focused_runtime_in_workspace(ws_idx) { + if let Some(rt) = self.focused_runtime_in_workspace(terminal_runtimes, ws_idx) { match mouse.kind { MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH), MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH), @@ -1132,11 +1175,17 @@ impl AppState { } } - pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + pub(super) fn forward_pane_mouse_button( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { return false; }; let column = mouse.column.saturating_sub(info.inner_rect.x); @@ -1151,11 +1200,17 @@ impl AppState { true } - fn forward_pane_reported_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + fn forward_pane_reported_wheel( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { return false; }; if !rt @@ -1177,11 +1232,17 @@ impl AppState { true } - pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + pub(super) fn forward_pane_wheel( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { return false; }; match rt.wheel_routing() { @@ -1215,11 +1276,13 @@ impl AppState { pub(super) fn set_pane_scroll_offset( &self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, offset_from_bottom: usize, ) { if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { + if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + { rt.set_scroll_offset_from_bottom(offset_from_bottom); } } @@ -1227,6 +1290,7 @@ impl AppState { pub(super) fn scrollbar_target_at( &self, + terminal_runtimes: &TerminalRuntimeRegistry, col: u16, row: u16, ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { @@ -1239,7 +1303,7 @@ impl AppState { && row < track.y + track.height }) })?; - let rt = self.runtime_for_pane_in_workspace(ws_idx, info.id)?; + let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1259,6 +1323,7 @@ impl AppState { pub(super) fn scrollbar_offset_for_pane_row( &self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, row: u16, grab_row_offset: u16, @@ -1270,7 +1335,7 @@ impl AppState { .iter() .find(|info| info.id == pane_id)?; let track = crate::ui::pane_scrollbar_rect(info)?; - let rt = self.runtime_for_pane_in_workspace(ws_idx, pane_id)?; + let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1435,6 +1500,7 @@ mod tests { app.state.mode = Mode::ContextMenu; handle_context_menu_key( &mut app.state, + &mut app.terminal_runtimes, KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), ); assert_eq!(app.state.mode, Mode::ConfirmClose); @@ -1458,6 +1524,56 @@ mod tests { assert_eq!(app.state.workspaces[0].display_name(), "a"); } + #[tokio::test] + async fn keyboard_context_menu_split_keeps_new_runtime() { + let mut app = app_for_mouse_test(); + app.state.default_shell = "/usr/bin/true".into(); + let (workspace, terminal, runtime) = Workspace::new( + std::env::current_dir().unwrap_or_else(|_| "/".into()), + 24, + 80, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + &app.state.default_shell, + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .expect("workspace should spawn"); + app.state.workspaces = vec![workspace]; + app.terminal_runtimes.insert(terminal.id.clone(), runtime); + app.state.terminals.insert(terminal.id.clone(), terminal); + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let runtime_count = app.terminal_runtimes.len(); + app.state.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Pane { + pane_id, + has_manual_label: false, + }, + x: 2, + y: 2, + list: MenuListState::new(1), + }); + app.state.mode = Mode::ContextMenu; + + handle_context_menu_key( + &mut app.state, + &mut app.terminal_runtimes, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(app.state.mode, Mode::Terminal); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); + assert_eq!(app.terminal_runtimes.len(), runtime_count + 1); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } + } + #[test] fn dragging_pane_split_updates_captured_layout_ratio() { let mut app = app_for_mouse_test(); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 17180fed..9256bbbb 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -16,6 +16,7 @@ use crate::{ }, input::TerminalKey, layout::NavDirection, + terminal::TerminalRuntimeRegistry, }; pub(crate) fn terminal_direct_navigation_action( @@ -55,7 +56,12 @@ impl App { self.launch_focused_scrollback_editor(); finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); } else { - execute_navigate_action_in_context(&mut self.state, action, ActionContext::Prefix); + execute_navigate_action_in_context( + &mut self.state, + &mut self.terminal_runtimes, + action, + ActionContext::Prefix, + ); } self.selection_autoscroll_deadline = None; return; @@ -88,6 +94,7 @@ impl App { } else { execute_navigate_action_in_context( &mut self.state, + &mut self.terminal_runtimes, action, ActionContext::Navigate, ); @@ -105,7 +112,10 @@ impl App { let Some(ws_idx) = self.state.active else { return false; }; - let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) else { + let Some(rt) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + else { return false; }; @@ -174,11 +184,7 @@ impl App { env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); } if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { - tab.cwd_for_pane( - pane_id, - &self.state.terminals, - &self.state.terminal_runtimes, - ) + tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) }) { env.push(( "HERDR_ACTIVE_PANE_CWD".to_string(), @@ -245,7 +251,7 @@ impl App { .ok_or_else(|| std::io::Error::other("no focused pane"))?; let scrollback = self .state - .runtime_for_pane_in_workspace(ws_idx, pane_id) + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? .recent_text(usize::MAX); @@ -298,7 +304,7 @@ impl App { tab.cwd_for_pane( previous_focus, &self.state.terminals, - &self.state.terminal_runtimes, + &self.terminal_runtimes, ) }); let new_pane = ws.split_focused_command( @@ -312,8 +318,7 @@ impl App { self.state.host_terminal_theme, )?; let new_pane_id = new_pane.pane_id; - self.state - .terminal_runtimes + self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); self.state .terminals @@ -428,6 +433,7 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) #[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); state.update_dismissed = true; let terminal_key = TerminalKey::from(key); @@ -441,7 +447,12 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { } if let Some(action) = action_for_key(state, terminal_key, BindingDispatch::Prefix) { - execute_navigate_action_in_context(state, action, ActionContext::Navigate); + execute_navigate_action_in_context( + state, + &mut terminal_runtimes, + action, + ActionContext::Navigate, + ); } } @@ -587,11 +598,18 @@ fn action_for_key( #[cfg(test)] pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { - execute_navigate_action_in_context(state, action, ActionContext::Navigate); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + execute_navigate_action_in_context( + state, + &mut terminal_runtimes, + action, + ActionContext::Navigate, + ); } pub(super) fn execute_navigate_action_in_context( state: &mut AppState, + terminal_runtimes: &mut TerminalRuntimeRegistry, action: NavigateAction, context: ActionContext, ) { @@ -694,11 +712,11 @@ pub(super) fn execute_navigate_action_in_context( NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), NavigateAction::SplitVertical => { - state.split_pane(Direction::Horizontal); + state.split_pane(terminal_runtimes, Direction::Horizontal); leave_navigate_mode(state); } NavigateAction::SplitHorizontal => { - state.split_pane(Direction::Vertical); + state.split_pane(terminal_runtimes, Direction::Vertical); leave_navigate_mode(state); } NavigateAction::ClosePane => { @@ -1192,10 +1210,12 @@ mod tests { #[test] fn empty_state_new_tab_is_no_op() { let mut state = crate::app::state::AppState::test_new(); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); state.mode = Mode::Prefix; execute_navigate_action_in_context( &mut state, + &mut terminal_runtimes, NavigateAction::NewTab, ActionContext::Prefix, ); @@ -1277,9 +1297,7 @@ mod tests { ) .expect("workspace should spawn"); app.state.workspaces = vec![workspace]; - app.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + app.terminal_runtimes.insert(terminal.id.clone(), runtime); app.state.terminals.insert(terminal.id.clone(), terminal); app.state.active = Some(0); app.state.selected = 0; @@ -1303,7 +1321,7 @@ mod tests { .await; assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.state.terminal_runtimes.len(), 2); + assert_eq!(app.terminal_runtimes.len(), 2); assert!(app.state.workspaces[0].tabs[0].zoomed); let _ = wait_for_file(&output_path); @@ -1321,6 +1339,11 @@ mod tests { assert!(!app.state.workspaces[0].tabs[0].zoomed); assert_eq!(app.state.mode, Mode::Terminal); let _ = std::fs::remove_file(output_path); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } } #[tokio::test] @@ -1337,7 +1360,12 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"), + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 4096, + b"alpha\nbeta\n", + ), ); app.state.workspaces = vec![workspace]; app.state.active = Some(0); diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs index ad1dd33f..06de30b5 100644 --- a/src/app/input/selection.rs +++ b/src/app/input/selection.rs @@ -1,10 +1,14 @@ use crossterm::event::{MouseEvent, MouseEventKind}; -use crate::app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}; +use crate::{ + app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}, + terminal::TerminalRuntimeRegistry, +}; impl AppState { pub(crate) fn update_selection_cursor( &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, screen_col: u16, screen_row: u16, @@ -12,7 +16,7 @@ impl AppState { let Some(info) = self.pane_info_by_id(pane_id).cloned() else { return; }; - let metrics = self.pane_scroll_metrics(pane_id); + let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); if let Some(selection) = self.selection.as_mut() { selection.drag(screen_col, screen_row, info.inner_rect, metrics); } @@ -22,7 +26,12 @@ impl AppState { usize::from(distance).saturating_mul(3).clamp(3, 15) } - pub(super) fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) { + pub(super) fn update_selection_drag( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + screen_col: u16, + screen_row: u16, + ) { let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { return; }; @@ -44,14 +53,16 @@ impl AppState { // Anchor is stored in absolute row; for a simple screen // comparison, check whether the mouse is on a different // cell than the anchor's screen position. - let (ar, ac) = - s.anchor_screen_pos(info.inner_rect, self.pane_scroll_metrics(s.pane_id)); + let (ar, ac) = s.anchor_screen_pos( + info.inner_rect, + self.pane_scroll_metrics(terminal_runtimes, s.pane_id), + ); ar != screen_row || ac != screen_col }); let is_dragging = was_dragging || anchor_differs_from_mouse; // Advance the selection cursor. - self.update_selection_cursor(pane_id, screen_col, screen_row); + self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); // If the mouse is on a different cell than the anchor but drag() // didn't transition (cursor clamped to edge == anchor), force @@ -67,9 +78,13 @@ impl AppState { if screen_row < top { // Cursor above pane — immediate scroll + set autoscroll state if is_dragging { - self.scroll_pane_up(pane_id, Self::selection_edge_scroll_lines(top - screen_row)); + self.scroll_pane_up( + terminal_runtimes, + pane_id, + Self::selection_edge_scroll_lines(top - screen_row), + ); // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(pane_id, screen_col, screen_row); + self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); self.selection_autoscroll = Some(SelectionAutoscroll { direction: SelectionAutoscrollDirection::Up, last_mouse_screen_col: screen_col, @@ -81,11 +96,12 @@ impl AppState { // Cursor below pane — immediate scroll + set autoscroll state if is_dragging { self.scroll_pane_down( + terminal_runtimes, pane_id, Self::selection_edge_scroll_lines(screen_row - bottom), ); // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(pane_id, screen_col, screen_row); + self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); self.selection_autoscroll = Some(SelectionAutoscroll { direction: SelectionAutoscrollDirection::Down, last_mouse_screen_col: screen_col, @@ -123,7 +139,11 @@ impl AppState { } } - pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool { + pub(super) fn scroll_selection_with_wheel( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + mouse: MouseEvent, + ) -> bool { const LINES_PER_NOTCH: usize = 3; let Some(selection) = self.selection.as_ref() else { @@ -135,11 +155,15 @@ impl AppState { let pane_id = selection.pane_id; self.focus_pane(pane_id); match mouse.kind { - MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH), - MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH), + MouseEventKind::ScrollUp => { + self.scroll_pane_up(terminal_runtimes, pane_id, LINES_PER_NOTCH) + } + MouseEventKind::ScrollDown => { + self.scroll_pane_down(terminal_runtimes, pane_id, LINES_PER_NOTCH) + } _ => return false, } - self.update_selection_cursor(pane_id, mouse.column, mouse.row); + self.update_selection_cursor(terminal_runtimes, pane_id, mouse.column, mouse.row); true } } @@ -148,6 +172,7 @@ impl AppState { mod autoscroll_tests { use super::*; use crate::layout::PaneInfo; + use crate::terminal::TerminalRuntimeRegistry; use crate::workspace::Workspace; use ratatui::layout::Rect; @@ -188,7 +213,8 @@ mod autoscroll_tests { let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); sel.drag(4, 5, Rect::new(0, 5, 80, 24), None); state.selection = Some(sel); - state.update_selection_drag(5, 4); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 5, 4); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); } @@ -200,7 +226,8 @@ mod autoscroll_tests { let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); sel.drag(0, 0, Rect::new(0, 0, 80, 24), None); state.selection = Some(sel); - state.update_selection_drag(0, 0); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 0); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); } @@ -211,7 +238,8 @@ mod autoscroll_tests { let (mut state, pane_id) = make_state_with_pane(); state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); // Same-cell drag on top edge row — still anchored - state.update_selection_drag(0, 0); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 0); assert!(state.selection_autoscroll.is_none()); } @@ -222,7 +250,8 @@ mod autoscroll_tests { let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); sel.drag(23, 0, Rect::new(0, 0, 80, 24), None); state.selection = Some(sel); - state.update_selection_drag(0, 23); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 23); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); } @@ -234,7 +263,8 @@ mod autoscroll_tests { // Anchor at bottom edge row state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None)); // Same-cell drag — still anchored - state.update_selection_drag(0, 23); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 23); assert!(state.selection_autoscroll.is_none()); } @@ -246,7 +276,8 @@ mod autoscroll_tests { sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); state.selection = Some(sel); // Drag cursor one row below the pane bottom - state.update_selection_drag(0, 24); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 0, 24); let autoscroll = state.selection_autoscroll.as_ref().unwrap(); assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); } @@ -266,7 +297,8 @@ mod autoscroll_tests { inner_rect: Rect::new(0, 0, 80, 24), }); // Move cursor into safe zone (middle of pane, not on edge rows) - state.update_selection_drag(5, 12); + let terminal_runtimes = TerminalRuntimeRegistry::new(); + state.update_selection_drag(&terminal_runtimes, 5, 12); assert!(state.selection_autoscroll.is_none()); } } diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 41a62e82..eee5b863 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -47,6 +47,7 @@ impl App { } else { super::navigate::execute_navigate_action_in_context( &mut self.state, + &mut self.terminal_runtimes, action, super::navigate::ActionContext::Direct, ); @@ -88,7 +89,9 @@ impl App { let ws_idx = self.state.active?; let ws = self.state.workspaces.get(ws_idx)?; let pane_id = ws.focused_pane_id()?; - let rt = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?; + let rt = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?; // Intercept plain PageUp/PageDown presses for pane scrollback when the // focused pane doesn't handle its own scrolling (e.g., a plain shell @@ -116,9 +119,11 @@ impl App { .unwrap_or(10) .max(1); if key_event.code == KeyCode::PageUp { - self.state.scroll_pane_up(pane_id, lines); + self.state + .scroll_pane_up(&self.terminal_runtimes, pane_id, lines); } else { - self.state.scroll_pane_down(pane_id, lines); + self.state + .scroll_pane_down(&self.terminal_runtimes, pane_id, lines); } debug!( code = ?key_event.code, @@ -223,7 +228,7 @@ mod tests { let start_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let start_row = info.inner_rect.y; @@ -242,7 +247,7 @@ mod tests { let end_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after drag"); assert_eq!( @@ -329,7 +334,7 @@ mod tests { let start_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let top_row = info.inner_rect.y; @@ -340,7 +345,7 @@ mod tests { let end_metrics = app .state - .runtime_for_pane(pane_id) + .runtime_for_pane(&app.terminal_runtimes, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after wheel"); assert_eq!( @@ -523,7 +528,12 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"), + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 4096, + b"alpha\nbeta\n", + ), ); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -602,6 +612,7 @@ mod tests { api_rx, crate::api::EventHub::default(), ); + app.state.default_shell = "/usr/bin/true".into(); let (workspace, terminal, runtime) = Workspace::new( std::env::current_dir().unwrap_or_else(|_| "/".into()), 24, @@ -615,9 +626,7 @@ mod tests { ) .expect("workspace should spawn"); app.state.workspaces = vec![workspace]; - app.state - .terminal_runtimes - .insert(terminal.id.clone(), runtime); + app.terminal_runtimes.insert(terminal.id.clone(), runtime); app.state.terminals.insert(terminal.id.clone(), terminal); app.state.active = Some(0); app.state.selected = 0; @@ -639,6 +648,11 @@ mod tests { assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); assert!(app.state.workspaces[0].tabs[0].zoomed); assert_eq!(app.state.mode, Mode::Terminal); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } } #[tokio::test] @@ -648,7 +662,7 @@ mod tests { let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 80, 24)); let info = pane_infos[0].clone(); - let (runtime, mut rx) = crate::pane::PaneRuntime::test_with_channel( + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel( info.inner_rect.width, info.inner_rect.height, ); @@ -677,7 +691,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -693,8 +707,8 @@ mod tests { let start_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); assert_eq!(start_metrics.offset_from_bottom, 0); @@ -702,8 +716,8 @@ mod tests { let end_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); assert_eq!( end_metrics.offset_from_bottom, @@ -720,7 +734,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -737,8 +751,8 @@ mod tests { app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); let after_up = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); assert!(after_up.offset_from_bottom > 0); @@ -748,8 +762,8 @@ mod tests { )); let after_down = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageDown"); assert_eq!(after_down.offset_from_bottom, 0); } @@ -763,7 +777,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -780,8 +794,8 @@ mod tests { app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); let after_press = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp press"); assert_eq!( after_press.offset_from_bottom, @@ -795,8 +809,8 @@ mod tests { let after_release = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp release"); assert_eq!( after_release.offset_from_bottom, @@ -813,7 +827,7 @@ mod tests { let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -831,8 +845,8 @@ mod tests { let metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after modified PageUp"); assert_eq!(metrics.offset_from_bottom, 0); } @@ -848,7 +862,7 @@ mod tests { bytes.extend_from_slice(&numbered_lines_bytes(64)); ws.tabs[0].runtimes.insert( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -864,8 +878,8 @@ mod tests { let start_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); assert_eq!(start_metrics.offset_from_bottom, 0); @@ -873,8 +887,8 @@ mod tests { let end_metrics = app .state - .runtime_for_pane_in_workspace(0, pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); // Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom. assert_eq!(end_metrics.offset_from_bottom, 0); diff --git a/src/app/mod.rs b/src/app/mod.rs index 4c4845a1..6a94f116 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -62,6 +62,7 @@ pub(crate) struct OverlayPaneState { pub struct App { pub state: AppState, + pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry, pub event_tx: mpsc::Sender<AppEvent>, pub(crate) event_rx: mpsc::Receiver<AppEvent>, pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, @@ -224,7 +225,7 @@ impl App { // Try to restore previous session let mut restored_terminals = std::collections::HashMap::new(); - let mut restored_terminal_runtimes = std::collections::HashMap::new(); + let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let ( workspaces, active, @@ -255,7 +256,7 @@ impl App { render_dirty.clone(), ); restored_terminals = terminals; - restored_terminal_runtimes = terminal_runtimes; + restored_terminal_runtimes = terminal_runtimes.into(); if ws.is_empty() { crate::logging::session_restored(0, "empty"); ( @@ -346,7 +347,6 @@ impl App { let mut state = AppState { terminals: std::collections::HashMap::new(), - terminal_runtimes: std::collections::HashMap::new(), direct_attach_resize_locks: std::collections::HashSet::new(), workspaces, active, @@ -457,14 +457,14 @@ impl App { global_menu: state::MenuListState::new(0), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), session_dirty: false, + terminal_runtime_shutdowns: Vec::new(), }; state.terminals = restored_terminals; - state.terminal_runtimes = restored_terminal_runtimes; for ws_idx in 0..state.workspaces.len() { let cwd = state.workspaces[ws_idx] - .resolved_identity_cwd_from(&state.terminals, &state.terminal_runtimes); + .resolved_identity_cwd_from(&state.terminals, &restored_terminal_runtimes); state.workspaces[ws_idx].cached_git_branch = cwd.as_deref().and_then(crate::workspace::git_branch); } @@ -488,6 +488,7 @@ impl App { config_diagnostic_deadline: None, toast_deadline: None, state, + terminal_runtimes: restored_terminal_runtimes, event_tx, event_rx, last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL, @@ -593,14 +594,31 @@ impl App { let area = frame.area(); if kitty_graphics_enabled { cell_size = crate::kitty_graphics::HostCellSize::from_terminal(area); - crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size); + crate::ui::compute_view_with_cell_size( + &mut self.state, + &self.terminal_runtimes, + area, + cell_size, + ); } else { - crate::ui::compute_view(&mut self.state, area); + crate::ui::compute_view_with_runtime_registry( + &mut self.state, + &self.terminal_runtimes, + area, + ); } - crate::ui::render(&self.state, frame); + crate::ui::render_with_runtime_registry( + &self.state, + &self.terminal_runtimes, + frame, + ); })?; if kitty_graphics_enabled { - crate::kitty_graphics::paint_local_pane_graphics(&self.state, cell_size)?; + crate::kitty_graphics::paint_local_pane_graphics( + &self.state, + &self.terminal_runtimes, + cell_size, + )?; } self.last_render_at = Some(now); needs_render = false; @@ -664,7 +682,9 @@ impl App { } fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> { - let desired = self.state.should_capture_host_mouse(); + let desired = self + .state + .should_capture_host_mouse_from(&self.terminal_runtimes); if desired == *active { return Ok(()); } @@ -1024,7 +1044,8 @@ impl App { if self.state.mouse_capture { self.handle_mouse_event_headless(mouse); } else { - self.state.handle_pane_mouse_only(mouse); + self.state + .handle_pane_mouse_only(&self.terminal_runtimes, mouse); } } crate::raw_input::RawInputEvent::Paste(text) => { @@ -1032,9 +1053,11 @@ impl App { if let Some(ws_idx) = self.state.active { if let Some(ws) = self.state.workspaces.get(ws_idx) { if let Some(focused) = ws.focused_pane_id() { - if let Some(runtime) = - self.state.runtime_for_pane_in_workspace(ws_idx, focused) - { + if let Some(runtime) = self.state.runtime_for_pane_in_workspace( + &self.terminal_runtimes, + ws_idx, + focused, + ) { let _ = runtime.try_send_bytes(bytes::Bytes::from( if runtime .input_state() @@ -1087,7 +1110,11 @@ impl App { input::handle_confirm_close_key(&mut self.state, key_event); } Mode::ContextMenu => { - input::handle_context_menu_key(&mut self.state, key_event); + input::handle_context_menu_key( + &mut self.state, + &mut self.terminal_runtimes, + key_event, + ); } Mode::KeybindHelp => { input::handle_keybind_help_key(&mut self.state, key_event); @@ -2221,7 +2248,7 @@ mod tests { 3 ); - let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect(); + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); for (_terminal_id, runtime) in runtimes { runtime.shutdown(); } @@ -2268,7 +2295,7 @@ mod tests { assert_eq!(app.state.active, Some(0)); assert_eq!(app.state.workspaces[0].active_tab, background_tab); - let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect(); + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); for (_terminal_id, runtime) in runtimes { runtime.shutdown(); } diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 5671bf38..7f7306cc 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -11,10 +11,20 @@ use crate::events::AppEvent; use crate::workspace::{Workspace, WorkspaceGitStatus}; impl App { + pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) { + let terminal_ids = std::mem::take(&mut self.state.terminal_runtime_shutdowns); + for terminal_id in terminal_ids { + if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { + runtime.shutdown(); + } + } + } + pub(crate) fn drain_api_requests(&mut self) -> bool { let mut changed = false; while let Ok(msg) = self.api_rx.try_recv() { changed |= self.handle_api_request_message(msg); + self.shutdown_detached_terminal_runtimes(); } changed } @@ -53,7 +63,7 @@ impl App { &mut self, event: crate::raw_input::RawInputEvent, ) -> bool { - match event { + let changed = match event { crate::raw_input::RawInputEvent::Key(key) => { let key_id = repeat_key_identity(&key); match key.kind { @@ -90,7 +100,8 @@ impl App { if self.state.mouse_capture { self.handle_mouse(mouse); } else { - self.state.handle_pane_mouse_only(mouse); + self.state + .handle_pane_mouse_only(&self.terminal_runtimes, mouse); } true } @@ -108,7 +119,9 @@ impl App { self.update_host_terminal_theme(kind, color) } crate::raw_input::RawInputEvent::Unsupported => false, - } + }; + self.shutdown_detached_terminal_runtimes(); + changed } fn handle_resize_poll(&mut self) -> bool { @@ -248,7 +261,10 @@ impl App { } // Scrollback boundary detection via ScrollMetrics — fail-closed if unavailable - let Some(metrics) = self.state.pane_scroll_metrics(pane_id) else { + let Some(metrics) = self + .state + .pane_scroll_metrics(&self.terminal_runtimes, pane_id) + else { self.stop_selection_autoscroll(); return; }; @@ -259,7 +275,8 @@ impl App { self.stop_selection_autoscroll(); return; } - self.state.scroll_pane_up(pane_id, 1); + self.state + .scroll_pane_up(&self.terminal_runtimes, pane_id, 1); } crate::app::state::SelectionAutoscrollDirection::Down => { let at_bottom = metrics.offset_from_bottom == 0; @@ -267,12 +284,14 @@ impl App { self.stop_selection_autoscroll(); return; } - self.state.scroll_pane_down(pane_id, 1); + self.state + .scroll_pane_down(&self.terminal_runtimes, pane_id, 1); } } // Extend selection cursor to last known mouse position self.state.update_selection_cursor( + &self.terminal_runtimes, pane_id, autoscroll.last_mouse_screen_col, autoscroll.last_mouse_screen_row, @@ -328,7 +347,7 @@ impl App { .workspaces .iter() .filter_map(|ws| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.state.terminal_runtimes) + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) .map(|cwd| (ws.id.clone(), cwd)) }) .collect(); diff --git a/src/app/session.rs b/src/app/session.rs index 85bdd1bb..976e320e 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -28,7 +28,7 @@ impl App { let snap = crate::persist::capture( &self.state.workspaces, &self.state.terminals, - &self.state.terminal_runtimes, + &self.terminal_runtimes, self.state.active, self.state.selected, self.state.agent_panel_scope, diff --git a/src/app/state.rs b/src/app/state.rs index 6924dc65..13958d03 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -891,8 +891,6 @@ pub enum SidebarWidthSource { pub struct AppState { pub terminals: std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalState>, - pub terminal_runtimes: - std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalRuntime>, /// Terminal ids whose size is currently owned by a direct attach client. pub direct_attach_resize_locks: std::collections::HashSet<crate::terminal::TerminalId>, pub workspaces: Vec<Workspace>, @@ -1001,6 +999,9 @@ pub struct AppState { pub host_terminal_theme: TerminalTheme, /// Set when a persisted session snapshot would change. pub session_dirty: bool, + /// Terminal runtimes that should be shut down by the app/runtime layer + /// after state has detached their terminal metadata. + pub(crate) terminal_runtime_shutdowns: Vec<crate::terminal::TerminalId>, } impl AppState { @@ -1039,17 +1040,23 @@ impl AppState { section == SettingsSection::Integrations && self.integration_updates_available() } - pub fn focused_pane_requests_mouse_capture(&self) -> bool { + pub(crate) fn focused_pane_requests_mouse_capture_from( + &self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ) -> bool { self.mode == Mode::Terminal && self .active - .and_then(|idx| self.focused_runtime_in_workspace(idx)) + .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) .and_then(crate::terminal::TerminalRuntime::input_state) .is_some_and(crate::pane::InputState::mouse_reporting_enabled) } - pub fn should_capture_host_mouse(&self) -> bool { - self.mouse_capture || self.focused_pane_requests_mouse_capture() + pub(crate) fn should_capture_host_mouse_from( + &self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ) -> bool { + self.mouse_capture || self.focused_pane_requests_mouse_capture_from(terminal_runtimes) } pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool { @@ -1066,11 +1073,12 @@ impl AppState { /// Returns true when the given (workspace, tab, pane) refers to the /// currently focused pane in the active workspace's active tab. - pub(crate) fn runtime_for_pane_in_workspace( - &self, + pub(crate) fn runtime_for_pane_in_workspace<'a>( + &'a self, + terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, ws_idx: usize, pane_id: crate::layout::PaneId, - ) -> Option<&crate::terminal::TerminalRuntime> { + ) -> Option<&'a crate::terminal::TerminalRuntime> { #[cfg(test)] if let Some(runtime) = self.workspaces.get(ws_idx)?.test_runtimes.get(&pane_id) { return Some(runtime); @@ -1086,14 +1094,15 @@ impl AppState { return Some(runtime); } let terminal_id = self.workspaces.get(ws_idx)?.terminal_id(pane_id)?; - self.terminal_runtimes.get(terminal_id) + terminal_runtimes.get(terminal_id) } #[cfg(test)] - pub(crate) fn runtime_for_pane( - &self, + pub(crate) fn runtime_for_pane<'a>( + &'a self, + terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, - ) -> Option<&crate::terminal::TerminalRuntime> { + ) -> Option<&'a crate::terminal::TerminalRuntime> { self.workspaces.iter().find_map(|ws| { #[cfg(test)] if let Some(runtime) = ws.test_runtimes.get(&pane_id) { @@ -1104,17 +1113,18 @@ impl AppState { return Some(runtime); } let terminal_id = ws.terminal_id(pane_id)?; - self.terminal_runtimes.get(terminal_id) + terminal_runtimes.get(terminal_id) }) } - pub(crate) fn focused_runtime_in_workspace( - &self, + pub(crate) fn focused_runtime_in_workspace<'a>( + &'a self, + terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, ws_idx: usize, - ) -> Option<&crate::terminal::TerminalRuntime> { + ) -> Option<&'a crate::terminal::TerminalRuntime> { let ws = self.workspaces.get(ws_idx)?; let pane_id = ws.focused_pane_id()?; - self.runtime_for_pane_in_workspace(ws_idx, pane_id) + self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) } pub fn is_active_pane( @@ -1161,7 +1171,6 @@ impl AppState { pub fn test_new() -> Self { Self { terminals: std::collections::HashMap::new(), - terminal_runtimes: std::collections::HashMap::new(), direct_attach_resize_locks: std::collections::HashSet::new(), workspaces: Vec::new(), active: None, @@ -1262,6 +1271,7 @@ impl AppState { global_menu: MenuListState::new(0), host_terminal_theme: TerminalTheme::default(), session_dirty: false, + terminal_runtime_shutdowns: Vec::new(), } } @@ -1289,14 +1299,13 @@ impl AppState { pane_id: crate::layout::PaneId, runtime: crate::terminal::TerminalRuntime, ) { - let Some(terminal_id) = self + if let Some(ws) = self .workspaces - .iter() - .find_map(|ws| ws.terminal_id(pane_id).cloned()) - else { - return; - }; - self.terminal_runtimes.insert(terminal_id, runtime); + .iter_mut() + .find(|ws| ws.terminal_id(pane_id).is_some()) + { + ws.insert_test_runtime(pane_id, runtime); + } } } diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs index 71ca83bb..d38d115c 100644 --- a/src/app/terminal_targets.rs +++ b/src/app/terminal_targets.rs @@ -162,11 +162,7 @@ impl App { 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, - &self.state.terminals, - &self.state.terminal_runtimes, - ) + .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) .map(|cwd| cwd.display().to_string()), agent_status: pane_agent_status(terminal.state, pane.seen), }) diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index 802725f0..a45818df 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -37,7 +37,7 @@ impl App { return; } - for runtime in self.state.terminal_runtimes.values() { + for runtime in self.terminal_runtimes.values() { runtime.apply_host_terminal_theme(self.state.host_terminal_theme); } @@ -1,20 +1,21 @@ -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::UnixStream; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Serialize; -use crate::api; +use crate::api::client::{ApiClient, ApiClientError}; use crate::api::schema::{ - AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, 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, + AgentStatus, Method, OutputMatch, PaneAgentState, PaneWaitForOutputParams, ReadFormat, + ReadSource, Request, SplitDirection, Subscription, }; +mod agent; +mod integration; +mod pane; +mod server; +mod status; +mod tab; +mod workspace; + pub enum CommandOutcome { Handled(i32), NotCli, @@ -27,20 +28,20 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { let exit_code = match command { "server" => { - let Some(exit_code) = run_server_command(&args[2..])? else { + let Some(exit_code) = server::run_server_command(&args[2..])? else { return Ok(CommandOutcome::NotCli); }; exit_code } - "status" => run_status_command(&args[2..])?, + "status" => status::run_status_command(&args[2..])?, "config" => run_config_command(&args[2..])?, - "workspace" => run_workspace_command(&args[2..])?, - "tab" => run_tab_command(&args[2..])?, - "agent" => run_agent_command(&args[2..])?, + "workspace" => workspace::run_workspace_command(&args[2..])?, + "tab" => tab::run_tab_command(&args[2..])?, + "agent" => agent::run_agent_command(&args[2..])?, "terminal" => run_terminal_command(&args[2..])?, - "pane" => run_pane_command(&args[2..])?, + "pane" => pane::run_pane_command(&args[2..])?, "wait" => run_wait_command(&args[2..])?, - "integration" => run_integration_command(&args[2..])?, + "integration" => integration::run_integration_command(&args[2..])?, "session" => run_session_command(&args[2..])?, _ => return Ok(CommandOutcome::NotCli), }; @@ -48,54 +49,6 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { Ok(CommandOutcome::Handled(exit_code)) } -fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>> { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - return Ok(None); - }; - - match subcommand { - "stop" => server_stop(&args[1..]).map(Some), - "reload-config" => server_reload_config(&args[1..]).map(Some), - "help" | "--help" | "-h" => { - print_server_help(); - Ok(Some(0)) - } - _ => { - print_server_help(); - Ok(Some(2)) - } - } -} - -fn run_status_command(args: &[String]) -> std::io::Result<i32> { - match args.first().map(|arg| arg.as_str()) { - None => print_full_status(), - Some("server") => { - if args.len() > 1 { - eprintln!("usage: herdr status server"); - return Ok(2); - } - print_server_status() - } - Some("client") => { - if args.len() > 1 { - eprintln!("usage: herdr status client"); - return Ok(2); - } - print_client_status(); - Ok(0) - } - Some("help" | "--help" | "-h") => { - print_status_help(); - Ok(0) - } - Some(_) => { - print_status_help(); - Ok(2) - } - } -} - fn run_config_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_config_help(); @@ -204,206 +157,6 @@ fn key_config_backup_path(path: &std::path::Path) -> std::path::PathBuf { path.with_file_name(format!("{file_name}.bak-keybind-v2-{timestamp}")) } -#[derive(Debug, Clone, PartialEq, Eq)] -enum ServerRuntimeStatus { - Running { - version: Option<String>, - protocol: Option<u32>, - }, - NotRunning, -} - -fn print_full_status() -> std::io::Result<i32> { - let server = read_server_runtime_status()?; - - println!("client:"); - println!(" version: {}", env!("CARGO_PKG_VERSION")); - println!(" protocol: {}", crate::server::protocol::PROTOCOL_VERSION); - println!(); - println!("server:"); - print_server_status_body(&server, " "); - println!(); - println!("update:"); - println!(" restart_needed: {}", restart_needed_label(&server)); - - Ok(0) -} - -fn print_server_status() -> std::io::Result<i32> { - let server = read_server_runtime_status()?; - print_server_status_body(&server, ""); - Ok(0) -} - -fn print_client_status() { - println!("version: {}", env!("CARGO_PKG_VERSION")); - println!("protocol: {}", crate::server::protocol::PROTOCOL_VERSION); - println!("binary: {}", current_exe_label()); -} - -fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) { - match server { - ServerRuntimeStatus::Running { version, protocol } => { - println!("{indent}status: running"); - println!("{indent}version: {}", option_label(version.as_deref())); - println!("{indent}protocol: {}", protocol_label(*protocol)); - println!("{indent}compatible: {}", compatibility_label(*protocol)); - println!("{indent}socket: {}", api::socket_path().display()); - } - ServerRuntimeStatus::NotRunning => { - println!("{indent}status: not running"); - println!("{indent}socket: {}", api::socket_path().display()); - } - } -} - -fn read_server_runtime_status() -> std::io::Result<ServerRuntimeStatus> { - match send_request(&Request { - id: "cli:status:server".into(), - method: Method::Ping(PingParams::default()), - }) { - Ok(response) => { - if response.get("error").is_some() { - return Err(std::io::Error::other(format!( - "server status request failed: {}", - response - ))); - } - - let result = &response["result"]; - Ok(ServerRuntimeStatus::Running { - version: result - .get("version") - .and_then(|value| value.as_str()) - .map(str::to_owned), - protocol: result - .get("protocol") - .and_then(|value| value.as_u64()) - .and_then(|value| u32::try_from(value).ok()), - }) - } - Err(err) if server_not_running_error(&err) => Ok(ServerRuntimeStatus::NotRunning), - Err(err) => Err(err), - } -} - -fn server_not_running_error(err: &std::io::Error) -> bool { - matches!( - err.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused - ) -} - -fn option_label(value: Option<&str>) -> &str { - value.unwrap_or("unknown") -} - -fn protocol_label(protocol: Option<u32>) -> String { - protocol - .map(|value| value.to_string()) - .unwrap_or_else(|| "unknown".to_string()) -} - -fn compatibility_label(protocol: Option<u32>) -> &'static str { - match protocol { - Some(protocol) if protocol == crate::server::protocol::PROTOCOL_VERSION => "yes", - Some(_) => "no", - None => "unknown", - } -} - -fn restart_needed_label(server: &ServerRuntimeStatus) -> &'static str { - match server { - ServerRuntimeStatus::Running { version, .. } => match version.as_deref() { - Some(env!("CARGO_PKG_VERSION")) => "no", - Some(_) => "yes", - None => "unknown", - }, - ServerRuntimeStatus::NotRunning => "no", - } -} - -fn current_exe_label() -> String { - std::env::current_exe() - .map(|path| path.display().to_string()) - .unwrap_or_else(|err| format!("unknown ({err})")) -} - -fn run_workspace_command(args: &[String]) -> std::io::Result<i32> { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_workspace_help(); - return Ok(2); - }; - - match subcommand { - "list" => workspace_list(&args[1..]), - "create" => workspace_create(&args[1..]), - "get" => workspace_get(&args[1..]), - "focus" => workspace_focus(&args[1..]), - "rename" => workspace_rename(&args[1..]), - "close" => workspace_close(&args[1..]), - "help" | "--help" | "-h" => { - print_workspace_help(); - Ok(0) - } - _ => { - print_workspace_help(); - Ok(2) - } - } -} - -fn run_tab_command(args: &[String]) -> std::io::Result<i32> { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_tab_help(); - return Ok(2); - }; - - match subcommand { - "list" => tab_list(&args[1..]), - "create" => tab_create(&args[1..]), - "get" => tab_get(&args[1..]), - "focus" => tab_focus(&args[1..]), - "rename" => tab_rename(&args[1..]), - "close" => tab_close(&args[1..]), - "help" | "--help" | "-h" => { - print_tab_help(); - Ok(0) - } - _ => { - print_tab_help(); - Ok(2) - } - } -} - -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..]), - "wait" => agent_wait(&args[1..]), - "attach" => agent_attach(&args[1..]), - "start" => agent_start(&args[1..]), - "help" | "--help" | "-h" => { - print_agent_help(); - Ok(0) - } - _ => { - print_agent_help(); - Ok(2) - } - } -} - fn run_terminal_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_terminal_help(); @@ -423,34 +176,6 @@ fn run_terminal_command(args: &[String]) -> std::io::Result<i32> { } } -fn run_pane_command(args: &[String]) -> std::io::Result<i32> { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_pane_help(); - return Ok(2); - }; - - match subcommand { - "list" => pane_list(&args[1..]), - "get" => pane_get(&args[1..]), - "read" => pane_read(&args[1..]), - "rename" => pane_rename(&args[1..]), - "split" => pane_split(&args[1..]), - "close" => pane_close(&args[1..]), - "send-text" => pane_send_text(&args[1..]), - "send-keys" => pane_send_keys(&args[1..]), - "report-agent" => pane_report_agent(&args[1..]), - "run" => pane_run(&args[1..]), - "help" | "--help" | "-h" => { - print_pane_help(); - Ok(0) - } - _ => { - print_pane_help(); - Ok(2) - } - } -} - fn run_wait_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_wait_help(); @@ -471,27 +196,6 @@ fn run_wait_command(args: &[String]) -> std::io::Result<i32> { } } -fn run_integration_command(args: &[String]) -> std::io::Result<i32> { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_integration_help(); - return Ok(2); - }; - - match subcommand { - "install" => integration_install(&args[1..]), - "uninstall" => integration_uninstall(&args[1..]), - "status" => integration_status(&args[1..]), - "help" | "--help" | "-h" => { - print_integration_help(); - Ok(0) - } - _ => { - print_integration_help(); - Ok(2) - } - } -} - fn run_session_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_session_help(); @@ -514,27 +218,6 @@ fn run_session_command(args: &[String]) -> std::io::Result<i32> { } } -fn server_stop(args: &[String]) -> std::io::Result<i32> { - if !args.is_empty() { - eprintln!("usage: herdr server stop"); - return Ok(2); - } - - send_ok_request(Method::ServerStop(EmptyParams::default())) -} - -fn server_reload_config(args: &[String]) -> std::io::Result<i32> { - if !args.is_empty() { - eprintln!("usage: herdr server reload-config"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:server:reload-config".into(), - method: Method::ServerReloadConfig(EmptyParams::default()), - })?) -} - fn session_attach_help(args: &[String]) -> std::io::Result<i32> { if matches!( args.first().map(String::as_str), @@ -623,541 +306,6 @@ fn session_delete(args: &[String]) -> std::io::Result<i32> { } } -fn workspace_list(args: &[String]) -> std::io::Result<i32> { - if !args.is_empty() { - eprintln!("usage: herdr workspace list"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:workspace:list".into(), - method: Method::WorkspaceList(EmptyParams::default()), - })?) -} - -fn workspace_create(args: &[String]) -> std::io::Result<i32> { - let mut cwd = None; - let mut focus = false; - let mut label = None; - - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--cwd" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --cwd"); - return Ok(2); - }; - cwd = Some(value.clone()); - index += 2; - } - "--label" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --label"); - return Ok(2); - }; - label = Some(value.clone()); - index += 2; - } - "--focus" => { - focus = true; - index += 1; - } - "--no-focus" => { - focus = false; - index += 1; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - print_response(&send_request(&Request { - id: "cli:workspace:create".into(), - method: Method::WorkspaceCreate(WorkspaceCreateParams { cwd, focus, label }), - })?) -} - -fn workspace_get(args: &[String]) -> std::io::Result<i32> { - let Some(raw_workspace_id) = args.first() else { - eprintln!("usage: herdr workspace get <workspace_id>"); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr workspace get <workspace_id>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:workspace:get".into(), - method: Method::WorkspaceGet(WorkspaceTarget { - workspace_id: normalize_workspace_id(raw_workspace_id), - }), - })?) -} - -fn workspace_focus(args: &[String]) -> std::io::Result<i32> { - let Some(raw_workspace_id) = args.first() else { - eprintln!("usage: herdr workspace focus <workspace_id>"); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr workspace focus <workspace_id>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:workspace:focus".into(), - method: Method::WorkspaceFocus(WorkspaceTarget { - workspace_id: normalize_workspace_id(raw_workspace_id), - }), - })?) -} - -fn workspace_rename(args: &[String]) -> std::io::Result<i32> { - if args.len() < 2 { - eprintln!("usage: herdr workspace rename <workspace_id> <label>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:workspace:rename".into(), - method: Method::WorkspaceRename(WorkspaceRenameParams { - workspace_id: normalize_workspace_id(&args[0]), - label: args[1..].join(" "), - }), - })?) -} - -fn workspace_close(args: &[String]) -> std::io::Result<i32> { - let Some(raw_workspace_id) = args.first() else { - eprintln!("usage: herdr workspace close <workspace_id>"); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr workspace close <workspace_id>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:workspace:close".into(), - method: Method::WorkspaceClose(WorkspaceTarget { - workspace_id: normalize_workspace_id(raw_workspace_id), - }), - })?) -} - -fn tab_list(args: &[String]) -> std::io::Result<i32> { - let mut workspace_id = None; - - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--workspace" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --workspace"); - return Ok(2); - }; - workspace_id = Some(normalize_workspace_id(value)); - index += 2; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - print_response(&send_request(&Request { - id: "cli:tab:list".into(), - method: Method::TabList(TabListParams { workspace_id }), - })?) -} - -fn tab_create(args: &[String]) -> std::io::Result<i32> { - let mut workspace_id = None; - let mut cwd = None; - let mut focus = false; - let mut label = None; - - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--workspace" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --workspace"); - return Ok(2); - }; - workspace_id = Some(normalize_workspace_id(value)); - index += 2; - } - "--cwd" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --cwd"); - return Ok(2); - }; - cwd = Some(value.clone()); - index += 2; - } - "--label" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --label"); - return Ok(2); - }; - label = Some(value.clone()); - index += 2; - } - "--focus" => { - focus = true; - index += 1; - } - "--no-focus" => { - focus = false; - index += 1; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - print_response(&send_request(&Request { - id: "cli:tab:create".into(), - method: Method::TabCreate(TabCreateParams { - workspace_id, - cwd, - focus, - label, - }), - })?) -} - -fn tab_get(args: &[String]) -> std::io::Result<i32> { - let Some(raw_tab_id) = args.first() else { - eprintln!("usage: herdr tab get <tab_id>"); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr tab get <tab_id>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:tab:get".into(), - method: Method::TabGet(TabTarget { - tab_id: normalize_tab_id(raw_tab_id), - }), - })?) -} - -fn tab_focus(args: &[String]) -> std::io::Result<i32> { - let Some(raw_tab_id) = args.first() else { - eprintln!("usage: herdr tab focus <tab_id>"); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr tab focus <tab_id>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:tab:focus".into(), - method: Method::TabFocus(TabTarget { - tab_id: normalize_tab_id(raw_tab_id), - }), - })?) -} - -fn tab_rename(args: &[String]) -> std::io::Result<i32> { - if args.len() < 2 { - eprintln!("usage: herdr tab rename <tab_id> <label>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:tab:rename".into(), - method: Method::TabRename(TabRenameParams { - tab_id: normalize_tab_id(&args[0]), - label: args[1..].join(" "), - }), - })?) -} - -fn tab_close(args: &[String]) -> std::io::Result<i32> { - let Some(raw_tab_id) = args.first() else { - eprintln!("usage: herdr tab close <tab_id>"); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr tab close <tab_id>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:tab:close".into(), - method: Method::TabClose(TabTarget { - tab_id: normalize_tab_id(raw_tab_id), - }), - })?) -} - -fn agent_start(args: &[String]) -> std::io::Result<i32> { - let Some(name) = args.first() else { - eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); - return Ok(2); - }; - - let Some(separator) = args.iter().position(|arg| arg == "--") else { - eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); - return Ok(2); - }; - if separator == args.len() - 1 { - eprintln!("agent start requires argv after --"); - return Ok(2); - } - - let mut cwd = None; - let mut workspace_id = None; - let mut tab_id = None; - let mut split = None; - let mut focus = false; - - let mut index = 1; - while index < separator { - match args[index].as_str() { - "--cwd" => { - let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --cwd"); - return Ok(2); - }; - cwd = Some(value.clone()); - index += 2; - } - "--workspace" => { - let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --workspace"); - return Ok(2); - }; - workspace_id = Some(normalize_workspace_id(value)); - index += 2; - } - "--tab" => { - let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --tab"); - return Ok(2); - }; - tab_id = Some(normalize_tab_id(value)); - index += 2; - } - "--split" => { - let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --split"); - return Ok(2); - }; - split = Some(parse_split_direction(value)?); - index += 2; - } - "--focus" => { - focus = true; - index += 1; - } - "--no-focus" => { - focus = false; - index += 1; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - print_response(&send_request(&Request { - id: "cli:agent:start".into(), - method: Method::AgentStart(AgentStartParams { - name: name.clone(), - cwd, - workspace_id, - tab_id, - split, - focus, - argv: args[separator + 1..].to_vec(), - }), - })?) -} - -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_attach(args: &[String]) -> std::io::Result<i32> { - let (target, takeover) = - match parse_attach_target(args, "usage: herdr agent attach <target> [--takeover]") { - Ok(parsed) => parsed, - Err(code) => return Ok(code), - }; - - let response = resolve_agent_target(&target, "cli:agent:attach:resolve")?; - if response.get("error").is_some() { - eprintln!("{}", serde_json::to_string(&response).unwrap()); - return Ok(1); - } - let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { - eprintln!("agent attach failed: response did not include terminal_id"); - return Ok(1); - }; - crate::client::run_terminal_attach(terminal_id.to_owned(), takeover)?; - Ok(0) -} - -fn agent_wait(args: &[String]) -> std::io::Result<i32> { - let Some(target) = args.first() else { - eprintln!("usage: herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]"); - return Ok(2); - }; - - let mut timeout_ms = None; - let mut desired_status = None; - - let mut index = 1; - while index < args.len() { - match args[index].as_str() { - "--status" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --status"); - return Ok(2); - }; - desired_status = Some(parse_agent_wait_status(value)?); - index += 2; - } - "--timeout" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --timeout"); - return Ok(2); - }; - timeout_ms = Some(parse_u64_flag("--timeout", value)?); - index += 2; - } - "help" | "--help" | "-h" => { - eprintln!("usage: herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]"); - return Ok(0); - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - let Some(agent_status) = desired_status else { - eprintln!("missing required --status"); - return Ok(2); - }; - - let response = resolve_agent_target(target, "cli:agent:wait:resolve")?; - if response.get("error").is_some() { - eprintln!("{}", serde_json::to_string(&response).unwrap()); - return Ok(1); - } - if response["result"]["agent"]["agent_status"] - .as_str() - .is_some_and(|current| agent_wait_status_satisfied(agent_status, current)) - { - println!("{}", serde_json::to_string(&response).unwrap()); - return Ok(0); - } - - let Some(pane_id) = response["result"]["agent"]["pane_id"].as_str() else { - eprintln!("agent wait failed: response did not include pane_id"); - return Ok(1); - }; - - let subscriptions = if agent_status == AgentStatus::Idle { - vec![ - Subscription::PaneAgentStatusChanged { - pane_id: pane_id.to_owned(), - agent_status: Some(AgentStatus::Idle), - }, - Subscription::PaneAgentStatusChanged { - pane_id: pane_id.to_owned(), - agent_status: Some(AgentStatus::Done), - }, - ] - } else { - vec![Subscription::PaneAgentStatusChanged { - pane_id: pane_id.to_owned(), - agent_status: Some(agent_status), - }] - }; - - wait_for_agent_change( - Request { - id: "cli:agent:wait".into(), - method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams { - subscriptions, - }), - }, - timeout_ms, - "timed out waiting for agent status change", - ) -} - -fn resolve_agent_target(target: &str, request_id: &str) -> std::io::Result<serde_json::Value> { - send_request(&Request { - id: request_id.into(), - method: Method::AgentGet(AgentTarget { - target: target.to_owned(), - }), - }) -} - fn terminal_attach(args: &[String]) -> std::io::Result<i32> { let (terminal_id, takeover) = match parse_attach_target( args, @@ -1170,7 +318,7 @@ fn terminal_attach(args: &[String]) -> std::io::Result<i32> { Ok(0) } -fn parse_attach_target(args: &[String], usage: &str) -> Result<(String, bool), i32> { +pub(super) fn parse_attach_target(args: &[String], usage: &str) -> Result<(String, bool), i32> { let Some(target) = args.first() else { eprintln!("{usage}"); return Err(2); @@ -1192,574 +340,6 @@ fn parse_attach_target(args: &[String], usage: &str) -> Result<(String, bool), i Ok((target.clone(), takeover)) } -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; - - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--workspace" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --workspace"); - return Ok(2); - }; - workspace_id = Some(normalize_workspace_id(value)); - index += 2; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - print_response(&send_request(&Request { - id: "cli:pane:list".into(), - method: Method::PaneList(PaneListParams { workspace_id }), - })?) -} - -fn pane_get(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane get <pane_id>"); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr pane get <pane_id>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:pane:get".into(), - method: Method::PaneGet(PaneTarget { - pane_id: normalize_pane_id(raw_pane_id), - }), - })?) -} - -fn pane_rename(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); - return Ok(2); - }; - if args.len() < 2 { - eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); - return Ok(2); - } - let label = if args.len() == 2 && args[1] == "--clear" { - None - } else { - Some(args[1..].join(" ")) - }; - - print_response(&send_request(&Request { - id: "cli:pane:rename".into(), - method: Method::PaneRename(PaneRenameParams { - pane_id: normalize_pane_id(raw_pane_id), - label, - }), - })?) -} - -fn pane_read(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); - return Ok(2); - }; - - let pane_id = normalize_pane_id(raw_pane_id); - 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)?; - index += 2; - } - "--ansi" => { - format = ReadFormat::Ansi; - index += 1; - } - "--raw" => { - format = ReadFormat::Ansi; - strip_ansi = false; - index += 1; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - let response = send_request(&Request { - id: "cli:pane:read".into(), - method: Method::PaneRead(PaneReadParams { - pane_id, - source, - lines, - format, - strip_ansi, - }), - })?; - - if let Some(error) = response.get("error") { - eprintln!("{}", serde_json::to_string(error).unwrap()); - return Ok(1); - } - - if let Some(text) = response["result"]["read"]["text"].as_str() { - print!("{text}"); - } - Ok(0) -} - -fn pane_split(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!( - "usage: herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]" - ); - return Ok(2); - }; - - let pane_id = normalize_pane_id(raw_pane_id); - let mut direction = None; - let mut cwd = None; - let mut focus = false; - - let mut index = 1; - while index < args.len() { - match args[index].as_str() { - "--direction" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --direction"); - return Ok(2); - }; - direction = Some(parse_split_direction(value)?); - index += 2; - } - "--cwd" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --cwd"); - return Ok(2); - }; - cwd = Some(value.clone()); - index += 2; - } - "--focus" => { - focus = true; - index += 1; - } - "--no-focus" => { - focus = false; - index += 1; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - let Some(direction) = direction else { - eprintln!("missing required --direction"); - return Ok(2); - }; - - print_response(&send_request(&Request { - id: "cli:pane:split".into(), - method: Method::PaneSplit(PaneSplitParams { - workspace_id: None, - target_pane_id: pane_id, - direction, - cwd, - focus, - }), - })?) -} - -fn pane_close(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane close <pane_id>"); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr pane close <pane_id>"); - return Ok(2); - } - - print_response(&send_request(&Request { - id: "cli:pane:close".into(), - method: Method::PaneClose(PaneTarget { - pane_id: normalize_pane_id(raw_pane_id), - }), - })?) -} - -fn pane_send_text(args: &[String]) -> std::io::Result<i32> { - if args.len() < 2 { - eprintln!("usage: herdr pane send-text <pane_id> <text>"); - return Ok(2); - } - - let pane_id = normalize_pane_id(&args[0]); - let text = args[1..].join(" "); - send_ok_request(Method::PaneSendText(PaneSendTextParams { pane_id, text })) -} - -fn pane_send_keys(args: &[String]) -> std::io::Result<i32> { - if args.len() < 2 { - eprintln!("usage: herdr pane send-keys <pane_id> <key> [key ...]"); - return Ok(2); - } - - let pane_id = normalize_pane_id(&args[0]); - let keys = args[1..].to_vec(); - send_ok_request(Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys })) -} - -fn pane_run(args: &[String]) -> std::io::Result<i32> { - if args.len() < 2 { - eprintln!("usage: herdr pane run <pane_id> <command>"); - return Ok(2); - } - - let pane_id = normalize_pane_id(&args[0]); - let text = args[1..].join(" "); - send_ok_request(Method::PaneSendInput(PaneSendInputParams { - pane_id, - text, - keys: vec!["Enter".into()], - })) -} - -fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); - return Ok(2); - }; - - let pane_id = normalize_pane_id(raw_pane_id); - let mut source = None; - let mut agent = None; - let mut state = None; - let mut message = None; - let mut custom_status = None; - let mut seq = None; - - 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 = Some(value.clone()); - index += 2; - } - "--agent" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --agent"); - return Ok(2); - }; - agent = Some(value.clone()); - index += 2; - } - "--state" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --state"); - return Ok(2); - }; - state = Some(parse_pane_agent_state(value)?); - index += 2; - } - "--message" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --message"); - return Ok(2); - }; - message = Some(value.clone()); - index += 2; - } - "--custom-status" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --custom-status"); - return Ok(2); - }; - custom_status = Some(value.clone()); - index += 2; - } - "--seq" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --seq"); - return Ok(2); - }; - seq = Some(parse_u64_flag("--seq", value)?); - index += 2; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - let Some(source) = source else { - eprintln!("missing required --source"); - return Ok(2); - }; - let Some(agent) = agent else { - eprintln!("missing required --agent"); - return Ok(2); - }; - let Some(state) = state else { - eprintln!("missing required --state"); - return Ok(2); - }; - - send_ok_request(Method::PaneReportAgent(PaneReportAgentParams { - pane_id, - source, - agent, - state, - message, - custom_status, - seq, - })) -} - -fn integration_status(args: &[String]) -> std::io::Result<i32> { - let outdated_only = match args { - [] => false, - [flag] if flag == "--outdated-only" => true, - _ => { - eprintln!("usage: herdr integration status [--outdated-only]"); - return Ok(2); - } - }; - - if outdated_only { - crate::integration::print_outdated_update_notice(); - return Ok(0); - } - - for status in crate::integration::installed_integration_statuses() { - let target = crate::integration::integration_target_label(status.target); - let version = match status.installed_version { - Some(version) => format!("v{version}"), - None => "legacy".to_string(), - }; - let state = match status.state { - crate::integration::IntegrationStatusKind::NotInstalled => "not installed".to_string(), - crate::integration::IntegrationStatusKind::Current => { - format!("current ({version})") - } - crate::integration::IntegrationStatusKind::Outdated => { - format!("outdated ({version} < v{})", status.expected_version) - } - }; - println!("{target}: {state} ({})", status.path.display()); - } - - Ok(0) -} - -fn integration_install(args: &[String]) -> std::io::Result<i32> { - let Some(target) = parse_integration_target(args, "install")? else { - return Ok(2); - }; - - match crate::integration::install_target(target) { - Ok(messages) => { - print_integration_messages(messages); - Ok(0) - } - Err(err) => { - eprintln!("{err}"); - Ok(1) - } - } -} - -fn integration_uninstall(args: &[String]) -> std::io::Result<i32> { - let Some(target) = parse_integration_target(args, "uninstall")? else { - return Ok(2); - }; - - match crate::integration::uninstall_target(target) { - Ok(messages) => { - print_integration_messages(messages); - Ok(0) - } - Err(err) => { - eprintln!("{err}"); - Ok(1) - } - } -} - -fn print_integration_messages(messages: Vec<String>) { - for message in messages { - println!("{message}"); - } -} - -fn parse_integration_target( - args: &[String], - action: &str, -) -> std::io::Result<Option<IntegrationTarget>> { - let Some(target) = args.first().map(|arg| arg.as_str()) else { - eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>"); - return Ok(None); - }; - if args.len() != 1 { - eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>"); - return Ok(None); - } - - let parsed = match target { - "pi" => IntegrationTarget::Pi, - "claude" => IntegrationTarget::Claude, - "codex" => IntegrationTarget::Codex, - "opencode" => IntegrationTarget::Opencode, - "hermes" => IntegrationTarget::Hermes, - _ => { - eprintln!("unknown integration target: {target}"); - eprintln!("currently supported: pi, claude, codex, opencode, hermes"); - return Ok(None); - } - }; - - Ok(Some(parsed)) -} - fn wait_output(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { eprintln!("usage: herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex]"); @@ -1912,58 +492,41 @@ fn wait_agent_status(args: &[String]) -> std::io::Result<i32> { ) } -fn wait_for_agent_change( +pub(super) fn wait_for_agent_change( request: Request, timeout_ms: Option<u64>, timeout_message: &str, ) -> std::io::Result<i32> { - let mut stream = UnixStream::connect(api::socket_path())?; - stream.write_all(serde_json::to_string(&request)?.as_bytes())?; - stream.write_all(b"\n")?; - stream.flush()?; - - if let Some(timeout_ms) = timeout_ms { - stream.set_read_timeout(Some(Duration::from_millis(timeout_ms)))?; - } - - let mut reader = BufReader::new(stream); - let mut ack = String::new(); - reader.read_line(&mut ack)?; - if ack.trim().is_empty() { - eprintln!("empty subscription ack"); - return Ok(1); - } - let ack_value: serde_json::Value = serde_json::from_str(&ack)?; - if ack_value.get("error").is_some() { - eprintln!("{}", serde_json::to_string(&ack_value).unwrap()); - return Ok(1); + let read_timeout = timeout_ms.map(Duration::from_millis); + let (ack, mut stream) = ApiClient::local() + .subscribe_value(&request, read_timeout) + .map_err(api_client_error_to_io)?; + if let Err(err) = crate::api::client::parse_response_value(ack) { + if let ApiClientError::ErrorResponse(response) = err { + eprintln!("{}", serde_json::to_string(&response).unwrap()); + return Ok(1); + } + return Err(api_client_error_to_io(err)); } - let mut event = String::new(); - match reader.read_line(&mut event) { - Ok(0) => { + match stream.next_event() { + Ok(None) => { eprintln!("subscription closed before event arrived"); Ok(1) } - Ok(_) => { - let event_value: serde_json::Value = serde_json::from_str(&event)?; + Ok(Some(event_value)) => { println!("{}", serde_json::to_string(&event_value).unwrap()); Ok(0) } - Err(err) - if matches!( - err.kind(), - std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock - ) => - { + Err(ApiClientError::Io(err)) if api_timeout_error(&err) => { eprintln!("{timeout_message}"); Ok(1) } - Err(err) => Err(err), + Err(err) => Err(api_client_error_to_io(err)), } } -fn print_response(response: &serde_json::Value) -> std::io::Result<i32> { +pub(super) fn print_response(response: &serde_json::Value) -> std::io::Result<i32> { if response.get("error").is_some() { eprintln!("{}", serde_json::to_string(response).unwrap()); return Ok(1); @@ -1973,7 +536,7 @@ fn print_response(response: &serde_json::Value) -> std::io::Result<i32> { Ok(0) } -fn send_ok_request(method: Method) -> std::io::Result<i32> { +pub(super) fn send_ok_request(method: Method) -> std::io::Result<i32> { let response = send_request(&Request { id: "cli:request".into(), method, @@ -1987,31 +550,39 @@ fn send_ok_request(method: Method) -> std::io::Result<i32> { Ok(0) } -fn send_request(request: &Request) -> std::io::Result<serde_json::Value> { - let mut stream = UnixStream::connect(api::socket_path())?; - stream.write_all(serde_json::to_string(request)?.as_bytes())?; - stream.write_all(b"\n")?; - stream.flush()?; +pub(super) fn send_request(request: &Request) -> std::io::Result<serde_json::Value> { + ApiClient::local() + .request_value(request) + .map_err(api_client_error_to_io) +} - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader.read_line(&mut line)?; - serde_json::from_str(&line).map_err(std::io::Error::other) +fn api_timeout_error(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) +} + +fn api_client_error_to_io(err: ApiClientError) -> std::io::Error { + match err { + ApiClientError::Io(err) => err, + err => std::io::Error::other(err), + } } -fn normalize_workspace_id(value: &str) -> String { +pub(super) fn normalize_workspace_id(value: &str) -> String { value.to_string() } -fn normalize_tab_id(value: &str) -> String { +pub(super) fn normalize_tab_id(value: &str) -> String { value.to_string() } -fn normalize_pane_id(value: &str) -> String { +pub(super) fn normalize_pane_id(value: &str) -> String { value.to_string() } -fn parse_split_direction(value: &str) -> std::io::Result<SplitDirection> { +pub(super) fn parse_split_direction(value: &str) -> std::io::Result<SplitDirection> { match value { "right" => Ok(SplitDirection::Right), "down" => Ok(SplitDirection::Down), @@ -2021,7 +592,7 @@ fn parse_split_direction(value: &str) -> std::io::Result<SplitDirection> { } } -fn parse_read_source(value: &str) -> std::io::Result<ReadSource> { +pub(super) fn parse_read_source(value: &str) -> std::io::Result<ReadSource> { match value { "visible" => Ok(ReadSource::Visible), "recent" => Ok(ReadSource::Recent), @@ -2032,7 +603,7 @@ fn parse_read_source(value: &str) -> std::io::Result<ReadSource> { } } -fn parse_read_format(value: &str) -> std::io::Result<ReadFormat> { +pub(super) fn parse_read_format(value: &str) -> std::io::Result<ReadFormat> { match value { "text" => Ok(ReadFormat::Text), "ansi" => Ok(ReadFormat::Ansi), @@ -2042,31 +613,6 @@ fn parse_read_format(value: &str) -> std::io::Result<ReadFormat> { } } -fn agent_wait_status_satisfied(desired: AgentStatus, current: &str) -> bool { - match desired { - AgentStatus::Idle => matches!(current, "idle" | "done"), - AgentStatus::Working => current == "working", - AgentStatus::Blocked => current == "blocked", - AgentStatus::Unknown => current == "unknown", - AgentStatus::Done => false, - } -} - -fn parse_agent_wait_status(value: &str) -> std::io::Result<AgentStatus> { - match value { - "idle" => Ok(AgentStatus::Idle), - "working" => Ok(AgentStatus::Working), - "blocked" => Ok(AgentStatus::Blocked), - "unknown" => Ok(AgentStatus::Unknown), - "done" => Err(std::io::Error::other( - "done is a UI attention state; use idle for CLI agent completion waits", - )), - _ => Err(std::io::Error::other(format!( - "invalid agent status: {value} (expected idle, working, blocked, or unknown)" - ))), - } -} - fn parse_agent_status(value: &str) -> std::io::Result<AgentStatus> { match value { "idle" => Ok(AgentStatus::Idle), @@ -2080,7 +626,7 @@ fn parse_agent_status(value: &str) -> std::io::Result<AgentStatus> { } } -fn parse_pane_agent_state(value: &str) -> std::io::Result<PaneAgentState> { +pub(super) fn parse_pane_agent_state(value: &str) -> std::io::Result<PaneAgentState> { match value { "idle" => Ok(PaneAgentState::Idle), "working" => Ok(PaneAgentState::Working), @@ -2092,13 +638,13 @@ fn parse_pane_agent_state(value: &str) -> std::io::Result<PaneAgentState> { } } -fn parse_u32_flag(flag: &str, value: &str) -> std::io::Result<u32> { +pub(super) fn parse_u32_flag(flag: &str, value: &str) -> std::io::Result<u32> { value .parse::<u32>() .map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}"))) } -fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result<u64> { +pub(super) fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result<u64> { value .parse::<u64>() .map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}"))) @@ -2166,86 +712,17 @@ fn print_session_error(code: &str, message: &str) { ); } -fn print_server_help() { - eprintln!("herdr server commands:"); - eprintln!(" herdr server run as headless server"); - eprintln!(" herdr server stop stop the running server via the API socket"); - eprintln!(" herdr server reload-config reload config.toml in the running server"); -} - -fn print_status_help() { - eprintln!("herdr status commands:"); - eprintln!(" herdr status show local client and running server status"); - eprintln!(" herdr status server show running server status"); - eprintln!(" herdr status client show local client binary status"); -} - fn print_config_help() { eprintln!("herdr config commands:"); eprintln!(" herdr config reset-keys back up config.toml and remove custom keybindings"); } -fn print_workspace_help() { - eprintln!("herdr workspace commands:"); - eprintln!(" herdr workspace list"); - eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--focus] [--no-focus]"); - eprintln!(" herdr workspace get <workspace_id>"); - eprintln!(" herdr workspace focus <workspace_id>"); - eprintln!(" herdr workspace rename <workspace_id> <label>"); - eprintln!(" herdr workspace close <workspace_id>"); -} - -fn print_tab_help() { - eprintln!("herdr tab commands:"); - eprintln!(" herdr tab list [--workspace <workspace_id>]"); - eprintln!( - " herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--focus] [--no-focus]" - ); - eprintln!(" herdr tab get <tab_id>"); - eprintln!(" herdr tab focus <tab_id>"); - eprintln!(" herdr tab rename <tab_id> <label>"); - 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!(" herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]"); - eprintln!(" herdr agent attach <target> [--takeover]"); - eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); - eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids"); - eprintln!( - " agent send writes literal text; use pane run when you want command text plus Enter" - ); -} - fn print_terminal_help() { eprintln!("herdr terminal commands:"); eprintln!(" herdr terminal attach <terminal_id> [--takeover]"); eprintln!(" detach from direct attach with ctrl+b q; send literal ctrl+b with ctrl+b ctrl+b"); } -fn print_pane_help() { - eprintln!("herdr pane commands:"); - eprintln!(" herdr pane list [--workspace <workspace_id>]"); - eprintln!(" herdr pane get <pane_id>"); - eprintln!(" herdr pane rename <pane_id> <label>|--clear"); - eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); - eprintln!( - " herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]" - ); - eprintln!(" herdr pane close <pane_id>"); - eprintln!(" herdr pane send-text <pane_id> <text>"); - eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); - eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); - eprintln!(" herdr pane run <pane_id> <command>"); -} - fn print_wait_help() { eprintln!("herdr wait commands:"); eprintln!(" herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw]"); @@ -2254,21 +731,6 @@ fn print_wait_help() { ); } -fn print_integration_help() { - eprintln!("herdr integration commands:"); - eprintln!(" herdr integration install pi"); - eprintln!(" herdr integration install claude"); - eprintln!(" herdr integration install codex"); - eprintln!(" herdr integration install opencode"); - eprintln!(" herdr integration install hermes"); - eprintln!(" herdr integration uninstall pi"); - eprintln!(" herdr integration uninstall claude"); - eprintln!(" herdr integration uninstall codex"); - eprintln!(" herdr integration uninstall opencode"); - eprintln!(" herdr integration uninstall hermes"); - eprintln!(" herdr integration status [--outdated-only]"); -} - fn print_session_help() { eprintln!("herdr session commands:"); eprintln!(" herdr session list [--json]"); diff --git a/src/cli/agent.rs b/src/cli/agent.rs new file mode 100644 index 00000000..5005eda1 --- /dev/null +++ b/src/cli/agent.rs @@ -0,0 +1,429 @@ +use crate::api::schema::{ + AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus, + AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, Subscription, +}; + +pub(super) 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..]), + "wait" => agent_wait(&args[1..]), + "attach" => agent_attach(&args[1..]), + "start" => agent_start(&args[1..]), + "help" | "--help" | "-h" => { + print_agent_help(); + Ok(0) + } + _ => { + print_agent_help(); + Ok(2) + } + } +} + +fn agent_start(args: &[String]) -> std::io::Result<i32> { + let Some(name) = args.first() else { + eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); + return Ok(2); + }; + + let Some(separator) = args.iter().position(|arg| arg == "--") else { + eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); + return Ok(2); + }; + if separator == args.len() - 1 { + eprintln!("agent start requires argv after --"); + return Ok(2); + } + + let mut cwd = None; + let mut workspace_id = None; + let mut tab_id = None; + let mut split = None; + let mut focus = false; + + let mut index = 1; + while index < separator { + match args[index].as_str() { + "--cwd" => { + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(value.clone()); + index += 2; + } + "--workspace" => { + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + "--tab" => { + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { + eprintln!("missing value for --tab"); + return Ok(2); + }; + tab_id = Some(super::normalize_tab_id(value)); + index += 2; + } + "--split" => { + let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { + eprintln!("missing value for --split"); + return Ok(2); + }; + split = Some(super::parse_split_direction(value)?); + index += 2; + } + "--focus" => { + focus = true; + index += 1; + } + "--no-focus" => { + focus = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + super::print_response(&super::send_request(&Request { + id: "cli:agent:start".into(), + method: Method::AgentStart(AgentStartParams { + name: name.clone(), + cwd, + workspace_id, + tab_id, + split, + focus, + argv: args[separator + 1..].to_vec(), + }), + })?) +} + +fn agent_list(args: &[String]) -> std::io::Result<i32> { + if !args.is_empty() { + eprintln!("usage: herdr agent list"); + return Ok(2); + } + + super::print_response(&super::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); + } + + super::print_response(&super::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); + } + + super::print_response(&super::send_request(&Request { + id: "cli:agent:focus".into(), + method: Method::AgentFocus(AgentTarget { + target: target.clone(), + }), + })?) +} + +fn agent_attach(args: &[String]) -> std::io::Result<i32> { + let (target, takeover) = + match super::parse_attach_target(args, "usage: herdr agent attach <target> [--takeover]") { + Ok(parsed) => parsed, + Err(code) => return Ok(code), + }; + + let response = resolve_agent_target(&target, "cli:agent:attach:resolve")?; + if response.get("error").is_some() { + eprintln!("{}", serde_json::to_string(&response).unwrap()); + return Ok(1); + } + let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { + eprintln!("agent attach failed: response did not include terminal_id"); + return Ok(1); + }; + crate::client::run_terminal_attach(terminal_id.to_owned(), takeover)?; + Ok(0) +} + +fn agent_wait(args: &[String]) -> std::io::Result<i32> { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]"); + return Ok(2); + }; + + let mut timeout_ms = None; + let mut desired_status = None; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--status" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --status"); + return Ok(2); + }; + desired_status = Some(parse_agent_wait_status(value)?); + index += 2; + } + "--timeout" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --timeout"); + return Ok(2); + }; + timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); + index += 2; + } + "help" | "--help" | "-h" => { + eprintln!("usage: herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]"); + return Ok(0); + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(agent_status) = desired_status else { + eprintln!("missing required --status"); + return Ok(2); + }; + + let response = resolve_agent_target(target, "cli:agent:wait:resolve")?; + if response.get("error").is_some() { + eprintln!("{}", serde_json::to_string(&response).unwrap()); + return Ok(1); + } + if response["result"]["agent"]["agent_status"] + .as_str() + .is_some_and(|current| agent_wait_status_satisfied(agent_status, current)) + { + println!("{}", serde_json::to_string(&response).unwrap()); + return Ok(0); + } + + let Some(pane_id) = response["result"]["agent"]["pane_id"].as_str() else { + eprintln!("agent wait failed: response did not include pane_id"); + return Ok(1); + }; + + let subscriptions = if agent_status == AgentStatus::Idle { + vec![ + Subscription::PaneAgentStatusChanged { + pane_id: pane_id.to_owned(), + agent_status: Some(AgentStatus::Idle), + }, + Subscription::PaneAgentStatusChanged { + pane_id: pane_id.to_owned(), + agent_status: Some(AgentStatus::Done), + }, + ] + } else { + vec![Subscription::PaneAgentStatusChanged { + pane_id: pane_id.to_owned(), + agent_status: Some(agent_status), + }] + }; + + super::wait_for_agent_change( + Request { + id: "cli:agent:wait".into(), + method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams { + subscriptions, + }), + }, + timeout_ms, + "timed out waiting for agent status change", + ) +} + +fn resolve_agent_target(target: &str, request_id: &str) -> std::io::Result<serde_json::Value> { + super::send_request(&Request { + id: request_id.into(), + method: Method::AgentGet(AgentTarget { + target: target.to_owned(), + }), + }) +} + +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(" ")) + }; + + super::print_response(&super::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); + } + + super::print_response(&super::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 = super::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(super::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 = super::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); + } + } + } + + super::print_response(&super::send_request(&Request { + id: "cli:agent:read".into(), + method: Method::AgentRead(AgentReadParams { + target: target.clone(), + source, + lines, + format, + strip_ansi, + }), + })?) +} + +fn agent_wait_status_satisfied(desired: AgentStatus, current: &str) -> bool { + match desired { + AgentStatus::Idle => matches!(current, "idle" | "done"), + AgentStatus::Working => current == "working", + AgentStatus::Blocked => current == "blocked", + AgentStatus::Unknown => current == "unknown", + AgentStatus::Done => false, + } +} + +fn parse_agent_wait_status(value: &str) -> std::io::Result<AgentStatus> { + match value { + "idle" => Ok(AgentStatus::Idle), + "working" => Ok(AgentStatus::Working), + "blocked" => Ok(AgentStatus::Blocked), + "unknown" => Ok(AgentStatus::Unknown), + "done" => Err(std::io::Error::other( + "done is a UI attention state; use idle for CLI agent completion waits", + )), + _ => Err(std::io::Error::other(format!( + "invalid agent status: {value} (expected idle, working, blocked, or unknown)" + ))), + } +} + +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!(" herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]"); + eprintln!(" herdr agent attach <target> [--takeover]"); + eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); + eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids"); + eprintln!( + " agent send writes literal text; use pane run when you want command text plus Enter" + ); +} diff --git a/src/cli/integration.rs b/src/cli/integration.rs new file mode 100644 index 00000000..6239faf2 --- /dev/null +++ b/src/cli/integration.rs @@ -0,0 +1,142 @@ +use crate::api::schema::IntegrationTarget; + +pub(super) fn run_integration_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_integration_help(); + return Ok(2); + }; + + match subcommand { + "install" => integration_install(&args[1..]), + "uninstall" => integration_uninstall(&args[1..]), + "status" => integration_status(&args[1..]), + "help" | "--help" | "-h" => { + print_integration_help(); + Ok(0) + } + _ => { + print_integration_help(); + Ok(2) + } + } +} + +fn integration_status(args: &[String]) -> std::io::Result<i32> { + let outdated_only = match args { + [] => false, + [flag] if flag == "--outdated-only" => true, + _ => { + eprintln!("usage: herdr integration status [--outdated-only]"); + return Ok(2); + } + }; + + if outdated_only { + crate::integration::print_outdated_update_notice(); + return Ok(0); + } + + for status in crate::integration::installed_integration_statuses() { + let target = crate::integration::integration_target_label(status.target); + let version = match status.installed_version { + Some(version) => format!("v{version}"), + None => "legacy".to_string(), + }; + let state = match status.state { + crate::integration::IntegrationStatusKind::NotInstalled => "not installed".to_string(), + crate::integration::IntegrationStatusKind::Current => { + format!("current ({version})") + } + crate::integration::IntegrationStatusKind::Outdated => { + format!("outdated ({version} < v{})", status.expected_version) + } + }; + println!("{target}: {state} ({})", status.path.display()); + } + + Ok(0) +} + +fn integration_install(args: &[String]) -> std::io::Result<i32> { + let Some(target) = parse_integration_target(args, "install")? else { + return Ok(2); + }; + + match crate::integration::install_target(target) { + Ok(messages) => { + print_integration_messages(messages); + Ok(0) + } + Err(err) => { + eprintln!("{err}"); + Ok(1) + } + } +} + +fn integration_uninstall(args: &[String]) -> std::io::Result<i32> { + let Some(target) = parse_integration_target(args, "uninstall")? else { + return Ok(2); + }; + + match crate::integration::uninstall_target(target) { + Ok(messages) => { + print_integration_messages(messages); + Ok(0) + } + Err(err) => { + eprintln!("{err}"); + Ok(1) + } + } +} + +fn print_integration_messages(messages: Vec<String>) { + for message in messages { + println!("{message}"); + } +} + +fn parse_integration_target( + args: &[String], + action: &str, +) -> std::io::Result<Option<IntegrationTarget>> { + let Some(target) = args.first().map(|arg| arg.as_str()) else { + eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>"); + return Ok(None); + }; + if args.len() != 1 { + eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>"); + return Ok(None); + } + + let parsed = match target { + "pi" => IntegrationTarget::Pi, + "claude" => IntegrationTarget::Claude, + "codex" => IntegrationTarget::Codex, + "opencode" => IntegrationTarget::Opencode, + "hermes" => IntegrationTarget::Hermes, + _ => { + eprintln!("unknown integration target: {target}"); + eprintln!("currently supported: pi, claude, codex, opencode, hermes"); + return Ok(None); + } + }; + + Ok(Some(parsed)) +} + +fn print_integration_help() { + eprintln!("herdr integration commands:"); + eprintln!(" herdr integration install pi"); + eprintln!(" herdr integration install claude"); + eprintln!(" herdr integration install codex"); + eprintln!(" herdr integration install opencode"); + eprintln!(" herdr integration install hermes"); + eprintln!(" herdr integration uninstall pi"); + eprintln!(" herdr integration uninstall claude"); + eprintln!(" herdr integration uninstall codex"); + eprintln!(" herdr integration uninstall opencode"); + eprintln!(" herdr integration uninstall hermes"); + eprintln!(" herdr integration status [--outdated-only]"); +} diff --git a/src/cli/pane.rs b/src/cli/pane.rs new file mode 100644 index 00000000..58d65af6 --- /dev/null +++ b/src/cli/pane.rs @@ -0,0 +1,410 @@ +use crate::api::schema::{ + Method, PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, + PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, + ReadFormat, ReadSource, Request, +}; + +pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_pane_help(); + return Ok(2); + }; + + match subcommand { + "list" => pane_list(&args[1..]), + "get" => pane_get(&args[1..]), + "read" => pane_read(&args[1..]), + "rename" => pane_rename(&args[1..]), + "split" => pane_split(&args[1..]), + "close" => pane_close(&args[1..]), + "send-text" => pane_send_text(&args[1..]), + "send-keys" => pane_send_keys(&args[1..]), + "report-agent" => pane_report_agent(&args[1..]), + "run" => pane_run(&args[1..]), + "help" | "--help" | "-h" => { + print_pane_help(); + Ok(0) + } + _ => { + print_pane_help(); + Ok(2) + } + } +} + +fn pane_list(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + super::print_response(&super::send_request(&Request { + id: "cli:pane:list".into(), + method: Method::PaneList(PaneListParams { workspace_id }), + })?) +} + +fn pane_get(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane get <pane_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr pane get <pane_id>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:pane:get".into(), + method: Method::PaneGet(PaneTarget { + pane_id: super::normalize_pane_id(raw_pane_id), + }), + })?) +} + +fn pane_rename(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); + return Ok(2); + }; + if args.len() < 2 { + eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); + return Ok(2); + } + let label = if args.len() == 2 && args[1] == "--clear" { + None + } else { + Some(args[1..].join(" ")) + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:rename".into(), + method: Method::PaneRename(PaneRenameParams { + pane_id: super::normalize_pane_id(raw_pane_id), + label, + }), + })?) +} + +fn pane_read(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); + return Ok(2); + }; + + let pane_id = super::normalize_pane_id(raw_pane_id); + 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 = super::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(super::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 = super::parse_read_format(value)?; + index += 2; + } + "--ansi" => { + format = ReadFormat::Ansi; + index += 1; + } + "--raw" => { + format = ReadFormat::Ansi; + strip_ansi = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let response = super::send_request(&Request { + id: "cli:pane:read".into(), + method: Method::PaneRead(PaneReadParams { + pane_id, + source, + lines, + format, + strip_ansi, + }), + })?; + + if let Some(error) = response.get("error") { + eprintln!("{}", serde_json::to_string(error).unwrap()); + return Ok(1); + } + + if let Some(text) = response["result"]["read"]["text"].as_str() { + print!("{text}"); + } + Ok(0) +} + +fn pane_split(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!( + "usage: herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]" + ); + return Ok(2); + }; + + let pane_id = super::normalize_pane_id(raw_pane_id); + let mut direction = None; + let mut cwd = None; + let mut focus = false; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--direction" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --direction"); + return Ok(2); + }; + direction = Some(super::parse_split_direction(value)?); + index += 2; + } + "--cwd" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(value.clone()); + index += 2; + } + "--focus" => { + focus = true; + index += 1; + } + "--no-focus" => { + focus = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(direction) = direction else { + eprintln!("missing required --direction"); + return Ok(2); + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:split".into(), + method: Method::PaneSplit(PaneSplitParams { + workspace_id: None, + target_pane_id: pane_id, + direction, + cwd, + focus, + }), + })?) +} + +fn pane_close(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane close <pane_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr pane close <pane_id>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:pane:close".into(), + method: Method::PaneClose(PaneTarget { + pane_id: super::normalize_pane_id(raw_pane_id), + }), + })?) +} + +fn pane_send_text(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr pane send-text <pane_id> <text>"); + return Ok(2); + } + + let pane_id = super::normalize_pane_id(&args[0]); + let text = args[1..].join(" "); + super::send_ok_request(Method::PaneSendText(PaneSendTextParams { pane_id, text })) +} + +fn pane_send_keys(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr pane send-keys <pane_id> <key> [key ...]"); + return Ok(2); + } + + let pane_id = super::normalize_pane_id(&args[0]); + let keys = args[1..].to_vec(); + super::send_ok_request(Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys })) +} + +fn pane_run(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr pane run <pane_id> <command>"); + return Ok(2); + } + + let pane_id = super::normalize_pane_id(&args[0]); + let text = args[1..].join(" "); + super::send_ok_request(Method::PaneSendInput(PaneSendInputParams { + pane_id, + text, + keys: vec!["Enter".into()], + })) +} + +fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); + return Ok(2); + }; + + let pane_id = super::normalize_pane_id(raw_pane_id); + let mut source = None; + let mut agent = None; + let mut state = None; + let mut message = None; + let mut custom_status = None; + let mut seq = None; + + 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 = Some(value.clone()); + index += 2; + } + "--agent" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --agent"); + return Ok(2); + }; + agent = Some(value.clone()); + index += 2; + } + "--state" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --state"); + return Ok(2); + }; + state = Some(super::parse_pane_agent_state(value)?); + index += 2; + } + "--message" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --message"); + return Ok(2); + }; + message = Some(value.clone()); + index += 2; + } + "--custom-status" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --custom-status"); + return Ok(2); + }; + custom_status = Some(value.clone()); + index += 2; + } + "--seq" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --seq"); + return Ok(2); + }; + seq = Some(super::parse_u64_flag("--seq", value)?); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(source) = source else { + eprintln!("missing required --source"); + return Ok(2); + }; + let Some(agent) = agent else { + eprintln!("missing required --agent"); + return Ok(2); + }; + let Some(state) = state else { + eprintln!("missing required --state"); + return Ok(2); + }; + + super::send_ok_request(Method::PaneReportAgent(PaneReportAgentParams { + pane_id, + source, + agent, + state, + message, + custom_status, + seq, + })) +} + +fn print_pane_help() { + eprintln!("herdr pane commands:"); + eprintln!(" herdr pane list [--workspace <workspace_id>]"); + eprintln!(" herdr pane get <pane_id>"); + eprintln!(" herdr pane rename <pane_id> <label>|--clear"); + eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); + eprintln!( + " herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]" + ); + eprintln!(" herdr pane close <pane_id>"); + eprintln!(" herdr pane send-text <pane_id> <text>"); + eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); + eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); + eprintln!(" herdr pane run <pane_id> <command>"); +} diff --git a/src/cli/server.rs b/src/cli/server.rs new file mode 100644 index 00000000..311cc31a --- /dev/null +++ b/src/cli/server.rs @@ -0,0 +1,48 @@ +use crate::api::schema::{EmptyParams, Method, Request}; + +pub(super) fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + return Ok(None); + }; + + match subcommand { + "stop" => server_stop(&args[1..]).map(Some), + "reload-config" => server_reload_config(&args[1..]).map(Some), + "help" | "--help" | "-h" => { + print_server_help(); + Ok(Some(0)) + } + _ => { + print_server_help(); + Ok(Some(2)) + } + } +} + +fn server_stop(args: &[String]) -> std::io::Result<i32> { + if !args.is_empty() { + eprintln!("usage: herdr server stop"); + return Ok(2); + } + + super::send_ok_request(Method::ServerStop(EmptyParams::default())) +} + +fn server_reload_config(args: &[String]) -> std::io::Result<i32> { + if !args.is_empty() { + eprintln!("usage: herdr server reload-config"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:server:reload-config".into(), + method: Method::ServerReloadConfig(EmptyParams::default()), + })?) +} + +fn print_server_help() { + eprintln!("herdr server commands:"); + eprintln!(" herdr server run as headless server"); + eprintln!(" herdr server stop stop the running server via the API socket"); + eprintln!(" herdr server reload-config reload config.toml in the running server"); +} diff --git a/src/cli/status.rs b/src/cli/status.rs new file mode 100644 index 00000000..9b8a1648 --- /dev/null +++ b/src/cli/status.rs @@ -0,0 +1,299 @@ +use serde::Serialize; + +use crate::api; +use crate::api::client::{ApiClient, ApiClientError}; + +pub(super) fn run_status_command(args: &[String]) -> std::io::Result<i32> { + let Some((scope, json)) = parse_status_args(args) else { + return Ok(2); + }; + + match scope { + StatusScope::Full => print_full_status(json), + StatusScope::Server => print_server_status(json), + StatusScope::Client => { + print_client_status(json)?; + Ok(0) + } + StatusScope::Help => { + print_status_help(); + Ok(0) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StatusScope { + Full, + Server, + Client, + Help, +} + +fn parse_status_args(args: &[String]) -> Option<(StatusScope, bool)> { + match args.first().map(|arg| arg.as_str()) { + None => Some((StatusScope::Full, false)), + Some("--json") if args.len() == 1 => Some((StatusScope::Full, true)), + Some("server") => { + parse_status_scope_args(args, StatusScope::Server, "herdr status server [--json]") + } + Some("client") => { + parse_status_scope_args(args, StatusScope::Client, "herdr status client [--json]") + } + Some("help" | "--help" | "-h") => { + if args.len() > 1 { + print_status_help(); + return None; + } + Some((StatusScope::Help, false)) + } + Some(_) => { + print_status_help(); + None + } + } +} + +fn parse_status_scope_args( + args: &[String], + scope: StatusScope, + usage: &str, +) -> Option<(StatusScope, bool)> { + match args.get(1).map(|arg| arg.as_str()) { + None => Some((scope, false)), + Some("--json") if args.len() == 2 => Some((scope, true)), + _ => { + eprintln!("usage: {usage}"); + None + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ServerRuntimeStatus { + Running { + version: Option<String>, + protocol: Option<u32>, + }, + NotRunning, +} + +fn print_full_status(json: bool) -> std::io::Result<i32> { + let server = read_server_runtime_status()?; + + if json { + print_json(&FullStatusJson { + client: client_status_json(), + server: server_status_json(&server), + update: update_status_json(&server), + })?; + return Ok(0); + } + + println!("client:"); + println!(" version: {}", env!("CARGO_PKG_VERSION")); + println!(" protocol: {}", crate::protocol::PROTOCOL_VERSION); + println!(); + println!("server:"); + print_server_status_body(&server, " "); + println!(); + println!("update:"); + println!(" restart_needed: {}", restart_needed_label(&server)); + + Ok(0) +} + +fn print_server_status(json: bool) -> std::io::Result<i32> { + let server = read_server_runtime_status()?; + if json { + print_json(&server_status_json(&server))?; + return Ok(0); + } + print_server_status_body(&server, ""); + Ok(0) +} + +fn print_client_status(json: bool) -> std::io::Result<()> { + if json { + print_json(&client_status_json())?; + return Ok(()); + } + + println!("version: {}", env!("CARGO_PKG_VERSION")); + println!("protocol: {}", crate::protocol::PROTOCOL_VERSION); + println!("binary: {}", current_exe_label()); + Ok(()) +} + +fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) { + match server { + ServerRuntimeStatus::Running { version, protocol } => { + println!("{indent}status: running"); + println!("{indent}version: {}", option_label(version.as_deref())); + println!("{indent}protocol: {}", protocol_label(*protocol)); + println!("{indent}compatible: {}", compatibility_label(*protocol)); + println!("{indent}socket: {}", api::socket_path().display()); + } + ServerRuntimeStatus::NotRunning => { + println!("{indent}status: not running"); + println!("{indent}socket: {}", api::socket_path().display()); + } + } +} + +fn read_server_runtime_status() -> std::io::Result<ServerRuntimeStatus> { + match ApiClient::local().status() { + Ok(status) => Ok(ServerRuntimeStatus::Running { + version: status.version, + protocol: status.protocol, + }), + Err(ApiClientError::Io(err)) if server_not_running_error(&err) => { + Ok(ServerRuntimeStatus::NotRunning) + } + Err(err) => Err(api_client_error_to_io(err)), + } +} + +fn server_not_running_error(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) +} + +fn api_client_error_to_io(err: ApiClientError) -> std::io::Error { + match err { + ApiClientError::Io(err) => err, + err => std::io::Error::other(err), + } +} + +fn option_label(value: Option<&str>) -> &str { + value.unwrap_or("unknown") +} + +fn protocol_label(protocol: Option<u32>) -> String { + protocol + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn compatibility_label(protocol: Option<u32>) -> &'static str { + match protocol { + Some(protocol) if protocol == crate::protocol::PROTOCOL_VERSION => "yes", + Some(_) => "no", + None => "unknown", + } +} + +fn restart_needed_label(server: &ServerRuntimeStatus) -> &'static str { + match server { + ServerRuntimeStatus::Running { version, .. } => match version.as_deref() { + Some(env!("CARGO_PKG_VERSION")) => "no", + Some(_) => "yes", + None => "unknown", + }, + ServerRuntimeStatus::NotRunning => "no", + } +} + +#[derive(Serialize)] +struct FullStatusJson { + client: ClientStatusJson, + server: ServerStatusJson, + update: UpdateStatusJson, +} + +#[derive(Serialize)] +struct ClientStatusJson { + version: &'static str, + protocol: u32, + binary: String, + session: Option<String>, +} + +#[derive(Serialize)] +struct ServerStatusJson { + status: &'static str, + running: bool, + version: Option<String>, + protocol: Option<u32>, + compatible: Option<bool>, + socket: String, + session: Option<String>, + restart_needed: Option<bool>, +} + +#[derive(Serialize)] +struct UpdateStatusJson { + restart_needed: Option<bool>, +} + +fn client_status_json() -> ClientStatusJson { + ClientStatusJson { + version: env!("CARGO_PKG_VERSION"), + protocol: crate::protocol::PROTOCOL_VERSION, + binary: current_exe_label(), + session: crate::session::active_name(), + } +} + +fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson { + match server { + ServerRuntimeStatus::Running { version, protocol } => ServerStatusJson { + status: "running", + running: true, + version: version.clone(), + protocol: *protocol, + compatible: protocol.map(|value| value == crate::protocol::PROTOCOL_VERSION), + socket: api::socket_path().display().to_string(), + session: crate::session::active_name(), + restart_needed: restart_needed_bool(server), + }, + ServerRuntimeStatus::NotRunning => ServerStatusJson { + status: "not_running", + running: false, + version: None, + protocol: None, + compatible: None, + socket: api::socket_path().display().to_string(), + session: crate::session::active_name(), + restart_needed: Some(false), + }, + } +} + +fn update_status_json(server: &ServerRuntimeStatus) -> UpdateStatusJson { + UpdateStatusJson { + restart_needed: restart_needed_bool(server), + } +} + +fn restart_needed_bool(server: &ServerRuntimeStatus) -> Option<bool> { + match server { + ServerRuntimeStatus::Running { version, .. } => match version.as_deref() { + Some(env!("CARGO_PKG_VERSION")) => Some(false), + Some(_) => Some(true), + None => None, + }, + ServerRuntimeStatus::NotRunning => Some(false), + } +} + +fn print_json(value: &impl Serialize) -> std::io::Result<()> { + println!("{}", serde_json::to_string(value)?); + Ok(()) +} + +fn current_exe_label() -> String { + std::env::current_exe() + .map(|path| path.display().to_string()) + .unwrap_or_else(|err| format!("unknown ({err})")) +} + +fn print_status_help() { + eprintln!("herdr status commands:"); + eprintln!(" herdr status [--json] show local client and running server status"); + eprintln!(" herdr status server [--json] show running server status"); + eprintln!(" herdr status client [--json] show local client binary status"); +} diff --git a/src/cli/tab.rs b/src/cli/tab.rs new file mode 100644 index 00000000..3fcc0647 --- /dev/null +++ b/src/cli/tab.rs @@ -0,0 +1,194 @@ +use crate::api::schema::{ + Method, Request, TabCreateParams, TabListParams, TabRenameParams, TabTarget, +}; + +pub(super) fn run_tab_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_tab_help(); + return Ok(2); + }; + + match subcommand { + "list" => tab_list(&args[1..]), + "create" => tab_create(&args[1..]), + "get" => tab_get(&args[1..]), + "focus" => tab_focus(&args[1..]), + "rename" => tab_rename(&args[1..]), + "close" => tab_close(&args[1..]), + "help" | "--help" | "-h" => { + print_tab_help(); + Ok(0) + } + _ => { + print_tab_help(); + Ok(2) + } + } +} + +fn tab_list(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + super::print_response(&super::send_request(&Request { + id: "cli:tab:list".into(), + method: Method::TabList(TabListParams { workspace_id }), + })?) +} + +fn tab_create(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + let mut cwd = None; + let mut focus = false; + let mut label = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + "--cwd" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(value.clone()); + index += 2; + } + "--label" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --label"); + return Ok(2); + }; + label = Some(value.clone()); + index += 2; + } + "--focus" => { + focus = true; + index += 1; + } + "--no-focus" => { + focus = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + super::print_response(&super::send_request(&Request { + id: "cli:tab:create".into(), + method: Method::TabCreate(TabCreateParams { + workspace_id, + cwd, + focus, + label, + }), + })?) +} + +fn tab_get(args: &[String]) -> std::io::Result<i32> { + let Some(raw_tab_id) = args.first() else { + eprintln!("usage: herdr tab get <tab_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr tab get <tab_id>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:tab:get".into(), + method: Method::TabGet(TabTarget { + tab_id: super::normalize_tab_id(raw_tab_id), + }), + })?) +} + +fn tab_focus(args: &[String]) -> std::io::Result<i32> { + let Some(raw_tab_id) = args.first() else { + eprintln!("usage: herdr tab focus <tab_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr tab focus <tab_id>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:tab:focus".into(), + method: Method::TabFocus(TabTarget { + tab_id: super::normalize_tab_id(raw_tab_id), + }), + })?) +} + +fn tab_rename(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr tab rename <tab_id> <label>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:tab:rename".into(), + method: Method::TabRename(TabRenameParams { + tab_id: super::normalize_tab_id(&args[0]), + label: args[1..].join(" "), + }), + })?) +} + +fn tab_close(args: &[String]) -> std::io::Result<i32> { + let Some(raw_tab_id) = args.first() else { + eprintln!("usage: herdr tab close <tab_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr tab close <tab_id>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:tab:close".into(), + method: Method::TabClose(TabTarget { + tab_id: super::normalize_tab_id(raw_tab_id), + }), + })?) +} + +fn print_tab_help() { + eprintln!("herdr tab commands:"); + eprintln!(" herdr tab list [--workspace <workspace_id>]"); + eprintln!( + " herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--focus] [--no-focus]" + ); + eprintln!(" herdr tab get <tab_id>"); + eprintln!(" herdr tab focus <tab_id>"); + eprintln!(" herdr tab rename <tab_id> <label>"); + eprintln!(" herdr tab close <tab_id>"); +} diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs new file mode 100644 index 00000000..ae29792c --- /dev/null +++ b/src/cli/workspace.rs @@ -0,0 +1,163 @@ +use crate::api::schema::{ + EmptyParams, Method, Request, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, +}; + +pub(super) fn run_workspace_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_workspace_help(); + return Ok(2); + }; + + match subcommand { + "list" => workspace_list(&args[1..]), + "create" => workspace_create(&args[1..]), + "get" => workspace_get(&args[1..]), + "focus" => workspace_focus(&args[1..]), + "rename" => workspace_rename(&args[1..]), + "close" => workspace_close(&args[1..]), + "help" | "--help" | "-h" => { + print_workspace_help(); + Ok(0) + } + _ => { + print_workspace_help(); + Ok(2) + } + } +} + +fn workspace_list(args: &[String]) -> std::io::Result<i32> { + if !args.is_empty() { + eprintln!("usage: herdr workspace list"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:workspace:list".into(), + method: Method::WorkspaceList(EmptyParams::default()), + })?) +} + +fn workspace_create(args: &[String]) -> std::io::Result<i32> { + let mut cwd = None; + let mut focus = false; + let mut label = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--cwd" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(value.clone()); + index += 2; + } + "--label" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --label"); + return Ok(2); + }; + label = Some(value.clone()); + index += 2; + } + "--focus" => { + focus = true; + index += 1; + } + "--no-focus" => { + focus = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + super::print_response(&super::send_request(&Request { + id: "cli:workspace:create".into(), + method: Method::WorkspaceCreate(WorkspaceCreateParams { cwd, focus, label }), + })?) +} + +fn workspace_get(args: &[String]) -> std::io::Result<i32> { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace get <workspace_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr workspace get <workspace_id>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:workspace:get".into(), + method: Method::WorkspaceGet(WorkspaceTarget { + workspace_id: super::normalize_workspace_id(raw_workspace_id), + }), + })?) +} + +fn workspace_focus(args: &[String]) -> std::io::Result<i32> { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace focus <workspace_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr workspace focus <workspace_id>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:workspace:focus".into(), + method: Method::WorkspaceFocus(WorkspaceTarget { + workspace_id: super::normalize_workspace_id(raw_workspace_id), + }), + })?) +} + +fn workspace_rename(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr workspace rename <workspace_id> <label>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:workspace:rename".into(), + method: Method::WorkspaceRename(WorkspaceRenameParams { + workspace_id: super::normalize_workspace_id(&args[0]), + label: args[1..].join(" "), + }), + })?) +} + +fn workspace_close(args: &[String]) -> std::io::Result<i32> { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace close <workspace_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr workspace close <workspace_id>"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:workspace:close".into(), + method: Method::WorkspaceClose(WorkspaceTarget { + workspace_id: super::normalize_workspace_id(raw_workspace_id), + }), + })?) +} + +fn print_workspace_help() { + eprintln!("herdr workspace commands:"); + eprintln!(" herdr workspace list"); + eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--focus] [--no-focus]"); + eprintln!(" herdr workspace get <workspace_id>"); + eprintln!(" herdr workspace focus <workspace_id>"); + eprintln!(" herdr workspace rename <workspace_id> <label>"); + eprintln!(" herdr workspace close <workspace_id>"); +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 4d962b64..11273061 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -12,7 +12,6 @@ //! - Forwards OSC 52 clipboard writes from server to its own stdout //! - Displays sound/toast notifications forwarded from server -pub(crate) mod blit; mod input; use std::collections::HashSet; @@ -30,11 +29,12 @@ use crossterm::event::{ use crossterm::execute; use tracing::{debug, info, warn}; -use crate::server::headless::client_socket_path; -use crate::server::protocol::{ +use crate::protocol::render_ansi; +use crate::protocol::{ self, ClientKeybindings, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; +use crate::server::socket_paths::client_socket_path; static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); @@ -45,7 +45,7 @@ static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::ne /// State tracking for the thin client. struct ClientState { /// Stateful semantic-frame encoder used when the server sends FrameData. - blit_encoder: blit::BlitEncoder, + blit_encoder: render_ansi::BlitEncoder, /// Whether host mouse capture is currently active. mouse_capture_active: bool, /// The terminal size we reported to the server in our last Hello/Resize. @@ -106,7 +106,7 @@ impl AttachEscapeState { impl ClientState { fn request_full_redraw(&mut self) { - self.blit_encoder = blit::BlitEncoder::new(); + self.blit_encoder = render_ansi::BlitEncoder::new(); } } @@ -574,7 +574,7 @@ async fn run_client_loop( attach_escape: Option<AttachEscapeState>, ) -> Result<(), ClientError> { let mut state = ClientState { - blit_encoder: blit::BlitEncoder::new(), + blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active, reported_size: (cols, rows), sound_config, diff --git a/src/input/encode.rs b/src/input/encode.rs index ea7949aa..2778a78b 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -6,7 +6,7 @@ const KITTY_FLAG_REPORT_EVENT_TYPES: u16 = 0b0000_0010; const KITTY_FLAG_REPORT_ALTERNATE_KEYS: u16 = 0b0000_0100; /// Encode a key event for a PTY child using the pane's negotiated keyboard protocol. -#[allow(dead_code)] // exercised in input unit tests; production uses PaneRuntime helpers +#[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> { encode_terminal_key(key.into(), protocol) } @@ -24,7 +24,7 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec< encode_legacy(key.as_key_event()) } -#[allow(dead_code)] // exercised in input unit tests; production uses PaneRuntime helpers +#[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers pub fn encode_cursor_key(code: KeyCode, application_cursor: bool) -> Vec<u8> { match (code, application_cursor) { (KeyCode::Up, true) => b"\x1bOA".to_vec(), diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index 857bd241..90cf4006 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -13,6 +13,7 @@ use crate::app::state::AppState; use crate::app::Mode; use crate::ghostty::{KittyImageDescriptor, KittyImageFormat, KittyImagePlacement}; use crate::layout::PaneId; +use crate::terminal::TerminalRuntimeRegistry; const KITTY_CHUNK_BYTES: usize = 3072; const HOST_IMAGE_ID_BASE: u32 = 10_000; @@ -133,11 +134,15 @@ pub(crate) fn is_enabled() -> bool { KITTY_GRAPHICS_ENABLED.load(Ordering::Acquire) } -pub(crate) fn paint_local_pane_graphics(app: &AppState, cell_size: HostCellSize) -> io::Result<()> { +pub(crate) fn paint_local_pane_graphics( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + cell_size: HostCellSize, +) -> io::Result<()> { let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default())); let mut bytes = Vec::new(); if let Ok(mut cache) = cache.lock() { - bytes = encode_local_pane_graphics(app, cell_size, &mut cache); + bytes = encode_local_pane_graphics(app, terminal_runtimes, cell_size, &mut cache); } if bytes.is_empty() { return Ok(()); @@ -155,6 +160,7 @@ pub(crate) fn paint_local_pane_graphics(app: &AppState, cell_size: HostCellSize) pub(crate) fn encode_local_pane_graphics( app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, cell_size: HostCellSize, cache: &mut HostGraphicsCache, ) -> Vec<u8> { @@ -183,7 +189,8 @@ pub(crate) fn encode_local_pane_graphics( let view_key = active_view_key(app); let uploaded_images = cache.images.clone(); - let placements = collect_visible_placements(app, cell_size, &uploaded_images); + let placements = + collect_visible_placements(app, terminal_runtimes, cell_size, &uploaded_images); tracing::debug!( placements_collected = placements.len(), "collect_visible_placements result" @@ -352,6 +359,7 @@ fn active_view_key(app: &AppState) -> Option<HostViewKey> { fn collect_visible_placements( app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, cell_size: HostCellSize, uploaded_images: &HashMap<u32, ImageSignature>, ) -> Vec<HostPlacement> { @@ -374,13 +382,13 @@ fn collect_visible_placements( tracing::debug!( ws_idx, - terminal_runtimes_len = app.terminal_runtimes.len(), + terminal_runtimes_len = terminal_runtimes.len(), pane_infos_len = app.view.pane_infos.len(), "collect_visible_placements: starting iteration" ); let mut placements = Vec::new(); for info in &app.view.pane_infos { - let runtime = match app.runtime_for_pane_in_workspace(ws_idx, info.id) { + let runtime = match app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { Some(rt) => rt, None => { tracing::debug!(pane_id = ?info.id, "collect_visible_placements: runtime not found"); diff --git a/src/main.rs b/src/main.rs index c8a2c26a..5eb48d9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ mod pane; mod persist; mod platform; mod product_announcements; +mod protocol; mod raw_input; mod release_notes; mod remote; diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 96b6074a..a3811c36 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -5,6 +5,7 @@ use ratatui::layout::Direction; use serde::{Deserialize, Serialize}; use crate::layout::Node; +use crate::terminal::TerminalRuntimeRegistry; use crate::workspace::Workspace; /// Current snapshot format version. @@ -201,10 +202,7 @@ pub fn capture( crate::terminal::TerminalId, crate::terminal::TerminalState, >, - terminal_runtimes: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalRuntime, - >, + terminal_runtimes: &TerminalRuntimeRegistry, active: Option<usize>, selected: usize, agent_panel_scope: crate::app::state::AgentPanelScope, @@ -231,10 +229,7 @@ fn capture_workspace( crate::terminal::TerminalId, crate::terminal::TerminalState, >, - terminal_runtimes: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalRuntime, - >, + terminal_runtimes: &TerminalRuntimeRegistry, ) -> WorkspaceSnapshot { WorkspaceSnapshot { id: Some(ws.id.clone()), @@ -257,10 +252,7 @@ fn capture_tab( crate::terminal::TerminalId, crate::terminal::TerminalState, >, - terminal_runtimes: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalRuntime, - >, + terminal_runtimes: &TerminalRuntimeRegistry, ) -> TabSnapshot { let mut panes = HashMap::new(); for id in tab.panes.keys() { @@ -373,10 +365,11 @@ mod tests { } fn capture_from_state(state: &AppState) -> SessionSnapshot { + let terminal_runtimes = TerminalRuntimeRegistry::new(); capture( &state.workspaces, &state.terminals, - &state.terminal_runtimes, + &terminal_runtimes, state.active, state.selected, state.agent_panel_scope, diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 8e5ad7a6..269e3ad8 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -232,7 +232,7 @@ fn read_clipboard_image_with_command(program: &str, args: &[&str]) -> Option<Vec fn read_clipboard_image_with_spawned_command(command: Command) -> Option<Vec<u8>> { read_clipboard_image_with_spawned_command_max( command, - crate::server::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD, + crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD, ) } diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 7bec8e26..444f458d 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -231,7 +231,7 @@ pub fn read_clipboard_image() -> Option<ClipboardImage> { } let bytes = match std::fs::File::open(&path).ok().and_then(|file| { - read_limited_reader(file, crate::server::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD).ok() + read_limited_reader(file, crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD).ok() }) { Some(LimitedRead::Complete(bytes)) => bytes, Some(LimitedRead::Empty | LimitedRead::Oversized) | None => { diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 00000000..0cdeecbb --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,6 @@ +//! Shared wire protocol and presentation encoding code. + +pub(crate) mod render_ansi; +mod wire; + +pub use wire::*; diff --git a/src/client/blit.rs b/src/protocol/render_ansi.rs index 2196e1ff..8caa98d0 100644 --- a/src/client/blit.rs +++ b/src/protocol/render_ansi.rs @@ -29,7 +29,7 @@ use std::io::Write; use unicode_width::UnicodeWidthStr; -use crate::server::protocol::{CellData, FrameData}; +use crate::protocol::{CellData, FrameData}; /// Bytes produced by a [`BlitEncoder`] for one terminal frame. pub(crate) struct EncodedBlit { @@ -612,7 +612,7 @@ fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameD #[cfg(test)] mod tests { use super::*; - use crate::server::protocol::{CellData, CursorState}; + use crate::protocol::{CellData, CursorState}; const WIDE_GRAPHEME: &str = "💡"; diff --git a/src/server/protocol.rs b/src/protocol/wire.rs index 33a5f513..33a5f513 100644 --- a/src/server/protocol.rs +++ b/src/protocol/wire.rs diff --git a/src/remote.rs b/src/remote.rs index cfe2d49e..237f06de 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -20,7 +20,7 @@ const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); -const CURRENT_PROTOCOL: u32 = crate::server::protocol::PROTOCOL_VERSION; +const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION; const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; @@ -165,7 +165,7 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { pub(crate) fn run_remote_client_bridge() -> io::Result<()> { ensure_remote_server_running()?; - let socket_path = crate::server::headless::client_socket_path(); + let socket_path = crate::server::socket_paths::client_socket_path(); let stream = UnixStream::connect(&socket_path).map_err(|err| { io::Error::new( err.kind(), @@ -190,7 +190,7 @@ pub(crate) fn run_remote_client_bridge() -> io::Result<()> { } fn ensure_remote_server_running() -> io::Result<()> { - let socket_path = crate::server::headless::client_socket_path(); + let socket_path = crate::server::socket_paths::client_socket_path(); if crate::server::autodetect::is_server_listening() { let status = crate::api::read_runtime_status_at( &crate::api::socket_path(), @@ -398,7 +398,7 @@ fn remote_path_probe_command() -> &'static str { r#"path=$(command -v herdr) || exit 1 test -n "$path" || exit 1 version=$("$path" --version) || exit 1 -status=$("$path" status client) || exit 1 +status=$("$path" status client --json) || exit 1 printf '%s\n%s\n%s\n' "$path" "$version" "$status" "# } @@ -407,8 +407,8 @@ fn remote_herdr_from_path_probe(remote_herdr: &RemoteHerdr, stdout: &str) -> Opt let mut lines = stdout.lines(); let path = lines.next()?; let version = lines.next()?.trim(); - let status = lines.collect::<Vec<_>>().join("\n"); - let protocol = parse_status_protocol(&status)?; + let status = lines.next()?; + let protocol = parse_client_status_json(status)?.protocol; if !path.starts_with('/') || version != format!("herdr {CURRENT_VERSION}") || protocol != CURRENT_PROTOCOL @@ -421,7 +421,7 @@ fn remote_herdr_from_path_probe(remote_herdr: &RemoteHerdr, stdout: &str) -> Opt fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> { let command = format!( - "test -x {0} && {0} --version && {0} status client", + "test -x {0} && {0} --version && {0} status client --json", remote_herdr.shell_path ); let output = ssh_output(target, &command)?; @@ -432,9 +432,11 @@ fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result let stdout = String::from_utf8_lossy(&output.stdout); let mut lines = stdout.lines(); let version = lines.next().unwrap_or_default().trim(); - let status = lines.collect::<Vec<_>>().join("\n"); + let status = lines.next().unwrap_or_default(); Ok(version == format!("herdr {CURRENT_VERSION}") - && parse_status_protocol(&status) == Some(CURRENT_PROTOCOL)) + && parse_client_status_json(status) + .map(|status| status.protocol == CURRENT_PROTOCOL) + .unwrap_or(false)) } fn remote_binary_override_path() -> io::Result<Option<PathBuf>> { @@ -561,47 +563,46 @@ fn remote_server_status( target: &str, remote_herdr: &RemoteHerdr, ) -> io::Result<RemoteServerStatus> { - let command = format!("{} status server", remote_herdr.shell_path); + let command = format!("{} status server --json", remote_herdr.shell_path); let output = ssh_output(target, &command)?; if !output.status.success() { return Err(command_failed("remote server status failed", &output)); } let stdout = String::from_utf8_lossy(&output.stdout); - if stdout - .lines() - .any(|line| line.trim() == "status: not running") - { - return Ok(RemoteServerStatus::NotRunning); - } - - if stdout.lines().any(|line| line.trim() == "status: running") { - return Ok(RemoteServerStatus::Running { - version: parse_status_version(&stdout).map(str::to_owned), - protocol: parse_status_protocol(&stdout), - }); - } + parse_remote_server_status_json(stdout.trim()) +} - Err(io::Error::other(format!( - "could not parse remote server status from `{}`", - stdout.trim() - ))) +#[derive(Debug, Deserialize)] +struct RemoteClientStatusJson { + protocol: u32, } -fn parse_status_value<'a>(status: &'a str, key: &str) -> Option<&'a str> { - status.lines().find_map(|line| { - let line = line.trim(); - let (found_key, value) = line.split_once(':')?; - (found_key == key).then_some(value.trim()) - }) +#[derive(Debug, Deserialize)] +struct RemoteServerStatusJson { + running: bool, + version: Option<String>, + protocol: Option<u32>, } -fn parse_status_version(status: &str) -> Option<&str> { - parse_status_value(status, "version") +fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> { + serde_json::from_str(status).ok() } -fn parse_status_protocol(status: &str) -> Option<u32> { - parse_status_value(status, "protocol")?.parse().ok() +fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatus> { + let parsed: RemoteServerStatusJson = serde_json::from_str(status).map_err(|err| { + io::Error::other(format!( + "could not parse remote server status JSON from `{status}`: {err}" + )) + })?; + if !parsed.running { + return Ok(RemoteServerStatus::NotRunning); + } + + Ok(RemoteServerStatus::Running { + version: parsed.version, + protocol: parsed.protocol, + }) } fn confirm_remote_server_stop( @@ -1095,7 +1096,7 @@ fn run_client_process( let status = Command::new(exe) .arg("client") .env( - crate::server::headless::CLIENT_SOCKET_PATH_ENV_VAR, + crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, local_socket, ) .env("HERDR_RENDER_ENCODING", "terminal-ansi") @@ -1452,7 +1453,7 @@ mod tests { }); let remote_herdr = remote_herdr_from_path_probe( &remote_herdr, - &format!("/usr/bin/herdr\nherdr 0.0.0\nprotocol: {CURRENT_PROTOCOL}\n"), + &format!("/usr/bin/herdr\nherdr 0.0.0\n{{\"protocol\":{CURRENT_PROTOCOL}}}\n"), ); assert!(remote_herdr.is_none()); @@ -1476,32 +1477,45 @@ mod tests { os: "linux", arch: "x86_64", }); - let stdout = format!("/usr/bin/herdr\nherdr {CURRENT_VERSION}\nprotocol: 0\n"); + let stdout = format!("/usr/bin/herdr\nherdr {CURRENT_VERSION}\n{{\"protocol\":0}}\n"); let remote_herdr = remote_herdr_from_path_probe(&remote_herdr, &stdout); assert!(remote_herdr.is_none()); } #[test] - fn parse_status_protocol_reads_protocol_line() { + fn parse_client_status_json_reads_protocol() { assert_eq!( - parse_status_protocol("version: x\nprotocol: 8\nbinary: y"), + parse_client_status_json(r#"{"version":"x","protocol":8,"binary":"/bin/herdr"}"#) + .map(|status| status.protocol), Some(8) ); + assert!(parse_client_status_json(r#"{"protocol":"unknown"}"#).is_none()); + } + + #[test] + fn parse_remote_server_status_json_reads_running_server() { assert_eq!( - parse_status_protocol("status: running\nprotocol: unknown"), - None + parse_remote_server_status_json( + r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"# + ) + .unwrap(), + RemoteServerStatus::Running { + version: Some("0.6.0".into()), + protocol: Some(8) + } ); - assert_eq!(parse_status_protocol("status: not running"), None); } #[test] - fn parse_status_version_reads_version_line() { + fn parse_remote_server_status_json_reads_stopped_server() { assert_eq!( - parse_status_version("status: running\nversion: 0.6.0\nprotocol: 8"), - Some("0.6.0") + parse_remote_server_status_json( + r#"{"status":"not_running","running":false,"version":null,"protocol":null}"# + ) + .unwrap(), + RemoteServerStatus::NotRunning ); - assert_eq!(parse_status_version("status: not running"), None); } #[test] @@ -1565,7 +1579,7 @@ mod tests { } fn matching_path_probe_stdout(path: &str) -> String { - format!("{path}\nherdr {CURRENT_VERSION}\nprotocol: {CURRENT_PROTOCOL}\n") + format!("{path}\nherdr {CURRENT_VERSION}\n{{\"protocol\":{CURRENT_PROTOCOL}}}\n") } fn remote_env_lock() -> &'static std::sync::Mutex<()> { diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index fd48de9b..976d2583 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -18,7 +18,7 @@ use std::time::Duration; use tracing::{info, warn}; -use super::headless::client_socket_path; +use super::socket_paths::client_socket_path; /// Maximum time to wait for the server's client socket to become ready /// after spawning the server process. @@ -91,7 +91,7 @@ fn validate_running_server_compatibility() -> io::Result<()> { )); }; - if status.protocol == Some(crate::server::protocol::PROTOCOL_VERSION) { + if status.protocol == Some(crate::protocol::PROTOCOL_VERSION) { return Ok(()); } @@ -103,7 +103,7 @@ fn validate_running_server_compatibility() -> io::Result<()> { .map(|value| value.to_string()) .unwrap_or_else(|| "unknown".to_string()), env!("CARGO_PKG_VERSION"), - crate::server::protocol::PROTOCOL_VERSION + crate::protocol::PROTOCOL_VERSION ))) } diff --git a/src/server/client_accept.rs b/src/server/client_accept.rs new file mode 100644 index 00000000..61db2eb9 --- /dev/null +++ b/src/server/client_accept.rs @@ -0,0 +1,50 @@ +use std::io; +use std::os::unix::net::UnixListener; +use std::sync::{atomic::AtomicBool, Arc}; + +use tokio::sync::mpsc; +use tracing::{debug, error, warn}; + +use crate::server::client_transport::{self, ServerEvent}; + +/// Accepts pending thin-client connections and starts their handshake readers. +pub(crate) fn accept_pending_client_connections( + listener: &UnixListener, + next_client_id: &mut u64, + should_quit: &Arc<AtomicBool>, + server_event_tx: &mpsc::Sender<ServerEvent>, +) -> io::Result<()> { + loop { + match listener.accept() { + Ok((stream, _addr)) => { + let client_id = *next_client_id; + *next_client_id = next_client_id.saturating_add(1); + + if let Err(err) = stream.set_nonblocking(true) { + warn!(err = %err, "failed to set client stream nonblocking"); + continue; + } + + let should_quit = should_quit.clone(); + let server_event_tx = server_event_tx.clone(); + std::thread::spawn(move || { + if let Err(err) = client_transport::handle_client_handshake( + stream, + client_id, + &server_event_tx, + &should_quit, + ) { + debug!(client_id, err = %err, "client handshake failed"); + } + }); + } + Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => break, + Err(err) => { + error!(err = %err, "client listener accept failed"); + break; + } + } + } + + Ok(()) +} diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 0abea899..5a5984aa 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -13,7 +13,7 @@ use std::time::Duration; use tokio::sync::mpsc; use tracing::{debug, warn}; -use crate::server::protocol::{ +use crate::protocol::{ self, ClientKeybindings, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; diff --git a/src/server/clients.rs b/src/server/clients.rs new file mode 100644 index 00000000..4ee1ff09 --- /dev/null +++ b/src/server/clients.rs @@ -0,0 +1,208 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::protocol::RenderEncoding; +use crate::server::client_transport::ClientWriter; +use crate::server::render_stream::ClientRenderState; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ClientConnectionMode { + App, + TerminalAttach { terminal_id: String }, +} + +pub(crate) type RenderTarget = ( + u64, + (u16, u16), + crate::kitty_graphics::HostCellSize, + bool, + ClientConnectionMode, +); + +/// A connected client tracked by the server. +pub(crate) struct ClientConnection { + /// Whether this connection is the full app client or a direct terminal attach. + pub(crate) mode: ClientConnectionMode, + /// Client-local app keybindings. None means use the server's keybindings. + pub(crate) keybindings: Option<Box<crate::config::LiveKeybindConfig>>, + /// The client's terminal size after clamping. + pub(crate) terminal_size: (u16, u16), + /// Pixel size of one client terminal cell. + pub(crate) cell_size: crate::kitty_graphics::HostCellSize, + /// Last known host terminal default colors for this client. + pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, + /// Last reported focus state for this client's outer terminal. + pub(crate) outer_terminal_focus: Option<bool>, + /// Monotonic activity stamp used to choose the fallback foreground client. + pub(crate) last_activity: u64, + /// Render baseline for the negotiated client encoding. + pub(crate) render_state: ClientRenderState, + /// Client-local host Kitty graphics cache. + pub(crate) graphics_cache: crate::kitty_graphics::HostGraphicsCache, + /// Whether the next graphics frame must clear and rebuild host-side Kitty state. + pub(crate) graphics_surface_reset_pending: bool, + /// Whether a render was skipped because the render channel was full. + pub(crate) render_pending: bool, + /// Last host mouse capture mode sent to this client. + pub(crate) host_mouse_capture_active: Option<bool>, + /// Temporary files staged from this client's local clipboard image pastes. + pub(crate) staged_clipboard_files: Vec<PathBuf>, + /// Channels for sending framed ServerMessage data to the client writer thread. + pub(crate) writer: Option<ClientWriter>, +} + +impl ClientConnection { + #[cfg(test)] + pub(crate) fn new( + terminal_size: (u16, u16), + cell_size: crate::kitty_graphics::HostCellSize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + outer_terminal_focus: Option<bool>, + last_activity: u64, + render_encoding: RenderEncoding, + writer: Option<ClientWriter>, + ) -> Self { + Self::new_with_mode( + ClientConnectionMode::App, + None, + terminal_size, + cell_size, + host_terminal_theme, + outer_terminal_focus, + last_activity, + render_encoding, + writer, + ) + } + + pub(crate) fn new_with_mode( + mode: ClientConnectionMode, + keybindings: Option<Box<crate::config::LiveKeybindConfig>>, + terminal_size: (u16, u16), + cell_size: crate::kitty_graphics::HostCellSize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + outer_terminal_focus: Option<bool>, + last_activity: u64, + render_encoding: RenderEncoding, + writer: Option<ClientWriter>, + ) -> Self { + Self { + mode, + keybindings, + terminal_size, + cell_size, + host_terminal_theme, + outer_terminal_focus, + last_activity, + render_state: ClientRenderState::new(render_encoding), + graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), + graphics_surface_reset_pending: false, + render_pending: false, + host_mouse_capture_active: None, + staged_clipboard_files: Vec::new(), + writer, + } + } + + pub(crate) fn request_full_redraw(&mut self) { + self.render_state.reset_baseline(); + self.graphics_surface_reset_pending = true; + } + + pub(crate) fn request_semantic_redraw_after_input(&mut self) { + self.render_state.reset_semantic_input_baseline(); + } + + pub(crate) fn update_host_theme_from_events( + &mut self, + events: &[crate::raw_input::RawInputEvent], + ) -> bool { + let mut next_theme = self.host_terminal_theme; + for event in events { + if let crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } = event { + next_theme = next_theme.with_color(*kind, *color); + } + } + + if next_theme == self.host_terminal_theme { + return false; + } + + self.host_terminal_theme = next_theme; + true + } + + pub(crate) fn update_outer_focus_from_events( + &mut self, + events: &[crate::raw_input::RawInputEvent], + ) -> Option<bool> { + let next_focus = events + .iter() + .filter_map(|event| match event { + crate::raw_input::RawInputEvent::OuterFocusGained => Some(true), + crate::raw_input::RawInputEvent::OuterFocusLost => Some(false), + _ => None, + }) + .next_back()?; + + self.outer_terminal_focus = Some(next_focus); + Some(next_focus) + } +} + +pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool { + events.iter().any(|event| { + matches!( + event, + crate::raw_input::RawInputEvent::Key(_) + | crate::raw_input::RawInputEvent::Mouse(_) + | crate::raw_input::RawInputEvent::Paste(_) + | crate::raw_input::RawInputEvent::OuterFocusGained + ) + }) +} + +pub(crate) fn latest_app_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> { + clients + .iter() + .filter(|(_, client)| matches!(client.mode, ClientConnectionMode::App)) + .max_by_key(|(_, client)| client.last_activity) + .map(|(&client_id, _)| client_id) +} + +pub(crate) fn terminal_attach_client_ids( + clients: &HashMap<u64, ClientConnection>, + terminal_id: &str, +) -> Vec<u64> { + clients + .iter() + .filter_map(|(&client_id, client)| match &client.mode { + ClientConnectionMode::TerminalAttach { + terminal_id: attached, + } if attached == terminal_id => Some(client_id), + _ => None, + }) + .collect() +} + +pub(crate) fn render_targets( + clients: &HashMap<u64, ClientConnection>, + foreground_client_id: Option<u64>, +) -> Vec<RenderTarget> { + let mut targets: Vec<RenderTarget> = clients + .iter() + .filter(|(_, client)| client.writer.is_some()) + .map(|(&client_id, client)| { + ( + client_id, + client.terminal_size, + client.cell_size, + foreground_client_id == Some(client_id), + client.mode.clone(), + ) + }) + .collect(); + + targets.sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id)); + targets +} diff --git a/src/server/headless.rs b/src/server/headless.rs index ddfd631c..842ded80 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -25,23 +25,35 @@ use std::time::{Duration, Instant}; use ratatui::layout::Rect; use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; use base64::Engine; use bytes::Bytes; use crate::api; use crate::app; -use crate::app::state::AppState; use crate::config; -use crate::detect::AgentState; use crate::events::AppEvent; -use crate::layout::PaneId; -use crate::server::client_transport::{self, ClientWriter, ServerEvent}; -use crate::server::protocol::{ - self, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, +use crate::protocol::{self, FrameData, ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE}; +use crate::server::client_accept::accept_pending_client_connections; +use crate::server::client_transport::ServerEvent; +use crate::server::clients::{ + events_include_interaction, latest_app_client, render_targets, terminal_attach_client_ids, + ClientConnection, ClientConnectionMode, }; -use crate::server::render_stream::ClientRenderState; +use crate::server::keybindings::{app_keybindings, apply_keybindings}; +use crate::server::notifications::{ + should_forward_toast_to_clients, toast_message_from_state_change, toast_notify_kind, +}; +use crate::server::socket_paths::{ + client_socket_path, prepare_socket_path, restrict_socket_permissions, +}; +use crate::server::terminal_attach::paste_payload_for_runtime; + +#[cfg(test)] +use crate::protocol::RenderEncoding; +#[cfg(test)] +use crate::server::client_transport::ClientWriter; // --------------------------------------------------------------------------- // Loop event enum for the headless server event loop @@ -64,28 +76,6 @@ enum LoopEvent { const MIN_COLS: u16 = 80; const MIN_ROWS: u16 = 24; -fn paste_payload_for_runtime(runtime: &crate::terminal::TerminalRuntime, text: &str) -> String { - if runtime - .input_state() - .map(|state| state.bracketed_paste) - .unwrap_or(false) - { - format!("\x1b[200~{text}\x1b[201~") - } else { - text.to_owned() - } -} - -/// Legacy environment variable for overriding the client socket path. -/// -/// Contractual override behavior for auto-detect uses `HERDR_SOCKET_PATH`. -/// This variable is kept as a fallback for callers that explicitly need a -/// client-only override when `HERDR_SOCKET_PATH` is not set. -pub const CLIENT_SOCKET_PATH_ENV_VAR: &str = "HERDR_CLIENT_SOCKET_PATH"; - -/// Socket permission mode (owner read/write only). -const SOCKET_PERMISSION_MODE: u32 = 0o600; - /// Timeout for in-flight API requests during shutdown. #[allow(dead_code)] const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5); @@ -99,263 +89,6 @@ const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5); /// avoid reintroducing the idle CPU spin. const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250); -fn should_forward_toast_to_clients(delivery: config::ToastDelivery) -> bool { - toast_notify_kind(delivery).is_some() -} - -fn toast_notify_kind(delivery: config::ToastDelivery) -> Option<protocol::NotifyKind> { - match delivery { - config::ToastDelivery::Terminal => Some(protocol::NotifyKind::Toast), - config::ToastDelivery::System => Some(protocol::NotifyKind::SystemToast), - config::ToastDelivery::Off | config::ToastDelivery::Herdr => None, - } -} - -fn toast_event_text(kind: app::state::ToastKind) -> &'static str { - match kind { - app::state::ToastKind::NeedsAttention => "needs attention", - app::state::ToastKind::Finished => "finished", - app::state::ToastKind::UpdateInstalled => "updated", - } -} - -fn toast_message_from_state_change( - state: &AppState, - pane_id: PaneId, - suppress_active_tab_notifications: bool, - prev_state: AgentState, - new_state: AgentState, -) -> Option<String> { - let kind = app::actions::notification_toast_for_state_change( - suppress_active_tab_notifications, - prev_state, - new_state, - )?; - - state - .workspaces - .iter() - .enumerate() - .find_map(|(ws_idx, ws)| { - ws.tabs.iter().find_map(|tab| { - let pane = tab.panes.get(&pane_id)?; - let agent_label = state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label())?; - Some(format!( - "{} {}: {}", - agent_label, - toast_event_text(kind), - app::actions::notification_context(ws, ws_idx, pane_id) - )) - }) - }) -} - -// --------------------------------------------------------------------------- -// Socket path helpers -// --------------------------------------------------------------------------- - -/// Returns the path for the client protocol socket. -/// -/// Contract-aligned override behavior: -/// 1. If CLI `--session <name>` is active, use that session's client socket. -/// 2. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by -/// inserting `-client` before `.sock` (e.g. `herdr.sock` -> `herdr-client.sock`). -/// This keeps JSON API and client socket overrides consistent. -/// 3. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback). -/// 4. Otherwise, use the active session data directory. -pub fn client_socket_path() -> PathBuf { - if crate::session::explicit_session_requested() { - return crate::session::client_socket_path_for(crate::session::active_name().as_deref()); - } - client_socket_path_from_overrides( - std::env::var(api::SOCKET_PATH_ENV_VAR).ok().as_deref(), - std::env::var(CLIENT_SOCKET_PATH_ENV_VAR).ok().as_deref(), - ) -} - -fn client_socket_path_from_overrides( - api_socket_override: Option<&str>, - client_socket_override: Option<&str>, -) -> PathBuf { - if let Some(api_socket_override) = api_socket_override { - return derive_client_socket_from_api_socket(Path::new(api_socket_override)); - } - - if let Some(client_socket_override) = client_socket_override { - return PathBuf::from(client_socket_override); - } - - crate::session::client_socket_path_for(crate::session::active_name().as_deref()) -} - -fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { - let stem = api_socket_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("herdr"); - let parent = api_socket_path.parent().unwrap_or_else(|| Path::new("")); - - if api_socket_path - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext == "sock") - { - return parent.join(format!("{stem}-client.sock")); - } - - parent.join(format!("{stem}-client.sock")) -} - -fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig { - crate::config::LiveKeybindConfig { - prefix: (app.state.prefix_code, app.state.prefix_mods), - keybinds: app.state.keybinds.clone(), - } -} - -fn apply_keybindings(app: &mut app::App, keybindings: &crate::config::LiveKeybindConfig) { - app.state.prefix_code = keybindings.prefix.0; - app.state.prefix_mods = keybindings.prefix.1; - app.state.keybinds = keybindings.keybinds.clone(); -} - -// --------------------------------------------------------------------------- -// Connected client state -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ClientConnectionMode { - App, - TerminalAttach { terminal_id: String }, -} - -type RenderTarget = ( - u64, - (u16, u16), - crate::kitty_graphics::HostCellSize, - bool, - ClientConnectionMode, -); - -/// A connected client tracked by the server. -struct ClientConnection { - /// Whether this connection is the full app client or a direct terminal attach. - mode: ClientConnectionMode, - /// Client-local app keybindings. None means use the server's keybindings. - keybindings: Option<Box<crate::config::LiveKeybindConfig>>, - /// The client's terminal size (after clamping). - terminal_size: (u16, u16), - /// Pixel size of one client terminal cell. - cell_size: crate::kitty_graphics::HostCellSize, - /// Last known host terminal default colors for this client. - host_terminal_theme: crate::terminal_theme::TerminalTheme, - /// Last reported focus state for this client's outer terminal. - outer_terminal_focus: Option<bool>, - /// Monotonic activity stamp used to choose the fallback foreground client. - last_activity: u64, - /// Render baseline for the negotiated client encoding. - render_state: ClientRenderState, - /// Client-local host Kitty graphics cache. - graphics_cache: crate::kitty_graphics::HostGraphicsCache, - /// Whether the next graphics frame must clear and rebuild host-side Kitty state. - graphics_surface_reset_pending: bool, - /// Whether a render was skipped because the render channel was full. - render_pending: bool, - /// Last host mouse capture mode sent to this client. - host_mouse_capture_active: Option<bool>, - /// Temporary files staged from this client's local clipboard image pastes. - staged_clipboard_files: Vec<PathBuf>, - /// Channels for sending framed ServerMessage data to the client writer thread. - writer: Option<ClientWriter>, -} - -impl ClientConnection { - #[cfg(test)] - fn new( - terminal_size: (u16, u16), - cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option<bool>, - last_activity: u64, - render_encoding: RenderEncoding, - writer: Option<ClientWriter>, - ) -> Self { - Self::new_with_mode( - ClientConnectionMode::App, - None, - terminal_size, - cell_size, - host_terminal_theme, - outer_terminal_focus, - last_activity, - render_encoding, - writer, - ) - } - - fn new_with_mode( - mode: ClientConnectionMode, - keybindings: Option<Box<crate::config::LiveKeybindConfig>>, - terminal_size: (u16, u16), - cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option<bool>, - last_activity: u64, - render_encoding: RenderEncoding, - writer: Option<ClientWriter>, - ) -> Self { - Self { - mode, - keybindings, - terminal_size, - cell_size, - host_terminal_theme, - outer_terminal_focus, - last_activity, - render_state: ClientRenderState::new(render_encoding), - graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), - graphics_surface_reset_pending: false, - render_pending: false, - host_mouse_capture_active: None, - staged_clipboard_files: Vec::new(), - writer, - } - } - - fn request_full_redraw(&mut self) { - self.render_state.reset_baseline(); - self.graphics_surface_reset_pending = true; - } - - fn request_semantic_redraw_after_input(&mut self) { - self.render_state.reset_semantic_input_baseline(); - } -} - -// --------------------------------------------------------------------------- -// Stale socket cleanup -// --------------------------------------------------------------------------- - -/// Prepares a socket path for binding: creates parent directories, -/// removes stale socket files (where no server is listening), and -/// returns an error if a live server is already bound. -fn prepare_socket_path(path: &Path) -> io::Result<()> { - crate::ipc::prepare_socket_path(path, |path| { - format!( - "herdr server is already running (socket busy at {})", - path.display() - ) - }) -} - -/// Restricts socket file permissions to owner-only (0o600). -fn restrict_socket_permissions(path: &Path) -> io::Result<()> { - crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) -} - // --------------------------------------------------------------------------- // Headless server // --------------------------------------------------------------------------- @@ -616,9 +349,18 @@ impl HeadlessServer { let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() { - crate::ui::compute_view_with_cell_size(&mut self.app.state, area, client.cell_size); + crate::ui::compute_view_with_cell_size( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + client.cell_size, + ); } else { - crate::ui::compute_view(&mut self.app.state, area); + crate::ui::compute_view_with_runtime_registry( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + ); } // Shared runtime size changes affect pane wrapping and foreground-driven @@ -697,12 +439,7 @@ impl HeadlessServer { } fn promote_latest_remaining_client(&mut self) -> bool { - let next_foreground = self - .clients - .iter() - .filter(|(_, client)| matches!(client.mode, ClientConnectionMode::App)) - .max_by_key(|(_, client)| client.last_activity) - .map(|(&client_id, _)| client_id); + let next_foreground = latest_app_client(&self.clients); let changed = next_foreground != self.foreground_client_id; self.foreground_client_id = next_foreground; self.sync_foreground_client_state(); @@ -768,20 +505,12 @@ impl HeadlessServer { return false; }; - let mut next_theme = client.host_terminal_theme; - for event in events { - if let crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } = event { - next_theme = next_theme.with_color(*kind, *color); - } - } - - if next_theme == client.host_terminal_theme { + if !client.update_host_theme_from_events(events) { return false; } - client.host_terminal_theme = next_theme; if self.foreground_client_id == Some(client_id) { - self.app.set_host_terminal_theme(next_theme) + self.app.set_host_terminal_theme(client.host_terminal_theme) } else { false } @@ -792,77 +521,25 @@ impl HeadlessServer { client_id: u64, events: &[crate::raw_input::RawInputEvent], ) { - let next_focus = events - .iter() - .filter_map(|event| match event { - crate::raw_input::RawInputEvent::OuterFocusGained => Some(true), - crate::raw_input::RawInputEvent::OuterFocusLost => Some(false), - _ => None, - }) - .next_back(); - - let Some(next_focus) = next_focus else { + let Some(client) = self.clients.get_mut(&client_id) else { return; }; - let Some(client) = self.clients.get_mut(&client_id) else { + let Some(next_focus) = client.update_outer_focus_from_events(events) else { return; }; - client.outer_terminal_focus = Some(next_focus); if self.foreground_client_id == Some(client_id) { self.app.state.outer_terminal_focus = Some(next_focus); } } - fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool { - events.iter().any(|event| { - matches!( - event, - crate::raw_input::RawInputEvent::Key(_) - | crate::raw_input::RawInputEvent::Mouse(_) - | crate::raw_input::RawInputEvent::Paste(_) - | crate::raw_input::RawInputEvent::OuterFocusGained - ) - }) - } - /// Accepts pending client connections from the non-blocking listener. fn accept_client_connections(&mut self) -> io::Result<()> { - loop { - match self.client_listener.accept() { - Ok((stream, _addr)) => { - let client_id = self.next_client_id; - self.next_client_id += 1; - - if let Err(err) = stream.set_nonblocking(true) { - warn!(err = %err, "failed to set client stream nonblocking"); - continue; - } - - // Spawn a thread for the handshake and read loop. - let should_quit = self.should_quit.clone(); - let server_event_tx = self.server_event_tx.clone(); - std::thread::spawn(move || { - if let Err(err) = client_transport::handle_client_handshake( - stream, - client_id, - &server_event_tx, - &should_quit, - ) { - debug!(client_id, err = %err, "client handshake failed"); - } - }); - } - Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => { - // No more pending connections. - break; - } - Err(err) => { - error!(err = %err, "client listener accept failed"); - break; - } - } - } - Ok(()) + accept_pending_client_connections( + &self.client_listener, + &mut self.next_client_id, + &self.should_quit, + &self.server_event_tx, + ) } /// Drains server events from the dedicated channel. @@ -890,7 +567,7 @@ impl HeadlessServer { terminal_id: &str, ) -> Option<&crate::terminal::TerminalRuntime> { let terminal_id = self.terminal_id_by_string(terminal_id)?; - self.app.state.terminal_runtimes.get(&terminal_id) + self.app.terminal_runtimes.get(&terminal_id) } fn write_client_clipboard_image( @@ -1328,16 +1005,7 @@ impl HeadlessServer { } fn shutdown_terminal_attach_clients(&mut self, terminal_id: &str, reason: String) { - let client_ids: Vec<u64> = self - .clients - .iter() - .filter_map(|(&client_id, client)| match &client.mode { - ClientConnectionMode::TerminalAttach { - terminal_id: attached, - } if attached == terminal_id => Some(client_id), - _ => None, - }) - .collect(); + let client_ids = terminal_attach_client_ids(&self.clients, terminal_id); for client_id in client_ids { self.send_to_client( @@ -1419,7 +1087,7 @@ impl HeadlessServer { .state .direct_attach_resize_locks .insert(real_terminal_id.clone()); - if let Some(runtime) = self.app.state.terminal_runtimes.get(&real_terminal_id) { + if let Some(runtime) = self.app.terminal_runtimes.get(&real_terminal_id) { runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px); } true @@ -1504,7 +1172,7 @@ impl HeadlessServer { } } self.update_client_outer_focus_from_events(client_id, &events); - let interaction = Self::events_include_interaction(&events); + let interaction = events_include_interaction(&events); let foreground_changed = if interaction { self.promote_client_to_foreground(client_id) } else { @@ -1878,7 +1546,10 @@ impl HeadlessServer { } fn stream_host_mouse_capture_mode(&mut self) { - let enabled = self.app.state.should_capture_host_mouse(); + let enabled = self + .app + .state + .should_capture_host_mouse_from(&self.app.terminal_runtimes); let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled }) { Ok(framed) => framed, @@ -1921,33 +1592,18 @@ impl HeadlessServer { /// Renders the current state to client-sized virtual buffers and streams /// frames to all connected clients. fn render_and_stream(&mut self) { - let foreground_client_id = self.foreground_client_id; - let mut render_targets: Vec<RenderTarget> = self - .clients - .iter() - .filter(|(_, client)| client.writer.is_some()) - .map(|(&client_id, client)| { - ( - client_id, - client.terminal_size, - client.cell_size, - foreground_client_id == Some(client_id), - client.mode.clone(), - ) - }) - .collect(); - - render_targets - .sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id)); + let render_targets = render_targets(&self.clients, self.foreground_client_id); if render_targets.is_empty() { let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); let resize_panes = self.app.state.view.pane_infos.is_empty(); - let _ = crate::server::render_stream::render_virtual( + let _ = crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, + &self.app.terminal_runtimes, area, resize_panes, + crate::kitty_graphics::HostCellSize::default(), ); debug!( cols, @@ -1964,21 +1620,26 @@ impl HeadlessServer { ClientConnectionMode::App => { let (buffer, cursor) = if self.app.state.kitty_graphics_enabled && cell_size.is_known() { - crate::server::render_stream::render_virtual_with_cell_size( + crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, + &self.app.terminal_runtimes, area, is_foreground, cell_size, ) } else { - crate::server::render_stream::render_virtual( + crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, + &self.app.terminal_runtimes, area, is_foreground, + crate::kitty_graphics::HostCellSize::default(), ) }; - let hyperlinks = - crate::server::render_stream::visible_hyperlinks(&self.app.state); + let hyperlinks = crate::server::render_stream::visible_hyperlinks( + &self.app.state, + &self.app.terminal_runtimes, + ); FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) } ClientConnectionMode::TerminalAttach { terminal_id } => { @@ -2014,6 +1675,7 @@ impl HeadlessServer { .graphics .extend(crate::kitty_graphics::encode_local_pane_graphics( &self.app.state, + &self.app.terminal_runtimes, cell_size, &mut next_graphics_cache, )); @@ -2415,7 +2077,8 @@ fn init_logging() { mod tests { use super::*; - use crate::server::protocol::CursorState; + use crate::app::AppState; + use crate::protocol::CursorState; fn test_headless_server() -> HeadlessServer { let config = crate::config::Config::default(); @@ -2632,100 +2295,6 @@ new_tab = "prefix+t" } #[test] - fn client_socket_path_derived_from_api_socket_override() { - let path = client_socket_path_from_overrides(Some("/tmp/test-herdr.sock"), None); - assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); - } - - #[test] - fn client_socket_path_api_override_takes_precedence_over_legacy_client_override() { - let path = client_socket_path_from_overrides( - Some("/tmp/test-herdr.sock"), - Some("/tmp/legacy-client.sock"), - ); - assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); - } - - #[test] - fn client_socket_path_respects_legacy_client_override_without_api_override() { - let path = client_socket_path_from_overrides(None, Some("/tmp/test-herdr-client.sock")); - assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); - } - - #[test] - fn client_socket_path_defaults_to_config_dir() { - std::env::remove_var(crate::session::SESSION_ENV_VAR); - crate::session::clear_explicit_session_for_test(); - let path = client_socket_path_from_overrides(None, None); - assert_eq!(path, config::config_dir().join("herdr-client.sock")); - } - - #[test] - fn derive_client_socket_from_api_socket_without_sock_extension() { - let derived = derive_client_socket_from_api_socket(Path::new("/tmp/custom-api")); - assert_eq!(derived, PathBuf::from("/tmp/custom-api-client.sock")); - } - - #[test] - fn prepare_socket_path_removes_stale_socket() { - let dir = PathBuf::from(format!( - "/tmp/hs-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = fs::create_dir_all(&dir); - let socket_path = dir.join("stale.sock"); - - // Create a socket file that nobody is listening on. - { - let _listener = UnixListener::bind(&socket_path).expect("bind stale socket"); - } - // The listener scope ended, so the socket is now stale. - let deadline = std::time::Instant::now() + Duration::from_secs(1); - while std::time::Instant::now() < deadline { - if std::os::unix::net::UnixStream::connect(&socket_path).is_err() { - break; - } - std::thread::sleep(Duration::from_millis(10)); - } - - // prepare_socket_path should remove it without error. - let result = prepare_socket_path(&socket_path); - assert!(result.is_ok(), "should remove stale socket: {result:?}"); - - // Socket file should be gone. - assert!(!socket_path.exists()); - - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn prepare_socket_path_rejects_live_socket() { - let dir = PathBuf::from(format!( - "/tmp/hl-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = fs::create_dir_all(&dir); - let socket_path = dir.join("live.sock"); - - // Bind a live listener. - let _listener = UnixListener::bind(&socket_path).expect("bind"); - - let result = prepare_socket_path(&socket_path); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::AddrInUse); - - let _ = fs::remove_dir_all(&dir); - } - - #[test] fn virtual_render_produces_nonempty_buffer() { let mut state = AppState::test_new(); let area = Rect::new(0, 0, 80, 24); @@ -2876,8 +2445,9 @@ new_tab = "prefix+t" let area = Rect::new(0, 0, 80, 24); let _ = crate::server::render_stream::render_virtual(&mut state, area, true); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let runtime = state - .runtime_for_pane(pane_id) + .runtime_for_pane(&terminal_runtimes, pane_id) .expect("pane runtime after initial render"); runtime.scroll_up(6); assert!(crate::ui::pane_is_scrolled_back(runtime)); @@ -3031,8 +2601,9 @@ new_tab = "prefix+t" let area = Rect::new(0, 0, 80, 24); let _ = crate::server::render_stream::render_virtual(&mut state, area, true); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let runtime = state - .runtime_for_pane(pane_id) + .runtime_for_pane(&terminal_runtimes, pane_id) .expect("pane runtime after initial render"); runtime.scroll_up(6); assert!(crate::ui::pane_is_scrolled_back(runtime)); @@ -3325,7 +2896,7 @@ new_tab = "prefix+t" server .app .state - .runtime_for_pane(active_pane) + .runtime_for_pane(&server.app.terminal_runtimes, active_pane) .unwrap() .current_size(), expected @@ -3334,7 +2905,7 @@ new_tab = "prefix+t" server .app .state - .runtime_for_pane(background_pane) + .runtime_for_pane(&server.app.terminal_runtimes, background_pane) .unwrap() .current_size(), expected diff --git a/src/server/keybindings.rs b/src/server/keybindings.rs new file mode 100644 index 00000000..262463ff --- /dev/null +++ b/src/server/keybindings.rs @@ -0,0 +1,17 @@ +use crate::app; + +pub(crate) fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig { + crate::config::LiveKeybindConfig { + prefix: (app.state.prefix_code, app.state.prefix_mods), + keybinds: app.state.keybinds.clone(), + } +} + +pub(crate) fn apply_keybindings( + app: &mut app::App, + keybindings: &crate::config::LiveKeybindConfig, +) { + app.state.prefix_code = keybindings.prefix.0; + app.state.prefix_mods = keybindings.prefix.1; + app.state.keybinds = keybindings.keybinds.clone(); +} diff --git a/src/server/mod.rs b/src/server/mod.rs index b41e2a67..b123e111 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,6 +1,11 @@ pub mod autodetect; +pub(crate) mod client_accept; pub(crate) mod client_transport; +pub(crate) mod clients; pub(crate) mod clipboard_image; pub mod headless; -pub mod protocol; +pub(crate) mod keybindings; +pub(crate) mod notifications; pub(crate) mod render_stream; +pub mod socket_paths; +pub(crate) mod terminal_attach; diff --git a/src/server/notifications.rs b/src/server/notifications.rs new file mode 100644 index 00000000..1729c698 --- /dev/null +++ b/src/server/notifications.rs @@ -0,0 +1,60 @@ +use crate::app; +use crate::app::state::AppState; +use crate::config; +use crate::detect::AgentState; +use crate::layout::PaneId; +use crate::protocol; + +pub(crate) fn should_forward_toast_to_clients(delivery: config::ToastDelivery) -> bool { + toast_notify_kind(delivery).is_some() +} + +pub(crate) fn toast_notify_kind(delivery: config::ToastDelivery) -> Option<protocol::NotifyKind> { + match delivery { + config::ToastDelivery::Terminal => Some(protocol::NotifyKind::Toast), + config::ToastDelivery::System => Some(protocol::NotifyKind::SystemToast), + config::ToastDelivery::Off | config::ToastDelivery::Herdr => None, + } +} + +pub(crate) fn toast_message_from_state_change( + state: &AppState, + pane_id: PaneId, + suppress_active_tab_notifications: bool, + prev_state: AgentState, + new_state: AgentState, +) -> Option<String> { + let kind = app::actions::notification_toast_for_state_change( + suppress_active_tab_notifications, + prev_state, + new_state, + )?; + + state + .workspaces + .iter() + .enumerate() + .find_map(|(ws_idx, ws)| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + let agent_label = state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label())?; + Some(format!( + "{} {}: {}", + agent_label, + toast_event_text(kind), + app::actions::notification_context(ws, ws_idx, pane_id) + )) + }) + }) +} + +fn toast_event_text(kind: app::state::ToastKind) -> &'static str { + match kind { + app::state::ToastKind::NeedsAttention => "needs attention", + app::state::ToastKind::Finished => "finished", + app::state::ToastKind::UpdateInstalled => "updated", + } +} diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index b6e50444..3548f98a 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -5,10 +5,9 @@ use ratatui::layout::{Position, Rect, Size}; use crate::app::state::AppState; use crate::app::Mode; -use crate::client::blit::{BlitEncoder, EncodedBlit}; -use crate::server::protocol::{ - CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame, -}; +use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit}; +use crate::protocol::{CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame}; +use crate::terminal::TerminalRuntimeRegistry; /// Per-client render baseline for the negotiated render encoding. pub(crate) enum ClientRenderState { @@ -217,29 +216,33 @@ impl Backend for CursorTrackingBackend { /// This produces the same output as the monolithic binary's terminal draw, /// but writes to a `Buffer` instead of stdout. Cursor visibility is captured /// from explicit frame cursor intent rather than incidental backend state. +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn render_virtual( app_state: &mut AppState, area: Rect, resize_panes: bool, ) -> (ratatui::buffer::Buffer, Option<CursorState>) { - render_virtual_with_cell_size( + let terminal_runtimes = TerminalRuntimeRegistry::new(); + render_virtual_with_runtime_registry( app_state, + &terminal_runtimes, area, resize_panes, crate::kitty_graphics::HostCellSize::default(), ) } -pub(crate) fn render_virtual_with_cell_size( +pub(crate) fn render_virtual_with_runtime_registry( app_state: &mut AppState, + terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) -> (ratatui::buffer::Buffer, Option<CursorState>) { if resize_panes { - crate::ui::compute_view_with_cell_size(app_state, area, cell_size); + crate::ui::compute_view_with_cell_size(app_state, terminal_runtimes, area, cell_size); } else { - crate::ui::compute_view_without_resizing_panes(app_state, area); + crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area); } let backend = CursorTrackingBackend::new(area.width, area.height); @@ -247,13 +250,13 @@ pub(crate) fn render_virtual_with_cell_size( terminal .draw(|frame| { - crate::ui::render(app_state, frame); + crate::ui::render_with_runtime_registry(app_state, terminal_runtimes, frame); }) .expect("render to TestBackend should never fail"); let buffer = terminal.backend().buffer().clone(); - let cursor = - focused_terminal_cursor(app_state).or_else(|| terminal.backend().rendered_cursor()); + let cursor = focused_terminal_cursor(app_state, terminal_runtimes) + .or_else(|| terminal.backend().rendered_cursor()); (buffer, cursor) } @@ -286,7 +289,10 @@ pub(crate) fn render_terminal_virtual( (buffer, cursor) } -pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), String, String)> { +pub(crate) fn visible_hyperlinks( + app_state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, +) -> Vec<((u16, u16), String, String)> { let Some(ws_idx) = app_state.active else { return Vec::new(); }; @@ -302,7 +308,7 @@ pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), Strin for info in &app_state.view.pane_infos { if let Some(runtime) = tab .terminal_id(info.id) - .and_then(|terminal_id| app_state.terminal_runtimes.get(terminal_id)) + .and_then(|terminal_id| terminal_runtimes.get(terminal_id)) { links.extend(runtime.visible_hyperlinks(info.inner_rect)); } @@ -310,7 +316,10 @@ pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), Strin links } -fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> { +fn focused_terminal_cursor( + app_state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, +) -> Option<CursorState> { if app_state.mode != Mode::Terminal { return None; } @@ -321,7 +330,7 @@ fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> { .pane_infos .iter() .find(|info| info.is_focused)?; - let rt = app_state.runtime_for_pane_in_workspace(ws_idx, info.id)?; + let rt = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; let scrolled_back = crate::ui::pane_is_scrolled_back(rt); // Determine whether the IME-anchor reveal applies to this focused pane. diff --git a/src/server/socket_paths.rs b/src/server/socket_paths.rs new file mode 100644 index 00000000..1630ae7c --- /dev/null +++ b/src/server/socket_paths.rs @@ -0,0 +1,180 @@ +use std::io; +use std::path::{Path, PathBuf}; + +/// Legacy environment variable for overriding the client socket path. +/// +/// Contractual override behavior for auto-detect uses `HERDR_SOCKET_PATH`. +/// This variable is kept as a fallback for callers that explicitly need a +/// client-only override when `HERDR_SOCKET_PATH` is not set. +pub const CLIENT_SOCKET_PATH_ENV_VAR: &str = "HERDR_CLIENT_SOCKET_PATH"; + +/// Socket permission mode (owner read/write only). +const SOCKET_PERMISSION_MODE: u32 = 0o600; + +/// Returns the path for the client protocol socket. +/// +/// Contract-aligned override behavior: +/// 1. If CLI `--session <name>` is active, use that session's client socket. +/// 2. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by +/// inserting `-client` before `.sock` (e.g. `herdr.sock` -> `herdr-client.sock`). +/// This keeps JSON API and client socket overrides consistent. +/// 3. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback). +/// 4. Otherwise, use the active session data directory. +pub fn client_socket_path() -> PathBuf { + if crate::session::explicit_session_requested() { + return crate::session::client_socket_path_for(crate::session::active_name().as_deref()); + } + client_socket_path_from_overrides( + std::env::var(crate::api::SOCKET_PATH_ENV_VAR) + .ok() + .as_deref(), + std::env::var(CLIENT_SOCKET_PATH_ENV_VAR).ok().as_deref(), + ) +} + +pub(crate) fn client_socket_path_from_overrides( + api_socket_override: Option<&str>, + client_socket_override: Option<&str>, +) -> PathBuf { + if let Some(api_socket_override) = api_socket_override { + return derive_client_socket_from_api_socket(Path::new(api_socket_override)); + } + + if let Some(client_socket_override) = client_socket_override { + return PathBuf::from(client_socket_override); + } + + crate::session::client_socket_path_for(crate::session::active_name().as_deref()) +} + +pub(crate) fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { + let stem = api_socket_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("herdr"); + let parent = api_socket_path.parent().unwrap_or_else(|| Path::new("")); + + if api_socket_path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "sock") + { + return parent.join(format!("{stem}-client.sock")); + } + + parent.join(format!("{stem}-client.sock")) +} + +/// Prepares a socket path for binding: creates parent directories, +/// removes stale socket files where no server is listening, and rejects live +/// sockets that are already in use. +pub(crate) fn prepare_socket_path(path: &Path) -> io::Result<()> { + crate::ipc::prepare_socket_path(path, |path| { + format!( + "herdr server is already running (socket busy at {})", + path.display() + ) + }) +} + +/// Restricts socket file permissions to owner-only (0o600). +pub(crate) fn restrict_socket_permissions(path: &Path) -> io::Result<()> { + crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::net::UnixListener; + use std::time::Duration; + + #[test] + fn client_socket_path_derived_from_api_socket_override() { + let path = client_socket_path_from_overrides(Some("/tmp/test-herdr.sock"), None); + assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); + } + + #[test] + fn client_socket_path_api_override_takes_precedence_over_legacy_client_override() { + let path = client_socket_path_from_overrides( + Some("/tmp/test-herdr.sock"), + Some("/tmp/legacy-client.sock"), + ); + assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); + } + + #[test] + fn client_socket_path_respects_legacy_client_override_without_api_override() { + let path = client_socket_path_from_overrides(None, Some("/tmp/test-herdr-client.sock")); + assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock")); + } + + #[test] + fn client_socket_path_defaults_to_config_dir() { + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + let path = client_socket_path_from_overrides(None, None); + assert_eq!(path, crate::config::config_dir().join("herdr-client.sock")); + } + + #[test] + fn derive_client_socket_from_api_socket_without_sock_extension() { + let derived = derive_client_socket_from_api_socket(Path::new("/tmp/custom-api")); + assert_eq!(derived, PathBuf::from("/tmp/custom-api-client.sock")); + } + + #[test] + fn prepare_socket_path_removes_stale_socket() { + let dir = PathBuf::from(format!( + "/tmp/hs-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = fs::create_dir_all(&dir); + let socket_path = dir.join("stale.sock"); + + { + let _listener = UnixListener::bind(&socket_path).expect("bind stale socket"); + } + + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while std::time::Instant::now() < deadline { + if std::os::unix::net::UnixStream::connect(&socket_path).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + + let result = prepare_socket_path(&socket_path); + assert!(result.is_ok(), "should remove stale socket: {result:?}"); + assert!(!socket_path.exists()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn prepare_socket_path_rejects_live_socket() { + let dir = PathBuf::from(format!( + "/tmp/hl-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = fs::create_dir_all(&dir); + let socket_path = dir.join("live.sock"); + + let _listener = UnixListener::bind(&socket_path).expect("bind"); + + let result = prepare_socket_path(&socket_path); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::AddrInUse); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/server/terminal_attach.rs b/src/server/terminal_attach.rs new file mode 100644 index 00000000..01fc899f --- /dev/null +++ b/src/server/terminal_attach.rs @@ -0,0 +1,14 @@ +pub(crate) fn paste_payload_for_runtime( + runtime: &crate::terminal::TerminalRuntime, + text: &str, +) -> String { + if runtime + .input_state() + .map(|state| state.bracketed_paste) + .unwrap_or(false) + { + format!("\x1b[200~{text}\x1b[201~") + } else { + text.to_owned() + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 5f9bb41b..588d5d76 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,13 +1,10 @@ mod id; +mod runtime; +mod runtime_registry; pub mod state; pub use id::TerminalId; +pub use runtime::TerminalRuntime; +pub(crate) use runtime_registry::TerminalRuntimeRegistry; pub(crate) use state::stabilize_agent_state; pub use state::{EffectiveStateChange, TerminalState}; - -/// Live runtime for a server-owned terminal. -/// -/// The implementation still lives in the pane module during this migration, but -/// all new ownership should address it by `TerminalId` through this terminal -/// noun. -pub type TerminalRuntime = crate::pane::PaneRuntime; diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs new file mode 100644 index 00000000..43c2467d --- /dev/null +++ b/src/terminal/runtime.rs @@ -0,0 +1,312 @@ +use std::sync::{atomic::AtomicBool, Arc}; + +use bytes::Bytes; +use ratatui::{layout::Rect, Frame}; +use tokio::sync::{mpsc, Notify}; + +use crate::events::AppEvent; +use crate::layout::PaneId; + +/// Live runtime for a server-owned terminal. +/// +/// The PTY implementation still delegates to the legacy pane runtime while the +/// migration proceeds, but production code now depends on this terminal-layer +/// type instead of the pane module's implementation detail. +pub struct TerminalRuntime(crate::pane::PaneRuntime); + +impl TerminalRuntime { + pub fn shutdown(self) { + self.0.shutdown(); + } + + pub fn spawn( + pane_id: PaneId, + rows: u16, + cols: u16, + cwd: std::path::PathBuf, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + default_shell: &str, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, + ) -> std::io::Result<Self> { + crate::pane::PaneRuntime::spawn( + pane_id, + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + default_shell, + events, + render_notify, + render_dirty, + ) + .map(Self) + } + + pub fn spawn_shell_command( + pane_id: PaneId, + rows: u16, + cols: u16, + cwd: std::path::PathBuf, + command: &str, + extra_env: &[(String, String)], + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, + ) -> std::io::Result<Self> { + crate::pane::PaneRuntime::spawn_shell_command( + pane_id, + rows, + cols, + cwd, + command, + extra_env, + scrollback_limit_bytes, + host_terminal_theme, + events, + render_notify, + render_dirty, + ) + .map(Self) + } + + pub fn spawn_argv_command( + pane_id: PaneId, + rows: u16, + cols: u16, + cwd: std::path::PathBuf, + argv: &[String], + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, + ) -> std::io::Result<Self> { + crate::pane::PaneRuntime::spawn_argv_command( + pane_id, + rows, + cols, + cwd, + argv, + scrollback_limit_bytes, + host_terminal_theme, + events, + render_notify, + render_dirty, + ) + .map(Self) + } + + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { + self.0.apply_host_terminal_theme(theme); + } + + pub fn begin_graceful_release(&self, agent: crate::detect::Agent) { + self.0.begin_graceful_release(agent); + } + + pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { + self.0.resize(rows, cols, cell_width_px, cell_height_px); + } + + pub fn scroll_up(&self, lines: usize) { + self.0.scroll_up(lines); + } + + pub fn scroll_down(&self, lines: usize) { + self.0.scroll_down(lines); + } + + pub fn scroll_reset(&self) { + self.0.scroll_reset(); + } + + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { + self.0.set_scroll_offset_from_bottom(lines); + } + + pub fn scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { + self.0.scroll_metrics() + } + + pub fn input_state(&self) -> Option<crate::pane::InputState> { + self.0.input_state() + } + + pub fn cursor_state( + &self, + area: Rect, + show_cursor: bool, + ) -> Option<crate::pane::TerminalCursorState> { + self.0.cursor_state(area, show_cursor) + } + + pub fn visible_text(&self) -> String { + self.0.visible_text() + } + + pub fn visible_ansi(&self) -> String { + self.0.visible_ansi() + } + + pub fn recent_text(&self, lines: usize) -> String { + self.0.recent_text(lines) + } + + pub fn recent_ansi(&self, lines: usize) -> String { + self.0.recent_ansi(lines) + } + + pub fn recent_unwrapped_text(&self, lines: usize) -> String { + self.0.recent_unwrapped_text(lines) + } + + pub fn recent_unwrapped_ansi(&self, lines: usize) -> String { + self.0.recent_unwrapped_ansi(lines) + } + + pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> { + self.0.extract_selection(selection) + } + + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { + self.0.render(frame, area, show_cursor); + } + + pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> { + self.0.visible_hyperlinks(area) + } + + pub fn kitty_image_placements_with_data_filter<F>( + &self, + needs_data: F, + ) -> Vec<crate::ghostty::KittyImagePlacement> + where + F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool, + { + self.0.kitty_image_placements_with_data_filter(needs_data) + } + + pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol { + self.0.keyboard_protocol() + } + + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec<u8> { + self.0.encode_terminal_key(key) + } + + pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> { + self.0.send_bytes(bytes).await + } + + pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> { + self.0.try_send_bytes(bytes) + } + + pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError<Bytes>> { + self.0.send_paste(text).await + } + + pub fn try_send_focus_event(&self, event: crate::ghostty::FocusEvent) -> bool { + self.0.try_send_focus_event(event) + } + + pub fn wheel_routing(&self) -> Option<crate::pane::WheelRouting> { + self.0.wheel_routing() + } + + pub fn encode_mouse_button( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + self.0.encode_mouse_button(kind, column, row, modifiers) + } + + pub fn encode_mouse_wheel( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + self.0.encode_mouse_wheel(kind, column, row, modifiers) + } + + pub fn encode_alternate_scroll( + &self, + kind: crossterm::event::MouseEventKind, + ) -> Option<Vec<u8>> { + self.0.encode_alternate_scroll(kind) + } + + pub fn cwd(&self) -> Option<std::path::PathBuf> { + self.0.cwd() + } +} + +#[cfg(test)] +impl TerminalRuntime { + pub(crate) fn current_size(&self) -> (u16, u16) { + self.0.current_size() + } + + pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver<Bytes>) { + let (runtime, rx) = crate::pane::PaneRuntime::test_with_channel(cols, rows); + (Self(runtime), rx) + } + + pub(crate) fn test_with_channel_capacity( + cols: u16, + rows: u16, + capacity: usize, + ) -> (Self, mpsc::Receiver<Bytes>) { + let (runtime, rx) = + crate::pane::PaneRuntime::test_with_channel_capacity(cols, rows, capacity); + (Self(runtime), rx) + } + + pub(crate) fn test_with_screen_bytes(cols: u16, rows: u16, bytes: &[u8]) -> Self { + Self(crate::pane::PaneRuntime::test_with_screen_bytes( + cols, rows, bytes, + )) + } + + pub(crate) fn test_with_scrollback_bytes( + cols: u16, + rows: u16, + scrollback_limit_bytes: usize, + bytes: &[u8], + ) -> Self { + Self(crate::pane::PaneRuntime::test_with_scrollback_bytes( + cols, + rows, + scrollback_limit_bytes, + bytes, + )) + } + + pub(crate) fn test_with_channel_and_scrollback_bytes( + cols: u16, + rows: u16, + scrollback_limit_bytes: usize, + bytes: &[u8], + channel_capacity: usize, + ) -> (Self, mpsc::Receiver<Bytes>) { + let (runtime, rx) = crate::pane::PaneRuntime::test_with_channel_and_scrollback_bytes( + cols, + rows, + scrollback_limit_bytes, + bytes, + channel_capacity, + ); + (Self(runtime), rx) + } +} diff --git a/src/terminal/runtime_registry.rs b/src/terminal/runtime_registry.rs new file mode 100644 index 00000000..d1aeeae2 --- /dev/null +++ b/src/terminal/runtime_registry.rs @@ -0,0 +1,54 @@ +use std::collections::HashMap; + +use super::{TerminalId, TerminalRuntime}; + +/// Server-owned live terminal runtimes, keyed by durable terminal id. +/// +/// This sits outside `AppState` so pure state can stay focused on workspace, +/// pane, and terminal metadata while the server/application layer owns PTYs, +/// parser backends, detector tasks, and channels. +#[derive(Default)] +pub(crate) struct TerminalRuntimeRegistry { + runtimes: HashMap<TerminalId, TerminalRuntime>, +} + +impl TerminalRuntimeRegistry { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn get(&self, terminal_id: &TerminalId) -> Option<&TerminalRuntime> { + self.runtimes.get(terminal_id) + } + + pub(crate) fn insert( + &mut self, + terminal_id: TerminalId, + runtime: TerminalRuntime, + ) -> Option<TerminalRuntime> { + self.runtimes.insert(terminal_id, runtime) + } + + pub(crate) fn remove(&mut self, terminal_id: &TerminalId) -> Option<TerminalRuntime> { + self.runtimes.remove(terminal_id) + } + + pub(crate) fn values(&self) -> impl Iterator<Item = &TerminalRuntime> { + self.runtimes.values() + } + + pub(crate) fn len(&self) -> usize { + self.runtimes.len() + } + + #[cfg(test)] + pub(crate) fn drain(&mut self) -> impl Iterator<Item = (TerminalId, TerminalRuntime)> + '_ { + self.runtimes.drain() + } +} + +impl From<HashMap<TerminalId, TerminalRuntime>> for TerminalRuntimeRegistry { + fn from(runtimes: HashMap<TerminalId, TerminalRuntime>) -> Self { + Self { runtimes } + } +} @@ -70,6 +70,7 @@ pub(crate) use self::{ }; use crate::app::state::ViewLayout; use crate::app::{AppState, Mode}; +use crate::terminal::TerminalRuntimeRegistry; const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator @@ -86,8 +87,18 @@ pub(super) fn spinner_frame(tick: u32) -> &'static str { /// Called before render to separate mutation from drawing. #[cfg_attr(not(test), allow(dead_code))] pub fn compute_view(app: &mut AppState, area: Rect) { + let terminal_runtimes = TerminalRuntimeRegistry::new(); + compute_view_with_runtime_registry(app, &terminal_runtimes, area); +} + +pub fn compute_view_with_runtime_registry( + app: &mut AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + area: Rect, +) { compute_view_internal( app, + terminal_runtimes, area, true, crate::kitty_graphics::HostCellSize::default(), @@ -96,10 +107,11 @@ pub fn compute_view(app: &mut AppState, area: Rect) { pub fn compute_view_with_cell_size( app: &mut AppState, + terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, cell_size: crate::kitty_graphics::HostCellSize, ) { - compute_view_internal(app, area, true, cell_size); + compute_view_internal(app, terminal_runtimes, area, true, cell_size); } /// Compute view geometry for a client-sized render without resizing pane runtimes. @@ -107,9 +119,14 @@ pub fn compute_view_with_cell_size( /// This is used by the headless server when a non-foreground client needs its /// own frame size while the shared pane runtimes stay pinned to the foreground /// client. -pub(crate) fn compute_view_without_resizing_panes(app: &mut AppState, area: Rect) { +pub(crate) fn compute_view_without_resizing_panes( + app: &mut AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + area: Rect, +) { compute_view_internal( app, + terminal_runtimes, area, false, crate::kitty_graphics::HostCellSize::default(), @@ -118,6 +135,7 @@ pub(crate) fn compute_view_without_resizing_panes(app: &mut AppState, area: Rect fn resize_background_tab_panes_to_terminal_area( app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, terminal_area: Rect, cell_size: crate::kitty_graphics::HostCellSize, ) { @@ -126,19 +144,20 @@ fn resize_background_tab_panes_to_terminal_area( if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() { continue; } - resize_tab_panes(app, tab, terminal_area, cell_size); + resize_tab_panes(app, terminal_runtimes, tab, terminal_area, cell_size); } } } fn compute_view_internal( app: &mut AppState, + terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) { if is_mobile_width(area) { - compute_mobile_view(app, area, resize_panes, cell_size); + compute_mobile_view(app, terminal_runtimes, area, resize_panes, cell_size); return; } @@ -199,9 +218,20 @@ fn compute_view_internal( .map(|ws| ws.layout.splits(terminal_area)) .unwrap_or_default(); - let pane_infos = compute_pane_infos(app, terminal_area, resize_panes, cell_size); + let pane_infos = compute_pane_infos( + app, + terminal_runtimes, + terminal_area, + resize_panes, + cell_size, + ); if resize_panes { - resize_background_tab_panes_to_terminal_area(app, terminal_area, cell_size); + resize_background_tab_panes_to_terminal_area( + app, + terminal_runtimes, + terminal_area, + cell_size, + ); } let toast_hit_area = app @@ -230,6 +260,7 @@ fn compute_view_internal( fn compute_mobile_view( app: &mut AppState, + terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, @@ -255,9 +286,20 @@ fn compute_mobile_view( .map(|ws| ws.layout.splits(terminal_area)) .unwrap_or_default(); - let pane_infos = compute_pane_infos(app, terminal_area, resize_panes, cell_size); + let pane_infos = compute_pane_infos( + app, + terminal_runtimes, + terminal_area, + resize_panes, + cell_size, + ); if resize_panes { - resize_background_tab_panes_to_terminal_area(app, terminal_area, cell_size); + resize_background_tab_panes_to_terminal_area( + app, + terminal_runtimes, + terminal_area, + cell_size, + ); } let header_hits = compute_mobile_header_hit_areas(app, header_rect); @@ -286,7 +328,17 @@ fn compute_mobile_view( } /// Render the UI — reads AppState but does not mutate it. +#[cfg_attr(not(test), allow(dead_code))] pub fn render(app: &AppState, frame: &mut Frame) { + let terminal_runtimes = TerminalRuntimeRegistry::new(); + render_with_runtime_registry(app, &terminal_runtimes, frame); +} + +pub fn render_with_runtime_registry( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + frame: &mut Frame, +) { let sidebar_area = app.view.sidebar_rect; let tab_bar_area = app.view.tab_bar_rect; let terminal_area = app.view.terminal_area; @@ -301,7 +353,7 @@ pub fn render(app: &AppState, frame: &mut Frame) { if app.view.layout != ViewLayout::Mobile { render_tab_bar(app, frame, tab_bar_area); } - render_panes(app, frame, terminal_area); + render_panes(app, terminal_runtimes, frame, terminal_area); match app.mode { Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()), diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 7b12177b..8bcdf184 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -286,10 +286,7 @@ fn render_header_status(app: &AppState, frame: &mut Frame, area: Rect) { Span::styled(dot, dot_style.bg(p.panel_bg)), Span::raw(" "), Span::styled( - truncate( - &ws.display_name_from(&app.terminals, &app.terminal_runtimes), - name_w.saturating_sub(4) as usize, - ), + truncate(&ws.display_name(), name_w.saturating_sub(4) as usize), Style::default() .fg(p.text) .bg(p.panel_bg) @@ -440,10 +437,7 @@ fn render_mobile_switcher_content(app: &AppState, frame: &mut Frame, viewport: R Span::styled(dot, dot_style.bg(bg)), Span::styled(" ", Style::default().bg(bg)), Span::styled( - truncate( - &ws.display_name_from(&app.terminals, &app.terminal_runtimes), - content.width.saturating_sub(5) as usize, - ), + truncate(&ws.display_name(), content.width.saturating_sub(5) as usize), Style::default() .fg(p.text) .bg(bg) diff --git a/src/ui/panes.rs b/src/ui/panes.rs index 550d34ce..a1729cd6 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -11,7 +11,7 @@ use super::widgets::panel_contrast_fg; use crate::app::state::Palette; use crate::app::{AppState, Mode}; use crate::layout::PaneInfo; -use crate::terminal::TerminalRuntime; +use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry}; pub(crate) fn pane_is_scrolled_back(rt: &TerminalRuntime) -> bool { rt.scroll_metrics() @@ -64,7 +64,7 @@ fn pane_inner_rect(area: Rect, framed: bool) -> Rect { } fn runtime_for_tab_pane<'a>( - app: &'a AppState, + terminal_runtimes: &'a TerminalRuntimeRegistry, tab: &'a crate::workspace::Tab, pane_id: crate::layout::PaneId, ) -> Option<(&'a crate::terminal::TerminalId, &'a TerminalRuntime)> { @@ -73,7 +73,7 @@ fn runtime_for_tab_pane<'a>( if let Some(runtime) = tab.runtimes.get(&pane_id) { return Some((terminal_id, runtime)); } - app.terminal_runtimes + terminal_runtimes .get(terminal_id) .map(|runtime| (terminal_id, runtime)) } @@ -100,6 +100,7 @@ fn stable_scrollbar_gutter(rt: &TerminalRuntime, pane_inner: Rect) -> (Rect, Opt /// Resize every visible runtime in a tab to the geometry it would receive if the tab were selected. pub(super) fn resize_tab_panes( app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, tab: &crate::workspace::Tab, area: Rect, cell_size: crate::kitty_graphics::HostCellSize, @@ -108,7 +109,7 @@ pub(super) fn resize_tab_panes( if tab.zoomed { let focused_id = tab.layout.focused(); - if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, tab, focused_id) { + if let Some((terminal_id, rt)) = runtime_for_tab_pane(terminal_runtimes, tab, focused_id) { let pane_inner = pane_inner_rect(area, multi_pane); let inner_rect = stable_terminal_inner_rect(pane_inner); if !app.direct_attach_resize_locks.contains(terminal_id) { @@ -130,7 +131,7 @@ pub(super) fn resize_tab_panes( area }; - if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, tab, info.id) { + if let Some((terminal_id, rt)) = runtime_for_tab_pane(terminal_runtimes, tab, info.id) { let inner_rect = stable_terminal_inner_rect(pane_inner); if !app.direct_attach_resize_locks.contains(terminal_id) { rt.resize( @@ -147,6 +148,7 @@ pub(super) fn resize_tab_panes( /// Compute pane layout info and optionally resize pane runtimes to match. pub(super) fn compute_pane_infos( app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, @@ -166,7 +168,7 @@ pub(super) fn compute_pane_infos( let pane_inner = pane_inner_rect(area, multi_pane); let mut inner_rect = pane_inner; let mut scrollbar_rect = None; - if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, focused_id) { + if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, focused_id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner); if resize_panes && ws.terminal_id(focused_id).is_some_and(|terminal_id| { @@ -209,7 +211,7 @@ pub(super) fn compute_pane_infos( let mut inner_rect = pane_inner; let mut scrollbar_rect = None; - if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) { + if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner); if resize_panes && ws.terminal_id(info.id).is_some_and(|terminal_id| { @@ -232,7 +234,12 @@ pub(super) fn compute_pane_infos( pane_infos } -pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { +pub(super) fn render_panes( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + frame: &mut Frame, + area: Rect, +) { let Some(ws_idx) = app.active else { render_empty(app, frame, area); return; @@ -246,7 +253,7 @@ pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { let terminal_active = app.mode == Mode::Terminal; for info in &app.view.pane_infos { - if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) { + if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { if multi_pane { let (border_style, border_set) = if info.is_focused && terminal_active { ( @@ -404,8 +411,10 @@ mod tests { app.active = Some(0); let area = Rect::new(10, 3, 40, 8); + let terminal_runtimes = TerminalRuntimeRegistry::new(); let infos = compute_pane_infos( &app, + &terminal_runtimes, area, false, crate::kitty_graphics::HostCellSize::default(), @@ -431,8 +440,10 @@ mod tests { app.active = Some(0); let area = Rect::new(10, 3, 40, 8); + let terminal_runtimes = TerminalRuntimeRegistry::new(); let infos = compute_pane_infos( &app, + &terminal_runtimes, area, false, crate::kitty_graphics::HostCellSize::default(), @@ -458,8 +469,10 @@ mod tests { app.active = Some(0); let area = Rect::new(10, 3, 40, 8); + let terminal_runtimes = TerminalRuntimeRegistry::new(); let infos = compute_pane_infos( &app, + &terminal_runtimes, area, false, crate::kitty_graphics::HostCellSize::default(), @@ -485,8 +498,10 @@ mod tests { app.active = Some(0); let area = Rect::new(10, 3, 4, 8); + let terminal_runtimes = TerminalRuntimeRegistry::new(); let infos = compute_pane_infos( &app, + &terminal_runtimes, area, false, crate::kitty_graphics::HostCellSize::default(), @@ -516,8 +531,10 @@ mod tests { app.active = Some(0); let area = Rect::new(10, 3, 40, 8); + let terminal_runtimes = TerminalRuntimeRegistry::new(); let infos = compute_pane_infos( &app, + &terminal_runtimes, area, false, crate::kitty_graphics::HostCellSize::default(), diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 1d722d2a..a2432716 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -138,7 +138,7 @@ pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { .enumerate() .flat_map(|(ws_idx, ws)| { let multi_tab = ws.tabs.len() > 1; - let workspace_label = ws.display_name_from(&app.terminals, &app.terminal_runtimes); + let workspace_label = ws.display_name(); ws.pane_details(&app.terminals) .into_iter() .map(move |detail| AgentPanelEntry { @@ -647,10 +647,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig Span::styled(" ", Style::default()), Span::styled(icon, icon_style), Span::styled(" ", Style::default()), - Span::styled( - ws.display_name_from(&app.terminals, &app.terminal_runtimes), - name_style, - ), + Span::styled(ws.display_name(), name_style), ]; frame.render_widget( diff --git a/src/update.rs b/src/update.rs index 92d83250..98943424 100644 --- a/src/update.rs +++ b/src/update.rs @@ -420,7 +420,7 @@ fn client_protocol_server_is_running_at(socket_path: &Path) -> bool { } fn client_protocol_server_is_running() -> bool { - client_protocol_server_is_running_at(&crate::server::headless::client_socket_path()) + client_protocol_server_is_running_at(&crate::server::socket_paths::client_socket_path()) } fn read_running_server_info() -> Result<Option<crate::api::RuntimeStatus>, String> { diff --git a/src/workspace.rs b/src/workspace.rs index 47fa9cf7..70ff97f5 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -13,7 +13,7 @@ use crate::layout::PaneId; #[cfg(test)] use crate::layout::TileLayout; use crate::pane::PaneState; -use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; +use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState}; mod aggregate; mod git; @@ -536,7 +536,7 @@ impl Workspace { pub fn resolved_identity_cwd_from( &self, terminals: &HashMap<TerminalId, TerminalState>, - terminal_runtimes: &HashMap<TerminalId, TerminalRuntime>, + terminal_runtimes: &TerminalRuntimeRegistry, ) -> Option<PathBuf> { self.tabs .first() @@ -557,7 +557,7 @@ impl Workspace { pub fn display_name_from( &self, terminals: &HashMap<TerminalId, TerminalState>, - terminal_runtimes: &HashMap<TerminalId, TerminalRuntime>, + terminal_runtimes: &TerminalRuntimeRegistry, ) -> String { if let Some(name) = &self.custom_name { return name.clone(); @@ -765,7 +765,7 @@ mod tests { terminal_id.clone(), TerminalState::new(terminal_id, PathBuf::from("/herdr-test/pion")), ); - let terminal_runtimes = HashMap::new(); + let terminal_runtimes = TerminalRuntimeRegistry::new(); assert_eq!(ws.display_name_from(&terminals, &terminal_runtimes), "pion"); assert_eq!( diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 3616b95c..d5ebe00f 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -9,7 +9,7 @@ use tokio::sync::{mpsc, Notify}; use crate::events::AppEvent; use crate::layout::{PaneId, TileLayout}; use crate::pane::PaneState; -use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; +use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState}; pub(crate) type DetachedPane = (PaneId, TerminalId); @@ -393,7 +393,7 @@ impl Tab { &self, pane_id: PaneId, terminals: &HashMap<TerminalId, TerminalState>, - terminal_runtimes: &HashMap<TerminalId, TerminalRuntime>, + terminal_runtimes: &TerminalRuntimeRegistry, ) -> Option<PathBuf> { let terminal_id = self.terminal_id(pane_id)?; terminal_runtimes |
