aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 20:19:54 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 20:22:57 +0300
commit866aaf740e10fcfce169aaf81380ea0257fbe272 (patch)
treef815392b7c6b24a7c0de14e655bd635656383813 /src
parentca579c402d5668560ca13059f82c03178be18b2a (diff)
refactor: separate server client boundaries
Diffstat (limited to 'src')
-rw-r--r--src/api/client.rs245
-rw-r--r--src/api/event_hub.rs39
-rw-r--r--src/api/mod.rs1356
-rw-r--r--src/api/server.rs769
-rw-r--r--src/api/status.rs54
-rw-r--r--src/api/subscriptions.rs368
-rw-r--r--src/api/wait.rs124
-rw-r--r--src/app/actions.rs72
-rw-r--r--src/app/agents.rs7
-rw-r--r--src/app/api.rs1339
-rw-r--r--src/app/api/agents.rs127
-rw-r--r--src/app/api/integrations.rs46
-rw-r--r--src/app/api/panes.rs353
-rw-r--r--src/app/api/responses.rs19
-rw-r--r--src/app/api/tabs.rs233
-rw-r--r--src/app/api/workspaces.rs168
-rw-r--r--src/app/creation.rs25
-rw-r--r--src/app/input/mod.rs33
-rw-r--r--src/app/input/modal.rs19
-rw-r--r--src/app/input/mouse.rs194
-rw-r--r--src/app/input/navigate.rs68
-rw-r--r--src/app/input/selection.rs72
-rw-r--r--src/app/input/terminal.rs84
-rw-r--r--src/app/mod.rs61
-rw-r--r--src/app/runtime.rs33
-rw-r--r--src/app/session.rs2
-rw-r--r--src/app/state.rs61
-rw-r--r--src/app/terminal_targets.rs6
-rw-r--r--src/app/theme_sync.rs2
-rw-r--r--src/cli.rs1664
-rw-r--r--src/cli/agent.rs429
-rw-r--r--src/cli/integration.rs142
-rw-r--r--src/cli/pane.rs410
-rw-r--r--src/cli/server.rs48
-rw-r--r--src/cli/status.rs299
-rw-r--r--src/cli/tab.rs194
-rw-r--r--src/cli/workspace.rs163
-rw-r--r--src/client/mod.rs12
-rw-r--r--src/input/encode.rs4
-rw-r--r--src/kitty_graphics.rs18
-rw-r--r--src/main.rs1
-rw-r--r--src/persist/snapshot.rs19
-rw-r--r--src/platform/linux.rs2
-rw-r--r--src/platform/macos.rs2
-rw-r--r--src/protocol/mod.rs6
-rw-r--r--src/protocol/render_ansi.rs (renamed from src/client/blit.rs)4
-rw-r--r--src/protocol/wire.rs (renamed from src/server/protocol.rs)0
-rw-r--r--src/remote.rs114
-rw-r--r--src/server/autodetect.rs6
-rw-r--r--src/server/client_accept.rs50
-rw-r--r--src/server/client_transport.rs2
-rw-r--r--src/server/clients.rs208
-rw-r--r--src/server/headless.rs573
-rw-r--r--src/server/keybindings.rs17
-rw-r--r--src/server/mod.rs7
-rw-r--r--src/server/notifications.rs60
-rw-r--r--src/server/render_stream.rs39
-rw-r--r--src/server/socket_paths.rs180
-rw-r--r--src/server/terminal_attach.rs14
-rw-r--r--src/terminal/mod.rs11
-rw-r--r--src/terminal/runtime.rs312
-rw-r--r--src/terminal/runtime_registry.rs54
-rw-r--r--src/ui.rs70
-rw-r--r--src/ui/mobile.rs10
-rw-r--r--src/ui/panes.rs35
-rw-r--r--src/ui/sidebar.rs7
-rw-r--r--src/update.rs2
-rw-r--r--src/workspace.rs8
-rw-r--r--src/workspace/tab.rs4
69 files changed, 6074 insertions, 5105 deletions
diff --git a/src/api/client.rs b/src/api/client.rs
new file mode 100644
index 00000000..7929f937
--- /dev/null
+++ b/src/api/client.rs
@@ -0,0 +1,245 @@
+use std::fmt;
+use std::io::{self, BufRead, BufReader, Write};
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+use std::time::Duration;
+
+use serde::de::DeserializeOwned;
+
+use crate::api::schema::{
+ ErrorResponse, EventsSubscribeParams, Method, PingParams, Request, ResponseResult,
+ SubscriptionEventEnvelope, SuccessResponse,
+};
+
+/// API connection target resolved by clients at the process edge.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ConnectionTarget {
+ LocalSession(Option<String>),
+ SocketPath(PathBuf),
+}
+
+impl ConnectionTarget {
+ fn socket_path(&self) -> PathBuf {
+ match self {
+ Self::LocalSession(None) => crate::api::socket_path(),
+ Self::LocalSession(Some(name)) => crate::session::api_socket_path_for(Some(name)),
+ Self::SocketPath(path) => path.clone(),
+ }
+ }
+}
+
+/// Reusable client for Herdr's newline-delimited JSON API.
+#[derive(Debug, Clone)]
+pub struct ApiClient {
+ target: ConnectionTarget,
+}
+
+impl ApiClient {
+ pub fn local() -> Self {
+ Self::for_target(ConnectionTarget::LocalSession(None))
+ }
+
+ pub fn for_target(target: ConnectionTarget) -> Self {
+ Self { target }
+ }
+
+ pub fn socket_path(&self) -> PathBuf {
+ self.target.socket_path()
+ }
+
+ pub fn request(&self, request: Request) -> Result<SuccessResponse, ApiClientError> {
+ let value = self.request_value(&request)?;
+ parse_response_value(value)
+ }
+
+ pub fn request_value(&self, request: &Request) -> Result<serde_json::Value, ApiClientError> {
+ let mut stream = self.connect()?;
+ write_request(&mut stream, request)?;
+
+ let mut reader = BufReader::new(stream);
+ read_json_line(&mut reader)
+ }
+
+ pub fn request_value_with_timeout(
+ &self,
+ request: &Request,
+ timeout: Duration,
+ ) -> Result<serde_json::Value, ApiClientError> {
+ let mut stream = self.connect()?;
+ stream.set_write_timeout(Some(timeout))?;
+ stream.set_read_timeout(Some(timeout))?;
+ write_request(&mut stream, request)?;
+
+ let mut reader = BufReader::new(stream);
+ read_json_line(&mut reader)
+ }
+
+ #[allow(dead_code)] // Kept as the typed subscription API; CLI wait paths use subscribe_value to preserve raw ack errors.
+ pub fn subscribe(
+ &self,
+ id: impl Into<String>,
+ params: EventsSubscribeParams,
+ read_timeout: Option<Duration>,
+ ) -> Result<(SuccessResponse, EventStream), ApiClientError> {
+ let request = Request {
+ id: id.into(),
+ method: Method::EventsSubscribe(params),
+ };
+ let (ack, stream) = self.subscribe_value(&request, read_timeout)?;
+ Ok((parse_response_value(ack)?, stream))
+ }
+
+ pub fn subscribe_value(
+ &self,
+ request: &Request,
+ read_timeout: Option<Duration>,
+ ) -> Result<(serde_json::Value, EventStream), ApiClientError> {
+ let mut stream = self.connect()?;
+ write_request(&mut stream, request)?;
+ if let Some(timeout) = read_timeout {
+ stream.set_read_timeout(Some(timeout))?;
+ }
+
+ let mut reader = BufReader::new(stream);
+ let ack = read_json_line(&mut reader)?;
+ Ok((ack, EventStream { reader }))
+ }
+
+ pub fn status(&self) -> Result<crate::api::RuntimeStatus, ApiClientError> {
+ let response = self.request(Request {
+ id: "api-client:status".into(),
+ method: Method::Ping(PingParams::default()),
+ })?;
+ match response.result {
+ ResponseResult::Pong { version, protocol } => Ok(crate::api::RuntimeStatus {
+ version: Some(version),
+ protocol: Some(protocol),
+ }),
+ result => Err(ApiClientError::UnexpectedResult(format!("{result:?}"))),
+ }
+ }
+
+ fn connect(&self) -> io::Result<UnixStream> {
+ UnixStream::connect(self.socket_path())
+ }
+}
+
+pub struct EventStream {
+ reader: BufReader<UnixStream>,
+}
+
+impl EventStream {
+ pub fn next_value(&mut self) -> Result<Option<serde_json::Value>, ApiClientError> {
+ read_optional_json_line(&mut self.reader)
+ }
+
+ pub fn next_event(&mut self) -> Result<Option<SubscriptionEventEnvelope>, ApiClientError> {
+ self.next_value()?
+ .map(serde_json::from_value)
+ .transpose()
+ .map_err(ApiClientError::Json)
+ }
+}
+
+#[derive(Debug)]
+pub enum ApiClientError {
+ Io(io::Error),
+ Json(serde_json::Error),
+ ErrorResponse(ErrorResponse),
+ EmptyResponse,
+ UnexpectedResult(String),
+}
+
+impl fmt::Display for ApiClientError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Io(err) => write!(f, "{err}"),
+ Self::Json(err) => write!(f, "{err}"),
+ Self::ErrorResponse(response) => write!(f, "{}", response.error.message),
+ Self::EmptyResponse => write!(f, "empty api response"),
+ Self::UnexpectedResult(result) => write!(f, "unexpected api result: {result}"),
+ }
+ }
+}
+
+impl std::error::Error for ApiClientError {}
+
+impl From<io::Error> for ApiClientError {
+ fn from(err: io::Error) -> Self {
+ Self::Io(err)
+ }
+}
+
+impl From<serde_json::Error> for ApiClientError {
+ fn from(err: serde_json::Error) -> Self {
+ Self::Json(err)
+ }
+}
+
+fn write_request(stream: &mut UnixStream, request: &Request) -> Result<(), ApiClientError> {
+ stream.write_all(serde_json::to_string(request)?.as_bytes())?;
+ stream.write_all(b"\n")?;
+ stream.flush()?;
+ Ok(())
+}
+
+fn read_json_line<T: DeserializeOwned>(
+ reader: &mut BufReader<UnixStream>,
+) -> Result<T, ApiClientError> {
+ let mut line = String::new();
+ let read = reader.read_line(&mut line)?;
+ if read == 0 || line.trim().is_empty() {
+ return Err(ApiClientError::EmptyResponse);
+ }
+ serde_json::from_str(&line).map_err(ApiClientError::Json)
+}
+
+fn read_optional_json_line<T: DeserializeOwned>(
+ reader: &mut BufReader<UnixStream>,
+) -> Result<Option<T>, ApiClientError> {
+ let mut line = String::new();
+ let read = reader.read_line(&mut line)?;
+ if read == 0 {
+ return Ok(None);
+ }
+ if line.trim().is_empty() {
+ return Err(ApiClientError::EmptyResponse);
+ }
+ serde_json::from_str(&line)
+ .map(Some)
+ .map_err(ApiClientError::Json)
+}
+
+#[derive(serde::Deserialize)]
+#[serde(untagged)]
+enum WireResponse {
+ Success(Box<SuccessResponse>),
+ Error(ErrorResponse),
+}
+
+pub(crate) fn parse_response_value(
+ value: serde_json::Value,
+) -> Result<SuccessResponse, ApiClientError> {
+ match serde_json::from_value(value)? {
+ WireResponse::Success(response) => Ok(*response),
+ WireResponse::Error(response) => Err(ApiClientError::ErrorResponse(response)),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn local_session_target_resolves_named_session_socket() {
+ let client = ApiClient::for_target(ConnectionTarget::LocalSession(Some("work".into())));
+ assert!(client.socket_path().ends_with("sessions/work/herdr.sock"));
+ }
+
+ #[test]
+ fn socket_path_target_uses_explicit_path() {
+ let path = PathBuf::from("/tmp/herdr-test.sock");
+ let client = ApiClient::for_target(ConnectionTarget::SocketPath(path.clone()));
+ assert_eq!(client.socket_path(), path);
+ }
+}
diff --git a/src/api/event_hub.rs b/src/api/event_hub.rs
new file mode 100644
index 00000000..b66a2157
--- /dev/null
+++ b/src/api/event_hub.rs
@@ -0,0 +1,39 @@
+#[derive(Clone, Default)]
+pub struct EventHub {
+ inner: std::sync::Arc<std::sync::Mutex<EventHubState>>,
+}
+
+#[derive(Default)]
+struct EventHubState {
+ next_sequence: u64,
+ events: Vec<(u64, crate::api::schema::EventEnvelope)>,
+}
+
+impl EventHub {
+ const MAX_EVENTS: usize = 512;
+
+ pub fn push(&self, event: crate::api::schema::EventEnvelope) {
+ let Ok(mut state) = self.inner.lock() else {
+ return;
+ };
+ state.next_sequence += 1;
+ let sequence = state.next_sequence;
+ state.events.push((sequence, event));
+ let overflow = state.events.len().saturating_sub(Self::MAX_EVENTS);
+ if overflow > 0 {
+ state.events.drain(0..overflow);
+ }
+ }
+
+ pub fn events_after(&self, sequence: u64) -> Vec<(u64, crate::api::schema::EventEnvelope)> {
+ let Ok(state) = self.inner.lock() else {
+ return Vec::new();
+ };
+ state
+ .events
+ .iter()
+ .filter(|(event_sequence, _)| *event_sequence > sequence)
+ .cloned()
+ .collect()
+ }
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 4893c295..34b37138 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -1,32 +1,23 @@
+pub mod client;
+mod event_hub;
pub mod schema;
+mod server;
+mod status;
+mod subscriptions;
+mod wait;
-use std::fs;
-use std::io::{self, BufRead, BufReader, Read, Write};
-use std::os::unix::net::{UnixListener, UnixStream};
-use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::Arc;
-use std::time::Duration;
+pub use event_hub::EventHub;
+pub use server::start_server;
+pub use status::{read_runtime_status_at, RuntimeStatus};
-use tracing::{debug, error, info, warn};
+use std::path::PathBuf;
-use regex::Regex;
use tokio::sync::mpsc;
-use crate::api::schema::{
- ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, Request,
- ResponseResult, Subscription, SubscriptionEventData, SubscriptionEventEnvelope,
- SubscriptionEventKind, SuccessResponse,
-};
+use crate::api::schema::{Method, Request};
pub const SOCKET_PATH_ENV_VAR: &str = "HERDR_SOCKET_PATH";
-const SOCKET_PERMISSION_MODE: u32 = 0o600;
-const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100);
-const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
-const INITIAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
-const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
-
pub(crate) fn request_changes_ui(request: &Request) -> bool {
matches!(
&request.method,
@@ -58,1331 +49,6 @@ pub struct ApiRequestMessage {
pub type ApiRequestSender = mpsc::UnboundedSender<ApiRequestMessage>;
-#[derive(Clone, Default)]
-pub struct EventHub {
- inner: std::sync::Arc<std::sync::Mutex<EventHubState>>,
-}
-
-#[derive(Default)]
-struct EventHubState {
- next_sequence: u64,
- events: Vec<(u64, crate::api::schema::EventEnvelope)>,
-}
-
-impl EventHub {
- const MAX_EVENTS: usize = 512;
-
- pub fn push(&self, event: crate::api::schema::EventEnvelope) {
- let Ok(mut state) = self.inner.lock() else {
- return;
- };
- state.next_sequence += 1;
- let sequence = state.next_sequence;
- state.events.push((sequence, event));
- let overflow = state.events.len().saturating_sub(Self::MAX_EVENTS);
- if overflow > 0 {
- state.events.drain(0..overflow);
- }
- }
-
- pub fn events_after(&self, sequence: u64) -> Vec<(u64, crate::api::schema::EventEnvelope)> {
- let Ok(state) = self.inner.lock() else {
- return Vec::new();
- };
- state
- .events
- .iter()
- .filter(|(event_sequence, _)| *event_sequence > sequence)
- .cloned()
- .collect()
- }
-}
-
pub fn socket_path() -> PathBuf {
crate::session::active_api_socket_path()
}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RuntimeStatus {
- pub version: Option<String>,
- pub protocol: Option<u32>,
-}
-
-pub fn read_runtime_status_at(
- socket_path: &Path,
- timeout: Duration,
-) -> io::Result<Option<RuntimeStatus>> {
- if !socket_path.exists() {
- return Ok(None);
- }
-
- let mut stream = match UnixStream::connect(socket_path) {
- Ok(stream) => stream,
- Err(err)
- if matches!(
- err.kind(),
- io::ErrorKind::ConnectionRefused
- | io::ErrorKind::NotFound
- | io::ErrorKind::TimedOut
- ) =>
- {
- return Ok(None);
- }
- Err(err) => return Err(err),
- };
-
- stream.set_write_timeout(Some(timeout))?;
- stream.set_read_timeout(Some(timeout))?;
-
- let request = Request {
- id: "runtime:status".into(),
- method: Method::Ping(crate::api::schema::PingParams::default()),
- };
- stream.write_all(serde_json::to_string(&request)?.as_bytes())?;
- stream.write_all(b"\n")?;
- stream.flush()?;
-
- let mut reader = BufReader::new(stream);
- let mut line = String::new();
- let read = reader.read_line(&mut line)?;
- if read == 0 || line.trim().is_empty() {
- return Err(io::Error::new(
- io::ErrorKind::UnexpectedEof,
- "empty server status response",
- ));
- }
-
- let response: serde_json::Value = serde_json::from_str(&line).map_err(io::Error::other)?;
- if response.get("error").is_some() {
- return Err(io::Error::other(format!(
- "server status request failed: {response}"
- )));
- }
-
- let result = &response["result"];
- Ok(Some(RuntimeStatus {
- version: result
- .get("version")
- .and_then(|value| value.as_str())
- .map(str::to_owned),
- protocol: result
- .get("protocol")
- .and_then(|value| value.as_u64())
- .and_then(|value| u32::try_from(value).ok()),
- }))
-}
-
-pub struct ServerHandle {
- _thread: std::thread::JoinHandle<()>,
- path: PathBuf,
- running: Arc<AtomicBool>,
-}
-
-impl Drop for ServerHandle {
- fn drop(&mut self) {
- self.running.store(false, Ordering::Relaxed);
-
- if let Err(err) = fs::remove_file(&self.path) {
- if err.kind() != std::io::ErrorKind::NotFound {
- warn!(path = %self.path.display(), err = %err, "failed to remove api socket on shutdown");
- }
- }
- }
-}
-
-pub fn start_server(
- api_tx: ApiRequestSender,
- event_hub: EventHub,
-) -> std::io::Result<ServerHandle> {
- let path = socket_path();
- prepare_socket_path(&path)?;
-
- let listener = UnixListener::bind(&path)?;
- restrict_socket_permissions(&path)?;
- info!(path = %path.display(), "api server listening");
-
- let running = Arc::new(AtomicBool::new(true));
- let listener_running = Arc::clone(&running);
- let thread = std::thread::spawn(move || {
- for stream in listener.incoming() {
- match stream {
- Ok(stream) => {
- let api_tx = api_tx.clone();
- let event_hub = event_hub.clone();
- let connection_running = Arc::clone(&listener_running);
- std::thread::spawn(move || {
- if let Err(err) =
- handle_connection(stream, &api_tx, &event_hub, &connection_running)
- {
- warn!(err = %err, "api connection failed");
- }
- });
- }
- Err(err) => {
- error!(err = %err, "api listener accept failed");
- break;
- }
- }
- }
- debug!("api server thread exiting");
- });
-
- Ok(ServerHandle {
- _thread: thread,
- path,
- running,
- })
-}
-
-fn prepare_socket_path(path: &Path) -> std::io::Result<()> {
- crate::ipc::prepare_socket_path(path, |path| {
- format!(
- "herdr is already running (socket busy at {})",
- path.display()
- )
- })
-}
-
-fn restrict_socket_permissions(path: &Path) -> std::io::Result<()> {
- crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE)
-}
-
-fn handle_connection(
- mut stream: UnixStream,
- api_tx: &ApiRequestSender,
- event_hub: &EventHub,
- running: &Arc<AtomicBool>,
-) -> std::io::Result<()> {
- stream.set_read_timeout(Some(INITIAL_REQUEST_TIMEOUT))?;
- stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT))?;
-
- let mut line = String::new();
- {
- let mut reader = BufReader::new(&stream);
- let read = reader.read_line(&mut line)?;
- if read == 0 {
- return Ok(());
- }
- }
-
- stream.set_read_timeout(None)?;
-
- let line = line.trim();
- if line.is_empty() {
- return Ok(());
- }
-
- let request = match serde_json::from_str::<Request>(line) {
- Ok(request) => request,
- Err(err) => {
- write_json_line_allow_disconnect(
- &mut stream,
- &ErrorResponse {
- id: String::new(),
- error: ErrorBody {
- code: "invalid_request".into(),
- message: format!("invalid request: {err}"),
- },
- },
- )?;
- return Ok(());
- }
- };
-
- let request_id = request.id.clone();
- let method = api_method_name(&request.method);
- let changes_ui = request_changes_ui(&request);
- crate::logging::api_request_started(&request_id, method, changes_ui);
-
- match request.method {
- Method::EventsSubscribe(params) => {
- let result = stream_subscriptions(
- stream,
- request_id.clone(),
- params,
- api_tx,
- event_hub,
- running,
- );
- match &result {
- Ok(()) => crate::logging::api_request_completed(
- &request_id,
- method,
- "stream_closed",
- changes_ui,
- ),
- Err(err) => {
- crate::logging::api_request_failed(&request_id, method, &err.to_string())
- }
- }
- result
- }
- Method::PaneWaitForOutput(params) => {
- let Some(response) =
- wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)?
- else {
- crate::logging::api_request_completed(
- &request_id,
- method,
- "client_disconnected",
- changes_ui,
- );
- return Ok(());
- };
- let result = write_text_line_allow_disconnect(&mut stream, &response);
- match &result {
- Ok(()) => crate::logging::api_request_completed(
- &request_id,
- method,
- api_response_outcome(&response),
- changes_ui,
- ),
- Err(err) => {
- crate::logging::api_request_failed(&request_id, method, &err.to_string())
- }
- }
- result
- }
- method_body => {
- let response = handle_request(
- Request {
- id: request_id.clone(),
- method: method_body,
- },
- api_tx,
- );
- let result = write_text_line_allow_disconnect(&mut stream, &response);
- match &result {
- Ok(()) => crate::logging::api_request_completed(
- &request_id,
- method,
- api_response_outcome(&response),
- changes_ui,
- ),
- Err(err) => {
- crate::logging::api_request_failed(&request_id, method, &err.to_string())
- }
- }
- result
- }
- }
-}
-
-fn handle_request(request: Request, api_tx: &ApiRequestSender) -> String {
- match request.method {
- Method::Ping(_) => serde_json::to_string(&SuccessResponse {
- id: request.id,
- result: ResponseResult::Pong {
- version: env!("CARGO_PKG_VERSION").into(),
- protocol: crate::server::protocol::PROTOCOL_VERSION,
- },
- })
- .unwrap_or_else(|_| {
- r#"{"id":"","error":{"code":"internal_error","message":"failed to encode response"}}"#
- .to_string()
- }),
- _ => dispatch_to_app(request, api_tx),
- }
-}
-
-fn api_method_name(method: &Method) -> &'static str {
- match method {
- Method::Ping(_) => "ping",
- Method::ServerStop(_) => "server.stop",
- Method::ServerReloadConfig(_) => "server.reload_config",
- Method::WorkspaceCreate(_) => "workspace.create",
- Method::WorkspaceList(_) => "workspace.list",
- Method::WorkspaceGet(_) => "workspace.get",
- Method::WorkspaceFocus(_) => "workspace.focus",
- Method::WorkspaceRename(_) => "workspace.rename",
- Method::WorkspaceClose(_) => "workspace.close",
- Method::TabCreate(_) => "tab.create",
- Method::TabList(_) => "tab.list",
- Method::TabGet(_) => "tab.get",
- Method::TabFocus(_) => "tab.focus",
- Method::TabRename(_) => "tab.rename",
- Method::TabClose(_) => "tab.close",
- Method::AgentList(_) => "agent.list",
- Method::AgentGet(_) => "agent.get",
- Method::AgentRead(_) => "agent.read",
- Method::AgentSend(_) => "agent.send",
- Method::AgentRename(_) => "agent.rename",
- Method::AgentFocus(_) => "agent.focus",
- Method::AgentStart(_) => "agent.start",
- Method::PaneSplit(_) => "pane.split",
- Method::PaneList(_) => "pane.list",
- Method::PaneGet(_) => "pane.get",
- Method::PaneRename(_) => "pane.rename",
- Method::PaneSendText(_) => "pane.send_text",
- Method::PaneSendKeys(_) => "pane.send_keys",
- Method::PaneSendInput(_) => "pane.send_input",
- Method::PaneRead(_) => "pane.read",
- Method::PaneReportAgent(_) => "pane.report_agent",
- Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority",
- Method::PaneReleaseAgent(_) => "pane.release_agent",
- Method::PaneClose(_) => "pane.close",
- Method::EventsSubscribe(_) => "events.subscribe",
- Method::EventsWait(_) => "events.wait",
- Method::PaneWaitForOutput(_) => "pane.wait_for_output",
- Method::IntegrationInstall(_) => "integration.install",
- Method::IntegrationUninstall(_) => "integration.uninstall",
- }
-}
-
-fn api_response_outcome(response: &str) -> &'static str {
- let Ok(value) = serde_json::from_str::<serde_json::Value>(response) else {
- return "error";
- };
-
- match value
- .get("error")
- .and_then(|error| error.get("code"))
- .and_then(|code| code.as_str())
- {
- Some("timeout") => "timeout",
- Some(_) => "error",
- None => "ok",
- }
-}
-
-fn output_match_read_source(
- source: &crate::api::schema::ReadSource,
-) -> crate::api::schema::ReadSource {
- match source {
- crate::api::schema::ReadSource::Recent => crate::api::schema::ReadSource::RecentUnwrapped,
- other => *other,
- }
-}
-
-fn wait_for_output(
- request_id: String,
- params: crate::api::schema::PaneWaitForOutputParams,
- stream: &mut UnixStream,
- api_tx: &ApiRequestSender,
- running: &Arc<AtomicBool>,
-) -> std::io::Result<Option<String>> {
- crate::logging::api_wait_started(&request_id, &params.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 &params.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, &params.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(&params.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, &params.r#match, regex.as_ref());
- if matched_line.is_some() {
- let revision = read.revision;
- crate::logging::api_wait_completed(&request_id, &params.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, &params.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, &params.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 &params.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, &params.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(&params.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, &params.r#match, regex.as_ref());
+ if matched_line.is_some() {
+ let revision = read.revision;
+ crate::logging::api_wait_completed(&request_id, &params.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, &params.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(&params.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(&params.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(&params.target, params.name) {
- Ok(agent) => agent,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: self.agent_rename_error_body(err),
- })
- .unwrap();
- }
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::AgentInfo { agent },
- }
- }
- Method::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(&params.target) {
- Ok(resolved) => resolved,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: self.agent_target_error_body(err),
- })
- .unwrap();
- }
- };
- let Some((pane, workspace_id)) =
- self.lookup_runtime(resolved.ws_idx, resolved.pane_id)
- else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "agent_not_found".into(),
- message: format!("agent target {} not found", params.target),
- },
- })
- .unwrap();
- };
- let requested_lines = params.lines.unwrap_or(80).min(1000) as usize;
- let text = match params.format {
- ReadFormat::Text => match params.source {
- ReadSource::Visible => pane.visible_text(),
- ReadSource::Recent => pane.recent_text(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
- },
- ReadFormat::Ansi => match params.source {
- ReadSource::Visible => pane.visible_ansi(),
- ReadSource::Recent => pane.recent_ansi(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
- },
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneRead {
- read: PaneReadResult {
- pane_id: self
- .public_pane_id(resolved.ws_idx, resolved.pane_id)
- .unwrap_or_else(|| params.target.clone()),
- workspace_id,
- tab_id: self
- .public_tab_id(resolved.ws_idx, resolved.tab_idx)
- .unwrap(),
- source: params.source,
- format: params.format,
- text,
- revision: 0,
- truncated: false,
- },
- },
- }
- }
- Method::AgentSend(params) => {
- let resolved = match self.resolve_terminal_target(&params.target) {
- Ok(resolved) => resolved,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: self.agent_target_error_body(err),
- })
- .unwrap();
- }
- };
- let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id)
- else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "agent_not_found".into(),
- message: format!("agent target {} not found", params.target),
- },
- })
- .unwrap();
- };
- if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "agent_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneSplit(params) => {
- let Some((ws_idx, target_pane_id)) = self.parse_pane_id(&params.target_pane_id)
- else {
- 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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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, &params.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, &params.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(&params.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, &params.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(&params.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(&params.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, &params.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(&params.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, &params.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(&params.target_pane_id) else {
+ return pane_not_found(id, &params.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, &params.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, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.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, &params.pane_id);
+ };
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
+ return pane_not_found(id, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.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, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(agent_label) = normalize_reported_agent_label(&params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(agent_label) = normalize_reported_agent_label(&params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let encoded_keys = match encode_api_keys(runtime, &params.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, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let encoded_keys = match encode_api_keys(runtime, &params.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(&params.tab_id) else {
+ return tab_not_found(id, &params.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, &params.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(&params.workspace_id) else {
+ return workspace_not_found(id, &params.workspace_id);
+ };
+ let Some(ws) = self.state.workspaces.get_mut(index) else {
+ return workspace_not_found(id, &params.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);
}
diff --git a/src/cli.rs b/src/cli.rs
index e9fe6e81..fed93fba 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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 }
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
index 050cf194..7e5187b0 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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