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 | |
| parent | ca579c402d5668560ca13059f82c03178be18b2a (diff) | |
refactor: separate server client boundaries
73 files changed, 6113 insertions, 5108 deletions
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6de5624..9b77cc59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -298,7 +298,7 @@ jobs: git show "${GITHUB_REF_NAME}:docs/next/product-announcement.json" > "$ANNOUNCEMENT_PATH" cp "$ANNOUNCEMENT_PATH" "$ANNOUNCEMENT_ORIGINAL_PATH" python3 scripts/changelog.py validate-product-announcement --path "$ANNOUNCEMENT_PATH" - RELEASE_PROTOCOL=$(git show "${GITHUB_REF_NAME}:src/server/protocol.rs" | python3 -c 'import re, sys; match = re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", sys.stdin.read()); sys.exit(1) if match is None else print(match.group(1))') + RELEASE_PROTOCOL=$(git show "${GITHUB_REF_NAME}:src/protocol/wire.rs" | python3 -c 'import re, sys; match = re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", sys.stdin.read()); sys.exit(1) if match is None else print(match.group(1))') CURRENT_VERSION=$(python3 -c 'import json; print(json.load(open("website/latest.json")).get("version", ""))') if [ "$CURRENT_VERSION" = "$VERSION" ]; then @@ -106,7 +106,7 @@ The app update check and the in-app **What's New** flow both depend on that exac Do not edit `website/latest.json` during normal feature, fix, or test work. It describes the latest published release binaries, not the current unreleased source tree. The release workflow updates it after release assets are published. -When changing the server/client wire protocol, compare `src/server/protocol.rs::PROTOCOL_VERSION` against the latest released tag. Bump it only if the current source protocol is not already greater than the latest released protocol. Multiple unreleased wire changes in the same release cycle must share the same single protocol bump; Herdr supports tagged releases, not arbitrary `master` client/server compatibility. When a bump is required, update all hardcoded protocol expectations and manual protocol fixtures in tests. Keep protocol test expectations intentionally explicit so compatibility changes are reviewed instead of silently following the constant. +When changing the server/client wire protocol, compare `src/protocol/wire.rs::PROTOCOL_VERSION` against the latest released tag. Bump it only if the current source protocol is not already greater than the latest released protocol. Multiple unreleased wire changes in the same release cycle must share the same single protocol bump; Herdr supports tagged releases, not arbitrary `master` client/server compatibility. When a bump is required, update all hardcoded protocol expectations and manual protocol fixtures in tests. Keep protocol test expectations intentionally explicit so compatibility changes are reviewed instead of silently following the constant. ## External contributor guardrail diff --git a/scripts/changelog.py b/scripts/changelog.py index 91c93f6e..1e7d2738 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -18,7 +18,7 @@ VERSION_WITH_DATE_RE = re.compile(r"^(?P<version>.+?)\s+-\s+\d{4}-\d{2}-\d{2}$") DEFAULT_RELEASE_REPO = "ogulcancelik/herdr" DEFAULT_LATEST_JSON_PATH = Path("website/latest.json") DEFAULT_PRODUCT_ANNOUNCEMENT_PATH = Path("docs/next/product-announcement.json") -PROTOCOL_SOURCE_PATH = Path("src/server/protocol.rs") +PROTOCOL_SOURCE_PATH = Path("src/protocol/wire.rs") ASSET_TARGETS = ( "linux-x86_64", "linux-aarch64", 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 diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index efe730ec..93d0161f 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -1021,6 +1021,32 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); + let full_json = run_cli_json(&socket_path, &["status", "--json"]); + assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(full_json["client"]["protocol"], 9); + assert_eq!(full_json["server"]["status"], "running"); + assert_eq!(full_json["server"]["running"], true); + assert_eq!(full_json["server"]["compatible"], true); + assert_eq!( + full_json["server"]["socket"], + socket_path.display().to_string() + ); + assert_eq!(full_json["server"]["restart_needed"], false); + assert_eq!(full_json["update"]["restart_needed"], false); + + let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); + assert_eq!(server_json["status"], "running"); + assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(server_json["protocol"], 9); + assert_eq!(server_json["compatible"], true); + + let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); + assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(client_json["protocol"], 9); + assert!(client_json["binary"] + .as_str() + .is_some_and(|path| !path.is_empty())); + cleanup_spawned_herdr(herdr, base); } @@ -1042,6 +1068,16 @@ fn status_reports_not_running_when_server_socket_is_missing() { "stdout: {stdout}" ); + let status_json = run_cli_json(&socket_path, &["status", "--json"]); + assert_eq!(status_json["server"]["status"], "not_running"); + assert_eq!(status_json["server"]["running"], false); + assert_eq!( + status_json["server"]["socket"], + socket_path.display().to_string() + ); + assert_eq!(status_json["server"]["restart_needed"], false); + assert_eq!(status_json["update"]["restart_needed"], false); + cleanup_test_base(&base); } |
