aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-06 02:27:06 +0300
committerGitHub <noreply@github.com>2026-09-06 02:27:06 +0300
commit9e9bc8a144667a6b7debfd4f0c7a31ff0e11493a (patch)
tree03f1259f91fd9edeacfd4b904f2e84d4acf25232 /src
parent35b0dff96579f099e0436290085024424ffb289c (diff)
feat: manage multiple ssh machines from one client (#3670)
Diffstat (limited to 'src')
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/api/schema/common.rs6
-rw-r--r--src/api/schema/response.rs6
-rw-r--r--src/api/schema/server.rs6
-rw-r--r--src/api/schema/tests.rs2
-rw-r--r--src/api/server.rs13
-rw-r--r--src/cli.rs2
-rw-r--r--src/cli/machine.rs295
-rw-r--r--src/cli/spec.rs19
-rw-r--r--src/cli/spec/completion.rs14
-rw-r--r--src/cli/spec/machine.rs49
-rw-r--r--src/cli/status.rs12
-rw-r--r--src/client/attach.rs3
-rw-r--r--src/client/catalog_reload.rs276
-rw-r--r--src/client/clipboard_forwarding.rs16
-rw-r--r--src/client/clipboard_images.rs22
-rw-r--r--src/client/config_reload.rs129
-rw-r--r--src/client/endpoint.rs120
-rw-r--r--src/client/endpoint/activation.rs1152
-rw-r--r--src/client/endpoint/activation/model.rs174
-rw-r--r--src/client/endpoint/activation/protocol.rs219
-rw-r--r--src/client/endpoint/activation_tests.rs1305
-rw-r--r--src/client/endpoint/catalog.rs578
-rw-r--r--src/client/endpoint/control.rs63
-rw-r--r--src/client/endpoint/health.rs99
-rw-r--r--src/client/endpoint/message_policy.rs124
-rw-r--r--src/client/endpoint/registry.rs629
-rw-r--r--src/client/endpoint/supervisor.rs492
-rw-r--r--src/client/endpoint/writer.rs400
-rw-r--r--src/client/endpoint_commands.rs484
-rw-r--r--src/client/events.rs34
-rw-r--r--src/client/handshake.rs34
-rw-r--r--src/client/loop_config.rs16
-rw-r--r--src/client/mod.rs2258
-rw-r--r--src/client/shell.rs12
-rw-r--r--src/client/shell/actions.rs75
-rw-r--r--src/client/shell/agent_sidebar.rs106
-rw-r--r--src/client/shell/aggregate_navigation.rs262
-rw-r--r--src/client/shell/composition.rs148
-rw-r--r--src/client/shell/endpoint_agent_state.rs263
-rw-r--r--src/client/shell/endpoint_agents.rs129
-rw-r--r--src/client/shell/endpoint_navigation.rs290
-rw-r--r--src/client/shell/endpoint_notices.rs80
-rw-r--r--src/client/shell/endpoint_sidebar.rs452
-rw-r--r--src/client/shell/endpoints.rs506
-rw-r--r--src/client/shell/graphics.rs5
-rw-r--r--src/client/shell/input.rs69
-rw-r--r--src/client/shell/input_source.rs41
-rw-r--r--src/client/shell/mobile.rs399
-rw-r--r--src/client/shell/mouse.rs81
-rw-r--r--src/client/shell/notification_policy.rs319
-rw-r--r--src/client/shell/notifications.rs417
-rw-r--r--src/client/shell/overlay_input.rs183
-rw-r--r--src/client/shell/overlays.rs223
-rw-r--r--src/client/shell/render.rs51
-rw-r--r--src/client/shell/settings.rs6
-rw-r--r--src/client/shell/sidebar.rs33
-rw-r--r--src/client/shell/state.rs328
-rw-r--r--src/client/shell/tests/agents_worktrees_notifications.rs8
-rw-r--r--src/client/shell/tests/copy.rs10
-rw-r--r--src/client/shell/tests/endpoint_requests.rs298
-rw-r--r--src/client/shell/tests/endpoints.rs1205
-rw-r--r--src/client/shell/tests/keybindings_settings.rs5
-rw-r--r--src/client/shell/tests/mobile.rs95
-rw-r--r--src/client/shell/tests/mod.rs4
-rw-r--r--src/client/shell/tests/popup_focus_projection.rs2
-rw-r--r--src/client/shell/tests/startup_overlays.rs1
-rw-r--r--src/client/shell/worktrees.rs61
-rw-r--r--src/client/shell_runtime.rs739
-rw-r--r--src/client/startup.rs24
-rw-r--r--src/client/state.rs231
-rw-r--r--src/client/terminal_sessions.rs13
-rw-r--r--src/client/tests/mod.rs25
-rw-r--r--src/client/transport.rs146
-rw-r--r--src/config/sidebar.rs5
-rw-r--r--src/ghostty/mod.rs12
-rw-r--r--src/ipc.rs7
-rw-r--r--src/kitty_graphics/surface.rs4
-rw-r--r--src/main.rs15
-rw-r--r--src/pane.rs4
-rw-r--r--src/pane/terminal.rs12
-rw-r--r--src/platform/client_state.rs32
-rw-r--r--src/platform/linux.rs2
-rw-r--r--src/platform/macos.rs2
-rw-r--r--src/platform/mod.rs3
-rw-r--r--src/platform/unix_common.rs19
-rw-r--r--src/platform/windows.rs42
-rw-r--r--src/protocol/endpoint.rs44
-rw-r--r--src/remote.rs6
-rw-r--r--src/remote/args.rs126
-rw-r--r--src/remote/attach.rs949
-rw-r--r--src/remote/process.rs87
-rw-r--r--src/remote/restart_policy.rs161
-rw-r--r--src/remote/saved.rs136
-rw-r--r--src/server/autodetect.rs46
-rw-r--r--src/server/client_commands.rs30
-rw-r--r--src/server/client_endpoint_control.rs24
-rw-r--r--src/server/client_shell_graphics.rs42
-rw-r--r--src/server/client_transport.rs123
-rw-r--r--src/server/clients.rs13
-rw-r--r--src/server/headless.rs217
-rw-r--r--src/server/headless/client_views.rs36
-rw-r--r--src/server/headless/endpoint_requests.rs130
-rw-r--r--src/server/headless/render.rs30
-rw-r--r--src/server/headless/retained_surface.rs113
-rw-r--r--src/server/headless/surface_interest.rs86
-rw-r--r--src/server/headless/tests/mod.rs68
-rw-r--r--src/server/headless/tests/pane_graphics.rs247
-rw-r--r--src/server/headless/tests/surface_interest.rs725
-rw-r--r--src/server/mod.rs1
-rw-r--r--src/terminal/runtime.rs4
-rw-r--r--src/ui/sidebar.rs4
-rw-r--r--src/ui/sidebar/tokens.rs35
-rw-r--r--src/update.rs6
114 files changed, 17372 insertions, 2674 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 074cd4ed..ba9f0c3a 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -69,6 +69,8 @@ pub enum Method {
ClientWindowTitleSet(ClientWindowTitleSetParams),
#[serde(rename = "client.window_title.clear")]
ClientWindowTitleClear(EmptyParams),
+ #[serde(rename = "client_shell.surface.set")]
+ ClientShellSurfaceSet(ClientShellSurfaceSetParams),
#[serde(rename = "session.snapshot")]
SessionSnapshot(EmptyParams),
#[serde(rename = "workspace.create")]
diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs
index 15f56e6c..bf0d7caf 100644
--- a/src/api/schema/common.rs
+++ b/src/api/schema/common.rs
@@ -61,6 +61,12 @@ pub struct ClientWindowTitleSetParams {
pub title: String,
}
+/// Updates whether the requesting client shell receives and controls pane presentation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+pub struct ClientShellSurfaceSetParams {
+ pub active: bool,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SplitDirection {
diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs
index 180d6645..e6d4de4d 100644
--- a/src/api/schema/response.rs
+++ b/src/api/schema/response.rs
@@ -295,6 +295,12 @@ pub enum ResponseResult {
status: crate::config::ConfigReloadStatus,
diagnostics: Vec<String>,
},
+ /// Acknowledgement for the client-shell surface interest lease. This method is new on the
+ /// endpoint protocol, so its revision-bearing result can establish an activation floor.
+ ClientShellSurfaceSet {
+ active: bool,
+ projection_revision: u64,
+ },
Ok {},
}
diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs
index 96e51564..b274bc95 100644
--- a/src/api/schema/server.rs
+++ b/src/api/schema/server.rs
@@ -21,4 +21,10 @@ pub struct ServerCapabilities {
/// Stable client-owned endpoint generation supported by this server.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint_protocol_generation: Option<u32>,
+ /// Whether this server supports explicit client-shell surface interest.
+ #[serde(default)]
+ pub surface_interest: bool,
+ /// Whether this server supports endpoint health probes.
+ #[serde(default)]
+ pub health_check: bool,
}
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index 693f0e85..b566e8f6 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -725,6 +725,8 @@ fn success_response_round_trips() {
live_handoff: true,
detached_server_daemon: true,
endpoint_protocol_generation: Some(1),
+ surface_interest: true,
+ health_check: true,
}),
},
};
diff --git a/src/api/server.rs b/src/api/server.rs
index 655d89c5..b8ef7c38 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -69,6 +69,8 @@ fn default_capabilities() -> Option<ServerCapabilities> {
live_handoff: crate::platform::capabilities().live_handoff,
detached_server_daemon: crate::platform::current_process_is_detached_server_daemon(),
endpoint_protocol_generation: Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ surface_interest: true,
+ health_check: true,
})
}
@@ -352,6 +354,14 @@ fn handle_request(
});
}
+ if matches!(&request.method, Method::ClientShellSurfaceSet(_)) {
+ return error_response_json(
+ request.id,
+ "connection_local_only",
+ "client_shell.surface.set is only available through a client shell endpoint".into(),
+ );
+ }
+
if matches!(&request.method, Method::ServerStop(_)) {
if let Some(server_stop) = server_stop {
server_stop.store(true, Ordering::Release);
@@ -386,6 +396,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str {
Method::CommandInvoke(_) => "command.invoke",
Method::ClientWindowTitleSet(_) => "client.window_title.set",
Method::ClientWindowTitleClear(_) => "client.window_title.clear",
+ Method::ClientShellSurfaceSet(_) => "client_shell.surface.set",
Method::SessionSnapshot(_) => "session.snapshot",
Method::WorkspaceCreate(_) => "workspace.create",
Method::WorkspaceList(_) => "workspace.list",
@@ -1136,6 +1147,8 @@ mod tests {
endpoint_protocol_generation: Some(
crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
),
+ surface_interest: true,
+ health_check: true,
}),
None,
None,
diff --git a/src/cli.rs b/src/cli.rs
index b432528d..ea643f6a 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -26,6 +26,7 @@ mod agent;
mod api;
mod completion;
mod integration;
+mod machine;
mod notification;
mod pane;
mod plugin;
@@ -113,6 +114,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
"completion" | "completions" => completion::run_completion_command(&args[2..])?,
"config" => run_config_command(&args[2..])?,
"channel" => run_channel_command(&args[2..])?,
+ "machine" => machine::run_machine_command(&args[2..])?,
"workspace" => workspace::run_workspace_command(&args[2..])?,
"worktree" => worktree::run_worktree_command(&args[2..])?,
"tab" => tab::run_tab_command(&args[2..])?,
diff --git a/src/cli/machine.rs b/src/cli/machine.rs
new file mode 100644
index 00000000..a55e4611
--- /dev/null
+++ b/src/cli/machine.rs
@@ -0,0 +1,295 @@
+use serde::Serialize;
+
+use crate::client::endpoint::{EndpointCatalog, ProfileId};
+
+const HELP: &str = "Usage:
+ herdr machine list [--json]
+ herdr machine add <ssh-target> --label <label> [--remote-session <name>]
+ herdr machine rename <profile-id> --label <label>
+ herdr machine remove <profile-id>
+ herdr machine enable <profile-id>
+ herdr machine disable <profile-id>
+
+Add prepares the remote Herdr installation and starts its server before saving.
+Missing or incompatible installations require approval in an interactive terminal.
+Changes apply automatically to open local Herdr clients.
+Removing or disabling a machine leaves its remote sessions running.
+Saved machines contain only a label, SSH target, explicit Herdr session, and enabled state.
+SSH credentials and key material remain owned by OpenSSH.";
+
+#[derive(Serialize)]
+struct MachineListRow<'a> {
+ id: &'a str,
+ label: &'a str,
+ target: &'a str,
+ session: &'a str,
+ enabled: bool,
+ selected: bool,
+}
+
+pub(super) fn run_machine_command(args: &[String]) -> std::io::Result<i32> {
+ match args.first().map(String::as_str) {
+ Some("list") => list(&args[1..]),
+ Some("add") => add(&args[1..]),
+ Some("rename") => rename(&args[1..]),
+ Some("remove") => remove(&args[1..]),
+ Some("enable") => set_enabled(&args[1..], true),
+ Some("disable") => set_enabled(&args[1..], false),
+ Some("help" | "--help" | "-h") => {
+ println!("{HELP}");
+ Ok(0)
+ }
+ _ => {
+ eprintln!("{HELP}");
+ Ok(2)
+ }
+ }
+}
+
+fn list(args: &[String]) -> std::io::Result<i32> {
+ let json = match args {
+ [] => false,
+ [flag] if flag == "--json" => true,
+ _ => {
+ eprintln!("usage: herdr machine list [--json]");
+ return Ok(2);
+ }
+ };
+ let catalog = load_catalog()?;
+ let rows = catalog
+ .ssh
+ .iter()
+ .map(|profile| MachineListRow {
+ id: profile.id.as_str(),
+ label: &profile.label,
+ target: &profile.target,
+ session: &profile.session,
+ enabled: profile.enabled,
+ selected: catalog.selected_profile.as_ref() == Some(&profile.id),
+ })
+ .collect::<Vec<_>>();
+ if json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&rows).map_err(std::io::Error::other)?
+ );
+ return Ok(0);
+ }
+ if rows.is_empty() {
+ println!("No saved SSH machines.");
+ return Ok(0);
+ }
+ for row in rows {
+ let state = if row.enabled { "enabled" } else { "disabled" };
+ println!(
+ "{}\t{}\t{}\t{}\t{}",
+ row.id, row.label, row.target, row.session, state
+ );
+ }
+ Ok(0)
+}
+
+fn add(args: &[String]) -> std::io::Result<i32> {
+ let args = super::expand_equals_args(args, &["--label", "--remote-session"]);
+ let Some(target) = args.first().filter(|value| !value.starts_with('-')) else {
+ eprintln!(
+ "usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>]"
+ );
+ return Ok(2);
+ };
+ let mut label = None;
+ let mut session = None;
+ let mut index = 1;
+ while index < args.len() {
+ let (name, value) = match args[index].as_str() {
+ "--label" | "--remote-session" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for {}", args[index]);
+ return Ok(2);
+ };
+ index += 2;
+ (args[index - 2].as_str(), value.clone())
+ }
+ unknown => {
+ eprintln!("unknown machine add option: {unknown}");
+ return Ok(2);
+ }
+ };
+ match name {
+ "--label" if label.is_none() => label = Some(value),
+ "--remote-session" if session.is_none() => session = Some(value),
+ "--remote-session" => {
+ eprintln!("--remote-session can only be specified once");
+ return Ok(2);
+ }
+ "--label" => {
+ eprintln!("--label can only be specified once");
+ return Ok(2);
+ }
+ _ => unreachable!("validated machine add option"),
+ }
+ }
+ let Some(label) = label else {
+ eprintln!("--label is required");
+ return Ok(2);
+ };
+ let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned());
+ let mut catalog = load_catalog()?;
+ match catalog.add_ssh(label.clone(), target, session.clone()) {
+ Ok(_) => {}
+ Err(error) => {
+ eprintln!("error: {error}");
+ return Ok(2);
+ }
+ }
+ if let Err(error) = crate::remote::prepare_saved_ssh(target, &session) {
+ eprintln!("error: {error}; machine was not saved");
+ crate::remote::print_remote_error_hint(&error, target);
+ return Ok(1);
+ }
+ // Setup can wait for human approval. Do not overwrite catalog edits made meanwhile.
+ let mut catalog = load_catalog().map_err(|error| {
+ std::io::Error::other(format!(
+ "remote prepared, but machine was not saved: {error}"
+ ))
+ })?;
+ let id = match catalog.add_ssh(label, target, session) {
+ Ok(id) => id,
+ Err(error) => {
+ eprintln!("error: {error}");
+ return Ok(2);
+ }
+ };
+ store_catalog(&catalog).map_err(|error| {
+ std::io::Error::other(format!(
+ "remote prepared, but machine was not saved: {error}"
+ ))
+ })?;
+ println!("Saved SSH machine {id}. Remote server is ready.");
+ println!("Open Herdr clients connect automatically.");
+ Ok(0)
+}
+
+fn rename(args: &[String]) -> std::io::Result<i32> {
+ let args = super::expand_equals_args(args, &["--label"]);
+ let [raw_id, flag, label] = args.as_slice() else {
+ eprintln!("usage: herdr machine rename <profile-id> --label <label>");
+ return Ok(2);
+ };
+ if flag != "--label" {
+ eprintln!("usage: herdr machine rename <profile-id> --label <label>");
+ return Ok(2);
+ }
+ let id = match ProfileId::parse(raw_id.clone()) {
+ Ok(id) => id,
+ Err(error) => {
+ eprintln!("error: {error}");
+ return Ok(2);
+ }
+ };
+ let mut catalog = load_catalog()?;
+ match catalog.rename_ssh(&id, label) {
+ Ok(true) => {}
+ Ok(false) => {
+ eprintln!("machine profile {id} was not found");
+ return Ok(1);
+ }
+ Err(error) => {
+ eprintln!("error: {error}");
+ return Ok(2);
+ }
+ }
+ store_catalog(&catalog)?;
+ println!("Renamed SSH machine {id}.");
+ Ok(0)
+}
+
+fn remove(args: &[String]) -> std::io::Result<i32> {
+ let Some(id) = one_profile_id(args, "usage: herdr machine remove <profile-id>")? else {
+ return Ok(2);
+ };
+ let mut catalog = load_catalog()?;
+ let previous_selection = catalog.selected_profile.clone();
+ if !catalog.remove_ssh(&id) {
+ eprintln!("machine profile {id} was not found");
+ return Ok(1);
+ }
+ store_catalog(&catalog)?;
+ if catalog.selected_profile != previous_selection {
+ catalog.store_selection().map_err(std::io::Error::other)?;
+ }
+ println!("Removed SSH machine {id}.");
+ Ok(0)
+}
+
+fn set_enabled(args: &[String], enabled: bool) -> std::io::Result<i32> {
+ let action = if enabled { "enable" } else { "disable" };
+ let usage = format!("usage: herdr machine {action} <profile-id>");
+ let Some(id) = one_profile_id(args, &usage)? else {
+ return Ok(2);
+ };
+ let mut catalog = load_catalog()?;
+ let previous_selection = catalog.selected_profile.clone();
+ if !catalog.set_enabled(&id, enabled) {
+ eprintln!("machine profile {id} was not found");
+ return Ok(1);
+ }
+ store_catalog(&catalog)?;
+ if catalog.selected_profile != previous_selection {
+ catalog.store_selection().map_err(std::io::Error::other)?;
+ }
+ println!(
+ "{} SSH machine {id}.",
+ if enabled { "Enabled" } else { "Disabled" }
+ );
+ Ok(0)
+}
+
+fn one_profile_id(args: &[String], usage: &str) -> std::io::Result<Option<ProfileId>> {
+ let [raw] = args else {
+ eprintln!("{usage}");
+ return Ok(None);
+ };
+ match ProfileId::parse(raw.clone()) {
+ Ok(id) => Ok(Some(id)),
+ Err(error) => {
+ eprintln!("error: {error}");
+ Ok(None)
+ }
+ }
+}
+
+fn load_catalog() -> std::io::Result<EndpointCatalog> {
+ EndpointCatalog::load().map_err(std::io::Error::other)
+}
+
+fn store_catalog(catalog: &EndpointCatalog) -> std::io::Result<()> {
+ catalog.store_profiles().map_err(std::io::Error::other)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn profile_id_parser_rejects_target_text() {
+ assert!(one_profile_id(&["build.example".into()], "usage")
+ .unwrap()
+ .is_none());
+ }
+
+ #[test]
+ fn list_rows_do_not_have_credential_fields() {
+ let encoded = serde_json::to_string(&MachineListRow {
+ id: "0123456789abcdef0123456789abcdef",
+ label: "Build",
+ target: "dev@build",
+ session: "agents",
+ enabled: true,
+ selected: false,
+ })
+ .unwrap();
+ assert!(!encoded.contains("password"));
+ assert!(!encoded.contains("key"));
+ }
+}
diff --git a/src/cli/spec.rs b/src/cli/spec.rs
index cf23e320..2da0a25c 100644
--- a/src/cli/spec.rs
+++ b/src/cli/spec.rs
@@ -2,6 +2,9 @@ use std::io::Write;
use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint};
+mod completion;
+mod machine;
+
pub(super) fn command() -> Command {
let command = Command::new("herdr")
.about("terminal workspace manager for AI coding agents")
@@ -25,11 +28,12 @@ pub(super) fn command() -> Command {
.action(ArgAction::SetTrue)
.help("Print version and exit"),
)
- .subcommand(completion_command())
+ .subcommand(completion::command())
.subcommand(update_command())
.subcommand(status_command())
.subcommand(config_command())
.subcommand(channel_command())
+ .subcommand(machine::command())
.subcommand(server_command())
.subcommand(api_command())
.subcommand(workspace_command())
@@ -108,19 +112,6 @@ fn write_requested_help(
Ok(true)
}
-fn completion_command() -> Command {
- Command::new("completion")
- .visible_alias("completions")
- .about("Generate shell completion scripts")
- .arg(
- Arg::new("shell")
- .value_name("SHELL")
- .required(true)
- .value_parser(super::completion::SUPPORTED_SHELLS)
- .help("Shell to generate completions for"),
- )
-}
-
fn update_command() -> Command {
Command::new("update")
.about("Download and install the latest version")
diff --git a/src/cli/spec/completion.rs b/src/cli/spec/completion.rs
new file mode 100644
index 00000000..8cc27d7b
--- /dev/null
+++ b/src/cli/spec/completion.rs
@@ -0,0 +1,14 @@
+use clap::{Arg, Command};
+
+pub(super) fn command() -> Command {
+ Command::new("completion")
+ .visible_alias("completions")
+ .about("Generate shell completion scripts")
+ .arg(
+ Arg::new("shell")
+ .value_name("SHELL")
+ .required(true)
+ .value_parser(super::super::completion::SUPPORTED_SHELLS)
+ .help("Shell to generate completions for"),
+ )
+}
diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs
new file mode 100644
index 00000000..188e27b9
--- /dev/null
+++ b/src/cli/spec/machine.rs
@@ -0,0 +1,49 @@
+use clap::{Arg, Command};
+
+use super::{json_flag, option};
+
+pub(super) fn command() -> Command {
+ Command::new("machine")
+ .about("Manage saved SSH machines")
+ .subcommand(
+ Command::new("list")
+ .about("List saved SSH machines")
+ .arg(json_flag()),
+ )
+ .subcommand(
+ Command::new("add")
+ .about("Prepare the remote Herdr server and save an SSH machine")
+ .arg(
+ Arg::new("ssh-target")
+ .value_name("SSH_TARGET")
+ .required(true),
+ )
+ .arg(
+ option("label", "LABEL")
+ .required(true)
+ .help("Set the machine label shown in the sidebar"),
+ )
+ .arg(
+ option("remote-session", "NAME")
+ .help("Set the explicit Herdr session on the remote machine"),
+ ),
+ )
+ .subcommand(
+ profile_command("rename", "Rename a saved SSH machine").arg(
+ option("label", "LABEL")
+ .required(true)
+ .help("Set the machine label shown in the sidebar"),
+ ),
+ )
+ .subcommand(profile_command("remove", "Remove a saved SSH machine"))
+ .subcommand(profile_command("enable", "Enable a saved SSH machine"))
+ .subcommand(profile_command("disable", "Disable a saved SSH machine"))
+}
+
+fn profile_command(name: &'static str, about: &'static str) -> Command {
+ Command::new(name).about(about).arg(
+ Arg::new("profile-id")
+ .value_name("PROFILE_ID")
+ .required(true),
+ )
+}
diff --git a/src/cli/status.rs b/src/cli/status.rs
index 4dba81b7..c6dda462 100644
--- a/src/cli/status.rs
+++ b/src/cli/status.rs
@@ -255,6 +255,7 @@ struct ClientStatusJson {
channel: &'static str,
protocol: u32,
endpoint_protocol_generation: u32,
+ endpoint_capabilities: Vec<&'static str>,
binary: String,
session: Option<String>,
}
@@ -279,6 +280,8 @@ struct ServerCapabilitiesJson {
live_handoff: bool,
detached_server_daemon: bool,
endpoint_protocol_generation: Option<u32>,
+ surface_interest: bool,
+ health_check: bool,
}
#[derive(Serialize)]
@@ -293,6 +296,11 @@ fn client_status_json() -> ClientStatusJson {
channel: crate::config::Config::load().config.update.channel.as_str(),
protocol: crate::protocol::PROTOCOL_VERSION,
endpoint_protocol_generation: crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
+ endpoint_capabilities: vec![
+ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY,
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY,
+ crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY,
+ ],
binary: current_exe_label(),
session: crate::session::active_name(),
}
@@ -315,6 +323,8 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson {
live_handoff: capabilities.live_handoff,
detached_server_daemon: capabilities.detached_server_daemon,
endpoint_protocol_generation: capabilities.endpoint_protocol_generation,
+ surface_interest: capabilities.surface_interest,
+ health_check: capabilities.health_check,
}),
compatible: protocol.map(|value| value == crate::protocol::PROTOCOL_VERSION),
endpoint_compatible: capabilities.as_ref().and_then(|capabilities| {
@@ -404,6 +414,8 @@ mod tests {
live_handoff: true,
detached_server_daemon: true,
endpoint_protocol_generation: endpoint_generation,
+ surface_interest: true,
+ health_check: true,
}),
}
}
diff --git a/src/client/attach.rs b/src/client/attach.rs
index e385775c..2cabe5bb 100644
--- a/src/client/attach.rs
+++ b/src/client/attach.rs
@@ -9,7 +9,6 @@ use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
#[cfg(unix)]
use super::write_to_server;
#[cfg(unix)]
-use crate::ipc::LocalStream;
#[cfg(unix)]
use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientMessage};
@@ -260,7 +259,7 @@ fn attach_scroll_action(
#[cfg(unix)]
pub(super) fn write_attach_semantic_action(
- stream: &mut LocalStream,
+ stream: &mut impl super::ClientMessageSink,
action: AttachSemanticAction,
) -> io::Result<()> {
let message = match action {
diff --git a/src/client/catalog_reload.rs b/src/client/catalog_reload.rs
new file mode 100644
index 00000000..21f33bea
--- /dev/null
+++ b/src/client/catalog_reload.rs
@@ -0,0 +1,276 @@
+use super::*;
+
+pub(super) fn watch_profiles(
+ event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
+ should_quit: Arc<AtomicBool>,
+) {
+ // One bounded read per second per client, independent of rendering and pane count.
+ std::thread::spawn(move || {
+ let mut previous = None;
+ while !should_quit.load(Ordering::Acquire) {
+ let current = endpoint::EndpointCatalog::load_profiles();
+ if previous.as_ref() != Some(&current) {
+ previous = Some(current.clone());
+ if event_tx
+ .blocking_send(ClientLoopEvent::EndpointCatalog(current))
+ .is_err()
+ {
+ break;
+ }
+ }
+ std::thread::sleep(Duration::from_secs(1));
+ }
+ });
+}
+
+// Only called between surface handoffs: removing a source must not invalidate an in-flight
+// rollback. Connection attempts are independent and fenced by supervisor generations.
+pub(super) fn apply_profiles(
+ state: &mut ClientState,
+ endpoints: &mut endpoint::EndpointRegistry,
+ commands: &mut endpoint_commands::EndpointCommands,
+ supervisors: &mut endpoint::EndpointSupervisors,
+ catalog: &mut endpoint::EndpointCatalog,
+ profiles: Vec<endpoint::SavedSshEndpoint>,
+ now: std::time::Instant,
+) -> bool {
+ if catalog.ssh == profiles {
+ return false;
+ }
+ let previous_size = state
+ .shell
+ .as_ref()
+ .map(|shell| shell.surface_size(state.reported_size.0, state.reported_size.1));
+ let retired = supervisors.reconcile_profiles(&profiles, now);
+ let active_removed = retired.contains(endpoints.active_id());
+ for endpoint_id in retired {
+ endpoints.disconnect(&endpoint_id);
+ let cancelled = commands.disconnect(&endpoint_id);
+ #[cfg(unix)]
+ state.retire_endpoint_graphics(&endpoint_id);
+ if let Some(shell) = state.shell.as_mut() {
+ for request_id in cancelled {
+ shell.cancel_endpoint_request(&request_id);
+ }
+ shell.retire_endpoint(&endpoint_id);
+ }
+ }
+ catalog.ssh = profiles;
+ if active_removed {
+ endpoints.select_unavailable_local();
+ catalog.select_local();
+ state.freeze_presentation();
+ if let Some(shell) = state.shell.as_mut() {
+ shell.select_unavailable_local();
+ }
+ } else if catalog.selected_profile.as_ref().is_some_and(|selected| {
+ !catalog
+ .ssh
+ .iter()
+ .any(|profile| &profile.id == selected && profile.enabled)
+ }) {
+ catalog.select_local();
+ }
+ if let Some(shell) = state.shell.as_mut() {
+ shell.set_endpoint_catalog(&catalog.ssh);
+ if endpoints.active_surface_available()
+ && previous_size
+ != Some(shell.surface_size(state.reported_size.0, state.reported_size.1))
+ {
+ shell.invalidate_pane_surface();
+ endpoints.send(&client_shell_resize_message(
+ shell,
+ state.reported_size.0,
+ state.reported_size.1,
+ state.reported_cell_size.0,
+ state.reported_cell_size.1,
+ state.pixel_geometry_exact,
+ ));
+ }
+ }
+ active_removed
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use endpoint::{ClientEndpointId, EndpointCatalog, EndpointRegistry, EndpointSupervisors};
+ use std::sync::atomic::AtomicUsize;
+ use std::time::Instant;
+
+ struct Transport(Arc<AtomicUsize>);
+
+ impl endpoint::EndpointTransport for Transport {
+ fn send(&mut self, _: &ClientMessage) -> io::Result<()> {
+ Ok(())
+ }
+
+ fn disconnect(&mut self) {
+ self.0.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+
+ fn state() -> ClientState {
+ ClientState {
+ blit_encoder: render_ansi::BlitEncoder::new(),
+ mouse_capture_active: false,
+ endpoint_mouse_capture_requested: false,
+ endpoint_sgr_pixels_requested: false,
+ host_theme_updates: Vec::new(),
+ direct_mouse_capture_preference: false,
+ shell_mouse_capture_preference: false,
+ direct_keyboard_protocol: Default::default(),
+ pane_keyboard_report_all: false,
+ keyboard_report_all_active: false,
+ reported_size: (100, 30),
+ reported_cell_size: (0, 0),
+ sound_config: Default::default(),
+ kitty_graphics_enabled: false,
+ pixel_geometry_enabled: false,
+ pixel_geometry_exact: false,
+ #[cfg(unix)]
+ direct_graphics_response: Default::default(),
+ #[cfg(unix)]
+ retired_direct_graphics: None,
+ #[cfg(unix)]
+ pending_surface_graphics: HashMap::new(),
+ attach_escape: None,
+ #[cfg(unix)]
+ mouse_scroll_lines: 3,
+ remote_image_paste_key: None,
+ redraw_on_focus_gained: false,
+ repaint_pending: false,
+ presentation_frozen: false,
+ draw_host_cursor: false,
+ detached_process_children: Vec::new(),
+ shell: Some(shell::ClientShellState::new(
+ shell::ClientShellConfig::from_config(&crate::config::Config::default()),
+ )),
+ }
+ }
+
+ #[test]
+ fn live_catalog_add_and_rename_keep_local_connection_and_selection() {
+ let now = Instant::now();
+ let mut state = state();
+ let disconnected = Arc::new(AtomicUsize::new(0));
+ let mut endpoints =
+ EndpointRegistry::new(Transport(disconnected.clone()), 1, Default::default());
+ let mut supervisors = EndpointSupervisors::new(&[], now);
+ let mut commands = endpoint_commands::EndpointCommands::default();
+ let mut catalog = EndpointCatalog::default();
+ let mut profile = endpoint::SavedSshEndpoint::new("Build", "build", "main").unwrap();
+ for label in ["Build", "Renamed"] {
+ profile.label = label.into();
+ assert!(!apply_profiles(
+ &mut state,
+ &mut endpoints,
+ &mut commands,
+ &mut supervisors,
+ &mut catalog,
+ vec![profile.clone()],
+ now
+ ));
+ assert_eq!(endpoints.active_id(), &ClientEndpointId::Local);
+ assert!(endpoints.active_surface_available());
+ assert_eq!(
+ endpoints
+ .connection(&ClientEndpointId::Local)
+ .unwrap()
+ .generation,
+ 1
+ );
+ assert_eq!(catalog.selected_profile, None);
+ assert_eq!(
+ state
+ .shell
+ .as_ref()
+ .unwrap()
+ .endpoint_label(&ClientEndpointId::Ssh(profile.id.clone())),
+ label
+ );
+ }
+ assert_eq!(disconnected.load(Ordering::Relaxed), 0);
+ }
+
+ #[test]
+ fn live_catalog_remove_or_disable_active_machine_selects_local_without_input() {
+ for local_online in [false, true] {
+ for disable in [false, true] {
+ let now = Instant::now();
+ let mut state = state();
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog.add_ssh("Build", "build", "main").unwrap();
+ let remote = ClientEndpointId::Ssh(id.clone());
+ catalog.select_ssh(&id);
+ state
+ .shell
+ .as_mut()
+ .unwrap()
+ .set_endpoint_catalog(&catalog.ssh);
+ let mut supervisors = EndpointSupervisors::new(&catalog.ssh, now);
+ let mut endpoints = EndpointRegistry::empty();
+ let local_disconnects = Arc::new(AtomicUsize::new(0));
+ if local_online {
+ endpoints.insert(
+ ClientEndpointId::Local,
+ Transport(local_disconnects.clone()),
+ 1,
+ Default::default(),
+ false,
+ );
+ }
+ let remote_disconnects = Arc::new(AtomicUsize::new(0));
+ endpoints.insert(
+ remote.clone(),
+ Transport(remote_disconnects.clone()),
+ 2,
+ Default::default(),
+ true,
+ );
+ endpoints.set_active(&remote);
+ endpoints.unfreeze_input();
+ let mut commands = endpoint_commands::EndpointCommands::default();
+ let profiles = if disable {
+ let mut profiles = catalog.ssh.clone();
+ profiles[0].enabled = false;
+ profiles
+ } else {
+ Vec::new()
+ };
+ assert!(apply_profiles(
+ &mut state,
+ &mut endpoints,
+ &mut commands,
+ &mut supervisors,
+ &mut catalog,
+ profiles,
+ now
+ ));
+ assert_eq!(endpoints.active_id(), &ClientEndpointId::Local);
+ assert!(!endpoints.active_surface_available());
+ assert!(endpoints.connection(&remote).is_none());
+ assert_eq!(
+ endpoints.connection(&ClientEndpointId::Local).is_some(),
+ local_online
+ );
+ assert!(state
+ .shell
+ .as_ref()
+ .unwrap()
+ .endpoint_is_active(&ClientEndpointId::Local));
+ assert!(!state.shell.as_ref().unwrap().has_presented_surface());
+ assert!(state.presentation_frozen);
+ assert_eq!(catalog.selected_profile, None);
+ assert_eq!(remote_disconnects.load(Ordering::Relaxed), 1);
+ assert_eq!(local_disconnects.load(Ordering::Relaxed), 0);
+ assert!(!supervisors.record_status(
+ &remote,
+ 2,
+ endpoint::ClientEndpointStatus::Online,
+ now
+ ));
+ }
+ }
+ }
+}
diff --git a/src/client/clipboard_forwarding.rs b/src/client/clipboard_forwarding.rs
new file mode 100644
index 00000000..2de5f49f
--- /dev/null
+++ b/src/client/clipboard_forwarding.rs
@@ -0,0 +1,16 @@
+use super::*;
+
+pub(super) fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> {
+ use base64::Engine;
+
+ base64::engine::general_purpose::STANDARD.decode(data).ok()
+}
+
+pub(super) fn forward_clipboard(data: &str) -> bool {
+ let Some(bytes) = decode_clipboard_payload(data) else {
+ warn!("received invalid clipboard payload from server");
+ return false;
+ };
+ crate::selection::write_osc52_bytes(&bytes);
+ true
+}
diff --git a/src/client/clipboard_images.rs b/src/client/clipboard_images.rs
index c1c49cbc..1faebf39 100644
--- a/src/client/clipboard_images.rs
+++ b/src/client/clipboard_images.rs
@@ -2,16 +2,15 @@ use std::path::PathBuf;
use tracing::{info, warn};
-use crate::ipc::LocalStream;
#[cfg(windows)]
use crate::protocol::ClientInputEvent;
use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD;
use crate::protocol::{ClientClipboardImageTarget, ClientMessage};
-use super::{is_remote_client_process, write_to_server, ClientError};
+use super::{write_to_server, ClientError};
pub(super) fn write_remote_image_to_server(
- stream: &mut LocalStream,
+ stream: &mut impl super::ClientMessageSink,
target: ClientClipboardImageTarget,
image: crate::platform::ClipboardImage,
source: &'static str,
@@ -46,10 +45,6 @@ pub(super) fn write_remote_image_to_server(
pub(super) fn client_remote_image_paste_key(
config: &crate::config::Config,
) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> {
- if !is_remote_client_process() {
- return None;
- }
-
match config.remote_image_paste_key() {
Ok(key) => key,
Err(diagnostic) => {
@@ -59,14 +54,25 @@ pub(super) fn client_remote_image_paste_key(
}
}
+pub(super) fn endpoint_accepts_local_images(
+ remote_client_process: bool,
+ endpoint_id: &super::endpoint::ClientEndpointId,
+ active_surface_available: bool,
+) -> bool {
+ active_surface_available && (remote_client_process || !endpoint_id.is_local())
+}
+
#[cfg(unix)]
pub(super) fn should_bridge_clipboard_image_paste(
data: &[u8],
is_remote_client: bool,
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
) -> bool {
+ if !is_remote_client {
+ return false;
+ }
if data == b"\x1b[200~\x1b[201~" {
- return is_remote_client;
+ return true;
}
let Some(remote_image_paste_key) = remote_image_paste_key else {
diff --git a/src/client/config_reload.rs b/src/client/config_reload.rs
new file mode 100644
index 00000000..c20de760
--- /dev/null
+++ b/src/client/config_reload.rs
@@ -0,0 +1,129 @@
+use super::*;
+
+pub(super) fn init_logging() {
+ crate::logging::init_file_logging("herdr-client.log");
+}
+
+pub(super) fn apply_reload(
+ state: &mut ClientState,
+ endpoints: &mut endpoint::EndpointRegistry,
+ pending_activation: &mut Option<endpoint::PendingEndpointActivation>,
+ host_mouse_capture_active: &std::sync::atomic::AtomicBool,
+ host_sgr_pixels_active: &std::sync::atomic::AtomicBool,
+ prefix_input_source: &mut impl crate::platform::PrefixInputSource,
+) -> Result<(), ClientError> {
+ let previous_mouse_capture = state.shell_mouse_capture_preference;
+ let mut mouse_capture = previous_mouse_capture;
+ reload_local_client_config(
+ &mut state.sound_config,
+ &mut state.redraw_on_focus_gained,
+ &mut state.draw_host_cursor,
+ &mut state.remote_image_paste_key,
+ &mut mouse_capture,
+ );
+ state.shell_mouse_capture_preference = mouse_capture;
+ state.direct_mouse_capture_preference = state.attach_escape.is_some() && mouse_capture;
+ if state.shell.is_some() && previous_mouse_capture != mouse_capture {
+ write_to_server(
+ endpoints,
+ &ClientMessage::ClientShellMouseCapture {
+ enabled: mouse_capture,
+ },
+ )
+ .map_err(ClientError::ConnectionLost)?;
+ }
+ if state.attach_escape.is_some() {
+ let enabled = effective_mouse_capture(
+ state.endpoint_mouse_capture_requested,
+ state.direct_mouse_capture_preference,
+ );
+ let sgr_pixels = effective_sgr_pixel_mouse(
+ enabled,
+ state.endpoint_sgr_pixels_requested,
+ state.pixel_geometry_exact,
+ );
+ if enabled != state.mouse_capture_active
+ || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire)
+ {
+ set_mouse_capture(enabled, sgr_pixels).map_err(ClientError::ConnectionFailed)?;
+ }
+ state.mouse_capture_active = enabled;
+ host_mouse_capture_active.store(enabled, Ordering::Release);
+ host_sgr_pixels_active.store(sgr_pixels, Ordering::Release);
+ }
+ let (frame, resize) = if let Some(shell) = state.shell.as_mut() {
+ let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1);
+ shell.reload_client_config();
+ let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1);
+ let resize = (previous_size != next_size).then(|| {
+ shell.invalidate_pane_surface();
+ client_shell_resize_message(
+ shell,
+ state.reported_size.0,
+ state.reported_size.1,
+ state.reported_cell_size.0,
+ state.reported_cell_size.1,
+ state.pixel_geometry_exact,
+ )
+ });
+ (
+ shell.compose(state.reported_size.0, state.reported_size.1),
+ resize,
+ )
+ } else {
+ (None, None)
+ };
+ apply_client_shell_input_source_changes(state, prefix_input_source);
+ if let Some(resize) = resize {
+ if let Some(activation) = pending_activation.as_mut() {
+ if let Err(error) = activation.update_resize(resize, endpoints) {
+ rollback_endpoint_activation(state, endpoints, pending_activation, error, false);
+ }
+ } else {
+ write_to_server(endpoints, &resize).map_err(ClientError::ConnectionLost)?;
+ }
+ }
+ if let Some(frame) = frame {
+ state.present_frame(frame);
+ }
+ Ok(())
+}
+
+pub(super) fn reload_local_client_config(
+ sound_config: &mut crate::config::SoundConfig,
+ redraw_on_focus_gained: &mut bool,
+ draw_host_cursor: &mut bool,
+ remote_image_paste_key: &mut Option<(
+ crossterm::event::KeyCode,
+ crossterm::event::KeyModifiers,
+ )>,
+ mouse_capture: &mut bool,
+) {
+ match crate::config::load_live_config() {
+ Ok(loaded) => {
+ let invalid_section = |section: &str| {
+ loaded
+ .invalid_sections
+ .iter()
+ .any(|invalid| invalid == section)
+ };
+ if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none()
+ {
+ for diagnostic in loaded.config.ui.sound.diagnostics() {
+ warn!(diagnostic = %diagnostic, "local sound config diagnostic");
+ }
+ *sound_config = loaded.config.ui.sound.clone();
+ *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained;
+ *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor);
+ *mouse_capture = loaded.config.ui.mouse_capture;
+ }
+ if !invalid_section("keys") {
+ *remote_image_paste_key = client_remote_image_paste_key(&loaded.config);
+ }
+ debug!("reloaded local client config");
+ }
+ Err(diagnostics) => {
+ warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config");
+ }
+ }
+}
diff --git a/src/client/endpoint.rs b/src/client/endpoint.rs
new file mode 100644
index 00000000..77aaae3b
--- /dev/null
+++ b/src/client/endpoint.rs
@@ -0,0 +1,120 @@
+use std::fmt;
+
+use serde::{Deserialize, Serialize};
+use sha2::{Digest as _, Sha256};
+
+mod activation;
+mod catalog;
+mod control;
+mod health;
+mod message_policy;
+mod registry;
+mod supervisor;
+mod writer;
+
+pub(crate) use activation::*;
+pub(crate) use catalog::*;
+pub(crate) use control::*;
+pub(crate) use message_policy::*;
+pub(crate) use registry::*;
+pub(crate) use supervisor::*;
+pub(crate) use writer::NativeEndpointTransport;
+
+const PROFILE_ID_BYTES: usize = 16;
+
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[serde(transparent)]
+pub(crate) struct ProfileId(String);
+
+impl ProfileId {
+ pub(crate) fn parse(value: impl Into<String>) -> Result<Self, String> {
+ let value = value.into();
+ if value.len() != PROFILE_ID_BYTES * 2
+ || !value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err("endpoint profile id must be 32 lowercase hexadecimal characters".into());
+ }
+ Ok(Self(value))
+ }
+
+ pub(crate) fn generate() -> Self {
+ use std::sync::atomic::{AtomicU64, Ordering};
+ static NEXT_ID: AtomicU64 = AtomicU64::new(1);
+
+ let sequence = NEXT_ID.fetch_add(1, Ordering::Relaxed);
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos();
+ let digest = Sha256::digest(format!("{}:{now}:{sequence}", std::process::id()).as_bytes());
+ Self(
+ digest[..PROFILE_ID_BYTES]
+ .iter()
+ .map(|byte| format!("{byte:02x}"))
+ .collect(),
+ )
+ }
+
+ pub(crate) fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl fmt::Display for ProfileId {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.0)
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub(crate) enum ClientEndpointId {
+ Local,
+ Ssh(ProfileId),
+}
+
+impl ClientEndpointId {
+ pub(crate) fn is_local(&self) -> bool {
+ matches!(self, Self::Local)
+ }
+
+ pub(crate) fn storage_key(&self) -> String {
+ match self {
+ Self::Local => "local".into(),
+ Self::Ssh(profile_id) => format!("ssh:{profile_id}"),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum ClientEndpointStatus {
+ Connecting,
+ Online,
+ Reconnecting,
+ Attention,
+ Disabled,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn profile_ids_are_opaque_and_stable_when_parsed() {
+ let first = ProfileId::generate();
+ let second = ProfileId::generate();
+ assert_ne!(first, second);
+ assert_eq!(ProfileId::parse(first.to_string()).unwrap(), first);
+ assert_eq!(first.as_str().len(), 32);
+ }
+
+ #[test]
+ fn endpoint_storage_keys_do_not_contain_ssh_targets() {
+ let profile = ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap();
+ assert_eq!(
+ ClientEndpointId::Ssh(profile).storage_key(),
+ "ssh:0123456789abcdef0123456789abcdef"
+ );
+ }
+}
diff --git a/src/client/endpoint/activation.rs b/src/client/endpoint/activation.rs
new file mode 100644
index 00000000..a5b63344
--- /dev/null
+++ b/src/client/endpoint/activation.rs
@@ -0,0 +1,1152 @@
+use std::time::{Duration, Instant};
+
+use super::{ClientEndpointId, ClientEndpointStatus, EndpointRegistry, EndpointSendOutcome};
+
+mod model;
+mod protocol;
+pub(crate) use model::{
+ ActivationBeginError, ActivationCompletion, ActivationRollback, EndpointActivationIntent,
+ PendingEndpointActivation, SurfaceActivationProgress,
+};
+use model::{ActivationEvidence, ActivationPhase, EndpointLease};
+use protocol::*;
+
+const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(5);
+
+impl PendingEndpointActivation {
+ #[allow(clippy::too_many_arguments)]
+ pub(crate) fn begin(
+ shell: &crate::client::shell::ClientShellState,
+ endpoints: &mut EndpointRegistry,
+ target: ClientEndpointId,
+ focus: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ resize: crate::protocol::ClientMessage,
+ serial: u64,
+ now: Instant,
+ ) -> Result<Self, ActivationBeginError> {
+ resize_geometry(&resize).ok_or_else(|| {
+ ActivationBeginError::Preflight(
+ "endpoint activation did not include a surface resize".to_owned(),
+ )
+ })?;
+ let source_id = endpoints.active_id().clone();
+ let source_has_live_surface = endpoints
+ .connection(&source_id)
+ .is_some_and(|connection| connection.surface_active);
+ let (source, source_available) = if source_has_live_surface {
+ (
+ endpoint_lease(shell, endpoints, &source_id)
+ .map_err(ActivationBeginError::Preflight)?,
+ true,
+ )
+ } else {
+ (disconnected_endpoint_lease(shell, &source_id), false)
+ };
+ let target_lease =
+ endpoint_lease(shell, endpoints, &target).map_err(ActivationBeginError::Preflight)?;
+ let source_compatible = !source_available
+ || endpoints
+ .connection(&source_id)
+ .is_some_and(|connection| connection.negotiation.supports_surface_interest());
+ let target_compatible = endpoints
+ .connection(&target)
+ .is_some_and(|connection| connection.negotiation.supports_surface_interest());
+ if !source_compatible || !target_compatible {
+ return Err(ActivationBeginError::Preflight(
+ "endpoint must be updated before it can join the selected surface".into(),
+ ));
+ }
+
+ let source_is_target = source.endpoint_id == target_lease.endpoint_id;
+ // Validate every typed lifecycle and optional focus envelope before the first transport
+ // write. Any error above this line is guaranteed not to have changed either endpoint.
+ let source_release_request = (source_available && !source_is_target)
+ .then(|| {
+ surface_interest_request(
+ &source.boot_id,
+ format!("client-shell-surface:{serial}:off"),
+ false,
+ )
+ })
+ .transpose()
+ .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?;
+ surface_interest_request(
+ &target_lease.boot_id,
+ format!("client-shell-surface:{serial}:on"),
+ true,
+ )
+ .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?;
+ if let Some(target) = focus.as_ref() {
+ focus_request(
+ &target_lease.boot_id,
+ format!("client-shell-focus:{serial}:1"),
+ target,
+ )
+ .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?;
+ }
+
+ endpoints.freeze_input();
+ let mut activation = Self {
+ source,
+ source_available,
+ target: target_lease,
+ focus,
+ host_focused: shell.host_focus_baseline(),
+ resize: resize.clone(),
+ phase: ActivationPhase::ReleasingSource {
+ request_id: format!("client-shell-surface:{serial}:off"),
+ },
+ deadline: now + ACTIVATION_TIMEOUT,
+ epoch: serial,
+ next_focus_serial: 0,
+ rollback_error: None,
+ successor: None,
+ };
+
+ // Reconnecting the selected endpoint has no live source surface to release. All normal
+ // handoffs must make the source locally inactive before a target request is even sent.
+ if source_is_target || !source_available {
+ if let Err(error) = activation.start_target(endpoints, resize) {
+ return Err(ActivationBeginError::Partial {
+ activation: Box::new(activation),
+ error,
+ });
+ }
+ } else {
+ // `surface.set(false)` removes the viewer, but old servers only emit the PTY focus
+ // loss while the viewer is still active. Revoke it explicitly before source-off.
+ if endpoints.send_to(
+ &activation.source.endpoint_id,
+ &crate::protocol::ClientMessage::ClientShellFocus { focused: false },
+ ) != EndpointSendOutcome::Sent
+ {
+ return Err(ActivationBeginError::Partial {
+ activation: Box::new(activation),
+ error: "source endpoint focus revoke could not be sent".into(),
+ });
+ }
+ let request = source_release_request.expect("validated source release request");
+ if endpoints.send_to(&activation.source.endpoint_id, &request)
+ != EndpointSendOutcome::Sent
+ {
+ return Err(ActivationBeginError::Partial {
+ activation: Box::new(activation),
+ error: "source endpoint release could not be sent".into(),
+ });
+ }
+ // This is deliberately before the acknowledgement: the source is no longer viewed
+ // locally while its release is in flight, and pane input is consequently blocked.
+ endpoints.set_surface_active(&activation.source.endpoint_id, false);
+ }
+ Ok(activation)
+ }
+
+ pub(crate) fn target(&self) -> &ClientEndpointId {
+ &self.target.endpoint_id
+ }
+
+ fn geometry(&self) -> crate::protocol::ClientSurfaceSize {
+ resize_geometry(&self.resize).expect("activation resize was validated before construction")
+ }
+
+ pub(crate) fn presentation_sync_endpoint(&self) -> Option<&ClientEndpointId> {
+ match self.phase {
+ ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id),
+ ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id),
+ _ => None,
+ }
+ }
+
+ /// The complete source command lane cannot safely cross source-off into a later presentation
+ /// epoch. Other endpoint lanes are not part of this retirement.
+ pub(crate) fn source_command_lane(&self) -> Option<&ClientEndpointId> {
+ (self.source_available && self.source.endpoint_id != self.target.endpoint_id)
+ .then_some(&self.source.endpoint_id)
+ }
+
+ pub(crate) fn can_retarget(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.target.endpoint_id == *endpoint_id
+ && self.successor.is_none()
+ && matches!(
+ self.phase,
+ ActivationPhase::ReleasingSource { .. } | ActivationPhase::ActivatingTarget { .. }
+ )
+ }
+
+ /// Replace an in-flight handoff with the latest endpoint-qualified intent. The current
+ /// transaction is still reversed through target-off/source-on; the replacement is launched
+ /// by the caller only after the source's coherent restoration commits.
+ pub(crate) fn supersede(
+ &mut self,
+ endpoint_id: ClientEndpointId,
+ target: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ endpoints: &mut EndpointRegistry,
+ ) -> ActivationRollback {
+ self.successor = Some(EndpointActivationIntent {
+ endpoint_id,
+ target,
+ });
+ // Source-on is already ordered and must finish before any replacement is allowed to
+ // begin. Later rapid selections only replace the retained intent; they never turn a
+ // safe restoration into an unavailable state.
+ let source_restoration_in_flight = matches!(
+ self.phase,
+ ActivationPhase::RestoringSource { .. }
+ ) || matches!(
+ &self.phase,
+ ActivationPhase::SynchronizingPresentation { completion, .. }
+ if matches!(completion.as_ref(), ActivationCompletion::RestoredSource { .. })
+ );
+ if source_restoration_in_flight {
+ return ActivationRollback::Pending;
+ }
+ self.rollback(
+ endpoints,
+ "endpoint handoff superseded by a newer selection".into(),
+ false,
+ )
+ }
+
+ pub(crate) fn accepts_endpoint(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool {
+ (self.source_available
+ && self.source.endpoint_id == *endpoint_id
+ && self.source.generation == generation)
+ || (self.target.endpoint_id == *endpoint_id && self.target.generation == generation)
+ }
+
+ pub(crate) fn involves_endpoint(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.source.endpoint_id == *endpoint_id || self.target.endpoint_id == *endpoint_id
+ }
+
+ pub(crate) fn accepts_response(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ boot_id: &str,
+ request_id: &str,
+ ) -> bool {
+ match &self.phase {
+ ActivationPhase::ReleasingSource {
+ request_id: expected,
+ } => {
+ endpoint_matches(&self.source, endpoint_id, generation, boot_id)
+ && expected == request_id
+ }
+ ActivationPhase::ActivatingTarget {
+ request_id: expected,
+ focus_request_id,
+ ..
+ } => {
+ endpoint_matches(&self.target, endpoint_id, generation, boot_id)
+ && (expected == request_id || focus_request_id.as_deref() == Some(request_id))
+ }
+ ActivationPhase::ReleasingTargetForRollback {
+ request_id: expected,
+ } => {
+ endpoint_matches(&self.target, endpoint_id, generation, boot_id)
+ && expected == request_id
+ }
+ ActivationPhase::RestoringSource {
+ request_id: expected,
+ ..
+ } => {
+ endpoint_matches(&self.source, endpoint_id, generation, boot_id)
+ && expected == request_id
+ }
+ ActivationPhase::SynchronizingPresentation {
+ lease,
+ request_id: expected,
+ ..
+ } => {
+ endpoint_matches(lease, endpoint_id, generation, boot_id) && expected == request_id
+ }
+ ActivationPhase::AwaitingPresentationEffects { .. } => false,
+ }
+ }
+
+ pub(crate) fn expired(&self, now: Instant) -> bool {
+ now >= self.deadline
+ }
+
+ #[cfg(test)]
+ pub(crate) fn receive_response(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ request_id: &str,
+ data: &[u8],
+ endpoints: &mut EndpointRegistry,
+ ) -> SurfaceActivationProgress {
+ let boot_id = if self.source.endpoint_id == *endpoint_id {
+ self.source.boot_id.clone()
+ } else {
+ self.target.boot_id.clone()
+ };
+ self.receive_response_for_boot(
+ endpoint_id,
+ generation,
+ &boot_id,
+ request_id,
+ data,
+ endpoints,
+ )
+ }
+
+ pub(crate) fn receive_response_for_boot(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ boot_id: &str,
+ request_id: &str,
+ data: &[u8],
+ endpoints: &mut EndpointRegistry,
+ ) -> SurfaceActivationProgress {
+ if !self.accepts_response(endpoint_id, generation, boot_id, request_id) {
+ return SurfaceActivationProgress::Stale;
+ }
+ let result = match decode_endpoint_response(request_id, data) {
+ Ok(result) => result,
+ Err(error) => {
+ return SurfaceActivationProgress::Rejected {
+ source_release_rejected: matches!(
+ self.phase,
+ ActivationPhase::ReleasingSource { .. }
+ ) && error.code.is_some(),
+ message: error.message,
+ };
+ }
+ };
+ match &mut self.phase {
+ ActivationPhase::ReleasingSource { .. } => {
+ if let Err(message) = surface_set_revision(&result, false) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ if let Err(message) = self.start_target(endpoints, self.resize.clone()) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ SurfaceActivationProgress::Pending
+ }
+ ActivationPhase::ActivatingTarget {
+ request_id: surface_request_id,
+ acknowledged_revision,
+ ..
+ } if surface_request_id == request_id => {
+ let revision = match surface_set_revision(&result, true) {
+ Ok(revision) => revision,
+ Err(message) => {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ };
+ *acknowledged_revision = Some(revision);
+ self.progress()
+ }
+ ActivationPhase::ActivatingTarget {
+ focus_request_id,
+ focus_request_target,
+ focus_acknowledged,
+ ..
+ } if focus_request_id.as_deref() == Some(request_id) => {
+ let requested = focus_request_target.clone();
+ let Some(requested) = requested else {
+ return SurfaceActivationProgress::Stale;
+ };
+ if !focus_result_matches(Some(&requested), &result) {
+ return SurfaceActivationProgress::Rejected {
+ message: "endpoint focus returned an unexpected result".into(),
+ source_release_rejected: false,
+ };
+ }
+ *focus_request_id = None;
+ *focus_request_target = None;
+ if self.focus != Some(requested) {
+ *focus_acknowledged = false;
+ if let Err(message) = self.send_latest_focus(endpoints) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ return self.progress();
+ }
+ *focus_acknowledged = true;
+ self.progress()
+ }
+ ActivationPhase::ReleasingTargetForRollback { .. } => {
+ if let Err(message) = surface_set_revision(&result, false) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ endpoints.set_surface_active(&self.target.endpoint_id, false);
+ if !self.source_available {
+ return SurfaceActivationProgress::Rejected {
+ message: self.rollback_error.clone().unwrap_or_else(|| {
+ "the previous endpoint is no longer connected".into()
+ }),
+ source_release_rejected: false,
+ };
+ }
+ if let Err(message) = self.start_source_restore(endpoints, self.resize.clone()) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ SurfaceActivationProgress::Pending
+ }
+ ActivationPhase::RestoringSource {
+ acknowledged_revision,
+ ..
+ } => {
+ let revision = match surface_set_revision(&result, true) {
+ Ok(revision) => revision,
+ Err(message) => {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ };
+ *acknowledged_revision = Some(revision);
+ self.progress()
+ }
+ ActivationPhase::SynchronizingPresentation {
+ acknowledged_revision,
+ ..
+ } => {
+ let revision = match surface_set_revision(&result, true) {
+ Ok(revision) => revision,
+ Err(message) => {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ };
+ *acknowledged_revision = Some(revision);
+ self.progress()
+ }
+ _ => SurfaceActivationProgress::Stale,
+ }
+ }
+
+ pub(crate) fn receive_snapshot(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ snapshot: &crate::protocol::ClientShellSnapshot,
+ ) -> SurfaceActivationProgress {
+ let lease = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => &self.target,
+ ActivationPhase::RestoringSource { .. } => &self.source,
+ ActivationPhase::SynchronizingPresentation { lease, .. } => lease,
+ _ => return SurfaceActivationProgress::Stale,
+ };
+ if !endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id)
+ || snapshot.revision < lease.minimum_revision
+ {
+ return SurfaceActivationProgress::Stale;
+ }
+ let evidence = match &mut self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. }
+ if endpoint_matches(&self.target, endpoint_id, generation, &snapshot.boot_id) =>
+ {
+ evidence
+ }
+ ActivationPhase::RestoringSource { evidence, .. }
+ if endpoint_matches(&self.source, endpoint_id, generation, &snapshot.boot_id) =>
+ {
+ evidence
+ }
+ ActivationPhase::SynchronizingPresentation {
+ lease, evidence, ..
+ } if endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id) => evidence,
+ _ => return SurfaceActivationProgress::Stale,
+ };
+ evidence.record_snapshot(snapshot);
+ self.progress()
+ }
+
+ pub(crate) fn receive_surface(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ surface: crate::protocol::PaneSurfaceFrame,
+ ) -> SurfaceActivationProgress {
+ let lease = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => &self.target,
+ ActivationPhase::RestoringSource { .. } => &self.source,
+ ActivationPhase::SynchronizingPresentation { lease, .. } => lease,
+ _ => return SurfaceActivationProgress::Stale,
+ };
+ if !endpoint_matches(lease, endpoint_id, generation, &surface.boot_id) {
+ return SurfaceActivationProgress::Stale;
+ }
+ if !surface_matches_geometry(&surface, self.geometry()) {
+ return SurfaceActivationProgress::Pending;
+ }
+ match &mut self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. }
+ | ActivationPhase::RestoringSource { evidence, .. }
+ | ActivationPhase::SynchronizingPresentation { evidence, .. } => {
+ evidence.record_surface(surface)
+ }
+ _ => unreachable!("checked activation phase"),
+ }
+ self.progress()
+ }
+
+ pub(crate) fn receive_presentation_effects_ready(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ token: &str,
+ ) -> SurfaceActivationProgress {
+ let ActivationPhase::AwaitingPresentationEffects {
+ lease,
+ token: expected,
+ ready,
+ ..
+ } = &mut self.phase
+ else {
+ return SurfaceActivationProgress::Stale;
+ };
+ if lease.endpoint_id != *endpoint_id || lease.generation != generation || expected != token
+ {
+ return SurfaceActivationProgress::Stale;
+ }
+ *ready = true;
+ SurfaceActivationProgress::Ready
+ }
+
+ /// A same-endpoint navigation request replaces the desired target but never joins the
+ /// in-flight focus RPC. Once that request resolves, `send_latest_focus` sends only the most
+ /// recent desired target.
+ pub(crate) fn retarget(
+ &mut self,
+ focus: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<(), String> {
+ self.focus = focus;
+ if let ActivationPhase::ActivatingTarget {
+ focus_request_id,
+ focus_acknowledged,
+ ..
+ } = &mut self.phase
+ {
+ *focus_acknowledged = self.focus.is_none() && focus_request_id.is_none();
+ } else {
+ // The latest target is retained and will be sent immediately after source release.
+ return Ok(());
+ }
+ self.send_latest_focus(endpoints)
+ }
+
+ pub(crate) fn update_resize(
+ &mut self,
+ resize: crate::protocol::ClientMessage,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<(), String> {
+ resize_geometry(&resize)
+ .ok_or_else(|| "endpoint activation did not include a surface resize".to_owned())?;
+ self.resize = resize.clone();
+ let restart_effects_fence = match &self.phase {
+ ActivationPhase::AwaitingPresentationEffects {
+ lease, completion, ..
+ } => Some((lease.clone(), (**completion).clone())),
+ _ => None,
+ };
+ if let Some((lease, completion)) = restart_effects_fence {
+ if endpoints.send_to(&lease.endpoint_id, &resize) != EndpointSendOutcome::Sent {
+ return Err("pending endpoint resize could not be sent".into());
+ }
+ return self.start_presentation_sync(endpoints, lease, completion);
+ }
+ match &mut self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. }
+ | ActivationPhase::RestoringSource { evidence, .. }
+ | ActivationPhase::SynchronizingPresentation { evidence, .. } => {
+ evidence.invalidate_surface()
+ }
+ _ => {}
+ }
+ let destination = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id),
+ ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id),
+ ActivationPhase::SynchronizingPresentation { lease, .. } => Some(&lease.endpoint_id),
+ _ => None,
+ };
+ if let Some(destination) = destination {
+ if endpoints.send_to(destination, &resize) != EndpointSendOutcome::Sent {
+ return Err("pending endpoint resize could not be sent".into());
+ }
+ }
+ Ok(())
+ }
+
+ pub(crate) fn update_host_focus(
+ &mut self,
+ focused: bool,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<(), String> {
+ self.host_focused = focused;
+ let restart = self.presentation_restart();
+ let destination = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id),
+ ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id),
+ ActivationPhase::SynchronizingPresentation { lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { lease, .. } => {
+ Some(&lease.endpoint_id)
+ }
+ _ => None,
+ };
+ if let Some(destination) = destination {
+ if endpoints.send_to(
+ destination,
+ &crate::protocol::ClientMessage::ClientShellFocus { focused },
+ ) != EndpointSendOutcome::Sent
+ {
+ return Err("pending endpoint focus baseline could not be sent".into());
+ }
+ }
+ if let Some((lease, completion)) = restart {
+ self.start_presentation_sync(endpoints, lease, completion)?;
+ } else {
+ self.invalidate_current_evidence();
+ }
+ Ok(())
+ }
+
+ pub(crate) fn update_host_theme(
+ &mut self,
+ update: crate::protocol::ClientHostThemeUpdate,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<(), String> {
+ let restart = self.presentation_restart();
+ let destination = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id),
+ ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id),
+ ActivationPhase::SynchronizingPresentation { lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { lease, .. } => {
+ Some(&lease.endpoint_id)
+ }
+ _ => None,
+ };
+ if let Some(destination) = destination {
+ let message = crate::protocol::ClientMessage::ClientShellHostTheme { update };
+ if endpoints.send_to(destination, &message) != EndpointSendOutcome::Sent {
+ return Err("pending endpoint host theme could not be sent".into());
+ }
+ }
+ if let Some((lease, completion)) = restart {
+ self.start_presentation_sync(endpoints, lease, completion)?;
+ } else {
+ self.invalidate_current_evidence();
+ }
+ Ok(())
+ }
+
+ fn presentation_restart(&self) -> Option<(EndpointLease, ActivationCompletion)> {
+ match &self.phase {
+ ActivationPhase::SynchronizingPresentation {
+ lease, completion, ..
+ }
+ | ActivationPhase::AwaitingPresentationEffects {
+ lease, completion, ..
+ } => Some((lease.clone(), (**completion).clone())),
+ _ => None,
+ }
+ }
+
+ fn invalidate_current_evidence(&mut self) {
+ match &mut self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. }
+ | ActivationPhase::RestoringSource { evidence, .. } => evidence.invalidate_surface(),
+ _ => {}
+ }
+ }
+
+ /// Losing the source revokes its surface and removes the rollback destination; it must not
+ /// cancel a healthy target. Losing the target restores the source when it is still available.
+ pub(crate) fn endpoint_disconnected(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ endpoint_id: &ClientEndpointId,
+ error: String,
+ ) -> ActivationRollback {
+ self.rollback_error = Some(error.clone());
+ if self.target.endpoint_id == *endpoint_id && self.source.endpoint_id != *endpoint_id {
+ return match self.phase {
+ ActivationPhase::RestoringSource { .. } => ActivationRollback::Pending,
+ _ => match self.start_source_restore(endpoints, self.resize.clone()) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(restore_error) => ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint could not be restored safely: {restore_error}"
+ )),
+ },
+ };
+ }
+ if self.source.endpoint_id != *endpoint_id {
+ return ActivationRollback::Unavailable(error);
+ }
+ self.source_available = false;
+ if self.source.endpoint_id != self.target.endpoint_id {
+ match &self.phase {
+ ActivationPhase::ReleasingSource { .. } => {
+ return match self.start_target(endpoints, self.resize.clone()) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(message) => ActivationRollback::Unavailable(message),
+ };
+ }
+ ActivationPhase::ActivatingTarget { .. } => return ActivationRollback::Pending,
+ ActivationPhase::SynchronizingPresentation { lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { lease, .. }
+ if lease.endpoint_id == self.target.endpoint_id =>
+ {
+ return ActivationRollback::Pending
+ }
+ _ => {}
+ }
+ }
+ match self.phase {
+ ActivationPhase::ReleasingSource { .. } => ActivationRollback::Unavailable(error),
+ ActivationPhase::ActivatingTarget { .. } => {
+ match self.start_target_release(endpoints) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(release_error) => ActivationRollback::Unavailable(format!(
+ "{error}; target endpoint could not be released safely: {release_error}"
+ )),
+ }
+ }
+ ActivationPhase::ReleasingTargetForRollback { .. } => ActivationRollback::Pending,
+ ActivationPhase::RestoringSource { .. } => ActivationRollback::Unavailable(error),
+ ActivationPhase::SynchronizingPresentation { ref lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => {
+ if lease.endpoint_id == self.target.endpoint_id {
+ match self.start_target_release(endpoints) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(release_error) => ActivationRollback::Unavailable(format!(
+ "{error}; target endpoint could not be released safely: {release_error}"
+ )),
+ }
+ } else {
+ ActivationRollback::Unavailable(error)
+ }
+ }
+ }
+ }
+
+ pub(crate) fn rollback(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ error: String,
+ source_release_rejected: bool,
+ ) -> ActivationRollback {
+ self.rollback_error = Some(error.clone());
+ if matches!(self.phase, ActivationPhase::ReleasingSource { .. }) && source_release_rejected
+ {
+ // Rejection proves source-off did not commit, but cached source metadata may have
+ // advanced while the frame was frozen. Restore through the same coherent on/sync
+ // path rather than immediately exposing a stale source projection.
+ return match self.start_source_restore(endpoints, self.resize.clone()) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(restore_error) => ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint could not resume: {restore_error}"
+ )),
+ };
+ }
+ let result = match self.phase {
+ ActivationPhase::ReleasingSource { .. } => {
+ if self.source_available {
+ self.start_source_restore(endpoints, self.resize.clone())
+ } else {
+ Err("the previous endpoint is no longer connected".into())
+ }
+ }
+ ActivationPhase::ActivatingTarget { .. } => self.start_target_release(endpoints),
+ ActivationPhase::ReleasingTargetForRollback { .. } => {
+ // The target may have observed target-on or target-off. Closing this transport
+ // is the only safe local revocation when target-off is not acknowledged.
+ endpoints.fail(
+ &self.target.endpoint_id,
+ std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "endpoint did not acknowledge surface revocation",
+ ),
+ );
+ if !self.source_available {
+ return ActivationRollback::Unavailable(format!(
+ "{error}; the target connection was closed because no presentation owner could be proven"
+ ));
+ }
+ self.start_source_restore(endpoints, self.resize.clone())
+ }
+ ActivationPhase::RestoringSource { .. } => {
+ return ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint could not be restored"
+ ));
+ }
+ ActivationPhase::SynchronizingPresentation { ref lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => {
+ if lease.endpoint_id == self.target.endpoint_id {
+ self.start_target_release(endpoints)
+ } else {
+ return ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint presentation could not be synchronized"
+ ));
+ }
+ }
+ };
+ match result {
+ Ok(()) => ActivationRollback::Pending,
+ Err(rollback_error) => ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint could not be restored safely: {rollback_error}"
+ )),
+ }
+ }
+
+ pub(crate) fn complete(
+ &mut self,
+ shell: &mut crate::client::shell::ClientShellState,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<ActivationCompletion, String> {
+ if let ActivationPhase::SynchronizingPresentation {
+ lease,
+ acknowledged_revision,
+ evidence,
+ completion,
+ ..
+ } = &self.phase
+ {
+ let lease = lease.clone();
+ let completion = (**completion).clone();
+ let surface = coherent_completion_surface(
+ shell,
+ &lease,
+ evidence,
+ *acknowledged_revision,
+ self.geometry(),
+ )?;
+ if endpoints.active_id() != &lease.endpoint_id
+ || !shell.endpoint_projection_available(&lease.endpoint_id)
+ || !shell.activate_endpoint_projection(&lease.endpoint_id)
+ {
+ return Err(
+ "endpoint became unavailable during presentation synchronization".into(),
+ );
+ }
+ shell.set_pane_surface(surface);
+ self.start_presentation_effects_fence(endpoints, lease, completion)?;
+ return Ok(ActivationCompletion::AwaitingPresentationEffects);
+ }
+ if let ActivationPhase::AwaitingPresentationEffects {
+ ready: true,
+ completion,
+ ..
+ } = &self.phase
+ {
+ return Ok((**completion).clone());
+ }
+
+ let (lease, evidence, acknowledgement_revision, completion) = match &self.phase {
+ ActivationPhase::ActivatingTarget {
+ evidence,
+ acknowledged_revision,
+ ..
+ } => {
+ let completion = ActivationCompletion::Activated;
+ (
+ self.target.clone(),
+ evidence,
+ *acknowledged_revision,
+ completion,
+ )
+ }
+ ActivationPhase::RestoringSource {
+ evidence,
+ acknowledged_revision,
+ ..
+ } => {
+ let completion = ActivationCompletion::RestoredSource {
+ error: self
+ .rollback_error
+ .clone()
+ .unwrap_or_else(|| "endpoint handoff was rolled back".into()),
+ successor: self.successor.clone(),
+ };
+ (
+ self.source.clone(),
+ evidence,
+ *acknowledged_revision,
+ completion,
+ )
+ }
+ _ => return Err("endpoint activation completed in an invalid phase".into()),
+ };
+ let surface = coherent_completion_surface(
+ shell,
+ &lease,
+ evidence,
+ acknowledgement_revision,
+ self.geometry(),
+ )?;
+ endpoints.set_surface_active(&lease.endpoint_id, true);
+ shell.set_endpoint_status(&lease.endpoint_id, ClientEndpointStatus::Online);
+ if !shell.endpoint_projection_available(&lease.endpoint_id)
+ || !endpoints.set_active(&lease.endpoint_id)
+ {
+ return Err("endpoint became unavailable during activation".into());
+ }
+ let activated = shell.activate_endpoint_projection(&lease.endpoint_id);
+ debug_assert!(activated, "preflighted endpoint projection must activate");
+ debug_assert!(shell.endpoint_is_active(endpoints.active_id()));
+ shell.set_pane_surface(surface);
+ self.start_presentation_sync(endpoints, lease.clone(), completion)?;
+ Ok(ActivationCompletion::AwaitingPresentationSync {
+ previous: self.source.endpoint_id.clone(),
+ endpoint: lease.endpoint_id,
+ })
+ }
+
+ fn start_target(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ resize: crate::protocol::ClientMessage,
+ ) -> Result<(), String> {
+ let request_id = format!("client-shell-surface:{}:on", self.epoch);
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ // A transport may fail after writing any baseline or surface message. Enter the target
+ // phase first so every uncertain target write is reversed through target-off before
+ // source restoration is considered.
+ self.phase = ActivationPhase::ActivatingTarget {
+ request_id: request_id.clone(),
+ acknowledged_revision: None,
+ focus_request_id: None,
+ focus_request_target: None,
+ focus_acknowledged: self.focus.is_none(),
+ evidence: ActivationEvidence::default(),
+ };
+ send_surface_activation(
+ endpoints,
+ &self.target,
+ request_id,
+ &resize,
+ self.host_focused,
+ )?;
+
+ // From this point the target may have processed surface.set(true). Optional navigation
+ // is serialized through one coalescing focus lane.
+ self.send_latest_focus(endpoints)
+ }
+
+ fn start_presentation_sync(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ lease: EndpointLease,
+ completion: ActivationCompletion,
+ ) -> Result<(), String> {
+ let request_id = format!("client-shell-surface:{}:presentation-sync", self.epoch);
+ let request = surface_interest_request(&lease.boot_id, request_id.clone(), true)
+ .map_err(|error| error.to_string())?;
+ self.phase = ActivationPhase::SynchronizingPresentation {
+ lease: lease.clone(),
+ request_id,
+ acknowledged_revision: None,
+ evidence: ActivationEvidence::default(),
+ completion: Box::new(completion),
+ };
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ if endpoints.send_to(&lease.endpoint_id, &request) != EndpointSendOutcome::Sent {
+ return Err("endpoint presentation synchronization could not be sent".into());
+ }
+ Ok(())
+ }
+
+ fn start_presentation_effects_fence(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ lease: EndpointLease,
+ completion: ActivationCompletion,
+ ) -> Result<(), String> {
+ let token = format!("{}:{}:{}", self.epoch, lease.generation, lease.boot_id);
+ self.phase = ActivationPhase::AwaitingPresentationEffects {
+ lease: lease.clone(),
+ token: token.clone(),
+ ready: false,
+ completion: Box::new(completion),
+ };
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ let message = crate::protocol::ClientMessage::EndpointControl {
+ kind: crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND.into(),
+ data: token,
+ };
+ if endpoints.send_to(&lease.endpoint_id, &message) != EndpointSendOutcome::Sent {
+ return Err("endpoint presentation effects fence could not be sent".into());
+ }
+ Ok(())
+ }
+
+ fn start_target_release(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> {
+ let request_id = format!("client-shell-surface:{}:rollback-target-off", self.epoch);
+ let request = surface_interest_request(&self.target.boot_id, request_id.clone(), false)
+ .map_err(|error| error.to_string())?;
+ // Set the rollback phase before the potentially observed target-off write.
+ self.phase = ActivationPhase::ReleasingTargetForRollback { request_id };
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent {
+ return Err("target endpoint release could not be sent".into());
+ }
+ Ok(())
+ }
+
+ fn start_source_restore(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ resize: crate::protocol::ClientMessage,
+ ) -> Result<(), String> {
+ let request_id = format!("client-shell-surface:{}:rollback-source-on", self.epoch);
+ // Source baseline writes can also be observed before their send reports an error.
+ self.phase = ActivationPhase::RestoringSource {
+ request_id: request_id.clone(),
+ acknowledged_revision: None,
+ evidence: ActivationEvidence::default(),
+ };
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ send_surface_activation(
+ endpoints,
+ &self.source,
+ request_id,
+ &resize,
+ self.host_focused,
+ )
+ }
+
+ fn send_latest_focus(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> {
+ let desired = self.focus.clone();
+ let Some(desired) = desired else {
+ if let ActivationPhase::ActivatingTarget {
+ focus_request_id,
+ focus_acknowledged,
+ ..
+ } = &mut self.phase
+ {
+ if focus_request_id.is_none() {
+ *focus_acknowledged = true;
+ }
+ }
+ return Ok(());
+ };
+ if !matches!(
+ &self.phase,
+ ActivationPhase::ActivatingTarget {
+ focus_request_id: None,
+ ..
+ }
+ ) {
+ return Ok(());
+ }
+ let request_id = self
+ .next_focus_request_id()
+ .expect("desired focus creates a request id");
+ if let ActivationPhase::ActivatingTarget {
+ focus_request_id,
+ focus_request_target,
+ focus_acknowledged,
+ ..
+ } = &mut self.phase
+ {
+ *focus_acknowledged = false;
+ *focus_request_id = Some(request_id.clone());
+ *focus_request_target = Some(desired.clone());
+ }
+ let request = focus_request(&self.target.boot_id, request_id, &desired)
+ .map_err(|error| error.to_string())?;
+ if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent {
+ return Err("endpoint focus could not be sent".into());
+ }
+ Ok(())
+ }
+
+ fn next_focus_request_id(&mut self) -> Option<String> {
+ self.focus.as_ref()?;
+ self.next_focus_serial = self.next_focus_serial.saturating_add(1);
+ Some(format!(
+ "client-shell-focus:{}:{}",
+ self.epoch, self.next_focus_serial
+ ))
+ }
+
+ fn progress(&self) -> SurfaceActivationProgress {
+ match &self.phase {
+ ActivationPhase::ActivatingTarget {
+ acknowledged_revision,
+ focus_acknowledged,
+ evidence,
+ ..
+ } if acknowledged_revision.is_some_and(|revision| {
+ *focus_acknowledged
+ && evidence
+ .coherent_surface(revision, self.geometry())
+ .is_some_and(|surface| self.target_matches(surface))
+ }) =>
+ {
+ SurfaceActivationProgress::Ready
+ }
+ ActivationPhase::RestoringSource {
+ acknowledged_revision,
+ evidence,
+ ..
+ }
+ | ActivationPhase::SynchronizingPresentation {
+ acknowledged_revision,
+ evidence,
+ ..
+ } if acknowledged_revision.is_some_and(|revision| {
+ evidence
+ .coherent_surface(revision, self.geometry())
+ .is_some()
+ }) =>
+ {
+ SurfaceActivationProgress::Ready
+ }
+ _ => SurfaceActivationProgress::Pending,
+ }
+ }
+
+ fn target_matches(&self, surface: &crate::protocol::PaneSurfaceFrame) -> bool {
+ let evidence = match &self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. } => evidence,
+ _ => return false,
+ };
+ match &self.focus {
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id)) => {
+ evidence.focused_pane_id.as_deref() == Some(pane_id)
+ && surface
+ .panes
+ .iter()
+ .any(|pane| pane.focused && &pane.pane_id == pane_id)
+ }
+ Some(crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id)) => {
+ evidence.focused_tab_id.as_deref() == Some(tab_id)
+ }
+ Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id)) => {
+ evidence.focused_workspace_id.as_deref() == Some(workspace_id)
+ }
+ None => true,
+ }
+ }
+}
+
+#[cfg(test)]
+#[path = "activation_tests.rs"]
+mod tests;
diff --git a/src/client/endpoint/activation/model.rs b/src/client/endpoint/activation/model.rs
new file mode 100644
index 00000000..c94029e9
--- /dev/null
+++ b/src/client/endpoint/activation/model.rs
@@ -0,0 +1,174 @@
+use std::time::Instant;
+
+use super::super::ClientEndpointId;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(super) struct EndpointLease {
+ pub(super) endpoint_id: ClientEndpointId,
+ pub(super) generation: u64,
+ pub(super) boot_id: String,
+ /// The endpoint cache may already be newer than the first activation event. Never let an
+ /// activation prove coherence with a revision that the monotonic cache has discarded.
+ pub(super) minimum_revision: u64,
+}
+
+#[derive(Clone, Debug, Default)]
+pub(super) struct ActivationEvidence {
+ pub(super) snapshot_revision: Option<u64>,
+ pub(super) focused_workspace_id: Option<String>,
+ pub(super) focused_tab_id: Option<String>,
+ pub(super) focused_pane_id: Option<String>,
+ pub(super) surface: Option<crate::protocol::PaneSurfaceFrame>,
+}
+
+impl ActivationEvidence {
+ pub(super) fn record_snapshot(&mut self, snapshot: &crate::protocol::ClientShellSnapshot) {
+ if self
+ .snapshot_revision
+ .is_none_or(|current| snapshot.revision >= current)
+ {
+ self.snapshot_revision = Some(snapshot.revision);
+ self.focused_workspace_id = snapshot.focused_workspace_id.clone();
+ self.focused_tab_id = snapshot.focused_tab_id.clone();
+ self.focused_pane_id = snapshot.focused_pane_id.clone();
+ }
+ }
+
+ pub(super) fn record_surface(&mut self, surface: crate::protocol::PaneSurfaceFrame) {
+ let replace = self.surface.as_ref().is_none_or(|current| {
+ surface.projection_revision > current.projection_revision
+ || (surface.projection_revision == current.projection_revision
+ && surface.surface_revision >= current.surface_revision)
+ });
+ if replace {
+ self.surface = Some(surface);
+ }
+ }
+
+ pub(super) fn invalidate_surface(&mut self) {
+ self.surface = None;
+ }
+
+ pub(super) fn coherent_surface(
+ &self,
+ minimum_revision: u64,
+ geometry: crate::protocol::ClientSurfaceSize,
+ ) -> Option<&crate::protocol::PaneSurfaceFrame> {
+ self.surface.as_ref().filter(|surface| {
+ self.snapshot_revision == Some(surface.projection_revision)
+ && surface.projection_revision >= minimum_revision
+ && super::surface_matches_geometry(surface, geometry)
+ })
+ }
+}
+
+#[derive(Clone, Debug)]
+pub(super) enum ActivationPhase {
+ ReleasingSource {
+ request_id: String,
+ },
+ ActivatingTarget {
+ request_id: String,
+ acknowledged_revision: Option<u64>,
+ /// At most one focus request may be in flight. Retargets only replace `focus` until
+ /// this response arrives, at which point the latest target is sent.
+ focus_request_id: Option<String>,
+ focus_request_target: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ focus_acknowledged: bool,
+ evidence: ActivationEvidence,
+ },
+ ReleasingTargetForRollback {
+ request_id: String,
+ },
+ RestoringSource {
+ request_id: String,
+ acknowledged_revision: Option<u64>,
+ evidence: ActivationEvidence,
+ },
+ SynchronizingPresentation {
+ lease: EndpointLease,
+ request_id: String,
+ acknowledged_revision: Option<u64>,
+ evidence: ActivationEvidence,
+ completion: Box<ActivationCompletion>,
+ },
+ AwaitingPresentationEffects {
+ lease: EndpointLease,
+ token: String,
+ ready: bool,
+ completion: Box<ActivationCompletion>,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum SurfaceActivationProgress {
+ Pending,
+ Ready,
+ Rejected {
+ message: String,
+ source_release_rejected: bool,
+ },
+ Stale,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum ActivationRollback {
+ /// A correlated rollback request is in flight; retain the frozen frame.
+ Pending,
+ /// Neither endpoint can be made safe to present. Keep pane input frozen while presenting
+ /// client chrome and this error.
+ Unavailable(String),
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum ActivationCompletion {
+ AwaitingPresentationSync {
+ previous: ClientEndpointId,
+ endpoint: ClientEndpointId,
+ },
+ AwaitingPresentationEffects,
+ Activated,
+ RestoredSource {
+ error: String,
+ /// A newer endpoint-qualified selection arrived while this handoff was frozen. It is
+ /// started only after the original source has been coherently restored.
+ successor: Option<EndpointActivationIntent>,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct EndpointActivationIntent {
+ pub(crate) endpoint_id: ClientEndpointId,
+ pub(crate) target: Option<crate::client::shell::ClientEndpointFocusTarget>,
+}
+
+/// Begin failures are separated by whether the transport may already have observed a lifecycle
+/// write. A partial transaction must be kept and rolled back under a frozen presentation.
+#[derive(Debug)]
+pub(crate) enum ActivationBeginError {
+ Preflight(String),
+ Partial {
+ activation: Box<PendingEndpointActivation>,
+ error: String,
+ },
+}
+
+/// The only owner of an endpoint handoff. The registry's active endpoint remains the committed
+/// endpoint until the target commits, while this object owns the uncommitted lifecycle lane.
+#[derive(Debug)]
+pub(crate) struct PendingEndpointActivation {
+ pub(super) source: EndpointLease,
+ pub(super) source_available: bool,
+ pub(super) target: EndpointLease,
+ pub(super) focus: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ pub(super) host_focused: bool,
+ pub(super) resize: crate::protocol::ClientMessage,
+ pub(super) phase: ActivationPhase,
+ pub(super) deadline: Instant,
+ pub(super) epoch: u64,
+ pub(super) next_focus_serial: u64,
+ pub(super) rollback_error: Option<String>,
+ /// A different endpoint was selected while this source-off-first transaction was in flight.
+ /// Keep only the latest intent until source restoration commits.
+ pub(super) successor: Option<EndpointActivationIntent>,
+}
diff --git a/src/client/endpoint/activation/protocol.rs b/src/client/endpoint/activation/protocol.rs
new file mode 100644
index 00000000..569d53f3
--- /dev/null
+++ b/src/client/endpoint/activation/protocol.rs
@@ -0,0 +1,219 @@
+use super::super::{ClientEndpointId, EndpointRegistry, EndpointSendOutcome};
+use super::model::{ActivationEvidence, EndpointLease};
+
+pub(super) fn focus_result_matches(
+ focus: Option<&crate::client::shell::ClientEndpointFocusTarget>,
+ result: &crate::api::schema::ResponseResult,
+) -> bool {
+ match (focus, result) {
+ (
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(expected)),
+ crate::api::schema::ResponseResult::PaneInfo { pane },
+ ) => pane.focused && &pane.pane_id == expected,
+ (
+ Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(expected)),
+ crate::api::schema::ResponseResult::WorkspaceInfo { workspace },
+ ) => workspace.focused && &workspace.workspace_id == expected,
+ (
+ Some(crate::client::shell::ClientEndpointFocusTarget::Tab(expected)),
+ crate::api::schema::ResponseResult::TabInfo { tab },
+ ) => tab.focused && &tab.tab_id == expected,
+ _ => false,
+ }
+}
+
+pub(super) fn endpoint_lease(
+ shell: &crate::client::shell::ClientShellState,
+ endpoints: &EndpointRegistry,
+ endpoint_id: &ClientEndpointId,
+) -> Result<EndpointLease, String> {
+ let connection = endpoints
+ .connection(endpoint_id)
+ .ok_or_else(|| "endpoint connection is unavailable".to_owned())?;
+ let (boot_id, minimum_revision) = shell
+ .endpoint_snapshot_identity(endpoint_id, connection.generation)
+ .ok_or_else(|| "endpoint metadata is not ready for this connection".to_owned())?;
+ Ok(EndpointLease {
+ endpoint_id: endpoint_id.clone(),
+ generation: connection.generation,
+ boot_id: boot_id.to_owned(),
+ minimum_revision,
+ })
+}
+
+pub(super) fn disconnected_endpoint_lease(
+ shell: &crate::client::shell::ClientShellState,
+ endpoint_id: &ClientEndpointId,
+) -> EndpointLease {
+ EndpointLease {
+ endpoint_id: endpoint_id.clone(),
+ generation: 0,
+ boot_id: shell
+ .endpoint_boot_id(endpoint_id)
+ .unwrap_or_default()
+ .to_owned(),
+ minimum_revision: 0,
+ }
+}
+
+pub(super) fn endpoint_matches(
+ lease: &EndpointLease,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ boot_id: &str,
+) -> bool {
+ lease.endpoint_id == *endpoint_id && lease.generation == generation && lease.boot_id == boot_id
+}
+
+pub(super) fn coherent_completion_surface(
+ shell: &crate::client::shell::ClientShellState,
+ lease: &EndpointLease,
+ evidence: &ActivationEvidence,
+ acknowledgement_revision: Option<u64>,
+ geometry: crate::protocol::ClientSurfaceSize,
+) -> Result<crate::protocol::PaneSurfaceFrame, String> {
+ let acknowledgement_revision = acknowledgement_revision.ok_or_else(|| {
+ "endpoint activation completed without a surface acknowledgement".to_owned()
+ })?;
+ let surface = evidence
+ .surface
+ .clone()
+ .ok_or_else(|| "endpoint activation completed without a surface".to_owned())?;
+ if surface.projection_revision < acknowledgement_revision
+ || !surface_matches_geometry(&surface, geometry)
+ {
+ return Err("endpoint activation lost its acknowledged surface evidence".into());
+ }
+ if !shell.endpoint_snapshot_matches(
+ &lease.endpoint_id,
+ lease.generation,
+ &lease.boot_id,
+ surface.projection_revision,
+ ) {
+ return Err("endpoint activation lost its coherent snapshot/surface pair".into());
+ }
+ Ok(surface)
+}
+
+pub(super) fn resize_geometry(
+ message: &crate::protocol::ClientMessage,
+) -> Option<crate::protocol::ClientSurfaceSize> {
+ match message {
+ crate::protocol::ClientMessage::ClientShellResize { surface_size, .. } => {
+ Some(*surface_size)
+ }
+ _ => None,
+ }
+}
+
+pub(super) fn surface_matches_geometry(
+ surface: &crate::protocol::PaneSurfaceFrame,
+ geometry: crate::protocol::ClientSurfaceSize,
+) -> bool {
+ surface.frame.width == geometry.cols && surface.frame.height == geometry.rows
+}
+
+pub(super) fn send_surface_activation(
+ endpoints: &mut EndpointRegistry,
+ target: &EndpointLease,
+ request_id: String,
+ resize: &crate::protocol::ClientMessage,
+ focused: bool,
+) -> Result<(), String> {
+ if endpoints.send_to(&target.endpoint_id, resize) != EndpointSendOutcome::Sent {
+ return Err("endpoint resize could not be sent".into());
+ }
+ let request = surface_interest_request(&target.boot_id, request_id, true)
+ .map_err(|error| error.to_string())?;
+ if endpoints.send_to(&target.endpoint_id, &request) != EndpointSendOutcome::Sent {
+ return Err("endpoint activation could not be sent".into());
+ }
+ // Inactive endpoints reject focus events. Activate first, then establish the host baseline
+ // on the same ordered transport before navigation or presentation can commit.
+ if endpoints.send_to(
+ &target.endpoint_id,
+ &crate::protocol::ClientMessage::ClientShellFocus { focused },
+ ) != EndpointSendOutcome::Sent
+ {
+ return Err("endpoint focus baseline could not be sent".into());
+ }
+ Ok(())
+}
+
+pub(super) fn decode_endpoint_response(
+ request_id: &str,
+ data: &[u8],
+) -> Result<crate::api::schema::ResponseResult, crate::client::shell::ClientShellEndpointError> {
+ crate::client::endpoint_commands::parse_response(request_id, data)
+}
+
+pub(super) fn surface_set_revision(
+ result: &crate::api::schema::ResponseResult,
+ expected_active: bool,
+) -> Result<u64, String> {
+ match result {
+ crate::api::schema::ResponseResult::ClientShellSurfaceSet {
+ active,
+ projection_revision,
+ } if *active == expected_active => Ok(*projection_revision),
+ _ => Err("surface activation returned an invalid acknowledgement".into()),
+ }
+}
+
+pub(super) fn focus_request(
+ boot_id: &str,
+ request_id: String,
+ focus: &crate::client::shell::ClientEndpointFocusTarget,
+) -> std::io::Result<crate::protocol::ClientMessage> {
+ let method = match focus {
+ crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id) => {
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id: workspace_id.clone(),
+ })
+ }
+ crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id) => {
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget {
+ pane_id: pane_id.clone(),
+ })
+ }
+ crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id) => {
+ crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget {
+ tab_id: tab_id.clone(),
+ })
+ }
+ };
+ endpoint_request(
+ boot_id,
+ crate::api::schema::Request {
+ id: request_id,
+ method,
+ },
+ )
+}
+
+pub(super) fn surface_interest_request(
+ boot_id: &str,
+ request_id: String,
+ active: bool,
+) -> std::io::Result<crate::protocol::ClientMessage> {
+ endpoint_request(
+ boot_id,
+ crate::api::schema::Request {
+ id: request_id,
+ method: crate::api::schema::Method::ClientShellSurfaceSet(
+ crate::api::schema::ClientShellSurfaceSetParams { active },
+ ),
+ },
+ )
+}
+
+fn endpoint_request(
+ boot_id: &str,
+ request: crate::api::schema::Request,
+) -> std::io::Result<crate::protocol::ClientMessage> {
+ Ok(crate::protocol::ClientMessage::ClientShellEndpointRequest {
+ boot_id: boot_id.to_owned(),
+ request: serde_json::to_string(&request)
+ .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?,
+ })
+}
diff --git a/src/client/endpoint/activation_tests.rs b/src/client/endpoint/activation_tests.rs
new file mode 100644
index 00000000..b66cb98b
--- /dev/null
+++ b/src/client/endpoint/activation_tests.rs
@@ -0,0 +1,1305 @@
+use super::*;
+
+fn endpoint() -> ClientEndpointId {
+ ClientEndpointId::Ssh(
+ super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ )
+}
+
+fn lease(id: ClientEndpointId, generation: u64, boot: &str) -> EndpointLease {
+ EndpointLease {
+ endpoint_id: id,
+ generation,
+ boot_id: boot.into(),
+ minimum_revision: 0,
+ }
+}
+
+#[derive(Clone)]
+struct FakeTransport {
+ sent: std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>,
+ fail_after_write: bool,
+}
+
+impl super::super::EndpointTransport for FakeTransport {
+ fn send(&mut self, message: &crate::protocol::ClientMessage) -> std::io::Result<()> {
+ self.sent.lock().unwrap().push(message.clone());
+ if self.fail_after_write {
+ Err(std::io::Error::other("simulated observed write failure"))
+ } else {
+ Ok(())
+ }
+ }
+}
+
+fn negotiation() -> super::super::EndpointNegotiation {
+ super::super::EndpointNegotiation::new(
+ vec!["client_shell.surface.set".into()],
+ vec![
+ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(),
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(),
+ ],
+ )
+}
+
+fn test_snapshot(boot_id: &str, revision: u64) -> crate::protocol::ClientShellSnapshot {
+ crate::protocol::ClientShellSnapshot {
+ boot_id: boot_id.into(),
+ revision,
+ config_diagnostic: None,
+ product_announcement: None,
+ update_available: None,
+ update_install_command: String::new(),
+ server_keybindings_toml: None,
+ latest_release_notes_available: false,
+ integration_updates_available: false,
+ worktree_directory: String::new(),
+ release_notes: None,
+ focused_workspace_id: None,
+ focused_tab_id: None,
+ focused_pane_id: None,
+ tab_bar_right: Vec::new(),
+ tab_bar_right_separator: String::new(),
+ agent_view_label: None,
+ agent_order: Vec::new(),
+ workspaces: Vec::new(),
+ tabs: Vec::new(),
+ panes: Vec::new(),
+ agents: Vec::new(),
+ commands: Vec::new(),
+ }
+}
+
+type SentMessages = std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>;
+type TestFixture = (
+ crate::client::ClientShellState,
+ EndpointRegistry,
+ SentMessages,
+ SentMessages,
+);
+
+fn shell_and_registry() -> TestFixture {
+ shell_and_registry_with_source_failure(false)
+}
+
+fn shell_and_registry_with_source_failure(source_fail_after_write: bool) -> TestFixture {
+ let mut shell = crate::client::ClientShellState::new(
+ crate::client::ClientShellConfig::from_config(&crate::config::Config::default()),
+ );
+ let profile = super::super::SavedSshEndpoint {
+ id: super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Remote".into(),
+ target: "dev@example.com".into(),
+ session: "main".into(),
+ enabled: true,
+ };
+ let target = ClientEndpointId::Ssh(profile.id.clone());
+ shell.set_endpoint_catalog(&[profile]);
+ shell.set_snapshot(Box::new(test_snapshot("local-boot", 1)));
+ shell.set_endpoint_status(&target, ClientEndpointStatus::Online);
+ shell.set_endpoint_snapshot(&target, Box::new(test_snapshot("remote-boot", 1)));
+
+ let local_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+ let remote_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+ let mut endpoints = EndpointRegistry::new(
+ FakeTransport {
+ sent: local_sent.clone(),
+ fail_after_write: source_fail_after_write,
+ },
+ 1,
+ negotiation(),
+ );
+ endpoints.insert(
+ target,
+ FakeTransport {
+ sent: remote_sent.clone(),
+ fail_after_write: false,
+ },
+ 7,
+ negotiation(),
+ false,
+ );
+ (shell, endpoints, local_sent, remote_sent)
+}
+
+fn surface_success(id: &str, active: bool, projection_revision: u64) -> Vec<u8> {
+ serde_json::to_vec(&crate::api::schema::SuccessResponse {
+ id: id.into(),
+ result: crate::api::schema::ResponseResult::ClientShellSurfaceSet {
+ active,
+ projection_revision,
+ },
+ })
+ .unwrap()
+}
+
+fn workspace_focus_success(id: &str, workspace_id: &str) -> Vec<u8> {
+ serde_json::to_vec(&crate::api::schema::SuccessResponse {
+ id: id.into(),
+ result: crate::api::schema::ResponseResult::WorkspaceInfo {
+ workspace: crate::api::schema::WorkspaceInfo {
+ workspace_id: workspace_id.into(),
+ number: 1,
+ label: workspace_id.into(),
+ focused: true,
+ pane_count: 1,
+ tab_count: 1,
+ active_tab_id: "tab".into(),
+ agent_status: crate::api::schema::AgentStatus::Unknown,
+ tokens: Default::default(),
+ worktree: None,
+ },
+ },
+ })
+ .unwrap()
+}
+
+fn failure(id: &str, message: &str) -> Vec<u8> {
+ serde_json::to_vec(&crate::api::schema::ErrorResponse {
+ id: id.into(),
+ error: crate::api::schema::ErrorBody {
+ code: "surface_rejected".into(),
+ message: message.into(),
+ },
+ })
+ .unwrap()
+}
+
+fn surface_set_active(message: &crate::protocol::ClientMessage) -> Option<bool> {
+ let crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } = message else {
+ return None;
+ };
+ let request: crate::api::schema::Request = serde_json::from_str(request).ok()?;
+ match request.method {
+ crate::api::schema::Method::ClientShellSurfaceSet(params) => Some(params.active),
+ _ => None,
+ }
+}
+
+fn resize() -> crate::protocol::ClientMessage {
+ crate::protocol::ClientMessage::ClientShellResize {
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 },
+ pixel_mouse: false,
+ }
+}
+
+fn surface(boot_id: &str, revision: u64, pane: &str) -> crate::protocol::PaneSurfaceFrame {
+ crate::protocol::PaneSurfaceFrame {
+ boot_id: boot_id.into(),
+ projection_revision: revision,
+ surface_revision: revision,
+ frame: crate::protocol::FrameData {
+ cells: Vec::new(),
+ width: 80,
+ height: 24,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ },
+ panes: vec![crate::protocol::PaneSurfacePane {
+ pane_id: pane.into(),
+ content_revision: revision,
+ rect: crate::protocol::SurfaceRect {
+ x: 0,
+ y: 0,
+ width: 80,
+ height: 24,
+ },
+ inner_rect: crate::protocol::SurfaceRect {
+ x: 0,
+ y: 0,
+ width: 80,
+ height: 24,
+ },
+ scrollbar_rect: None,
+ scroll: None,
+ focused: true,
+ mouse_reporting: false,
+ sgr_pixel_mouse: false,
+ alternate_screen_active: false,
+ pixel_width: 0,
+ pixel_height: 0,
+ }],
+ splits: Vec::new(),
+ popup: None,
+ graphics: Default::default(),
+ }
+}
+
+fn machine() -> PendingEndpointActivation {
+ PendingEndpointActivation {
+ source: lease(ClientEndpointId::Local, 1, "local-boot"),
+ source_available: true,
+ target: lease(endpoint(), 7, "remote-boot"),
+ focus: None,
+ host_focused: true,
+ resize: resize(),
+ phase: ActivationPhase::ActivatingTarget {
+ request_id: "client-shell-surface:3:on".into(),
+ acknowledged_revision: Some(1),
+ focus_request_id: None,
+ focus_request_target: None,
+ focus_acknowledged: true,
+ evidence: ActivationEvidence::default(),
+ },
+ deadline: Instant::now() + ACTIVATION_TIMEOUT,
+ epoch: 3,
+ next_focus_serial: 0,
+ rollback_error: None,
+ successor: None,
+ }
+}
+
+#[test]
+fn source_off_request_is_distinct_and_precedes_target_on_phase() {
+ let activation = PendingEndpointActivation {
+ source: lease(ClientEndpointId::Local, 1, "local-boot"),
+ source_available: true,
+ target: lease(endpoint(), 7, "remote-boot"),
+ focus: None,
+ host_focused: true,
+ resize: resize(),
+ phase: ActivationPhase::ReleasingSource {
+ request_id: "client-shell-surface:9:off".into(),
+ },
+ deadline: Instant::now() + ACTIVATION_TIMEOUT,
+ epoch: 9,
+ next_focus_serial: 0,
+ rollback_error: None,
+ successor: None,
+ };
+ assert!(activation.accepts_response(
+ &ClientEndpointId::Local,
+ 1,
+ "local-boot",
+ "client-shell-surface:9:off"
+ ));
+ assert!(!activation.accepts_response(
+ &endpoint(),
+ 7,
+ "remote-boot",
+ "client-shell-surface:9:on"
+ ));
+}
+
+#[test]
+fn active_source_requires_metadata_from_its_current_connection_generation() {
+ let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry();
+ shell.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 99,
+ Box::new(test_snapshot("stale-local-boot", 1)),
+ );
+
+ let result = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 26,
+ Instant::now(),
+ );
+
+ assert!(
+ matches!(result, Err(ActivationBeginError::Preflight(message)) if message.contains("this connection"))
+ );
+ assert!(local_sent.lock().unwrap().is_empty());
+ assert!(remote_sent.lock().unwrap().is_empty());
+ assert!(endpoints.active_surface_available());
+}
+
+#[test]
+fn observed_begin_write_failure_returns_recoverable_partial_activation() {
+ let (shell, mut endpoints, local_sent, remote_sent) =
+ shell_and_registry_with_source_failure(true);
+ let result = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 10,
+ Instant::now(),
+ );
+
+ let ActivationBeginError::Partial {
+ mut activation,
+ error,
+ } = (match result {
+ Err(error) => error,
+ Ok(_) => panic!("an observed source write must return partial lifecycle state"),
+ })
+ else {
+ panic!("an observed source write must return partial lifecycle state");
+ };
+ assert!(error.contains("focus revoke"));
+ assert_eq!(
+ local_sent.lock().unwrap().first(),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false })
+ );
+ assert!(remote_sent.lock().unwrap().is_empty());
+ assert!(matches!(
+ activation.rollback(&mut endpoints, error, false),
+ ActivationRollback::Unavailable(_)
+ ));
+}
+
+#[test]
+fn source_release_is_sent_and_acknowledged_before_target_activation() {
+ let (shell, mut endpoints, local_sent, remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 11,
+ Instant::now(),
+ )
+ .unwrap();
+ let local = local_sent.lock().unwrap();
+ assert_eq!(
+ local.first(),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }),
+ "source focus is revoked before source-off"
+ );
+ assert_eq!(
+ local
+ .as_slice()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false],
+ "the source is released first"
+ );
+ drop(local);
+ assert!(remote_sent.lock().unwrap().is_empty());
+ assert!(
+ !endpoints.active_surface_available(),
+ "pane input is blocked while frozen"
+ );
+
+ assert_eq!(
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:11:off",
+ &surface_success("client-shell-surface:11:off", false, 1),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Pending
+ );
+ let remote = remote_sent.lock().unwrap();
+ assert!(matches!(
+ remote[0],
+ crate::protocol::ClientMessage::ClientShellResize { .. }
+ ));
+ assert_eq!(
+ remote.get(2),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true })
+ );
+ assert_eq!(remote.get(1).and_then(surface_set_active), Some(true));
+}
+
+#[test]
+fn activation_requires_an_exact_snapshot_surface_revision_pair() {
+ let mut activation = machine();
+ let target = endpoint();
+ let snapshot = crate::protocol::ClientShellSnapshot {
+ boot_id: "remote-boot".into(),
+ revision: 2,
+ config_diagnostic: None,
+ product_announcement: None,
+ update_available: None,
+ update_install_command: String::new(),
+ server_keybindings_toml: None,
+ latest_release_notes_available: false,
+ integration_updates_available: false,
+ worktree_directory: String::new(),
+ release_notes: None,
+ focused_workspace_id: None,
+ focused_tab_id: None,
+ focused_pane_id: None,
+ tab_bar_right: Vec::new(),
+ tab_bar_right_separator: String::new(),
+ agent_view_label: None,
+ agent_order: Vec::new(),
+ workspaces: Vec::new(),
+ tabs: Vec::new(),
+ panes: Vec::new(),
+ agents: Vec::new(),
+ commands: Vec::new(),
+ };
+ assert_eq!(
+ activation.receive_snapshot(&target, 7, &snapshot),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&target, 7, surface("remote-boot", 1, "pane")),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&target, 7, surface("remote-boot", 2, "pane")),
+ SurfaceActivationProgress::Ready
+ );
+}
+
+#[test]
+fn typed_target_ack_sets_a_floor_for_same_boot_activation_evidence() {
+ let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 16,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:16:off",
+ &surface_success("client-shell-surface:16:off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ activation.receive_response(
+ &target,
+ 7,
+ "client-shell-surface:16:on",
+ &surface_success("client-shell-surface:16:on", true, 4),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 3)),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&target, 7, surface("remote-boot", 3, "pane")),
+ SurfaceActivationProgress::Pending,
+ "a delayed same-boot surface below the acknowledgement floor is not evidence"
+ );
+ assert_eq!(
+ activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 4)),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&target, 7, surface("remote-boot", 4, "pane")),
+ SurfaceActivationProgress::Ready
+ );
+}
+
+#[test]
+fn stale_generation_and_boot_are_not_activation_evidence() {
+ let mut activation = machine();
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 6, surface("remote-boot", 1, "pane")),
+ SurfaceActivationProgress::Stale
+ );
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 7, surface("old-boot", 1, "pane")),
+ SurfaceActivationProgress::Stale
+ );
+}
+
+#[test]
+fn stale_response_boot_is_not_consumed() {
+ let mut activation = machine();
+ let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry();
+ assert_eq!(
+ activation.receive_response_for_boot(
+ &endpoint(),
+ 7,
+ "old-boot",
+ "client-shell-surface:3:on",
+ &surface_success("client-shell-surface:3:on", true, 2),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Stale
+ );
+}
+
+#[test]
+fn same_target_retarget_is_latest_wins() {
+ let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(
+ "old".into(),
+ )),
+ resize(),
+ 12,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:12:off",
+ &surface_success("client-shell-surface:12:off", false, 1),
+ &mut endpoints,
+ );
+ let old_focus = remote_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|message| match message {
+ crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. }
+ if surface_set_active(message).is_none() =>
+ {
+ Some(
+ serde_json::from_str::<crate::api::schema::Request>(request)
+ .unwrap()
+ .id,
+ )
+ }
+ _ => None,
+ })
+ .unwrap();
+ let sent_before_retarget = remote_sent.lock().unwrap().len();
+ activation
+ .retarget(
+ Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(
+ "new".into(),
+ )),
+ &mut endpoints,
+ )
+ .unwrap();
+ assert_eq!(remote_sent.lock().unwrap().len(), sent_before_retarget);
+ assert!(activation.accepts_response(&target, 7, "remote-boot", &old_focus));
+ assert_eq!(
+ activation.receive_response(
+ &target,
+ 7,
+ &old_focus,
+ &workspace_focus_success(&old_focus, "old"),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Pending
+ );
+ let latest_focus = remote_sent
+ .lock()
+ .unwrap()
+ .last()
+ .and_then(|message| match message {
+ crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } => Some(
+ serde_json::from_str::<crate::api::schema::Request>(request)
+ .unwrap()
+ .id,
+ ),
+ _ => None,
+ })
+ .unwrap();
+ assert_ne!(latest_focus, old_focus);
+ assert!(activation.accepts_response(&target, 7, "remote-boot", &latest_focus));
+}
+
+#[test]
+fn latest_host_focus_is_replayed_to_the_eventual_target() {
+ let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 25,
+ Instant::now(),
+ )
+ .unwrap();
+ activation.update_host_focus(false, &mut endpoints).unwrap();
+ assert!(remote_sent.lock().unwrap().is_empty());
+
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:25:off",
+ &surface_success("client-shell-surface:25:off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ remote_sent.lock().unwrap().get(2),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false })
+ );
+
+ activation.update_host_focus(true, &mut endpoints).unwrap();
+ assert_eq!(
+ remote_sent.lock().unwrap().last(),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true })
+ );
+}
+
+#[test]
+fn host_focus_change_restarts_an_issued_presentation_effects_fence() {
+ let (_shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let mut activation = machine();
+ activation.phase = ActivationPhase::AwaitingPresentationEffects {
+ lease: lease(endpoint(), 7, "remote-boot"),
+ token: "old-token".into(),
+ ready: false,
+ completion: Box::new(ActivationCompletion::Activated),
+ };
+
+ activation.update_host_focus(false, &mut endpoints).unwrap();
+
+ assert!(matches!(
+ activation.phase,
+ ActivationPhase::SynchronizingPresentation { .. }
+ ));
+ assert_eq!(
+ activation.receive_presentation_effects_ready(&endpoint(), 7, "old-token"),
+ SurfaceActivationProgress::Stale
+ );
+ assert_eq!(
+ remote_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![true]
+ );
+}
+
+#[test]
+fn source_release_rejection_restores_the_source_coherently() {
+ let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 13,
+ Instant::now(),
+ )
+ .unwrap();
+ assert_eq!(
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:13:off",
+ &failure("client-shell-surface:13:off", "source rejected release"),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Rejected {
+ message: "source rejected release".into(),
+ source_release_rejected: true,
+ }
+ );
+ assert_eq!(
+ activation.rollback(&mut endpoints, "source rejected release".into(), true),
+ ActivationRollback::Pending
+ );
+ assert_eq!(endpoints.active_id(), &ClientEndpointId::Local);
+ assert!(!endpoints.active_surface_available());
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true]
+ );
+}
+
+#[test]
+fn source_release_timeout_starts_an_acknowledged_source_restore() {
+ let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 14,
+ Instant::now(),
+ )
+ .unwrap();
+ assert_eq!(
+ activation.rollback(&mut endpoints, "source release timed out".into(), false),
+ ActivationRollback::Pending
+ );
+ let sent = local_sent.lock().unwrap();
+ assert_eq!(
+ sent.iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true]
+ );
+ assert!(activation.accepts_response(
+ &ClientEndpointId::Local,
+ 1,
+ "local-boot",
+ "client-shell-surface:14:rollback-source-on"
+ ));
+}
+
+#[test]
+fn resize_invalidates_already_recorded_surface_evidence() {
+ let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry();
+ let mut activation = machine();
+ assert_eq!(
+ activation.receive_snapshot(&endpoint(), 7, &test_snapshot("remote-boot", 1)),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")),
+ SurfaceActivationProgress::Ready
+ );
+ let resize = crate::protocol::ClientMessage::ClientShellResize {
+ cell_width_px: 9,
+ cell_height_px: 17,
+ surface_size: crate::protocol::ClientSurfaceSize {
+ cols: 100,
+ rows: 30,
+ },
+ pixel_mouse: true,
+ };
+ activation.update_resize(resize, &mut endpoints).unwrap();
+ assert_eq!(activation.progress(), SurfaceActivationProgress::Pending);
+}
+
+#[test]
+fn resize_during_activation_reaches_the_pending_target() {
+ let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 15,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:15:off",
+ &surface_success("client-shell-surface:15:off", false, 1),
+ &mut endpoints,
+ );
+ let resized = crate::protocol::ClientMessage::ClientShellResize {
+ cell_width_px: 9,
+ cell_height_px: 17,
+ surface_size: crate::protocol::ClientSurfaceSize {
+ cols: 100,
+ rows: 30,
+ },
+ pixel_mouse: true,
+ };
+ activation
+ .update_resize(resized.clone(), &mut endpoints)
+ .unwrap();
+ assert_eq!(remote_sent.lock().unwrap().last(), Some(&resized));
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")),
+ SurfaceActivationProgress::Pending,
+ "a surface for the prior geometry cannot commit"
+ );
+}
+
+#[test]
+fn rapid_a_to_b_to_a_restores_source_before_a_fresh_latest_epoch() {
+ let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 20,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:20:off",
+ &surface_success("client-shell-surface:20:off", false, 1),
+ &mut endpoints,
+ );
+
+ // The latest A-qualified target replaces B while B may already have accepted target-on.
+ assert_eq!(
+ activation.supersede(
+ ClientEndpointId::Local,
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "local-pane".into(),
+ )),
+ &mut endpoints,
+ ),
+ ActivationRollback::Pending
+ );
+ assert_eq!(
+ remote_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![true, false],
+ "B is released before A can be restored"
+ );
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 7, surface("remote-boot", 2, "pane")),
+ SurfaceActivationProgress::Stale,
+ "delayed B activation evidence cannot satisfy A restoration"
+ );
+ let _ = activation.receive_response(
+ &endpoint(),
+ 7,
+ "client-shell-surface:20:rollback-target-off",
+ &surface_success("client-shell-surface:20:rollback-target-off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true],
+ "source restoration is acknowledged rather than racing target ownership"
+ );
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:20:rollback-source-on",
+ &surface_success("client-shell-surface:20:rollback-source-on", true, 2),
+ &mut endpoints,
+ );
+ let local_snapshot = test_snapshot("local-boot", 2);
+ shell.set_snapshot(Box::new(local_snapshot.clone()));
+ assert_eq!(
+ activation.receive_snapshot(&ClientEndpointId::Local, 1, &local_snapshot),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(
+ &ClientEndpointId::Local,
+ 1,
+ surface("local-boot", 2, "pane")
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert!(matches!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::AwaitingPresentationSync {
+ endpoint: ClientEndpointId::Local,
+ ..
+ })
+ ));
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:20:presentation-sync",
+ &surface_success("client-shell-surface:20:presentation-sync", true, 3),
+ &mut endpoints,
+ );
+ let sync_snapshot = test_snapshot("local-boot", 3);
+ shell.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 1,
+ Box::new(sync_snapshot.clone()),
+ );
+ let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot);
+ assert_eq!(
+ activation.receive_surface(
+ &ClientEndpointId::Local,
+ 1,
+ surface("local-boot", 3, "pane")
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert_eq!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::AwaitingPresentationEffects)
+ );
+ assert_eq!(
+ activation.receive_presentation_effects_ready(
+ &ClientEndpointId::Local,
+ 1,
+ "20:1:local-boot"
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert!(matches!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::RestoredSource {
+ successor: Some(EndpointActivationIntent {
+ endpoint_id: ClientEndpointId::Local,
+ ..
+ }),
+ ..
+ })
+ ));
+ assert_eq!(endpoints.active_id(), &ClientEndpointId::Local);
+
+ // Runtime queues this successor with force=true, so even source==target receives a new
+ // activation epoch only after restoration committed.
+ let _fresh_epoch = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ ClientEndpointId::Local,
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "local-pane".into(),
+ )),
+ resize(),
+ 21,
+ Instant::now(),
+ )
+ .unwrap();
+ assert!(local_sent.lock().unwrap().iter().any(|message| {
+ matches!(
+ message,
+ crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. }
+ if serde_json::from_str::<crate::api::schema::Request>(request)
+ .is_ok_and(|request| request.id == "client-shell-surface:21:on")
+ )
+ }));
+}
+
+#[test]
+fn disconnected_committed_source_does_not_block_switching_to_a_live_endpoint() {
+ let (mut shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry();
+ let disconnected = endpoint();
+ endpoints.set_surface_active(&ClientEndpointId::Local, false);
+ endpoints.set_surface_active(&disconnected, true);
+ assert!(endpoints.set_active(&disconnected));
+ assert!(shell.activate_endpoint_projection(&disconnected));
+ endpoints.disconnect(&disconnected);
+
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ ClientEndpointId::Local,
+ None,
+ resize(),
+ 22,
+ Instant::now(),
+ )
+ .unwrap();
+ assert_eq!(activation.source_command_lane(), None);
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![true],
+ "there is no unavailable source release to await"
+ );
+
+ assert_eq!(
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:22:on",
+ &surface_success("client-shell-surface:22:on", true, 2),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Pending
+ );
+ let snapshot = test_snapshot("local-boot", 2);
+ shell.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 1,
+ Box::new(snapshot.clone()),
+ );
+ assert_eq!(
+ activation.receive_snapshot(&ClientEndpointId::Local, 1, &snapshot),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(
+ &ClientEndpointId::Local,
+ 1,
+ surface("local-boot", 2, "pane")
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert!(matches!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::AwaitingPresentationSync {
+ previous,
+ endpoint: ClientEndpointId::Local,
+ }) if previous == disconnected
+ ));
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:22:presentation-sync",
+ &surface_success("client-shell-surface:22:presentation-sync", true, 3),
+ &mut endpoints,
+ );
+ let sync_snapshot = test_snapshot("local-boot", 3);
+ shell.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 1,
+ Box::new(sync_snapshot.clone()),
+ );
+ let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot);
+ assert_eq!(
+ activation.receive_surface(
+ &ClientEndpointId::Local,
+ 1,
+ surface("local-boot", 3, "pane")
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert_eq!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::AwaitingPresentationEffects)
+ );
+ assert_eq!(
+ activation.receive_presentation_effects_ready(
+ &ClientEndpointId::Local,
+ 1,
+ "22:1:local-boot"
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert_eq!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::Activated)
+ );
+ assert_eq!(endpoints.active_id(), &ClientEndpointId::Local);
+ assert_ne!(endpoints.active_id(), &disconnected);
+}
+
+#[test]
+fn rollback_keeps_the_latest_intent_even_when_it_returns_to_the_target() {
+ let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 23,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:23:off",
+ &surface_success("client-shell-surface:23:off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ activation.supersede(
+ ClientEndpointId::Local,
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "local-pane".into()
+ )),
+ &mut endpoints,
+ ),
+ ActivationRollback::Pending
+ );
+ assert!(!activation.can_retarget(&target));
+ assert_eq!(
+ activation.supersede(
+ target.clone(),
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "remote-pane".into()
+ )),
+ &mut endpoints,
+ ),
+ ActivationRollback::Pending
+ );
+ assert_eq!(
+ activation.successor,
+ Some(EndpointActivationIntent {
+ endpoint_id: target,
+ target: Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "remote-pane".into()
+ )),
+ })
+ );
+}
+
+#[test]
+fn unacknowledged_target_release_closes_target_before_restoring_source() {
+ let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 24,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:24:off",
+ &surface_success("client-shell-surface:24:off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ activation.rollback(&mut endpoints, "target activation timed out".into(), false),
+ ActivationRollback::Pending
+ );
+ assert_eq!(
+ activation.rollback(&mut endpoints, "target release timed out".into(), false),
+ ActivationRollback::Pending
+ );
+ assert!(endpoints.connection(&target).is_none());
+ let failures = endpoints.take_failures();
+ assert_eq!(
+ failures.len(),
+ 1,
+ "rollback revocation must reach the reconnect owner"
+ );
+ assert_eq!(failures[0].endpoint_id, target);
+ assert_eq!(failures[0].generation, 7);
+ assert_eq!(failures[0].kind, std::io::ErrorKind::TimedOut);
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true]
+ );
+}
+
+#[test]
+fn target_loss_at_activation_deadline_restores_source_before_timeout() {
+ let (shell, mut endpoints, local_sent, _) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 30,
+ Instant::now(),
+ )
+ .unwrap();
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:30:off",
+ &surface_success("client-shell-surface:30:off", false, 1),
+ &mut endpoints,
+ );
+ let now = Instant::now();
+ activation.deadline = now;
+ assert!(activation.expired(now));
+ endpoints.fail(&target, std::io::ErrorKind::UnexpectedEof.into());
+ // Match the client timer: apply transport failures before checking phase expiry.
+ for failure in endpoints.take_failures() {
+ assert_eq!(
+ activation.endpoint_disconnected(&mut endpoints, &failure.endpoint_id, failure.message),
+ ActivationRollback::Pending
+ );
+ }
+ assert!(matches!(
+ activation.phase,
+ ActivationPhase::RestoringSource { .. }
+ ));
+ assert!(!activation.expired(now));
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true]
+ );
+}
+
+#[test]
+fn losing_local_during_handoff_does_not_revoke_the_healthy_target() {
+ for source_released in [false, true] {
+ let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 29,
+ Instant::now(),
+ )
+ .unwrap();
+ if source_released {
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:29:off",
+ &surface_success("client-shell-surface:29:off", false, 1),
+ &mut endpoints,
+ );
+ }
+ if !source_released {
+ activation.deadline = Instant::now() - Duration::from_millis(1);
+ }
+ endpoints.fail(
+ &ClientEndpointId::Local,
+ std::io::ErrorKind::BrokenPipe.into(),
+ );
+ assert_eq!(
+ activation.endpoint_disconnected(
+ &mut endpoints,
+ &ClientEndpointId::Local,
+ "Local stopped".into()
+ ),
+ ActivationRollback::Pending
+ );
+ assert!(!activation.source_available);
+ assert!(
+ !activation.expired(Instant::now()),
+ "starting the healthy target must get a fresh deadline"
+ );
+ assert!(matches!(
+ activation.phase,
+ ActivationPhase::ActivatingTarget { .. }
+ ));
+ assert!(endpoints.connection(&target).is_some());
+ assert_eq!(
+ remote_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![true]
+ );
+ }
+}
+
+#[test]
+fn resize_message_preserves_the_latest_surface_dimensions() {
+ assert_eq!(
+ resize_geometry(&resize()),
+ Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 })
+ );
+}
diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs
new file mode 100644
index 00000000..23f26387
--- /dev/null
+++ b/src/client/endpoint/catalog.rs
@@ -0,0 +1,578 @@
+use std::collections::HashSet;
+use std::io::{self, Read as _, Write as _};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use serde::{Deserialize, Serialize};
+
+use super::ProfileId;
+
+const CATALOG_VERSION: u32 = 1;
+const SELECTION_VERSION: u32 = 1;
+const MAX_CATALOG_BYTES: u64 = 64 * 1024;
+const MAX_PROFILES: usize = 64;
+const MAX_LABEL_BYTES: usize = 128;
+const MAX_TARGET_BYTES: usize = 1024;
+static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1);
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct SavedSshEndpoint {
+ pub(crate) id: ProfileId,
+ pub(crate) label: String,
+ pub(crate) target: String,
+ pub(crate) session: String,
+ pub(crate) enabled: bool,
+}
+
+impl SavedSshEndpoint {
+ pub(crate) fn new(
+ label: impl Into<String>,
+ target: impl Into<String>,
+ session: impl Into<String>,
+ ) -> Result<Self, String> {
+ let profile = Self {
+ id: ProfileId::generate(),
+ label: label.into(),
+ target: target.into(),
+ session: session.into(),
+ enabled: true,
+ };
+ profile.validate()?;
+ Ok(profile)
+ }
+
+ fn validate(&self) -> Result<(), String> {
+ ProfileId::parse(self.id.to_string())?;
+ let label = self.label.trim();
+ if label.is_empty() {
+ return Err("SSH endpoint label cannot be empty".into());
+ }
+ if label.len() > MAX_LABEL_BYTES || label.chars().any(char::is_control) {
+ return Err(format!(
+ "SSH endpoint label must be at most {MAX_LABEL_BYTES} bytes and contain no control characters"
+ ));
+ }
+ if self.target.len() > MAX_TARGET_BYTES || self.target.chars().any(char::is_control) {
+ return Err(format!(
+ "SSH target must be at most {MAX_TARGET_BYTES} bytes and contain no control characters"
+ ));
+ }
+ crate::remote::validate_remote_target(&self.target).map(|_| ())?;
+ let authority = self.target.strip_prefix("ssh://").unwrap_or(&self.target);
+ if authority
+ .rsplit_once('@')
+ .is_some_and(|(userinfo, _)| userinfo.contains(':'))
+ {
+ return Err("SSH target must not contain a password".into());
+ }
+ crate::session::validate_name(&self.session)?;
+ Ok(())
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct EndpointCatalog {
+ version: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) selected_profile: Option<ProfileId>,
+ #[serde(default)]
+ pub(crate) ssh: Vec<SavedSshEndpoint>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct EndpointSelection {
+ version: u32,
+ selected_profile: Option<ProfileId>,
+}
+
+impl Default for EndpointCatalog {
+ fn default() -> Self {
+ Self {
+ version: CATALOG_VERSION,
+ selected_profile: None,
+ ssh: Vec::new(),
+ }
+ }
+}
+
+impl EndpointCatalog {
+ pub(crate) fn load() -> Result<Self, String> {
+ Self::load_from_paths(&catalog_path(), &selection_path())
+ }
+
+ pub(crate) fn load_profiles() -> Result<Vec<SavedSshEndpoint>, String> {
+ // Live clients keep their own selection, independent of other attached clients.
+ Self::load_from_path(&catalog_path()).map(|catalog| catalog.ssh)
+ }
+
+ fn load_from_paths(catalog_path: &Path, selection_path: &Path) -> Result<Self, String> {
+ let mut catalog = Self::load_from_path(catalog_path)?;
+ match load_selection_from_path(selection_path) {
+ Ok(Some(selection)) => {
+ let valid = selection.selected_profile.as_ref().is_none_or(|selected| {
+ catalog
+ .ssh
+ .iter()
+ .any(|profile| &profile.id == selected && profile.enabled)
+ });
+ if valid {
+ catalog.selected_profile = selection.selected_profile;
+ } else {
+ tracing::warn!(
+ path = %selection_path.display(),
+ "saved endpoint selection is absent or disabled; using Local"
+ );
+ }
+ }
+ Ok(None) => {}
+ Err(error) => {
+ tracing::warn!(
+ %error,
+ path = %selection_path.display(),
+ "saved endpoint selection is unavailable; using Local"
+ );
+ }
+ }
+ Ok(catalog)
+ }
+
+ pub(crate) fn store_profiles(&self) -> Result<(), String> {
+ self.store_to_path(&catalog_path())
+ }
+
+ pub(crate) fn store_selection(&self) -> Result<(), String> {
+ self.store_selection_to_path(&selection_path())
+ }
+
+ fn store_selection_to_path(&self, path: &Path) -> Result<(), String> {
+ self.validate()?;
+ let content = serde_json::to_vec_pretty(&EndpointSelection {
+ version: SELECTION_VERSION,
+ selected_profile: self.selected_profile.clone(),
+ })
+ .map_err(|error| format!("failed to encode endpoint selection: {error}"))?;
+ store_private_json(path, &content, "endpoint selection")
+ }
+
+ pub(crate) fn add_ssh(
+ &mut self,
+ label: impl Into<String>,
+ target: impl Into<String>,
+ session: impl Into<String>,
+ ) -> Result<ProfileId, String> {
+ if self.ssh.len() >= MAX_PROFILES {
+ return Err(format!("at most {MAX_PROFILES} SSH endpoints can be saved"));
+ }
+ let profile = SavedSshEndpoint::new(label, target, session)?;
+ let id = profile.id.clone();
+ self.ssh.push(profile);
+ Ok(id)
+ }
+
+ pub(crate) fn rename_ssh(
+ &mut self,
+ id: &ProfileId,
+ label: impl Into<String>,
+ ) -> Result<bool, String> {
+ let Some(index) = self.ssh.iter().position(|profile| &profile.id == id) else {
+ return Ok(false);
+ };
+ let mut renamed = self.ssh[index].clone();
+ renamed.label = label.into();
+ renamed.validate()?;
+ self.ssh[index] = renamed;
+ Ok(true)
+ }
+
+ pub(crate) fn remove_ssh(&mut self, id: &ProfileId) -> bool {
+ let previous_len = self.ssh.len();
+ self.ssh.retain(|profile| &profile.id != id);
+ if self.selected_profile.as_ref() == Some(id) {
+ self.selected_profile = None;
+ }
+ self.ssh.len() != previous_len
+ }
+
+ pub(crate) fn select_local(&mut self) {
+ self.selected_profile = None;
+ }
+
+ pub(crate) fn select_endpoint(&mut self, endpoint_id: &super::ClientEndpointId) -> bool {
+ match endpoint_id {
+ super::ClientEndpointId::Local => {
+ self.select_local();
+ true
+ }
+ super::ClientEndpointId::Ssh(profile_id) => self.select_ssh(profile_id),
+ }
+ }
+
+ pub(crate) fn select_ssh(&mut self, id: &ProfileId) -> bool {
+ if !self
+ .ssh
+ .iter()
+ .any(|profile| &profile.id == id && profile.enabled)
+ {
+ return false;
+ }
+ self.selected_profile = Some(id.clone());
+ true
+ }
+
+ pub(crate) fn has_enabled_ssh(&self) -> bool {
+ self.ssh.iter().any(|profile| profile.enabled)
+ }
+
+ pub(crate) fn contains_enabled_target_session(&self, target: &str, session: &str) -> bool {
+ self.ssh.iter().any(|profile| {
+ profile.enabled && profile.target == target && profile.session == session
+ })
+ }
+
+ pub(crate) fn set_enabled(&mut self, id: &ProfileId, enabled: bool) -> bool {
+ let Some(profile) = self.ssh.iter_mut().find(|profile| &profile.id == id) else {
+ return false;
+ };
+ profile.enabled = enabled;
+ if !enabled && self.selected_profile.as_ref() == Some(id) {
+ self.selected_profile = None;
+ }
+ true
+ }
+
+ fn validate(&self) -> Result<(), String> {
+ if self.version != CATALOG_VERSION {
+ return Err(format!(
+ "unsupported endpoint catalog version {}; expected {CATALOG_VERSION}",
+ self.version
+ ));
+ }
+ if self.ssh.len() > MAX_PROFILES {
+ return Err(format!(
+ "endpoint catalog contains more than {MAX_PROFILES} SSH profiles"
+ ));
+ }
+ let mut ids = HashSet::new();
+ for profile in &self.ssh {
+ profile.validate()?;
+ if !ids.insert(profile.id.clone()) {
+ return Err(format!("duplicate endpoint profile id {}", profile.id));
+ }
+ }
+ if self.selected_profile.as_ref().is_some_and(|selected| {
+ !self
+ .ssh
+ .iter()
+ .any(|profile| &profile.id == selected && profile.enabled)
+ }) {
+ return Err("selected SSH endpoint is absent or disabled in the catalog".into());
+ }
+ Ok(())
+ }
+
+ fn load_from_path(path: &Path) -> Result<Self, String> {
+ let file = match std::fs::File::open(path) {
+ Ok(file) => file,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
+ Err(error) => {
+ return Err(format!(
+ "failed to open endpoint catalog {}: {error}",
+ path.display()
+ ))
+ }
+ };
+ let metadata = file
+ .metadata()
+ .map_err(|error| format!("failed to inspect endpoint catalog: {error}"))?;
+ if metadata.len() > MAX_CATALOG_BYTES {
+ return Err("endpoint catalog exceeds the storage limit".into());
+ }
+ let mut content = String::new();
+ file.take(MAX_CATALOG_BYTES + 1)
+ .read_to_string(&mut content)
+ .map_err(|error| format!("failed to read endpoint catalog: {error}"))?;
+ if content.len() as u64 > MAX_CATALOG_BYTES {
+ return Err("endpoint catalog exceeds the storage limit".into());
+ }
+ let catalog: Self = serde_json::from_str(&content)
+ .map_err(|error| format!("stored endpoint catalog is invalid: {error}"))?;
+ catalog.validate()?;
+ Ok(catalog)
+ }
+
+ fn store_to_path(&self, path: &Path) -> Result<(), String> {
+ self.validate()?;
+ let content = serde_json::to_vec_pretty(self)
+ .map_err(|error| format!("failed to encode endpoint catalog: {error}"))?;
+ store_private_json(path, &content, "endpoint catalog")
+ }
+}
+
+fn load_selection_from_path(path: &Path) -> Result<Option<EndpointSelection>, String> {
+ let content = match std::fs::read(path) {
+ Ok(content) => content,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
+ Err(error) => return Err(format!("failed to read endpoint selection: {error}")),
+ };
+ if content.len() as u64 > MAX_CATALOG_BYTES {
+ return Err("endpoint selection exceeds the storage limit".into());
+ }
+ let selection: EndpointSelection = serde_json::from_slice(&content)
+ .map_err(|error| format!("stored endpoint selection is invalid: {error}"))?;
+ if selection.version != SELECTION_VERSION {
+ return Err(format!(
+ "unsupported endpoint selection version {}; expected {SELECTION_VERSION}",
+ selection.version
+ ));
+ }
+ Ok(Some(selection))
+}
+
+fn store_private_json(path: &Path, content: &[u8], description: &str) -> Result<(), String> {
+ if content.len() as u64 > MAX_CATALOG_BYTES {
+ return Err(format!("{description} exceeds the storage limit"));
+ }
+ let parent = path
+ .parent()
+ .ok_or_else(|| format!("invalid {description} path: {}", path.display()))?;
+ std::fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create {description} directory: {error}"))?;
+ if let Ok(metadata) = std::fs::symlink_metadata(path) {
+ if metadata.file_type().is_symlink() || !metadata.is_file() {
+ return Err(format!(
+ "refusing to replace {description} through a non-file path"
+ ));
+ }
+ }
+
+ let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed);
+ let temp_path = parent.join(format!(".endpoints-{}-{sequence}.tmp", std::process::id()));
+ let mut temp = crate::platform::create_private_state_file(&temp_path)
+ .map_err(|error| format!("failed to create {description}: {error}"))?;
+ if let Err(error) = temp.write_all(content).and_then(|()| temp.sync_all()) {
+ drop(temp);
+ let _ = std::fs::remove_file(&temp_path);
+ return Err(format!("failed to write {description}: {error}"));
+ }
+ drop(temp);
+ if let Err(error) = crate::platform::replace_file(&temp_path, path) {
+ let _ = std::fs::remove_file(&temp_path);
+ return Err(format!("failed to activate {description}: {error}"));
+ }
+ crate::platform::sync_parent_directory(parent)
+ .map_err(|error| format!("failed to persist {description} directory: {error}"))
+}
+
+pub(crate) fn catalog_path() -> PathBuf {
+ crate::config::state_dir()
+ .join("client")
+ .join("endpoints.json")
+}
+
+fn selection_path() -> PathBuf {
+ crate::config::state_dir()
+ .join("client")
+ .join("endpoint-selection.json")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn path(name: &str) -> PathBuf {
+ std::env::temp_dir()
+ .join(format!(
+ "herdr-endpoint-catalog-{}-{name}",
+ std::process::id()
+ ))
+ .join("endpoints.json")
+ }
+
+ #[test]
+ fn catalog_roundtrip_persists_profiles_without_secret_fields() {
+ let path = path("roundtrip");
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog
+ .add_ssh("Build", "ssh://dev@build.example:2222", "agents")
+ .unwrap();
+ assert!(catalog.select_ssh(&id));
+ catalog.store_to_path(&path).unwrap();
+
+ let encoded = std::fs::read_to_string(&path).unwrap();
+ assert!(!encoded.contains("password"));
+ assert!(!encoded.contains("private_key"));
+ assert!(!encoded.contains("control_socket"));
+ let loaded = EndpointCatalog::load_from_path(&path).unwrap();
+ assert_eq!(loaded, catalog);
+ assert_eq!(loaded.ssh[0].id, id);
+ std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn duplicate_target_and_session_profiles_keep_distinct_opaque_ids() {
+ let mut catalog = EndpointCatalog::default();
+ let first = catalog.add_ssh("One", "build", "default").unwrap();
+ let second = catalog.add_ssh("Two", "build", "default").unwrap();
+ assert_ne!(first, second);
+ }
+
+ #[test]
+ fn catalog_rejects_passwords_embedded_in_ssh_targets() {
+ let mut catalog = EndpointCatalog::default();
+ assert!(catalog
+ .add_ssh("Build", "ssh://dev:secret@build.example", "default")
+ .unwrap_err()
+ .contains("must not contain a password"));
+ assert!(catalog
+ .add_ssh("Build", "dev:secret@build.example", "default")
+ .is_err());
+ assert!(catalog
+ .add_ssh("Build", "ssh://dev@[::1]:2222", "default")
+ .is_ok());
+ }
+
+ #[test]
+ fn interactive_bootstrap_matches_only_enabled_target_and_session() {
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog.add_ssh("Build", "build", "agents").unwrap();
+ assert!(catalog.contains_enabled_target_session("build", "agents"));
+ assert!(!catalog.contains_enabled_target_session("build", "default"));
+ assert!(catalog.set_enabled(&id, false));
+ assert!(!catalog.contains_enabled_target_session("build", "agents"));
+ }
+
+ #[test]
+ fn rename_changes_only_the_machine_label() {
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog.add_ssh("Old", "build", "agents").unwrap();
+ let original = catalog.ssh[0].clone();
+
+ assert!(catalog.rename_ssh(&id, "New").unwrap());
+ assert_eq!(catalog.ssh[0].label, "New");
+ assert_eq!(catalog.ssh[0].id, original.id);
+ assert_eq!(catalog.ssh[0].target, original.target);
+ assert_eq!(catalog.ssh[0].session, original.session);
+ assert!(catalog.rename_ssh(&id, "\n").is_err());
+ assert_eq!(catalog.ssh[0].label, "New");
+ }
+
+ #[test]
+ fn removal_and_disable_return_selection_to_local() {
+ let mut catalog = EndpointCatalog::default();
+ let first = catalog.add_ssh("One", "one", "default").unwrap();
+ assert!(catalog.select_ssh(&first));
+ assert!(catalog.set_enabled(&first, false));
+ assert_eq!(catalog.selected_profile, None);
+
+ assert!(catalog.set_enabled(&first, true));
+ assert!(catalog.select_ssh(&first));
+ assert!(catalog.remove_ssh(&first));
+ assert_eq!(catalog.selected_profile, None);
+ }
+
+ #[test]
+ fn catalog_rejects_unknown_fields_instead_of_retaining_possible_secrets() {
+ let path = path("unknown-field");
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(
+ &path,
+ r#"{
+ "version": 1,
+ "ssh": [{
+ "id": "0123456789abcdef0123456789abcdef",
+ "label": "Build",
+ "target": "build",
+ "session": "default",
+ "enabled": true,
+ "password": "must-not-be-accepted"
+ }]
+ }"#,
+ )
+ .unwrap();
+ assert!(EndpointCatalog::load_from_path(&path)
+ .unwrap_err()
+ .contains("unknown field"));
+ std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn storing_selection_does_not_rewrite_profile_membership() {
+ let catalog_path = path("separate-selection");
+ let selection_path = catalog_path.with_file_name("selection.json");
+ let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap());
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog.add_ssh("Build", "build", "agents").unwrap();
+ catalog.store_to_path(&catalog_path).unwrap();
+ let profiles_before = std::fs::read(&catalog_path).unwrap();
+
+ assert!(catalog.select_ssh(&id));
+ catalog.store_selection_to_path(&selection_path).unwrap();
+
+ assert_eq!(std::fs::read(&catalog_path).unwrap(), profiles_before);
+ assert_eq!(
+ load_selection_from_path(&selection_path)
+ .unwrap()
+ .unwrap()
+ .selected_profile,
+ Some(id)
+ );
+ std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn malformed_selection_does_not_discard_saved_profiles() {
+ let catalog_path = path("malformed-selection");
+ let selection_path = catalog_path.with_file_name("selection.json");
+ let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap());
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog.add_ssh("Build", "build", "agents").unwrap();
+ catalog.store_to_path(&catalog_path).unwrap();
+ std::fs::write(&selection_path, b"not json").unwrap();
+
+ let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap();
+ assert_eq!(loaded.ssh.len(), 1);
+ assert_eq!(loaded.ssh[0].id, id);
+ assert_eq!(loaded.selected_profile, None);
+ std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn absent_selected_profile_falls_back_without_discarding_catalog() {
+ let catalog_path = path("absent-selection");
+ let selection_path = catalog_path.with_file_name("selection.json");
+ let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap());
+ let mut catalog = EndpointCatalog::default();
+ let saved = catalog.add_ssh("Build", "build", "agents").unwrap();
+ catalog.store_to_path(&catalog_path).unwrap();
+ let missing = ProfileId::parse("fedcba9876543210fedcba9876543210").unwrap();
+ store_private_json(
+ &selection_path,
+ &serde_json::to_vec(&EndpointSelection {
+ version: SELECTION_VERSION,
+ selected_profile: Some(missing),
+ })
+ .unwrap(),
+ "endpoint selection",
+ )
+ .unwrap();
+
+ let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap();
+ assert_eq!(loaded.ssh[0].id, saved);
+ assert_eq!(loaded.selected_profile, None);
+ std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn invalid_or_missing_selected_profile_is_rejected() {
+ let catalog = EndpointCatalog {
+ selected_profile: Some(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()),
+ ..EndpointCatalog::default()
+ };
+ assert!(catalog.validate().is_err());
+ }
+}
diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs
new file mode 100644
index 00000000..7cf1159f
--- /dev/null
+++ b/src/client/endpoint/control.rs
@@ -0,0 +1,63 @@
+use super::ClientEndpointId;
+
+pub(crate) enum EndpointControlMessage {
+ HealthPong,
+ Snapshot(Box<crate::protocol::ClientShellSnapshot>),
+ Ignored,
+}
+
+pub(crate) fn decode_endpoint_control(
+ kind: &str,
+ data: &str,
+) -> Result<EndpointControlMessage, String> {
+ if kind == crate::protocol::endpoint::HEALTH_PONG_KIND {
+ return Ok(EndpointControlMessage::HealthPong);
+ }
+ if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND {
+ let snapshot = serde_json::from_str(data)
+ .map_err(|error| format!("invalid endpoint snapshot: {error}"))?;
+ return Ok(EndpointControlMessage::Snapshot(Box::new(snapshot)));
+ }
+ if kind.starts_with("shell.snapshot.") {
+ return Err(format!(
+ "unsupported mandatory endpoint snapshot codec {kind:?}"
+ ));
+ }
+ Ok(EndpointControlMessage::Ignored)
+}
+
+pub(crate) fn protocol_failure_is_fatal(endpoint_id: &ClientEndpointId) -> bool {
+ endpoint_id.is_local()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::client::endpoint::ProfileId;
+
+ #[test]
+ fn unknown_optional_controls_are_ignored() {
+ assert!(matches!(
+ decode_endpoint_control("future.optional", "not json").unwrap(),
+ EndpointControlMessage::Ignored
+ ));
+ }
+
+ #[test]
+ fn unknown_snapshot_codecs_are_rejected() {
+ assert_eq!(
+ decode_endpoint_control("shell.snapshot.v2", "{}")
+ .err()
+ .as_deref(),
+ Some("unsupported mandatory endpoint snapshot codec \"shell.snapshot.v2\"")
+ );
+ }
+
+ #[test]
+ fn only_local_protocol_failures_end_the_client() {
+ let remote =
+ ClientEndpointId::Ssh(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap());
+ assert!(protocol_failure_is_fatal(&ClientEndpointId::Local));
+ assert!(!protocol_failure_is_fatal(&remote));
+ }
+}
diff --git a/src/client/endpoint/health.rs b/src/client/endpoint/health.rs
new file mode 100644
index 00000000..87c08952
--- /dev/null
+++ b/src/client/endpoint/health.rs
@@ -0,0 +1,99 @@
+use std::time::{Duration, Instant};
+
+pub(super) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
+pub(super) const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(10);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(super) enum HealthAction {
+ None,
+ Ping,
+ Expired,
+}
+
+pub(super) struct EndpointHealth {
+ connected_at: Instant,
+ last_received: Instant,
+ ping_sent_at: Option<Instant>,
+ ready: bool,
+}
+
+impl EndpointHealth {
+ pub(super) fn new(now: Instant) -> Self {
+ Self {
+ connected_at: now,
+ last_received: now,
+ ping_sent_at: None,
+ ready: false,
+ }
+ }
+
+ pub(super) fn received(&mut self, now: Instant) {
+ self.last_received = now;
+ self.ping_sent_at = None;
+ }
+
+ pub(super) fn ready(&mut self) {
+ self.ready = true;
+ }
+
+ pub(super) fn action(&self, now: Instant) -> HealthAction {
+ let initial_snapshot_expired =
+ !self.ready && now.saturating_duration_since(self.connected_at) >= HEARTBEAT_TIMEOUT;
+ let probe_expired = self
+ .ping_sent_at
+ .is_some_and(|sent_at| now.saturating_duration_since(sent_at) >= HEARTBEAT_TIMEOUT);
+ if initial_snapshot_expired || probe_expired {
+ HealthAction::Expired
+ } else if self.ping_sent_at.is_none()
+ && now.saturating_duration_since(self.last_received) >= HEARTBEAT_INTERVAL
+ {
+ HealthAction::Ping
+ } else {
+ HealthAction::None
+ }
+ }
+
+ pub(super) fn ping_sent(&mut self, now: Instant) {
+ self.ping_sent_at = Some(now);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn quiet_connection_is_probed_then_expires_without_a_reply() {
+ let now = Instant::now();
+ let mut health = EndpointHealth::new(now);
+ assert_eq!(health.action(now), HealthAction::None);
+ assert_eq!(health.action(now + HEARTBEAT_INTERVAL), HealthAction::Ping);
+ health.ping_sent(now + HEARTBEAT_INTERVAL);
+ assert_eq!(
+ health.action(now + HEARTBEAT_INTERVAL + HEARTBEAT_TIMEOUT),
+ HealthAction::Expired
+ );
+ }
+
+ #[test]
+ fn any_incoming_message_satisfies_an_outstanding_probe() {
+ let now = Instant::now();
+ let mut health = EndpointHealth::new(now);
+ health.ready();
+ health.ping_sent(now);
+ health.received(now + HEARTBEAT_TIMEOUT - Duration::from_millis(1));
+ assert_eq!(health.action(now + HEARTBEAT_TIMEOUT), HealthAction::None);
+ }
+
+ #[test]
+ fn heartbeats_do_not_hide_a_missing_initial_snapshot() {
+ let now = Instant::now();
+ let mut health = EndpointHealth::new(now);
+ health.ping_sent(now + HEARTBEAT_INTERVAL);
+ health.received(now + HEARTBEAT_INTERVAL + Duration::from_secs(1));
+ assert_eq!(
+ health.action(now + HEARTBEAT_TIMEOUT),
+ HealthAction::Expired
+ );
+ }
+}
diff --git a/src/client/endpoint/message_policy.rs b/src/client/endpoint/message_policy.rs
new file mode 100644
index 00000000..5e84325a
--- /dev/null
+++ b/src/client/endpoint/message_policy.rs
@@ -0,0 +1,124 @@
+use crate::protocol::ServerMessage;
+
+/// Effects that are meaningful only for the endpoint currently holding the host presentation
+/// lease. During a frozen handoff they are intentionally dropped and the committed target asks
+/// for one bounded replay, rather than allowing target graphics or modes onto the source frame.
+pub(crate) fn is_presentation_effect(message: &ServerMessage) -> bool {
+ matches!(
+ message,
+ ServerMessage::MouseCapture { .. }
+ | ServerMessage::ClientShellKeyboardReportAll { .. }
+ | ServerMessage::WindowTitle { .. }
+ | ServerMessage::Graphics { .. }
+ | ServerMessage::GraphicsFile { .. }
+ | ServerMessage::GraphicsTransmissionRetired { .. }
+ | ServerMessage::TerminalBell { .. }
+ )
+}
+
+pub(crate) fn accepts_endpoint_message(
+ endpoint_active: bool,
+ activation_message: bool,
+ command_response: bool,
+ message: &ServerMessage,
+) -> bool {
+ endpoint_active
+ || matches!(
+ message,
+ ServerMessage::EndpointControl { .. }
+ | ServerMessage::SemanticNotification(_)
+ | ServerMessage::ServerShutdown { .. }
+ | ServerMessage::ClientShellSnapshot(_)
+ | ServerMessage::Welcome { .. }
+ )
+ || (activation_message
+ && matches!(
+ message,
+ ServerMessage::PaneSurface(_)
+ | ServerMessage::ClientShellEndpointResponseChunk { .. }
+ ))
+ || (command_response
+ && matches!(
+ message,
+ ServerMessage::ClientShellEndpointResponseChunk { .. }
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn inactive_endpoints_deliver_metadata_but_not_presentation_effects() {
+ assert!(accepts_endpoint_message(
+ false,
+ false,
+ false,
+ &ServerMessage::EndpointControl {
+ kind: "snapshot".into(),
+ data: "{}".into(),
+ }
+ ));
+ assert!(!accepts_endpoint_message(
+ false,
+ false,
+ false,
+ &ServerMessage::Clipboard {
+ data: "text".into()
+ }
+ ));
+ assert!(!accepts_endpoint_message(
+ false,
+ false,
+ false,
+ &ServerMessage::WindowTitle {
+ title: Some("remote".into())
+ }
+ ));
+ }
+
+ #[test]
+ fn frozen_handoffs_drop_target_effects_until_the_post_commit_resync() {
+ assert!(is_presentation_effect(&ServerMessage::MouseCapture {
+ enabled: true,
+ sgr_pixels: false,
+ }));
+ assert!(is_presentation_effect(&ServerMessage::GraphicsFile {
+ path: "asset".into(),
+ expected_len: 1,
+ image_id: 1,
+ transfer_id: 1,
+ leading: Vec::new(),
+ control: "a=t".into(),
+ surface_asset: None,
+ }));
+ assert!(!is_presentation_effect(&ServerMessage::EndpointControl {
+ kind: "shell.snapshot.v1".into(),
+ data: "{}".into(),
+ }));
+ }
+
+ #[test]
+ fn pending_activation_accepts_only_its_surface_lane() {
+ assert!(accepts_endpoint_message(
+ false,
+ true,
+ false,
+ &ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id: "boot".into(),
+ request_id: "surface".into(),
+ final_chunk: true,
+ data: Vec::new(),
+ }
+ ));
+ assert!(!accepts_endpoint_message(
+ false,
+ true,
+ false,
+ &ServerMessage::MouseCapture {
+ enabled: true,
+ sgr_pixels: false,
+ }
+ ));
+ }
+}
diff --git a/src/client/endpoint/registry.rs b/src/client/endpoint/registry.rs
new file mode 100644
index 00000000..437f39f3
--- /dev/null
+++ b/src/client/endpoint/registry.rs
@@ -0,0 +1,629 @@
+use std::collections::{HashMap, HashSet};
+use std::io;
+use std::time::Instant;
+
+use super::health::{EndpointHealth, HealthAction};
+use super::ClientEndpointId;
+use crate::protocol::ClientMessage;
+
+pub(crate) trait EndpointTransport: Send {
+ fn send(&mut self, message: &ClientMessage) -> io::Result<()>;
+
+ fn disconnect(&mut self) {}
+
+ fn flush(&mut self, _deadline: Instant) -> io::Result<()> {
+ Ok(())
+ }
+
+ fn take_error(&mut self) -> Option<io::Error> {
+ None
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub(crate) struct EndpointNegotiation {
+ methods: HashSet<String>,
+ capabilities: HashSet<String>,
+}
+
+impl EndpointNegotiation {
+ pub(crate) fn new(methods: Vec<String>, capabilities: Vec<String>) -> Self {
+ Self {
+ methods: methods.into_iter().collect(),
+ capabilities: capabilities.into_iter().collect(),
+ }
+ }
+
+ pub(crate) fn methods(&self) -> Vec<String> {
+ self.methods.iter().cloned().collect()
+ }
+
+ pub(crate) fn supports_method(&self, method: &str) -> bool {
+ self.methods.contains(method)
+ }
+
+ pub(crate) fn supports_capability(&self, capability: &str) -> bool {
+ self.capabilities.contains(capability)
+ }
+
+ pub(crate) fn supports_surface_interest(&self) -> bool {
+ self.supports_capability(crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY)
+ && self.supports_capability(
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY,
+ )
+ && self.supports_method("client_shell.surface.set")
+ }
+
+ pub(crate) fn supports_health_check(&self) -> bool {
+ self.supports_capability(crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY)
+ }
+}
+
+pub(crate) struct EndpointConnection {
+ transport: Box<dyn EndpointTransport>,
+ pub(crate) generation: u64,
+ pub(crate) surface_active: bool,
+ pub(crate) negotiation: EndpointNegotiation,
+ health: Option<EndpointHealth>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct EndpointTransportFailure {
+ pub(crate) endpoint_id: ClientEndpointId,
+ pub(crate) generation: u64,
+ pub(crate) kind: io::ErrorKind,
+ pub(crate) message: String,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum EndpointSendOutcome {
+ Sent,
+ NotSent,
+}
+
+pub(crate) struct EndpointRegistry {
+ active: ClientEndpointId,
+ input_enabled: bool,
+ connections: HashMap<ClientEndpointId, EndpointConnection>,
+ failures: Vec<EndpointTransportFailure>,
+}
+
+impl EndpointRegistry {
+ pub(crate) fn empty() -> Self {
+ Self {
+ active: ClientEndpointId::Local,
+ input_enabled: false,
+ connections: HashMap::new(),
+ failures: Vec::new(),
+ }
+ }
+
+ pub(crate) fn new(
+ local: impl EndpointTransport + 'static,
+ generation: u64,
+ negotiation: EndpointNegotiation,
+ ) -> Self {
+ let mut registry = Self::empty();
+ registry.input_enabled = true;
+ registry.insert(
+ ClientEndpointId::Local,
+ local,
+ generation,
+ negotiation,
+ true,
+ );
+ registry
+ }
+
+ pub(crate) fn active_id(&self) -> &ClientEndpointId {
+ &self.active
+ }
+
+ pub(crate) fn active_surface_available(&self) -> bool {
+ self.input_enabled
+ && self
+ .connections
+ .get(&self.active)
+ .is_some_and(|connection| connection.surface_active)
+ }
+
+ pub(crate) fn select_unavailable_local(&mut self) {
+ self.active = ClientEndpointId::Local;
+ self.freeze_input();
+ }
+
+ pub(crate) fn freeze_input(&mut self) {
+ self.input_enabled = false;
+ }
+
+ pub(crate) fn unfreeze_input(&mut self) {
+ self.input_enabled = true;
+ }
+
+ pub(crate) fn connection(&self, endpoint_id: &ClientEndpointId) -> Option<&EndpointConnection> {
+ self.connections.get(endpoint_id)
+ }
+
+ pub(crate) fn insert(
+ &mut self,
+ endpoint_id: ClientEndpointId,
+ transport: impl EndpointTransport + 'static,
+ generation: u64,
+ negotiation: EndpointNegotiation,
+ surface_active: bool,
+ ) {
+ let health = (!endpoint_id.is_local() && negotiation.supports_health_check())
+ .then(|| EndpointHealth::new(Instant::now()));
+ if let Some(mut previous) = self.connections.insert(
+ endpoint_id,
+ EndpointConnection {
+ transport: Box::new(transport),
+ generation,
+ surface_active,
+ negotiation,
+ health,
+ },
+ ) {
+ previous.transport.disconnect();
+ }
+ }
+
+ pub(crate) fn accepts(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool {
+ self.connections
+ .get(endpoint_id)
+ .is_some_and(|connection| connection.generation == generation)
+ }
+
+ pub(crate) fn received(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ now: Instant,
+ ) {
+ if let Some(health) = self
+ .connections
+ .get_mut(endpoint_id)
+ .filter(|connection| connection.generation == generation)
+ .and_then(|connection| connection.health.as_mut())
+ {
+ health.received(now);
+ }
+ }
+
+ pub(crate) fn mark_ready(&mut self, endpoint_id: &ClientEndpointId, generation: u64) {
+ if let Some(health) = self
+ .connections
+ .get_mut(endpoint_id)
+ .filter(|connection| connection.generation == generation)
+ .and_then(|connection| connection.health.as_mut())
+ {
+ health.ready();
+ }
+ }
+
+ pub(crate) fn tick_health(&mut self, now: Instant) {
+ let actions = self
+ .connections
+ .iter()
+ .filter_map(|(endpoint_id, connection)| {
+ connection
+ .health
+ .as_ref()
+ .map(|health| (endpoint_id.clone(), health.action(now)))
+ })
+ .filter(|(_, action)| *action != HealthAction::None)
+ .collect::<Vec<_>>();
+ for (endpoint_id, action) in actions {
+ match action {
+ HealthAction::None => {}
+ HealthAction::Ping => {
+ let ping = ClientMessage::EndpointControl {
+ kind: crate::protocol::endpoint::HEALTH_PING_KIND.into(),
+ data: String::new(),
+ };
+ if self.send_to(&endpoint_id, &ping) == EndpointSendOutcome::Sent {
+ if let Some(health) = self
+ .connections
+ .get_mut(&endpoint_id)
+ .and_then(|connection| connection.health.as_mut())
+ {
+ health.ping_sent(now);
+ }
+ }
+ }
+ HealthAction::Expired => self.record_failure(
+ endpoint_id,
+ io::Error::new(io::ErrorKind::TimedOut, "endpoint health check timed out"),
+ ),
+ }
+ }
+ }
+
+ pub(crate) fn set_active(&mut self, endpoint_id: &ClientEndpointId) -> bool {
+ if !self
+ .connections
+ .get(endpoint_id)
+ .is_some_and(|connection| connection.surface_active)
+ {
+ return false;
+ }
+ self.active = endpoint_id.clone();
+ true
+ }
+
+ pub(crate) fn set_surface_active(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ active: bool,
+ ) -> bool {
+ let Some(connection) = self.connections.get_mut(endpoint_id) else {
+ return false;
+ };
+ let changed = connection.surface_active != active;
+ connection.surface_active = active;
+ changed
+ }
+
+ pub(crate) fn send(&mut self, message: &ClientMessage) -> EndpointSendOutcome {
+ let endpoint_id = self.active.clone();
+ self.send_to(&endpoint_id, message)
+ }
+
+ pub(crate) fn send_to(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ message: &ClientMessage,
+ ) -> EndpointSendOutcome {
+ let result = self
+ .connections
+ .get_mut(endpoint_id)
+ .ok_or_else(|| io::Error::new(io::ErrorKind::NotConnected, "endpoint is unavailable"))
+ .and_then(|connection| connection.transport.send(message));
+ match result {
+ Ok(()) => EndpointSendOutcome::Sent,
+ Err(error) => {
+ self.record_failure(endpoint_id.clone(), error);
+ EndpointSendOutcome::NotSent
+ }
+ }
+ }
+
+ pub(crate) fn disconnect(&mut self, endpoint_id: &ClientEndpointId) {
+ self.failures
+ .retain(|failure| &failure.endpoint_id != endpoint_id);
+ if let Some(mut connection) = self.connections.remove(endpoint_id) {
+ connection.transport.disconnect();
+ }
+ }
+
+ pub(crate) fn fail(&mut self, endpoint_id: &ClientEndpointId, error: io::Error) {
+ self.record_failure(endpoint_id.clone(), error);
+ }
+
+ pub(crate) fn take_failures(&mut self) -> Vec<EndpointTransportFailure> {
+ let errors = self
+ .connections
+ .iter_mut()
+ .filter_map(|(id, connection)| {
+ connection
+ .transport
+ .take_error()
+ .map(|error| (id.clone(), error))
+ })
+ .collect::<Vec<_>>();
+ for (endpoint_id, error) in errors {
+ self.record_failure(endpoint_id, error);
+ }
+ std::mem::take(&mut self.failures)
+ }
+
+ fn record_failure(&mut self, endpoint_id: ClientEndpointId, error: io::Error) {
+ let Some(generation) = self
+ .connections
+ .get(&endpoint_id)
+ .map(|connection| connection.generation)
+ else {
+ return;
+ };
+ let failure = EndpointTransportFailure {
+ endpoint_id: endpoint_id.clone(),
+ generation,
+ kind: error.kind(),
+ message: error.to_string(),
+ };
+ if let Some(mut connection) = self.connections.remove(&endpoint_id) {
+ connection.transport.disconnect();
+ }
+ if let Some(existing) = self
+ .failures
+ .iter_mut()
+ .find(|existing| existing.endpoint_id == endpoint_id)
+ {
+ *existing = failure;
+ } else {
+ self.failures.push(failure);
+ }
+ }
+}
+
+impl Drop for EndpointRegistry {
+ fn drop(&mut self) {
+ let deadline = Instant::now() + std::time::Duration::from_millis(250);
+ for connection in self.connections.values_mut() {
+ let _ = connection.transport.send(&ClientMessage::Detach);
+ }
+ for connection in self.connections.values_mut() {
+ let _ = connection.transport.flush(deadline);
+ connection.transport.disconnect();
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::{Arc, Mutex};
+
+ use super::*;
+
+ struct FakeTransport {
+ sent: Arc<Mutex<Vec<ClientMessage>>>,
+ error: Option<io::ErrorKind>,
+ }
+
+ impl EndpointTransport for FakeTransport {
+ fn send(&mut self, message: &ClientMessage) -> io::Result<()> {
+ if let Some(kind) = self.error {
+ return Err(io::Error::new(kind, "fake transport failure"));
+ }
+ self.sent.lock().unwrap().push(message.clone());
+ Ok(())
+ }
+ }
+
+ fn negotiation() -> EndpointNegotiation {
+ EndpointNegotiation::new(
+ vec!["client_shell.surface.set".into()],
+ vec![
+ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(),
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(),
+ crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(),
+ ],
+ )
+ }
+
+ fn profile() -> crate::client::endpoint::ProfileId {
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()
+ }
+
+ #[test]
+ fn endpoint_failures_do_not_remove_other_connections() {
+ let local_sent = Arc::new(Mutex::new(Vec::new()));
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: local_sent.clone(),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ let ssh_id = ClientEndpointId::Ssh(profile());
+ registry.insert(
+ ssh_id.clone(),
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: Some(io::ErrorKind::BrokenPipe),
+ },
+ 2,
+ negotiation(),
+ true,
+ );
+ assert!(registry.set_active(&ssh_id));
+
+ assert_eq!(
+ registry.send(&ClientMessage::ClientShellFocus { focused: true }),
+ EndpointSendOutcome::NotSent
+ );
+ assert!(registry.connection(&ssh_id).is_none());
+ assert!(registry.connection(&ClientEndpointId::Local).is_some());
+ assert_eq!(registry.take_failures()[0].endpoint_id, ssh_id);
+
+ assert!(registry.set_active(&ClientEndpointId::Local));
+ assert_eq!(
+ registry.send(&ClientMessage::ClientShellFocus { focused: true }),
+ EndpointSendOutcome::Sent
+ );
+ assert_eq!(local_sent.lock().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn reconnecting_active_identity_does_not_count_as_an_active_surface() {
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ let ssh_id = ClientEndpointId::Ssh(profile());
+ registry.insert(
+ ssh_id.clone(),
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ true,
+ );
+ assert!(registry.set_active(&ssh_id));
+ assert!(registry.active_surface_available());
+ registry.disconnect(&ssh_id);
+ registry.insert(
+ ssh_id,
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 3,
+ negotiation(),
+ false,
+ );
+ assert!(!registry.active_surface_available());
+ }
+
+ #[test]
+ fn recovered_local_uses_transport_failure_not_remote_health_probes() {
+ let mut registry = EndpointRegistry::empty();
+ let sent = Arc::new(Mutex::new(Vec::new()));
+ registry.insert(
+ ClientEndpointId::Local,
+ FakeTransport {
+ sent: sent.clone(),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ false,
+ );
+ registry.tick_health(Instant::now() + std::time::Duration::from_secs(300));
+ assert!(registry.connection(&ClientEndpointId::Local).is_some());
+ assert!(sent.lock().unwrap().is_empty());
+ assert!(registry.take_failures().is_empty());
+ }
+
+ #[test]
+ fn negotiated_remote_health_probe_expires_the_connection() {
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ let ssh_id = ClientEndpointId::Ssh(profile());
+ let sent = Arc::new(Mutex::new(Vec::new()));
+ registry.insert(
+ ssh_id.clone(),
+ FakeTransport {
+ sent: sent.clone(),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ false,
+ );
+ let now = Instant::now();
+ registry.tick_health(now + super::super::health::HEARTBEAT_INTERVAL);
+ assert!(matches!(
+ sent.lock().unwrap().as_slice(),
+ [ClientMessage::EndpointControl { kind, .. }]
+ if kind == crate::protocol::endpoint::HEALTH_PING_KIND
+ ));
+
+ registry.tick_health(
+ now + super::super::health::HEARTBEAT_INTERVAL
+ + super::super::health::HEARTBEAT_TIMEOUT,
+ );
+ assert!(registry.connection(&ssh_id).is_none());
+ assert_eq!(registry.take_failures()[0].kind, io::ErrorKind::TimedOut);
+ }
+
+ #[test]
+ fn a_ready_endpoint_can_stay_connected_after_the_initial_deadline() {
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ let ssh_id = ClientEndpointId::Ssh(profile());
+ registry.insert(
+ ssh_id.clone(),
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ false,
+ );
+ let now = Instant::now();
+ registry.mark_ready(&ssh_id, 2);
+ registry.received(&ssh_id, 2, now + super::super::health::HEARTBEAT_INTERVAL);
+ registry.tick_health(now + super::super::health::HEARTBEAT_TIMEOUT);
+ assert!(registry.connection(&ssh_id).is_some());
+ }
+
+ #[test]
+ fn negotiated_surface_interest_requires_capability_and_method() {
+ assert!(negotiation().supports_surface_interest());
+ assert!(negotiation().supports_health_check());
+ assert!(
+ !EndpointNegotiation::new(vec!["client_shell.surface.set".into()], Vec::new())
+ .supports_surface_interest()
+ );
+ assert!(!EndpointNegotiation::new(
+ Vec::new(),
+ vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()]
+ )
+ .supports_surface_interest());
+ assert!(!EndpointNegotiation::new(
+ vec!["client_shell.surface.set".into()],
+ vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()]
+ )
+ .supports_surface_interest());
+ }
+
+ #[test]
+ fn dropping_registry_detaches_every_connected_endpoint() {
+ let local_sent = Arc::new(Mutex::new(Vec::new()));
+ let remote_sent = Arc::new(Mutex::new(Vec::new()));
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: local_sent.clone(),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ registry.insert(
+ ClientEndpointId::Ssh(profile()),
+ FakeTransport {
+ sent: remote_sent.clone(),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ false,
+ );
+
+ drop(registry);
+
+ assert!(matches!(
+ local_sent.lock().unwrap().as_slice(),
+ [ClientMessage::Detach]
+ ));
+ assert!(matches!(
+ remote_sent.lock().unwrap().as_slice(),
+ [ClientMessage::Detach]
+ ));
+ }
+
+ #[test]
+ fn stale_generations_are_rejected() {
+ let registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 7,
+ negotiation(),
+ );
+ assert!(registry.accepts(&ClientEndpointId::Local, 7));
+ assert!(!registry.accepts(&ClientEndpointId::Local, 6));
+ }
+}
diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs
new file mode 100644
index 00000000..458ce927
--- /dev/null
+++ b/src/client/endpoint/supervisor.rs
@@ -0,0 +1,492 @@
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use super::{ClientEndpointId, ClientEndpointStatus, EndpointNegotiation, NativeEndpointTransport};
+use crate::protocol::{ClientSurfaceSize, RenderEncoding};
+use interprocess::TryClone as _;
+
+const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(500);
+const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
+
+#[derive(Clone, Copy)]
+pub(crate) struct EndpointConnectOptions {
+ pub(crate) cols: u16,
+ pub(crate) rows: u16,
+ pub(crate) cell_width_px: u32,
+ pub(crate) cell_height_px: u32,
+ pub(crate) pixel_geometry_exact: bool,
+ pub(crate) surface_size: ClientSurfaceSize,
+ pub(crate) endpoint_keybindings: bool,
+ pub(crate) mouse_capture: bool,
+}
+
+pub(crate) enum EndpointSupervisorEvent {
+ Status {
+ endpoint_id: ClientEndpointId,
+ generation: u64,
+ status: ClientEndpointStatus,
+ message: String,
+ },
+ Connected {
+ endpoint_id: ClientEndpointId,
+ generation: u64,
+ reader: crate::ipc::LocalStream,
+ writer: NativeEndpointTransport,
+ negotiation: EndpointNegotiation,
+ },
+}
+
+#[derive(Clone)]
+enum ConnectTarget {
+ Local(PathBuf),
+ Ssh(super::SavedSshEndpoint),
+}
+
+struct ReconnectState {
+ target: ConnectTarget,
+ attempts: u32,
+ next_attempt: Option<Instant>,
+ in_flight: bool,
+ generation: Option<u64>,
+}
+
+impl ReconnectState {
+ fn new(target: ConnectTarget, now: Instant) -> Self {
+ Self {
+ target,
+ attempts: 0,
+ next_attempt: Some(now),
+ in_flight: false,
+ generation: None,
+ }
+ }
+}
+
+pub(crate) struct EndpointSupervisors {
+ endpoints: HashMap<ClientEndpointId, ReconnectState>,
+ next_generation: u64,
+ shutdown: Arc<AtomicBool>,
+}
+
+impl EndpointSupervisors {
+ pub(crate) fn new(profiles: &[super::SavedSshEndpoint], now: Instant) -> Self {
+ let endpoints = profiles
+ .iter()
+ .filter(|profile| profile.enabled)
+ .map(|profile| {
+ (
+ ClientEndpointId::Ssh(profile.id.clone()),
+ ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now),
+ )
+ })
+ .collect();
+ Self {
+ endpoints,
+ next_generation: 2,
+ shutdown: Arc::new(AtomicBool::new(false)),
+ }
+ }
+
+ pub(crate) fn add_local(&mut self, path: PathBuf, generation: Option<u64>, now: Instant) {
+ let mut state = ReconnectState::new(ConnectTarget::Local(path), now);
+ state.generation = generation;
+ if generation.is_some() {
+ state.next_attempt = None;
+ }
+ self.endpoints.insert(ClientEndpointId::Local, state);
+ }
+
+ pub(crate) fn reconcile_profiles(
+ &mut self,
+ profiles: &[super::SavedSshEndpoint],
+ now: Instant,
+ ) -> Vec<ClientEndpointId> {
+ let mut retired = Vec::new();
+ self.endpoints.retain(|endpoint_id, state| {
+ let ConnectTarget::Ssh(previous) = &state.target else {
+ return true;
+ };
+ let keep = profiles.iter().any(|profile| {
+ profile.id == previous.id
+ && profile.enabled
+ && profile.target == previous.target
+ && profile.session == previous.session
+ });
+ if !keep {
+ retired.push(endpoint_id.clone());
+ }
+ keep
+ });
+ for profile in profiles.iter().filter(|profile| profile.enabled) {
+ let state = self
+ .endpoints
+ .entry(ClientEndpointId::Ssh(profile.id.clone()))
+ .or_insert_with(|| ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now));
+ state.target = ConnectTarget::Ssh(profile.clone());
+ }
+ retired
+ }
+
+ pub(crate) fn spawn_due(
+ &mut self,
+ now: Instant,
+ options: EndpointConnectOptions,
+ event_tx: &tokio::sync::mpsc::Sender<EndpointSupervisorEvent>,
+ ) {
+ for (endpoint_id, state) in &mut self.endpoints {
+ if state.in_flight || state.next_attempt.is_none_or(|deadline| deadline > now) {
+ continue;
+ }
+ state.in_flight = true;
+ state.next_attempt = None;
+ let generation = self.next_generation;
+ state.generation = Some(generation);
+ self.next_generation = self.next_generation.saturating_add(1);
+ let endpoint_id = endpoint_id.clone();
+ let target = state.target.clone();
+ let event_tx = event_tx.clone();
+ let shutdown = self.shutdown.clone();
+ tokio::spawn(async move {
+ if shutdown.load(Ordering::Acquire) {
+ return;
+ }
+ let task_endpoint_id = endpoint_id.clone();
+ let result = tokio::task::spawn_blocking(move || {
+ connect_once(&target, options, endpoint_id, generation)
+ })
+ .await;
+ let event = match result {
+ Ok(Ok(event)) => event,
+ Ok(Err(error)) => EndpointSupervisorEvent::Status {
+ endpoint_id: task_endpoint_id,
+ generation,
+ status: if failure_needs_attention(&error) {
+ ClientEndpointStatus::Attention
+ } else {
+ ClientEndpointStatus::Reconnecting
+ },
+ message: error.to_string(),
+ },
+ Err(error) => EndpointSupervisorEvent::Status {
+ endpoint_id: task_endpoint_id,
+ generation,
+ status: ClientEndpointStatus::Reconnecting,
+ message: format!("endpoint connection task stopped unexpectedly: {error}"),
+ },
+ };
+ if !shutdown.load(Ordering::Acquire) {
+ let _ = event_tx.send(event).await;
+ }
+ });
+ }
+ }
+
+ pub(crate) fn record_status(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ status: ClientEndpointStatus,
+ now: Instant,
+ ) -> bool {
+ let Some(state) = self.endpoints.get_mut(endpoint_id) else {
+ return false;
+ };
+ if state.generation != Some(generation) {
+ return false;
+ }
+ state.in_flight = false;
+ match status {
+ ClientEndpointStatus::Online => {
+ state.attempts = 0;
+ state.next_attempt = None;
+ }
+ ClientEndpointStatus::Attention | ClientEndpointStatus::Disabled => {
+ state.next_attempt = None
+ }
+ ClientEndpointStatus::Connecting | ClientEndpointStatus::Reconnecting => {
+ state.attempts = state.attempts.saturating_add(1);
+ state.next_attempt = Some(now + retry_delay(state.attempts));
+ }
+ }
+ true
+ }
+
+ pub(crate) fn disconnected(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ now: Instant,
+ ) -> bool {
+ self.record_status(
+ endpoint_id,
+ generation,
+ ClientEndpointStatus::Reconnecting,
+ now,
+ )
+ }
+}
+
+impl Drop for EndpointSupervisors {
+ fn drop(&mut self) {
+ self.shutdown.store(true, Ordering::Release);
+ }
+}
+
+fn connect_once(
+ target: &ConnectTarget,
+ options: EndpointConnectOptions,
+ endpoint_id: ClientEndpointId,
+ generation: u64,
+) -> Result<EndpointSupervisorEvent, std::io::Error> {
+ let (mut stream, lifetime): (_, Box<dyn Send>) = match target {
+ ConnectTarget::Local(path) => {
+ let stream = crate::ipc::connect_local_stream(path).map_err(|error| {
+ // An absent Local socket is transient, unlike a missing SSH install.
+ if error.kind() == std::io::ErrorKind::NotFound {
+ std::io::Error::new(
+ std::io::ErrorKind::ConnectionRefused,
+ "Local is unavailable; start its server to reconnect",
+ )
+ } else {
+ error
+ }
+ })?;
+ (stream, Box::new(()))
+ }
+ ConnectTarget::Ssh(profile) => {
+ let connected = crate::remote::connect_saved_ssh(profile.id.as_str(), &profile.target, &profile.session).map_err(|error| {
+ if failure_needs_attention(&error) {
+ std::io::Error::new(error.kind(), format!("{error}. Run `{}` interactively to approve setup, then restart this client", crate::remote::saved_ssh_bootstrap_command(&profile.target, &profile.session)))
+ } else { error }
+ })?;
+ (connected.stream, Box::new(connected.bridge))
+ }
+ };
+ let handshake = super::super::do_handshake(
+ &mut stream,
+ options.cols,
+ options.rows,
+ options.cell_width_px,
+ options.cell_height_px,
+ options.pixel_geometry_exact,
+ Some(options.surface_size),
+ options.endpoint_keybindings,
+ options.mouse_capture,
+ false,
+ )
+ .map_err(handshake_error)?;
+ if handshake.encoding != RenderEncoding::SemanticFrame {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "endpoint did not negotiate the semantic client shell",
+ ));
+ }
+ let negotiation = EndpointNegotiation::new(
+ handshake.endpoint_methods.unwrap_or_default(),
+ handshake.endpoint_capabilities.unwrap_or_default(),
+ );
+ if !negotiation.supports_surface_interest()
+ || (!endpoint_id.is_local() && !negotiation.supports_health_check())
+ {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::Unsupported,
+ "this machine needs a server update before it can participate in multi-machine viewing",
+ ));
+ }
+ let reader = stream.try_clone()?;
+ let writer = NativeEndpointTransport::with_lifetime(stream, lifetime)?;
+ Ok(EndpointSupervisorEvent::Connected {
+ endpoint_id,
+ generation,
+ reader,
+ writer,
+ negotiation,
+ })
+}
+
+fn failure_needs_attention(error: &std::io::Error) -> bool {
+ crate::remote::saved_ssh_failure_needs_attention(error)
+}
+
+fn handshake_error(error: crate::client::ClientError) -> std::io::Error {
+ use crate::client::ClientError;
+ use crate::protocol::FramingError;
+ match error {
+ ClientError::ConnectionFailed(error) | ClientError::ConnectionLost(error) => error,
+ ClientError::HandshakeRejected { error, .. } => {
+ std::io::Error::new(std::io::ErrorKind::Unsupported, error)
+ }
+ ClientError::Protocol(FramingError::Io(error)) => error,
+ ClientError::Protocol(error) => {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string())
+ }
+ ClientError::ServerShutdown { reason } => std::io::Error::new(
+ std::io::ErrorKind::ConnectionAborted,
+ reason.unwrap_or_else(|| "server shut down during handshake".into()),
+ ),
+ }
+}
+
+fn retry_delay(attempt: u32) -> Duration {
+ INITIAL_RETRY_DELAY
+ .saturating_mul(
+ 1_u32
+ .checked_shl(attempt.saturating_sub(1).min(6))
+ .unwrap_or(u32::MAX),
+ )
+ .min(MAX_RETRY_DELAY)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::client::endpoint::ProfileId;
+
+ fn profile() -> super::super::SavedSshEndpoint {
+ super::super::SavedSshEndpoint {
+ id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Build".into(),
+ target: "build".into(),
+ session: "agents".into(),
+ enabled: true,
+ }
+ }
+
+ #[test]
+ fn live_catalog_preserves_renamed_connections_and_local_recovery() {
+ let now = Instant::now();
+ let mut profile = profile();
+ let id = ClientEndpointId::Ssh(profile.id.clone());
+ let mut supervisors = EndpointSupervisors::new(&[profile.clone()], now);
+ supervisors.add_local(PathBuf::from("local"), Some(1), now);
+ let state = supervisors.endpoints.get_mut(&id).unwrap();
+ state.generation = Some(7);
+ state.attempts = 3;
+ state.next_attempt = Some(now + Duration::from_secs(4));
+ profile.label = "Renamed".into();
+ assert!(supervisors.reconcile_profiles(&[profile], now).is_empty());
+ let state = &supervisors.endpoints[&id];
+ assert_eq!(state.generation, Some(7));
+ assert_eq!(state.attempts, 3);
+ assert_eq!(state.next_attempt, Some(now + Duration::from_secs(4)));
+ assert_eq!(
+ supervisors.endpoints[&ClientEndpointId::Local].generation,
+ Some(1)
+ );
+ }
+
+ #[test]
+ fn live_catalog_add_disable_enable_and_remove_fence_late_connections() {
+ let now = Instant::now();
+ let mut profile = profile();
+ let id = ClientEndpointId::Ssh(profile.id.clone());
+ let mut supervisors = EndpointSupervisors::new(&[], now);
+ assert!(supervisors
+ .reconcile_profiles(&[profile.clone()], now)
+ .is_empty());
+ assert_eq!(supervisors.endpoints[&id].next_attempt, Some(now));
+ supervisors.endpoints.get_mut(&id).unwrap().generation = Some(7);
+ supervisors.next_generation = 8;
+ profile.enabled = false;
+ assert_eq!(
+ supervisors.reconcile_profiles(&[profile.clone()], now),
+ vec![id.clone()]
+ );
+ assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now));
+ profile.enabled = true;
+ assert!(supervisors
+ .reconcile_profiles(&[profile.clone()], now)
+ .is_empty());
+ assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now));
+ assert_eq!(supervisors.next_generation, 8);
+ assert_eq!(supervisors.reconcile_profiles(&[], now), vec![id.clone()]);
+ assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now));
+ }
+
+ #[test]
+ fn live_catalog_destination_change_retires_only_that_machine() {
+ let now = Instant::now();
+ let mut changed = profile();
+ let other = super::super::SavedSshEndpoint::new("Other", "other", "main").unwrap();
+ let id = ClientEndpointId::Ssh(changed.id.clone());
+ let other_id = ClientEndpointId::Ssh(other.id.clone());
+ let mut supervisors = EndpointSupervisors::new(&[changed.clone(), other.clone()], now);
+ supervisors.endpoints.get_mut(&id).unwrap().generation = Some(2);
+ supervisors.endpoints.get_mut(&other_id).unwrap().generation = Some(3);
+ changed.session = "another-session".into();
+ assert_eq!(
+ supervisors.reconcile_profiles(&[changed, other], now),
+ vec![id.clone()]
+ );
+ assert_eq!(supervisors.endpoints[&id].generation, None);
+ assert_eq!(supervisors.endpoints[&other_id].generation, Some(3));
+ }
+
+ #[test]
+ fn retry_backoff_is_bounded() {
+ assert_eq!(retry_delay(1), INITIAL_RETRY_DELAY);
+ assert_eq!(retry_delay(100), MAX_RETRY_DELAY);
+ }
+
+ #[test]
+ fn handshake_network_failures_retry_but_incompatibility_needs_attention() {
+ let timeout = handshake_error(crate::client::ClientError::ConnectionLost(
+ std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out"),
+ ));
+ assert!(!failure_needs_attention(&timeout));
+ let rejected = handshake_error(crate::client::ClientError::HandshakeRejected {
+ version: 1,
+ error: "surface capability missing".into(),
+ });
+ assert_eq!(rejected.kind(), std::io::ErrorKind::Unsupported);
+ assert!(failure_needs_attention(&rejected));
+ }
+
+ #[test]
+ fn healthy_local_only_retries_after_its_connection_fails() {
+ let now = Instant::now();
+ let mut supervisors = EndpointSupervisors::new(&[profile()], now);
+ supervisors.add_local(PathBuf::from("local.sock"), Some(1), now);
+ assert!(supervisors.endpoints[&ClientEndpointId::Local]
+ .next_attempt
+ .is_none());
+ assert!(!supervisors.disconnected(&ClientEndpointId::Local, 0, now));
+ assert!(supervisors.endpoints[&ClientEndpointId::Local]
+ .next_attempt
+ .is_none());
+ assert!(supervisors.disconnected(&ClientEndpointId::Local, 1, now));
+ assert_eq!(
+ supervisors.endpoints[&ClientEndpointId::Local].next_attempt,
+ Some(now + INITIAL_RETRY_DELAY)
+ );
+ assert_eq!(
+ supervisors.endpoints[&ClientEndpointId::Ssh(profile().id)].next_attempt,
+ Some(now)
+ );
+ }
+
+ #[test]
+ fn ssh_recovery_rejects_stale_generations_and_stops_retries_for_attention() {
+ let now = Instant::now();
+ let mut supervisors = EndpointSupervisors::new(&[profile()], now);
+ let endpoint_id = ClientEndpointId::Ssh(profile().id);
+ supervisors
+ .endpoints
+ .get_mut(&endpoint_id)
+ .unwrap()
+ .generation = Some(4);
+ assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Online, now));
+ assert!(!supervisors.disconnected(&endpoint_id, 3, now));
+ assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none());
+ assert!(supervisors.disconnected(&endpoint_id, 4, now));
+ assert_eq!(
+ supervisors.endpoints[&endpoint_id].next_attempt,
+ Some(now + INITIAL_RETRY_DELAY)
+ );
+ assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Attention, now));
+ assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none());
+ }
+}
diff --git a/src/client/endpoint/writer.rs b/src/client/endpoint/writer.rs
new file mode 100644
index 00000000..973ff234
--- /dev/null
+++ b/src/client/endpoint/writer.rs
@@ -0,0 +1,400 @@
+use std::io;
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::{mpsc, Arc, Mutex};
+use std::time::{Duration, Instant};
+
+use interprocess::local_socket::traits::Stream as _;
+
+use super::EndpointTransport;
+use crate::ipc::LocalStream;
+use crate::protocol::ClientMessage;
+
+const MAX_QUEUED_MESSAGES: usize = 256;
+const MAX_QUEUED_BYTES: usize = 2 * crate::protocol::MAX_GRAPHICS_FRAME_SIZE;
+const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
+const IO_POLL_INTERVAL: Duration = Duration::from_millis(2);
+
+enum WriterCommand {
+ Frame(Vec<u8>),
+ Flush(mpsc::Sender<()>),
+}
+
+/// The UI only enqueues complete frames. A worker owns partial writes, cancellation, and the
+/// bridge lifetime, so neither socket backpressure nor bridge teardown can block other endpoints.
+pub(crate) struct NativeEndpointTransport {
+ sender: mpsc::SyncSender<WriterCommand>,
+ queued_bytes: Arc<AtomicUsize>,
+ stopped: Arc<AtomicBool>,
+ error: Arc<Mutex<Option<io::Error>>>,
+}
+
+impl NativeEndpointTransport {
+ pub(crate) fn with_lifetime(
+ mut stream: LocalStream,
+ lifetime: impl Send + 'static,
+ ) -> io::Result<Self> {
+ stream.set_nonblocking(true)?;
+ let (sender, receiver) = mpsc::sync_channel::<WriterCommand>(MAX_QUEUED_MESSAGES);
+ let queued_bytes = Arc::new(AtomicUsize::new(0));
+ let stopped = Arc::new(AtomicBool::new(false));
+ let error = Arc::new(Mutex::new(None));
+ let worker_bytes = queued_bytes.clone();
+ let worker_stop = stopped.clone();
+ let worker_error = error.clone();
+ std::thread::Builder::new()
+ .name("endpoint-writer".into())
+ .spawn(move || {
+ let _lifetime = lifetime;
+ while let Ok(command) = receiver.recv() {
+ if worker_stop.load(Ordering::Acquire) {
+ break;
+ }
+ let frame = match command {
+ WriterCommand::Frame(frame) => frame,
+ WriterCommand::Flush(done) => {
+ let _ = done.send(());
+ continue;
+ }
+ };
+ let result = write_frame(&mut stream, &frame, &worker_stop);
+ worker_bytes.fetch_sub(frame.len(), Ordering::AcqRel);
+ if let Err(error) = result {
+ if let Ok(mut slot) = worker_error.lock() {
+ *slot = Some(error);
+ }
+ worker_stop.store(true, Ordering::Release);
+ break;
+ }
+ }
+ })?;
+ Ok(Self {
+ sender,
+ queued_bytes,
+ stopped,
+ error,
+ })
+ }
+
+ pub(crate) fn stop_handle(&self) -> Arc<AtomicBool> {
+ self.stopped.clone()
+ }
+}
+
+impl EndpointTransport for NativeEndpointTransport {
+ fn send(&mut self, message: &ClientMessage) -> io::Result<()> {
+ if self.stopped.load(Ordering::Acquire) {
+ return Err(io::Error::new(
+ io::ErrorKind::BrokenPipe,
+ "endpoint writer stopped",
+ ));
+ }
+ let mut frame = Vec::new();
+ crate::protocol::write_message(&mut frame, message)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
+ let len = frame.len();
+ if self
+ .queued_bytes
+ .fetch_update(Ordering::AcqRel, Ordering::Acquire, |bytes| {
+ bytes
+ .checked_add(len)
+ .filter(|total| *total <= MAX_QUEUED_BYTES)
+ })
+ .is_err()
+ {
+ return Err(queue_full());
+ }
+ self.sender
+ .try_send(WriterCommand::Frame(frame))
+ .map_err(|error| {
+ self.queued_bytes.fetch_sub(len, Ordering::AcqRel);
+ match error {
+ mpsc::TrySendError::Full(_) => queue_full(),
+ mpsc::TrySendError::Disconnected(_) => {
+ io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped")
+ }
+ }
+ })
+ }
+
+ fn disconnect(&mut self) {
+ self.stopped.store(true, Ordering::Release);
+ }
+
+ fn flush(&mut self, deadline: Instant) -> io::Result<()> {
+ let (done, completion) = mpsc::channel();
+ self.sender
+ .try_send(WriterCommand::Flush(done))
+ .map_err(|_| queue_full())?;
+ completion
+ .recv_timeout(deadline.saturating_duration_since(Instant::now()))
+ .map_err(|error| match error {
+ mpsc::RecvTimeoutError::Timeout => {
+ io::Error::new(io::ErrorKind::TimedOut, "endpoint flush timed out")
+ }
+ mpsc::RecvTimeoutError::Disconnected => {
+ io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped")
+ }
+ })
+ }
+
+ fn take_error(&mut self) -> Option<io::Error> {
+ self.error.lock().ok()?.take()
+ }
+}
+
+impl Drop for NativeEndpointTransport {
+ fn drop(&mut self) {
+ self.stopped.store(true, Ordering::Release);
+ }
+}
+
+fn queue_full() -> io::Error {
+ // A message may already be partially written. Retrying or dropping only this message would
+ // lose input ordering; revoke the connection and recover through the normal lifecycle.
+ io::Error::new(
+ io::ErrorKind::ConnectionAborted,
+ "endpoint output queue is full",
+ )
+}
+
+fn write_frame(
+ writer: &mut impl io::Write,
+ mut frame: &[u8],
+ stopped: &AtomicBool,
+) -> io::Result<()> {
+ let deadline = Instant::now() + WRITE_TIMEOUT;
+ while !frame.is_empty() && !stopped.load(Ordering::Acquire) {
+ match writer.write(frame) {
+ Ok(0) => {}
+ Ok(written) => {
+ frame = &frame[written..];
+ continue;
+ }
+ Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
+ Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
+ Err(error) => return Err(error),
+ }
+ if Instant::now() >= deadline {
+ return Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ "endpoint write timed out",
+ ));
+ }
+ std::thread::sleep(IO_POLL_INTERVAL);
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn streams() -> (LocalStream, LocalStream, std::path::PathBuf) {
+ use interprocess::local_socket::traits::Listener as _;
+ let path = std::env::temp_dir().join(format!(
+ "herdr-writer-{}-{}.sock",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ let listener = crate::ipc::bind_private_local_listener(&path).unwrap();
+ let accepting = std::thread::spawn(move || listener.accept().unwrap());
+ let client = crate::ipc::connect_local_stream(&path).unwrap();
+ (client, accepting.join().unwrap(), path)
+ }
+
+ #[test]
+ fn native_endpoint_writer_delivers_ordered_protocol_frames() {
+ let (stream, mut peer, path) = streams();
+ let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap();
+ let (done, received) = mpsc::channel();
+ let reader = std::thread::spawn(move || {
+ let first: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap();
+ let second: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap();
+ done.send((first, second)).unwrap();
+ });
+ transport
+ .send(&ClientMessage::ClientShellFocus { focused: true })
+ .unwrap();
+ transport
+ .send(&ClientMessage::ClientShellFocus { focused: false })
+ .unwrap();
+ let messages = received.recv_timeout(Duration::from_secs(3)).unwrap();
+ assert_eq!(
+ messages,
+ (
+ ClientMessage::ClientShellFocus { focused: true },
+ ClientMessage::ClientShellFocus { focused: false }
+ )
+ );
+ reader.join().unwrap();
+ drop(transport);
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn registry_exit_flushes_queued_input_and_a_complete_detach() {
+ let (stream, mut peer, path) = streams();
+ let transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap();
+ let (done, received) = mpsc::channel();
+ let reader = std::thread::spawn(move || {
+ let result = (|| {
+ let first: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?;
+ let second: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?;
+ Ok::<_, crate::protocol::FramingError>((first, second))
+ })();
+ done.send(result).unwrap();
+ });
+ let mut registry = super::super::EndpointRegistry::new(
+ transport,
+ 1,
+ super::super::EndpointNegotiation::default(),
+ );
+ let input = ClientMessage::Input {
+ data: b"queued input".to_vec(),
+ };
+ assert_eq!(
+ registry.send(&input),
+ super::super::EndpointSendOutcome::Sent
+ );
+ drop(registry);
+ let (first, second) = received
+ .recv_timeout(Duration::from_secs(3))
+ .unwrap()
+ .expect("clean exit must flush complete frames before closing");
+ assert_eq!(first, input);
+ assert_eq!(second, ClientMessage::Detach);
+ reader.join().unwrap();
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn native_endpoint_flush_drains_large_frames_before_detach() {
+ let (stream, mut peer, path) = streams();
+ let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap();
+ let (done, received) = mpsc::channel();
+ let reader = std::thread::spawn(move || {
+ let first: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap();
+ let second: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap();
+ done.send((first, second)).unwrap();
+ });
+ let input = ClientMessage::Input {
+ data: vec![b'x'; 1024 * 1024],
+ };
+ transport.send(&input).unwrap();
+ transport.send(&ClientMessage::Detach).unwrap();
+ // Large-frame correctness must not depend on the registry's short exit grace period.
+ transport
+ .flush(Instant::now() + Duration::from_secs(10))
+ .unwrap();
+ drop(transport);
+ let (first, second) = received.recv_timeout(Duration::from_secs(10)).unwrap();
+ assert_eq!(first, input);
+ assert_eq!(second, ClientMessage::Detach);
+ reader.join().unwrap();
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn native_endpoint_teardown_does_not_wait_for_a_stalled_peer() {
+ struct Lifetime(mpsc::Sender<()>);
+ impl Drop for Lifetime {
+ fn drop(&mut self) {
+ let _ = self.0.send(());
+ }
+ }
+ let (stream, peer, path) = streams();
+ let (done, dropped) = mpsc::channel();
+ let mut transport = NativeEndpointTransport::with_lifetime(stream, Lifetime(done)).unwrap();
+ transport
+ .send(&ClientMessage::Input {
+ data: vec![b'x'; 2 * 1024 * 1024],
+ })
+ .unwrap();
+ drop(transport);
+ dropped
+ .recv_timeout(Duration::from_secs(3))
+ .expect("worker lifetime is released without peer reads");
+ drop(peer);
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn partial_writes_preserve_frame_bytes() {
+ struct PartialWriter(Vec<u8>);
+ impl io::Write for PartialWriter {
+ fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
+ let count = bytes.len().min(3);
+ self.0.extend_from_slice(&bytes[..count]);
+ Ok(count)
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+ }
+ let mut writer = PartialWriter(Vec::new());
+ write_frame(&mut writer, b"first frame", &AtomicBool::new(false)).unwrap();
+ write_frame(&mut writer, b"second frame", &AtomicBool::new(false)).unwrap();
+ assert_eq!(writer.0, b"first framesecond frame");
+ }
+
+ #[test]
+ fn a_stalled_write_is_cancellable_without_peer_progress() {
+ struct StalledWriter(mpsc::Sender<()>);
+ impl io::Write for StalledWriter {
+ fn write(&mut self, _: &[u8]) -> io::Result<usize> {
+ let _ = self.0.send(());
+ Err(io::ErrorKind::WouldBlock.into())
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+ }
+ let stop = Arc::new(AtomicBool::new(false));
+ let worker_stop = stop.clone();
+ let (attempted, attempts) = mpsc::channel();
+ let (done, completion) = mpsc::channel();
+ let worker = std::thread::spawn(move || {
+ let result = write_frame(&mut StalledWriter(attempted), b"input", &worker_stop);
+ done.send(result).unwrap();
+ });
+ attempts.recv_timeout(Duration::from_secs(2)).unwrap();
+ stop.store(true, Ordering::Release);
+ completion
+ .recv_timeout(Duration::from_secs(2))
+ .unwrap()
+ .unwrap();
+ worker.join().unwrap();
+ }
+
+ #[test]
+ fn a_full_queue_is_a_connection_failure_not_silent_input_loss() {
+ let (sender, _receiver) = mpsc::sync_channel(1);
+ let mut transport = NativeEndpointTransport {
+ sender,
+ queued_bytes: Arc::new(AtomicUsize::new(0)),
+ stopped: Arc::new(AtomicBool::new(false)),
+ error: Arc::new(Mutex::new(None)),
+ };
+ transport.send(&ClientMessage::Detach).unwrap();
+ assert_eq!(
+ transport.send(&ClientMessage::Detach).unwrap_err().kind(),
+ io::ErrorKind::ConnectionAborted
+ );
+ transport
+ .queued_bytes
+ .store(MAX_QUEUED_BYTES, Ordering::Release);
+ assert_eq!(
+ transport.send(&ClientMessage::Detach).unwrap_err().kind(),
+ io::ErrorKind::ConnectionAborted
+ );
+ }
+}
diff --git a/src/client/endpoint_commands.rs b/src/client/endpoint_commands.rs
index d07b3d42..8e77b796 100644
--- a/src/client/endpoint_commands.rs
+++ b/src/client/endpoint_commands.rs
@@ -1,127 +1,269 @@
-use std::collections::VecDeque;
+use std::collections::{HashMap, VecDeque};
use std::io;
use std::time::{Duration, Instant};
use crate::api::client::ApiClientError;
use crate::api::schema::{Request, ResponseResult};
-use crate::ipc::LocalStream;
use crate::protocol::ClientMessage;
+use super::endpoint::{ClientEndpointId, EndpointRegistry, EndpointSendOutcome};
use super::shell::ClientShellEndpointError;
const ENDPOINT_COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
+const MAX_RETIRED_REQUESTS_PER_ENDPOINT: usize = 128;
+
+struct QueuedCommand {
+ generation: u64,
+ boot_id: String,
+ request: Box<Request>,
+}
struct InFlightCommand {
+ generation: u64,
boot_id: String,
request_id: String,
response: Vec<u8>,
sent_at: Instant,
- timed_out: bool,
}
pub(super) struct EndpointCommandResult {
+ pub(super) endpoint_id: ClientEndpointId,
+ pub(super) generation: u64,
pub(super) boot_id: String,
pub(super) request_id: String,
pub(super) result: Result<ResponseResult, ClientShellEndpointError>,
}
#[derive(Default)]
-pub(super) struct EndpointCommands {
- queued: VecDeque<(String, Box<Request>)>,
+struct EndpointCommandLane {
+ queued: VecDeque<QueuedCommand>,
in_flight: Option<InFlightCommand>,
+ retired: VecDeque<(u64, String, String)>,
+}
+
+impl EndpointCommandLane {
+ fn retire(&mut self, request: (u64, String, String)) {
+ if self.retired.contains(&request) {
+ return;
+ }
+ if self.retired.len() == MAX_RETIRED_REQUESTS_PER_ENDPOINT {
+ self.retired.pop_front();
+ }
+ self.retired.push_back(request);
+ }
+
+ fn consume_retired(&mut self, request: &(u64, String, String), final_chunk: bool) -> bool {
+ let Some(index) = self.retired.iter().position(|retired| retired == request) else {
+ return false;
+ };
+ if final_chunk {
+ self.retired.remove(index);
+ }
+ true
+ }
+}
+
+#[derive(Default)]
+pub(super) struct EndpointCommands {
+ lanes: HashMap<ClientEndpointId, EndpointCommandLane>,
}
impl EndpointCommands {
- pub(super) fn enqueue(&mut self, boot_id: String, request: Box<Request>) {
- self.queued.push_back((boot_id, request));
+ pub(super) fn enqueue(
+ &mut self,
+ endpoint_id: ClientEndpointId,
+ generation: u64,
+ boot_id: String,
+ request: Box<Request>,
+ ) {
+ self.lanes
+ .entry(endpoint_id)
+ .or_default()
+ .queued
+ .push_back(QueuedCommand {
+ generation,
+ boot_id,
+ request,
+ });
}
- pub(super) fn send_next(&mut self, stream: &mut LocalStream) -> io::Result<()> {
- if self.in_flight.is_some() {
- return Ok(());
+ pub(super) fn send_next(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ endpoints: &mut EndpointRegistry,
+ ) -> Vec<String> {
+ let lane = self.lanes.entry(endpoint_id.clone()).or_default();
+ let mut cancelled = Vec::new();
+ if lane.in_flight.is_some() {
+ return cancelled;
}
- let Some((boot_id, request)) = self.queued.pop_front() else {
- return Ok(());
- };
- let request_id = request.id.clone();
- let request = serde_json::to_string(&request)
- .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
- super::write_to_server(
- stream,
- &ClientMessage::ClientShellEndpointRequest {
- boot_id: boot_id.clone(),
+ while let Some(queued) = lane.queued.pop_front() {
+ let request_id = queued.request.id.clone();
+ if !endpoints.accepts(endpoint_id, queued.generation) {
+ cancelled.push(request_id);
+ continue;
+ }
+ let request = match serde_json::to_string(&queued.request) {
+ Ok(request) => request,
+ Err(error) => {
+ tracing::warn!(%error, %request_id, "could not encode endpoint request");
+ cancelled.push(request_id);
+ continue;
+ }
+ };
+ let message = ClientMessage::ClientShellEndpointRequest {
+ boot_id: queued.boot_id.clone(),
request,
- },
- )?;
- self.in_flight = Some(InFlightCommand {
- boot_id,
- request_id,
- response: Vec::new(),
- sent_at: Instant::now(),
- timed_out: false,
- });
- Ok(())
+ };
+ if endpoints.send_to(endpoint_id, &message) != EndpointSendOutcome::Sent {
+ cancelled.push(request_id);
+ continue;
+ }
+ lane.in_flight = Some(InFlightCommand {
+ generation: queued.generation,
+ boot_id: queued.boot_id,
+ request_id,
+ response: Vec::new(),
+ sent_at: Instant::now(),
+ });
+ break;
+ }
+ cancelled
}
- pub(super) fn expire(&mut self, now: Instant) -> Option<EndpointCommandResult> {
- let command = self.in_flight.as_mut()?;
- if command.timed_out
- || now.saturating_duration_since(command.sent_at) < ENDPOINT_COMMAND_TIMEOUT
- {
- return None;
+ pub(super) fn accepts_response(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ response_generation: u64,
+ response_boot_id: &str,
+ response_request_id: &str,
+ ) -> bool {
+ self.lanes
+ .get(endpoint_id)
+ .and_then(|lane| lane.in_flight.as_ref())
+ .is_some_and(|command| {
+ command.generation == response_generation
+ && command.boot_id == response_boot_id
+ && command.request_id == response_request_id
+ })
+ }
+
+ /// Retire the complete source lane at source-off. The in-flight request is tombstoned for a
+ /// late endpoint-local response; every queued request is cancelled before it can run in a
+ /// later presentation epoch. Other endpoint lanes are deliberately untouched.
+ pub(super) fn retire_lane(&mut self, endpoint_id: &ClientEndpointId) -> Vec<String> {
+ let Some(lane) = self.lanes.get_mut(endpoint_id) else {
+ return Vec::new();
+ };
+ let mut request_ids = Vec::new();
+ if let Some(command) = lane.in_flight.take() {
+ lane.retire((
+ command.generation,
+ command.boot_id,
+ command.request_id.clone(),
+ ));
+ request_ids.push(command.request_id);
}
- command.timed_out = true;
- Some(EndpointCommandResult {
- boot_id: command.boot_id.clone(),
- request_id: command.request_id.clone(),
- result: Err(ClientShellEndpointError {
- code: Some("endpoint_timeout".into()),
- message: "this server did not respond to the action".into(),
- }),
- })
+ request_ids.extend(lane.queued.drain(..).map(|command| command.request.id));
+ request_ids
+ }
+
+ pub(super) fn expire(&mut self, now: Instant) -> Vec<EndpointCommandResult> {
+ self.lanes
+ .iter_mut()
+ .filter_map(|(endpoint_id, lane)| {
+ let command = lane.in_flight.as_ref()?;
+ if now.saturating_duration_since(command.sent_at) < ENDPOINT_COMMAND_TIMEOUT {
+ return None;
+ }
+ let command = lane.in_flight.take().expect("checked in-flight command");
+ lane.retire((
+ command.generation,
+ command.boot_id.clone(),
+ command.request_id.clone(),
+ ));
+ Some(EndpointCommandResult {
+ endpoint_id: endpoint_id.clone(),
+ generation: command.generation,
+ boot_id: command.boot_id,
+ request_id: command.request_id,
+ result: Err(ClientShellEndpointError {
+ code: Some("endpoint_timeout".into()),
+ message: "this server did not respond to the action".into(),
+ }),
+ })
+ })
+ .collect()
}
pub(super) fn receive_chunk(
&mut self,
+ endpoint_id: &ClientEndpointId,
+ response_generation: u64,
response_boot_id: &str,
response_request_id: &str,
final_chunk: bool,
data: Vec<u8>,
) -> io::Result<Option<EndpointCommandResult>> {
- let Some(in_flight) = self.in_flight.as_mut() else {
- return Err(io::Error::new(
- io::ErrorKind::InvalidData,
- "endpoint response arrived without an in-flight command",
- ));
+ let Some(lane) = self.lanes.get_mut(endpoint_id) else {
+ return Ok(None);
};
- if response_boot_id != in_flight.boot_id || response_request_id != in_flight.request_id {
- return Err(io::Error::new(
- io::ErrorKind::InvalidData,
- "endpoint response correlation did not match the in-flight command",
- ));
+ let retired = (
+ response_generation,
+ response_boot_id.to_owned(),
+ response_request_id.to_owned(),
+ );
+ if lane.consume_retired(&retired, final_chunk) {
+ return Ok(None);
+ }
+ let Some(in_flight) = lane.in_flight.as_mut() else {
+ return Ok(None);
+ };
+ if response_generation != in_flight.generation
+ || response_boot_id != in_flight.boot_id
+ || response_request_id != in_flight.request_id
+ {
+ return Ok(None);
}
in_flight.response.extend(data);
if !final_chunk {
return Ok(None);
}
- let in_flight = self.in_flight.take().expect("checked in-flight command");
- let response = String::from_utf8(in_flight.response)
- .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
- let result = parse_response(&in_flight.request_id, &response);
+ let in_flight = lane.in_flight.take().expect("checked in-flight command");
+ let result = parse_response(&in_flight.request_id, &in_flight.response);
Ok(Some(EndpointCommandResult {
+ endpoint_id: endpoint_id.clone(),
+ generation: in_flight.generation,
boot_id: in_flight.boot_id,
request_id: in_flight.request_id,
result,
}))
}
+
+ /// Disconnecting an endpoint also cancels its shell-pending requests. Connection generation
+ /// rejection handles any late wire response after the lane itself is removed.
+ pub(super) fn disconnect(&mut self, endpoint_id: &ClientEndpointId) -> Vec<String> {
+ let Some(lane) = self.lanes.remove(endpoint_id) else {
+ return Vec::new();
+ };
+ let mut request_ids = lane
+ .queued
+ .into_iter()
+ .map(|command| command.request.id)
+ .collect::<Vec<_>>();
+ if let Some(command) = lane.in_flight {
+ request_ids.push(command.request_id);
+ }
+ request_ids
+ }
}
-fn parse_response(
+pub(super) fn parse_response(
expected_id: &str,
- response: &str,
+ response: &[u8],
) -> Result<ResponseResult, ClientShellEndpointError> {
- let value = serde_json::from_str(response).map_err(|error| ClientShellEndpointError {
+ let value = serde_json::from_slice(response).map_err(|error| ClientShellEndpointError {
code: None,
message: format!("invalid endpoint response: {error}"),
})?;
@@ -159,19 +301,35 @@ mod tests {
use super::*;
use crate::api::schema::{ResponseResult, SuccessResponse};
+ fn endpoint() -> ClientEndpointId {
+ ClientEndpointId::Local
+ }
+
fn commands_with_in_flight() -> EndpointCommands {
EndpointCommands {
- in_flight: Some(InFlightCommand {
- boot_id: "boot-a".into(),
- request_id: "request-a".into(),
- response: Vec::new(),
- sent_at: Instant::now(),
- timed_out: false,
- }),
- ..EndpointCommands::default()
+ lanes: HashMap::from([(
+ endpoint(),
+ EndpointCommandLane {
+ in_flight: Some(InFlightCommand {
+ generation: 1,
+ boot_id: "boot-a".into(),
+ request_id: "request-a".into(),
+ response: Vec::new(),
+ sent_at: Instant::now(),
+ }),
+ ..EndpointCommandLane::default()
+ },
+ )]),
}
}
+ fn has_in_flight(commands: &EndpointCommands) -> bool {
+ commands
+ .lanes
+ .get(&endpoint())
+ .is_some_and(|lane| lane.in_flight.is_some())
+ }
+
#[test]
fn chunked_response_completion_is_correlated_and_clears_the_lane() {
let mut commands = commands_with_in_flight();
@@ -184,6 +342,8 @@ mod tests {
assert!(commands
.receive_chunk(
+ &endpoint(),
+ 1,
"boot-a",
"request-a",
false,
@@ -193,6 +353,8 @@ mod tests {
.is_none());
let completed = commands
.receive_chunk(
+ &endpoint(),
+ 1,
"boot-a",
"request-a",
true,
@@ -201,10 +363,12 @@ mod tests {
.unwrap()
.unwrap();
+ assert_eq!(completed.endpoint_id, endpoint());
+ assert_eq!(completed.generation, 1);
assert_eq!(completed.boot_id, "boot-a");
assert_eq!(completed.request_id, "request-a");
assert!(matches!(completed.result, Ok(ResponseResult::Ok {})));
- assert!(commands.in_flight.is_none());
+ assert!(!has_in_flight(&commands));
}
#[test]
@@ -224,6 +388,8 @@ mod tests {
for (index, chunk) in response.chunks(128 * 1024).enumerate() {
completed = commands
.receive_chunk(
+ &endpoint(),
+ 1,
"boot-a",
"request-a",
index + 1 == chunk_count,
@@ -243,8 +409,10 @@ mod tests {
let mut commands = commands_with_in_flight();
let expired = commands
.expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT)
+ .pop()
.expect("expired endpoint command");
+ assert_eq!(expired.endpoint_id, endpoint());
assert_eq!(expired.boot_id, "boot-a");
assert_eq!(expired.request_id, "request-a");
assert!(matches!(
@@ -254,30 +422,184 @@ mod tests {
..
}) if code == "endpoint_timeout"
));
- assert!(commands.in_flight.is_some());
+ assert!(!has_in_flight(&commands));
assert!(commands
.expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT)
+ .is_empty());
+ let late_response = serde_json::to_vec(&SuccessResponse {
+ id: "request-a".into(),
+ result: ResponseResult::Ok {},
+ })
+ .unwrap();
+ assert!(commands
+ .receive_chunk(&endpoint(), 1, "boot-a", "request-a", true, late_response)
+ .expect("late retired response is ignored")
.is_none());
+ assert!(!has_in_flight(&commands));
+ }
+
+ #[test]
+ fn endpoint_lanes_complete_independently() {
+ let remote = ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
+ let mut commands = commands_with_in_flight();
+ commands.lanes.insert(
+ remote.clone(),
+ EndpointCommandLane {
+ in_flight: Some(InFlightCommand {
+ generation: 2,
+ boot_id: "boot-b".into(),
+ request_id: "request-b".into(),
+ response: Vec::new(),
+ sent_at: Instant::now(),
+ }),
+ ..EndpointCommandLane::default()
+ },
+ );
+ let response = serde_json::to_vec(&SuccessResponse {
+ id: "request-b".into(),
+ result: ResponseResult::Ok {},
+ })
+ .unwrap();
+
+ let completed = commands
+ .receive_chunk(&remote, 2, "boot-b", "request-b", true, response)
+ .unwrap()
+ .expect("remote response");
+
+ assert_eq!(completed.endpoint_id, remote);
+ assert!(has_in_flight(&commands));
+ assert!(commands
+ .lanes
+ .get(&completed.endpoint_id)
+ .is_some_and(|lane| lane.in_flight.is_none()));
+ }
+
+ #[test]
+ fn retiring_complete_source_lane_cancels_queued_ids_and_keeps_other_lanes() {
+ let remote = ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
+ let mut commands = commands_with_in_flight();
+ commands
+ .lanes
+ .get_mut(&endpoint())
+ .unwrap()
+ .queued
+ .push_back(QueuedCommand {
+ generation: 1,
+ boot_id: "boot-a".into(),
+ request: Box::new(Request {
+ id: "queued-source".into(),
+ method: crate::api::schema::Method::WorkspaceList(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ }),
+ });
+ commands.lanes.insert(
+ remote.clone(),
+ EndpointCommandLane {
+ queued: VecDeque::from([QueuedCommand {
+ generation: 2,
+ boot_id: "boot-b".into(),
+ request: Box::new(Request {
+ id: "request-b".into(),
+ method: crate::api::schema::Method::WorkspaceList(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ }),
+ }]),
+ ..EndpointCommandLane::default()
+ },
+ );
+
+ assert_eq!(
+ commands.retire_lane(&endpoint()),
+ vec!["request-a", "queued-source"]
+ );
+ assert!(!has_in_flight(&commands));
+ assert!(commands
+ .lanes
+ .get(&endpoint())
+ .is_some_and(|lane| lane.queued.is_empty()));
+ assert!(commands
+ .lanes
+ .get(&remote)
+ .is_some_and(|lane| !lane.queued.is_empty()));
+
let late_response = serde_json::to_vec(&SuccessResponse {
id: "request-a".into(),
result: ResponseResult::Ok {},
})
.unwrap();
assert!(commands
- .receive_chunk("boot-a", "request-a", true, late_response)
- .expect("late response releases the synchronized lane")
- .is_some());
- assert!(commands.in_flight.is_none());
+ .receive_chunk(&endpoint(), 1, "boot-a", "request-a", true, late_response)
+ .unwrap()
+ .is_none());
}
#[test]
- fn response_from_another_boot_is_rejected() {
+ fn disconnect_returns_every_request_that_must_be_discarded() {
let mut commands = commands_with_in_flight();
+ commands
+ .lanes
+ .get_mut(&endpoint())
+ .unwrap()
+ .queued
+ .push_back(QueuedCommand {
+ generation: 1,
+ boot_id: "boot-a".into(),
+ request: Box::new(Request {
+ id: "queued-a".into(),
+ method: crate::api::schema::Method::WorkspaceList(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ }),
+ });
+ assert_eq!(
+ commands.disconnect(&endpoint()),
+ vec!["queued-a", "request-a"]
+ );
+ assert!(!commands.lanes.contains_key(&endpoint()));
+ }
- let Err(error) = commands.receive_chunk("boot-b", "request-a", true, b"{}".to_vec()) else {
- panic!("mismatched boot should fail");
- };
+ #[test]
+ fn retired_request_tombstones_are_bounded() {
+ let mut lane = EndpointCommandLane::default();
+ for serial in 0..MAX_RETIRED_REQUESTS_PER_ENDPOINT + 10 {
+ lane.retire((1, "boot".into(), format!("request-{serial}")));
+ }
+ assert_eq!(lane.retired.len(), MAX_RETIRED_REQUESTS_PER_ENDPOINT);
+ assert!(!lane
+ .retired
+ .contains(&(1, "boot".into(), "request-0".into())));
+ assert!(lane.retired.contains(&(
+ 1,
+ "boot".into(),
+ format!("request-{}", MAX_RETIRED_REQUESTS_PER_ENDPOINT + 9)
+ )));
+ }
+
+ #[test]
+ fn stale_or_unknown_responses_do_not_damage_the_live_lane() {
+ let mut commands = commands_with_in_flight();
+ let unknown = ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
- assert_eq!(error.kind(), io::ErrorKind::InvalidData);
+ assert!(commands
+ .receive_chunk(&endpoint(), 2, "boot-a", "request-a", true, b"{}".to_vec())
+ .unwrap()
+ .is_none());
+ assert!(commands
+ .receive_chunk(&endpoint(), 1, "boot-b", "request-a", true, b"{}".to_vec())
+ .unwrap()
+ .is_none());
+ assert!(commands
+ .receive_chunk(&unknown, 1, "boot-a", "request-a", true, b"{}".to_vec())
+ .unwrap()
+ .is_none());
+ assert!(has_in_flight(&commands));
}
}
diff --git a/src/client/events.rs b/src/client/events.rs
new file mode 100644
index 00000000..43f7b8ad
--- /dev/null
+++ b/src/client/events.rs
@@ -0,0 +1,34 @@
+use super::*;
+
+/// Internal events for the client event loop.
+pub(super) enum ClientLoopEvent {
+ #[cfg(unix)]
+ StdinInput(Vec<u8>),
+ #[cfg(unix)]
+ PixelMouse(Vec<u8>, crate::input::mouse::HostGeometry),
+ #[cfg(unix)]
+ DirectGraphicsResponse(direct_graphics::Response),
+ #[cfg(windows)]
+ StdinEvents(Vec<crate::protocol::ClientInputEvent>),
+ Resize(u16, u16, u32, u32, bool),
+ TerminalUnavailable(io::Error),
+ ServerMessage {
+ endpoint_id: endpoint::ClientEndpointId,
+ generation: u64,
+ message: Box<ServerMessage>,
+ },
+ ServerDisconnected {
+ endpoint_id: endpoint::ClientEndpointId,
+ generation: u64,
+ },
+ EndpointSupervisor(endpoint::EndpointSupervisorEvent),
+ EndpointCatalog(Result<Vec<endpoint::SavedSshEndpoint>, String>),
+ ActivateEndpoint {
+ endpoint_id: endpoint::ClientEndpointId,
+ target: Option<shell::ClientEndpointFocusTarget>,
+ /// A superseded handoff deliberately starts a fresh target-on epoch even when source and
+ /// latest target have the same identity after restoration.
+ force: bool,
+ },
+ Timer,
+}
diff --git a/src/client/handshake.rs b/src/client/handshake.rs
index 84dd6643..937f92ac 100644
--- a/src/client/handshake.rs
+++ b/src/client/handshake.rs
@@ -121,6 +121,29 @@ fn set_handshake_recv_timeout(
pub(super) struct HandshakeResult {
pub(super) encoding: RenderEncoding,
pub(super) endpoint_methods: Option<Vec<String>>,
+ pub(super) endpoint_capabilities: Option<Vec<String>>,
+}
+
+pub(crate) fn probe_endpoint_negotiation(
+ stream: &mut LocalStream,
+) -> io::Result<super::endpoint::EndpointNegotiation> {
+ let handshake = do_handshake(
+ stream,
+ 80,
+ 24,
+ 0,
+ 0,
+ false,
+ Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }),
+ false,
+ false,
+ false,
+ )
+ .map_err(io::Error::other)?;
+ Ok(super::endpoint::EndpointNegotiation::new(
+ handshake.endpoint_methods.unwrap_or_default(),
+ handshake.endpoint_capabilities.unwrap_or_default(),
+ ))
}
/// Performs the client→server handshake.
@@ -138,6 +161,7 @@ pub(super) fn do_handshake(
shell_surface_size: Option<crate::protocol::ClientSurfaceSize>,
endpoint_keybindings: bool,
mouse_capture: bool,
+ surface_active: bool,
) -> Result<HandshakeResult, ClientError> {
stream
.set_nonblocking(false)
@@ -157,6 +181,7 @@ pub(super) fn do_handshake(
&& direct_graphics_profile_allowed(),
endpoint_keybindings,
mouse_capture,
+ surface_active,
snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()],
surface_codecs: vec![SURFACE_CODEC_V1.into()],
input_codecs: vec![INPUT_CODEC_V1.into()],
@@ -181,9 +206,14 @@ pub(super) fn do_handshake(
protocol::write_message(stream, &hello)
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
+ let read_timeout = if endpoint_shell && !surface_active {
+ REMOTE_HANDSHAKE_READ_TIMEOUT
+ } else {
+ handshake_read_timeout()
+ };
set_handshake_recv_timeout(
stream,
- Some(handshake_read_timeout()),
+ Some(read_timeout),
"client handshake read timeout unavailable",
)?;
let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?;
@@ -238,6 +268,7 @@ pub(super) fn do_handshake(
return Ok(HandshakeResult {
encoding: RenderEncoding::SemanticFrame,
endpoint_methods: Some(welcome.methods),
+ endpoint_capabilities: Some(welcome.capabilities),
});
}
@@ -254,6 +285,7 @@ pub(super) fn do_handshake(
Ok(HandshakeResult {
encoding,
endpoint_methods: None,
+ endpoint_capabilities: None,
})
}
_ => Err(ClientError::Protocol(protocol::FramingError::Io(
diff --git a/src/client/loop_config.rs b/src/client/loop_config.rs
new file mode 100644
index 00000000..53ef9f38
--- /dev/null
+++ b/src/client/loop_config.rs
@@ -0,0 +1,16 @@
+use super::*;
+
+pub(super) struct ClientLoopConfig {
+ pub(super) sound_config: crate::config::SoundConfig,
+ pub(super) mouse_scroll_lines: usize,
+ pub(super) redraw_on_focus_gained: bool,
+ pub(super) host_cursor: crate::config::HostCursorModeConfig,
+ pub(super) kitty_graphics_enabled: bool,
+ pub(super) pixel_geometry_enabled: bool,
+ pub(super) pixel_geometry_fallback: bool,
+ pub(super) mouse_capture_active: bool,
+ pub(super) endpoint_keybindings: bool,
+ pub(super) remote_image_paste_key:
+ Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
+ pub(super) shell_config: Option<shell::ClientShellConfig>,
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index ea789431..b815a057 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -13,23 +13,46 @@
//! - Displays sound/toast notifications forwarded from server
mod attach;
+mod catalog_reload;
+mod clipboard_forwarding;
mod clipboard_images;
+mod config_reload;
#[cfg(unix)]
mod direct_graphics;
+pub(crate) mod endpoint;
mod endpoint_commands;
mod errors;
+mod events;
mod frame_output;
mod handshake;
mod input;
+mod loop_config;
mod notifications;
mod shell;
+mod shell_runtime;
+mod startup;
+mod state;
mod terminal_geometry;
mod terminal_sessions;
mod terminal_setup;
mod timer;
+mod transport;
+
+#[cfg(test)]
+use clipboard_forwarding::decode_clipboard_payload;
+use clipboard_forwarding::forward_clipboard;
+#[cfg(test)]
+use config_reload::reload_local_client_config;
+use config_reload::{apply_reload, init_logging};
+use events::ClientLoopEvent;
+use loop_config::ClientLoopConfig;
+use shell_runtime::*;
+use state::ClientState;
+use transport::*;
#[cfg(test)]
pub(crate) use shell::{ClientShellConfig, ClientShellState};
+pub use startup::{run_client, run_terminal_attach};
pub use terminal_sessions::{run_terminal_session_control, run_terminal_session_observe};
#[cfg(not(windows))]
@@ -65,7 +88,9 @@ use attach::direct_attach_pixel_mouse;
use attach::AttachEscapeState;
#[cfg(unix)]
use attach::{write_attach_semantic_action, AttachInputAction};
-use clipboard_images::{client_remote_image_paste_key, write_remote_image_to_server};
+use clipboard_images::{
+ client_remote_image_paste_key, endpoint_accepts_local_images, write_remote_image_to_server,
+};
#[cfg(windows)]
use clipboard_images::{read_image_file_from_client_events, should_bridge_clipboard_image_events};
#[cfg(unix)]
@@ -77,6 +102,7 @@ use frame_output::{
contains_kitty_graphics_bytes, record_received_kitty_graphics,
write_encoded_frame_with_graphics,
};
+pub(crate) use handshake::probe_endpoint_negotiation;
use handshake::{client_shell_keybinding_source, do_handshake, is_remote_client_process};
#[cfg(test)]
use handshake::{
@@ -103,243 +129,11 @@ use tracing::{debug, info, warn};
use crate::ipc::LocalStream;
use crate::protocol::render_ansi;
-use crate::protocol::{
- self, ClientMessage, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE,
- MAX_GRAPHICS_FRAME_SIZE,
-};
+use crate::protocol::{self, ClientMessage, FrameData, ServerMessage, MAX_GRAPHICS_FRAME_SIZE};
#[cfg(test)]
use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind};
use crate::server::socket_paths::client_socket_path;
-// ---------------------------------------------------------------------------
-// Client state
-// ---------------------------------------------------------------------------
-
-struct ClientLoopConfig {
- sound_config: crate::config::SoundConfig,
- mouse_scroll_lines: usize,
- redraw_on_focus_gained: bool,
- host_cursor: crate::config::HostCursorModeConfig,
- kitty_graphics_enabled: bool,
- pixel_geometry_enabled: bool,
- pixel_geometry_fallback: bool,
- mouse_capture_active: bool,
- remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
- shell_config: Option<shell::ClientShellConfig>,
-}
-
-/// State tracking for the thin client.
-struct ClientState {
- /// Stateful semantic-frame encoder used when the server sends FrameData.
- blit_encoder: render_ansi::BlitEncoder,
- /// Whether host mouse capture is currently active.
- mouse_capture_active: bool,
- /// Last mouse-capture demand published by the endpoint.
- endpoint_mouse_capture_requested: bool,
- /// Last exact-pixel mouse demand published by the endpoint.
- endpoint_sgr_pixels_requested: bool,
- /// Client-local direct-attach preference, combined with child mouse demand.
- direct_mouse_capture_preference: bool,
- /// Client-owned shell mouse-capture preference.
- shell_mouse_capture_preference: bool,
- /// Host keyboard protocol state currently owned for a direct terminal attach.
- direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState,
- /// Focused endpoint pane/popup demand for Kitty report-all input.
- pane_keyboard_report_all: bool,
- /// Whether the client-owned shell currently enabled host report-all input.
- keyboard_report_all_active: bool,
- /// The terminal size we reported to the server in our last handshake/Resize.
- reported_size: (u16, u16),
- /// Last exact host cell size used by client-rendered surfaces.
- reported_cell_size: (u32, u32),
- /// Client-local sound playback config, refreshed on server request.
- sound_config: crate::config::SoundConfig,
- /// Whether this client may write Kitty graphics bytes to its host terminal.
- kitty_graphics_enabled: bool,
- /// Whether resize reports inspect host pixel geometry.
- pixel_geometry_enabled: bool,
- /// Whether the latest host pixel geometry is exact enough for pixel mouse input.
- pixel_geometry_exact: bool,
- /// One bounded matcher, inactive unless a direct transmission is armed.
- #[cfg(unix)]
- direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>,
- /// One server-retired direct transfer to suppress if it was still queued.
- #[cfg(unix)]
- retired_direct_graphics: Option<(u64, u32)>,
- /// ClientShell assets waiting for the host terminal's direct-upload response.
- #[cfg(unix)]
- pending_surface_graphics: HashMap<u64, crate::protocol::SurfaceGraphicsAssetKey>,
- /// Direct attach prefix escape state. None for ClientShell connections.
- attach_escape: Option<AttachEscapeState>,
- /// Rows scrolled for one direct-attach wheel notch.
- #[cfg(unix)]
- mouse_scroll_lines: usize,
- /// Local-client shortcut that sends a clipboard image to a remote Herdr session.
- remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
- /// Whether outer focus gain should force a full host-terminal redraw.
- redraw_on_focus_gained: bool,
- /// Whether the next semantic frame must repaint every cell without clearing the surface.
- repaint_pending: bool,
- /// Whether this client draws the cursor into frame cells instead of using the host cursor.
- draw_host_cursor: bool,
- /// Browser opener processes launched by client-owned link activation.
- detached_process_children: Vec<std::process::Child>,
- /// Experimental client-owned shell state.
- shell: Option<shell::ClientShellState>,
-}
-
-impl Drop for ClientState {
- fn drop(&mut self) {
- if self.attach_escape.is_some() {
- let _ = crate::terminal_modes::set_direct_host_keyboard_protocol(
- &mut io::stdout(),
- &mut self.direct_keyboard_protocol,
- 0,
- 0,
- );
- }
- }
-}
-
-impl ClientState {
- fn request_repaint(&mut self) {
- self.repaint_pending = true;
- }
-
- fn present_graphics(&mut self, graphics: &[u8]) {
- if graphics.is_empty() || !self.kitty_graphics_enabled {
- return;
- }
- let mut stdout = io::stdout();
- let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics);
- let _ = stdout.flush();
- }
-
- fn present_surface_patch(
- &mut self,
- patch: shell::ClientComposedSurfacePatch,
- ) -> io::Result<bool> {
- if self.repaint_pending {
- crate::render_prof::event("client_surface_patch.fallback.repaint");
- return Ok(false);
- }
- let rows = if self.draw_host_cursor {
- let Some(rows) = self
- .blit_encoder
- .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref())
- else {
- crate::render_prof::event("client_surface_patch.fallback.drawn_cursor");
- return Ok(false);
- };
- rows
- } else {
- patch.rows
- };
- let encode_started = crate::render_prof::timer();
- let Some(encoded) =
- self.blit_encoder
- .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor)
- else {
- crate::render_prof::event("client_surface_patch.fallback.encode");
- return Ok(false);
- };
- crate::render_prof::duration_since("client_surface_patch.encode", encode_started);
- let write_started = crate::render_prof::timer();
- let mut stdout = io::stdout();
- stdout.write_all(&encoded.bytes)?;
- stdout.flush()?;
- crate::render_prof::duration_since("client_surface_patch.write", write_started);
- let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded);
- crate::render_prof::event(if committed {
- "client_surface_patch.success"
- } else {
- "client_surface_patch.fallback.commit"
- });
- Ok(committed)
- }
-
- fn present_frame(&mut self, frame_data: FrameData) {
- let frame_data = if self.draw_host_cursor {
- render_ansi::frame_with_drawn_cursor(frame_data)
- } else {
- frame_data
- };
- let encoded = if self.draw_host_cursor {
- self.blit_encoder
- .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending)
- } else {
- self.blit_encoder.encode(&frame_data, self.repaint_pending)
- };
- let mut stdout = io::stdout();
- let graphics = if self.kitty_graphics_enabled {
- frame_data.graphics.as_slice()
- } else {
- &[]
- };
- let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics);
- let _ = stdout.flush();
- self.blit_encoder.commit(frame_data, encoded);
- self.repaint_pending = false;
- }
-}
-
-// ---------------------------------------------------------------------------
-// Client event loop
-// ---------------------------------------------------------------------------
-
-/// Internal events for the client event loop.
-enum ClientLoopEvent {
- /// Raw input bytes from stdin.
- #[cfg(unix)]
- StdinInput(Vec<u8>),
- /// One confirmed SGR pixel report with geometry captured by the reader.
- #[cfg(unix)]
- PixelMouse(Vec<u8>, crate::input::mouse::HostGeometry),
- #[cfg(unix)]
- DirectGraphicsResponse(direct_graphics::Response),
- /// Structured input events from platforms without Unix-style stdin bytes.
- #[cfg(windows)]
- StdinEvents(Vec<crate::protocol::ClientInputEvent>),
- /// Terminal resize detected, including current exact-pixel eligibility.
- Resize(u16, u16, u32, u32, bool),
- /// The client's host terminal can no longer report a valid grid.
- TerminalUnavailable(io::Error),
- /// Server message received.
- ServerMessage(Box<ServerMessage>),
- /// Server reader thread exited (connection lost).
- ServerDisconnected,
- /// Timer tick.
- Timer,
-}
-
-/// Runs the thin client: connects to the server, performs the handshake,
-/// and enters the main event loop.
-///
-/// This is the entry point called from `main.rs` when running in client mode.
-pub fn run_client() -> io::Result<()> {
- run_client_with_mode(None, None, "connecting to server")
-}
-
-/// Runs a direct terminal attach client.
-#[cfg(unix)]
-pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> {
- run_client_with_mode(
- Some((terminal_id, takeover)),
- Some(AttachEscapeState::default()),
- "attaching to terminal",
- )
-}
-
-/// Direct terminal attach is Unix raw-byte input only until Windows gets a semantic attach path.
-#[cfg(windows)]
-pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> {
- debug_assert!(!crate::platform::capabilities().direct_terminal_attach);
- Err(io::Error::new(
- io::ErrorKind::Unsupported,
- "direct terminal attach is not supported on Windows yet",
- ))
-}
-
fn run_client_with_mode(
attach_request: Option<(String, bool)>,
attach_escape: Option<AttachEscapeState>,
@@ -373,6 +167,9 @@ fn run_client_with_mode(
let kitty_graphics_enabled =
loaded_config.config.kitty_graphics_enabled() && client_rendered_shell;
let pixel_geometry_enabled = kitty_graphics_enabled || attach_escape.is_some();
+ let endpoint_keybindings = shell_config
+ .as_ref()
+ .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings);
let loop_config = ClientLoopConfig {
sound_config: loaded_config.config.ui.sound,
mouse_scroll_lines,
@@ -382,6 +179,7 @@ fn run_client_with_mode(
pixel_geometry_enabled,
pixel_geometry_fallback: kitty_graphics_enabled,
mouse_capture_active: mouse_capture,
+ endpoint_keybindings,
remote_image_paste_key,
shell_config,
};
@@ -389,14 +187,26 @@ fn run_client_with_mode(
crate::logging::startup("client");
info!(path = %socket_path.display(), "{log_message}");
- // Try to connect to the server.
- let mut stream = match crate::ipc::connect_local_stream(&socket_path) {
- Ok(s) => s,
- Err(err) => {
- // Server unreachable — show clear error and exit.
- let client_err = ClientError::ConnectionFailed(err);
- eprintln!("herdr: {client_err}");
- std::process::exit(1);
+ let endpoint_catalog = if client_rendered_shell && !is_remote_client_process() {
+ endpoint::EndpointCatalog::load().unwrap_or_else(|error| {
+ warn!(%error, "saved SSH endpoint catalog is unavailable");
+ endpoint::EndpointCatalog::default()
+ })
+ } else {
+ endpoint::EndpointCatalog::default()
+ };
+ let federated = endpoint_catalog.has_enabled_ssh();
+
+ let initial_stream = match crate::ipc::connect_local_stream(&socket_path) {
+ Ok(stream) => Some(stream),
+ Err(error) if federated => {
+ warn!(%error, "Local is unavailable; keeping saved machines available");
+ None
+ }
+ Err(error) => {
+ return Err(io::Error::other(
+ ClientError::ConnectionFailed(error).to_string(),
+ ));
}
};
@@ -408,43 +218,56 @@ fn run_client_with_mode(
.shell_config
.as_ref()
.map(|shell| shell.initial_surface_size(cols, rows));
- let endpoint_keybindings = loop_config
- .shell_config
- .as_ref()
- .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings);
-
- // Perform handshake while the stream is still in blocking mode.
- let handshake = match do_handshake(
- &mut stream,
- cols,
- rows,
- cell_width_px,
- cell_height_px,
- exact_cell_size,
- shell_surface_size,
- endpoint_keybindings,
- loop_config.mouse_capture_active,
- ) {
- Ok(encoding) => encoding,
- Err(err) => {
- eprintln!("herdr: {err}");
- std::process::exit(1);
+ // Healthy Local attaches directly; only an actual failure enters background recovery.
+ let initial = initial_stream
+ .map(|mut stream| {
+ let handshake = do_handshake(
+ &mut stream,
+ cols,
+ rows,
+ cell_width_px,
+ cell_height_px,
+ exact_cell_size,
+ shell_surface_size,
+ endpoint_keybindings,
+ loop_config.mouse_capture_active,
+ true,
+ )
+ .map_err(|error| io::Error::other(error.to_string()))?;
+ if federated
+ && !endpoint::EndpointNegotiation::new(
+ handshake.endpoint_methods.clone().unwrap_or_default(),
+ handshake.endpoint_capabilities.clone().unwrap_or_default(),
+ )
+ .supports_surface_interest()
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "Local needs a server update before it can participate in multi-machine viewing",
+ ));
+ }
+ if let Some((terminal_id, takeover)) = attach_request {
+ write_to_server(
+ &mut stream,
+ &ClientMessage::AttachTerminal {
+ terminal_id,
+ takeover,
+ },
+ )?;
+ }
+ Ok((stream, handshake))
+ })
+ .transpose();
+ let initial = match initial {
+ Ok(initial) => initial,
+ Err(error) if federated => {
+ warn!(%error, "Local handshake failed; keeping saved machines available");
+ None
}
+ Err(error) => return Err(error),
};
- if let Some((terminal_id, takeover)) = attach_request {
- let attach = ClientMessage::AttachTerminal {
- terminal_id,
- takeover,
- };
- if let Err(err) = write_to_server(&mut stream, &attach) {
- eprintln!("herdr: failed to request terminal attach: {err}");
- std::process::exit(1);
- }
- }
-
- // Now set up the terminal. This must happen AFTER the handshake succeeds,
- // so we don't leave the terminal in raw mode if the server rejects us.
+ // The federated shell can show connection notices without any server snapshot.
let direct_attach = attach_escape.is_some();
let terminal_guard = if direct_attach {
setup_direct_attach_terminal(mouse_capture)
@@ -483,7 +306,8 @@ fn run_client_with_mode(
let result = rt.block_on(async {
run_client_loop(
- stream,
+ initial,
+ endpoint_catalog,
cols,
rows,
cell_width_px,
@@ -491,8 +315,6 @@ fn run_client_with_mode(
exact_cell_size,
should_quit,
loop_config,
- handshake.encoding,
- handshake.endpoint_methods,
attach_escape,
)
.await
@@ -526,187 +348,6 @@ fn run_client_with_mode(
Ok(())
}
-fn dispatch_client_shell_actions(
- actions: Vec<shell::ClientShellAction>,
- endpoint_commands: &mut endpoint_commands::EndpointCommands,
- write_stream: &mut LocalStream,
- detached_process_children: &mut Vec<std::process::Child>,
-) -> Result<Vec<crossterm::event::MouseEvent>, ClientError> {
- let mut replay_mouse = Vec::new();
- for action in actions {
- match action {
- shell::ClientShellAction::Endpoint { boot_id, request } => {
- endpoint_commands.enqueue(boot_id, request);
- }
- shell::ClientShellAction::ClipboardWrite(bytes) => {
- crate::selection::write_osc52_bytes(&bytes);
- }
- shell::ClientShellAction::Request(request) => {
- write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?;
- }
- shell::ClientShellAction::OpenSafeWebUrl(url) => {
- if crate::app::actions::safe_web_url(&url).is_some() {
- match crate::platform::open_url(&url) {
- Ok(Some(child)) => detached_process_children.push(child),
- Ok(None) => {}
- Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"),
- }
- }
- }
- shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events),
- shell::ClientShellAction::Keybind(action) => {
- debug!(
- ?action,
- "client shell action awaits its presentation family"
- );
- }
- }
- }
- endpoint_commands
- .send_next(write_stream)
- .map_err(ClientError::ConnectionLost)?;
- Ok(replay_mouse)
-}
-
-fn client_shell_resize_message(
- shell: &shell::ClientShellState,
- cols: u16,
- rows: u16,
- cell_width_px: u32,
- cell_height_px: u32,
- pixel_mouse: bool,
-) -> ClientMessage {
- ClientMessage::ClientShellResize {
- cell_width_px,
- cell_height_px,
- surface_size: shell.surface_size(cols, rows),
- pixel_mouse,
- }
-}
-
-fn sync_client_shell_keyboard_report_all(state: &mut ClientState) -> Result<(), ClientError> {
- let Some(shell) = state.shell.as_ref() else {
- return Ok(());
- };
- let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested();
- if desired == state.keyboard_report_all_active {
- return Ok(());
- }
- crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)
- .map_err(ClientError::ConnectionFailed)?;
- state.keyboard_report_all_active = desired;
- Ok(())
-}
-
-fn apply_client_shell_input_source_changes(
- state: &mut ClientState,
- prefix_input_source: &mut impl crate::platform::PrefixInputSource,
-) {
- let changes = state
- .shell
- .as_mut()
- .map(shell::ClientShellState::take_input_source_changes)
- .unwrap_or_default();
- for active in changes {
- if active {
- prefix_input_source.switch_to_ascii();
- } else {
- prefix_input_source.restore();
- }
- }
-}
-
-fn install_client_shell_snapshot(
- state: &mut ClientState,
- snapshot: Box<crate::protocol::ClientShellSnapshot>,
- write_stream: &mut LocalStream,
- prefix_input_source: &mut impl crate::platform::PrefixInputSource,
-) -> Result<(), ClientError> {
- let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell {
- let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1);
- shell.set_snapshot(snapshot);
- let graphics_cleanup = shell.take_pending_graphics_cleanup();
- let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1);
- (
- shell.compose(state.reported_size.0, state.reported_size.1),
- (previous_size != next_size).then(|| {
- client_shell_resize_message(
- shell,
- state.reported_size.0,
- state.reported_size.1,
- state.reported_cell_size.0,
- state.reported_cell_size.1,
- state.pixel_geometry_exact,
- )
- }),
- graphics_cleanup,
- )
- } else {
- (None, None, Vec::new())
- };
- apply_client_shell_input_source_changes(state, prefix_input_source);
- state.present_graphics(&graphics_cleanup);
- if let Some(resize) = resize {
- write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?;
- }
- if let Some(frame) = composed {
- state.present_frame(frame);
- }
- Ok(())
-}
-
-fn finish_client_shell_input(
- state: &mut ClientState,
- outcome: shell::ClientShellInput,
- frame: Option<FrameData>,
- write_stream: &mut LocalStream,
- endpoint_commands: &mut endpoint_commands::EndpointCommands,
- prefix_input_source: &mut impl crate::platform::PrefixInputSource,
-) -> Result<bool, ClientError> {
- apply_client_shell_input_source_changes(state, prefix_input_source);
- if outcome.detach {
- let _ = write_to_server(write_stream, &ClientMessage::Detach);
- return Ok(true);
- }
- if outcome.resize {
- let shell = state.shell.as_ref().expect("shell mode remains active");
- let resize = client_shell_resize_message(
- shell,
- state.reported_size.0,
- state.reported_size.1,
- state.reported_cell_size.0,
- state.reported_cell_size.1,
- state.pixel_geometry_exact,
- );
- write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?;
- }
- #[cfg(not(windows))]
- if outcome.query_host_appearance {
- query_host_terminal_appearance();
- }
- if outcome.query_host_theme {
- query_host_terminal_theme();
- }
- sync_client_shell_keyboard_report_all(state)?;
- let replay = dispatch_client_shell_actions(
- outcome.actions,
- endpoint_commands,
- write_stream,
- &mut state.detached_process_children,
- )?;
- debug_assert!(
- replay.is_empty(),
- "mouse replay only follows endpoint results"
- );
- for request in outcome.requests {
- write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?;
- }
- if let Some(frame) = frame {
- state.present_frame(frame);
- }
- Ok(false)
-}
-
/// The main client event loop.
///
/// Uses a threaded architecture:
@@ -715,7 +356,8 @@ fn finish_client_shell_input(
/// - server reader thread → reads ServerMessages and sends to main loop
/// - main loop: coordinates input, output, and server communication
async fn run_client_loop(
- stream: LocalStream,
+ initial: Option<(LocalStream, handshake::HandshakeResult)>,
+ mut endpoint_catalog: endpoint::EndpointCatalog,
cols: u16,
rows: u16,
initial_cell_width_px: u32,
@@ -723,20 +365,20 @@ async fn run_client_loop(
initial_pixel_geometry_exact: bool,
should_quit: Arc<AtomicBool>,
config: ClientLoopConfig,
- negotiated_encoding: RenderEncoding,
- endpoint_methods: Option<Vec<String>>,
attach_escape: Option<AttachEscapeState>,
) -> Result<(), ClientError> {
#[cfg(windows)]
let _ = config.mouse_scroll_lines;
let draw_host_cursor = attach_escape.is_none() && should_draw_host_cursor(config.host_cursor);
let is_remote_client = is_remote_client_process();
+ let local_unavailable = initial.is_none();
let mut state = ClientState {
blit_encoder: render_ansi::BlitEncoder::new(),
mouse_capture_active: config.mouse_capture_active,
endpoint_mouse_capture_requested: false,
endpoint_sgr_pixels_requested: false,
+ host_theme_updates: Vec::new(),
direct_mouse_capture_preference: attach_escape.is_some() && config.mouse_capture_active,
shell_mouse_capture_preference: config.mouse_capture_active,
direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState::default(),
@@ -760,15 +402,28 @@ async fn run_client_loop(
remote_image_paste_key: config.remote_image_paste_key,
redraw_on_focus_gained: config.redraw_on_focus_gained,
repaint_pending: false,
+ presentation_frozen: false,
draw_host_cursor,
detached_process_children: Vec::new(),
shell: config.shell_config.map(shell::ClientShellState::new),
};
+ let mut federated = endpoint_catalog.has_enabled_ssh();
if let Some(shell) = state.shell.as_mut() {
shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px);
- shell.set_endpoint_methods(endpoint_methods);
+ shell.set_endpoint_catalog(&endpoint_catalog.ssh);
+ shell.set_endpoint_methods_for(
+ &endpoint::ClientEndpointId::Local,
+ initial
+ .as_ref()
+ .and_then(|(_, handshake)| handshake.endpoint_methods.clone()),
+ );
+ if local_unavailable {
+ shell.set_endpoint_status(
+ &endpoint::ClientEndpointId::Local,
+ endpoint::ClientEndpointStatus::Connecting,
+ );
+ }
}
- debug!(?negotiated_encoding, "client render encoding active");
let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active));
// Cell size reported by the host terminal, packed as width<<32 | height.
// Zero means the host has not reported one.
@@ -777,6 +432,8 @@ async fn run_client_loop(
// Channel for events from the resize and server reader threads.
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256);
+ let (supervisor_tx, mut supervisor_rx) =
+ tokio::sync::mpsc::channel::<endpoint::EndpointSupervisorEvent>(64);
// Keep Windows console draining independent of server-frame backpressure.
#[cfg(windows)]
let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256);
@@ -833,7 +490,6 @@ async fn run_client_loop(
let resize_quit = should_quit.clone();
let resize_tx = event_tx.clone();
let resize_cell_size = reported_cell_size.clone();
- let kitty_graphics_enabled = state.kitty_graphics_enabled;
let pixel_geometry_enabled = state.pixel_geometry_enabled;
let pixel_geometry_fallback = config.pixel_geometry_fallback;
std::thread::spawn(move || {
@@ -851,31 +507,59 @@ async fn run_client_loop(
);
});
- // Spawn the server reader thread (blocking reads from the socket).
- // Clone the stream's file descriptor so we can read from a blocking stream.
- let server_read_quit = should_quit.clone();
- let server_read_tx = event_tx.clone();
- let read_stream = stream.try_clone().map_err(ClientError::ConnectionFailed)?;
- std::thread::spawn(move || {
- let max_frame_size = if kitty_graphics_enabled {
+ let mut write_stream = if let Some((stream, handshake)) = initial {
+ let max_frame_size = if state.kitty_graphics_enabled {
MAX_GRAPHICS_FRAME_SIZE
} else {
- MAX_FRAME_SIZE
+ crate::protocol::MAX_FRAME_SIZE
};
- server_reader_thread(
- read_stream,
- server_read_tx,
- &server_read_quit,
+ let transport = start_endpoint_transport(
+ stream,
+ (),
+ &event_tx,
+ endpoint::ClientEndpointId::Local,
+ 1,
max_frame_size,
+ )?;
+ let negotiation = endpoint::EndpointNegotiation::new(
+ handshake.endpoint_methods.unwrap_or_default(),
+ handshake.endpoint_capabilities.unwrap_or_default(),
);
- });
-
- // Use the original stream for writing (blocking is fine since we write
- // from the async loop).
- let mut write_stream = stream;
- write_stream
- .set_nonblocking(false)
- .map_err(ClientError::ConnectionFailed)?;
+ let mut registry = endpoint::EndpointRegistry::new(transport, 1, negotiation);
+ if state.shell.is_some() {
+ registry.send(&ClientMessage::ClientShellFocus { focused: true });
+ }
+ registry
+ } else {
+ endpoint::EndpointRegistry::empty()
+ };
+ let mut supervisors =
+ endpoint::EndpointSupervisors::new(&endpoint_catalog.ssh, std::time::Instant::now());
+ if federated {
+ supervisors.add_local(
+ client_socket_path(),
+ write_stream
+ .connection(&endpoint::ClientEndpointId::Local)
+ .map(|connection| connection.generation),
+ std::time::Instant::now(),
+ );
+ }
+ if local_unavailable {
+ if let Some(frame) = state
+ .shell
+ .as_mut()
+ .and_then(|shell| shell.compose(cols, rows))
+ {
+ state.present_frame(frame);
+ }
+ }
+ let mut next_surface_serial = 1_u64;
+ let mut pending_activation: Option<endpoint::PendingEndpointActivation> = None;
+ let mut scheduled_activation = None;
+ let mut pending_catalog: Option<Result<Vec<endpoint::SavedSshEndpoint>, String>> = None;
+ if state.shell.is_some() && !is_remote_client && state.attach_escape.is_none() {
+ catalog_reload::watch_profiles(event_tx.clone(), should_quit.clone());
+ }
// This (foreground) client owns the prefix ASCII input-source switch
// (implemented on macOS and Windows; a no-op on other platforms).
@@ -886,6 +570,111 @@ async fn run_client_loop(
#[cfg(windows)]
let mut stdin_open = true;
while !should_quit.load(Ordering::Acquire) {
+ if pending_activation.is_none() {
+ if let Some(reload) = pending_catalog.take() {
+ match reload {
+ Ok(profiles) => {
+ let now = std::time::Instant::now();
+ if !federated && profiles.iter().any(|profile| profile.enabled) {
+ // Keep Local recovery once enabled, even after removing the last SSH profile.
+ federated = true;
+ supervisors.add_local(
+ client_socket_path(),
+ write_stream
+ .connection(&endpoint::ClientEndpointId::Local)
+ .map(|connection| connection.generation),
+ now,
+ );
+ if write_stream
+ .connection(&endpoint::ClientEndpointId::Local)
+ .is_some_and(|connection| {
+ !connection.negotiation.supports_surface_interest()
+ })
+ {
+ if let Some(shell) = state.shell.as_mut() {
+ shell.receive_endpoint_unavailable(
+ "Update the Local server before switching between machines"
+ .into(),
+ );
+ }
+ }
+ }
+ let active_removed = catalog_reload::apply_profiles(
+ &mut state,
+ &mut write_stream,
+ &mut endpoint_commands,
+ &mut supervisors,
+ &mut endpoint_catalog,
+ profiles,
+ now,
+ );
+ if active_removed {
+ clear_endpoint_host_effects(
+ &mut state,
+ &host_mouse_capture_active,
+ &host_sgr_pixels_active,
+ );
+ scheduled_activation = None;
+ if state.shell.as_ref().is_some_and(|shell| {
+ shell.endpoint_projection_available(
+ &endpoint::ClientEndpointId::Local,
+ )
+ }) && write_stream
+ .connection(&endpoint::ClientEndpointId::Local)
+ .is_some()
+ {
+ scheduled_activation = Some(ClientLoopEvent::ActivateEndpoint {
+ endpoint_id: endpoint::ClientEndpointId::Local,
+ target: None,
+ force: true,
+ });
+ } else {
+ present_handoff_unavailable(
+ &mut state,
+ "Local is unavailable; reconnecting".into(),
+ );
+ }
+ }
+ }
+ Err(error) => {
+ warn!(%error, "saved machines could not be reloaded; keeping current connections");
+ if let Some(shell) = state.shell.as_mut() {
+ shell.receive_endpoint_unavailable(format!(
+ "Saved machines could not be reloaded; keeping current connections: {error}"
+ ));
+ }
+ }
+ }
+ apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source);
+ if let Some(shell) = state.shell.as_mut() {
+ let cleanup = shell.take_pending_graphics_cleanup();
+ let frame = shell.compose(state.reported_size.0, state.reported_size.1);
+ let frozen = state.presentation_frozen;
+ state.presentation_frozen = false;
+ state.present_graphics(&cleanup);
+ if let Some(frame) = frame {
+ state.present_frame(frame);
+ }
+ state.presentation_frozen = frozen;
+ }
+ }
+ }
+ if let Some(shell) = state.shell.as_ref() {
+ supervisors.spawn_due(
+ std::time::Instant::now(),
+ endpoint::EndpointConnectOptions {
+ cols: state.reported_size.0,
+ rows: state.reported_size.1,
+ cell_width_px: state.reported_cell_size.0,
+ cell_height_px: state.reported_cell_size.1,
+ pixel_geometry_exact: state.pixel_geometry_exact,
+ surface_size: shell.surface_size(state.reported_size.0, state.reported_size.1),
+ endpoint_keybindings: config.endpoint_keybindings,
+ mouse_capture: state.shell_mouse_capture_preference,
+ },
+ &supervisor_tx,
+ );
+ }
let timer_delay = state
.shell
.as_ref()
@@ -893,23 +682,34 @@ async fn run_client_loop(
shell.timer_delay(std::time::Instant::now())
});
let timer_deadline = client_timer.deadline(std::time::Instant::now(), timer_delay);
+ let immediate_event = scheduled_activation.take();
#[cfg(windows)]
- let event = tokio::select! {
- _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer,
- ev = stdin_rx.recv(), if stdin_open => match ev {
- Some(event) => event,
- None => {
- stdin_open = false;
- ClientLoopEvent::Timer
- }
- },
- ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer),
+ let event = if let Some(event) = immediate_event {
+ event
+ } else {
+ tokio::select! {
+ _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer,
+ ev = stdin_rx.recv(), if stdin_open => match ev {
+ Some(event) => event,
+ None => {
+ stdin_open = false;
+ ClientLoopEvent::Timer
+ }
+ },
+ ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer),
+ ev = supervisor_rx.recv() => ev.map(ClientLoopEvent::EndpointSupervisor).unwrap_or(ClientLoopEvent::Timer),
+ }
};
#[cfg(unix)]
- let event = tokio::select! {
- biased;
- _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer,
- ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer),
+ let event = if let Some(event) = immediate_event {
+ event
+ } else {
+ tokio::select! {
+ biased;
+ _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer,
+ ev = supervisor_rx.recv() => ev.map(ClientLoopEvent::EndpointSupervisor).unwrap_or(ClientLoopEvent::Timer),
+ ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer),
+ }
};
let now = std::time::Instant::now();
if let Some(shell) = state.shell.as_mut() {
@@ -917,8 +717,14 @@ async fn run_client_loop(
}
match event {
+ ClientLoopEvent::EndpointCatalog(reload) => pending_catalog = Some(reload),
#[cfg(unix)]
ClientLoopEvent::StdinInput(data) => {
+ let image_bridge_active = endpoint_accepts_local_images(
+ is_remote_client,
+ write_stream.active_id(),
+ write_stream.active_surface_available(),
+ );
if state.shell.is_some() {
if will_query_host_cell_size {
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
@@ -934,7 +740,7 @@ async fn run_client_loop(
if let Some(target) = image_target.clone() {
if should_bridge_clipboard_image_paste(
&data,
- is_remote_client,
+ image_bridge_active,
state.remote_image_paste_key,
) {
if let Some(image) = crate::platform::read_clipboard_image() {
@@ -951,7 +757,7 @@ async fn run_client_loop(
);
}
if let Some(image) =
- read_image_file_from_terminal_drop(&data, is_remote_client)
+ read_image_file_from_terminal_drop(&data, image_bridge_active)
{
write_remote_image_to_server(
&mut write_stream,
@@ -976,8 +782,10 @@ async fn run_client_loop(
outcome,
frame,
&mut write_stream,
+ &mut pending_activation,
&mut endpoint_commands,
&mut prefix_input_source,
+ &event_tx,
)? {
return Ok(());
}
@@ -1048,7 +856,7 @@ async fn run_client_loop(
};
if should_bridge_clipboard_image_paste(
&data,
- is_remote_client,
+ image_bridge_active,
state.remote_image_paste_key,
) {
if let Some(image) = crate::platform::read_clipboard_image() {
@@ -1064,7 +872,8 @@ async fn run_client_loop(
"clipboard image paste trigger received, but local clipboard has no image"
);
}
- if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) {
+ if let Some(image) = read_image_file_from_terminal_drop(&data, image_bridge_active)
+ {
write_remote_image_to_server(
&mut write_stream,
crate::protocol::ClientClipboardImageTarget::DirectTerminal,
@@ -1080,11 +889,28 @@ async fn run_client_loop(
}
#[cfg(unix)]
ClientLoopEvent::DirectGraphicsResponse(response) => {
- let composed = state
+ let pending_key = state
.pending_surface_graphics
- .remove(&response.transfer_id)
+ .keys()
+ .find(|(_, transfer_id, image_id)| {
+ *transfer_id == response.transfer_id && *image_id == response.image_id
+ })
+ .cloned();
+ let owner = pending_key
+ .as_ref()
+ .map(|(endpoint_id, _, _)| endpoint_id.clone());
+ let composed = pending_key
+ .and_then(|key| {
+ state
+ .pending_surface_graphics
+ .remove(&key)
+ .map(|asset| (key, asset))
+ })
.filter(|_| response.success)
- .and_then(|asset| {
+ .and_then(|((endpoint_id, _, _), asset)| {
+ if write_stream.active_id() != &endpoint_id {
+ return None;
+ }
let shell = state.shell.as_mut()?;
shell
.trust_direct_graphics_asset(&asset, response.image_id)
@@ -1096,8 +922,8 @@ async fn run_client_loop(
image_id: response.image_id,
success: response.success,
};
- if let Err(err) = write_to_server(&mut write_stream, &message) {
- return Err(ClientError::ConnectionLost(err));
+ if let Some(owner) = owner {
+ write_stream.send_to(&owner, &message);
}
if let Some(frame) = composed {
state.present_frame(frame);
@@ -1120,8 +946,10 @@ async fn run_client_loop(
outcome,
frame,
&mut write_stream,
+ &mut pending_activation,
&mut endpoint_commands,
&mut prefix_input_source,
+ &event_tx,
)? {
return Ok(());
}
@@ -1159,6 +987,11 @@ async fn run_client_loop(
}
#[cfg(windows)]
ClientLoopEvent::StdinEvents(events) => {
+ let image_bridge_active = endpoint_accepts_local_images(
+ is_remote_client,
+ write_stream.active_id(),
+ write_stream.active_surface_available(),
+ );
if state.shell.is_some() {
let image_target = state
.shell
@@ -1167,7 +1000,7 @@ async fn run_client_loop(
if let Some(target) = image_target.clone() {
if should_bridge_clipboard_image_events(
&events,
- is_remote_client,
+ image_bridge_active,
state.remote_image_paste_key,
) {
if let Some(image) = crate::platform::read_clipboard_image() {
@@ -1184,7 +1017,7 @@ async fn run_client_loop(
);
}
if let Some(image) =
- read_image_file_from_client_events(&events, is_remote_client)
+ read_image_file_from_client_events(&events, image_bridge_active)
{
write_remote_image_to_server(
&mut write_stream,
@@ -1209,8 +1042,10 @@ async fn run_client_loop(
outcome,
frame,
&mut write_stream,
+ &mut pending_activation,
&mut endpoint_commands,
&mut prefix_input_source,
+ &event_tx,
)? {
return Ok(());
}
@@ -1262,181 +1097,291 @@ async fn run_client_loop(
pixel_mouse: pixel_geometry_exact,
}
};
- if let Err(e) = write_to_server(&mut write_stream, &msg) {
+ if let Some(activation) = pending_activation.as_mut() {
+ if let Err(error) = activation.update_resize(msg, &mut write_stream) {
+ rollback_endpoint_activation(
+ &mut state,
+ &mut write_stream,
+ &mut pending_activation,
+ error,
+ false,
+ );
+ }
+ } else if let Err(e) = write_to_server(&mut write_stream, &msg) {
return Err(ClientError::ConnectionLost(e));
}
}
- ClientLoopEvent::ServerMessage(msg) => match *msg {
- ServerMessage::ClientShellSnapshot(_) => {
- return Err(ClientError::Protocol(protocol::FramingError::Io(
- io::Error::new(
- io::ErrorKind::InvalidData,
- "server sent an unnegotiated binary endpoint snapshot",
- ),
- )));
+ ClientLoopEvent::EndpointSupervisor(event) => match event {
+ endpoint::EndpointSupervisorEvent::Status {
+ endpoint_id,
+ generation,
+ status,
+ message,
+ } => {
+ if !supervisors.record_status(&endpoint_id, generation, status, now) {
+ continue;
+ }
+ if status == endpoint::ClientEndpointStatus::Attention {
+ warn!(endpoint = %endpoint_id.storage_key(), generation, error = %message, "endpoint needs attention");
+ }
+ let unavailable = state.shell.as_mut().and_then(|shell| {
+ shell.set_endpoint_status(&endpoint_id, status);
+ (status == endpoint::ClientEndpointStatus::Attention
+ && shell.endpoint_is_active(&endpoint_id))
+ .then(|| format!("{}: {message}", shell.endpoint_label(&endpoint_id)))
+ });
+ if let Some(message) = unavailable {
+ present_handoff_unavailable(&mut state, message);
+ } else if let Some(frame) = state.shell.as_mut().and_then(|shell| {
+ shell.compose(state.reported_size.0, state.reported_size.1)
+ }) {
+ state.present_frame(frame);
+ }
}
- ServerMessage::PaneSurface(surface) => {
- let composed = if let Some(shell) = &mut state.shell {
- shell.set_pane_surface(surface);
+ endpoint::EndpointSupervisorEvent::Connected {
+ endpoint_id,
+ generation,
+ reader,
+ writer,
+ negotiation,
+ } => {
+ if !supervisors.record_status(
+ &endpoint_id,
+ generation,
+ endpoint::ClientEndpointStatus::Online,
+ now,
+ ) {
+ continue;
+ }
+ let frame = state.shell.as_mut().and_then(|shell| {
+ shell.set_endpoint_methods_for(&endpoint_id, Some(negotiation.methods()));
shell.compose(state.reported_size.0, state.reported_size.1)
- } else {
- None
- };
- apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source);
- if let Some(frame) = composed {
+ });
+ let reader_quit = writer.stop_handle();
+ write_stream.insert(
+ endpoint_id.clone(),
+ writer,
+ generation,
+ negotiation,
+ false,
+ );
+ if let Some(frame) = frame {
state.present_frame(frame);
}
+ let reader_tx = event_tx.clone();
+ std::thread::spawn(move || {
+ server_reader_thread(
+ reader,
+ reader_tx,
+ &reader_quit,
+ MAX_GRAPHICS_FRAME_SIZE,
+ endpoint_id,
+ generation,
+ );
+ });
}
- ServerMessage::PaneSurfacePatch(patch) => {
- let patch_started = crate::render_prof::timer();
- let apply_started = crate::render_prof::timer();
- let outcome = state
- .shell
- .as_mut()
- .map(|shell| shell.apply_pane_surface_patch(patch));
- crate::render_prof::duration_since("client_surface_patch.apply", apply_started);
- let compose_fallback = match outcome {
- Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => {
- match state.present_surface_patch(patch) {
- Ok(presented) => !presented,
- Err(error) => {
- warn!(%error, "failed to present retained pane surface patch");
- state.request_repaint();
- false
+ },
+ ClientLoopEvent::ActivateEndpoint {
+ endpoint_id,
+ target,
+ force,
+ } => {
+ if !endpoint_catalog.select_endpoint(&endpoint_id) {
+ continue;
+ }
+ if let Err(error) = endpoint_catalog.store_selection() {
+ warn!(%error, "failed to persist desired endpoint selection");
+ }
+ begin_endpoint_activation(
+ &mut state,
+ &mut write_stream,
+ &mut endpoint_commands,
+ &mut pending_activation,
+ &mut next_surface_serial,
+ endpoint_id,
+ target,
+ force,
+ now,
+ &event_tx,
+ )?;
+ }
+ ClientLoopEvent::ServerMessage {
+ endpoint_id,
+ generation,
+ message,
+ } => {
+ if !write_stream.accepts(&endpoint_id, generation) {
+ continue;
+ }
+ write_stream.received(&endpoint_id, generation, now);
+ let endpoint_active = write_stream.active_id() == &endpoint_id
+ && write_stream
+ .connection(&endpoint_id)
+ .is_some_and(|connection| connection.surface_active);
+ let activation_message = pending_activation
+ .as_ref()
+ .is_some_and(|pending| pending.accepts_endpoint(&endpoint_id, generation));
+ let command_response = match message.as_ref() {
+ ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id,
+ request_id,
+ ..
+ } => endpoint_commands.accepts_response(
+ &endpoint_id,
+ generation,
+ boot_id,
+ request_id,
+ ),
+ _ => false,
+ };
+ if !endpoint::accepts_endpoint_message(
+ endpoint_active,
+ activation_message,
+ command_response,
+ message.as_ref(),
+ ) {
+ continue;
+ }
+ // Target presentation effects may arrive as soon as surface.set(true) is
+ // acknowledged. They cannot be applied while the source frame is frozen; the
+ // target receives one explicit replay after the coherent commit instead.
+ if state.presentation_frozen
+ && activation_message
+ && endpoint::is_presentation_effect(message.as_ref())
+ {
+ continue;
+ }
+ match *message {
+ ServerMessage::ClientShellSnapshot(_) => {
+ let message = "server sent an unnegotiated binary endpoint snapshot";
+ if federated || !endpoint_id.is_local() {
+ if handle_endpoint_attention(
+ &mut state,
+ &mut write_stream,
+ &mut endpoint_commands,
+ &mut supervisors,
+ &mut pending_activation,
+ &endpoint_id,
+ generation,
+ now,
+ message.into(),
+ ) {
+ clear_endpoint_host_effects(
+ &mut state,
+ &host_mouse_capture_active,
+ &host_sgr_pixels_active,
+ );
+ }
+ continue;
+ }
+ return Err(ClientError::Protocol(protocol::FramingError::Io(
+ io::Error::new(io::ErrorKind::InvalidData, message),
+ )));
+ }
+ ServerMessage::PaneSurface(surface) => {
+ if activation_message {
+ let progress = pending_activation.as_mut().map(|pending| {
+ pending.receive_surface(&endpoint_id, generation, surface)
+ });
+ if matches!(progress, Some(endpoint::SurfaceActivationProgress::Ready))
+ {
+ if let Some(event) = complete_endpoint_activation(
+ &mut state,
+ &mut write_stream,
+ &mut pending_activation,
+ &mut endpoint_commands,
+ )? {
+ scheduled_activation = Some(event);
}
}
+ continue;
}
- Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true,
- Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false,
- };
- apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source);
- if compose_fallback {
- let composed = state.shell.as_mut().and_then(|shell| {
+ if !endpoint_active {
+ continue;
+ }
+ let composed = if let Some(shell) = &mut state.shell {
+ shell.set_pane_surface(surface);
shell.compose(state.reported_size.0, state.reported_size.1)
- });
+ } else {
+ None
+ };
+ apply_client_shell_input_source_changes(
+ &mut state,
+ &mut prefix_input_source,
+ );
if let Some(frame) = composed {
state.present_frame(frame);
}
}
- crate::render_prof::duration_since("client_surface_patch.total", patch_started);
- crate::render_prof::flush_if_due();
- }
- ServerMessage::Terminal(frame) => {
- if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) {
- record_received_kitty_graphics(&frame.bytes);
+ ServerMessage::PaneSurfacePatch(patch) => {
+ let patch_started = crate::render_prof::timer();
+ let apply_started = crate::render_prof::timer();
+ let outcome = state
+ .shell
+ .as_mut()
+ .map(|shell| shell.apply_pane_surface_patch(patch));
+ crate::render_prof::duration_since(
+ "client_surface_patch.apply",
+ apply_started,
+ );
+ let compose_fallback = match outcome {
+ Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => {
+ match state.present_surface_patch(patch) {
+ Ok(presented) => !presented,
+ Err(error) => {
+ warn!(%error, "failed to present retained pane surface patch");
+ state.request_repaint();
+ false
+ }
+ }
+ }
+ Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true,
+ Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false,
+ };
+ apply_client_shell_input_source_changes(
+ &mut state,
+ &mut prefix_input_source,
+ );
+ if compose_fallback {
+ let composed = state.shell.as_mut().and_then(|shell| {
+ shell.compose(state.reported_size.0, state.reported_size.1)
+ });
+ if let Some(frame) = composed {
+ state.present_frame(frame);
+ }
+ }
+ crate::render_prof::duration_since(
+ "client_surface_patch.total",
+ patch_started,
+ );
+ crate::render_prof::flush_if_due();
}
- let mut stdout = io::stdout();
- let _ = stdout.write_all(&frame.bytes);
- let _ = stdout.flush();
- }
- ServerMessage::Graphics { bytes } => {
- if state.kitty_graphics_enabled {
- record_received_kitty_graphics(&bytes);
+ ServerMessage::Terminal(frame) => {
+ if state.kitty_graphics_enabled
+ && contains_kitty_graphics_bytes(&frame.bytes)
+ {
+ record_received_kitty_graphics(&frame.bytes);
+ }
let mut stdout = io::stdout();
- let _ = stdout.write_all(&bytes);
+ let _ = stdout.write_all(&frame.bytes);
let _ = stdout.flush();
}
- }
- ServerMessage::TerminalBell { count } => {
- if let Err(err) =
- crate::terminal_effects::write_terminal_bells(&mut io::stdout(), count)
- {
- warn!(err = %err, "failed to emit terminal bell");
- }
- }
- ServerMessage::GraphicsFile {
- path,
- expected_len,
- image_id,
- transfer_id,
- leading,
- control,
- surface_asset,
- } => {
- #[cfg(unix)]
- {
- if state.retired_direct_graphics.take() == Some((transfer_id, image_id)) {
- continue;
- }
- let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) {
- (Some(shell), Some(asset)) => {
- crate::kitty_graphics::surface::host_image_id(
- shell.graphics_scope(),
- asset,
- ) == image_id
- }
- (None, None) => true,
- _ => false,
- };
- let valid = state.kitty_graphics_enabled
- && surface_asset_valid
- && usize::try_from(expected_len).ok().is_some_and(|len| {
- crate::pane_graphics_files::validate_direct_source(
- std::path::Path::new(&path),
- len,
- )
- .is_ok()
- && direct_graphics::valid_control(&control, image_id, len)
- })
- && state
- .direct_graphics_response
- .lock()
- .is_ok_and(|mut matcher| matcher.arm(transfer_id, image_id));
- let sent = if valid {
- let mut command = Vec::new();
- crate::kitty_graphics::encode_kitty_regular_file(
- &mut command,
- &leading,
- &control,
- &path,
- );
+ ServerMessage::Graphics { bytes } => {
+ if state.kitty_graphics_enabled {
+ record_received_kitty_graphics(&bytes);
let mut stdout = io::stdout();
- let written = stdout
- .write_all(&command)
- .and_then(|()| stdout.flush())
- .is_ok();
- if written {
- record_received_kitty_graphics(&command);
- }
- written
- } else {
- false
- };
- if sent {
- if let Some(asset) = surface_asset {
- state.pending_surface_graphics.insert(transfer_id, asset);
- }
- if let Ok(mut matcher) = state.direct_graphics_response.lock() {
- matcher.start(transfer_id);
- }
- let started = ClientMessage::GraphicsTransmissionStarted {
- transfer_id,
- image_id,
- };
- if let Err(err) = write_to_server(&mut write_stream, &started) {
- return Err(ClientError::ConnectionLost(err));
- }
- } else {
- state.pending_surface_graphics.remove(&transfer_id);
- if let Ok(mut matcher) = state.direct_graphics_response.lock() {
- if valid {
- matcher.retire(transfer_id);
- } else {
- matcher.cancel(transfer_id);
- }
- }
- let result = ClientMessage::GraphicsTransmissionResult {
- transfer_id,
- image_id,
- success: false,
- };
- if let Err(err) = write_to_server(&mut write_stream, &result) {
- return Err(ClientError::ConnectionLost(err));
- }
+ let _ = stdout.write_all(&bytes);
+ let _ = stdout.flush();
+ }
+ }
+ ServerMessage::TerminalBell { count } => {
+ if let Err(err) =
+ crate::terminal_effects::write_terminal_bells(&mut io::stdout(), count)
+ {
+ warn!(err = %err, "failed to emit terminal bell");
}
}
- #[cfg(not(unix))]
- let _ = (
+ ServerMessage::GraphicsFile {
path,
expected_len,
image_id,
@@ -1444,319 +1389,546 @@ async fn run_client_loop(
leading,
control,
surface_asset,
- );
- }
- ServerMessage::GraphicsTransmissionRetired {
- transfer_id,
- image_id,
- } => {
- #[cfg(unix)]
- {
- state.retired_direct_graphics = Some((transfer_id, image_id));
- state.pending_surface_graphics.remove(&transfer_id);
- let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| {
- shell.retire_direct_graphics_image(image_id);
- shell
- .compose(state.reported_size.0, state.reported_size.1)
- .map(|frame| frame.graphics)
- .unwrap_or_else(|| shell.take_pending_graphics_cleanup())
- });
- state.present_graphics(&cleanup);
- if let Ok(mut matcher) = state.direct_graphics_response.lock() {
- matcher.retire(transfer_id);
+ } => {
+ #[cfg(unix)]
+ {
+ if state.retired_direct_graphics.take()
+ == Some((endpoint_id.clone(), transfer_id, image_id))
+ {
+ continue;
+ }
+ let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) {
+ (Some(shell), Some(asset)) => {
+ crate::kitty_graphics::surface::host_image_id(
+ shell.graphics_scope(),
+ asset,
+ ) == image_id
+ }
+ (None, None) => true,
+ _ => false,
+ };
+ let valid = state.kitty_graphics_enabled
+ && surface_asset_valid
+ && usize::try_from(expected_len).ok().is_some_and(|len| {
+ crate::pane_graphics_files::validate_direct_source(
+ std::path::Path::new(&path),
+ len,
+ )
+ .is_ok()
+ && direct_graphics::valid_control(&control, image_id, len)
+ })
+ && state
+ .direct_graphics_response
+ .lock()
+ .is_ok_and(|mut matcher| matcher.arm(transfer_id, image_id));
+ let sent = if valid {
+ let mut command = Vec::new();
+ crate::kitty_graphics::encode_kitty_regular_file(
+ &mut command,
+ &leading,
+ &control,
+ &path,
+ );
+ let mut stdout = io::stdout();
+ let written = stdout
+ .write_all(&command)
+ .and_then(|()| stdout.flush())
+ .is_ok();
+ if written {
+ record_received_kitty_graphics(&command);
+ }
+ written
+ } else {
+ false
+ };
+ if sent {
+ if let Some(asset) = surface_asset {
+ state.pending_surface_graphics.insert(
+ (endpoint_id.clone(), transfer_id, image_id),
+ asset,
+ );
+ }
+ if let Ok(mut matcher) = state.direct_graphics_response.lock() {
+ matcher.start(transfer_id);
+ }
+ let started = ClientMessage::GraphicsTransmissionStarted {
+ transfer_id,
+ image_id,
+ };
+ if let Err(err) = write_to_server(&mut write_stream, &started) {
+ return Err(ClientError::ConnectionLost(err));
+ }
+ } else {
+ state.pending_surface_graphics.remove(&(
+ endpoint_id.clone(),
+ transfer_id,
+ image_id,
+ ));
+ if let Ok(mut matcher) = state.direct_graphics_response.lock() {
+ if valid {
+ matcher.retire(transfer_id);
+ } else {
+ matcher.cancel(transfer_id);
+ }
+ }
+ let result = ClientMessage::GraphicsTransmissionResult {
+ transfer_id,
+ image_id,
+ success: false,
+ };
+ if let Err(err) = write_to_server(&mut write_stream, &result) {
+ return Err(ClientError::ConnectionLost(err));
+ }
+ }
}
+ #[cfg(not(unix))]
+ let _ = (
+ path,
+ expected_len,
+ image_id,
+ transfer_id,
+ leading,
+ control,
+ surface_asset,
+ );
}
- #[cfg(not(unix))]
- let _ = (transfer_id, image_id);
- }
- ServerMessage::ServerShutdown { reason } => {
- return Err(ClientError::ServerShutdown { reason });
- }
- ServerMessage::Notify {
- kind,
- message,
- body,
- } => {
- if state.shell.is_none() {
- handle_notify(kind, &message, body.as_deref(), &state.sound_config);
+ ServerMessage::GraphicsTransmissionRetired {
+ transfer_id,
+ image_id,
+ } => {
+ #[cfg(unix)]
+ {
+ state.retired_direct_graphics =
+ Some((endpoint_id.clone(), transfer_id, image_id));
+ state.pending_surface_graphics.remove(&(
+ endpoint_id.clone(),
+ transfer_id,
+ image_id,
+ ));
+ let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| {
+ shell.retire_direct_graphics_image(image_id);
+ shell
+ .compose(state.reported_size.0, state.reported_size.1)
+ .map(|frame| frame.graphics)
+ .unwrap_or_else(|| shell.take_pending_graphics_cleanup())
+ });
+ state.present_graphics(&cleanup);
+ if let Ok(mut matcher) = state.direct_graphics_response.lock() {
+ matcher.retire(transfer_id);
+ }
+ }
+ #[cfg(not(unix))]
+ let _ = (transfer_id, image_id);
}
- }
- ServerMessage::SemanticNotification(event) => {
- if state.shell.is_some() {
- let (effects, frame) = {
- let shell = state.shell.as_mut().expect("checked shell mode");
- let (effects, repaint) =
- shell.receive_notification(event, std::time::Instant::now());
- let frame = repaint
- .then(|| {
- shell.compose(state.reported_size.0, state.reported_size.1)
- })
- .flatten();
- (effects, frame)
- };
- handle_shell_notification_effects(effects, &state.sound_config);
- if let Some(frame) = frame {
- state.present_frame(frame);
+ ServerMessage::ServerShutdown { reason } => {
+ if !federated && endpoint_id.is_local() {
+ return Err(ClientError::ServerShutdown { reason });
}
+ write_stream.fail(
+ &endpoint_id,
+ io::Error::new(
+ io::ErrorKind::ConnectionAborted,
+ reason.unwrap_or_else(|| "server stopped".into()),
+ ),
+ );
}
- }
- ServerMessage::ClientShellError { message } => {
- if let Some(shell) = state.shell.as_mut() {
- if shell.receive_endpoint_error(message) {
- let frame = shell.compose(state.reported_size.0, state.reported_size.1);
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
+ if state.shell.is_none() {
+ handle_notify(kind, &message, body.as_deref(), &state.sound_config);
+ }
+ }
+ ServerMessage::SemanticNotification(event) => {
+ if state.shell.is_some() {
+ let (effects, frame) = {
+ let shell = state.shell.as_mut().expect("checked shell mode");
+ let (effects, repaint) = shell.receive_notification(
+ &endpoint_id,
+ event,
+ std::time::Instant::now(),
+ );
+ let frame = repaint
+ .then(|| {
+ shell.compose(state.reported_size.0, state.reported_size.1)
+ })
+ .flatten();
+ (effects, frame)
+ };
+ handle_shell_notification_effects(effects, &state.sound_config);
if let Some(frame) = frame {
state.present_frame(frame);
}
}
}
- }
- ServerMessage::ClientShellEndpointResponseChunk {
- boot_id,
- request_id,
- final_chunk,
- data,
- } => {
- let completed = endpoint_commands
- .receive_chunk(&boot_id, &request_id, final_chunk, data)
- .map_err(ClientError::ConnectionLost)?;
- let Some(completed) = completed else {
- continue;
- };
- let (repaint, actions) = state.shell.as_mut().map_or_else(
- || (false, Vec::new()),
- |shell| {
- shell.handle_endpoint_result(
- &completed.boot_id,
- &completed.request_id,
- completed.result,
- )
- },
- );
- if let Some(shell) = state.shell.as_mut() {
- shell.reconcile_input_source();
+ ServerMessage::ClientShellError { message } => {
+ if let Some(shell) = state.shell.as_mut() {
+ if shell.receive_endpoint_error(message) {
+ let frame =
+ shell.compose(state.reported_size.0, state.reported_size.1);
+ if let Some(frame) = frame {
+ state.present_frame(frame);
+ }
+ }
+ }
}
- apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source);
- let replay_mouse = dispatch_client_shell_actions(
- actions,
- &mut endpoint_commands,
- &mut write_stream,
- &mut state.detached_process_children,
- )?;
- if replay_mouse.is_empty() {
- if repaint {
- if let Some(frame) = state.shell.as_mut().and_then(|shell| {
- shell.compose(state.reported_size.0, state.reported_size.1)
- }) {
- state.present_frame(frame);
+ ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id,
+ request_id,
+ final_chunk,
+ data,
+ } => {
+ if pending_activation.as_ref().is_some_and(|pending| {
+ pending.accepts_response(
+ &endpoint_id,
+ generation,
+ &boot_id,
+ &request_id,
+ )
+ }) {
+ if !final_chunk {
+ rollback_endpoint_activation(
+ &mut state,
+ &mut write_stream,
+ &mut pending_activation,
+ "endpoint returned a chunked activation acknowledgement".into(),
+ false,
+ );
+ continue;
+ }
+ let progress = pending_activation.as_mut().map(|pending| {
+ pending.receive_response_for_boot(
+ &endpoint_id,
+ generation,
+ &boot_id,
+ &request_id,
+ &data,
+ &mut write_stream,
+ )
+ });
+ match progress {
+ Some(endpoint::SurfaceActivationProgress::Ready) => {
+ if let Some(event) = complete_endpoint_activation(
+ &mut state,
+ &mut write_stream,
+ &mut pending_activation,
+ &mut endpoint_commands,
+ )? {
+ scheduled_activation = Some(event);
+ }
+ }
+ Some(endpoint::SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected,
+ }) => {
+ rollback_endpoint_activation(
+ &mut state,
+ &mut write_stream,
+ &mut pending_activation,
+ message,
+ source_release_rejected,
+ );
+ }
+ _ => {}
}
+ continue;
}
- } else {
- let (outcome, frame) = {
- let shell = state.shell.as_mut().expect("shell endpoint response");
- let mut outcome = shell.replay_mouse_events(replay_mouse);
- outcome.repaint |= repaint;
- let frame = outcome
- .repaint
- .then(|| {
- shell.compose(state.reported_size.0, state.reported_size.1)
- })
- .flatten();
- (outcome, frame)
+ if request_id.starts_with("client-shell-surface:") {
+ continue;
+ }
+ let completed = endpoint_commands
+ .receive_chunk(
+ &endpoint_id,
+ generation,
+ &boot_id,
+ &request_id,
+ final_chunk,
+ data,
+ )
+ .map_err(ClientError::ConnectionLost)?;
+ let Some(completed) = completed else {
+ continue;
};
- if finish_client_shell_input(
+ let (repaint, actions) = state.shell.as_mut().map_or_else(
+ || (false, Vec::new()),
+ |shell| {
+ if completed.generation == generation
+ && shell.endpoint_is_active(&completed.endpoint_id)
+ {
+ shell.handle_endpoint_result(
+ &completed.boot_id,
+ &completed.request_id,
+ completed.result,
+ )
+ } else {
+ (
+ shell.cancel_endpoint_request(&completed.request_id),
+ Vec::new(),
+ )
+ }
+ },
+ );
+ if let Some(shell) = state.shell.as_mut() {
+ shell.reconcile_input_source();
+ }
+ apply_client_shell_input_source_changes(
&mut state,
- outcome,
- frame,
- &mut write_stream,
- &mut endpoint_commands,
&mut prefix_input_source,
- )? {
- return Ok(());
+ );
+ let (replay_mouse, dispatch_repaint) = dispatch_client_shell_actions(
+ actions,
+ &mut endpoint_commands,
+ &mut write_stream,
+ state.shell.as_mut(),
+ &mut state.detached_process_children,
+ &event_tx,
+ )?;
+ let repaint = repaint || dispatch_repaint;
+ if replay_mouse.is_empty() {
+ if repaint {
+ if let Some(frame) = state.shell.as_mut().and_then(|shell| {
+ shell.compose(state.reported_size.0, state.reported_size.1)
+ }) {
+ state.present_frame(frame);
+ }
+ }
+ } else {
+ let (outcome, frame) = {
+ let shell = state.shell.as_mut().expect("shell endpoint response");
+ let mut outcome = shell.replay_mouse_events(replay_mouse);
+ outcome.repaint |= repaint;
+ let frame = outcome
+ .repaint
+ .then(|| {
+ shell.compose(state.reported_size.0, state.reported_size.1)
+ })
+ .flatten();
+ (outcome, frame)
+ };
+ if finish_client_shell_input(
+ &mut state,
+ outcome,
+ frame,
+ &mut write_stream,
+ &mut pending_activation,
+ &mut endpoint_commands,
+ &mut prefix_input_source,
+ &event_tx,
+ )? {
+ return Ok(());
+ }
}
}
- }
- ServerMessage::Clipboard { data } => {
- if forward_clipboard(&data) {
- let (width, height) = state.reported_size;
- let frame = state.shell.as_mut().and_then(|shell| {
- shell
- .show_copy_feedback(std::time::Instant::now())
- .then(|| shell.compose(width, height))
- .flatten()
- });
- if let Some(frame) = frame {
- state.present_frame(frame);
+ ServerMessage::Clipboard { data } => {
+ if forward_clipboard(&data) {
+ let (width, height) = state.reported_size;
+ let frame = state.shell.as_mut().and_then(|shell| {
+ shell
+ .show_copy_feedback(std::time::Instant::now())
+ .then(|| shell.compose(width, height))
+ .flatten()
+ });
+ if let Some(frame) = frame {
+ state.present_frame(frame);
+ }
}
+ let _ = io::stdout().flush();
}
- let _ = io::stdout().flush();
- }
- ServerMessage::WindowTitle { title } => {
- let _ = crate::terminal_effects::write_window_title(
- &mut io::stdout(),
- title.as_deref(),
- );
- }
- ServerMessage::ReloadSoundConfig => {
- let previous_mouse_capture = state.shell_mouse_capture_preference;
- let mut mouse_capture = previous_mouse_capture;
- reload_local_client_config(
- &mut state.sound_config,
- &mut state.redraw_on_focus_gained,
- &mut state.draw_host_cursor,
- &mut state.remote_image_paste_key,
- &mut mouse_capture,
- );
- state.shell_mouse_capture_preference = mouse_capture;
- state.direct_mouse_capture_preference =
- state.attach_escape.is_some() && mouse_capture;
- if state.shell.is_some() && previous_mouse_capture != mouse_capture {
- write_to_server(
- &mut write_stream,
- &ClientMessage::ClientShellMouseCapture {
- enabled: mouse_capture,
- },
- )
- .map_err(ClientError::ConnectionLost)?;
- }
- if state.attach_escape.is_some() {
- let enabled = effective_mouse_capture(
- state.endpoint_mouse_capture_requested,
- state.direct_mouse_capture_preference,
+ ServerMessage::WindowTitle { title } => {
+ let _ = crate::terminal_effects::write_window_title(
+ &mut io::stdout(),
+ title.as_deref(),
);
- let sgr_pixels = effective_sgr_pixel_mouse(
+ }
+ ServerMessage::ReloadSoundConfig => apply_reload(
+ &mut state,
+ &mut write_stream,
+ &mut pending_activation,
+ &host_mouse_capture_active,
+ &host_sgr_pixels_active,
+ &mut prefix_input_source,
+ )?,
+ ServerMessage::MouseCapture {
+ enabled,
+ sgr_pixels,
+ } => {
+ state.endpoint_mouse_capture_requested = enabled;
+ state.endpoint_sgr_pixels_requested = sgr_pixels;
+ let enabled =
+ effective_mouse_capture(enabled, state.direct_mouse_capture_preference);
+ let next_sgr_pixels = effective_sgr_pixel_mouse(
enabled,
- state.endpoint_sgr_pixels_requested,
+ sgr_pixels,
state.pixel_geometry_exact,
);
- if enabled != state.mouse_capture_active
- || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire)
- {
- set_mouse_capture(enabled, sgr_pixels)
+ let mouse_mode_changed = enabled != state.mouse_capture_active
+ || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire);
+ if mouse_mode_changed {
+ #[cfg(windows)]
+ if enabled && windows_vti_input_backend_enabled() && is_ssh_session() {
+ let _ = enable_windows_virtual_terminal_input();
+ }
+ set_mouse_capture(enabled, next_sgr_pixels)
.map_err(ClientError::ConnectionFailed)?;
+ #[cfg(windows)]
+ if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() {
+ let _ = enable_windows_virtual_terminal_input();
+ }
}
state.mouse_capture_active = enabled;
host_mouse_capture_active.store(enabled, Ordering::Release);
- host_sgr_pixels_active.store(sgr_pixels, Ordering::Release);
+ host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release);
}
- let (frame, resize) = if let Some(shell) = state.shell.as_mut() {
- let previous_size =
- shell.surface_size(state.reported_size.0, state.reported_size.1);
- shell.reload_client_config();
- let next_size =
- shell.surface_size(state.reported_size.0, state.reported_size.1);
- let resize = (previous_size != next_size).then(|| {
- shell.invalidate_pane_surface();
- client_shell_resize_message(
- shell,
- state.reported_size.0,
- state.reported_size.1,
- state.reported_cell_size.0,
- state.reported_cell_size.1,
- state.pixel_geometry_exact,
+ ServerMessage::DirectTerminalKeyboardProtocol {
+ flags,
+ modify_other_keys_level,
+ } => {
+ if state.attach_escape.is_some() {
+ crate::terminal_modes::set_direct_host_keyboard_protocol(
+ &mut io::stdout(),
+ &mut state.direct_keyboard_protocol,
+ flags,
+ modify_other_keys_level,
)
- });
- (
- shell.compose(state.reported_size.0, state.reported_size.1),
- resize,
- )
- } else {
- (None, None)
- };
- apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source);
- if let Some(resize) = resize {
- write_to_server(&mut write_stream, &resize)
- .map_err(ClientError::ConnectionLost)?;
- }
- if let Some(frame) = frame {
- state.present_frame(frame);
- }
- }
- ServerMessage::MouseCapture {
- enabled,
- sgr_pixels,
- } => {
- state.endpoint_mouse_capture_requested = enabled;
- state.endpoint_sgr_pixels_requested = sgr_pixels;
- let enabled =
- effective_mouse_capture(enabled, state.direct_mouse_capture_preference);
- let next_sgr_pixels =
- effective_sgr_pixel_mouse(enabled, sgr_pixels, state.pixel_geometry_exact);
- let mouse_mode_changed = enabled != state.mouse_capture_active
- || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire);
- if mouse_mode_changed {
- #[cfg(windows)]
- if enabled && windows_vti_input_backend_enabled() && is_ssh_session() {
- let _ = enable_windows_virtual_terminal_input();
- }
- set_mouse_capture(enabled, next_sgr_pixels)
.map_err(ClientError::ConnectionFailed)?;
- #[cfg(windows)]
- if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() {
- let _ = enable_windows_virtual_terminal_input();
}
}
- state.mouse_capture_active = enabled;
- host_mouse_capture_active.store(enabled, Ordering::Release);
- host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release);
- }
- ServerMessage::DirectTerminalKeyboardProtocol {
- flags,
- modify_other_keys_level,
- } => {
- if state.attach_escape.is_some() {
- crate::terminal_modes::set_direct_host_keyboard_protocol(
- &mut io::stdout(),
- &mut state.direct_keyboard_protocol,
- flags,
- modify_other_keys_level,
- )
- .map_err(ClientError::ConnectionFailed)?;
- }
- }
- ServerMessage::ClientShellKeyboardReportAll { enabled } => {
- if state.shell.is_some() {
- state.pane_keyboard_report_all = enabled;
- sync_client_shell_keyboard_report_all(&mut state)?;
+ ServerMessage::ClientShellKeyboardReportAll { enabled } => {
+ if state.shell.is_some() {
+ state.pane_keyboard_report_all = enabled;
+ sync_client_shell_keyboard_report_all(&mut state)?;
+ }
}
- }
- ServerMessage::EndpointControl { kind, data } => {
- if kind != crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND {
- if kind.starts_with("shell.snapshot.") {
- return Err(ClientError::Protocol(protocol::FramingError::Io(
- io::Error::new(
- io::ErrorKind::InvalidData,
- format!(
- "unsupported mandatory endpoint snapshot codec {kind:?}"
- ),
- ),
- )));
+ ServerMessage::EndpointControl { kind, data } => {
+ if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND {
+ let progress = pending_activation.as_mut().map(|activation| {
+ activation.receive_presentation_effects_ready(
+ &endpoint_id,
+ generation,
+ &data,
+ )
+ });
+ if matches!(progress, Some(endpoint::SurfaceActivationProgress::Ready))
+ {
+ if let Some(event) = complete_endpoint_activation(
+ &mut state,
+ &mut write_stream,
+ &mut pending_activation,
+ &mut endpoint_commands,
+ )? {
+ scheduled_activation = Some(event);
+ }
+ }
+ continue;
+ }
+ let snapshot = match endpoint::decode_endpoint_control(&kind, &data) {
+ Ok(endpoint::EndpointControlMessage::HealthPong) => continue,
+ Ok(endpoint::EndpointControlMessage::Ignored) => {
+ debug!(%kind, "ignoring unknown endpoint control message");
+ continue;
+ }
+ Ok(endpoint::EndpointControlMessage::Snapshot(snapshot)) => snapshot,
+ Err(message)
+ if federated
+ || !endpoint::protocol_failure_is_fatal(&endpoint_id) =>
+ {
+ if handle_endpoint_attention(
+ &mut state,
+ &mut write_stream,
+ &mut endpoint_commands,
+ &mut supervisors,
+ &mut pending_activation,
+ &endpoint_id,
+ generation,
+ now,
+ message,
+ ) {
+ clear_endpoint_host_effects(
+ &mut state,
+ &host_mouse_capture_active,
+ &host_sgr_pixels_active,
+ );
+ }
+ continue;
+ }
+ Err(message) => {
+ return Err(ClientError::Protocol(protocol::FramingError::Io(
+ io::Error::new(io::ErrorKind::InvalidData, message),
+ )));
+ }
+ };
+ let projection_pending = activation_message;
+ let activation_progress = activation_message
+ .then(|| {
+ pending_activation.as_mut().map(|pending| {
+ pending.receive_snapshot(&endpoint_id, generation, &snapshot)
+ })
+ })
+ .flatten();
+ install_client_shell_snapshot(
+ &mut state,
+ &endpoint_id,
+ snapshot,
+ projection_pending,
+ &mut write_stream,
+ &mut prefix_input_source,
+ )?;
+ if matches!(
+ activation_progress,
+ Some(endpoint::SurfaceActivationProgress::Ready)
+ ) {
+ if let Some(event) = complete_endpoint_activation(
+ &mut state,
+ &mut write_stream,
+ &mut pending_activation,
+ &mut endpoint_commands,
+ )? {
+ scheduled_activation = Some(event);
+ }
+ }
+ write_stream.mark_ready(&endpoint_id, generation);
+ let selected_endpoint = endpoint_catalog
+ .selected_profile
+ .as_ref()
+ .map_or(endpoint::ClientEndpointId::Local, |profile_id| {
+ endpoint::ClientEndpointId::Ssh(profile_id.clone())
+ });
+ let activation_ready = state.shell.as_ref().is_some_and(|shell| {
+ shell.endpoint_has_snapshot(&selected_endpoint)
+ && (!write_stream
+ .connection(write_stream.active_id())
+ .is_some_and(|connection| connection.surface_active)
+ || shell.endpoint_boot_id(write_stream.active_id()).is_some())
+ });
+ let needs_surface = write_stream
+ .connection(&selected_endpoint)
+ .is_some_and(|connection| !connection.surface_active);
+ if activation_ready && needs_surface && pending_activation.is_none() {
+ scheduled_activation = Some(ClientLoopEvent::ActivateEndpoint {
+ endpoint_id: selected_endpoint,
+ target: None,
+ force: false,
+ });
}
- debug!(%kind, "ignoring unknown endpoint control message");
- continue;
}
- let snapshot: crate::protocol::ClientShellSnapshot =
- serde_json::from_str(&data).map_err(|error| {
- ClientError::Protocol(protocol::FramingError::Io(io::Error::new(
- io::ErrorKind::InvalidData,
- format!("invalid endpoint snapshot: {error}"),
- )))
- })?;
- install_client_shell_snapshot(
- &mut state,
- Box::new(snapshot),
- &mut write_stream,
- &mut prefix_input_source,
- )?;
+ ServerMessage::Welcome { .. } => {
+ debug!("received unexpected Welcome in main loop");
+ }
}
- ServerMessage::Welcome { .. } => {
- debug!("received unexpected Welcome in main loop");
+ }
+ ClientLoopEvent::ServerDisconnected {
+ endpoint_id,
+ generation,
+ } => {
+ if !write_stream.accepts(&endpoint_id, generation) {
+ continue;
}
- },
- ClientLoopEvent::ServerDisconnected => {
- return Err(ClientError::ConnectionLost(io::Error::new(
- io::ErrorKind::UnexpectedEof,
- "server closed connection",
- )));
+ write_stream.fail(
+ &endpoint_id,
+ io::Error::new(io::ErrorKind::UnexpectedEof, "connection was lost"),
+ );
}
ClientLoopEvent::Timer => {
client_timer.fired();
@@ -1767,12 +1939,80 @@ async fn run_client_loop(
state
.detached_process_children
.retain_mut(|child| child.try_wait().ok().flatten().is_none());
+ write_stream.tick_health(now);
+ for failure in write_stream.take_failures() {
+ if write_stream.connection(&failure.endpoint_id).is_some()
+ && !write_stream.accepts(&failure.endpoint_id, failure.generation)
+ {
+ continue;
+ }
+ warn!(
+ endpoint = %failure.endpoint_id.storage_key(),
+ error = %failure.message,
+ "endpoint transport failed"
+ );
+ if !federated && failure.endpoint_id.is_local() {
+ return Err(ClientError::ConnectionLost(io::Error::new(
+ failure.kind,
+ failure.message,
+ )));
+ }
+ if handle_endpoint_disconnect(
+ &mut state,
+ &mut write_stream,
+ &mut endpoint_commands,
+ &mut supervisors,
+ &mut pending_activation,
+ &failure.endpoint_id,
+ failure.generation,
+ now,
+ &format!("{}; reconnecting", failure.message),
+ ) {
+ clear_endpoint_host_effects(
+ &mut state,
+ &host_mouse_capture_active,
+ &host_sgr_pixels_active,
+ );
+ }
+ }
+ // A revoked transport changes the safe rollback destination. Handle those
+ // failures before applying a timeout to the remaining activation phase.
+ if pending_activation
+ .as_ref()
+ .is_some_and(|activation| activation.expired(now))
+ {
+ let endpoint_id = pending_activation
+ .as_ref()
+ .map(|activation| activation.target().clone())
+ .expect("checked pending activation");
+ let label = state
+ .shell
+ .as_ref()
+ .map(|shell| shell.endpoint_label(&endpoint_id).to_owned())
+ .unwrap_or_else(|| "Endpoint".into());
+ rollback_endpoint_activation(
+ &mut state,
+ &mut write_stream,
+ &mut pending_activation,
+ format!("{label} did not produce a coherent surface in time"),
+ false,
+ );
+ }
if state.shell.is_some() {
- let expired_endpoint = endpoint_commands.expire(now);
+ let expired_endpoints = endpoint_commands
+ .expire(now)
+ .into_iter()
+ .filter(|expired| {
+ write_stream.accepts(&expired.endpoint_id, expired.generation)
+ })
+ .collect::<Vec<_>>();
let (effects, outcome, frame) = {
let shell = state.shell.as_mut().expect("checked shell mode");
let mut outcome = shell.tick_selection_autoscroll(now);
- if let Some(expired) = expired_endpoint {
+ for expired in expired_endpoints {
+ if !shell.endpoint_is_active(&expired.endpoint_id) {
+ continue;
+ }
let (repaint, actions) = shell.handle_endpoint_result(
&expired.boot_id,
&expired.request_id,
@@ -1795,8 +2035,10 @@ async fn run_client_loop(
outcome,
frame,
&mut write_stream,
+ &mut pending_activation,
&mut endpoint_commands,
&mut prefix_input_source,
+ &event_tx,
)? {
return Ok(());
}
@@ -1813,141 +2055,5 @@ async fn run_client_loop(
Ok(())
}
-// ---------------------------------------------------------------------------
-// Server reader thread
-// ---------------------------------------------------------------------------
-
-/// Blocking thread that reads ServerMessages from the server and sends them
-/// to the main event loop.
-fn server_reader_thread(
- mut stream: LocalStream,
- event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
- should_quit: &Arc<AtomicBool>,
- max_frame_size: usize,
-) {
- // Ensure the read stream is in blocking mode to avoid WouldBlock errors
- // from read_exact inside read_message. The stream should already be
- // blocking after handshake, but we enforce it here as a safety measure.
- if stream.set_nonblocking(false).is_err() {
- // If we can't set blocking mode, the stream is likely broken.
- let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
- return;
- }
-
- loop {
- if should_quit.load(Ordering::Acquire) {
- break;
- }
-
- match protocol::read_message(&mut stream, max_frame_size) {
- Ok(msg) => {
- if event_tx
- .blocking_send(ClientLoopEvent::ServerMessage(Box::new(msg)))
- .is_err()
- {
- break; // Main loop gone.
- }
- }
- Err(protocol::FramingError::UnexpectedEof) => {
- // Server closed connection.
- let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
- break;
- }
- Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => {
- // Should not happen with blocking mode, but handle gracefully
- // in case the stream was set nonblocking by another clone.
- std::thread::sleep(Duration::from_millis(1));
- continue;
- }
- Err(err) => {
- warn!(err = %err, "server read error");
- let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
- break;
- }
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Write helper
-// ---------------------------------------------------------------------------
-
-/// Writes a message to the server stream (blocking).
-fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result<()> {
- protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string()))
-}
-
-// ---------------------------------------------------------------------------
-// Notifications
-// ---------------------------------------------------------------------------
-
-fn reload_local_client_config(
- sound_config: &mut crate::config::SoundConfig,
- redraw_on_focus_gained: &mut bool,
- draw_host_cursor: &mut bool,
- remote_image_paste_key: &mut Option<(
- crossterm::event::KeyCode,
- crossterm::event::KeyModifiers,
- )>,
- mouse_capture: &mut bool,
-) {
- match crate::config::load_live_config() {
- Ok(loaded) => {
- let invalid_section = |section: &str| {
- loaded
- .invalid_sections
- .iter()
- .any(|invalid| invalid == section)
- };
- if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none()
- {
- for diagnostic in loaded.config.ui.sound.diagnostics() {
- warn!(diagnostic = %diagnostic, "local sound config diagnostic");
- }
- *sound_config = loaded.config.ui.sound.clone();
- *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained;
- *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor);
- *mouse_capture = loaded.config.ui.mouse_capture;
- }
- if !invalid_section("keys") {
- *remote_image_paste_key = client_remote_image_paste_key(&loaded.config);
- }
- debug!("reloaded local client config");
- }
- Err(diagnostics) => {
- warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config");
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Clipboard forwarding
-// ---------------------------------------------------------------------------
-
-/// Decode a clipboard payload forwarded by the server.
-fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> {
- use base64::Engine;
- base64::engine::general_purpose::STANDARD.decode(data).ok()
-}
-
-/// Forwards a clipboard write from the server to the local client clipboard.
-fn forward_clipboard(data: &str) -> bool {
- let Some(bytes) = decode_clipboard_payload(data) else {
- warn!("received invalid clipboard payload from server");
- return false;
- };
-
- crate::selection::write_osc52_bytes(&bytes);
- true
-}
-
-fn init_logging() {
- crate::logging::init_file_logging("herdr-client.log");
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
#[cfg(test)]
mod tests;
diff --git a/src/client/shell.rs b/src/client/shell.rs
index 9a877744..14f4f660 100644
--- a/src/client/shell.rs
+++ b/src/client/shell.rs
@@ -2,15 +2,25 @@ use std::collections::{HashMap, HashSet, VecDeque};
mod actions;
mod agent_sidebar;
+mod aggregate_navigation;
mod composition;
mod config;
mod context_menu;
mod copy_mode;
+mod endpoint_agent_state;
+mod endpoint_agents;
+mod endpoint_navigation;
+mod endpoint_notices;
+mod endpoint_sidebar;
+mod endpoints;
+pub(super) use endpoints::*;
mod global_menu;
mod graphics;
mod input;
+mod input_source;
mod mobile;
mod mouse;
+mod notification_policy;
mod notifications;
mod overlay_input;
mod preferences;
@@ -21,6 +31,7 @@ mod state;
mod surface_patch;
mod worktrees;
+pub(in crate::client::shell) use render::sidebar;
pub(crate) use state::*;
#[cfg(test)]
pub(super) use surface_patch::apply_composed_surface_patch;
@@ -34,6 +45,7 @@ use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use unicode_width::UnicodeWidthStr;
+use super::endpoint::{ClientEndpointId, ClientEndpointStatus, SavedSshEndpoint};
use crate::app::state::Palette;
use crate::config::{
Config, LiveKeybindConfig, SidebarCollapsedModeConfig, SpacesSidebarConfig,
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs
index 1ebd4e29..f99d8f3a 100644
--- a/src/client/shell/actions.rs
+++ b/src/client/shell/actions.rs
@@ -147,6 +147,9 @@ impl ClientShellState {
}
return;
}
+ if self.handle_endpoint_navigation(action, outcome) {
+ return;
+ }
if let Some(method) = self.endpoint_method_for_action(action) {
self.push_endpoint_method(method, outcome);
return;
@@ -408,7 +411,9 @@ impl ClientShellState {
kind: PendingEndpointKind,
outcome: &mut ClientShellInput,
) -> bool {
- if self.snapshot.is_none() {
+ if !self.endpoint_is_online(&self.active_endpoint_id) {
+ let label = self.active_endpoint_label().to_owned();
+ outcome.repaint |= self.receive_endpoint_unavailable(format!("{label} is not ready"));
return false;
}
let method_name = crate::api::api_method_name(&method).to_owned();
@@ -423,7 +428,9 @@ impl ClientShellState {
);
return false;
}
- let snapshot = self.snapshot.as_deref().expect("checked snapshot");
+ let Some(snapshot) = self.snapshot.as_deref() else {
+ return false;
+ };
let confirmation_workspace_id = match &method {
crate::api::schema::Method::TabClose(target) => snapshot
.tabs
@@ -450,6 +457,7 @@ impl ClientShellState {
},
);
outcome.actions.push(ClientShellAction::Endpoint {
+ endpoint_id: self.active_endpoint_id.clone(),
boot_id: snapshot.boot_id.clone(),
request: Box::new(crate::api::schema::Request {
id: request_id,
@@ -468,6 +476,58 @@ impl ClientShellState {
)
}
+ pub(crate) fn receive_endpoint_unavailable(&mut self, message: String) -> bool {
+ self.push_endpoint_notice(
+ ClientEndpointNoticeKind::Unavailable,
+ message.clone(),
+ "Endpoint unavailable",
+ message,
+ )
+ }
+
+ pub(crate) fn focus_endpoint_target(
+ &mut self,
+ target: ClientEndpointFocusTarget,
+ ) -> Vec<ClientShellAction> {
+ let method = match target {
+ ClientEndpointFocusTarget::Workspace(workspace_id) => {
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id,
+ })
+ }
+ ClientEndpointFocusTarget::Tab(tab_id) => {
+ crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id })
+ }
+ ClientEndpointFocusTarget::Pane(pane_id) => {
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id })
+ }
+ };
+ let mut outcome = ClientShellInput::default();
+ self.push_endpoint_method(method, &mut outcome);
+ outcome.actions
+ }
+
+ pub(crate) fn cancel_endpoint_request(&mut self, request_id: &str) -> bool {
+ let Some(pending) = self.pending_requests.get(request_id) else {
+ return false;
+ };
+ let boot_id = pending.boot_id.clone();
+ let (repaint, actions) = self.handle_endpoint_result(
+ &boot_id,
+ request_id,
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_cancelled".into()),
+ message: "This server action was interrupted. Check its state before retrying."
+ .into(),
+ }),
+ );
+ debug_assert!(
+ actions.is_empty(),
+ "cancellation must not start another action"
+ );
+ repaint
+ }
+
pub(crate) fn handle_endpoint_result(
&mut self,
boot_id: &str,
@@ -506,6 +566,12 @@ impl ClientShellState {
"Server timed out",
format!("This server did not respond to {}.", pending.method_name),
),
+ "endpoint_cancelled" => (
+ ClientEndpointNoticeKind::Unavailable,
+ "cancelled".to_owned(),
+ "Action interrupted",
+ error.message.clone(),
+ ),
"server_unavailable" => (
ClientEndpointNoticeKind::Unavailable,
"server".to_owned(),
@@ -613,6 +679,9 @@ impl ClientShellState {
Some("endpoint returned an unexpected selection result".to_owned());
(true, fallback())
}
+ Err(error) if error.code.as_deref() == Some("endpoint_cancelled") => {
+ (true, Vec::new())
+ }
Err(_) => (true, fallback()),
};
}
@@ -724,7 +793,7 @@ impl ClientShellState {
Err(error)
if matches!(
error.code.as_deref(),
- Some("stale_content" | "stale_target")
+ Some("stale_content" | "stale_target" | "endpoint_cancelled")
) =>
{
self.url_click_consumes_until_up = completed_before_release;
diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs
index 8f87b6ad..d6fe3352 100644
--- a/src/client/shell/agent_sidebar.rs
+++ b/src/client/shell/agent_sidebar.rs
@@ -10,11 +10,11 @@ use ratatui::{
use super::*;
-struct AgentRow {
- pane_id: String,
- status: crate::api::schema::AgentStatus,
- focused: bool,
- rows: Vec<Vec<crate::ui::ResolvedToken>>,
+pub(super) struct AgentRow {
+ pub(super) pane_id: String,
+ pub(super) status: crate::api::schema::AgentStatus,
+ pub(super) focused: bool,
+ pub(super) rows: Vec<Vec<crate::ui::ResolvedToken>>,
}
pub(super) fn ordered_agent_pane_ids(
@@ -57,9 +57,46 @@ pub(super) fn render_agent_panel(
agent_scroll: &mut usize,
hits: &mut ShellHitMap,
) {
- if area.height == 0 {
+ if !render_agent_panel_header(
+ buffer,
+ area,
+ snapshot.agent_view_label.as_deref(),
+ config,
+ hits,
+ ) {
return;
}
+
+ let rows = agent_rows(snapshot, config, None);
+ render_agent_list(
+ buffer,
+ area,
+ &rows,
+ snapshot
+ .agent_view_label
+ .as_ref()
+ .map(|_| " no matching agents"),
+ config,
+ agent_scroll,
+ hits,
+ |row| row.rows.len(),
+ |buffer, rect, row, hits| {
+ hits.agents.push((rect, row.pane_id.clone()));
+ render_agent_row(buffer, rect, row, config);
+ },
+ );
+}
+
+pub(super) fn render_agent_panel_header(
+ buffer: &mut Buffer,
+ area: Rect,
+ agent_view_label: Option<&str>,
+ config: &ClientShellConfig,
+ hits: &mut ShellHitMap,
+) -> bool {
+ if area.height == 0 {
+ return false;
+ }
put_text(
buffer,
area.x,
@@ -69,7 +106,7 @@ pub(super) fn render_agent_panel(
Style::default().fg(config.palette.surface_dim),
);
if area.height < 2 {
- return;
+ return false;
}
put_text(
buffer,
@@ -81,14 +118,10 @@ pub(super) fn render_agent_panel(
.fg(config.palette.overlay0)
.add_modifier(Modifier::BOLD),
);
- let sort_label =
- snapshot
- .agent_view_label
- .as_deref()
- .unwrap_or(match config.agent_panel_sort {
- crate::config::AgentPanelSortConfig::Spaces => "grouped",
- crate::config::AgentPanelSortConfig::Priority => "priority",
- });
+ let sort_label = agent_view_label.unwrap_or(match config.agent_panel_sort {
+ crate::config::AgentPanelSortConfig::Spaces => "grouped",
+ crate::config::AgentPanelSortConfig::Priority => "priority",
+ });
let sort_width = display_width(sort_label).min(area.width as usize) as u16;
let sort_rect = Rect::new(
area.right().saturating_sub(sort_width),
@@ -96,7 +129,7 @@ pub(super) fn render_agent_panel(
sort_width,
1,
);
- hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() {
+ hits.agent_sort_toggle = if config.mouse_capture && agent_view_label.is_none() {
sort_rect
} else {
Rect::default()
@@ -108,15 +141,27 @@ pub(super) fn render_agent_panel(
sort_rect.width,
sort_label,
Style::default()
- .fg(if snapshot.agent_view_label.is_some() {
+ .fg(if agent_view_label.is_some() {
config.palette.accent
} else {
config.palette.overlay0
})
.add_modifier(Modifier::BOLD),
);
+ true
+}
- let rows = agent_rows(snapshot, config);
+pub(super) fn render_agent_list<T>(
+ buffer: &mut Buffer,
+ area: Rect,
+ rows: &[T],
+ empty_message: Option<&str>,
+ config: &ClientShellConfig,
+ agent_scroll: &mut usize,
+ hits: &mut ShellHitMap,
+ row_lines: impl Fn(&T) -> usize,
+ mut render_row: impl FnMut(&mut Buffer, Rect, &T, &mut ShellHitMap),
+) {
let body = Rect::new(
area.x,
area.y.saturating_add(3),
@@ -126,13 +171,13 @@ pub(super) fn render_agent_panel(
hits.agent_body = body;
if body.is_empty() || rows.is_empty() {
*agent_scroll = 0;
- if !body.is_empty() && snapshot.agent_view_label.is_some() {
+ if let Some(message) = empty_message.filter(|_| !body.is_empty()) {
put_text(
buffer,
body.x,
body.y,
body.width,
- " no matching agents",
+ message,
Style::default()
.fg(config.palette.overlay0)
.add_modifier(Modifier::DIM),
@@ -143,7 +188,7 @@ pub(super) fn render_agent_panel(
let row_heights = rows
.iter()
- .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16)
+ .map(|row| row_lines(row).max(1).min(u16::MAX as usize) as u16)
.collect::<Vec<_>>();
let gaps = rows
.iter()
@@ -167,13 +212,12 @@ pub(super) fn render_agent_panel(
let content_width = body.width.saturating_sub(u16::from(show_scrollbar));
let mut y = body.y;
for (index, row) in rows.iter().enumerate().skip(*agent_scroll) {
- let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height);
+ let height = row_heights[index].min(body.height);
if y.saturating_add(height) > body.bottom() {
break;
}
let rect = Rect::new(body.x, y, content_width, height);
- hits.agents.push((rect, row.pane_id.clone()));
- render_agent_row(buffer, rect, row, config);
+ render_row(buffer, rect, row, hits);
y = y
.saturating_add(height)
.saturating_add(if index + 1 < rows.len() {
@@ -190,7 +234,11 @@ pub(super) fn render_agent_panel(
}
}
-fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec<AgentRow> {
+pub(super) fn agent_rows(
+ snapshot: &ClientShellSnapshot,
+ config: &ClientShellConfig,
+ machine: Option<&str>,
+) -> Vec<AgentRow> {
ordered_agent_pane_ids(snapshot, config.agent_panel_sort)
.into_iter()
.filter_map(|pane_id| {
@@ -238,6 +286,7 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec
let rows = crate::ui::sidebar_agent_rows(
&config.agents,
crate::ui::AgentTokenContext {
+ machine,
workspace: &workspace.label,
tab: tab_label,
pane: agent
@@ -262,7 +311,12 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec
.collect()
}
-fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) {
+pub(super) fn render_agent_row(
+ buffer: &mut Buffer,
+ rect: Rect,
+ row: &AgentRow,
+ config: &ClientShellConfig,
+) {
let palette = &config.palette;
let row_style = if row.focused {
Style::default().bg(palette.active_row_bg)
diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs
new file mode 100644
index 00000000..5c826bf3
--- /dev/null
+++ b/src/client/shell/aggregate_navigation.rs
@@ -0,0 +1,262 @@
+//! Endpoint-qualified rows shared by aggregate navigation surfaces.
+
+use super::*;
+use crate::protocol::ClientShellAgent;
+
+#[derive(Clone, Copy)]
+pub(super) struct CachedEndpointSnapshot<'a> {
+ pub(super) endpoint_id: &'a ClientEndpointId,
+ pub(super) label: &'a str,
+ pub(super) status: ClientEndpointStatus,
+ pub(super) snapshot: &'a ClientShellSnapshot,
+ pub(super) agent_recency: &'a HashMap<String, u64>,
+}
+
+impl CachedEndpointSnapshot<'_> {
+ pub(super) fn stale(self) -> bool {
+ self.status != ClientEndpointStatus::Online
+ }
+}
+
+pub(super) fn cached_endpoint_snapshots(
+ endpoints: &[ClientShellEndpoint],
+) -> impl Iterator<Item = CachedEndpointSnapshot<'_>> {
+ endpoints.iter().filter_map(|endpoint| {
+ endpoint
+ .snapshot
+ .as_deref()
+ .map(|snapshot| CachedEndpointSnapshot {
+ endpoint_id: &endpoint.endpoint_id,
+ label: &endpoint.label,
+ status: endpoint.status,
+ snapshot,
+ agent_recency: &endpoint.agent_recency,
+ })
+ })
+}
+
+pub(super) struct AggregateAgentRow<'a> {
+ pub(super) endpoint: CachedEndpointSnapshot<'a>,
+ pub(super) agent: &'a ClientShellAgent,
+ pub(super) recency: u64,
+}
+
+pub(super) struct AggregateAgentTarget {
+ pub(super) endpoint_id: ClientEndpointId,
+ pub(super) pane_id: String,
+}
+
+pub(super) fn aggregate_agent_rows(
+ endpoints: &[ClientShellEndpoint],
+ sort: crate::config::AgentPanelSortConfig,
+) -> Vec<AggregateAgentRow<'_>> {
+ let mut rows = cached_endpoint_snapshots(endpoints)
+ .flat_map(|endpoint| {
+ super::agent_sidebar::ordered_agent_pane_ids(endpoint.snapshot, sort)
+ .into_iter()
+ .filter_map(move |pane_id| {
+ let agent = endpoint
+ .snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane_id)?;
+ Some(AggregateAgentRow {
+ recency: endpoint
+ .agent_recency
+ .get(&pane_id)
+ .copied()
+ .unwrap_or_default(),
+ endpoint,
+ agent,
+ })
+ })
+ })
+ .collect::<Vec<_>>();
+ if sort == crate::config::AgentPanelSortConfig::Priority {
+ rows.sort_by_key(|row| {
+ (
+ row.endpoint.stale(),
+ std::cmp::Reverse(status_priority(row.agent.agent_status)),
+ std::cmp::Reverse(row.recency),
+ )
+ });
+ }
+ rows
+}
+
+pub(super) fn online_agent_targets(
+ endpoints: &[ClientShellEndpoint],
+ sort: crate::config::AgentPanelSortConfig,
+) -> Vec<AggregateAgentTarget> {
+ aggregate_agent_rows(endpoints, sort)
+ .into_iter()
+ .filter(|row| !row.endpoint.stale())
+ .map(|row| AggregateAgentTarget {
+ endpoint_id: row.endpoint.endpoint_id.clone(),
+ pane_id: row.agent.pane_id.clone(),
+ })
+ .collect()
+}
+
+pub(super) fn navigator_rows(
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
+ navigator: &ClientNavigatorOverlay,
+) -> Vec<ClientNavigatorRow> {
+ let query = navigator.query.trim().to_lowercase();
+ let filter = |status| match navigator.filter {
+ Some(ClientNavigatorFilter::Blocked) => status == crate::api::schema::AgentStatus::Blocked,
+ Some(ClientNavigatorFilter::Working) => status == crate::api::schema::AgentStatus::Working,
+ Some(ClientNavigatorFilter::Idle) => status == crate::api::schema::AgentStatus::Idle,
+ Some(ClientNavigatorFilter::Done) => status == crate::api::schema::AgentStatus::Done,
+ None => true,
+ };
+ let text = |value: &str| query.is_empty() || value.to_lowercase().contains(&query);
+ let filtering = navigator.filter.is_some() || !query.is_empty();
+ let federated = endpoints.len() > 1;
+ let depth_offset = u8::from(federated);
+ let mut rows = Vec::new();
+
+ for endpoint in endpoints {
+ let stale = endpoint.status != ClientEndpointStatus::Online;
+ let endpoint_query_matches = !query.is_empty() && text(&endpoint.label);
+ let mut endpoint_rows = Vec::new();
+ if let Some(snapshot) = endpoint.snapshot.as_deref() {
+ for workspace in &snapshot.workspaces {
+ let workspace_meta = workspace.branch.clone().unwrap_or_default();
+ let mut children = Vec::new();
+ for tab in snapshot
+ .tabs
+ .iter()
+ .filter(|tab| tab.workspace_id == workspace.workspace_id)
+ {
+ let mut panes = Vec::new();
+ for (index, pane) in snapshot
+ .panes
+ .iter()
+ .filter(|pane| pane.tab_id == tab.tab_id)
+ .enumerate()
+ {
+ let agent = snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane.pane_id);
+ let status = agent
+ .map_or(crate::api::schema::AgentStatus::Unknown, |agent| {
+ agent.agent_status
+ });
+ let label = pane
+ .label
+ .clone()
+ .or_else(|| agent.and_then(|agent| agent.name.clone()))
+ .or_else(|| agent.and_then(|agent| agent.display_agent.clone()))
+ .or_else(|| agent.and_then(|agent| agent.title.clone()))
+ .unwrap_or_else(|| format!("pane {}", index + 1));
+ let meta = pane
+ .foreground_cwd
+ .clone()
+ .or_else(|| pane.cwd.clone())
+ .unwrap_or_default();
+ if !filtering
+ || filter(status)
+ && (endpoint_query_matches || text(&label) || text(&meta))
+ {
+ panes.push(ClientNavigatorRow {
+ depth: 2 + depth_offset,
+ label,
+ meta,
+ status: Some(status),
+ stale,
+ current: endpoint.endpoint_id == *active_endpoint_id
+ && snapshot.focused_pane_id.as_deref() == Some(&pane.pane_id),
+ target: ClientNavigatorTarget::Pane {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ pane_id: pane.pane_id.clone(),
+ },
+ });
+ }
+ }
+ if !filtering
+ || filter(tab.agent_status) && (endpoint_query_matches || text(&tab.label))
+ || !panes.is_empty()
+ {
+ children.push(ClientNavigatorRow {
+ depth: 1 + depth_offset,
+ label: tab.label.clone(),
+ meta: format!(
+ "{} panes",
+ snapshot
+ .panes
+ .iter()
+ .filter(|pane| pane.tab_id == tab.tab_id)
+ .count()
+ ),
+ status: None,
+ stale,
+ current: false,
+ target: ClientNavigatorTarget::Tab {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ tab_id: tab.tab_id.clone(),
+ },
+ });
+ children.extend(panes);
+ }
+ }
+ let workspace_matches = filter(workspace.agent_status)
+ && (endpoint_query_matches || text(&workspace.label) || text(&workspace_meta));
+ if !filtering || workspace_matches || !children.is_empty() {
+ let key = (endpoint.endpoint_id.clone(), workspace.workspace_id.clone());
+ endpoint_rows.push(ClientNavigatorRow {
+ depth: depth_offset,
+ label: workspace.label.clone(),
+ meta: workspace_meta,
+ status: None,
+ stale,
+ current: false,
+ target: ClientNavigatorTarget::Workspace {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ workspace_id: workspace.workspace_id.clone(),
+ },
+ });
+ if navigator.expanded_workspaces.contains(&key) || filtering {
+ endpoint_rows.extend(children);
+ }
+ }
+ }
+ }
+ if !filtering || endpoint_query_matches || !endpoint_rows.is_empty() {
+ if federated {
+ rows.push(ClientNavigatorRow {
+ depth: 0,
+ label: endpoint.label.to_owned(),
+ meta: String::new(),
+ status: None,
+ stale,
+ current: false,
+ target: ClientNavigatorTarget::Machine {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ },
+ });
+ }
+ rows.extend(endpoint_rows);
+ }
+ }
+ rows
+}
+
+pub(super) fn navigator_selected_index(
+ rows: &[ClientNavigatorRow],
+ navigator: &ClientNavigatorOverlay,
+) -> Option<usize> {
+ match navigator.selected.as_ref() {
+ Some(target) => rows.iter().position(|row| row.target == *target),
+ None => (!rows.is_empty()).then_some(0),
+ }
+}
+
+pub(super) fn selected_navigator_target(
+ rows: &[ClientNavigatorRow],
+ navigator: &ClientNavigatorOverlay,
+) -> Option<ClientNavigatorTarget> {
+ navigator_selected_index(rows, navigator).map(|index| rows[index].target.clone())
+}
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs
index bcbf92fa..a80ebb3f 100644
--- a/src/client/shell/composition.rs
+++ b/src/client/shell/composition.rs
@@ -20,9 +20,95 @@ fn restore_mode_bar(
}
impl ClientShellState {
+ fn compose_unavailable(&mut self, cols: u16, rows: u16) -> FrameData {
+ let layout = self.layout(cols, rows);
+ let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows));
+ buffer.set_style(
+ buffer.area,
+ Style::default()
+ .fg(self.config.palette.text)
+ .bg(self.config.palette.panel_bg),
+ );
+ self.hits = ShellHitMap::default();
+ let sidebar = if layout.sidebar.width > 0 {
+ layout.sidebar
+ } else {
+ Rect::new(0, 1, cols, rows.saturating_sub(2))
+ };
+ super::endpoint_sidebar::render_expanded(
+ &mut buffer,
+ sidebar,
+ self.snapshot.as_deref(),
+ &self.config,
+ &mut render::ShellRenderState {
+ endpoints: &self.endpoints,
+ active_endpoint_id: &self.active_endpoint_id,
+ collapsed_endpoints: &self.collapsed_endpoints,
+ collapsed_groups: &self.collapsed_groups,
+ workspace_scroll: &mut self.workspace_scroll,
+ agent_scroll: &mut self.agent_scroll,
+ tab_scroll: &mut self.tab_scroll,
+ reveal_focused_workspace: &mut self.reveal_focused_workspace,
+ reveal_focused_tab: &mut self.reveal_focused_tab,
+ sidebar_collapsed: false,
+ sidebar_section_split: self.sidebar_section_split,
+ tab_drag_insert_index: None,
+ selected_workspace_id: self.navigate_workspace_id.as_deref(),
+ dragged_workspace_id: None,
+ workspace_drop_indicator_row: None,
+ },
+ &mut self.hits,
+ );
+ if !self.config.mouse_capture {
+ self.hits = ShellHitMap::default();
+ }
+ let message = self.endpoint_error.clone().unwrap_or_else(|| {
+ let status = self
+ .endpoint_status(&self.active_endpoint_id)
+ .unwrap_or(ClientEndpointStatus::Connecting);
+ let (_, label, _) = endpoint_status_presentation(status, &self.config.palette);
+ format!(
+ "{}: {label}. Select a connected machine.",
+ self.active_endpoint_label()
+ )
+ });
+ let message_area = if layout.sidebar.width > 0 {
+ layout.pane_surface
+ } else {
+ Rect::new(0, 0, cols, 1)
+ };
+ render::put_text(
+ &mut buffer,
+ message_area.x,
+ message_area.y,
+ message_area.width,
+ &message,
+ Style::default().fg(self.config.palette.overlay0),
+ );
+ render::render_mode_bar(
+ &mut buffer,
+ Rect::new(0, 0, cols, rows),
+ self.mode,
+ None,
+ self.endpoint_error.as_deref(),
+ false,
+ &self.config.keybinds,
+ &self.config.palette,
+ );
+ FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[])
+ }
+
pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> {
self.last_composed_size = Some((cols, rows));
+ if self.snapshot.is_none() || self.pane_surface.is_none() {
+ return Some(self.compose_unavailable(cols, rows));
+ }
let snapshot = self.snapshot.as_deref()?;
+ // A one-step successor is retained separately until its exact snapshot arrives; do not
+ // keep composing the now-superseded current pair while it is pending.
+ if self.pending_pane_surface.is_some() {
+ return None;
+ }
let surface = self.pane_surface.as_ref()?;
if snapshot.revision != surface.projection_revision {
return None;
@@ -53,6 +139,9 @@ impl ClientShellState {
snapshot,
&self.config,
render::ShellRenderState {
+ endpoints: &self.endpoints,
+ active_endpoint_id: &self.active_endpoint_id,
+ collapsed_endpoints: &self.collapsed_endpoints,
collapsed_groups: &self.collapsed_groups,
workspace_scroll: &mut self.workspace_scroll,
agent_scroll: &mut self.agent_scroll,
@@ -270,7 +359,14 @@ impl ClientShellState {
restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref());
self.hits.notification_toast = Rect::default();
let has_config_diagnostic = self.config_diagnostic.is_some();
+ let active_lifecycle = self
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id)
+ .filter(|endpoint| endpoint.status != ClientEndpointStatus::Online)
+ .map(|endpoint| (endpoint.label.clone(), endpoint.status));
if has_config_diagnostic
+ || active_lifecycle.is_some()
|| self.visible_endpoint_notice.is_some()
|| self.visible_notification.is_some()
{
@@ -289,21 +385,32 @@ impl ClientShellState {
&self.config.palette,
);
}
+ let lifecycle_offset = active_lifecycle.as_ref().map_or(0, |(label, status)| {
+ let _ = endpoint_notices::render_lifecycle_banner(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ label,
+ *status,
+ u16::from(has_config_diagnostic) + layout.mobile_header.height,
+ &self.config.palette,
+ );
+ 1
+ });
if let Some(notice) = self.visible_endpoint_notice.as_ref() {
self.hits.notification_toast = if layout.mobile_header.is_empty() {
- notifications::render_endpoint_notice(
+ endpoint_notices::render_notice(
&mut composed,
Rect::new(0, 0, cols, rows),
notice,
- u16::from(has_config_diagnostic),
+ u16::from(has_config_diagnostic) + lifecycle_offset,
&self.config.palette,
)
} else {
- notifications::render_mobile_endpoint_notice_banner(
+ endpoint_notices::render_mobile_banner(
&mut composed,
Rect::new(0, 0, cols, rows),
notice,
- has_config_diagnostic,
+ has_config_diagnostic || lifecycle_offset > 0,
&self.config.palette,
)
};
@@ -314,7 +421,7 @@ impl ClientShellState {
Rect::new(0, 0, cols, rows),
notification,
self.config.toast_position,
- u16::from(has_config_diagnostic),
+ u16::from(has_config_diagnostic) + lifecycle_offset,
&self.config.palette,
)
} else {
@@ -322,7 +429,7 @@ impl ClientShellState {
&mut composed,
Rect::new(0, 0, cols, rows),
notification,
- has_config_diagnostic,
+ has_config_diagnostic || lifecycle_offset > 0,
&self.config.palette,
)
};
@@ -399,12 +506,33 @@ impl ClientShellState {
&mut composed,
Rect::new(0, 0, cols, rows),
snapshot,
+ &self.endpoints,
+ &self.active_endpoint_id,
&self.config,
self.navigate_workspace_id.as_deref(),
&mut self.mobile_switcher_scroll,
&mut self.reveal_mobile_workspace,
&mut self.hits,
);
+ if let Some((label, status)) = active_lifecycle.as_ref() {
+ let _ = endpoint_notices::render_lifecycle_banner(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ label,
+ *status,
+ 2,
+ &self.config.palette,
+ );
+ }
+ if let Some(notice) = self.visible_endpoint_notice.as_ref() {
+ self.hits.notification_toast = endpoint_notices::render_mobile_banner(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ notice,
+ active_lifecycle.is_some(),
+ &self.config.palette,
+ );
+ }
frame.replace_from_ratatui_buffer_preserving_effects(&composed, None);
self.hits.panes.clear();
self.hits.pane_splits.clear();
@@ -431,6 +559,8 @@ impl ClientShellState {
&mut composed,
overlay,
snapshot,
+ &self.endpoints,
+ &self.active_endpoint_id,
&self.config.keybinds,
&self.config.palette,
)?;
@@ -474,6 +604,12 @@ impl ClientShellState {
.scroll
.min(u16::try_from(self.hits.release_notes_max_scroll).unwrap_or(u16::MAX));
}
+ if self.endpoint_status(&self.active_endpoint_id) != Some(ClientEndpointStatus::Online) {
+ frame.cursor = None;
+ self.hits.panes.clear();
+ self.hits.pane_splits.clear();
+ self.hits.popup = None;
+ }
self.compose_graphics(&mut frame, layout);
Some(frame)
}
diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs
new file mode 100644
index 00000000..fdcd7478
--- /dev/null
+++ b/src/client/shell/endpoint_agent_state.rs
@@ -0,0 +1,263 @@
+use std::collections::HashMap;
+
+use crate::api::schema::AgentStatus;
+use crate::protocol::{ClientShellAgent, ClientShellSnapshot, PaneSurfaceFrame};
+
+#[derive(Clone, Debug, Default)]
+pub(super) struct EndpointAgentPresentation {
+ boot_id: Option<String>,
+ acknowledged: HashMap<String, u64>,
+}
+
+impl EndpointAgentPresentation {
+ pub(super) fn project_snapshot(&mut self, snapshot: &mut ClientShellSnapshot) {
+ if self.boot_id.as_deref() != Some(snapshot.boot_id.as_str()) {
+ self.boot_id = Some(snapshot.boot_id.clone());
+ self.acknowledged.clear();
+ self.acknowledged.extend(
+ snapshot
+ .agents
+ .iter()
+ .map(|agent| (agent.pane_id.clone(), agent.state_change_seq)),
+ );
+ }
+ self.acknowledged.retain(|pane_id, _| {
+ snapshot
+ .agents
+ .iter()
+ .any(|agent| &agent.pane_id == pane_id)
+ });
+ for agent in &mut snapshot.agents {
+ agent.agent_status = self.projected_status(agent);
+ }
+ project_aggregate_status(snapshot);
+ }
+
+ pub(super) fn acknowledge_surface(
+ &mut self,
+ snapshot: &mut ClientShellSnapshot,
+ surface: &PaneSurfaceFrame,
+ outer_focused: Option<bool>,
+ ) -> bool {
+ if outer_focused == Some(false)
+ || self.boot_id.as_deref() != Some(surface.boot_id.as_str())
+ || snapshot.boot_id != surface.boot_id
+ || snapshot.revision != surface.projection_revision
+ {
+ return false;
+ }
+
+ let mut changed = false;
+ for pane in &surface.panes {
+ let Some(agent) = snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane.pane_id)
+ else {
+ continue;
+ };
+ let acknowledged = self.acknowledged.entry(agent.pane_id.clone()).or_default();
+ if *acknowledged < agent.state_change_seq {
+ *acknowledged = agent.state_change_seq;
+ changed = true;
+ }
+ }
+ if changed {
+ for agent in &mut snapshot.agents {
+ agent.agent_status = self.projected_status(agent);
+ }
+ project_aggregate_status(snapshot);
+ }
+ changed
+ }
+
+ fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus {
+ match agent.agent_status {
+ AgentStatus::Idle | AgentStatus::Done => {
+ if self
+ .acknowledged
+ .get(&agent.pane_id)
+ .is_some_and(|sequence| *sequence >= agent.state_change_seq)
+ {
+ AgentStatus::Idle
+ } else {
+ AgentStatus::Done
+ }
+ }
+ status => status,
+ }
+ }
+}
+
+fn project_aggregate_status(snapshot: &mut ClientShellSnapshot) {
+ for tab in &mut snapshot.tabs {
+ if let Some(status) = snapshot
+ .agents
+ .iter()
+ .filter(|agent| agent.tab_id == tab.tab_id)
+ .map(|agent| agent.agent_status)
+ .max_by_key(|status| super::status_priority(*status))
+ {
+ tab.agent_status = status;
+ }
+ }
+ for workspace in &mut snapshot.workspaces {
+ if let Some(status) = snapshot
+ .agents
+ .iter()
+ .filter(|agent| agent.workspace_id == workspace.workspace_id)
+ .map(|agent| agent.agent_status)
+ .max_by_key(|status| super::status_priority(*status))
+ {
+ workspace.agent_status = status;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::protocol::{FrameData, PaneSurfacePane, SurfaceRect};
+
+ fn agent(status: AgentStatus, sequence: u64) -> ClientShellAgent {
+ ClientShellAgent {
+ pane_id: "agent-pane".into(),
+ workspace_id: "workspace".into(),
+ tab_id: "tab".into(),
+ name: None,
+ display_agent: None,
+ agent: None,
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: status,
+ state_change_seq: sequence,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: true,
+ }
+ }
+
+ fn snapshot(status: AgentStatus, sequence: u64, revision: u64) -> ClientShellSnapshot {
+ let mut snapshot = crate::client::shell::tests::snapshot();
+ snapshot.boot_id = "endpoint-boot".into();
+ snapshot.revision = revision;
+ snapshot.agents = vec![agent(status, sequence)];
+ snapshot
+ }
+
+ fn surface(revision: u64) -> PaneSurfaceFrame {
+ let rect = SurfaceRect {
+ x: 0,
+ y: 0,
+ width: 1,
+ height: 1,
+ };
+ PaneSurfaceFrame {
+ boot_id: "endpoint-boot".into(),
+ projection_revision: revision,
+ surface_revision: 1,
+ frame: FrameData {
+ cells: Vec::new(),
+ width: 0,
+ height: 0,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ },
+ panes: vec![PaneSurfacePane {
+ pane_id: "agent-pane".into(),
+ content_revision: 1,
+ rect,
+ inner_rect: rect,
+ scrollbar_rect: None,
+ scroll: None,
+ focused: true,
+ mouse_reporting: false,
+ sgr_pixel_mouse: false,
+ alternate_screen_active: false,
+ pixel_width: 0,
+ pixel_height: 0,
+ }],
+ splits: Vec::new(),
+ popup: None,
+ graphics: Default::default(),
+ }
+ }
+
+ #[test]
+ fn first_snapshot_establishes_an_idle_baseline_without_server_seen_authority() {
+ let mut presentation = EndpointAgentPresentation::default();
+ let mut snapshot = snapshot(AgentStatus::Done, 4, 1);
+
+ presentation.project_snapshot(&mut snapshot);
+
+ assert_eq!(snapshot.agents[0].agent_status, AgentStatus::Idle);
+ }
+
+ #[test]
+ fn unpresented_working_completion_projects_done() {
+ let mut presentation = EndpointAgentPresentation::default();
+ let mut initial = snapshot(AgentStatus::Working, 4, 1);
+ presentation.project_snapshot(&mut initial);
+ let mut completed = snapshot(AgentStatus::Idle, 5, 2);
+
+ presentation.project_snapshot(&mut completed);
+
+ assert_eq!(completed.agents[0].agent_status, AgentStatus::Done);
+ }
+
+ #[test]
+ fn coherent_presented_surface_acknowledges_completion() {
+ let mut presentation = EndpointAgentPresentation::default();
+ let mut initial = snapshot(AgentStatus::Working, 4, 1);
+ presentation.project_snapshot(&mut initial);
+ let mut completed = snapshot(AgentStatus::Idle, 5, 2);
+ presentation.project_snapshot(&mut completed);
+
+ assert!(presentation.acknowledge_surface(&mut completed, &surface(2), Some(true)));
+ assert_eq!(completed.agents[0].agent_status, AgentStatus::Idle);
+ }
+
+ #[test]
+ fn clients_acknowledge_the_same_endpoint_completion_independently() {
+ let mut viewing_client = EndpointAgentPresentation::default();
+ let mut background_client = EndpointAgentPresentation::default();
+ let mut initial_for_viewer = snapshot(AgentStatus::Working, 4, 1);
+ let mut initial_for_background = initial_for_viewer.clone();
+ viewing_client.project_snapshot(&mut initial_for_viewer);
+ background_client.project_snapshot(&mut initial_for_background);
+ let mut completed_for_viewer = snapshot(AgentStatus::Idle, 5, 2);
+ let mut completed_for_background = completed_for_viewer.clone();
+ viewing_client.project_snapshot(&mut completed_for_viewer);
+ background_client.project_snapshot(&mut completed_for_background);
+
+ assert!(viewing_client.acknowledge_surface(
+ &mut completed_for_viewer,
+ &surface(2),
+ Some(true)
+ ));
+
+ assert_eq!(
+ completed_for_viewer.agents[0].agent_status,
+ AgentStatus::Idle
+ );
+ assert_eq!(
+ completed_for_background.agents[0].agent_status,
+ AgentStatus::Done
+ );
+ }
+
+ #[test]
+ fn stale_or_unfocused_surface_does_not_acknowledge_completion() {
+ let mut presentation = EndpointAgentPresentation::default();
+ let mut initial = snapshot(AgentStatus::Working, 4, 1);
+ presentation.project_snapshot(&mut initial);
+ let mut completed = snapshot(AgentStatus::Idle, 5, 2);
+ presentation.project_snapshot(&mut completed);
+
+ assert!(!presentation.acknowledge_surface(&mut completed, &surface(1), Some(true)));
+ assert!(!presentation.acknowledge_surface(&mut completed, &surface(2), Some(false)));
+ assert_eq!(completed.agents[0].agent_status, AgentStatus::Done);
+ }
+}
diff --git a/src/client/shell/endpoint_agents.rs b/src/client/shell/endpoint_agents.rs
new file mode 100644
index 00000000..a49fccf9
--- /dev/null
+++ b/src/client/shell/endpoint_agents.rs
@@ -0,0 +1,129 @@
+use super::render::put_text;
+use super::*;
+
+pub(super) fn render_collapsed(
+ buffer: &mut Buffer,
+ area: Rect,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
+ config: &ClientShellConfig,
+ hits: &mut ShellHitMap,
+) {
+ let rows = agent_rows(endpoints, active_endpoint_id, config);
+ for (index, row) in rows.into_iter().take(area.height as usize).enumerate() {
+ let rect = Rect::new(area.x, area.y + index as u16, area.width, 1);
+ if row.agent.focused {
+ buffer.set_style(rect, Style::default().bg(config.palette.active_row_bg));
+ }
+ let initial = row.machine_label.chars().next().unwrap_or('?');
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ rect.width,
+ &format!(
+ "{initial}{}",
+ status_icon(row.agent.status, config.status_indicators)
+ ),
+ Style::default()
+ .fg(if row.stale {
+ config.palette.overlay0
+ } else {
+ status_color(row.agent.status, &config.palette)
+ })
+ .add_modifier(if row.stale {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ }),
+ );
+ hits.endpoint_agents
+ .push((rect, row.endpoint_id, row.agent.pane_id));
+ }
+}
+
+pub(super) fn render_expanded(
+ buffer: &mut Buffer,
+ area: Rect,
+ agent_view_label: Option<&str>,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
+ config: &ClientShellConfig,
+ agent_scroll: &mut usize,
+ hits: &mut ShellHitMap,
+) {
+ if !super::agent_sidebar::render_agent_panel_header(
+ buffer,
+ area,
+ agent_view_label,
+ config,
+ hits,
+ ) {
+ return;
+ }
+ let rows = agent_rows(endpoints, active_endpoint_id, config);
+ super::agent_sidebar::render_agent_list(
+ buffer,
+ area,
+ &rows,
+ agent_view_label.map(|_| " no matching agents"),
+ config,
+ agent_scroll,
+ hits,
+ |row| row.agent.rows.len(),
+ |buffer, rect, row, hits| {
+ super::agent_sidebar::render_agent_row(buffer, rect, &row.agent, config);
+ if row.stale {
+ buffer.set_style(
+ rect,
+ Style::default()
+ .fg(config.palette.overlay0)
+ .add_modifier(Modifier::DIM),
+ );
+ }
+ hits.endpoint_agents
+ .push((rect, row.endpoint_id.clone(), row.agent.pane_id.clone()));
+ },
+ );
+}
+
+struct EndpointAgentRow {
+ endpoint_id: ClientEndpointId,
+ machine_label: String,
+ stale: bool,
+ agent: super::agent_sidebar::AgentRow,
+}
+
+fn agent_rows(
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
+ config: &ClientShellConfig,
+) -> Vec<EndpointAgentRow> {
+ let mut rendered_rows = endpoints
+ .iter()
+ .filter_map(|endpoint| {
+ endpoint.snapshot.as_deref().map(|snapshot| {
+ super::agent_sidebar::agent_rows(snapshot, config, Some(&endpoint.label))
+ .into_iter()
+ .map(|agent| ((endpoint.endpoint_id.clone(), agent.pane_id.clone()), agent))
+ .collect::<Vec<_>>()
+ })
+ })
+ .flatten()
+ .collect::<HashMap<_, _>>();
+
+ super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort)
+ .into_iter()
+ .filter_map(|row| {
+ let key = (row.endpoint.endpoint_id.clone(), row.agent.pane_id.clone());
+ let mut agent = rendered_rows.remove(&key)?;
+ agent.focused &= row.endpoint.endpoint_id == active_endpoint_id;
+ Some(EndpointAgentRow {
+ endpoint_id: row.endpoint.endpoint_id.clone(),
+ machine_label: row.endpoint.label.to_owned(),
+ stale: row.endpoint.stale(),
+ agent,
+ })
+ })
+ .collect()
+}
diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs
new file mode 100644
index 00000000..adb29c93
--- /dev/null
+++ b/src/client/shell/endpoint_navigation.rs
@@ -0,0 +1,290 @@
+use super::*;
+
+impl ClientShellState {
+ pub(super) fn active_endpoint_workspace_at(&self, point: (u16, u16)) -> Option<String> {
+ self.hits
+ .workspaces
+ .iter()
+ .find(|hit| {
+ hit.endpoint_id == self.active_endpoint_id && super::contains(hit.rect, point)
+ })
+ .map(|hit| hit.workspace_id.clone())
+ }
+
+ pub(super) fn endpoint_workspace_is_draggable(&self, press: &ClientWorkspacePress) -> bool {
+ press.endpoint_id == self.active_endpoint_id
+ && self
+ .snapshot
+ .as_deref()
+ .and_then(|snapshot| {
+ snapshot
+ .workspaces
+ .iter()
+ .find(|workspace| workspace.workspace_id == press.workspace_id)
+ })
+ .is_some_and(|workspace| {
+ !workspace
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| worktree.is_linked_worktree)
+ })
+ }
+
+ pub(super) fn finish_endpoint_workspace_press(
+ &mut self,
+ press: ClientWorkspacePress,
+ outcome: &mut ClientShellInput,
+ ) {
+ if press.endpoint_id == self.active_endpoint_id {
+ self.push_endpoint_method(
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id: press.workspace_id,
+ }),
+ outcome,
+ );
+ } else if self.endpoint_is_online(&press.endpoint_id) {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id: press.endpoint_id,
+ target: Some(ClientEndpointFocusTarget::Workspace(press.workspace_id)),
+ });
+ } else {
+ let label = self.endpoint_label(&press.endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ outcome.repaint = true;
+ }
+ }
+
+ pub(super) fn handle_endpoint_machine_click(
+ &mut self,
+ point: (u16, u16),
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ let Some(endpoint_id) = self
+ .hits
+ .machines
+ .iter()
+ .find(|hit| super::contains(hit.rect, point))
+ .map(|hit| hit.endpoint_id.clone())
+ else {
+ return false;
+ };
+ if endpoint_id == self.active_endpoint_id {
+ if !self.collapsed_endpoints.remove(&endpoint_id) {
+ self.collapsed_endpoints.insert(endpoint_id);
+ }
+ outcome.repaint = true;
+ } else if self.endpoint_is_online(&endpoint_id) {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: None,
+ });
+ } else {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ outcome.repaint = true;
+ }
+ true
+ }
+
+ pub(super) fn handle_endpoint_agent_click(
+ &mut self,
+ point: (u16, u16),
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ let Some((endpoint_id, pane_id)) = self
+ .hits
+ .endpoint_agents
+ .iter()
+ .find(|(rect, _, _)| super::contains(*rect, point))
+ .map(|(_, endpoint_id, pane_id)| (endpoint_id.clone(), pane_id.clone()))
+ else {
+ return false;
+ };
+ if !self.endpoint_is_online(&endpoint_id) {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is reconnecting"));
+ outcome.repaint = true;
+ } else if endpoint_id == self.active_endpoint_id {
+ self.push_endpoint_method(
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }),
+ outcome,
+ );
+ } else {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ });
+ }
+ true
+ }
+
+ pub(super) fn handle_endpoint_navigation(
+ &mut self,
+ action: crate::input::KeybindAction,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ use crate::input::KeybindAction;
+ if !self.multi_endpoint_active() {
+ return false;
+ }
+ if matches!(
+ action,
+ KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace
+ ) {
+ let workspaces = self
+ .endpoints
+ .iter()
+ .filter(|endpoint| endpoint.status == ClientEndpointStatus::Online)
+ .flat_map(|endpoint| {
+ endpoint
+ .snapshot
+ .as_deref()
+ .map_or_else(Vec::new, |snapshot| {
+ render::workspace_entries(snapshot, &HashSet::new())
+ .into_iter()
+ .filter_map(|entry| {
+ snapshot.workspaces.get(entry.index).map(|workspace| {
+ (
+ endpoint.endpoint_id.clone(),
+ workspace.workspace_id.clone(),
+ )
+ })
+ })
+ .collect()
+ })
+ })
+ .collect::<Vec<_>>();
+ if workspaces.is_empty() {
+ return true;
+ }
+ let focused = self
+ .snapshot
+ .as_deref()
+ .and_then(|snapshot| snapshot.focused_workspace_id.as_deref());
+ let current = workspaces.iter().position(|(endpoint_id, workspace_id)| {
+ endpoint_id == &self.active_endpoint_id && Some(workspace_id.as_str()) == focused
+ });
+ let next = match (current, action) {
+ (Some(index), KeybindAction::PreviousWorkspace) => {
+ (index + workspaces.len() - 1) % workspaces.len()
+ }
+ (Some(index), KeybindAction::NextWorkspace) => (index + 1) % workspaces.len(),
+ (None, KeybindAction::PreviousWorkspace) => workspaces.len() - 1,
+ (None, KeybindAction::NextWorkspace) => 0,
+ _ => unreachable!("endpoint workspace navigation"),
+ };
+ let (endpoint_id, workspace_id) = workspaces[next].clone();
+ self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Workspace(workspace_id),
+ outcome,
+ );
+ return true;
+ }
+ if matches!(
+ action,
+ KeybindAction::PreviousAgent | KeybindAction::NextAgent | KeybindAction::FocusAgent(_)
+ ) {
+ let agents = super::aggregate_navigation::online_agent_targets(
+ &self.endpoints,
+ self.config.agent_panel_sort,
+ );
+ if agents.is_empty() {
+ return true;
+ }
+ let next = match action {
+ KeybindAction::FocusAgent(index) => {
+ if index >= agents.len() {
+ return true;
+ }
+ index
+ }
+ KeybindAction::PreviousAgent | KeybindAction::NextAgent => {
+ let focused = self
+ .snapshot
+ .as_deref()
+ .and_then(|snapshot| snapshot.focused_pane_id.as_deref());
+ let current = agents.iter().position(|target| {
+ target.endpoint_id == self.active_endpoint_id
+ && Some(target.pane_id.as_str()) == focused
+ });
+ match (current, action) {
+ (Some(index), KeybindAction::PreviousAgent) => {
+ (index + agents.len() - 1) % agents.len()
+ }
+ (Some(index), KeybindAction::NextAgent) => (index + 1) % agents.len(),
+ (None, KeybindAction::PreviousAgent) => agents.len() - 1,
+ _ => 0,
+ }
+ }
+ _ => unreachable!("endpoint agent navigation"),
+ };
+ let target = &agents[next];
+ self.focus_or_activate(
+ target.endpoint_id.clone(),
+ ClientEndpointFocusTarget::Pane(target.pane_id.clone()),
+ outcome,
+ );
+ return true;
+ }
+ false
+ }
+
+ pub(super) fn activate_endpoint(
+ &mut self,
+ endpoint_id: ClientEndpointId,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ if !self.endpoint_is_online(&endpoint_id) {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ outcome.repaint = true;
+ return false;
+ }
+ if endpoint_id != self.active_endpoint_id {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: None,
+ });
+ }
+ true
+ }
+
+ pub(super) fn focus_or_activate(
+ &mut self,
+ endpoint_id: ClientEndpointId,
+ target: ClientEndpointFocusTarget,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ if !self.endpoint_is_online(&endpoint_id) {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ outcome.repaint = true;
+ return false;
+ }
+ if endpoint_id == self.active_endpoint_id {
+ let method = match target {
+ ClientEndpointFocusTarget::Workspace(workspace_id) => {
+ crate::api::schema::Method::WorkspaceFocus(
+ crate::api::schema::WorkspaceTarget { workspace_id },
+ )
+ }
+ ClientEndpointFocusTarget::Tab(tab_id) => {
+ crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id })
+ }
+ ClientEndpointFocusTarget::Pane(pane_id) => {
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget {
+ pane_id,
+ })
+ }
+ };
+ self.push_endpoint_method(method, outcome);
+ } else {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: Some(target),
+ });
+ }
+ true
+ }
+}
diff --git a/src/client/shell/endpoint_notices.rs b/src/client/shell/endpoint_notices.rs
new file mode 100644
index 00000000..e796d2c5
--- /dev/null
+++ b/src/client/shell/endpoint_notices.rs
@@ -0,0 +1,80 @@
+use super::*;
+use ratatui::widgets::{Clear, Widget};
+
+pub(super) fn render_mobile_banner(
+ buffer: &mut Buffer,
+ area: Rect,
+ notice: &ClientVisibleEndpointNotice,
+ offset_for_warning: bool,
+ palette: &Palette,
+) -> Rect {
+ super::notifications::render_mobile_notice_banner(
+ buffer,
+ area,
+ &notice.title,
+ Some(&notice.body),
+ palette.red,
+ offset_for_warning,
+ palette,
+ )
+}
+
+pub(super) fn render_lifecycle_banner(
+ buffer: &mut Buffer,
+ area: Rect,
+ label: &str,
+ status: ClientEndpointStatus,
+ top_offset: u16,
+ palette: &Palette,
+) -> Rect {
+ if area.is_empty() || status == ClientEndpointStatus::Online {
+ return Rect::default();
+ }
+ let (symbol, state, color) = endpoint_status_presentation(status, palette);
+ let text = format!("{symbol} {label} · {state}");
+ let width = u16::try_from(unicode_width::UnicodeWidthStr::width(text.as_str()) + 2)
+ .unwrap_or(u16::MAX)
+ .min(area.width);
+ let y = area
+ .y
+ .saturating_add(top_offset)
+ .min(area.bottom().saturating_sub(1));
+ let rect = Rect::new(area.right().saturating_sub(width), y, width, 1);
+ Clear.render(rect, buffer);
+ buffer.set_style(rect, Style::default().bg(palette.surface0));
+ super::render::put_text(
+ buffer,
+ rect.x.saturating_add(1),
+ rect.y,
+ rect.width.saturating_sub(2),
+ &text,
+ Style::default().fg(color).bg(palette.surface0),
+ );
+ rect
+}
+
+pub(super) fn render_notice(
+ buffer: &mut Buffer,
+ area: Rect,
+ notice: &ClientVisibleEndpointNotice,
+ top_offset: u16,
+ palette: &Palette,
+) -> Rect {
+ super::notifications::render_notification_card(
+ buffer,
+ area,
+ &notice.title,
+ &notice.body,
+ crate::config::ToastHerdrPosition::TopRight,
+ top_offset,
+ match notice.key.kind {
+ ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => {
+ palette.red
+ }
+ ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => {
+ palette.yellow
+ }
+ },
+ palette,
+ )
+}
diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs
new file mode 100644
index 00000000..51c940cd
--- /dev/null
+++ b/src/client/shell/endpoint_sidebar.rs
@@ -0,0 +1,452 @@
+use super::render::{display_width, put_right_text, put_text, ShellRenderState};
+use super::*;
+
+pub(super) fn render_collapsed(
+ buffer: &mut Buffer,
+ area: Rect,
+ config: &ClientShellConfig,
+ state: &mut ShellRenderState<'_>,
+ hits: &mut ShellHitMap,
+) {
+ let palette = &config.palette;
+ super::render::render_sidebar_background(buffer, area, palette);
+ let (workspace_area, divider_y, detail_area) = super::sidebar::collapsed_sidebar_sections(area);
+ let mut y = workspace_area.y;
+ for (index, endpoint) in state.endpoints.iter().enumerate() {
+ if y >= workspace_area.bottom() {
+ break;
+ }
+ let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1);
+ let active = &endpoint.endpoint_id == state.active_endpoint_id;
+ let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id);
+ if active && collapsed {
+ buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ }
+ let label = if endpoint.endpoint_id.is_local() {
+ "L".to_owned()
+ } else {
+ (index + 1).to_string()
+ };
+ let marker = if collapsed { "▸" } else { "▾" };
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ rect.width.saturating_sub(1),
+ &format!("{marker}{label}"),
+ Style::default().fg(if endpoint.status == ClientEndpointStatus::Online {
+ palette.text
+ } else {
+ palette.overlay0
+ }),
+ );
+ if !endpoint.endpoint_id.is_local() {
+ let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette);
+ put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color));
+ }
+ hits.machines.push(MachineHit {
+ rect,
+ endpoint_id: endpoint.endpoint_id.clone(),
+ });
+ y = y.saturating_add(1);
+ if collapsed {
+ continue;
+ }
+ let Some(snapshot) = endpoint.snapshot.as_deref() else {
+ continue;
+ };
+ for workspace in &snapshot.workspaces {
+ if y >= workspace_area.bottom() {
+ break;
+ }
+ let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1);
+ let focused = active && workspace.focused;
+ if focused {
+ buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ }
+ let stale = endpoint.status != ClientEndpointStatus::Online;
+ let number = format!(" {}", workspace.number);
+ let number_width = super::render::display_width(&number).min(rect.width);
+ let dim = if stale {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ };
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ number_width,
+ &number,
+ Style::default()
+ .fg(if focused && !stale {
+ palette.text
+ } else {
+ palette.overlay0
+ })
+ .add_modifier(dim),
+ );
+ put_text(
+ buffer,
+ rect.x.saturating_add(number_width),
+ rect.y,
+ rect.width.saturating_sub(number_width),
+ status_icon(workspace.agent_status, config.status_indicators),
+ Style::default()
+ .fg(if stale {
+ palette.overlay0
+ } else {
+ status_color(workspace.agent_status, palette)
+ })
+ .add_modifier(dim),
+ );
+ hits.workspaces.push(WorkspaceHit {
+ rect,
+ endpoint_id: endpoint.endpoint_id.clone(),
+ workspace_id: workspace.workspace_id.clone(),
+ indented: false,
+ group_toggle: None,
+ });
+ y = y.saturating_add(1);
+ }
+ }
+ if let Some(divider_y) = divider_y {
+ put_text(
+ buffer,
+ workspace_area.x,
+ divider_y,
+ workspace_area.width,
+ &"─".repeat(workspace_area.width as usize),
+ Style::default().fg(palette.surface_dim),
+ );
+ }
+ super::endpoint_agents::render_collapsed(
+ buffer,
+ detail_area,
+ state.endpoints,
+ state.active_endpoint_id,
+ config,
+ hits,
+ );
+ hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 {
+ Rect::default()
+ } else {
+ Rect::new(
+ workspace_area.x + workspace_area.width / 2,
+ area.bottom().saturating_sub(1),
+ 1,
+ 1,
+ )
+ };
+ put_text(
+ buffer,
+ hits.sidebar_toggle.x,
+ hits.sidebar_toggle.y,
+ hits.sidebar_toggle.width,
+ "»",
+ Style::default().fg(palette.overlay0),
+ );
+}
+
+pub(super) fn render_expanded(
+ buffer: &mut Buffer,
+ area: Rect,
+ active_snapshot: Option<&ClientShellSnapshot>,
+ config: &ClientShellConfig,
+ state: &mut ShellRenderState<'_>,
+ hits: &mut ShellHitMap,
+) {
+ let palette = &config.palette;
+ super::render::render_sidebar_background(buffer, area, palette);
+ hits.sidebar_divider = if area.is_empty() {
+ Rect::default()
+ } else {
+ Rect::new(area.right().saturating_sub(1), area.y, 1, area.height)
+ };
+ let (workspace_area, detail_area) =
+ crate::ui::expanded_sidebar_sections(area, state.sidebar_section_split);
+ hits.sidebar_section_divider =
+ crate::ui::sidebar_section_divider_rect(area, state.sidebar_section_split);
+ put_text(
+ buffer,
+ workspace_area.x,
+ workspace_area.y,
+ workspace_area.width,
+ " machines",
+ Style::default()
+ .fg(palette.overlay0)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ enum Row {
+ Endpoint(usize),
+ Workspace {
+ endpoint: usize,
+ entry: WorkspaceEntry,
+ },
+ }
+ let mut rows = Vec::new();
+ for (endpoint_index, endpoint) in state.endpoints.iter().enumerate() {
+ rows.push(Row::Endpoint(endpoint_index));
+ if state.collapsed_endpoints.contains(&endpoint.endpoint_id) {
+ continue;
+ }
+ if let Some(snapshot) = endpoint.snapshot.as_deref() {
+ rows.extend(
+ super::sidebar::workspace_entries(snapshot, &HashSet::new())
+ .into_iter()
+ .map(|entry| Row::Workspace {
+ endpoint: endpoint_index,
+ entry,
+ }),
+ );
+ }
+ }
+ let body = Rect::new(
+ workspace_area.x,
+ workspace_area.y.saturating_add(WORKSPACE_HEADER_ROWS),
+ workspace_area.width,
+ workspace_area
+ .height
+ .saturating_sub(WORKSPACE_HEADER_ROWS + 1),
+ );
+ hits.workspace_body = body;
+ let row_heights = rows
+ .iter()
+ .map(|row| match row {
+ Row::Endpoint(_) => 1,
+ Row::Workspace { endpoint, entry } => state.endpoints[*endpoint]
+ .snapshot
+ .as_deref()
+ .and_then(|snapshot| snapshot.workspaces.get(entry.index))
+ .map(|workspace| {
+ super::sidebar::workspace_rows(
+ workspace,
+ workspace.agent_status,
+ entry.indented,
+ &config.spaces,
+ )
+ .len()
+ .max(1)
+ .min(u16::MAX as usize) as u16
+ })
+ .unwrap_or(1),
+ })
+ .collect::<Vec<_>>();
+ let gaps = vec![0; rows.len()];
+ let metrics = super::scroll::list_scroll_metrics(
+ &row_heights,
+ &gaps,
+ body.height,
+ *state.workspace_scroll,
+ );
+ hits.workspace_max_scroll = metrics.max_offset_from_bottom;
+ hits.workspace_scroll_metrics = Some(metrics);
+ *state.workspace_scroll = metrics
+ .max_offset_from_bottom
+ .saturating_sub(metrics.offset_from_bottom);
+ let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1;
+ let content_width = body.width.saturating_sub(u16::from(show_scrollbar));
+ let mut y = body.y;
+ for row in rows.iter().skip(*state.workspace_scroll) {
+ match row {
+ Row::Endpoint(index) => {
+ if y >= body.bottom() {
+ break;
+ }
+ let endpoint = &state.endpoints[*index];
+ let rect = Rect::new(body.x, y, content_width, 1);
+ let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id);
+ let marker = if collapsed { "▸" } else { "▾" };
+ render_endpoint_row(
+ buffer,
+ rect,
+ marker,
+ endpoint,
+ collapsed && &endpoint.endpoint_id == state.active_endpoint_id,
+ palette,
+ );
+ hits.machines.push(MachineHit {
+ rect,
+ endpoint_id: endpoint.endpoint_id.clone(),
+ });
+ y = y.saturating_add(1);
+ }
+ Row::Workspace { endpoint, entry } => {
+ let endpoint = &state.endpoints[*endpoint];
+ let Some(snapshot) = endpoint.snapshot.as_deref() else {
+ continue;
+ };
+ let Some(workspace) = snapshot.workspaces.get(entry.index) else {
+ continue;
+ };
+ let tokens = super::sidebar::workspace_rows(
+ workspace,
+ workspace.agent_status,
+ entry.indented,
+ &config.spaces,
+ );
+ let height = (tokens.len().max(1).min(u16::MAX as usize) as u16).min(body.height);
+ if y.saturating_add(height) > body.bottom() {
+ break;
+ }
+ let rect = Rect::new(body.x, y, content_width, height);
+ let nested = Rect::new(
+ rect.x.saturating_add(2),
+ rect.y,
+ rect.width.saturating_sub(2),
+ rect.height,
+ );
+ let endpoint_active = &endpoint.endpoint_id == state.active_endpoint_id;
+ super::sidebar::render_workspace_rows(
+ buffer,
+ nested,
+ workspace,
+ workspace.agent_status,
+ config.status_indicators,
+ entry,
+ tokens,
+ endpoint_active,
+ false,
+ false,
+ palette,
+ );
+ if endpoint.status != ClientEndpointStatus::Online {
+ buffer.set_style(
+ rect,
+ Style::default()
+ .fg(palette.overlay0)
+ .add_modifier(Modifier::DIM),
+ );
+ }
+ hits.workspaces.push(WorkspaceHit {
+ rect,
+ endpoint_id: endpoint.endpoint_id.clone(),
+ workspace_id: workspace.workspace_id.clone(),
+ indented: entry.indented,
+ group_toggle: None,
+ });
+ y = y.saturating_add(height);
+ }
+ }
+ }
+ if show_scrollbar {
+ let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height);
+ hits.workspace_scrollbar = track;
+ super::scroll::render_list_scrollbar(buffer, track, metrics, palette);
+ }
+
+ let footer_y = workspace_area.bottom().saturating_sub(1);
+ if config.mouse_capture {
+ let label = format!(" new · {}", active_endpoint_label(state));
+ hits.new_workspace = Rect::new(
+ workspace_area.x,
+ footer_y,
+ display_width(&label).min(workspace_area.width),
+ u16::from(workspace_area.height > 0),
+ );
+ put_text(
+ buffer,
+ workspace_area.x,
+ footer_y,
+ workspace_area.width,
+ &label,
+ Style::default().fg(palette.overlay0),
+ );
+ let attention = active_snapshot.is_some_and(super::global_menu::global_menu_attention);
+ let width = if attention { 8 } else { 6 }.min(workspace_area.width);
+ hits.global_launcher = Rect::new(
+ workspace_area.right().saturating_sub(width),
+ footer_y,
+ width,
+ 1,
+ );
+ put_right_text(
+ buffer,
+ workspace_area,
+ footer_y,
+ if attention { "● menu" } else { "menu" },
+ Style::default().fg(if attention {
+ palette.accent
+ } else {
+ palette.overlay0
+ }),
+ );
+ }
+ super::endpoint_agents::render_expanded(
+ buffer,
+ detail_area,
+ active_snapshot.and_then(|snapshot| snapshot.agent_view_label.as_deref()),
+ state.endpoints,
+ state.active_endpoint_id,
+ config,
+ state.agent_scroll,
+ hits,
+ );
+ hits.sidebar_toggle = Rect::new(
+ area.right().saturating_sub(2),
+ area.bottom().saturating_sub(1),
+ u16::from(area.width > 1),
+ u16::from(area.height > 0),
+ );
+ put_text(
+ buffer,
+ hits.sidebar_toggle.x,
+ hits.sidebar_toggle.y,
+ hits.sidebar_toggle.width,
+ "«",
+ Style::default().fg(palette.overlay0),
+ );
+}
+
+fn active_endpoint_label<'a>(state: &'a ShellRenderState<'_>) -> &'a str {
+ state
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == state.active_endpoint_id)
+ .map_or("Local", |endpoint| endpoint.label.as_str())
+}
+
+fn render_endpoint_row(
+ buffer: &mut Buffer,
+ rect: Rect,
+ marker: &str,
+ endpoint: &ClientShellEndpoint,
+ highlighted: bool,
+ palette: &Palette,
+) {
+ if highlighted {
+ buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ }
+ let (glyph, state, color) = endpoint_status_presentation(endpoint.status, palette);
+ let state = if endpoint.status == ClientEndpointStatus::Online {
+ ""
+ } else {
+ state
+ };
+ let signal = if endpoint.endpoint_id.is_local() {
+ String::new()
+ } else if state.is_empty() {
+ glyph.to_owned()
+ } else {
+ format!("{glyph} {state}")
+ };
+ let signal_width = display_width(&signal).min(rect.width);
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ rect.width.saturating_sub(signal_width.saturating_add(1)),
+ &format!(" {marker} {}", endpoint.label),
+ Style::default()
+ .fg(
+ if matches!(endpoint.status, ClientEndpointStatus::Disabled) {
+ palette.overlay0
+ } else {
+ palette.text
+ },
+ )
+ .add_modifier(Modifier::BOLD),
+ );
+ put_right_text(buffer, rect, rect.y, &signal, Style::default().fg(color));
+}
diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs
new file mode 100644
index 00000000..2f5a8e46
--- /dev/null
+++ b/src/client/shell/endpoints.rs
@@ -0,0 +1,506 @@
+use super::*;
+
+#[derive(Clone, Debug)]
+pub(crate) struct ClientShellEndpoint {
+ pub(crate) endpoint_id: ClientEndpointId,
+ pub(crate) label: String,
+ pub(crate) status: ClientEndpointStatus,
+ pub(crate) snapshot: Option<Box<ClientShellSnapshot>>,
+ /// Connection generation that produced `snapshot`. `None` is reserved for local tests.
+ pub(crate) snapshot_generation: Option<u64>,
+ pub(crate) agent_recency: HashMap<String, u64>,
+ pub(super) agent_presentation: super::endpoint_agent_state::EndpointAgentPresentation,
+ pub(crate) methods: Option<HashSet<String>>,
+}
+
+pub(super) struct MachineHit {
+ pub(super) rect: Rect,
+ pub(super) endpoint_id: ClientEndpointId,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum ClientEndpointFocusTarget {
+ Workspace(String),
+ Tab(String),
+ Pane(String),
+}
+
+impl ClientShellState {
+ pub(crate) fn set_endpoint_catalog(&mut self, profiles: &[SavedSshEndpoint]) {
+ let mut next = Vec::with_capacity(profiles.len().saturating_add(1));
+ let local = self
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id.is_local())
+ .cloned()
+ .unwrap_or_else(local_endpoint);
+ next.push(local);
+ for profile in profiles {
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ let previous = self
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .filter(|endpoint| {
+ profile.enabled && endpoint.status != ClientEndpointStatus::Disabled
+ });
+ next.push(ClientShellEndpoint {
+ endpoint_id,
+ label: profile.label.clone(),
+ status: previous.map_or(
+ if profile.enabled {
+ ClientEndpointStatus::Connecting
+ } else {
+ ClientEndpointStatus::Disabled
+ },
+ |endpoint| endpoint.status,
+ ),
+ snapshot: previous.and_then(|endpoint| endpoint.snapshot.clone()),
+ snapshot_generation: previous.and_then(|endpoint| endpoint.snapshot_generation),
+ agent_recency: previous
+ .map(|endpoint| endpoint.agent_recency.clone())
+ .unwrap_or_default(),
+ agent_presentation: previous
+ .map(|endpoint| endpoint.agent_presentation.clone())
+ .unwrap_or_default(),
+ methods: previous.and_then(|endpoint| endpoint.methods.clone()),
+ });
+ }
+
+ if !next
+ .iter()
+ .any(|endpoint| endpoint.endpoint_id == self.active_endpoint_id)
+ {
+ self.select_unavailable_local();
+ }
+ self.collapsed_endpoints.retain(|endpoint_id| {
+ next.iter()
+ .any(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ });
+ self.endpoints = next;
+ }
+
+ pub(crate) fn select_unavailable_local(&mut self) {
+ self.reset_endpoint_projection();
+ self.active_endpoint_id = ClientEndpointId::Local;
+ self.mode = ClientShellMode::Terminal;
+ self.snapshot = None;
+ self.graphics.set_scope("local:unavailable");
+ self.reconcile_input_source();
+ }
+
+ pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) {
+ self.retire_endpoint_notifications(endpoint_id);
+ if let Some(endpoint) = self
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ {
+ endpoint.status = ClientEndpointStatus::Disabled;
+ endpoint.snapshot = None;
+ endpoint.snapshot_generation = None;
+ endpoint.methods = None;
+ endpoint.agent_recency.clear();
+ endpoint.agent_presentation = Default::default();
+ }
+ }
+
+ pub(crate) fn set_endpoint_status(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ status: ClientEndpointStatus,
+ ) {
+ if let Some(endpoint) = self
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ {
+ endpoint.status = status;
+ }
+ }
+
+ pub(crate) fn mark_endpoint_disconnected(&mut self, endpoint_id: &ClientEndpointId) {
+ self.set_endpoint_status(endpoint_id, ClientEndpointStatus::Reconnecting);
+ if endpoint_id == &self.active_endpoint_id {
+ let pending = self.pending_requests.keys().cloned().collect::<Vec<_>>();
+ for request_id in pending {
+ self.cancel_endpoint_request(&request_id);
+ }
+ self.pending_integration_installs = 0;
+ self.pane_scroll_in_flight.clear();
+ self.pane_scroll_queued.clear();
+ }
+ }
+
+ pub(crate) fn set_endpoint_methods_for(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ methods: Option<Vec<String>>,
+ ) {
+ let methods = methods.map(|methods| methods.into_iter().collect::<HashSet<_>>());
+ if let Some(endpoint) = self
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ {
+ endpoint.methods = methods;
+ }
+ }
+
+ pub(crate) fn endpoint_projection_available(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.endpoints.iter().any(|endpoint| {
+ &endpoint.endpoint_id == endpoint_id
+ && endpoint.status == ClientEndpointStatus::Online
+ && endpoint.snapshot.is_some()
+ })
+ }
+
+ pub(crate) fn activate_endpoint_projection(&mut self, endpoint_id: &ClientEndpointId) -> bool {
+ let Some(endpoint) = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ else {
+ return false;
+ };
+ if endpoint.status != ClientEndpointStatus::Online {
+ return false;
+ }
+ let Some(snapshot) = endpoint.snapshot.clone() else {
+ return false;
+ };
+ if endpoint_id != &self.active_endpoint_id {
+ self.active_endpoint_id = endpoint_id.clone();
+ self.pane_surface = None;
+ self.pending_pane_surface = None;
+ }
+ self.apply_active_snapshot(snapshot);
+ true
+ }
+
+ pub(crate) fn endpoint_status(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ ) -> Option<ClientEndpointStatus> {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .map(|endpoint| endpoint.status)
+ }
+
+ pub(crate) fn endpoint_has_snapshot(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .is_some_and(|endpoint| endpoint.snapshot.is_some())
+ }
+
+ pub(crate) fn endpoint_is_online(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.endpoint_status(endpoint_id) == Some(ClientEndpointStatus::Online)
+ && self.endpoint_has_snapshot(endpoint_id)
+ }
+
+ pub(crate) fn endpoint_boot_id(&self, endpoint_id: &ClientEndpointId) -> Option<&str> {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)?
+ .snapshot
+ .as_deref()
+ .map(|snapshot| snapshot.boot_id.as_str())
+ }
+
+ pub(crate) fn endpoint_snapshot_matches(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ boot_id: &str,
+ revision: u64,
+ ) -> bool {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .is_some_and(|endpoint| {
+ endpoint
+ .snapshot_generation
+ .is_none_or(|snapshot_generation| snapshot_generation == generation)
+ && endpoint.snapshot.as_deref().is_some_and(|snapshot| {
+ snapshot.boot_id == boot_id && snapshot.revision == revision
+ })
+ })
+ }
+
+ pub(crate) fn endpoint_snapshot_identity(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ ) -> Option<(&str, u64)> {
+ let endpoint = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)?;
+ if endpoint
+ .snapshot_generation
+ .is_some_and(|snapshot_generation| snapshot_generation != generation)
+ {
+ return None;
+ }
+ endpoint
+ .snapshot
+ .as_deref()
+ .map(|snapshot| (snapshot.boot_id.as_str(), snapshot.revision))
+ }
+
+ /// A terminal normally starts focused. `None` means this host cannot report focus events,
+ /// not that the endpoint has no viewer; activation therefore sends an explicit true baseline.
+ pub(crate) fn host_focus_baseline(&self) -> bool {
+ self.outer_focused.unwrap_or(true)
+ }
+
+ pub(crate) fn endpoint_label(&self, endpoint_id: &ClientEndpointId) -> &str {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .map_or("Unknown endpoint", |endpoint| endpoint.label.as_str())
+ }
+
+ pub(crate) fn active_endpoint_label(&self) -> &str {
+ self.endpoint_label(&self.active_endpoint_id)
+ }
+
+ pub(crate) fn endpoint_is_active(&self, endpoint_id: &ClientEndpointId) -> bool {
+ &self.active_endpoint_id == endpoint_id
+ }
+
+ pub(crate) fn multi_endpoint_active(&self) -> bool {
+ self.endpoints.len() > 1
+ }
+
+ #[cfg(test)]
+ pub(crate) fn set_snapshot(&mut self, snapshot: Box<ClientShellSnapshot>) {
+ let endpoint_id = self.active_endpoint_id.clone();
+ self.set_endpoint_snapshot(&endpoint_id, snapshot);
+ }
+
+ #[cfg(test)]
+ pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) {
+ let endpoint_id = self.active_endpoint_id.clone();
+ self.set_endpoint_methods_for(&endpoint_id, methods);
+ }
+
+ pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool {
+ self.endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id)
+ .and_then(|endpoint| endpoint.methods.as_ref())
+ .is_none_or(|methods| methods.contains(crate::api::api_method_name(method)))
+ }
+
+ pub(super) fn focused_tab_count(&self) -> usize {
+ let Some(snapshot) = self.snapshot.as_deref() else {
+ return 0;
+ };
+ snapshot
+ .tabs
+ .iter()
+ .filter(|tab| {
+ Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref()
+ })
+ .count()
+ }
+
+ #[cfg(test)]
+ pub(crate) fn cache_endpoint_snapshot(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot_with_surface(endpoint_id, None, snapshot, true);
+ }
+
+ pub(crate) fn cache_endpoint_snapshot_for_generation(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, true);
+ }
+
+ /// Metadata delivered while an endpoint surface is inactive must never advance this
+ /// aggregate client's viewed watermark, even if the frozen source frame still exists.
+ pub(crate) fn cache_endpoint_snapshot_inactive_for_generation(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, false);
+ }
+
+ fn cache_endpoint_snapshot_with_surface(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: Option<u64>,
+ mut snapshot: Box<ClientShellSnapshot>,
+ acknowledge_surface: bool,
+ ) {
+ snapshot
+ .commands
+ .retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown);
+ let Some(index) = self
+ .endpoints
+ .iter()
+ .position(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ else {
+ return;
+ };
+ if self.endpoints[index].snapshot_generation == generation
+ && self.endpoints[index]
+ .snapshot
+ .as_deref()
+ .is_some_and(|previous| {
+ previous.boot_id == snapshot.boot_id && previous.revision > snapshot.revision
+ })
+ {
+ return;
+ }
+ let boot_changed = self.endpoints[index]
+ .snapshot
+ .as_deref()
+ .is_some_and(|previous| previous.boot_id != snapshot.boot_id);
+ if boot_changed {
+ self.retire_endpoint_notifications(endpoint_id);
+ }
+ self.endpoints[index]
+ .agent_presentation
+ .project_snapshot(&mut snapshot);
+ let presented_surface = if acknowledge_surface && endpoint_id == &self.active_endpoint_id {
+ self.pane_surface.as_ref()
+ } else {
+ None
+ };
+ if let Some(surface) = presented_surface {
+ self.endpoints[index]
+ .agent_presentation
+ .acknowledge_surface(&mut snapshot, surface, self.outer_focused);
+ }
+ let previous = self.endpoints[index].snapshot.as_deref();
+ let mut next_recency = self
+ .endpoints
+ .iter()
+ .flat_map(|endpoint| endpoint.agent_recency.values())
+ .copied()
+ .max()
+ .unwrap_or_default();
+ let mut agents = snapshot.agents.iter().collect::<Vec<_>>();
+ agents.sort_by_key(|agent| agent.state_change_seq);
+ let mut recency = self.endpoints[index].agent_recency.clone();
+ for agent in agents {
+ let changed = previous
+ .and_then(|snapshot| {
+ snapshot
+ .agents
+ .iter()
+ .find(|previous| previous.pane_id == agent.pane_id)
+ })
+ .is_none_or(|previous| previous.state_change_seq != agent.state_change_seq);
+ if changed {
+ next_recency = next_recency.saturating_add(1);
+ recency.insert(agent.pane_id.clone(), next_recency);
+ }
+ }
+ recency.retain(|pane_id, _| {
+ snapshot
+ .agents
+ .iter()
+ .any(|agent| &agent.pane_id == pane_id)
+ });
+ let endpoint = &mut self.endpoints[index];
+ endpoint.agent_recency = recency;
+ endpoint.snapshot_generation = generation;
+ endpoint.snapshot = Some(snapshot);
+ }
+
+ pub(crate) fn acknowledge_active_surface_agents(&mut self, surface: &PaneSurfaceFrame) -> bool {
+ let Some(index) = self
+ .endpoints
+ .iter()
+ .position(|endpoint| endpoint.endpoint_id == self.active_endpoint_id)
+ else {
+ return false;
+ };
+ let changed = {
+ let endpoint = &mut self.endpoints[index];
+ let Some(snapshot) = endpoint.snapshot.as_deref_mut() else {
+ return false;
+ };
+ endpoint
+ .agent_presentation
+ .acknowledge_surface(snapshot, surface, self.outer_focused)
+ };
+ if changed {
+ self.snapshot = self.endpoints[index].snapshot.clone();
+ }
+ changed
+ }
+
+ #[cfg(test)]
+ pub(crate) fn set_endpoint_snapshot(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot(endpoint_id, snapshot);
+ self.apply_cached_endpoint_snapshot(endpoint_id);
+ }
+
+ pub(crate) fn set_endpoint_snapshot_for_generation(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot_for_generation(endpoint_id, generation, snapshot);
+ self.apply_cached_endpoint_snapshot(endpoint_id);
+ }
+
+ fn apply_cached_endpoint_snapshot(&mut self, endpoint_id: &ClientEndpointId) {
+ let Some(snapshot) = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.clone())
+ else {
+ return;
+ };
+ if endpoint_id == &self.active_endpoint_id {
+ self.apply_active_snapshot(snapshot);
+ }
+ }
+}
+
+pub(super) fn endpoint_status_presentation(
+ status: ClientEndpointStatus,
+ palette: &Palette,
+) -> (&'static str, &'static str, ratatui::style::Color) {
+ match status {
+ ClientEndpointStatus::Connecting => ("◐", "connecting", palette.yellow),
+ ClientEndpointStatus::Online => ("●", "online", palette.green),
+ ClientEndpointStatus::Reconnecting => ("◐", "reconnecting", palette.yellow),
+ ClientEndpointStatus::Attention => ("!", "attention", palette.red),
+ ClientEndpointStatus::Disabled => ("·", "disabled", palette.overlay0),
+ }
+}
+
+pub(super) fn local_endpoint() -> ClientShellEndpoint {
+ ClientShellEndpoint {
+ endpoint_id: ClientEndpointId::Local,
+ label: "Local".into(),
+ status: ClientEndpointStatus::Online,
+ snapshot: None,
+ snapshot_generation: None,
+ agent_recency: HashMap::new(),
+ agent_presentation: Default::default(),
+ methods: None,
+ }
+}
diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs
index 8a512d3a..31061c9b 100644
--- a/src/client/shell/graphics.rs
+++ b/src/client/shell/graphics.rs
@@ -3,10 +3,7 @@ use super::*;
impl ClientShellState {
#[cfg(unix)]
pub(crate) fn graphics_scope(&self) -> &str {
- self.snapshot
- .as_deref()
- .map(|snapshot| snapshot.boot_id.as_str())
- .unwrap_or_default()
+ self.graphics.scope()
}
#[cfg(unix)]
diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs
index d2ca3a82..f8826136 100644
--- a/src/client/shell/input.rs
+++ b/src/client/shell/input.rs
@@ -236,6 +236,9 @@ impl ClientShellState {
self.outer_focused = Some(true);
outcome.query_host_appearance = true;
outcome.repaint |= self.config.redraw_on_focus_gained;
+ if let Some(surface) = self.pane_surface.clone() {
+ outcome.repaint |= self.acknowledge_active_surface_agents(&surface);
+ }
outcome
.requests
.push(ClientMessage::ClientShellFocus { focused: true });
@@ -798,7 +801,36 @@ impl ClientShellState {
) {
use crate::input::{KeybindAction, KeybindMatch};
- let indexed_target_exists = match &binding {
+ if !self.indexed_navigation_target_exists(&binding) {
+ return;
+ }
+
+ if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding {
+ self.cycle_pane(false, outcome);
+ } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding {
+ self.cycle_pane(true, outcome);
+ } else {
+ if !preserve_navigate {
+ self.mode = ClientShellMode::Terminal;
+ }
+ self.record_binding(binding, outcome);
+ }
+ if !preserve_navigate {
+ if self.mode == ClientShellMode::Navigate {
+ self.mode = self.copy_or_terminal_mode();
+ }
+ self.navigate_workspace_id = None;
+ }
+ outcome.repaint = true;
+ }
+
+ pub(super) fn indexed_navigation_target_exists(
+ &self,
+ binding: &crate::input::KeybindMatch,
+ ) -> bool {
+ use crate::input::{KeybindAction, KeybindMatch};
+
+ match binding {
KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => {
self.snapshot.as_deref().is_some_and(|snapshot| {
self.navigation_workspace_entries(snapshot)
@@ -819,38 +851,15 @@ impl ClientShellState {
})
.is_some(),
KeybindMatch::Action(KeybindAction::FocusAgent(index)) => {
- self.snapshot.as_deref().is_some_and(|snapshot| {
- super::agent_sidebar::ordered_agent_pane_ids(
- snapshot,
- self.config.agent_panel_sort,
- )
- .get(*index)
- .is_some()
- })
+ super::aggregate_navigation::online_agent_targets(
+ &self.endpoints,
+ self.config.agent_panel_sort,
+ )
+ .get(*index)
+ .is_some()
}
_ => true,
- };
- if !indexed_target_exists {
- return;
}
-
- if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding {
- self.cycle_pane(false, outcome);
- } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding {
- self.cycle_pane(true, outcome);
- } else {
- if !preserve_navigate {
- self.mode = ClientShellMode::Terminal;
- }
- self.record_binding(binding, outcome);
- }
- if !preserve_navigate {
- if self.mode == ClientShellMode::Navigate {
- self.mode = self.copy_or_terminal_mode();
- }
- self.navigate_workspace_id = None;
- }
- outcome.repaint = true;
}
fn move_navigate_workspace(&mut self, delta: isize) {
diff --git a/src/client/shell/input_source.rs b/src/client/shell/input_source.rs
new file mode 100644
index 00000000..7997c72f
--- /dev/null
+++ b/src/client/shell/input_source.rs
@@ -0,0 +1,41 @@
+use super::*;
+
+impl ClientShellState {
+ fn wants_ascii_input(&self) -> bool {
+ if let Some(overlay) = self.overlay.as_ref() {
+ return matches!(
+ overlay,
+ ClientShellOverlay::ConfirmClose(_)
+ | ClientShellOverlay::Help(_)
+ | ClientShellOverlay::Navigator(_)
+ | ClientShellOverlay::WorktreeRemove(_)
+ | ClientShellOverlay::ContextMenu(_)
+ | ClientShellOverlay::GlobalMenu(_)
+ );
+ }
+ matches!(
+ self.mode,
+ ClientShellMode::Prefix
+ | ClientShellMode::Navigate
+ | ClientShellMode::Resize
+ | ClientShellMode::Copy
+ )
+ }
+
+ pub(crate) fn reconcile_input_source(&mut self) {
+ // Keep the platform restore token while another window has focus. Restoring
+ // through a global key injection is only safe after this client regains focus.
+ if self.outer_focused == Some(false) {
+ return;
+ }
+ let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input();
+ if desired != self.ascii_input_source_active {
+ self.ascii_input_source_active = desired;
+ self.pending_input_source_changes.push(desired);
+ }
+ }
+
+ pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> {
+ std::mem::take(&mut self.pending_input_source_changes)
+ }
+}
diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs
index 014f51a0..abe50590 100644
--- a/src/client/shell/mobile.rs
+++ b/src/client/shell/mobile.rs
@@ -202,6 +202,16 @@ fn render_header_button(
}
}
+fn mobile_endpoint_state(status: ClientEndpointStatus) -> &'static str {
+ match status {
+ ClientEndpointStatus::Connecting => "connecting",
+ ClientEndpointStatus::Online => "online",
+ ClientEndpointStatus::Reconnecting => "reconnecting",
+ ClientEndpointStatus::Attention => "attention",
+ ClientEndpointStatus::Disabled => "disabled",
+ }
+}
+
fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String {
let tabs = snapshot
.tabs
@@ -354,6 +364,8 @@ pub(super) fn render_mobile_switcher(
buffer: &mut Buffer,
area: Rect,
snapshot: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
config: &ClientShellConfig,
selected_workspace_id: Option<&str>,
scroll: &mut usize,
@@ -426,6 +438,8 @@ pub(super) fn render_mobile_switcher(
let items = mobile_items(
snapshot,
+ endpoints,
+ active_endpoint_id,
config,
selected_workspace_id,
viewport.width.saturating_sub(1),
@@ -440,8 +454,10 @@ pub(super) fn render_mobile_switcher(
let end = start.saturating_add(item.lines.len());
if matches!(
item.target.as_ref(),
- Some(ClientMobileTarget::Workspace(workspace_id))
- if workspace_id == selected_workspace_id
+ Some(ClientMobileTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ }) if endpoint_id == active_endpoint_id && workspace_id == selected_workspace_id
) {
if start < *scroll {
*scroll = start;
@@ -558,22 +574,51 @@ fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) {
fn mobile_items(
snapshot: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
config: &ClientShellConfig,
selected_workspace_id: Option<&str>,
content_width: u16,
) -> Vec<MobileItem> {
let palette = &config.palette;
let mut items = Vec::new();
- let ordered_agents =
- super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort);
- if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() {
- let title = snapshot
- .agent_view_label
- .as_deref()
+ if endpoints.len() > 1 {
+ items.push(MobileItem::section("machines", palette));
+ for endpoint in endpoints {
+ let background = palette.panel_bg;
+ let (symbol, state, color) = endpoint_status_presentation(endpoint.status, palette);
+ items.push(MobileItem {
+ lines: vec![
+ Line::from(vec![
+ Span::styled(" ", Style::default().bg(background)),
+ Span::styled(symbol, Style::default().fg(color).bg(background)),
+ Span::styled(
+ format!(" {}", endpoint.label),
+ Style::default()
+ .fg(palette.text)
+ .bg(background)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ]),
+ Line::from(Span::styled(
+ format!(" {state}"),
+ Style::default().fg(palette.overlay0).bg(background),
+ )),
+ ],
+ background,
+ target: Some(ClientMobileTarget::Machine(endpoint.endpoint_id.clone())),
+ });
+ }
+ }
+ let agents =
+ super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort);
+ let agent_view_label = snapshot.agent_view_label.as_deref();
+ if !agents.is_empty() || agent_view_label.is_some() {
+ let title = agent_view_label
.map(|label| format!("agents · {label}"))
.unwrap_or_else(|| "agents".to_owned());
items.push(MobileItem::section(title, palette));
- if ordered_agents.is_empty() {
+ if agents.is_empty() {
items.push(MobileItem {
lines: vec![Line::from(Span::styled(
" no matching agents",
@@ -586,19 +631,19 @@ fn mobile_items(
target: None,
});
}
- for pane_id in ordered_agents {
- let Some(agent) = snapshot
- .agents
- .iter()
- .find(|agent| agent.pane_id == pane_id)
- else {
- continue;
- };
- let workspace = snapshot
+ for row in agents {
+ let endpoint = row.endpoint;
+ let agent = row.agent;
+ let workspace = endpoint
+ .snapshot
.workspaces
.iter()
.find(|workspace| workspace.workspace_id == agent.workspace_id);
- let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id);
+ let tab = endpoint
+ .snapshot
+ .tabs
+ .iter()
+ .find(|tab| tab.tab_id == agent.tab_id);
let agent_label = agent
.display_agent
.as_deref()
@@ -609,7 +654,8 @@ fn mobile_items(
.map(|workspace| workspace.label.as_str())
.unwrap_or(agent_label);
let mut detail = Vec::new();
- let workspace_tab_count = snapshot
+ let workspace_tab_count = endpoint
+ .snapshot
.tabs
.iter()
.filter(|candidate| candidate.workspace_id == agent.workspace_id)
@@ -633,11 +679,24 @@ fn mobile_items(
}),
);
detail.push(agent_label.to_owned());
- let background = if agent.focused {
+ if endpoint.stale() {
+ detail.push(mobile_endpoint_state(endpoint.status).to_owned());
+ }
+ let background = if endpoint.endpoint_id == active_endpoint_id && agent.focused {
palette.surface_dim
} else {
palette.panel_bg
};
+ let foreground = if endpoint.stale() {
+ palette.overlay0
+ } else {
+ palette.text
+ };
+ let dim = if endpoint.stale() {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ };
items.push(MobileItem {
lines: vec![
Line::from(vec![
@@ -645,19 +704,24 @@ fn mobile_items(
Span::styled(
status_icon(agent.agent_status, config.status_indicators),
Style::default()
- .fg(status_color(agent.agent_status, palette))
- .bg(background),
+ .fg(if endpoint.stale() {
+ palette.overlay0
+ } else {
+ status_color(agent.agent_status, palette)
+ })
+ .bg(background)
+ .add_modifier(dim),
),
Span::styled(" ", Style::default().bg(background)),
Span::styled(
crate::ui::truncate_end(
- primary,
+ &format!("{} · {primary}", endpoint.label),
usize::from(content_width.saturating_sub(5)),
),
Style::default()
- .fg(palette.text)
+ .fg(foreground)
.bg(background)
- .add_modifier(Modifier::BOLD),
+ .add_modifier(Modifier::BOLD | dim),
),
]),
Line::from(Span::styled(
@@ -665,11 +729,17 @@ fn mobile_items(
&format!(" {}", detail.join(" · ")),
usize::from(content_width),
),
- Style::default().fg(palette.overlay0).bg(background),
+ Style::default()
+ .fg(palette.overlay0)
+ .bg(background)
+ .add_modifier(dim),
)),
],
background,
- target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())),
+ target: Some(ClientMobileTarget::Agent {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ pane_id: agent.pane_id.clone(),
+ }),
});
}
}
@@ -680,91 +750,119 @@ fn mobile_items(
ClientMobileTarget::NewWorkspace,
palette,
));
- for entry in super::render::workspace_entries(snapshot, &HashSet::new()) {
- let Some(workspace) = snapshot.workspaces.get(entry.index) else {
- continue;
- };
- let selected = selected_workspace_id == Some(workspace.workspace_id.as_str());
- let background = if selected {
- palette.surface0
- } else if workspace.focused {
- palette.surface_dim
- } else {
- palette.panel_bg
- };
- let connector = if entry.indented {
- if entry.last_child {
- "└─ "
+ for endpoint in super::aggregate_navigation::cached_endpoint_snapshots(endpoints) {
+ for entry in super::render::workspace_entries(endpoint.snapshot, &HashSet::new()) {
+ let Some(workspace) = endpoint.snapshot.workspaces.get(entry.index) else {
+ continue;
+ };
+ let selected = endpoint.endpoint_id == active_endpoint_id
+ && selected_workspace_id == Some(workspace.workspace_id.as_str());
+ let background = if selected {
+ palette.surface0
+ } else if endpoint.endpoint_id == active_endpoint_id && workspace.focused {
+ palette.surface_dim
} else {
- "├─ "
- }
- } else {
- ""
- };
- let name = if entry.indented && !workspace.custom_label {
- workspace
- .branch
- .as_deref()
- .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch)))
- .unwrap_or(&workspace.label)
- } else {
- &workspace.label
- };
- let branch = workspace.branch.as_deref().unwrap_or("shell");
- let detail_prefix = if entry.indented {
- if entry.last_child {
- " "
+ palette.panel_bg
+ };
+ let connector = if entry.indented {
+ if entry.last_child {
+ "└─ "
+ } else {
+ "├─ "
+ }
} else {
- " │ "
- }
- } else {
- " "
- };
- items.push(MobileItem {
- lines: vec![
- Line::from(vec![
- Span::styled(
- format!(" {connector}"),
- Style::default().fg(palette.overlay0).bg(background),
- ),
- Span::styled(
- status_icon(workspace.agent_status, config.status_indicators),
- Style::default()
- .fg(status_color(workspace.agent_status, palette))
- .bg(background),
- ),
- Span::styled(" ", Style::default().bg(background)),
- Span::styled(
+ ""
+ };
+ let name = if entry.indented && !workspace.custom_label {
+ workspace
+ .branch
+ .as_deref()
+ .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch)))
+ .unwrap_or(&workspace.label)
+ } else {
+ &workspace.label
+ };
+ let branch = workspace.branch.as_deref().unwrap_or("shell");
+ let detail_prefix = if entry.indented {
+ if entry.last_child {
+ " "
+ } else {
+ " │ "
+ }
+ } else {
+ " "
+ };
+ let dim = if endpoint.stale() {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ };
+ let foreground = if endpoint.stale() {
+ palette.overlay0
+ } else {
+ palette.text
+ };
+ let status = if endpoint.stale() {
+ palette.overlay0
+ } else {
+ status_color(workspace.agent_status, palette)
+ };
+ let stale_detail = if endpoint.stale() {
+ format!(" · {}", mobile_endpoint_state(endpoint.status))
+ } else {
+ String::new()
+ };
+ items.push(MobileItem {
+ lines: vec![
+ Line::from(vec![
+ Span::styled(
+ format!(" {connector}"),
+ Style::default()
+ .fg(palette.overlay0)
+ .bg(background)
+ .add_modifier(dim),
+ ),
+ Span::styled(
+ status_icon(workspace.agent_status, config.status_indicators),
+ Style::default().fg(status).bg(background).add_modifier(dim),
+ ),
+ Span::styled(" ", Style::default().bg(background)),
+ Span::styled(
+ crate::ui::truncate_end(
+ &format!("{} · {name}", endpoint.label),
+ usize::from(content_width.saturating_sub(if entry.indented {
+ 8
+ } else {
+ 5
+ })),
+ ),
+ Style::default()
+ .fg(foreground)
+ .bg(background)
+ .add_modifier(Modifier::BOLD | dim),
+ ),
+ ]),
+ Line::from(Span::styled(
crate::ui::truncate_end(
- name,
- usize::from(content_width.saturating_sub(if entry.indented {
- 8
- } else {
- 5
- })),
+ &format!(
+ "{detail_prefix}{branch} · {}{stale_detail}",
+ compact_tab_status(endpoint.snapshot, workspace)
+ ),
+ usize::from(content_width),
),
Style::default()
- .fg(palette.text)
+ .fg(palette.overlay0)
.bg(background)
- .add_modifier(Modifier::BOLD),
- ),
- ]),
- Line::from(Span::styled(
- crate::ui::truncate_end(
- &format!(
- "{detail_prefix}{branch} · {}",
- compact_tab_status(snapshot, workspace)
- ),
- usize::from(content_width),
- ),
- Style::default().fg(palette.overlay0).bg(background),
- )),
- ],
- background,
- target: Some(ClientMobileTarget::Workspace(
- workspace.workspace_id.clone(),
- )),
- });
+ .add_modifier(dim),
+ )),
+ ],
+ background,
+ target: Some(ClientMobileTarget::Workspace {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ workspace_id: workspace.workspace_id.clone(),
+ }),
+ });
+ }
}
if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() {
@@ -803,7 +901,10 @@ fn mobile_items(
.add_modifier(Modifier::BOLD),
))],
background,
- target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())),
+ target: Some(ClientMobileTarget::Tab {
+ endpoint_id: active_endpoint_id.clone(),
+ tab_id: tab.tab_id.clone(),
+ }),
});
}
}
@@ -890,6 +991,22 @@ impl ClientShellState {
.find(|(rect, _)| super::contains(*rect, point))
.map(|(_, target)| target.clone());
match target {
+ Some(ClientMobileTarget::Machine(endpoint_id)) => {
+ if endpoint_id == self.active_endpoint_id {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ } else if self.endpoint_is_online(&endpoint_id) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: None,
+ });
+ } else {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ }
+ }
Some(ClientMobileTarget::NewWorkspace) => {
self.mobile_switcher_suspended = true;
self.record_binding(
@@ -897,34 +1014,44 @@ impl ClientShellState {
outcome,
);
}
- Some(
- target @ (ClientMobileTarget::Workspace(_)
- | ClientMobileTarget::Tab(_)
- | ClientMobileTarget::Agent(_)),
- ) => {
- let method = match target {
- ClientMobileTarget::Workspace(workspace_id) => {
- crate::api::schema::Method::WorkspaceFocus(
- crate::api::schema::WorkspaceTarget { workspace_id },
- )
- }
- ClientMobileTarget::Tab(tab_id) => {
- crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget {
- tab_id,
- })
- }
- ClientMobileTarget::Agent(pane_id) => {
- crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget {
- pane_id,
- })
- }
- ClientMobileTarget::NewWorkspace
- | ClientMobileTarget::NewTab
- | ClientMobileTarget::Menu(_) => return true,
- };
- self.mode = ClientShellMode::Terminal;
- self.navigate_workspace_id = None;
- self.push_endpoint_method(method, outcome);
+ Some(ClientMobileTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ }) => {
+ if self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Workspace(workspace_id),
+ outcome,
+ ) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ }
+ }
+ Some(ClientMobileTarget::Agent {
+ endpoint_id,
+ pane_id,
+ }) => {
+ if self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Pane(pane_id),
+ outcome,
+ ) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ }
+ }
+ Some(ClientMobileTarget::Tab {
+ endpoint_id,
+ tab_id,
+ }) => {
+ if self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Tab(tab_id),
+ outcome,
+ ) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ }
}
Some(ClientMobileTarget::NewTab) => {
self.mobile_switcher_suspended = true;
diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs
index 612319e2..48646f28 100644
--- a/src/client/shell/mouse.rs
+++ b/src/client/shell/mouse.rs
@@ -466,6 +466,9 @@ impl ClientShellState {
if self.hits.workspace_body.height == 0
|| point.1 < self.hits.workspace_body.y.saturating_sub(1)
|| point.1 >= self.hits.new_workspace.y
+ || self.hits.workspaces.iter().any(|hit| {
+ hit.endpoint_id != self.active_endpoint_id && super::contains(hit.rect, point)
+ })
{
return None;
}
@@ -473,12 +476,17 @@ impl ClientShellState {
.hits
.workspaces
.iter()
- .filter(|hit| !hit.indented)
+ .filter(|hit| hit.endpoint_id == self.active_endpoint_id && !hit.indented)
.map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1)))
.collect::<Vec<_>>();
let snapshot = self.snapshot.as_deref()?;
let entries = render::workspace_entries(snapshot, &self.collapsed_groups);
- let last_hit = self.hits.workspaces.last()?;
+ let last_hit = self
+ .hits
+ .workspaces
+ .iter()
+ .rev()
+ .find(|hit| hit.endpoint_id == self.active_endpoint_id)?;
let last_position = entries.iter().position(|entry| {
snapshot
.workspaces
@@ -1109,21 +1117,7 @@ impl ClientShellState {
.max(mouse.row.abs_diff(press.start_row));
if delta >= 1 {
let source_workspace_id = press.workspace_id.clone();
- let draggable = self
- .snapshot
- .as_deref()
- .and_then(|snapshot| {
- snapshot
- .workspaces
- .iter()
- .find(|workspace| workspace.workspace_id == source_workspace_id)
- })
- .is_some_and(|workspace| {
- !workspace
- .worktree
- .as_ref()
- .is_some_and(|worktree| worktree.is_linked_worktree)
- });
+ let draggable = self.endpoint_workspace_is_draggable(press);
if draggable {
if let Some(target) = self.workspace_drop_target_at(point) {
self.chrome_drag = Some(ClientChromeDrag::Workspace {
@@ -1268,14 +1262,7 @@ impl ClientShellState {
return;
}
if let Some(press) = self.workspace_press.take() {
- self.push_endpoint_method(
- crate::api::schema::Method::WorkspaceFocus(
- crate::api::schema::WorkspaceTarget {
- workspace_id: press.workspace_id,
- },
- ),
- outcome,
- );
+ self.finish_endpoint_workspace_press(press, outcome);
return;
}
if let Some(press) = self.tab_press.take() {
@@ -1552,14 +1539,14 @@ impl ClientShellState {
.navigator_rows
.iter()
.find(|(rect, _)| super::contains(*rect, point))
- .copied();
+ .cloned();
match mouse.kind {
MouseEventKind::Moved => {
- if let Some((_, index)) = row_hit {
+ if let Some((_, target)) = row_hit {
if let Some(ClientShellOverlay::Navigator(navigator)) =
self.overlay.as_mut()
{
- navigator.selected = index;
+ navigator.selected = Some(target);
}
outcome.repaint = true;
}
@@ -1573,30 +1560,13 @@ impl ClientShellState {
navigator.filter = None;
}
outcome.repaint = true;
- } else if let Some((rect, index)) = row_hit {
+ } else if let Some((rect, target)) = row_hit {
if let Some(ClientShellOverlay::Navigator(navigator)) =
self.overlay.as_mut()
{
- navigator.selected = index;
+ navigator.selected = Some(target.clone());
}
- let workspace = self
- .snapshot
- .as_deref()
- .zip(self.overlay.as_ref())
- .and_then(|(snapshot, overlay)| match overlay {
- ClientShellOverlay::Navigator(navigator) => {
- render::client_navigator_rows(snapshot, navigator)
- .get(index)
- .map(|row| {
- matches!(
- row.target,
- ClientNavigatorTarget::Workspace(_)
- )
- })
- }
- _ => None,
- })
- .unwrap_or(false);
+ let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. });
if workspace && mouse.column <= rect.x.saturating_add(3) {
self.toggle_selected_navigator_workspace();
outcome.repaint = true;
@@ -1749,13 +1719,7 @@ impl ClientShellState {
return;
}
let workspace_id = (!self.sidebar_collapsed)
- .then(|| {
- self.hits
- .workspaces
- .iter()
- .find(|hit| super::contains(hit.rect, point))
- .map(|hit| hit.workspace_id.clone())
- })
+ .then(|| self.active_endpoint_workspace_at(point))
.flatten();
if let Some(workspace_id) = workspace_id {
self.open_workspace_context_menu(workspace_id, mouse.column, mouse.row);
@@ -1949,6 +1913,9 @@ impl ClientShellState {
outcome.repaint = true;
return;
}
+ if self.handle_endpoint_machine_click(point, outcome) {
+ return;
+ }
if super::contains(self.hits.global_launcher, point) {
self.toggle_global_menu();
outcome.repaint = true;
@@ -2023,6 +1990,7 @@ impl ClientShellState {
.iter()
.find(|hit| super::contains(hit.rect, point))
.map(|hit| ClientWorkspacePress {
+ endpoint_id: hit.endpoint_id.clone(),
workspace_id: hit.workspace_id.clone(),
start_column: mouse.column,
start_row: mouse.row,
@@ -2059,6 +2027,9 @@ impl ClientShellState {
self.tab_press = Some(tab_press);
return;
}
+ if self.handle_endpoint_agent_click(point, outcome) {
+ return;
+ }
let agent_pane_id = self
.hits
.agents
diff --git a/src/client/shell/notification_policy.rs b/src/client/shell/notification_policy.rs
new file mode 100644
index 00000000..f3eeeb3c
--- /dev/null
+++ b/src/client/shell/notification_policy.rs
@@ -0,0 +1,319 @@
+use super::*;
+
+const MAX_QUEUED_NOTIFICATIONS: usize = 8;
+const COMPLETION_EVIDENCE_GRACE: std::time::Duration = std::time::Duration::from_secs(1);
+pub(super) const COMPLETION_RECHECK_INTERVAL: std::time::Duration =
+ std::time::Duration::from_millis(50);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum NotificationValidation {
+ Current,
+ AwaitingSnapshot,
+ Stale,
+}
+
+fn notification_duration(kind: SemanticNotificationKind) -> std::time::Duration {
+ std::time::Duration::from_secs(match kind {
+ SemanticNotificationKind::NeedsAttention => 8,
+ SemanticNotificationKind::Finished => 5,
+ SemanticNotificationKind::UpdateInstalled => 3,
+ SemanticNotificationKind::Custom => 5,
+ })
+}
+
+impl ClientShellState {
+ pub(super) fn retire_endpoint_notifications(&mut self, endpoint_id: &ClientEndpointId) {
+ self.pending_notifications
+ .retain(|pending| &pending.endpoint_id != endpoint_id);
+ self.queued_notifications
+ .retain(|queued| &queued.endpoint_id != endpoint_id);
+ if self
+ .visible_notification
+ .as_ref()
+ .is_some_and(|visible| &visible.endpoint_id == endpoint_id)
+ {
+ self.visible_notification = None;
+ self.promote_queued_notification(std::time::Instant::now());
+ }
+ }
+
+ fn queue_visible_notification(
+ &mut self,
+ mut notification: ClientVisibleNotification,
+ now: std::time::Instant,
+ ) {
+ if self.visible_notification.is_none() {
+ notification.deadline = now + notification_duration(notification.event.kind);
+ self.visible_notification = Some(notification);
+ return;
+ }
+ if self.queued_notifications.len() == MAX_QUEUED_NOTIFICATIONS {
+ self.queued_notifications.pop_front();
+ }
+ self.queued_notifications.push_back(notification);
+ }
+
+ fn promote_queued_notification(&mut self, now: std::time::Instant) -> bool {
+ let Some(mut notification) = self.queued_notifications.pop_front() else {
+ return false;
+ };
+ notification.deadline = now + notification_duration(notification.event.kind);
+ self.visible_notification = Some(notification);
+ true
+ }
+
+ pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) {
+ let Some(notification) = self.visible_notification.as_ref() else {
+ return;
+ };
+ if notification.event.pane_id.is_some()
+ && !self.endpoint_is_online(&notification.endpoint_id)
+ {
+ let label = self.endpoint_label(&notification.endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is unavailable"));
+ outcome.repaint = true;
+ return;
+ }
+ let notification = self
+ .visible_notification
+ .take()
+ .expect("checked visible notification");
+ self.promote_queued_notification(std::time::Instant::now());
+ outcome.repaint = true;
+ let Some(pane_id) = notification.event.pane_id else {
+ return;
+ };
+ if notification.endpoint_id == self.active_endpoint_id {
+ self.push_endpoint_method(
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }),
+ outcome,
+ );
+ } else if self.endpoint_is_online(&notification.endpoint_id) {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id: notification.endpoint_id,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ });
+ }
+ }
+
+ pub(crate) fn receive_notification(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ event: SemanticNotification,
+ now: std::time::Instant,
+ ) -> (Vec<ClientShellNotificationEffect>, bool) {
+ let delay = if event.kind == SemanticNotificationKind::Custom {
+ 0
+ } else {
+ self.config.toast_delay_seconds
+ };
+ let deadline = now
+ .checked_add(std::time::Duration::from_secs(delay))
+ .unwrap_or(now);
+ let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| {
+ self.visible_notification.as_ref().is_some_and(|visible| {
+ visible.endpoint_id == *endpoint_id
+ && visible.event.pane_id.as_deref() == Some(pane_id)
+ })
+ });
+ if let Some(pane_id) = event.pane_id.as_deref() {
+ self.pending_notifications.retain(|pending| {
+ pending.endpoint_id != *endpoint_id
+ || pending.event.pane_id.as_deref() != Some(pane_id)
+ });
+ self.queued_notifications.retain(|queued| {
+ queued.endpoint_id != *endpoint_id
+ || queued.event.pane_id.as_deref() != Some(pane_id)
+ });
+ if cleared_visible {
+ self.visible_notification = None;
+ self.promote_queued_notification(now);
+ }
+ }
+ // Completion evidence is advisory. A Finished effect is valid only while the
+ // client-projected pane remains Done, even when delivery is immediate.
+ let validate_state = delay > 0 || event.kind == SemanticNotificationKind::Finished;
+ self.pending_notifications.push(ClientPendingNotification {
+ endpoint_id: endpoint_id.clone(),
+ event,
+ deadline,
+ expires_at: now.checked_add(COMPLETION_EVIDENCE_GRACE).unwrap_or(now),
+ validate_state,
+ });
+ let (effects, repaint) = self.tick_notifications(now);
+ (effects, repaint || cleared_visible)
+ }
+
+ pub(crate) fn tick_notifications(
+ &mut self,
+ now: std::time::Instant,
+ ) -> (Vec<ClientShellNotificationEffect>, bool) {
+ let mut repaint = false;
+ if self
+ .visible_notification
+ .as_ref()
+ .is_some_and(|visible| now >= visible.deadline)
+ {
+ self.visible_notification = None;
+ self.promote_queued_notification(now);
+ repaint = true;
+ }
+ if self
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|visible| now >= visible.deadline)
+ {
+ self.visible_endpoint_notice = None;
+ repaint = true;
+ }
+
+ let pending = std::mem::take(&mut self.pending_notifications);
+ let mut effects = Vec::new();
+ for pending in pending {
+ if pending.deadline > now {
+ self.pending_notifications.push(pending);
+ continue;
+ }
+ if pending.validate_state {
+ match self.notification_validation(&pending.endpoint_id, &pending.event) {
+ NotificationValidation::Current => {}
+ NotificationValidation::AwaitingSnapshot if now < pending.expires_at => {
+ let mut pending = pending;
+ pending.deadline = now
+ .checked_add(COMPLETION_RECHECK_INTERVAL)
+ .unwrap_or(now)
+ .min(pending.expires_at);
+ self.pending_notifications.push(pending);
+ continue;
+ }
+ NotificationValidation::AwaitingSnapshot | NotificationValidation::Stale => {
+ continue;
+ }
+ }
+ }
+ let target_active =
+ self.notification_target_is_active(&pending.endpoint_id, &pending.event);
+ let suppress_external = target_active && self.outer_focused != Some(false);
+ if let Some(sound) = pending.event.sound {
+ let suppress_sound =
+ pending.event.kind == SemanticNotificationKind::Finished && suppress_external;
+ if !suppress_sound {
+ effects.push(ClientShellNotificationEffect::Sound {
+ sound: match sound {
+ SemanticNotificationSound::Done => crate::sound::Sound::Done,
+ SemanticNotificationSound::Request => crate::sound::Sound::Request,
+ },
+ agent: pending.event.agent.clone(),
+ });
+ }
+ }
+
+ match self.config.toast_delivery {
+ crate::config::ToastDelivery::Off => {}
+ crate::config::ToastDelivery::Herdr if !target_active => {
+ self.queue_visible_notification(
+ ClientVisibleNotification {
+ endpoint_id: pending.endpoint_id,
+ event: pending.event,
+ deadline: now,
+ },
+ now,
+ );
+ repaint = true;
+ }
+ crate::config::ToastDelivery::Herdr => {}
+ crate::config::ToastDelivery::Terminal if !suppress_external => {
+ effects.push(ClientShellNotificationEffect::Terminal {
+ title: pending.event.title,
+ body: pending.event.body,
+ });
+ }
+ crate::config::ToastDelivery::System if !suppress_external => {
+ effects.push(ClientShellNotificationEffect::System {
+ title: pending.event.title,
+ body: pending.event.body,
+ });
+ }
+ crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {}
+ }
+ }
+ (effects, repaint)
+ }
+
+ fn notification_target_is_active(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ event: &SemanticNotification,
+ ) -> bool {
+ if endpoint_id != &self.active_endpoint_id {
+ return false;
+ }
+ let Some(snapshot) = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ else {
+ return false;
+ };
+ if let Some(tab_id) = event.tab_id.as_deref() {
+ return snapshot.focused_tab_id.as_deref() == Some(tab_id);
+ }
+ event.workspace_id.as_deref().is_some_and(|workspace_id| {
+ snapshot.focused_workspace_id.as_deref() == Some(workspace_id)
+ })
+ }
+
+ fn notification_validation(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ event: &SemanticNotification,
+ ) -> NotificationValidation {
+ let Some(pane_id) = event.pane_id.as_deref() else {
+ // Finished notifications carry no independently trustworthy completion state. Without
+ // a projected pane to verify as Done, do not emit completion chrome or sound.
+ return if event.kind == SemanticNotificationKind::Finished {
+ NotificationValidation::Stale
+ } else {
+ NotificationValidation::Current
+ };
+ };
+ let Some(agent) = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ .and_then(|snapshot| {
+ snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane_id)
+ })
+ else {
+ return NotificationValidation::AwaitingSnapshot;
+ };
+ match event.kind {
+ SemanticNotificationKind::NeedsAttention
+ if agent.agent_status == crate::api::schema::AgentStatus::Blocked =>
+ {
+ NotificationValidation::Current
+ }
+ SemanticNotificationKind::Finished
+ if agent.agent_status == crate::api::schema::AgentStatus::Done =>
+ {
+ NotificationValidation::Current
+ }
+ SemanticNotificationKind::Finished
+ if agent.agent_status == crate::api::schema::AgentStatus::Working =>
+ {
+ NotificationValidation::AwaitingSnapshot
+ }
+ SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => {
+ NotificationValidation::Current
+ }
+ SemanticNotificationKind::NeedsAttention | SemanticNotificationKind::Finished => {
+ NotificationValidation::Stale
+ }
+ }
+ }
+}
diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs
index 43fe566a..b8437ea7 100644
--- a/src/client/shell/notifications.rs
+++ b/src/client/shell/notifications.rs
@@ -1,3 +1,5 @@
+#[cfg(test)]
+use super::notification_policy::COMPLETION_RECHECK_INTERVAL;
use super::*;
use ratatui::{
style::Color,
@@ -5,7 +7,7 @@ use ratatui::{
widgets::{Block, Borders, Clear, Paragraph, Widget},
};
-fn render_mobile_notice_banner(
+pub(super) fn render_mobile_notice_banner(
buffer: &mut Buffer,
area: Rect,
title: &str,
@@ -102,25 +104,7 @@ pub(super) fn render_mobile_notification_banner(
)
}
-pub(super) fn render_mobile_endpoint_notice_banner(
- buffer: &mut Buffer,
- area: Rect,
- notice: &ClientVisibleEndpointNotice,
- offset_for_warning: bool,
- palette: &Palette,
-) -> Rect {
- render_mobile_notice_banner(
- buffer,
- area,
- &notice.title,
- Some(&notice.body),
- palette.red,
- offset_for_warning,
- palette,
- )
-}
-
-fn render_notification_card(
+pub(super) fn render_notification_card(
buffer: &mut Buffer,
area: Rect,
title: &str,
@@ -221,205 +205,13 @@ pub(super) fn render_visible_notification(
)
}
-pub(super) fn render_endpoint_notice(
- buffer: &mut Buffer,
- area: Rect,
- notice: &ClientVisibleEndpointNotice,
- top_offset: u16,
- palette: &Palette,
-) -> Rect {
- render_notification_card(
- buffer,
- area,
- &notice.title,
- &notice.body,
- crate::config::ToastHerdrPosition::TopRight,
- top_offset,
- match notice.key.kind {
- ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => {
- palette.red
- }
- ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => {
- palette.yellow
- }
- },
- palette,
- )
-}
-
-impl ClientShellState {
- pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) {
- let Some(notification) = self.visible_notification.take() else {
- return;
- };
- outcome.repaint = true;
- if let Some(pane_id) = notification.event.pane_id {
- self.push_endpoint_method(
- crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }),
- outcome,
- );
- }
- }
-
- pub(crate) fn receive_notification(
- &mut self,
- event: SemanticNotification,
- now: std::time::Instant,
- ) -> (Vec<ClientShellNotificationEffect>, bool) {
- let delay = if event.kind == SemanticNotificationKind::Custom {
- 0
- } else {
- self.config.toast_delay_seconds
- };
- let deadline = now
- .checked_add(std::time::Duration::from_secs(delay))
- .unwrap_or(now);
- let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| {
- self.visible_notification
- .as_ref()
- .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id))
- });
- if let Some(pane_id) = event.pane_id.as_deref() {
- self.pending_notifications
- .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id));
- if cleared_visible {
- self.visible_notification = None;
- }
- }
- self.pending_notifications.push(ClientPendingNotification {
- event,
- deadline,
- validate_state: delay > 0,
- });
- let (effects, repaint) = self.tick_notifications(now);
- (effects, repaint || cleared_visible)
- }
-
- pub(crate) fn tick_notifications(
- &mut self,
- now: std::time::Instant,
- ) -> (Vec<ClientShellNotificationEffect>, bool) {
- let mut repaint = false;
- if self
- .visible_notification
- .as_ref()
- .is_some_and(|visible| now >= visible.deadline)
- {
- self.visible_notification = None;
- repaint = true;
- }
- if self
- .visible_endpoint_notice
- .as_ref()
- .is_some_and(|visible| now >= visible.deadline)
- {
- self.visible_endpoint_notice = None;
- repaint = true;
- }
-
- let pending = std::mem::take(&mut self.pending_notifications);
- let mut effects = Vec::new();
- for pending in pending {
- if pending.deadline > now {
- self.pending_notifications.push(pending);
- continue;
- }
- if pending.validate_state && !self.notification_still_current(&pending.event) {
- continue;
- }
- let target_active = self.notification_target_is_active(&pending.event);
- let suppress_external = target_active && self.outer_focused != Some(false);
- if let Some(sound) = pending.event.sound {
- let suppress_sound =
- pending.event.kind == SemanticNotificationKind::Finished && suppress_external;
- if !suppress_sound {
- effects.push(ClientShellNotificationEffect::Sound {
- sound: match sound {
- SemanticNotificationSound::Done => crate::sound::Sound::Done,
- SemanticNotificationSound::Request => crate::sound::Sound::Request,
- },
- agent: pending.event.agent.clone(),
- });
- }
- }
-
- match self.config.toast_delivery {
- crate::config::ToastDelivery::Off => {}
- crate::config::ToastDelivery::Herdr if !target_active => {
- let duration = match pending.event.kind {
- SemanticNotificationKind::NeedsAttention => 8,
- SemanticNotificationKind::Finished => 5,
- SemanticNotificationKind::UpdateInstalled => 3,
- SemanticNotificationKind::Custom => 5,
- };
- self.visible_notification = Some(ClientVisibleNotification {
- event: pending.event,
- deadline: now + std::time::Duration::from_secs(duration),
- });
- repaint = true;
- }
- crate::config::ToastDelivery::Herdr => {}
- crate::config::ToastDelivery::Terminal if !suppress_external => {
- effects.push(ClientShellNotificationEffect::Terminal {
- title: pending.event.title,
- body: pending.event.body,
- });
- }
- crate::config::ToastDelivery::System if !suppress_external => {
- effects.push(ClientShellNotificationEffect::System {
- title: pending.event.title,
- body: pending.event.body,
- });
- }
- crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {}
- }
- }
- (effects, repaint)
- }
-
- fn notification_target_is_active(&self, event: &SemanticNotification) -> bool {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return false;
- };
- if let Some(tab_id) = event.tab_id.as_deref() {
- return snapshot.focused_tab_id.as_deref() == Some(tab_id);
- }
- event.workspace_id.as_deref().is_some_and(|workspace_id| {
- snapshot.focused_workspace_id.as_deref() == Some(workspace_id)
- })
- }
-
- fn notification_still_current(&self, event: &SemanticNotification) -> bool {
- let Some(pane_id) = event.pane_id.as_deref() else {
- return true;
- };
- let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| {
- snapshot
- .agents
- .iter()
- .find(|agent| agent.pane_id == pane_id)
- }) else {
- return false;
- };
- match event.kind {
- SemanticNotificationKind::NeedsAttention => {
- agent.agent_status == crate::api::schema::AgentStatus::Blocked
- }
- SemanticNotificationKind::Finished => matches!(
- agent.agent_status,
- crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done
- ),
- SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true,
- }
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
fn notification() -> ClientVisibleNotification {
ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
event: SemanticNotification {
kind: SemanticNotificationKind::Custom,
title: "notice".into(),
@@ -463,6 +255,205 @@ mod tests {
}
#[test]
+ fn unavailable_notification_target_stays_visible_and_reports_the_machine() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.visible_notification = Some(ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
+ event: SemanticNotification {
+ kind: SemanticNotificationKind::NeedsAttention,
+ title: "agent needs attention".into(),
+ body: None,
+ sound: None,
+ agent: Some("agent".into()),
+ workspace_id: Some("workspace".into()),
+ tab_id: Some("tab".into()),
+ pane_id: Some("pane".into()),
+ position: None,
+ },
+ deadline: std::time::Instant::now() + std::time::Duration::from_secs(5),
+ });
+ let mut outcome = ClientShellInput::default();
+
+ state.focus_visible_notification(&mut outcome);
+
+ assert!(state.visible_notification.is_some());
+ assert!(state
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|notice| notice.body.contains("Local is unavailable")));
+ assert!(outcome.repaint);
+ }
+
+ #[test]
+ fn immediate_finished_evidence_for_acknowledged_idle_never_toasts_or_sounds() {
+ let mut config = ClientShellConfig::from_config(&Config::default());
+ config.toast_delivery = crate::config::ToastDelivery::Herdr;
+ config.toast_delay_seconds = 0;
+ let mut state = ClientShellState::new(config);
+ let mut snapshot = super::super::tests::snapshot();
+ snapshot.agents.push(crate::protocol::ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some("agent".into()),
+ display_agent: None,
+ agent: Some("agent".into()),
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: crate::api::schema::AgentStatus::Idle,
+ state_change_seq: 2,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: true,
+ });
+ state.set_snapshot(Box::new(snapshot));
+
+ let (effects, repaint) = state.receive_notification(
+ &ClientEndpointId::Local,
+ SemanticNotification {
+ kind: SemanticNotificationKind::Finished,
+ title: "agent finished".into(),
+ body: None,
+ sound: Some(SemanticNotificationSound::Done),
+ agent: Some("agent".into()),
+ workspace_id: Some("ws_1".into()),
+ tab_id: Some("tab_1".into()),
+ pane_id: Some("pane_1".into()),
+ position: None,
+ },
+ std::time::Instant::now(),
+ );
+
+ assert!(effects.is_empty());
+ assert!(!repaint);
+ assert!(state.visible_notification.is_none());
+ }
+
+ #[test]
+ fn finished_hint_waits_for_the_projected_completion_snapshot() {
+ let mut config = ClientShellConfig::from_config(&Config::default());
+ config.toast_delivery = crate::config::ToastDelivery::Terminal;
+ config.toast_delay_seconds = 0;
+ let mut state = ClientShellState::new(config);
+ let mut snapshot = super::super::tests::snapshot();
+ snapshot.agents.push(crate::protocol::ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some("agent".into()),
+ display_agent: None,
+ agent: Some("agent".into()),
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: crate::api::schema::AgentStatus::Working,
+ state_change_seq: 1,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: false,
+ });
+ state.set_snapshot(Box::new(snapshot.clone()));
+ let now = std::time::Instant::now();
+ let event = SemanticNotification {
+ kind: SemanticNotificationKind::Finished,
+ title: "agent finished".into(),
+ body: None,
+ sound: None,
+ agent: Some("agent".into()),
+ workspace_id: Some("ws_1".into()),
+ tab_id: Some("background-tab".into()),
+ pane_id: Some("pane_1".into()),
+ position: None,
+ };
+
+ let (effects, _) = state.receive_notification(&ClientEndpointId::Local, event, now);
+ assert!(effects.is_empty());
+ assert_eq!(state.pending_notifications.len(), 1);
+
+ snapshot.revision = snapshot.revision.saturating_add(1);
+ snapshot.agents[0].agent_status = crate::api::schema::AgentStatus::Idle;
+ snapshot.agents[0].state_change_seq = 2;
+ state.set_snapshot(Box::new(snapshot));
+ let (effects, _) = state.tick_notifications(now + COMPLETION_RECHECK_INTERVAL);
+ assert!(matches!(
+ effects.as_slice(),
+ [ClientShellNotificationEffect::Terminal { title, .. }] if title == "agent finished"
+ ));
+ assert!(state.pending_notifications.is_empty());
+ }
+
+ #[test]
+ fn retiring_one_endpoint_preserves_other_endpoint_notifications() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let mut local = notification();
+ local.event.title = "local".into();
+ let remote_id = ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
+ let mut remote = notification();
+ remote.endpoint_id = remote_id.clone();
+ remote.event.title = "remote".into();
+ state.visible_notification = Some(local);
+ state.queued_notifications.push_back(remote);
+
+ state.retire_endpoint_notifications(&ClientEndpointId::Local);
+
+ assert_eq!(
+ state
+ .visible_notification
+ .as_ref()
+ .map(|notification| (&notification.endpoint_id, notification.event.title.as_str())),
+ Some((&remote_id, "remote"))
+ );
+ }
+
+ #[test]
+ fn concurrent_endpoint_notifications_are_queued_in_arrival_order() {
+ let mut config = Config::default();
+ config.ui.toast.delivery = crate::config::ToastDelivery::Herdr;
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
+ let now = std::time::Instant::now();
+ for title in ["first", "second"] {
+ state.receive_notification(
+ &ClientEndpointId::Local,
+ SemanticNotification {
+ kind: SemanticNotificationKind::Custom,
+ title: title.into(),
+ body: None,
+ sound: None,
+ agent: None,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: Some(title.into()),
+ position: None,
+ },
+ now,
+ );
+ }
+
+ assert_eq!(
+ state
+ .visible_notification
+ .as_ref()
+ .map(|notification| notification.event.title.as_str()),
+ Some("first")
+ );
+ assert_eq!(state.queued_notifications.len(), 1);
+
+ state.tick_notifications(now + std::time::Duration::from_secs(5));
+
+ assert_eq!(
+ state
+ .visible_notification
+ .as_ref()
+ .map(|notification| notification.event.title.as_str()),
+ Some("second")
+ );
+ assert!(state.queued_notifications.is_empty());
+ }
+
+ #[test]
fn notification_rect_stays_inside_short_nonzero_area() {
let palette = crate::app::client_palette_from_config(&Config::default());
for height in [1, 2] {
diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs
index 154b00b6..db089f4e 100644
--- a/src/client/shell/overlay_input.rs
+++ b/src/client/shell/overlay_input.rs
@@ -191,98 +191,123 @@ impl ClientShellState {
}
pub(super) fn open_navigator_overlay(&mut self) {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return;
- };
- let expanded_workspaces = snapshot
- .workspaces
- .iter()
- .map(|workspace| workspace.workspace_id.clone())
- .collect();
+ let expanded_workspaces =
+ super::aggregate_navigation::cached_endpoint_snapshots(&self.endpoints)
+ .flat_map(|endpoint| {
+ endpoint.snapshot.workspaces.iter().map(move |workspace| {
+ (endpoint.endpoint_id.clone(), workspace.workspace_id.clone())
+ })
+ })
+ .collect();
let mut navigator = ClientNavigatorOverlay {
query: String::new(),
search_focused: false,
- selected: 0,
+ selected: None,
scroll: 0,
filter: None,
expanded_workspaces,
};
- let rows = render::client_navigator_rows(snapshot, &navigator);
- navigator.selected = rows.iter().position(|row| row.current).unwrap_or(0);
+ let rows =
+ render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, &navigator);
+ navigator.selected = rows
+ .iter()
+ .find(|row| row.current)
+ .map(|row| row.target.clone());
self.overlay = Some(ClientShellOverlay::Navigator(navigator));
}
pub(super) fn move_navigator_selection(&mut self, delta: isize) {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return;
- };
let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else {
return;
};
- let rows = render::client_navigator_rows(snapshot, navigator);
+ let rows =
+ render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator);
if rows.is_empty() {
- navigator.selected = 0;
+ navigator.selected = None;
return;
}
- navigator.selected = (navigator.selected as isize + delta)
- .clamp(0, rows.len().saturating_sub(1) as isize) as usize;
+ let selected =
+ super::aggregate_navigation::navigator_selected_index(&rows, navigator).unwrap_or(0);
+ let next =
+ (selected as isize + delta).clamp(0, rows.len().saturating_sub(1) as isize) as usize;
+ navigator.selected = Some(rows[next].target.clone());
}
pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) {
- let target = {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return;
- };
- let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else {
- return;
- };
- render::client_navigator_rows(snapshot, navigator)
- .get(navigator.selected)
- .map(|row| row.target.clone())
- };
+ let target = self.overlay.as_ref().and_then(|overlay| match overlay {
+ ClientShellOverlay::Navigator(navigator) => {
+ let rows = render::client_navigator_rows(
+ &self.endpoints,
+ &self.active_endpoint_id,
+ navigator,
+ );
+ super::aggregate_navigation::selected_navigator_target(&rows, navigator)
+ }
+ _ => None,
+ });
let Some(target) = target else {
return;
};
- self.overlay = None;
- let method = match target {
- ClientNavigatorTarget::Workspace(workspace_id) => {
- crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
- workspace_id,
- })
- }
- ClientNavigatorTarget::Tab(tab_id) => {
- crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id })
+ let activated = match target {
+ ClientNavigatorTarget::Machine { endpoint_id } => {
+ self.activate_endpoint(endpoint_id, outcome)
}
- ClientNavigatorTarget::Pane(pane_id) => {
- crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id })
+ ClientNavigatorTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ } => self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Workspace(workspace_id),
+ outcome,
+ ),
+ ClientNavigatorTarget::Tab {
+ endpoint_id,
+ tab_id,
+ } => {
+ self.focus_or_activate(endpoint_id, ClientEndpointFocusTarget::Tab(tab_id), outcome)
}
+ ClientNavigatorTarget::Pane {
+ endpoint_id,
+ pane_id,
+ } => self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Pane(pane_id),
+ outcome,
+ ),
};
- self.push_endpoint_method(method, outcome);
+ if activated {
+ self.overlay = None;
+ }
outcome.repaint = true;
}
pub(super) fn toggle_selected_navigator_workspace(&mut self) {
- let workspace_id = {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return;
- };
- let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else {
- return;
- };
- render::client_navigator_rows(snapshot, navigator)
- .get(navigator.selected)
- .and_then(|row| match &row.target {
- ClientNavigatorTarget::Workspace(workspace_id) => Some(workspace_id.clone()),
- _ => None,
- })
- };
- if let (Some(workspace_id), Some(ClientShellOverlay::Navigator(navigator))) =
- (workspace_id, self.overlay.as_mut())
+ let workspace_key = self.overlay.as_ref().and_then(|overlay| match overlay {
+ ClientShellOverlay::Navigator(navigator) => {
+ let rows = render::client_navigator_rows(
+ &self.endpoints,
+ &self.active_endpoint_id,
+ navigator,
+ );
+ super::aggregate_navigation::selected_navigator_target(&rows, navigator).and_then(
+ |target| match target {
+ ClientNavigatorTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ } => Some((endpoint_id, workspace_id)),
+ _ => None,
+ },
+ )
+ }
+ _ => None,
+ });
+ if let (Some(workspace_key), Some(ClientShellOverlay::Navigator(navigator))) =
+ (workspace_key, self.overlay.as_mut())
{
- if !navigator.expanded_workspaces.remove(&workspace_id) {
- navigator.expanded_workspaces.insert(workspace_id);
+ if !navigator.expanded_workspaces.remove(&workspace_key) {
+ navigator.expanded_workspaces.insert(workspace_key);
}
- navigator.selected = 0;
+ navigator.selected = None;
navigator.scroll = 0;
}
}
@@ -433,7 +458,7 @@ impl ClientShellState {
Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => {
navigator.query.push_str(text);
navigator.filter = None;
- navigator.selected = 0;
+ navigator.selected = None;
true
}
_ => false,
@@ -641,11 +666,11 @@ impl ClientShellState {
if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) {
navigator.query.clear();
navigator.filter = None;
- navigator.selected = 0;
+ navigator.selected = None;
} else if code == KeyCode::Backspace {
navigator.query.pop();
navigator.filter = None;
- navigator.selected = 0;
+ navigator.selected = None;
} else if let KeyCode::Char(character) = code {
if modifiers.difference(KeyModifiers::SHIFT).is_empty() {
navigator.filter = None;
@@ -654,7 +679,7 @@ impl ClientShellState {
} else {
navigator.query.push(character);
}
- navigator.selected = 0;
+ navigator.selected = None;
}
}
outcome.repaint = true;
@@ -664,7 +689,7 @@ impl ClientShellState {
if code == KeyCode::Backspace && modifiers.is_empty() {
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
if navigator.filter.take().is_some() {
- navigator.selected = 0;
+ navigator.selected = None;
}
}
outcome.repaint = true;
@@ -672,25 +697,23 @@ impl ClientShellState {
}
if code == KeyCode::Home && modifiers.is_empty() {
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
- navigator.selected = 0;
+ navigator.selected = None;
navigator.scroll = 0;
}
outcome.repaint = true;
return;
}
if matches!(code, KeyCode::End | KeyCode::Char('G')) && modifiers.is_empty() {
- let last = self
- .snapshot
- .as_deref()
- .zip(self.overlay.as_ref())
- .and_then(|(snapshot, overlay)| match overlay {
- ClientShellOverlay::Navigator(navigator) => {
- Some(render::client_navigator_rows(snapshot, navigator).len())
- }
- _ => None,
- })
- .unwrap_or(0)
- .saturating_sub(1);
+ let last = self.overlay.as_ref().and_then(|overlay| match overlay {
+ ClientShellOverlay::Navigator(navigator) => render::client_navigator_rows(
+ &self.endpoints,
+ &self.active_endpoint_id,
+ navigator,
+ )
+ .last()
+ .map(|row| row.target.clone()),
+ _ => None,
+ });
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
navigator.selected = last;
}
@@ -735,7 +758,7 @@ impl ClientShellState {
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
navigator.query.clear();
navigator.filter = Some(filter);
- navigator.selected = 0;
+ navigator.selected = None;
}
outcome.repaint = true;
return;
@@ -744,7 +767,7 @@ impl ClientShellState {
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
navigator.query.clear();
navigator.filter = None;
- navigator.selected = 0;
+ navigator.selected = None;
}
outcome.repaint = true;
return;
diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs
index 30ac78b9..faf252a8 100644
--- a/src/client/shell/overlays.rs
+++ b/src/client/shell/overlays.rs
@@ -10,7 +10,7 @@ pub(crate) struct OverlayRender {
pub(crate) cancel: Rect,
pub(crate) navigator_popup: Rect,
pub(crate) navigator_search: Rect,
- pub(crate) navigator_rows: Vec<(Rect, usize)>,
+ pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
pub(crate) worktree_search: Rect,
pub(crate) worktree_rows: Vec<(Rect, usize)>,
pub(crate) help_popup: Rect,
@@ -33,6 +33,8 @@ pub(crate) fn render_client_overlay(
b: &mut Buffer,
o: &ClientShellOverlay,
s: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
k: &LiveKeybindConfig,
p: &Palette,
) -> Option<OverlayRender> {
@@ -58,7 +60,9 @@ pub(crate) fn render_client_overlay(
ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p),
ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p),
ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p),
- ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p),
+ ClientShellOverlay::Navigator(v) => {
+ render_navigator_overlay(b, v, endpoints, active_endpoint_id, p)
+ }
ClientShellOverlay::Settings(v) => {
settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p)
}
@@ -669,91 +673,11 @@ fn render_rename_overlay(
})
}
-pub(crate) fn client_navigator_rows(
- s: &ClientShellSnapshot,
- n: &ClientNavigatorOverlay,
-) -> Vec<ClientNavigatorRow> {
- let q = n.query.trim().to_lowercase();
- let filter = |st| match n.filter {
- Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked,
- Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working,
- Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle,
- Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done,
- None => true,
- };
- let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q);
- let filtering = n.filter.is_some() || !q.is_empty();
- let mut out = Vec::new();
- for w in &s.workspaces {
- let mut children = Vec::new();
- for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) {
- let mut panes = Vec::new();
- for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() {
- let a = s.agents.iter().find(|a| a.pane_id == p.pane_id);
- let st = a
- .map(|a| a.agent_status)
- .unwrap_or(crate::api::schema::AgentStatus::Unknown);
- let label = p
- .label
- .clone()
- .or_else(|| a.and_then(|a| a.name.clone()))
- .or_else(|| a.and_then(|a| a.display_agent.clone()))
- .or_else(|| a.and_then(|a| a.title.clone()))
- .unwrap_or_else(|| format!("pane {}", ix + 1));
- let meta = p
- .foreground_cwd
- .clone()
- .or_else(|| p.cwd.clone())
- .unwrap_or_default();
- if !filtering || filter(st) && (text(&label) || text(&meta)) {
- panes.push(ClientNavigatorRow {
- depth: 2,
- label,
- meta,
- status: st,
- current: s.focused_pane_id.as_deref() == Some(&p.pane_id),
- target: ClientNavigatorTarget::Pane(p.pane_id.clone()),
- })
- }
- }
- if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() {
- children.push(ClientNavigatorRow {
- depth: 1,
- label: t.label.clone(),
- meta: format!(
- "{} panes",
- s.panes.iter().filter(|p| p.tab_id == t.tab_id).count()
- ),
- status: t.agent_status,
- current: s.focused_tab_id.as_deref() == Some(&t.tab_id),
- target: ClientNavigatorTarget::Tab(t.tab_id.clone()),
- });
- children.extend(panes)
- }
- }
- let wm =
- filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text));
- if !filtering || wm || !children.is_empty() {
- out.push(ClientNavigatorRow {
- depth: 0,
- label: w.label.clone(),
- meta: w.branch.clone().unwrap_or_default(),
- status: w.agent_status,
- current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id),
- target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()),
- });
- if n.expanded_workspaces.contains(&w.workspace_id) || filtering {
- out.extend(children)
- }
- }
- }
- out
-}
-
fn render_navigator_overlay(
b: &mut Buffer,
n: &ClientNavigatorOverlay,
- s: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
p: &Palette,
) -> Option<OverlayRender> {
let a = b.area;
@@ -766,7 +690,7 @@ fn render_navigator_overlay(
a.height.saturating_sub(my * 2).max(4),
);
let i = panel(b, q, p.accent, p.panel_bg)?;
- let rows = client_navigator_rows(s, n);
+ let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n);
let search = if n.search_focused {
format!(" / {}", n.query)
} else if let Some(f) = n.filter {
@@ -798,7 +722,12 @@ fn render_navigator_overlay(
b,
i,
i.y,
- &format!("{} panes", s.panes.len()),
+ &format!(
+ "{} panes",
+ rows.iter()
+ .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .count()
+ ),
Style::default().fg(p.overlay0).bg(p.panel_bg),
);
put_text(
@@ -810,14 +739,12 @@ fn render_navigator_overlay(
Style::default().fg(p.surface1).bg(p.panel_bg),
);
let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4));
+ let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0);
let max = rows.len().saturating_sub(body.height as usize);
let scroll = n
.scroll
- .max(
- n.selected
- .saturating_sub(body.height.saturating_sub(1) as usize),
- )
- .min(n.selected)
+ .max(selected.saturating_sub(body.height.saturating_sub(1) as usize))
+ .min(selected)
.min(max);
let mut row_hits = Vec::new();
for (vis, (ix, r)) in rows
@@ -828,8 +755,17 @@ fn render_navigator_overlay(
.enumerate()
{
let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1);
- row_hits.push((rect, ix));
- let st = if ix == n.selected {
+ row_hits.push((rect, r.target.clone()));
+ let st = if r.stale {
+ Style::default()
+ .fg(p.overlay0)
+ .bg(if ix == selected {
+ p.surface0
+ } else {
+ p.panel_bg
+ })
+ .add_modifier(Modifier::DIM)
+ } else if ix == selected {
Style::default()
.fg(contrast(p))
.bg(p.accent)
@@ -840,23 +776,92 @@ fn render_navigator_overlay(
.bg(p.panel_bg)
};
b.set_style(rect, st);
- let tree = match r.depth {
- 0 => match &r.target {
- ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾",
- _ => "▸",
- },
- 1 => "└──",
- _ => " └──",
+ let tree = match &r.target {
+ ClientNavigatorTarget::Machine { .. } => "▾",
+ ClientNavigatorTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ } if n
+ .expanded_workspaces
+ .contains(&(endpoint_id.clone(), workspace_id.clone())) =>
+ {
+ if r.depth == 0 {
+ "▾"
+ } else {
+ " ▾"
+ }
+ }
+ ClientNavigatorTarget::Workspace { .. } => {
+ if r.depth == 0 {
+ "▸"
+ } else {
+ " ▸"
+ }
+ }
+ ClientNavigatorTarget::Tab { .. } => {
+ if r.depth == 1 {
+ "└──"
+ } else {
+ " └──"
+ }
+ }
+ ClientNavigatorTarget::Pane { .. } => {
+ if r.depth == 2 {
+ " └──"
+ } else {
+ " └──"
+ }
+ }
};
- let label = format!(
- " {} {} {} {}",
- if r.current { "◆" } else { " " },
- tree,
- status_dot(r.status),
- r.label
- );
+ let current = if r.current { "◆ " } else { "" };
+ let status = r.status.map(status_dot).unwrap_or_default();
+ let status_separator = if status.is_empty() { "" } else { " " };
+ let label = format!(" {tree} {current}{status}{status_separator}{}", r.label);
put_text(b, rect.x, rect.y, rect.width, &label, st);
- if !r.meta.is_empty() {
+ if let Some(status) = r.status {
+ let prefix = format!(" {tree} {current}");
+ let status_style = if r.stale || ix == selected {
+ st
+ } else {
+ Style::default().fg(status_color(status, p)).bg(p.panel_bg)
+ };
+ put_text(
+ b,
+ rect.x.saturating_add(display_width(&prefix)),
+ rect.y,
+ display_width(status_dot(status)),
+ status_dot(status),
+ status_style,
+ );
+ }
+ let machine_status = match &r.target {
+ ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .map(|endpoint| endpoint.status),
+ _ => None,
+ };
+ if let Some(status) = machine_status {
+ let (glyph, state, color) = endpoint_status_presentation(status, p);
+ let signal = if status == ClientEndpointStatus::Online {
+ glyph.to_owned()
+ } else {
+ format!("{glyph} {state}")
+ };
+ let signal_style = if ix == selected {
+ st
+ } else {
+ Style::default()
+ .fg(color)
+ .bg(p.panel_bg)
+ .add_modifier(if r.stale {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ })
+ };
+ put_right_text(b, rect, rect.y, &signal, signal_style);
+ } else if !r.meta.is_empty() {
let label_width = display_width(&label).min(rect.width);
let meta = Rect::new(
rect.x.saturating_add(label_width).saturating_add(1),
@@ -868,7 +873,7 @@ fn render_navigator_overlay(
}
}
let dy = i.bottom() - 2;
- if let Some(r) = rows.get(n.selected) {
+ if let Some(r) = rows.get(selected) {
put_text(
b,
i.x,
diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs
index 387695ec..ad9ac8b1 100644
--- a/src/client/shell/render.rs
+++ b/src/client/shell/render.rs
@@ -3,16 +3,31 @@ use super::*;
#[path = "../shell/overlays.rs"]
mod overlays;
#[path = "../shell/sidebar.rs"]
-mod sidebar;
+pub(in crate::client::shell) mod sidebar;
#[path = "../shell/tabs.rs"]
mod tabs;
pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel};
-pub(super) use overlays::{
- client_navigator_rows, render_client_overlay, render_context_menu, render_global_menu,
-};
+pub(super) use super::aggregate_navigation::navigator_rows as client_navigator_rows;
+pub(super) use overlays::{render_client_overlay, render_context_menu, render_global_menu};
pub(super) use sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries};
pub(super) use tabs::{render_tab_bar, tab_bar_status_width};
+
+pub(in crate::client::shell) fn render_sidebar_background(
+ buffer: &mut Buffer,
+ area: Rect,
+ palette: &Palette,
+) {
+ buffer.set_style(area, Style::default().bg(palette.sidebar_bg));
+ let separator_x = area.right().saturating_sub(1);
+ for y in area.y..area.bottom() {
+ if let Some(cell) = buffer.cell_mut((separator_x, y)) {
+ cell.set_symbol("│");
+ cell.set_style(Style::default().fg(palette.surface_dim));
+ }
+ }
+}
+
pub(super) fn render_mode_bar(
buffer: &mut Buffer,
pane_area: Rect,
@@ -196,6 +211,9 @@ pub(super) fn render_mode_bar(
}
pub(super) struct ShellRenderState<'a> {
+ pub(super) endpoints: &'a [ClientShellEndpoint],
+ pub(super) active_endpoint_id: &'a ClientEndpointId,
+ pub(super) collapsed_endpoints: &'a HashSet<ClientEndpointId>,
pub(super) collapsed_groups: &'a HashSet<String>,
pub(super) workspace_scroll: &'a mut usize,
pub(super) agent_scroll: &'a mut usize,
@@ -228,7 +246,26 @@ pub(super) fn render_shell(
);
}
if layout.sidebar.width > 0 {
- if state.sidebar_collapsed {
+ if state.endpoints.len() > 1 {
+ if state.sidebar_collapsed {
+ super::endpoint_sidebar::render_collapsed(
+ buffer,
+ layout.sidebar,
+ config,
+ &mut state,
+ &mut hits,
+ );
+ } else {
+ super::endpoint_sidebar::render_expanded(
+ buffer,
+ layout.sidebar,
+ Some(snapshot),
+ config,
+ &mut state,
+ &mut hits,
+ );
+ }
+ } else if state.sidebar_collapsed {
render_collapsed_sidebar(
buffer,
layout.sidebar,
@@ -267,8 +304,10 @@ pub(super) fn render_shell(
hits.agent_scrollbar = Rect::default();
hits.agent_sort_toggle = Rect::default();
hits.new_workspace = Rect::default();
+ hits.machines.clear();
hits.workspaces.clear();
hits.agents.clear();
+ hits.endpoint_agents.clear();
hits.tab_scroll_left = Rect::default();
hits.tab_scroll_right = Rect::default();
hits.new_tab = Rect::default();
@@ -277,7 +316,7 @@ pub(super) fn render_shell(
hits
}
-fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) {
+pub(super) fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) {
let width = display_width(text).min(area.width);
put_text(
buffer,
diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs
index d90d371f..200201c5 100644
--- a/src/client/shell/settings.rs
+++ b/src/client/shell/settings.rs
@@ -313,6 +313,9 @@ impl ClientShellState {
(true, Vec::new())
}
PendingEndpointKind::IntegrationInstall => {
+ let cancelled = result
+ .as_ref()
+ .is_err_and(|error| error.code.as_deref() == Some("endpoint_cancelled"));
self.pending_integration_installs =
self.pending_integration_installs.saturating_sub(1);
if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
@@ -328,7 +331,8 @@ impl ClientShellState {
}
settings.installing_integrations = self.pending_integration_installs > 0;
}
- let actions = if self.pending_integration_installs == 0
+ let actions = if !cancelled
+ && self.pending_integration_installs == 0
&& matches!(self.overlay, Some(ClientShellOverlay::Settings(_)))
{
let mut deferred = ClientShellInput::default();
diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs
index 2a71e297..0aaa0d51 100644
--- a/src/client/shell/sidebar.rs
+++ b/src/client/shell/sidebar.rs
@@ -4,7 +4,9 @@ use ratatui::{
widgets::{Paragraph, Widget},
};
-fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) {
+pub(in crate::client::shell) fn collapsed_sidebar_sections(
+ area: Rect,
+) -> (Rect, Option<u16>, Rect) {
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
if content.is_empty() {
return (Rect::default(), None, Rect::default());
@@ -85,6 +87,7 @@ pub(crate) fn render_collapsed_sidebar(
);
hits.workspaces.push(WorkspaceHit {
rect,
+ endpoint_id: ClientEndpointId::Local,
workspace_id: workspace.workspace_id.clone(),
indented: false,
group_toggle: None,
@@ -308,6 +311,7 @@ pub(crate) fn render_sidebar(
config.status_indicators,
entry,
rows,
+ true,
selected,
dragged,
palette,
@@ -330,6 +334,7 @@ pub(crate) fn render_sidebar(
});
hits.workspaces.push(WorkspaceHit {
rect,
+ endpoint_id: ClientEndpointId::Local,
workspace_id: workspace.workspace_id.clone(),
indented: entry.indented,
group_toggle,
@@ -440,17 +445,6 @@ pub(crate) fn render_sidebar(
);
}
-pub(crate) fn render_sidebar_background(buffer: &mut Buffer, area: Rect, palette: &Palette) {
- buffer.set_style(area, Style::default().bg(palette.sidebar_bg));
- let separator_x = area.right().saturating_sub(1);
- for y in area.y..area.bottom() {
- if let Some(cell) = buffer.cell_mut((separator_x, y)) {
- cell.set_symbol("│");
- cell.set_style(Style::default().fg(palette.surface_dim));
- }
- }
-}
-
pub(crate) fn workspace_entries(
snapshot: &ClientShellSnapshot,
collapsed_groups: &HashSet<String>,
@@ -540,7 +534,7 @@ pub(crate) fn workspace_entries(
entries
}
-fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> {
+pub(super) fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> {
let workspace = snapshot.workspaces.get(index)?;
let worktree = workspace.worktree.as_ref()?;
if worktree.is_linked_worktree {
@@ -560,7 +554,7 @@ fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<Stri
.then(|| worktree.key.clone())
}
-fn displayed_workspace_status(
+pub(super) fn displayed_workspace_status(
snapshot: &ClientShellSnapshot,
workspace: &ClientShellWorkspace,
collapsed_groups: &HashSet<String>,
@@ -589,7 +583,7 @@ fn displayed_workspace_status(
.unwrap_or(workspace.agent_status)
}
-fn workspace_rows(
+pub(in crate::client::shell) fn workspace_rows(
workspace: &ClientShellWorkspace,
status: crate::api::schema::AgentStatus,
indented: bool,
@@ -618,7 +612,7 @@ fn workspace_rows(
)
}
-fn render_workspace_rows(
+pub(in crate::client::shell) fn render_workspace_rows(
buffer: &mut Buffer,
area: Rect,
workspace: &ClientShellWorkspace,
@@ -626,6 +620,7 @@ fn render_workspace_rows(
indicators: crate::config::StatusIndicatorStyle,
entry: &WorkspaceEntry,
rows: Vec<Vec<crate::ui::ResolvedToken>>,
+ endpoint_active: bool,
selected: bool,
dragged: bool,
palette: &Palette,
@@ -661,7 +656,7 @@ fn render_workspace_rows(
} else {
x = x.saturating_add(3);
}
- let highlighted = workspace.focused || dragged;
+ let highlighted = endpoint_active && workspace.focused || dragged;
let workspace_style = Style::default()
.fg(if highlighted {
palette.text
@@ -673,7 +668,7 @@ fn render_workspace_rows(
} else {
Modifier::empty()
});
- let secondary_style = Style::default().fg(if workspace.focused {
+ let secondary_style = Style::default().fg(if endpoint_active && workspace.focused {
palette.mauve
} else {
palette.overlay0
@@ -703,7 +698,7 @@ fn render_workspace_rows(
Some(palette.selection_bg)
} else if dragged {
Some(palette.surface1)
- } else if workspace.focused {
+ } else if endpoint_active && workspace.focused {
Some(palette.active_row_bg)
} else {
None
diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs
index 60307f66..67bdff91 100644
--- a/src/client/shell/state.rs
+++ b/src/client/shell/state.rs
@@ -119,16 +119,27 @@ pub(super) struct ClientShellLayout {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ClientMobileTarget {
+ Machine(ClientEndpointId),
NewWorkspace,
- Workspace(String),
+ Workspace {
+ endpoint_id: ClientEndpointId,
+ workspace_id: String,
+ },
NewTab,
- Tab(String),
- Agent(String),
+ Tab {
+ endpoint_id: ClientEndpointId,
+ tab_id: String,
+ },
+ Agent {
+ endpoint_id: ClientEndpointId,
+ pane_id: String,
+ },
Menu(usize),
}
#[derive(Default)]
pub(super) struct ShellHitMap {
+ pub(super) machines: Vec<MachineHit>,
pub(super) workspaces: Vec<WorkspaceHit>,
pub(super) workspace_body: Rect,
pub(super) workspace_scrollbar: Rect,
@@ -139,6 +150,7 @@ pub(super) struct ShellHitMap {
pub(super) popup: Option<PaneHit>,
pub(super) pane_splits: Vec<PaneSplitHit>,
pub(super) agents: Vec<(Rect, String)>,
+ pub(super) endpoint_agents: Vec<(Rect, ClientEndpointId, String)>,
pub(super) agent_body: Rect,
pub(super) agent_scrollbar: Rect,
pub(super) agent_scroll_metrics: Option<crate::pane::ScrollMetrics>,
@@ -164,7 +176,7 @@ pub(super) struct ShellHitMap {
pub(super) overlay_cancel: Rect,
pub(super) navigator_popup: Rect,
pub(super) navigator_search: Rect,
- pub(super) navigator_rows: Vec<(Rect, usize)>,
+ pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
pub(super) worktree_search: Rect,
pub(super) worktree_rows: Vec<(Rect, usize)>,
pub(super) help_popup: Rect,
@@ -215,6 +227,7 @@ pub(super) struct ClientPaneMouseGesture {
}
pub(super) struct ClientWorkspacePress {
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) workspace_id: String,
pub(super) start_column: u16,
pub(super) start_row: u16,
@@ -271,6 +284,7 @@ pub(super) enum ClientChromeDrag {
pub(super) struct WorkspaceHit {
pub(super) rect: Rect,
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) workspace_id: String,
pub(super) indented: bool,
pub(super) group_toggle: Option<(Rect, String)>,
@@ -279,12 +293,17 @@ pub(super) struct WorkspaceHit {
#[derive(Debug)]
pub(crate) enum ClientShellAction {
Endpoint {
+ endpoint_id: ClientEndpointId,
boot_id: String,
request: Box<crate::api::schema::Request>,
},
ClipboardWrite(Vec<u8>),
Request(ClientMessage),
OpenSafeWebUrl(String),
+ ActivateEndpoint {
+ endpoint_id: ClientEndpointId,
+ target: Option<ClientEndpointFocusTarget>,
+ },
ReplayMouse(Vec<crossterm::event::MouseEvent>),
Keybind(crate::input::KeybindAction),
}
@@ -366,11 +385,23 @@ pub(super) enum ClientNavigatorFilter {
Done,
}
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum ClientNavigatorTarget {
- Workspace(String),
- Tab(String),
- Pane(String),
+ Machine {
+ endpoint_id: ClientEndpointId,
+ },
+ Workspace {
+ endpoint_id: ClientEndpointId,
+ workspace_id: String,
+ },
+ Tab {
+ endpoint_id: ClientEndpointId,
+ tab_id: String,
+ },
+ Pane {
+ endpoint_id: ClientEndpointId,
+ pane_id: String,
+ },
}
#[derive(Clone, Debug)]
@@ -378,7 +409,8 @@ pub(super) struct ClientNavigatorRow {
pub(super) depth: u8,
pub(super) label: String,
pub(super) meta: String,
- pub(super) status: crate::api::schema::AgentStatus,
+ pub(super) status: Option<crate::api::schema::AgentStatus>,
+ pub(super) stale: bool,
pub(super) current: bool,
pub(super) target: ClientNavigatorTarget,
}
@@ -387,10 +419,10 @@ pub(super) struct ClientNavigatorRow {
pub(super) struct ClientNavigatorOverlay {
pub(super) query: String,
pub(super) search_focused: bool,
- pub(super) selected: usize,
+ pub(super) selected: Option<ClientNavigatorTarget>,
pub(super) scroll: usize,
pub(super) filter: Option<ClientNavigatorFilter>,
- pub(super) expanded_workspaces: HashSet<String>,
+ pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>,
}
#[derive(Debug)]
@@ -741,12 +773,15 @@ pub(crate) enum ClientShellNotificationEffect {
}
pub(super) struct ClientPendingNotification {
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) event: SemanticNotification,
pub(super) deadline: std::time::Instant,
+ pub(super) expires_at: std::time::Instant,
pub(super) validate_state: bool,
}
pub(super) struct ClientVisibleNotification {
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) event: SemanticNotification,
pub(super) deadline: std::time::Instant,
}
@@ -861,6 +896,9 @@ pub(crate) struct ClientShellState {
pub(super) config: ClientShellConfig,
pub(super) snapshot: Option<Box<ClientShellSnapshot>>,
pub(super) pane_surface: Option<PaneSurfaceFrame>,
+ /// A future projection surface waits here until its matching snapshot arrives. The visible
+ /// pane surface always remains an exact snapshot pair.
+ pub(super) pending_pane_surface: Option<PaneSurfaceFrame>,
pub(super) graphics: crate::kitty_graphics::surface::ClientState,
pub(super) graphics_cell_size: crate::kitty_graphics::HostCellSize,
pub(super) popup_terminal_id: Option<String>,
@@ -887,6 +925,9 @@ pub(crate) struct ClientShellState {
pub(super) last_tab_bar_width: Option<u16>,
pub(super) last_composed_size: Option<(u16, u16)>,
pub(super) hits: ShellHitMap,
+ pub(super) endpoints: Vec<ClientShellEndpoint>,
+ pub(super) active_endpoint_id: ClientEndpointId,
+ pub(super) collapsed_endpoints: HashSet<ClientEndpointId>,
pub(super) mode: ClientShellMode,
pub(super) navigate_workspace_id: Option<String>,
pub(super) overlay: Option<ClientShellOverlay>,
@@ -917,14 +958,11 @@ pub(crate) struct ClientShellState {
pub(super) popup_pending: bool,
pub(super) popup_pending_deadline: Option<std::time::Instant>,
pub(super) next_request_id: u64,
- /// Methods advertised by this endpoint. `None` is used only by local tests
- /// and legacy construction paths; negotiated endpoint connections always
- /// install an explicit set.
- pub(super) endpoint_methods: Option<HashSet<String>>,
pub(super) pending_requests: HashMap<String, PendingEndpointRequest>,
pub(super) pending_integration_installs: usize,
pub(super) pending_notifications: Vec<ClientPendingNotification>,
pub(super) visible_notification: Option<ClientVisibleNotification>,
+ pub(super) queued_notifications: VecDeque<ClientVisibleNotification>,
pub(super) endpoint_notice_seen: HashSet<ClientEndpointNoticeKey>,
pub(super) visible_endpoint_notice: Option<ClientVisibleEndpointNotice>,
pub(super) outer_focused: Option<bool>,
@@ -1000,6 +1038,7 @@ impl ClientShellState {
config,
snapshot: None,
pane_surface: None,
+ pending_pane_surface: None,
graphics: crate::kitty_graphics::surface::ClientState::default(),
graphics_cell_size: crate::kitty_graphics::HostCellSize {
width_px: 1,
@@ -1029,6 +1068,9 @@ impl ClientShellState {
last_tab_bar_width: None,
last_composed_size: None,
hits: ShellHitMap::default(),
+ endpoints: vec![local_endpoint()],
+ active_endpoint_id: ClientEndpointId::Local,
+ collapsed_endpoints: HashSet::new(),
mode: ClientShellMode::Terminal,
navigate_workspace_id: None,
overlay,
@@ -1059,11 +1101,11 @@ impl ClientShellState {
popup_pending: false,
popup_pending_deadline: None,
next_request_id: 1,
- endpoint_methods: None,
pending_requests: HashMap::new(),
pending_integration_installs: 0,
pending_notifications: Vec::new(),
visible_notification: None,
+ queued_notifications: VecDeque::new(),
endpoint_notice_seen: HashSet::new(),
visible_endpoint_notice: None,
outer_focused: None,
@@ -1078,29 +1120,6 @@ impl ClientShellState {
}
}
- pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) {
- self.endpoint_methods = methods.map(|methods| methods.into_iter().collect());
- }
-
- pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool {
- self.endpoint_methods
- .as_ref()
- .is_none_or(|methods| methods.contains(crate::api::api_method_name(method)))
- }
-
- fn focused_tab_count(&self) -> usize {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return 0;
- };
- snapshot
- .tabs
- .iter()
- .filter(|tab| {
- Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref()
- })
- .count()
- }
-
pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool {
if !self.mobile_switcher_suspended || self.overlay.is_some() {
return false;
@@ -1173,16 +1192,80 @@ impl ClientShellState {
}
}
- pub(crate) fn set_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) {
+ pub(super) fn reset_endpoint_projection(&mut self) {
+ self.hits = ShellHitMap::default();
+ self.pane_surface = None;
+ self.pending_pane_surface = None;
+ self.input_leases = ClientInputLeases::default();
+ self.popup_terminal_id = None;
+ self.chrome_drag = None;
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.workspace_scroll = 0;
+ self.agent_scroll = 0;
+ self.tab_scroll = 0;
+ self.mobile_switcher_scroll = 0;
+ self.reveal_focused_workspace = true;
+ self.reveal_mobile_workspace = false;
+ self.mobile_switcher_suspended = false;
+ self.reveal_focused_tab = true;
+ self.last_tab_bar_width = None;
+ self.last_composed_size = None;
+ self.pending_requests.clear();
+ self.pane_scroll_in_flight.clear();
+ self.pane_scroll_queued.clear();
+ self.pane_scroll_targets.clear();
+ self.popup_pending = false;
+ self.popup_pending_deadline = None;
+ self.pending_integration_installs = 0;
+ self.endpoint_notice_seen.clear();
+ self.visible_endpoint_notice = None;
+ self.endpoint_error = None;
+ self.navigate_workspace_id = None;
+ self.overlay = self
+ .config
+ .startup_onboarding
+ .then_some(ClientShellOverlay::Onboarding);
+ self.previous_pane_id = None;
+ self.pane_mouse_gesture = None;
+ self.url_click_consumes_until_up = false;
+ self.replaying_url_click = false;
+ self.selection = None;
+ self.last_pane_click = None;
+ self.selection_autoscroll = None;
+ self.selection_autoscroll_deadline = None;
+ self.selection_highlight_clear_deadline = None;
+ self.pending_word_selection = None;
+ self.copy_mode = None;
+ if self.mode == ClientShellMode::Copy {
+ self.mode = ClientShellMode::Terminal;
+ }
+ self.reset_copy_pipeline();
+ self.copy_feedback = None;
+ self.copy_feedback_deadline = None;
+ self.host_mouse_pixels = None;
+ self.dismissed_product_announcement = None;
+ }
+
+ pub(super) fn apply_active_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) {
snapshot
.commands
.retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown);
- if self.snapshot.as_ref().is_some_and(|current| {
- current.boot_id == snapshot.boot_id && snapshot.revision < current.revision
- }) {
+ let graphics_scope = format!(
+ "{}:{}",
+ self.active_endpoint_id.storage_key(),
+ snapshot.boot_id
+ );
+ let endpoint_boot_changed =
+ self.snapshot.is_some() && self.graphics.scope() != graphics_scope;
+ if !endpoint_boot_changed
+ && self.snapshot.as_ref().is_some_and(|current| {
+ current.boot_id == snapshot.boot_id && snapshot.revision < current.revision
+ })
+ {
return;
}
- self.graphics.set_scope(&snapshot.boot_id);
+ self.graphics.set_scope(&graphics_scope);
let command_bindings_changed = self.snapshot.as_ref().is_none_or(|current| {
current.commands.len() != snapshot.commands.len()
|| current
@@ -1221,10 +1304,11 @@ impl ClientShellState {
self.local_config_diagnostic.as_deref(),
snapshot.config_diagnostic.as_deref(),
);
- let boot_changed = self
- .snapshot
- .as_ref()
- .is_some_and(|current| current.boot_id != snapshot.boot_id);
+ let boot_changed = endpoint_boot_changed
+ || self
+ .snapshot
+ .as_ref()
+ .is_some_and(|current| current.boot_id != snapshot.boot_id);
if boot_changed
|| self
.pane_surface
@@ -1234,55 +1318,7 @@ impl ClientShellState {
self.hits = ShellHitMap::default();
}
if boot_changed {
- self.pane_surface = None;
- self.input_leases = ClientInputLeases::default();
- self.popup_terminal_id = None;
- self.chrome_drag = None;
- self.workspace_press = None;
- self.tab_press = None;
- self.workspace_scroll = 0;
- self.agent_scroll = 0;
- self.tab_scroll = 0;
- self.mobile_switcher_scroll = 0;
- self.reveal_focused_workspace = true;
- self.reveal_mobile_workspace = false;
- self.mobile_switcher_suspended = false;
- self.reveal_focused_tab = true;
- self.last_tab_bar_width = None;
- self.last_composed_size = None;
- self.pending_requests.clear();
- self.pane_scroll_in_flight.clear();
- self.pane_scroll_queued.clear();
- self.pane_scroll_targets.clear();
- self.popup_pending = false;
- self.popup_pending_deadline = None;
- self.pending_integration_installs = 0;
- self.pending_notifications.clear();
- self.visible_notification = None;
- self.endpoint_notice_seen.clear();
- self.visible_endpoint_notice = None;
- self.endpoint_error = None;
- self.navigate_workspace_id = None;
- self.overlay = self
- .config
- .startup_onboarding
- .then_some(ClientShellOverlay::Onboarding);
- self.previous_pane_id = None;
- self.pane_mouse_gesture = None;
- self.url_click_consumes_until_up = false;
- self.replaying_url_click = false;
- self.selection = None;
- self.last_pane_click = None;
- self.selection_autoscroll = None;
- self.selection_autoscroll_deadline = None;
- self.selection_highlight_clear_deadline = None;
- self.pending_word_selection = None;
- self.copy_mode = None;
- self.reset_copy_pipeline();
- self.copy_feedback = None;
- self.copy_feedback_deadline = None;
- self.host_mouse_pixels = None;
- self.dismissed_product_announcement = None;
+ self.reset_endpoint_projection();
} else if let Some(previous) = self
.snapshot
.as_deref()
@@ -1474,16 +1510,64 @@ impl ClientShellState {
}
}
self.snapshot = Some(snapshot);
+ let pending_surface = self.pending_pane_surface.take();
+ if let Some(surface) = pending_surface {
+ let matching = self.snapshot.as_ref().is_some_and(|snapshot| {
+ surface.boot_id == snapshot.boot_id
+ && surface.projection_revision == snapshot.revision
+ });
+ if matching {
+ self.install_pane_surface(surface, false);
+ } else if self.snapshot.as_ref().is_some_and(|snapshot| {
+ surface.boot_id == snapshot.boot_id
+ && surface.projection_revision > snapshot.revision
+ }) {
+ self.pending_pane_surface = Some(surface);
+ }
+ }
self.resume_mobile_switcher_if_ready();
self.reconcile_input_source();
}
- pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) {
+ pub(crate) fn has_presented_surface(&self) -> bool {
+ self.pane_surface.is_some()
+ }
+
+ pub(crate) fn set_pane_surface(&mut self, surface: PaneSurfaceFrame) {
+ let Some(snapshot) = self.snapshot.as_ref() else {
+ return;
+ };
+ if surface.boot_id != snapshot.boot_id || surface.projection_revision < snapshot.revision {
+ return;
+ }
+ if self.pane_surface.as_ref().is_some_and(|current| {
+ current.boot_id == surface.boot_id
+ && (surface.projection_revision < current.projection_revision
+ || (surface.projection_revision == current.projection_revision
+ && surface.surface_revision < current.surface_revision))
+ }) {
+ return;
+ }
+ if surface.projection_revision == snapshot.revision.saturating_add(1) {
+ // The next expected surface waits separately for its exact snapshot. Keeping the
+ // current pair avoids treating this speculative successor as presentation evidence.
+ self.pending_pane_surface = Some(surface);
+ self.hits = ShellHitMap::default();
+ return;
+ }
+ // A surface that skips one or more revisions supersedes any retained pair, but is still
+ // not rendered until its matching snapshot arrives. Retain it monotonically so delayed
+ // intermediate surfaces cannot replace it.
+ self.install_pane_surface(surface, true);
+ }
+
+ fn install_pane_surface(&mut self, mut surface: PaneSurfaceFrame, retain_future: bool) {
let Some(snapshot) = self.snapshot.as_ref() else {
return;
};
if surface.boot_id != snapshot.boot_id
|| surface.projection_revision < snapshot.revision
+ || (!retain_future && surface.projection_revision != snapshot.revision)
|| self.pane_surface.as_ref().is_some_and(|current| {
current.boot_id == surface.boot_id
&& (surface.projection_revision < current.projection_revision
@@ -1493,13 +1577,12 @@ impl ClientShellState {
{
return;
}
- if self
- .snapshot
- .as_ref()
- .is_none_or(|snapshot| snapshot.revision != surface.projection_revision)
- {
+ // A retained future surface is not presentable yet. Clear hit targets immediately; the
+ // exact-pair compose guard prevents it from replacing the visible frame.
+ if surface.projection_revision != snapshot.revision {
self.hits = ShellHitMap::default();
}
+ self.acknowledge_active_surface_agents(&surface);
let previous_popup = self.popup_terminal_id.clone();
let next_popup = surface
.popup
@@ -1696,45 +1779,8 @@ impl ClientShellState {
pub(crate) fn invalidate_pane_surface(&mut self) {
self.pane_surface = None;
+ self.pending_pane_surface = None;
self.hits = ShellHitMap::default();
self.host_mouse_pixels = None;
}
-
- fn wants_ascii_input(&self) -> bool {
- if let Some(overlay) = self.overlay.as_ref() {
- return matches!(
- overlay,
- ClientShellOverlay::ConfirmClose(_)
- | ClientShellOverlay::Help(_)
- | ClientShellOverlay::Navigator(_)
- | ClientShellOverlay::WorktreeRemove(_)
- | ClientShellOverlay::ContextMenu(_)
- | ClientShellOverlay::GlobalMenu(_)
- );
- }
- matches!(
- self.mode,
- ClientShellMode::Prefix
- | ClientShellMode::Navigate
- | ClientShellMode::Resize
- | ClientShellMode::Copy
- )
- }
-
- pub(crate) fn reconcile_input_source(&mut self) {
- // Keep the platform restore token while another window has focus. Restoring
- // through a global key injection is only safe after this client regains focus.
- if self.outer_focused == Some(false) {
- return;
- }
- let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input();
- if desired != self.ascii_input_source_active {
- self.ascii_input_source_active = desired;
- self.pending_input_source_changes.push(desired);
- }
- }
-
- pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> {
- std::mem::take(&mut self.pending_input_source_changes)
- }
}
diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs
index 2ccfe77b..10f97118 100644
--- a/src/client/shell/tests/agents_worktrees_notifications.rs
+++ b/src/client/shell/tests/agents_worktrees_notifications.rs
@@ -966,9 +966,7 @@ fn worktree_create_previews_the_endpoint_owned_checkout_path() {
&state.overlay,
Some(ClientShellOverlay::WorktreeCreate(create))
if create.checkout_path
- == std::path::Path::new("/tmp/herdr-worktrees")
- .join("repo")
- .join("feature-client-shell").to_string_lossy()
+ == "/tmp/herdr-worktrees/repo/feature-client-shell"
));
let submit = state.handle_input_bytes(b"\r");
let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else {
@@ -1154,6 +1152,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
state.set_pane_surface(surface());
let now = std::time::Instant::now();
let (effects, repaint) = state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "codex needs attention".into(),
@@ -1215,6 +1214,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
assert!(state.visible_notification.is_none());
state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "codex needs attention".into(),
@@ -1245,6 +1245,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
assert!(state.visible_notification.is_none());
state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "first".into(),
@@ -1261,6 +1262,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
assert!(state.visible_notification.is_some());
state.config.toast_delay_seconds = 1;
let (_, repaint) = state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "replacement".into(),
diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs
index e8555db6..7d71a981 100644
--- a/src/client/shell/tests/copy.rs
+++ b/src/client/shell/tests/copy.rs
@@ -766,22 +766,22 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() {
state.handle_input_bytes(b"\x1b");
state.handle_input_bytes(b"a");
state.compose(106, 30).expect("navigator rows");
- let pane_index = {
- let snapshot = state.snapshot.as_deref().expect("snapshot");
+ let pane_target = {
let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
- render::client_navigator_rows(snapshot, navigator)
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
.iter()
- .position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_)))
+ .find(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .map(|row| row.target.clone())
.expect("pane row")
};
let pane_rect = state
.hits
.navigator_rows
.iter()
- .find(|(_, index)| *index == pane_index)
+ .find(|(_, target)| *target == pane_target)
.map(|(rect, _)| *rect)
.expect("visible pane row");
let select =
diff --git a/src/client/shell/tests/endpoint_requests.rs b/src/client/shell/tests/endpoint_requests.rs
new file mode 100644
index 00000000..8149b530
--- /dev/null
+++ b/src/client/shell/tests/endpoint_requests.rs
@@ -0,0 +1,298 @@
+use super::*;
+use crate::client::endpoint::{ClientEndpointId, ClientEndpointStatus};
+
+fn pending_popup() -> (ClientShellState, Vec<ClientShellAction>) {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let binding = crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::prefix("t"),
+ label: "prefix+t".into(),
+ command: "popup-command".into(),
+ action: crate::config::CustomCommandAction::Popup,
+ description: None,
+ width: None,
+ height: None,
+ };
+ let mut projection = snapshot();
+ projection
+ .commands
+ .push(crate::protocol::ClientShellCommand {
+ command_id: "cmd_popup".into(),
+ binding_label: binding.label.clone(),
+ binding_labels: binding.bindings.labels(),
+ action: crate::protocol::ClientShellCommandAction::Popup,
+ description: None,
+ });
+ state.set_snapshot(Box::new(projection));
+ state.set_pane_surface(surface());
+ let mut outcome = ClientShellInput::default();
+ state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome);
+ assert!(state.popup_pending);
+ (state, outcome.actions)
+}
+
+fn pending_worktree() -> (ClientShellState, Vec<ClientShellAction>) {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut outcome = ClientShellInput::default();
+ state.record_binding(
+ crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree),
+ &mut outcome,
+ );
+ let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
+ panic!("expected worktree preparation");
+ };
+ state.handle_endpoint_result("boot-1", &request.id, Ok(worktree_list_result(None)));
+ state.handle_input_bytes(b"feature/reconnect");
+ let outcome = state.handle_input_bytes(b"\r");
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if create.creating
+ ));
+ (state, outcome.actions)
+}
+
+fn request_id(actions: &[ClientShellAction]) -> &str {
+ let [ClientShellAction::Endpoint { request, .. }] = actions else {
+ panic!("expected one endpoint request");
+ };
+ &request.id
+}
+
+#[test]
+fn cancelling_popup_request_unblocks_input_and_ignores_late_success() {
+ let (mut state, actions) = pending_popup();
+ let id = request_id(&actions);
+ assert!(state.cancel_endpoint_request(id));
+ assert!(!state.popup_pending);
+ assert!(state.pending_requests.is_empty());
+ assert!(state
+ .handle_endpoint_result("boot-1", id, Ok(crate::api::schema::ResponseResult::Ok {}))
+ .1
+ .is_empty());
+ assert!(!state.popup_pending);
+ assert!(!state.handle_input_bytes(b"x").requests.is_empty());
+}
+
+#[test]
+fn disconnect_cancels_worktree_dialog_before_same_server_reconnect() {
+ let (mut state, _) = pending_worktree();
+ state.mark_endpoint_disconnected(&ClientEndpointId::Local);
+ assert!(state.pending_requests.is_empty());
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating
+ ));
+ state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online);
+ state.set_endpoint_snapshot(&ClientEndpointId::Local, Box::new(snapshot()));
+ assert!(state.activate_endpoint_projection(&ClientEndpointId::Local));
+ state.set_pane_surface(surface());
+ state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
+ KeyCode::Esc,
+ KeyModifiers::NONE,
+ ))]);
+ assert!(state.overlay.is_none());
+ assert!(!state.handle_input_bytes(b"x").requests.is_empty());
+}
+
+struct TestTransport {
+ fail: bool,
+}
+
+impl crate::client::endpoint::EndpointTransport for TestTransport {
+ fn send(&mut self, _: &ClientMessage) -> std::io::Result<()> {
+ if self.fail {
+ Err(std::io::ErrorKind::BrokenPipe.into())
+ } else {
+ Ok(())
+ }
+ }
+}
+
+#[test]
+fn dispatcher_cancels_worktree_requests_on_frozen_surface_or_failed_send() {
+ use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry};
+ use crate::client::endpoint_commands::EndpointCommands;
+
+ for fail_send in [false, true] {
+ let (mut state, actions) = pending_worktree();
+ let mut endpoints = EndpointRegistry::new(
+ TestTransport { fail: fail_send },
+ 1,
+ EndpointNegotiation::default(),
+ );
+ endpoints.set_surface_active(&ClientEndpointId::Local, fail_send);
+ let mut commands = EndpointCommands::default();
+ let (tx, _rx) = tokio::sync::mpsc::channel(16);
+ let (replay, repaint) = crate::client::shell_runtime::dispatch_client_shell_actions(
+ actions,
+ &mut commands,
+ &mut endpoints,
+ Some(&mut state),
+ &mut Vec::new(),
+ &tx,
+ )
+ .unwrap();
+ assert!(repaint);
+ assert!(replay.is_empty());
+ assert!(state.pending_requests.is_empty());
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating
+ ));
+ assert!(state
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|notice| { notice.title == "Action interrupted" }));
+ assert!(commands.disconnect(&ClientEndpointId::Local).is_empty());
+ }
+}
+
+#[test]
+fn stale_queued_request_is_cancelled_without_blocking_the_current_generation() {
+ use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry};
+ use crate::client::endpoint_commands::EndpointCommands;
+
+ let (mut state, actions) = pending_popup();
+ let stale_id = request_id(&actions).to_owned();
+ let current = state.focus_endpoint_target(ClientEndpointFocusTarget::Workspace("ws_1".into()));
+ let current_id = request_id(&current).to_owned();
+ let mut commands = EndpointCommands::default();
+ for (generation, actions) in [(1, actions), (2, current)] {
+ for action in actions {
+ let ClientShellAction::Endpoint {
+ endpoint_id,
+ boot_id,
+ request,
+ } = action
+ else {
+ panic!("expected endpoint request");
+ };
+ commands.enqueue(endpoint_id, generation, boot_id, request);
+ }
+ }
+ let mut endpoints = EndpointRegistry::new(
+ TestTransport { fail: false },
+ 2,
+ EndpointNegotiation::default(),
+ );
+ let cancelled = commands.send_next(&ClientEndpointId::Local, &mut endpoints);
+ assert_eq!(cancelled, vec![stale_id.clone()]);
+ state.cancel_endpoint_request(&stale_id);
+ assert!(!state.popup_pending);
+ assert!(!commands.accepts_response(&ClientEndpointId::Local, 1, "boot-1", &stale_id));
+ assert!(commands.accepts_response(&ClientEndpointId::Local, 2, "boot-1", &current_id));
+ assert!(state.pending_requests.contains_key(&current_id));
+}
+
+#[test]
+fn cancelled_integration_install_does_not_queue_a_refresh() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.open_settings_overlay();
+ let Some(ClientShellOverlay::Settings(settings)) = state.overlay.as_mut() else {
+ panic!("settings overlay");
+ };
+ settings.installing_integrations = true;
+ state.pending_integration_installs = 1;
+ let mut outcome = ClientShellInput::default();
+ assert!(state.push_endpoint_method_with_kind(
+ crate::api::schema::Method::IntegrationInstall(
+ crate::api::schema::IntegrationInstallParams {
+ target: crate::api::schema::IntegrationTarget::Pi,
+ }
+ ),
+ PendingEndpointKind::IntegrationInstall,
+ &mut outcome,
+ ));
+ assert!(state.cancel_endpoint_request(request_id(&outcome.actions)));
+ assert!(state.pending_requests.is_empty());
+ assert_eq!(state.pending_integration_installs, 0);
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::Settings(settings))
+ if !settings.installing_integrations && !settings.loading_integrations
+ ));
+}
+
+#[test]
+fn cancelled_selection_does_not_replay_its_fallback_key() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.selection = Some(crate::selection::Selection::absolute_range(
+ "pane_1".into(),
+ (0, 0),
+ (0, 2),
+ ));
+ let mut outcome = ClientShellInput::default();
+ state.request_selection_copy_with_fallback(
+ &mut outcome,
+ Some(crate::input::TerminalKey::new(
+ KeyCode::Char('c'),
+ KeyModifiers::CONTROL,
+ )),
+ );
+ let (_, actions) = state.handle_endpoint_result(
+ "boot-1",
+ request_id(&outcome.actions),
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_cancelled".into()),
+ message: "cancelled".into(),
+ }),
+ );
+ assert!(actions.is_empty());
+ assert!(state.pending_requests.is_empty());
+}
+
+#[test]
+fn cancelled_link_activation_does_not_replay_mouse_input() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.compose(100, 28).unwrap();
+ let pane_id = state.hits.panes[0].pane_id.clone();
+ let inner_rect = state.hits.panes[0].inner_rect;
+ let mut outcome = ClientShellInput::default();
+ state.push_endpoint_method_with_kind(
+ crate::api::schema::Method::PaneLinkActivate(crate::api::schema::PaneLinkActivateParams {
+ pane_id: pane_id.clone(),
+ viewport_row: 0,
+ col: 0,
+ content_revision: None,
+ offset_from_bottom: None,
+ }),
+ PendingEndpointKind::PaneLinkActivate {
+ pane_id,
+ inner_rect,
+ fallback_events: vec![MouseEvent {
+ kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
+ column: inner_rect.x,
+ row: inner_rect.y,
+ modifiers: KeyModifiers::NONE,
+ }],
+ },
+ &mut outcome,
+ );
+ let (_, actions) = state.handle_endpoint_result(
+ "boot-1",
+ request_id(&outcome.actions),
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_cancelled".into()),
+ message: "cancelled".into(),
+ }),
+ );
+ assert!(actions.is_empty());
+ assert!(state.url_click_consumes_until_up);
+}
+
+#[test]
+fn another_machine_disconnect_does_not_cancel_active_popup() {
+ let (mut state, _) = pending_popup();
+ let remote = ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
+ state.mark_endpoint_disconnected(&remote);
+ assert!(state.popup_pending);
+ assert_eq!(state.pending_requests.len(), 1);
+}
diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs
new file mode 100644
index 00000000..52c71923
--- /dev/null
+++ b/src/client/shell/tests/endpoints.rs
@@ -0,0 +1,1205 @@
+use super::*;
+use crate::client::endpoint::{
+ ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint,
+};
+use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
+
+fn remote_profile() -> SavedSshEndpoint {
+ SavedSshEndpoint {
+ id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Build".into(),
+ target: "dev@build.example".into(),
+ session: "agents".into(),
+ enabled: true,
+ }
+}
+
+fn agent(
+ name: &str,
+ status: crate::api::schema::AgentStatus,
+ state_change_seq: u64,
+) -> ClientShellAgent {
+ ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some(name.into()),
+ display_agent: None,
+ agent: Some("pi".into()),
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: status,
+ state_change_seq,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: true,
+ }
+}
+
+fn state_with_remote() -> (ClientShellState, ClientEndpointId) {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let profile = remote_profile();
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.workspaces[0].label = "remote-workspace".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ (state, endpoint_id)
+}
+
+#[test]
+fn switching_machines_from_copy_mode_restores_terminal_input() {
+ let (mut state, remote) = state_with_remote();
+ let mut local_surface = surface();
+ local_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 20,
+ viewport_rows: 2,
+ });
+ state.set_pane_surface(local_surface);
+ state.compose(100, 28).unwrap();
+ assert!(state.enter_copy_mode(&mut ClientShellInput::default()));
+ assert_eq!(state.mode, ClientShellMode::Copy);
+
+ assert!(state.activate_endpoint_projection(&remote));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+ state.compose(100, 28).unwrap();
+
+ assert!(state.copy_mode.is_none());
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ let input = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
+ KeyCode::Char('x'),
+ KeyModifiers::NONE,
+ ))]);
+ assert!(matches!(
+ input.requests.as_slice(),
+ [ClientMessage::ClientShellPaneInput { pane_id, events }]
+ if pane_id == "pane_1" && events.len() == 1
+ ));
+}
+
+#[test]
+fn live_catalog_rename_preserves_snapshot_and_disable_reenable_clears_it() {
+ let (mut state, remote) = state_with_remote();
+ let mut profile = remote_profile();
+ profile.label = "Renamed".into();
+ state.set_endpoint_catalog(&[profile.clone()]);
+ assert_eq!(state.endpoint_label(&remote), "Renamed");
+ assert!(state.endpoint_is_online(&remote));
+ assert_eq!(state.endpoint_boot_id(&remote), Some("remote-boot"));
+ profile.enabled = false;
+ state.set_endpoint_catalog(&[profile.clone()]);
+ assert_eq!(
+ state.endpoint_status(&remote),
+ Some(ClientEndpointStatus::Disabled)
+ );
+ assert!(!state.endpoint_has_snapshot(&remote));
+ profile.enabled = true;
+ state.set_endpoint_catalog(&[profile]);
+ assert_eq!(
+ state.endpoint_status(&remote),
+ Some(ClientEndpointStatus::Connecting)
+ );
+ assert!(!state.endpoint_has_snapshot(&remote));
+}
+
+#[test]
+fn live_catalog_active_removal_does_not_retain_remote_projection_or_input() {
+ let (mut state, remote) = state_with_remote();
+ assert!(state.activate_endpoint_projection(&remote));
+ state.set_pane_surface(surface());
+ state.mode = ClientShellMode::Prefix;
+ state.overlay = Some(ClientShellOverlay::Onboarding);
+ state.select_unavailable_local();
+ state.retire_endpoint(&remote);
+ state.set_endpoint_catalog(&[]);
+ assert!(state.endpoint_is_active(&ClientEndpointId::Local));
+ assert!(state.snapshot.is_none());
+ assert!(state.pane_surface.is_none());
+ assert!(state.pending_pane_surface.is_none());
+ assert!(state.overlay.is_none());
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ assert!(state.endpoint_has_snapshot(&ClientEndpointId::Local));
+ let frame = state.compose(100, 30).unwrap();
+ let buffer = frame.to_ratatui_buffer().unwrap();
+ let text = buffer
+ .content()
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+ assert!(!text.contains("remote-workspace"));
+}
+
+#[test]
+fn machine_navigation_does_not_require_a_local_snapshot_or_surface() {
+ for (cols, rows) in [(100, 28), (36, 18)] {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let profile = remote_profile();
+ let remote = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting);
+ state.set_endpoint_status(&remote, ClientEndpointStatus::Online);
+ state.set_endpoint_snapshot(&remote, Box::new(snapshot()));
+ assert!(state.snapshot.is_none());
+ assert!(state.pane_surface.is_none());
+ let frame = state
+ .compose(cols, rows)
+ .expect("connection chrome without Local");
+ let local = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .unwrap()
+ .rect;
+ let buffer = frame.to_ratatui_buffer().unwrap();
+ let local_row = (local.x..local.right())
+ .map(|x| buffer[(x, local.y)].symbol())
+ .collect::<String>();
+ assert!(!local_row.contains("reconnecting"));
+ assert!(
+ !local_row.contains('◐'),
+ "Local never gets a connection badge"
+ );
+ let hit = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == remote)
+ .unwrap()
+ .rect;
+ let mut outcome = ClientShellInput::default();
+ state.handle_mouse(
+ crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: hit.x + 5,
+ row: hit.y,
+ modifiers: KeyModifiers::NONE,
+ },
+ &mut outcome,
+ );
+ assert!(
+ matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint { endpoint_id, .. }] if endpoint_id == &remote)
+ );
+ assert!(
+ state.snapshot.is_none(),
+ "selection is committed only by coherent activation"
+ );
+ }
+}
+
+#[test]
+fn sidebar_renders_local_and_saved_ssh_endpoints_with_status() {
+ let (mut state, _) = state_with_remote();
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ let text = frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ assert!(text.contains("Local"));
+ assert!(text.contains("Build"));
+ assert!(text.contains("remote-workspace"));
+ let local = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("local machine row")
+ .rect;
+ let remote = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| !hit.endpoint_id.is_local())
+ .expect("remote machine row")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●");
+ assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●");
+ assert_eq!(
+ buffer[(remote.right() - 1, remote.y)].fg,
+ state.config.palette.green
+ );
+
+ state.sidebar_collapsed = true;
+ let frame = state.compose(100, 28).expect("collapsed endpoint frame");
+ let local = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("collapsed local machine row")
+ .rect;
+ let remote = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| !hit.endpoint_id.is_local())
+ .expect("collapsed remote machine row")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●");
+ assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●");
+}
+
+#[test]
+fn active_workspace_is_the_only_highlight_when_machine_is_expanded() {
+ let (mut state, endpoint_id) = state_with_remote();
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ let machine = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote machine hit")
+ .rect;
+ let workspace = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace hit")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_ne!(
+ buffer[(machine.x, machine.y)].bg,
+ state.config.palette.active_row_bg
+ );
+ assert_eq!(
+ buffer[(workspace.x + 2, workspace.y)].bg,
+ state.config.palette.active_row_bg
+ );
+
+ state.collapsed_endpoints.insert(endpoint_id.clone());
+ let frame = state.compose(100, 28).expect("collapsed endpoint frame");
+ let machine = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote machine hit")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_eq!(
+ buffer[(machine.x, machine.y)].bg,
+ state.config.palette.active_row_bg
+ );
+}
+
+#[test]
+fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() {
+ use crate::api::schema::AgentStatus;
+ use crate::config::{AgentSidebarToken, StatusIndicatorStyle};
+
+ let mut config = Config::default();
+ config.ui.status_indicators = StatusIndicatorStyle::Symbols;
+ config.ui.sidebar.agents.rows = vec![vec![
+ AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Machine,
+ AgentSidebarToken::Agent,
+ ]];
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
+ let profile = remote_profile();
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+
+ let mut local = snapshot();
+ local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
+ state.set_snapshot(Box::new(local));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.agents = vec![agent("remote agent", AgentStatus::Blocked, 1)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ let text = frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ assert!(text.contains("○ Local · local agent"), "frame: {text}");
+ assert!(text.contains("× Build · remote agent"), "frame: {text}");
+ assert!(text.contains("grouped"), "frame: {text}");
+ let toggle = state.hits.agent_sort_toggle;
+ assert!(!toggle.is_empty());
+ let click = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: toggle.x,
+ row: toggle.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert_eq!(
+ state.config.agent_panel_sort,
+ crate::config::AgentPanelSortConfig::Priority
+ );
+ assert!(click.actions.is_empty());
+
+ let buffer = frame
+ .to_ratatui_buffer()
+ .expect("aggregate frame should reconstruct");
+ assert!(buffer
+ .content()
+ .iter()
+ .any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red));
+}
+
+#[test]
+fn aggregate_priority_uses_client_observed_recency_across_machines() {
+ use crate::api::schema::AgentStatus;
+ use crate::config::AgentSidebarToken;
+
+ let mut config = Config::default();
+ config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority;
+ config.ui.sidebar.agents.rows =
+ vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
+ let profile = remote_profile();
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+
+ let mut local = snapshot();
+ local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
+ state.set_snapshot(Box::new(local));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
+
+ let mut local = snapshot();
+ local.agents = vec![agent("local agent", AgentStatus::Idle, 2)];
+ state.set_snapshot(Box::new(local));
+ let frame_text = |state: &mut ClientShellState| {
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n")
+ };
+ let text = frame_text(&mut state);
+ assert!(
+ text.find("Local · local agent").expect("local agent")
+ < text.find("Build · remote agent").expect("remote agent")
+ );
+
+ remote.agents = vec![agent("remote agent", AgentStatus::Working, 2)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
+ let text = frame_text(&mut state);
+ assert!(
+ text.find("Build · remote agent").expect("remote agent")
+ < text.find("Local · local agent").expect("local agent")
+ );
+
+ remote.agents = vec![agent("remote agent", AgentStatus::Idle, 3)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ let text = frame_text(&mut state);
+ assert!(
+ text.find("Build · remote agent").expect("remote agent")
+ < text.find("Local · local agent").expect("local agent")
+ );
+ let mut outcome = ClientShellInput::default();
+ assert!(
+ state.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(0), &mut outcome,)
+ );
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ }] if activated == &endpoint_id && pane_id == "pane_1"
+ ));
+}
+
+#[test]
+fn unselected_endpoint_completion_projects_done_client_side() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.agents = vec![agent("background agent", AgentStatus::Working, 2)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
+ remote.revision = 2;
+ remote.agents = vec![agent("background agent", AgentStatus::Idle, 3)];
+
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+
+ let status = state
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ .and_then(|snapshot| snapshot.agents.first())
+ .map(|agent| agent.agent_status);
+ assert_eq!(status, Some(AgentStatus::Done));
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+}
+
+#[test]
+fn clicking_remote_endpoint_requests_activation_without_mutating_projection() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.compose(100, 28).expect("combined endpoint frame");
+ let hit = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote endpoint hit")
+ .rect;
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: hit.x,
+ row: hit.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: None,
+ }] if activated == &endpoint_id
+ ));
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+ assert_eq!(
+ state
+ .snapshot
+ .as_deref()
+ .map(|snapshot| snapshot.boot_id.as_str()),
+ Some("boot-1")
+ );
+}
+
+#[test]
+fn context_menu_lookup_ignores_inactive_endpoint_workspaces() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.compose(100, 28).expect("combined endpoint frame");
+ let remote = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace")
+ .rect;
+ let local = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("local workspace")
+ .rect;
+
+ assert_eq!(
+ state.active_endpoint_workspace_at((remote.x, remote.y)),
+ None
+ );
+ assert_eq!(
+ state.active_endpoint_workspace_at((local.x, local.y)),
+ Some("ws_1".into())
+ );
+}
+
+#[test]
+fn future_surface_waits_for_its_exact_snapshot_revision() {
+ let (mut state, _) = state_with_remote();
+ let mut future = surface();
+ future.projection_revision = 2;
+ future.surface_revision = 2;
+ state.set_pane_surface(future);
+ assert_eq!(
+ state
+ .pane_surface
+ .as_ref()
+ .map(|surface| surface.projection_revision),
+ Some(1)
+ );
+ assert_eq!(
+ state
+ .pending_pane_surface
+ .as_ref()
+ .map(|surface| surface.projection_revision),
+ Some(2)
+ );
+
+ let mut next = snapshot();
+ next.revision = 2;
+ state.set_snapshot(Box::new(next));
+ assert_eq!(
+ state
+ .pane_surface
+ .as_ref()
+ .map(|surface| surface.projection_revision),
+ Some(2)
+ );
+ assert!(state.pending_pane_surface.is_none());
+}
+
+#[test]
+fn inactive_endpoint_snapshot_cache_never_regresses_revision() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let mut newest = snapshot();
+ newest.boot_id = "remote-boot".into();
+ newest.revision = 3;
+ newest.workspaces[0].label = "newest".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(newest));
+ let mut delayed = snapshot();
+ delayed.boot_id = "remote-boot".into();
+ delayed.revision = 2;
+ delayed.workspaces[0].label = "delayed".into();
+
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(delayed));
+
+ let label = state
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ .and_then(|snapshot| snapshot.workspaces.first())
+ .map(|workspace| workspace.label.as_str());
+ assert_eq!(label, Some("newest"));
+}
+
+#[test]
+fn new_connection_generation_accepts_a_lower_same_boot_projection_revision() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let mut previous = snapshot();
+ previous.boot_id = "shared-server-boot".into();
+ previous.revision = 9;
+ previous.workspaces[0].label = "old connection".into();
+ state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 4, Box::new(previous));
+ let mut reconnected = snapshot();
+ reconnected.boot_id = "shared-server-boot".into();
+ reconnected.revision = 1;
+ reconnected.workspaces[0].label = "new connection".into();
+
+ state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 5, Box::new(reconnected));
+
+ let endpoint = state
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint");
+ assert_eq!(endpoint.snapshot_generation, Some(5));
+ assert_eq!(endpoint.snapshot.as_ref().unwrap().revision, 1);
+ assert_eq!(
+ endpoint.snapshot.as_ref().unwrap().workspaces[0].label,
+ "new connection"
+ );
+}
+
+#[test]
+fn reconnect_snapshot_waits_for_coherent_activation_before_replacing_projection() {
+ let (mut state, endpoint_id) = state_with_remote();
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot");
+
+ state.mark_endpoint_disconnected(&endpoint_id);
+ let mut replacement = snapshot();
+ replacement.boot_id = "replacement-boot".into();
+ state.cache_endpoint_snapshot(&endpoint_id, Box::new(replacement));
+ assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot");
+
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ assert_eq!(
+ state.snapshot.as_deref().unwrap().boot_id,
+ "replacement-boot"
+ );
+}
+
+#[test]
+fn disconnected_active_endpoint_freezes_surface_and_marks_cached_ui_stale() {
+ use crate::api::schema::AgentStatus;
+ use crate::config::{AgentSidebarToken, StatusIndicatorStyle};
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state.config.status_indicators = StatusIndicatorStyle::Symbols;
+ state.config.agents.rows = vec![vec![
+ AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Machine,
+ AgentSidebarToken::Agent,
+ ]];
+ let endpoint = state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint");
+ endpoint.snapshot.as_mut().expect("remote snapshot").agents =
+ vec![agent("remote agent", AgentStatus::Blocked, 1)];
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+ state.pending_integration_installs = 2;
+
+ state.mark_endpoint_disconnected(&endpoint_id);
+ let frame = state.compose(100, 28).expect("frozen endpoint frame");
+ let text = frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ assert_eq!(state.pending_integration_installs, 0);
+ assert_eq!(
+ state.endpoint_status(&endpoint_id),
+ Some(ClientEndpointStatus::Reconnecting)
+ );
+ assert!(text.contains("◐ reconnecting"), "frame: {text}");
+ assert!(text.contains("Build · remote agent"), "frame: {text}");
+ assert!(
+ text.contains("LIVE"),
+ "frozen surface should remain: {text}"
+ );
+ assert!(state.hits.panes.is_empty());
+ assert!(frame.cursor.is_none());
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ let stale_icon = buffer
+ .content()
+ .iter()
+ .find(|cell| cell.symbol() == "×")
+ .expect("stale blocked icon");
+ assert_eq!(stale_icon.fg, state.config.palette.overlay0);
+}
+
+#[cfg(unix)]
+#[test]
+fn graphics_scope_qualifies_colliding_boot_ids_by_endpoint() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let local_scope = state.graphics_scope().to_owned();
+ let mut remote = snapshot();
+ remote.boot_id = "boot-1".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let remote_scope = state.graphics_scope();
+ assert_ne!(local_scope, remote_scope);
+ assert!(remote_scope.starts_with("ssh:0123456789abcdef0123456789abcdef:"));
+}
+
+#[test]
+fn navigator_uses_machine_parents_only_for_federated_clients() {
+ let (mut state, _) = state_with_remote();
+ state.open_navigator_overlay();
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+ let machines = rows
+ .iter()
+ .filter(|row| matches!(row.target, ClientNavigatorTarget::Machine { .. }))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ machines
+ .iter()
+ .map(|row| row.label.as_str())
+ .collect::<Vec<_>>(),
+ vec!["Local", "Build"]
+ );
+ assert!(rows.iter().all(|row| {
+ matches!(row.target, ClientNavigatorTarget::Machine { .. })
+ || (!row.label.contains("Local ·") && !row.label.contains("Build ·"))
+ }));
+ assert!(rows.iter().all(|row| match row.target {
+ ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(),
+ ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(),
+ ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(),
+ ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(),
+ }));
+ assert_eq!(rows.iter().filter(|row| row.current).count(), 1);
+
+ let mut local = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ local.set_snapshot(Box::new(snapshot()));
+ local.set_pane_surface(surface());
+ let frame = local.compose(100, 28).expect("local-only sidebar");
+ assert!(local.hits.machines.is_empty());
+ assert!(!frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n")
+ .contains(" machines"));
+ local.open_navigator_overlay();
+ let ClientShellOverlay::Navigator(navigator) = local.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&local.endpoints, &local.active_endpoint_id, navigator);
+ assert!(rows
+ .iter()
+ .all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. })));
+ assert!(rows.iter().all(|row| match row.target {
+ ClientNavigatorTarget::Workspace { .. } => row.depth == 0,
+ ClientNavigatorTarget::Tab { .. } => row.depth == 1,
+ ClientNavigatorTarget::Pane { .. } => row.depth == 2,
+ ClientNavigatorTarget::Machine { .. } => false,
+ }));
+}
+
+#[test]
+fn navigator_keeps_saved_machine_visible_before_metadata_arrives() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let endpoint = state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("saved remote endpoint");
+ endpoint.snapshot = None;
+ endpoint.status = ClientEndpointStatus::Connecting;
+ state.open_navigator_overlay();
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+
+ assert!(rows.iter().any(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
+ ) && row.label == "Build"
+ && row.stale
+ }));
+ assert!(!rows.iter().any(|row| match &row.target {
+ ClientNavigatorTarget::Machine { .. } => false,
+ ClientNavigatorTarget::Workspace {
+ endpoint_id: target,
+ ..
+ }
+ | ClientNavigatorTarget::Tab {
+ endpoint_id: target,
+ ..
+ }
+ | ClientNavigatorTarget::Pane {
+ endpoint_id: target,
+ ..
+ } => target == &endpoint_id,
+ }));
+}
+
+#[test]
+fn navigator_machine_selection_opens_its_remembered_view() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
+ .into_iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
+ )
+ })
+ .map(|row| row.target)
+ .expect("remote machine row")
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+
+ let mut outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut outcome);
+
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: None,
+ }] if activated == &endpoint_id
+ ));
+ assert!(state.overlay.is_none());
+}
+
+#[test]
+fn navigator_foreign_pane_selection_activates_its_endpoint() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Pane {
+ endpoint_id: target_endpoint,
+ pane_id,
+ } if target_endpoint == &endpoint_id && pane_id == "pane_1"
+ )
+ })
+ .map(|row| row.target.clone())
+ .expect("remote pane row")
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+ let mut local = snapshot();
+ let mut inserted = local.workspaces[0].clone();
+ inserted.workspace_id = "ws_2".into();
+ inserted.focused = false;
+ local.workspaces.push(inserted);
+ state.set_snapshot(Box::new(local));
+
+ let mut outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut outcome);
+
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ }] if activated == &endpoint_id && pane_id == "pane_1"
+ ));
+ assert!(state.overlay.is_none());
+}
+
+#[test]
+fn mobile_foreign_agent_and_workspace_targets_activate_their_endpoint() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .agents = vec![agent("remote agent", AgentStatus::Working, 2)];
+ state.mode = ClientShellMode::Navigate;
+ state.compose(44, 30).expect("mobile switcher");
+ let remote_agent = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(
+ target,
+ ClientMobileTarget::Agent {
+ endpoint_id: target_endpoint,
+ pane_id,
+ } if target_endpoint == &endpoint_id && pane_id == "pane_1"
+ )
+ .then_some(*rect)
+ })
+ .expect("remote agent target");
+ let click = |rect: Rect| {
+ RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: rect.x,
+ row: rect.y,
+ modifiers: KeyModifiers::empty(),
+ })
+ };
+ let outcome = state.handle_raw_events(vec![click(remote_agent)]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ }] if activated == &endpoint_id && pane_id == "pane_1"
+ ));
+
+ state.mode = ClientShellMode::Navigate;
+ state.compose(44, 30).expect("mobile switcher");
+ let remote_workspace = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(
+ target,
+ ClientMobileTarget::Workspace {
+ endpoint_id: target_endpoint,
+ workspace_id,
+ } if target_endpoint == &endpoint_id && workspace_id == "ws_1"
+ )
+ .then_some(*rect)
+ })
+ .expect("remote workspace target");
+ let outcome = state.handle_raw_events(vec![click(remote_workspace)]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)),
+ }] if activated == &endpoint_id && workspace_id == "ws_1"
+ ));
+}
+
+#[test]
+fn cached_offline_navigator_and_mobile_targets_are_dimmed_and_disabled() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .agents = vec![agent("remote agent", AgentStatus::Blocked, 2)];
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting);
+
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+ let machine = rows
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
+ )
+ })
+ .expect("cached remote machine row");
+ assert!(machine.stale);
+ let row = rows
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Pane {
+ endpoint_id: target_endpoint,
+ pane_id,
+ } if target_endpoint == &endpoint_id && pane_id == "pane_1"
+ )
+ })
+ .expect("cached remote pane row");
+ assert!(row.stale);
+ assert!(!row.meta.contains("reconnecting"));
+ row.target.clone()
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+ let mut navigator_outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut navigator_outcome);
+ assert!(navigator_outcome.actions.is_empty());
+ assert!(matches!(
+ state.overlay,
+ Some(ClientShellOverlay::Navigator(_))
+ ));
+
+ state.overlay = None;
+ state.mode = ClientShellMode::Navigate;
+ let frame = state.compose(44, 30).expect("offline mobile switcher");
+ let mobile_target = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(
+ target,
+ ClientMobileTarget::Workspace {
+ endpoint_id: target_endpoint,
+ workspace_id,
+ } if target_endpoint == &endpoint_id && workspace_id == "ws_1"
+ )
+ .then_some(*rect)
+ })
+ .expect("cached remote workspace target");
+ let buffer = frame.to_ratatui_buffer().expect("mobile frame buffer");
+ assert_eq!(
+ buffer[(mobile_target.x, mobile_target.y)].fg,
+ state.config.palette.overlay0
+ );
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: mobile_target.x,
+ row: mobile_target.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(outcome.actions.is_empty());
+ assert_eq!(state.mode, ClientShellMode::Navigate);
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+}
+
+#[test]
+fn focus_agent_index_uses_online_aggregate_rows() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .agents = vec![agent("remote agent", AgentStatus::Working, 2)];
+ let focus_agent =
+ |index| crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(index));
+
+ assert!(state.indexed_navigation_target_exists(&focus_agent(0)));
+ assert!(!state.indexed_navigation_target_exists(&focus_agent(1)));
+
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting);
+ assert!(!state.indexed_navigation_target_exists(&focus_agent(0)));
+}
+
+#[test]
+fn workspace_drag_rejects_foreign_endpoint_slots() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.compose(100, 28).expect("aggregate sidebar");
+ let local = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("local workspace")
+ .rect;
+ let remote = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace")
+ .rect;
+ let mouse = |kind, rect: Rect| {
+ RawInputEvent::Mouse(MouseEvent {
+ kind,
+ column: rect.x.saturating_add(1),
+ row: rect.y,
+ modifiers: KeyModifiers::empty(),
+ })
+ };
+
+ state.handle_raw_events(vec![mouse(MouseEventKind::Down(MouseButton::Left), local)]);
+ state.handle_raw_events(vec![mouse(MouseEventKind::Drag(MouseButton::Left), remote)]);
+
+ assert!(state.chrome_drag.is_none());
+}
+
+#[test]
+fn collapsed_aggregate_workspace_status_uses_its_status_color() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .workspaces[0]
+ .agent_status = AgentStatus::Blocked;
+ state.sidebar_collapsed = true;
+
+ let frame = state.compose(100, 28).expect("collapsed aggregate sidebar");
+ let workspace = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame buffer");
+ assert_eq!(
+ buffer[(workspace.x.saturating_add(2), workspace.y)].fg,
+ state.config.palette.red
+ );
+}
+
+#[test]
+fn navigator_foreign_tab_selection_keeps_the_tab_target() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Tab {
+ endpoint_id: target_endpoint,
+ tab_id,
+ ..
+ } if target_endpoint == &endpoint_id && tab_id == "tab_1"
+ )
+ })
+ .map(|row| row.target.clone())
+ .expect("remote tab row")
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+
+ let mut outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut outcome);
+
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Tab(tab_id)),
+ }] if activated == &endpoint_id && tab_id == "tab_1"
+ ));
+}
diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs
index d20485b3..b8f17e57 100644
--- a/src/client/shell/tests/keybindings_settings.rs
+++ b/src/client/shell/tests/keybindings_settings.rs
@@ -196,7 +196,10 @@ fn prefix_endpoint_action_uses_public_api_with_stable_ids() {
assert!(state.handle_input_bytes(&[0x02]).actions.is_empty());
let create = state.handle_input_bytes(b"c");
- let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else {
+ let [ClientShellAction::Endpoint {
+ boot_id, request, ..
+ }] = &create.actions[..]
+ else {
panic!("expected one endpoint action: {:?}", create.actions);
};
assert_eq!(boot_id, "boot-1");
diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs
index 094b141f..b7cc2a47 100644
--- a/src/client/shell/tests/mobile.rs
+++ b/src/client/shell/tests/mobile.rs
@@ -30,6 +30,7 @@ fn navigate_update_status_uses_released_desktop_and_mobile_placement() {
state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top;
state.visible_notification = Some(ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
event: SemanticNotification {
kind: SemanticNotificationKind::Custom,
title: "bottom notification".into(),
@@ -69,6 +70,74 @@ fn mobile_layout_reserves_only_client_header() {
}
#[test]
+fn mobile_switcher_can_activate_an_online_saved_machine() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let profile = crate::client::endpoint::SavedSshEndpoint {
+ id: crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Build".into(),
+ target: "build".into(),
+ session: "agents".into(),
+ enabled: true,
+ };
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ state.mode = ClientShellMode::Navigate;
+
+ let frame = state.compose(44, 30).expect("mobile machine switcher");
+ let text = frame
+ .cells
+ .iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>();
+ assert!(text.contains("machines"));
+ assert!(text.contains("Build"));
+ let machine = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(target, ClientMobileTarget::Machine(id) if id == &endpoint_id).then_some(*rect)
+ })
+ .expect("saved machine target");
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: machine.x,
+ row: machine.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: None,
+ }] if activated == &endpoint_id
+ ));
+
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+ state.mark_endpoint_disconnected(&endpoint_id);
+ state.receive_endpoint_unavailable("network lost".into());
+ state.mode = ClientShellMode::Navigate;
+ let stale = state.compose(44, 30).expect("stale mobile switcher");
+ let text = stale
+ .cells
+ .iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>();
+ assert!(text.contains("reconnecting"), "frame: {text}");
+ assert!(text.contains("network lost"), "frame: {text}");
+}
+
+#[test]
fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() {
let mut config = ClientShellConfig::from_config(&Config::default());
config.mouse_capture = false;
@@ -175,7 +244,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
.mobile_targets
.iter()
.find_map(|(rect, target)| {
- matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect)
+ matches!(
+ target,
+ ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_1"
+ )
+ .then_some(*rect)
})
.expect("workspace hit");
let focused = state.handle_raw_events(vec![click(workspace_hit)]);
@@ -199,7 +272,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
.mobile_targets
.iter()
.find_map(|(rect, target)| {
- matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect)
+ matches!(
+ target,
+ ClientMobileTarget::Agent { pane_id, .. } if pane_id == "pane_1"
+ )
+ .then_some(*rect)
})
.expect("agent hit");
let focused = state.handle_raw_events(vec![click(agent_hit)]);
@@ -221,7 +298,14 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
.mobile_targets
.iter()
.find_map(|(rect, target)| {
- matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect)
+ matches!(
+ target,
+ ClientMobileTarget::Tab {
+ endpoint_id,
+ tab_id,
+ } if endpoint_id.is_local() && tab_id == "tab_1"
+ )
+ .then_some(*rect)
})
.expect("tab hit");
let focused = state.handle_raw_events(vec![click(tab_hit)]);
@@ -534,7 +618,10 @@ fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() {
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8"));
assert!(state.mobile_switcher_scroll > 2);
assert!(state.hits.mobile_targets.iter().any(|(_, target)| {
- matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8")
+ matches!(
+ target,
+ ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_8"
+ )
}));
let close = state.hits.mobile_close;
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs
index 4cf97152..497ce2c6 100644
--- a/src/client/shell/tests/mod.rs
+++ b/src/client/shell/tests/mod.rs
@@ -6,7 +6,7 @@ use crate::protocol::{
};
use crossterm::event::MouseEvent;
-fn snapshot() -> ClientShellSnapshot {
+pub(super) fn snapshot() -> ClientShellSnapshot {
ClientShellSnapshot {
boot_id: "boot-1".into(),
revision: 1,
@@ -211,6 +211,8 @@ fn surface_with_popup() -> PaneSurfaceFrame {
mod agents_worktrees_notifications;
mod chrome_context;
mod copy;
+mod endpoint_requests;
+mod endpoints;
#[path = "input.rs"]
mod input_domain;
mod keybindings_settings;
diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs
index 6ccdcf6e..044ef145 100644
--- a/src/client/shell/tests/popup_focus_projection.rs
+++ b/src/client/shell/tests/popup_focus_projection.rs
@@ -82,7 +82,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay {
query: String::new(),
search_focused: false,
- selected: 0,
+ selected: None,
scroll: 0,
filter: None,
expanded_workspaces: HashSet::new(),
diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs
index 2e2901e8..7107aa71 100644
--- a/src/client/shell/tests/startup_overlays.rs
+++ b/src/client/shell/tests/startup_overlays.rs
@@ -541,6 +541,7 @@ fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() {
state.set_snapshot(Box::new(endpoint_snapshot));
state.set_pane_surface(surface());
state.visible_notification = Some(ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
event: SemanticNotification {
kind: SemanticNotificationKind::Custom,
title: "notification".into(),
diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs
index bcadf1c8..ea2e8f24 100644
--- a/src/client/shell/worktrees.rs
+++ b/src/client/shell/worktrees.rs
@@ -1,10 +1,24 @@
use super::*;
+fn checkout_path_preview(root: &str, repo: &str, branch: &str) -> String {
+ // This is an endpoint path, not a path on the machine drawing the dialog.
+ let separator = if !root.starts_with('/') && root.contains('\\') {
+ '\\'
+ } else {
+ '/'
+ };
+ format!(
+ "{}{separator}{repo}{separator}{}",
+ root.trim_end_matches(separator),
+ crate::worktree::branch_to_path_slug(branch)
+ )
+}
+
impl ClientShellState {
- fn endpoint_worktree_directory(&self) -> Option<std::path::PathBuf> {
+ fn endpoint_worktree_directory(&self) -> Option<String> {
self.snapshot
.as_deref()
- .map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory))
+ .map(|snapshot| snapshot.worktree_directory.clone())
}
pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool {
@@ -241,13 +255,8 @@ impl ClientShellState {
let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else {
return;
};
- create.checkout_path = crate::worktree::default_checkout_path(
- &worktree_directory,
- &create.repo_name,
- &create.branch,
- )
- .display()
- .to_string();
+ create.checkout_path =
+ checkout_path_preview(&worktree_directory, &create.repo_name, &create.branch);
create.error = None;
}
@@ -270,9 +279,7 @@ impl ClientShellState {
create.branch = branch.clone();
create.replace_on_type = false;
create.checkout_path =
- crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch)
- .display()
- .to_string();
+ checkout_path_preview(&worktree_directory, &create.repo_name, &branch);
create.creating = true;
create.error = None;
let workspace_id = create.source_workspace_id.clone();
@@ -399,13 +406,8 @@ impl ClientShellState {
let Some(worktree_directory) = self.endpoint_worktree_directory() else {
return false;
};
- let checkout_path = crate::worktree::default_checkout_path(
- &worktree_directory,
- &source.repo_name,
- &branch,
- )
- .display()
- .to_string();
+ let checkout_path =
+ checkout_path_preview(&worktree_directory, &source.repo_name, &branch);
self.overlay = Some(ClientShellOverlay::WorktreeCreate(
ClientWorktreeCreateOverlay {
source_workspace_id: workspace_id,
@@ -549,3 +551,24 @@ impl ClientShellState {
}
}
}
+
+#[cfg(test)]
+mod path_tests {
+ use super::checkout_path_preview;
+
+ #[test]
+ fn endpoint_path_style_is_independent_of_the_client_os() {
+ assert_eq!(
+ checkout_path_preview("/worktrees/", "repo", "feature/a"),
+ "/worktrees/repo/feature-a"
+ );
+ assert_eq!(
+ checkout_path_preview(r"C:\worktrees\", "repo", "feature/a"),
+ r"C:\worktrees\repo\feature-a"
+ );
+ assert_eq!(
+ checkout_path_preview(r"\\server\share", "repo", "feature/a"),
+ r"\\server\share\repo\feature-a"
+ );
+ }
+}
diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs
new file mode 100644
index 00000000..8a4af0b7
--- /dev/null
+++ b/src/client/shell_runtime.rs
@@ -0,0 +1,739 @@
+use super::*;
+
+pub(super) fn dispatch_client_shell_actions(
+ actions: Vec<shell::ClientShellAction>,
+ endpoint_commands: &mut endpoint_commands::EndpointCommands,
+ endpoints: &mut endpoint::EndpointRegistry,
+ mut shell: Option<&mut shell::ClientShellState>,
+ detached_process_children: &mut Vec<std::process::Child>,
+ event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>,
+) -> Result<(Vec<crossterm::event::MouseEvent>, bool), ClientError> {
+ let mut replay_mouse = Vec::new();
+ let mut repaint = false;
+ for action in actions {
+ match action {
+ shell::ClientShellAction::Endpoint {
+ endpoint_id,
+ boot_id,
+ request,
+ } => {
+ if let Some(connection) = endpoints.connection(&endpoint_id).filter(|_| {
+ endpoints.active_id() == &endpoint_id && endpoints.active_surface_available()
+ }) {
+ endpoint_commands.enqueue(endpoint_id, connection.generation, boot_id, request);
+ } else if let Some(shell) = shell.as_deref_mut() {
+ repaint |= shell.cancel_endpoint_request(&request.id);
+ }
+ }
+ shell::ClientShellAction::ClipboardWrite(bytes) => {
+ crate::selection::write_osc52_bytes(&bytes);
+ }
+ shell::ClientShellAction::Request(request) => {
+ if endpoints.active_surface_available() {
+ write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?;
+ }
+ }
+ shell::ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target,
+ } => {
+ let _ = event_tx.try_send(ClientLoopEvent::ActivateEndpoint {
+ endpoint_id,
+ target,
+ force: false,
+ });
+ }
+ shell::ClientShellAction::OpenSafeWebUrl(url) => {
+ if crate::app::actions::safe_web_url(&url).is_some() {
+ match crate::platform::open_url(&url) {
+ Ok(Some(child)) => detached_process_children.push(child),
+ Ok(None) => {}
+ Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"),
+ }
+ }
+ }
+ shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events),
+ shell::ClientShellAction::Keybind(action) => {
+ debug!(
+ ?action,
+ "client shell action awaits its presentation family"
+ );
+ }
+ }
+ }
+ // A source-off-first handoff leaves the registry's committed identity pointing at a
+ // deliberately surface-inactive source. Do not drain its retained queue into a server that
+ // must reject it; completion below resumes the committed owner's lane.
+ if endpoints.active_surface_available() {
+ let active_endpoint = endpoints.active_id().clone();
+ let cancelled = endpoint_commands.send_next(&active_endpoint, endpoints);
+ if let Some(shell) = shell {
+ for request_id in cancelled {
+ repaint |= shell.cancel_endpoint_request(&request_id);
+ }
+ }
+ }
+ Ok((replay_mouse, repaint))
+}
+
+pub(super) fn client_shell_resize_message(
+ shell: &shell::ClientShellState,
+ cols: u16,
+ rows: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ pixel_mouse: bool,
+) -> ClientMessage {
+ ClientMessage::ClientShellResize {
+ cell_width_px,
+ cell_height_px,
+ surface_size: shell.surface_size(cols, rows),
+ pixel_mouse,
+ }
+}
+
+pub(super) fn sync_client_shell_keyboard_report_all(
+ state: &mut ClientState,
+) -> Result<(), ClientError> {
+ let Some(shell) = state.shell.as_ref() else {
+ return Ok(());
+ };
+ let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested();
+ if desired == state.keyboard_report_all_active {
+ return Ok(());
+ }
+ crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)
+ .map_err(ClientError::ConnectionFailed)?;
+ state.keyboard_report_all_active = desired;
+ Ok(())
+}
+
+pub(super) fn clear_endpoint_host_effects(
+ state: &mut ClientState,
+ host_mouse_capture_active: &std::sync::atomic::AtomicBool,
+ host_sgr_pixels_active: &std::sync::atomic::AtomicBool,
+) {
+ state.endpoint_mouse_capture_requested = false;
+ state.endpoint_sgr_pixels_requested = false;
+ let enabled = if state.shell.is_some() {
+ state.shell_mouse_capture_preference
+ } else {
+ state.direct_mouse_capture_preference
+ };
+ let sgr_pixels = super::effective_sgr_pixel_mouse(enabled, false, state.pixel_geometry_exact);
+ if enabled != state.mouse_capture_active
+ || sgr_pixels != host_sgr_pixels_active.load(std::sync::atomic::Ordering::Acquire)
+ {
+ let _ = super::set_mouse_capture(enabled, sgr_pixels);
+ }
+ state.mouse_capture_active = enabled;
+ host_mouse_capture_active.store(enabled, std::sync::atomic::Ordering::Release);
+ host_sgr_pixels_active.store(sgr_pixels, std::sync::atomic::Ordering::Release);
+
+ state.pane_keyboard_report_all = false;
+ let _ = sync_client_shell_keyboard_report_all(state);
+ let _ = crate::terminal_effects::write_window_title(&mut std::io::stdout(), None);
+}
+
+pub(super) fn apply_client_shell_input_source_changes(
+ state: &mut ClientState,
+ prefix_input_source: &mut impl crate::platform::PrefixInputSource,
+) {
+ let changes = state
+ .shell
+ .as_mut()
+ .map(shell::ClientShellState::take_input_source_changes)
+ .unwrap_or_default();
+ for active in changes {
+ if active {
+ prefix_input_source.switch_to_ascii();
+ } else {
+ prefix_input_source.restore();
+ }
+ }
+}
+
+fn install_pending_activation(
+ state: &mut ClientState,
+ endpoint_commands: &mut endpoint_commands::EndpointCommands,
+ pending: &mut Option<endpoint::PendingEndpointActivation>,
+ next_surface_serial: &mut u64,
+ activation: endpoint::PendingEndpointActivation,
+) {
+ let retired = activation
+ .source_command_lane()
+ .map(|source| endpoint_commands.retire_lane(source))
+ .unwrap_or_default();
+ if let Some(shell) = state.shell.as_mut() {
+ for request_id in retired {
+ shell.cancel_endpoint_request(&request_id);
+ }
+ }
+ *next_surface_serial = next_surface_serial.saturating_add(1);
+ state.freeze_presentation();
+ *pending = Some(activation);
+}
+
+pub(super) fn begin_endpoint_activation(
+ state: &mut ClientState,
+ endpoints: &mut endpoint::EndpointRegistry,
+ endpoint_commands: &mut endpoint_commands::EndpointCommands,
+ pending: &mut Option<endpoint::PendingEndpointActivation>,
+ next_surface_serial: &mut u64,
+ endpoint_id: endpoint::ClientEndpointId,
+ target: Option<shell::ClientEndpointFocusTarget>,
+ force: bool,
+ now: std::time::Instant,
+ event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>,
+) -> Result<(), ClientError> {
+ if let Some(activation) = pending.as_mut() {
+ if activation.can_retarget(&endpoint_id) {
+ let retarget_error = activation.retarget(target, endpoints).err();
+ if let Some(error) = retarget_error {
+ rollback_endpoint_activation(state, endpoints, pending, error, false);
+ }
+ } else {
+ // Once rollback starts, even a request for the original target is a new intent. It
+ // replaces the retained successor instead of mutating the transaction being retired.
+ let outcome = activation.supersede(endpoint_id, target, endpoints);
+ if let endpoint::ActivationRollback::Unavailable(message) = outcome {
+ *pending = None;
+ present_handoff_unavailable(state, message);
+ }
+ }
+ return Ok(());
+ }
+ let already_active = !force
+ && endpoints.active_id() == &endpoint_id
+ && endpoints
+ .connection(&endpoint_id)
+ .is_some_and(|connection| connection.surface_active);
+ if already_active {
+ if let (Some(shell), Some(target)) = (state.shell.as_mut(), target) {
+ let actions = shell.focus_endpoint_target(target);
+ let (_, repaint) = dispatch_client_shell_actions(
+ actions,
+ endpoint_commands,
+ endpoints,
+ Some(shell),
+ &mut state.detached_process_children,
+ event_tx,
+ )?;
+ if repaint {
+ if let Some(frame) = shell.compose(state.reported_size.0, state.reported_size.1) {
+ state.present_frame(frame);
+ }
+ }
+ }
+ return Ok(());
+ }
+ let Some(shell) = state.shell.as_ref() else {
+ return Ok(());
+ };
+ let resize = client_shell_resize_message(
+ shell,
+ state.reported_size.0,
+ state.reported_size.1,
+ state.reported_cell_size.0,
+ state.reported_cell_size.1,
+ state.pixel_geometry_exact,
+ );
+ match endpoint::PendingEndpointActivation::begin(
+ shell,
+ endpoints,
+ endpoint_id.clone(),
+ target,
+ resize,
+ *next_surface_serial,
+ now,
+ ) {
+ Ok(activation) => install_pending_activation(
+ state,
+ endpoint_commands,
+ pending,
+ next_surface_serial,
+ activation,
+ ),
+ Err(endpoint::ActivationBeginError::Preflight(error)) => {
+ if let Some(shell) = state.shell.as_mut() {
+ shell.receive_endpoint_unavailable(format!(
+ "{}: {error}",
+ shell.endpoint_label(&endpoint_id)
+ ));
+ }
+ }
+ Err(endpoint::ActivationBeginError::Partial { activation, error }) => {
+ // A send error is not evidence that its peer did not observe the write. Freeze and
+ // retain the lifecycle object before rollback so no source or target output can be
+ // projected until one ownership path has been proved again.
+ install_pending_activation(
+ state,
+ endpoint_commands,
+ pending,
+ next_surface_serial,
+ *activation,
+ );
+ rollback_endpoint_activation(
+ state,
+ endpoints,
+ pending,
+ format!(
+ "{}: {error}",
+ state
+ .shell
+ .as_ref()
+ .map(|shell| shell.endpoint_label(&endpoint_id).to_owned())
+ .unwrap_or_else(|| format!("{endpoint_id:?}"))
+ ),
+ false,
+ );
+ }
+ }
+ Ok(())
+}
+
+pub(super) fn complete_endpoint_activation(
+ state: &mut ClientState,
+ endpoints: &mut endpoint::EndpointRegistry,
+ pending: &mut Option<endpoint::PendingEndpointActivation>,
+ endpoint_commands: &mut endpoint_commands::EndpointCommands,
+) -> Result<Option<ClientLoopEvent>, ClientError> {
+ let sync_endpoint = pending
+ .as_ref()
+ .and_then(endpoint::PendingEndpointActivation::presentation_sync_endpoint)
+ .cloned();
+ if let Some(endpoint_id) = sync_endpoint.as_ref() {
+ state.replay_host_theme(endpoints, endpoint_id);
+ }
+ let completion = {
+ let Some(activation) = pending.as_mut() else {
+ return Ok(None);
+ };
+ let Some(shell) = state.shell.as_mut() else {
+ return Ok(None);
+ };
+ match activation.complete(shell, endpoints) {
+ Ok(completion) => completion,
+ Err(error) => {
+ shell.receive_endpoint_unavailable(error);
+ return Ok(None);
+ }
+ }
+ };
+
+ if matches!(
+ completion,
+ endpoint::ActivationCompletion::AwaitingPresentationSync { .. }
+ ) {
+ #[cfg(unix)]
+ if let endpoint::ActivationCompletion::AwaitingPresentationSync { previous, endpoint } =
+ &completion
+ {
+ if previous != endpoint {
+ state.retire_endpoint_graphics(previous);
+ }
+ }
+ // The coherent target frame can replace the frozen source now, but the registry keeps
+ // pane input disabled until a second projection epoch has replayed host modes/effects.
+ state.unfreeze_presentation();
+ let (cleanup, frame) = {
+ let shell = state.shell.as_mut().expect("checked client shell");
+ (
+ shell.take_pending_graphics_cleanup(),
+ shell.compose(state.reported_size.0, state.reported_size.1),
+ )
+ };
+ state.present_graphics(&cleanup);
+ if let Some(frame) = frame {
+ state.present_frame(frame);
+ }
+ return Ok(None);
+ }
+ if completion == endpoint::ActivationCompletion::AwaitingPresentationEffects {
+ return Ok(None);
+ }
+
+ let _ = pending.take();
+ endpoints.unfreeze_input();
+ let successor = match completion {
+ endpoint::ActivationCompletion::RestoredSource {
+ error,
+ successor: next,
+ ..
+ } => {
+ if next.is_none() {
+ if let Some(shell) = state.shell.as_mut() {
+ shell.receive_endpoint_unavailable(error);
+ }
+ }
+ next
+ }
+ endpoint::ActivationCompletion::Activated => None,
+ endpoint::ActivationCompletion::AwaitingPresentationSync { .. }
+ | endpoint::ActivationCompletion::AwaitingPresentationEffects => unreachable!(),
+ };
+ state.unfreeze_presentation();
+ if successor.is_none() {
+ let active_endpoint = endpoints.active_id().clone();
+ let cancelled = endpoint_commands.send_next(&active_endpoint, endpoints);
+ if let Some(shell) = state.shell.as_mut() {
+ for request_id in cancelled {
+ shell.cancel_endpoint_request(&request_id);
+ }
+ }
+ }
+ let (cleanup, frame) = {
+ let shell = state.shell.as_mut().expect("checked client shell");
+ (
+ shell.take_pending_graphics_cleanup(),
+ shell.compose(state.reported_size.0, state.reported_size.1),
+ )
+ };
+ state.present_graphics(&cleanup);
+ if let Some(frame) = frame {
+ state.present_frame(frame);
+ }
+ if let Some(intent) = successor {
+ return Ok(Some(ClientLoopEvent::ActivateEndpoint {
+ endpoint_id: intent.endpoint_id,
+ target: intent.target,
+ force: true,
+ }));
+ }
+ Ok(None)
+}
+
+pub(super) fn present_handoff_unavailable(state: &mut ClientState, message: String) {
+ // An unavailable committed endpoint has no presentation lease. Keep all pane input and late
+ // source output blocked, while allowing this client-owned chrome frame through the freeze.
+ state.freeze_presentation();
+ let frame = state.shell.as_mut().and_then(|shell| {
+ shell.receive_endpoint_unavailable(message);
+ shell.compose(state.reported_size.0, state.reported_size.1)
+ });
+ if let Some(frame) = frame {
+ state.present_frozen_chrome(frame);
+ }
+}
+
+pub(super) fn rollback_endpoint_activation(
+ state: &mut ClientState,
+ endpoints: &mut endpoint::EndpointRegistry,
+ pending: &mut Option<endpoint::PendingEndpointActivation>,
+ error: String,
+ source_release_rejected: bool,
+) {
+ let Some(activation) = pending.as_mut() else {
+ return;
+ };
+ match activation.rollback(endpoints, error.clone(), source_release_rejected) {
+ endpoint::ActivationRollback::Pending => state.freeze_presentation(),
+ endpoint::ActivationRollback::Unavailable(message) => {
+ *pending = None;
+ // No endpoint has been proven safe to present. Keep pane input frozen, but render
+ // the client-owned unavailable chrome rather than silently swallowing the error.
+ present_handoff_unavailable(state, message);
+ }
+ }
+}
+
+pub(super) fn handle_endpoint_disconnect(
+ state: &mut ClientState,
+ endpoints: &mut endpoint::EndpointRegistry,
+ endpoint_commands: &mut endpoint_commands::EndpointCommands,
+ supervisors: &mut endpoint::EndpointSupervisors,
+ pending_activation: &mut Option<endpoint::PendingEndpointActivation>,
+ endpoint_id: &endpoint::ClientEndpointId,
+ generation: u64,
+ now: std::time::Instant,
+ notice: &str,
+) -> bool {
+ supervisors.disconnected(endpoint_id, generation, now);
+ #[cfg(unix)]
+ state.retire_endpoint_graphics(endpoint_id);
+ if pending_activation
+ .as_ref()
+ .is_some_and(|pending| pending.involves_endpoint(endpoint_id))
+ {
+ let outcome = pending_activation
+ .as_mut()
+ .expect("checked pending activation")
+ .endpoint_disconnected(
+ endpoints,
+ endpoint_id,
+ format!("endpoint connection was lost while activating {notice}"),
+ );
+ match outcome {
+ endpoint::ActivationRollback::Pending => {}
+ endpoint::ActivationRollback::Unavailable(error) => {
+ *pending_activation = None;
+ present_handoff_unavailable(state, error);
+ }
+ }
+ }
+ let endpoint_was_active = endpoints.active_id() == endpoint_id;
+ let cancelled = endpoint_commands.disconnect(endpoint_id);
+ let unavailable = state.shell.as_mut().and_then(|shell| {
+ for request_id in cancelled {
+ shell.cancel_endpoint_request(&request_id);
+ }
+ shell.mark_endpoint_disconnected(endpoint_id);
+ endpoint_was_active.then(|| format!("{} {notice}", shell.endpoint_label(endpoint_id)))
+ });
+ if let Some(message) = unavailable {
+ present_handoff_unavailable(state, message);
+ } else if let Some(frame) = state
+ .shell
+ .as_mut()
+ .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1))
+ {
+ state.present_frame(frame);
+ }
+ endpoint_was_active
+}
+
+pub(super) fn handle_endpoint_attention(
+ state: &mut ClientState,
+ endpoints: &mut endpoint::EndpointRegistry,
+ endpoint_commands: &mut endpoint_commands::EndpointCommands,
+ supervisors: &mut endpoint::EndpointSupervisors,
+ pending_activation: &mut Option<endpoint::PendingEndpointActivation>,
+ endpoint_id: &endpoint::ClientEndpointId,
+ generation: u64,
+ now: std::time::Instant,
+ message: String,
+) -> bool {
+ endpoints.disconnect(endpoint_id);
+ supervisors.record_status(
+ endpoint_id,
+ generation,
+ endpoint::ClientEndpointStatus::Attention,
+ now,
+ );
+ #[cfg(unix)]
+ state.retire_endpoint_graphics(endpoint_id);
+ if pending_activation
+ .as_ref()
+ .is_some_and(|pending| pending.involves_endpoint(endpoint_id))
+ {
+ let outcome = pending_activation
+ .as_mut()
+ .expect("checked pending activation")
+ .endpoint_disconnected(
+ endpoints,
+ endpoint_id,
+ "endpoint reported attention while activating".into(),
+ );
+ if let endpoint::ActivationRollback::Unavailable(error) = outcome {
+ *pending_activation = None;
+ present_handoff_unavailable(state, error);
+ }
+ }
+ let endpoint_was_active = endpoints.active_id() == endpoint_id;
+ let cancelled = endpoint_commands.disconnect(endpoint_id);
+ let unavailable = state.shell.as_mut().and_then(|shell| {
+ for request_id in cancelled {
+ shell.cancel_endpoint_request(&request_id);
+ }
+ shell.set_endpoint_status(endpoint_id, endpoint::ClientEndpointStatus::Attention);
+ endpoint_was_active.then(|| format!("{}: {message}", shell.endpoint_label(endpoint_id)))
+ });
+ if let Some(message) = unavailable {
+ present_handoff_unavailable(state, message);
+ } else if let Some(frame) = state
+ .shell
+ .as_mut()
+ .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1))
+ {
+ state.present_frame(frame);
+ }
+ endpoint_was_active
+}
+
+pub(super) fn install_client_shell_snapshot(
+ state: &mut ClientState,
+ endpoint_id: &endpoint::ClientEndpointId,
+ snapshot: Box<crate::protocol::ClientShellSnapshot>,
+ projection_pending: bool,
+ endpoints: &mut endpoint::EndpointRegistry,
+ prefix_input_source: &mut impl crate::platform::PrefixInputSource,
+) -> Result<(), ClientError> {
+ let Some(connection) = endpoints.connection(endpoint_id) else {
+ return Ok(());
+ };
+ let generation = connection.generation;
+ let project_snapshot =
+ !projection_pending && endpoints.active_id() == endpoint_id && connection.surface_active;
+ let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell {
+ let waits_for_selected_surface = projection_pending
+ || (endpoints.active_id() == endpoint_id
+ && !project_snapshot
+ && shell.has_presented_surface());
+ let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1);
+ if !waits_for_selected_surface {
+ shell.set_endpoint_status(endpoint_id, endpoint::ClientEndpointStatus::Online);
+ }
+ if project_snapshot {
+ shell.set_endpoint_snapshot_for_generation(endpoint_id, generation, snapshot);
+ } else {
+ shell.cache_endpoint_snapshot_inactive_for_generation(
+ endpoint_id,
+ generation,
+ snapshot,
+ );
+ }
+ let graphics_cleanup = shell.take_pending_graphics_cleanup();
+ let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1);
+ (
+ shell.compose(state.reported_size.0, state.reported_size.1),
+ (previous_size != next_size).then(|| {
+ client_shell_resize_message(
+ shell,
+ state.reported_size.0,
+ state.reported_size.1,
+ state.reported_cell_size.0,
+ state.reported_cell_size.1,
+ state.pixel_geometry_exact,
+ )
+ }),
+ graphics_cleanup,
+ )
+ } else {
+ (None, None, Vec::new())
+ };
+ apply_client_shell_input_source_changes(state, prefix_input_source);
+ state.present_graphics(&graphics_cleanup);
+ if let Some(resize) = resize {
+ endpoints.send_to(endpoint_id, &resize);
+ }
+ if let Some(frame) = composed {
+ if projection_pending {
+ state.present_frame(frame);
+ } else {
+ state.present_frozen_chrome(frame);
+ }
+ }
+ Ok(())
+}
+
+pub(super) fn finish_client_shell_input(
+ state: &mut ClientState,
+ outcome: shell::ClientShellInput,
+ frame: Option<FrameData>,
+ endpoints: &mut endpoint::EndpointRegistry,
+ pending_activation: &mut Option<endpoint::PendingEndpointActivation>,
+ endpoint_commands: &mut endpoint_commands::EndpointCommands,
+ prefix_input_source: &mut impl crate::platform::PrefixInputSource,
+ event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>,
+) -> Result<bool, ClientError> {
+ apply_client_shell_input_source_changes(state, prefix_input_source);
+ if outcome.detach {
+ let _ = write_to_server(endpoints, &ClientMessage::Detach);
+ return Ok(true);
+ }
+ if outcome.resize {
+ let shell = state.shell.as_ref().expect("shell mode remains active");
+ let resize = client_shell_resize_message(
+ shell,
+ state.reported_size.0,
+ state.reported_size.1,
+ state.reported_cell_size.0,
+ state.reported_cell_size.1,
+ state.pixel_geometry_exact,
+ );
+ if let Some(activation) = pending_activation.as_mut() {
+ if let Err(error) = activation.update_resize(resize, endpoints) {
+ rollback_endpoint_activation(state, endpoints, pending_activation, error, false);
+ }
+ } else {
+ let _ = write_to_server(endpoints, &resize);
+ }
+ }
+ #[cfg(not(windows))]
+ if outcome.query_host_appearance {
+ query_host_terminal_appearance();
+ }
+ if outcome.query_host_theme {
+ query_host_terminal_theme();
+ }
+ sync_client_shell_keyboard_report_all(state)?;
+ let (replay, dispatch_repaint) = dispatch_client_shell_actions(
+ outcome.actions,
+ endpoint_commands,
+ endpoints,
+ state.shell.as_mut(),
+ &mut state.detached_process_children,
+ event_tx,
+ )?;
+ let frame = if dispatch_repaint {
+ state
+ .shell
+ .as_mut()
+ .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1))
+ } else {
+ frame
+ };
+ debug_assert!(
+ replay.is_empty(),
+ "mouse replay only follows endpoint results"
+ );
+ let active_endpoint_online = state
+ .shell
+ .as_ref()
+ .is_none_or(|shell| shell.endpoint_is_online(endpoints.active_id()))
+ && endpoints.active_surface_available();
+ for request in outcome.requests {
+ if let ClientMessage::ClientShellHostTheme { update } = &request {
+ state.record_host_theme_update(update);
+ if let Some(activation) = pending_activation.as_mut() {
+ if let Err(error) = activation.update_host_theme(update.clone(), endpoints) {
+ rollback_endpoint_activation(
+ state,
+ endpoints,
+ pending_activation,
+ error,
+ false,
+ );
+ }
+ continue;
+ }
+ }
+ // Host focus belongs to a pending target even when the source has gone offline or has
+ // already had its surface revoked. Route it before the ordinary source-online gate.
+ if let ClientMessage::ClientShellFocus { focused } = request {
+ if let Some(activation) = pending_activation.as_mut() {
+ if let Err(error) = activation.update_host_focus(focused, endpoints) {
+ rollback_endpoint_activation(
+ state,
+ endpoints,
+ pending_activation,
+ error,
+ false,
+ );
+ }
+ continue;
+ }
+ if active_endpoint_online {
+ write_to_server(endpoints, &ClientMessage::ClientShellFocus { focused })
+ .map_err(ClientError::ConnectionLost)?;
+ }
+ continue;
+ }
+ if !active_endpoint_online {
+ continue;
+ }
+ if pending_activation.is_some() {
+ // Pane input and non-focus host effects do not cross the frozen handoff boundary.
+ continue;
+ }
+ write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?;
+ }
+ if let Some(frame) = frame {
+ if pending_activation.is_some() {
+ state.present_frame(frame);
+ } else {
+ state.present_frozen_chrome(frame);
+ }
+ }
+ Ok(false)
+}
diff --git a/src/client/startup.rs b/src/client/startup.rs
new file mode 100644
index 00000000..40ee2cc8
--- /dev/null
+++ b/src/client/startup.rs
@@ -0,0 +1,24 @@
+use super::*;
+
+/// Runs the thin client and enters the main event loop.
+pub fn run_client() -> io::Result<()> {
+ run_client_with_mode(None, None, "connecting to server")
+}
+
+#[cfg(unix)]
+pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> {
+ run_client_with_mode(
+ Some((terminal_id, takeover)),
+ Some(AttachEscapeState::default()),
+ "attaching to terminal",
+ )
+}
+
+#[cfg(windows)]
+pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> {
+ debug_assert!(!crate::platform::capabilities().direct_terminal_attach);
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "direct terminal attach is not supported on Windows yet",
+ ))
+}
diff --git a/src/client/state.rs b/src/client/state.rs
new file mode 100644
index 00000000..c26ca165
--- /dev/null
+++ b/src/client/state.rs
@@ -0,0 +1,231 @@
+use super::*;
+
+/// State tracking for the thin client.
+pub(super) struct ClientState {
+ /// Stateful semantic-frame encoder used when the server sends FrameData.
+ pub(super) blit_encoder: render_ansi::BlitEncoder,
+ pub(super) mouse_capture_active: bool,
+ pub(super) endpoint_mouse_capture_requested: bool,
+ pub(super) endpoint_sgr_pixels_requested: bool,
+ /// Latest physical host theme observations, retained so an endpoint selected after the
+ /// observation receives the same client-owned baseline.
+ pub(super) host_theme_updates: Vec<crate::protocol::ClientHostThemeUpdate>,
+ pub(super) direct_mouse_capture_preference: bool,
+ pub(super) shell_mouse_capture_preference: bool,
+ pub(super) direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState,
+ pub(super) pane_keyboard_report_all: bool,
+ pub(super) keyboard_report_all_active: bool,
+ pub(super) reported_size: (u16, u16),
+ pub(super) reported_cell_size: (u32, u32),
+ pub(super) sound_config: crate::config::SoundConfig,
+ pub(super) kitty_graphics_enabled: bool,
+ pub(super) pixel_geometry_enabled: bool,
+ pub(super) pixel_geometry_exact: bool,
+ #[cfg(unix)]
+ pub(super) direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>,
+ #[cfg(unix)]
+ pub(super) retired_direct_graphics: Option<(endpoint::ClientEndpointId, u64, u32)>,
+ #[cfg(unix)]
+ pub(super) pending_surface_graphics:
+ HashMap<(endpoint::ClientEndpointId, u64, u32), crate::protocol::SurfaceGraphicsAssetKey>,
+ pub(super) attach_escape: Option<AttachEscapeState>,
+ #[cfg(unix)]
+ pub(super) mouse_scroll_lines: usize,
+ pub(super) remote_image_paste_key:
+ Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
+ pub(super) redraw_on_focus_gained: bool,
+ pub(super) repaint_pending: bool,
+ /// During a source-off-first handoff the currently blitted frame remains authoritative until
+ /// an acknowledged target snapshot/surface pair commits.
+ pub(super) presentation_frozen: bool,
+ pub(super) draw_host_cursor: bool,
+ pub(super) detached_process_children: Vec<std::process::Child>,
+ pub(super) shell: Option<shell::ClientShellState>,
+}
+
+impl Drop for ClientState {
+ fn drop(&mut self) {
+ if self.attach_escape.is_some() {
+ let _ = crate::terminal_modes::set_direct_host_keyboard_protocol(
+ &mut io::stdout(),
+ &mut self.direct_keyboard_protocol,
+ 0,
+ 0,
+ );
+ }
+ }
+}
+
+impl ClientState {
+ pub(super) fn request_repaint(&mut self) {
+ self.repaint_pending = true;
+ }
+
+ pub(super) fn freeze_presentation(&mut self) {
+ self.presentation_frozen = true;
+ }
+
+ pub(super) fn record_host_theme_update(
+ &mut self,
+ update: &crate::protocol::ClientHostThemeUpdate,
+ ) {
+ use crate::protocol::ClientHostThemeUpdate;
+
+ match update {
+ ClientHostThemeUpdate::DefaultColor { kind, .. } => {
+ self.host_theme_updates.retain(|current| {
+ !matches!(
+ current,
+ ClientHostThemeUpdate::DefaultColor {
+ kind: current_kind,
+ ..
+ } if current_kind == kind
+ )
+ });
+ }
+ ClientHostThemeUpdate::PaletteColors(_) => self
+ .host_theme_updates
+ .retain(|current| !matches!(current, ClientHostThemeUpdate::PaletteColors(_))),
+ ClientHostThemeUpdate::Appearance(_) => self
+ .host_theme_updates
+ .retain(|current| !matches!(current, ClientHostThemeUpdate::Appearance(_))),
+ }
+ self.host_theme_updates.push(update.clone());
+ }
+
+ /// Replay the retained physical-host baseline only after an endpoint owns the committed
+ /// presentation. The endpoint transport preserves this order ahead of the resync control.
+ pub(super) fn replay_host_theme(
+ &self,
+ endpoints: &mut endpoint::EndpointRegistry,
+ endpoint_id: &endpoint::ClientEndpointId,
+ ) {
+ for update in &self.host_theme_updates {
+ let _ = endpoints.send_to(
+ endpoint_id,
+ &crate::protocol::ClientMessage::ClientShellHostTheme {
+ update: update.clone(),
+ },
+ );
+ }
+ }
+
+ pub(super) fn unfreeze_presentation(&mut self) {
+ self.presentation_frozen = false;
+ // A resize or metadata event may have happened while frozen. Force a full frame rather
+ // than attempting to patch the old source frame.
+ self.request_repaint();
+ }
+
+ /// Present a composed error/chrome frame while retaining the handoff input freeze. The pane
+ /// cells are still the last coherent surface; only client chrome (including the error) moves.
+ pub(super) fn present_frozen_chrome(&mut self, frame_data: FrameData) {
+ let frozen = self.presentation_frozen;
+ self.presentation_frozen = false;
+ self.present_frame(frame_data);
+ self.presentation_frozen = frozen;
+ }
+
+ pub(super) fn present_graphics(&mut self, graphics: &[u8]) {
+ if self.presentation_frozen || graphics.is_empty() || !self.kitty_graphics_enabled {
+ return;
+ }
+ let mut stdout = io::stdout();
+ let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics);
+ let _ = stdout.flush();
+ }
+
+ pub(super) fn present_surface_patch(
+ &mut self,
+ patch: shell::ClientComposedSurfacePatch,
+ ) -> io::Result<bool> {
+ if self.presentation_frozen || self.repaint_pending {
+ crate::render_prof::event("client_surface_patch.fallback.repaint");
+ return Ok(false);
+ }
+ let rows = if self.draw_host_cursor {
+ let Some(rows) = self
+ .blit_encoder
+ .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref())
+ else {
+ crate::render_prof::event("client_surface_patch.fallback.drawn_cursor");
+ return Ok(false);
+ };
+ rows
+ } else {
+ patch.rows
+ };
+ let encode_started = crate::render_prof::timer();
+ let Some(encoded) =
+ self.blit_encoder
+ .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor)
+ else {
+ crate::render_prof::event("client_surface_patch.fallback.encode");
+ return Ok(false);
+ };
+ crate::render_prof::duration_since("client_surface_patch.encode", encode_started);
+ let write_started = crate::render_prof::timer();
+ let mut stdout = io::stdout();
+ stdout.write_all(&encoded.bytes)?;
+ stdout.flush()?;
+ crate::render_prof::duration_since("client_surface_patch.write", write_started);
+ let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded);
+ crate::render_prof::event(if committed {
+ "client_surface_patch.success"
+ } else {
+ "client_surface_patch.fallback.commit"
+ });
+ Ok(committed)
+ }
+
+ #[cfg(unix)]
+ pub(super) fn retire_endpoint_graphics(&mut self, endpoint_id: &endpoint::ClientEndpointId) {
+ let transfer_ids = self
+ .pending_surface_graphics
+ .keys()
+ .filter(|(owner, _, _)| owner == endpoint_id)
+ .map(|(_, transfer_id, _)| *transfer_id)
+ .collect::<Vec<_>>();
+ self.pending_surface_graphics
+ .retain(|(owner, _, _), _| owner != endpoint_id);
+ if self
+ .retired_direct_graphics
+ .as_ref()
+ .is_some_and(|(owner, _, _)| owner == endpoint_id)
+ {
+ self.retired_direct_graphics = None;
+ }
+ if let Ok(mut matcher) = self.direct_graphics_response.lock() {
+ for transfer_id in transfer_ids {
+ matcher.retire(transfer_id);
+ }
+ }
+ }
+
+ pub(super) fn present_frame(&mut self, frame_data: FrameData) {
+ if self.presentation_frozen {
+ return;
+ }
+ let frame_data = if self.draw_host_cursor {
+ render_ansi::frame_with_drawn_cursor(frame_data)
+ } else {
+ frame_data
+ };
+ let encoded = if self.draw_host_cursor {
+ self.blit_encoder
+ .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending)
+ } else {
+ self.blit_encoder.encode(&frame_data, self.repaint_pending)
+ };
+ let mut stdout = io::stdout();
+ let graphics = if self.kitty_graphics_enabled {
+ frame_data.graphics.as_slice()
+ } else {
+ &[]
+ };
+ let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics);
+ let _ = stdout.flush();
+ self.blit_encoder.commit(frame_data, encoded);
+ self.repaint_pending = false;
+ }
+}
diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs
index a46af2b1..c7328efd 100644
--- a/src/client/terminal_sessions.rs
+++ b/src/client/terminal_sessions.rs
@@ -89,7 +89,18 @@ fn connect_terminal_session_stream(
}
};
- match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) {
+ match do_handshake(
+ &mut stream,
+ cols,
+ rows,
+ 0,
+ 0,
+ false,
+ None,
+ false,
+ false,
+ true,
+ ) {
Ok(handshake) if handshake.encoding == RenderEncoding::TerminalAnsi => {}
Ok(handshake) => {
eprintln!(
diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs
index 5d789444..cca73232 100644
--- a/src/client/tests/mod.rs
+++ b/src/client/tests/mod.rs
@@ -156,6 +156,26 @@ fn host_cursor_policy_native_and_drawn_override_auto_detection() {
));
}
+#[test]
+fn image_bridge_follows_the_selected_remote_endpoint() {
+ let remote = crate::client::endpoint::ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
+
+ assert!(endpoint_accepts_local_images(false, &remote, true));
+ assert!(endpoint_accepts_local_images(
+ true,
+ &crate::client::endpoint::ClientEndpointId::Local,
+ true,
+ ));
+ assert!(!endpoint_accepts_local_images(
+ false,
+ &crate::client::endpoint::ClientEndpointId::Local,
+ true,
+ ));
+ assert!(!endpoint_accepts_local_images(false, &remote, false));
+}
+
#[cfg(unix)]
#[test]
fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() {
@@ -181,6 +201,11 @@ fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() {
Some(ctrl_v)
));
assert!(!should_bridge_clipboard_image_paste(
+ &[0x16],
+ false,
+ Some(ctrl_v)
+ ));
+ assert!(!should_bridge_clipboard_image_paste(
b"\x1b[200~text\x1b[201~",
true,
Some(ctrl_v)
diff --git a/src/client/transport.rs b/src/client/transport.rs
new file mode 100644
index 00000000..4807c31d
--- /dev/null
+++ b/src/client/transport.rs
@@ -0,0 +1,146 @@
+use super::*;
+
+pub(super) fn start_endpoint_transport(
+ stream: LocalStream,
+ lifetime: impl Send + 'static,
+ event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>,
+ endpoint_id: endpoint::ClientEndpointId,
+ generation: u64,
+ max_frame_size: usize,
+) -> Result<endpoint::NativeEndpointTransport, ClientError> {
+ let reader = stream.try_clone().map_err(ClientError::ConnectionFailed)?;
+ let transport = endpoint::NativeEndpointTransport::with_lifetime(stream, lifetime)
+ .map_err(ClientError::ConnectionFailed)?;
+ let stopped = transport.stop_handle();
+ let event_tx = event_tx.clone();
+ std::thread::Builder::new()
+ .name("endpoint-reader".into())
+ .spawn(move || {
+ server_reader_thread(
+ reader,
+ event_tx,
+ &stopped,
+ max_frame_size,
+ endpoint_id,
+ generation,
+ );
+ })
+ .map_err(ClientError::ConnectionFailed)?;
+ Ok(transport)
+}
+
+/// Reads complete frames while retaining partial-read progress across nonblocking polls.
+pub(super) fn server_reader_thread(
+ mut stream: LocalStream,
+ event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
+ should_quit: &Arc<AtomicBool>,
+ max_frame_size: usize,
+ endpoint_id: endpoint::ClientEndpointId,
+ generation: u64,
+) {
+ if stream.set_nonblocking(true).is_err() {
+ let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected {
+ endpoint_id,
+ generation,
+ });
+ return;
+ }
+
+ let mut stream = EndpointReader {
+ stream: &mut stream,
+ stopped: should_quit,
+ };
+ loop {
+ if should_quit.load(Ordering::Acquire) {
+ break;
+ }
+
+ match protocol::read_message(&mut stream, max_frame_size) {
+ Ok(msg) => {
+ if event_tx
+ .blocking_send(ClientLoopEvent::ServerMessage {
+ endpoint_id: endpoint_id.clone(),
+ generation,
+ message: Box::new(msg),
+ })
+ .is_err()
+ {
+ break;
+ }
+ }
+ Err(protocol::FramingError::UnexpectedEof) => {
+ let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected {
+ endpoint_id: endpoint_id.clone(),
+ generation,
+ });
+ break;
+ }
+ Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => {
+ std::thread::sleep(Duration::from_millis(1));
+ }
+ Err(err) => {
+ warn!(err = %err, "server read error");
+ let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected {
+ endpoint_id: endpoint_id.clone(),
+ generation,
+ });
+ break;
+ }
+ }
+ }
+}
+
+struct EndpointReader<'a> {
+ stream: &'a mut LocalStream,
+ stopped: &'a AtomicBool,
+}
+
+impl io::Read for EndpointReader<'_> {
+ fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
+ loop {
+ if self.stopped.load(Ordering::Acquire) {
+ return Ok(0);
+ }
+ match crate::ipc::poll_local_stream_read_count(self.stream, buffer)? {
+ crate::ipc::LocalStreamReadCount::Data(count) => return Ok(count),
+ crate::ipc::LocalStreamReadCount::Closed => return Ok(0),
+ crate::ipc::LocalStreamReadCount::Pending => {
+ crate::platform::wait_client_stream_readable(self.stream)?;
+ }
+ }
+ }
+ }
+}
+
+pub(in crate::client) fn write_to_local_server(
+ stream: &mut LocalStream,
+ msg: &ClientMessage,
+) -> io::Result<()> {
+ protocol::write_message(stream, msg).map_err(|error| io::Error::other(error.to_string()))
+}
+
+pub(super) trait ClientMessageSink {
+ fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()>;
+}
+
+impl ClientMessageSink for LocalStream {
+ fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()> {
+ write_to_local_server(self, message)
+ }
+}
+
+impl ClientMessageSink for endpoint::EndpointRegistry {
+ fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()> {
+ // The lifecycle loop consumes failures for every endpoint, including Local. A send
+ // failure must not bypass that transition or tear down unrelated connections.
+ self.send(message);
+ Ok(())
+ }
+}
+
+pub(super) fn write_to_server(
+ stream: &mut impl ClientMessageSink,
+ msg: &ClientMessage,
+) -> io::Result<()> {
+ stream.send_client_message(msg)
+}
diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs
index f5715889..32838c21 100644
--- a/src/config/sidebar.rs
+++ b/src/config/sidebar.rs
@@ -104,6 +104,7 @@ pub struct SidebarTokenStyle {
pub enum AgentSidebarToken {
StateIcon,
StateText,
+ Machine,
Workspace,
Tab,
Pane,
@@ -234,6 +235,7 @@ fn agent_token_name(token: &AgentSidebarToken) -> String {
match token {
AgentSidebarToken::StateIcon => "state_icon".into(),
AgentSidebarToken::StateText => "state_text".into(),
+ AgentSidebarToken::Machine => "machine".into(),
AgentSidebarToken::Workspace => "workspace".into(),
AgentSidebarToken::Tab => "tab".into(),
AgentSidebarToken::Pane => "pane".into(),
@@ -288,6 +290,7 @@ impl<'de> Deserialize<'de> for AgentSidebarToken {
&[
("state_icon", Self::StateIcon),
("state_text", Self::StateText),
+ ("machine", Self::Machine),
("workspace", Self::Workspace),
("tab", Self::Tab),
("pane", Self::Pane),
@@ -393,6 +396,7 @@ impl Default for AgentsSidebarConfig {
rows: vec![
vec![
AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Machine,
AgentSidebarToken::Workspace,
AgentSidebarToken::Tab,
],
@@ -443,6 +447,7 @@ mod tests {
vec![
vec![
AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Machine,
AgentSidebarToken::Workspace,
AgentSidebarToken::Tab,
],
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index 77ac36da..cd774c6b 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -1557,6 +1557,11 @@ impl Terminal {
)
}
+ pub(crate) fn kitty_graphics_may_have_placements(&self) -> Result<bool, Error> {
+ let generation = self.kitty_graphics_generation()?;
+ Ok(generation != 0 && self.kitty_empty_generation.get() != Some(generation))
+ }
+
pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, Error> {
self.kitty_image_placements_with_data_filter(|_| true)
}
@@ -3448,13 +3453,16 @@ mod tests {
terminal.resize(10, 5, 8, 16).unwrap();
assert_eq!(terminal.kitty_graphics_generation().unwrap(), 0);
+ assert!(!terminal.kitty_graphics_may_have_placements().unwrap());
assert!(terminal.kitty_image_placements().unwrap().is_empty());
terminal.write(b"\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\");
let transmitted = terminal.kitty_graphics_generation().unwrap();
assert_ne!(transmitted, 0);
+ assert!(terminal.kitty_graphics_may_have_placements().unwrap());
assert!(terminal.kitty_image_placements().unwrap().is_empty());
assert_eq!(terminal.kitty_empty_generation.get(), Some(transmitted));
+ assert!(!terminal.kitty_graphics_may_have_placements().unwrap());
terminal.write(b"plain text");
assert_eq!(terminal.kitty_graphics_generation().unwrap(), transmitted);
@@ -3463,6 +3471,7 @@ mod tests {
terminal.write(b"\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\");
let placed = terminal.kitty_graphics_generation().unwrap();
assert_ne!(placed, transmitted);
+ assert!(terminal.kitty_graphics_may_have_placements().unwrap());
assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1);
terminal.resize(10, 5, 12, 24).unwrap();
@@ -3473,14 +3482,17 @@ mod tests {
assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed);
assert!(terminal.kitty_image_placements().unwrap().is_empty());
assert_ne!(terminal.kitty_empty_generation.get(), Some(placed));
+ assert!(terminal.kitty_graphics_may_have_placements().unwrap());
terminal.scroll_viewport_row(0);
assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1);
terminal.write(b"\x1b_Ga=d,d=A\x1b\\");
let deleted = terminal.kitty_graphics_generation().unwrap();
assert_ne!(deleted, placed);
+ assert!(terminal.kitty_graphics_may_have_placements().unwrap());
assert!(terminal.kitty_image_placements().unwrap().is_empty());
assert_eq!(terminal.kitty_empty_generation.get(), Some(deleted));
+ assert!(!terminal.kitty_graphics_may_have_placements().unwrap());
}
#[test]
diff --git a/src/ipc.rs b/src/ipc.rs
index 36e69ea2..291b6ca2 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -131,13 +131,6 @@ pub(crate) fn set_local_stream_polling(stream: &mut LocalStream, enabled: bool)
}
}
-#[cfg(unix)]
-pub(crate) fn shutdown_local_stream_write(stream: &LocalStream) -> io::Result<()> {
- match stream {
- LocalStream::UdSocket(stream) => stream.inner().shutdown(std::net::Shutdown::Write),
- }
-}
-
/// Binds a listener for private terminal traffic. Unix callers restrict the
/// socket file after binding; Windows must set the named-pipe DACL at creation.
pub(crate) fn bind_private_local_listener(path: &Path) -> io::Result<LocalListener> {
diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs
index bf2482b6..e5f00ac2 100644
--- a/src/kitty_graphics/surface.rs
+++ b/src/kitty_graphics/surface.rs
@@ -50,6 +50,10 @@ pub(crate) struct ClientState {
}
impl ClientState {
+ pub(crate) fn scope(&self) -> &str {
+ &self.scope
+ }
+
pub(crate) fn set_scope(&mut self, scope: &str) {
if self.scope == scope {
return;
diff --git a/src/main.rs b/src/main.rs
index ee38e84b..4d7e2f88 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -330,18 +330,18 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# distinct static glyphs for blocked, working, done, idle, and unknown states.
# status_indicators = "dots"
-# Expanded agent rows. Built-ins are state_icon, state_text, workspace, tab, pane, agent,
-# terminal_title, and terminal_title_stripped.
+# Expanded agent rows. Built-ins are state_icon, state_text, machine, workspace, tab,
+# pane, agent, terminal_title, and terminal_title_stripped.
# Custom values reported through pane metadata use a $name token.
# A token occurrence may be styled with { token = "workspace", fg = "#89b4fa", bold = true, dim = false }.
# Omitted style fields preserve the contextual default.
# [ui.sidebar.agents]
# Blank rows between agent entries. Set to 1 to restore the previous spacing.
# row_gap = 0
-# rows = [["state_icon", "workspace", "tab"], ["agent"]]
+# rows = [["state_icon", "machine", "workspace", "tab"], ["agent"]]
# Optional canonical agent IDs replace the default rows for matching agents.
# [ui.sidebar.agents.rows_by_agent]
-# claude = [["state_icon", "workspace", "tab"], ["terminal_title_stripped"], ["agent"]]
+# claude = [["state_icon", "machine", "workspace", "tab"], ["terminal_title_stripped"], ["agent"]]
# Expanded space rows. Built-ins are state_icon, state_text, workspace, branch, and git_status.
# Custom values reported through workspace metadata use a $name token, for example $jj_status.
@@ -587,6 +587,7 @@ fn main() -> io::Result<()> {
println!(" herdr completion zsh");
println!(" herdr update [--handoff]");
println!(" herdr channel set <stable|preview>");
+ println!(" herdr machine <subcommand> ...");
println!(" herdr server stop");
println!(" herdr server reload-config");
println!(" herdr api <subcommand> ...");
@@ -631,6 +632,7 @@ fn main() -> io::Result<()> {
"herdr channel <subcommand>",
"Manage the stable or preview update channel",
),
+ ("herdr machine <subcommand>", "Manage saved SSH machines"),
(
"herdr api <subcommand>",
"Inspect socket API metadata and live runtime state",
@@ -737,6 +739,7 @@ fn main() -> io::Result<()> {
"status",
"config",
"channel",
+ "machine",
"workspace",
"worktree",
"pane",
@@ -764,7 +767,9 @@ fn main() -> io::Result<()> {
let loaded_config = config::Config::load();
exit_if_nested_disabled(&loaded_config.config);
- if let Err(err) = server::autodetect::auto_detect_launch() {
+ let saved_federation =
+ client::endpoint::EndpointCatalog::load().is_ok_and(|catalog| catalog.has_enabled_ssh());
+ if let Err(err) = server::autodetect::auto_detect_launch(saved_federation) {
eprintln!("herdr: {err}");
std::process::exit(1);
}
diff --git a/src/pane.rs b/src/pane.rs
index 4f1ea0ad..83bad900 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -3086,6 +3086,10 @@ impl PaneRuntime {
self.terminal.visible_hyperlinks(area)
}
+ pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool {
+ self.terminal.kitty_graphics_may_have_placements()
+ }
+
pub fn kitty_image_placements_with_data_filter<F>(
&self,
needs_data: F,
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index a804a7fb..1dc5a88d 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -538,6 +538,10 @@ impl PaneTerminal {
self.ghostty.visible_hyperlinks(area)
}
+ pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool {
+ self.ghostty.kitty_graphics_may_have_placements()
+ }
+
pub fn kitty_image_placements_with_data_filter<F>(
&self,
needs_data: F,
@@ -2190,6 +2194,14 @@ impl GhosttyPaneTerminal {
.unwrap_or_default()
}
+ pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool {
+ self.core
+ .lock()
+ .ok()
+ .and_then(|core| core.terminal.kitty_graphics_may_have_placements().ok())
+ .unwrap_or(true)
+ }
+
pub fn kitty_image_placements_with_data_filter<F>(
&self,
needs_data: F,
diff --git a/src/platform/client_state.rs b/src/platform/client_state.rs
new file mode 100644
index 00000000..d339bae8
--- /dev/null
+++ b/src/platform/client_state.rs
@@ -0,0 +1,32 @@
+use std::path::Path;
+
+#[cfg(not(windows))]
+pub(crate) fn create_private_state_file(path: &Path) -> std::io::Result<std::fs::File> {
+ super::create_remote_ssh_config_file(path)
+}
+
+#[cfg(windows)]
+pub(crate) fn create_private_state_file(path: &Path) -> std::io::Result<std::fs::File> {
+ super::windows::create_remote_ssh_config_file(path)
+}
+
+#[cfg(not(windows))]
+pub(crate) fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> {
+ std::fs::rename(source, destination)
+}
+
+#[cfg(windows)]
+pub(crate) fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> {
+ super::windows::replace_file(source, destination)
+}
+
+#[cfg(not(windows))]
+pub(crate) fn sync_parent_directory(path: &Path) -> std::io::Result<()> {
+ std::fs::File::open(path)?.sync_all()
+}
+
+#[cfg(windows)]
+pub(crate) fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
+ // replace_file uses MOVEFILE_WRITE_THROUGH on Windows.
+ Ok(())
+}
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 8efe7357..6c09667a 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -17,7 +17,7 @@ pub(crate) use super::unix_common::{
create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path,
remote_private_temp_base, remote_reattach_argument, remote_reattach_program,
remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported,
- StatusCommandGuard,
+ wait_client_stream_readable, StatusCommandGuard,
};
const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"];
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index 948468ac..8840c7ed 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -17,7 +17,7 @@ pub(crate) use super::unix_common::{
create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path,
remote_private_temp_base, remote_reattach_argument, remote_reattach_program,
remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported,
- StatusCommandGuard,
+ wait_client_stream_readable, StatusCommandGuard,
};
const PROC_PGRP_ONLY: u32 = 2;
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index 4b49b9f7..86a431aa 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -236,6 +236,9 @@ mod unix_common;
#[cfg(unix)]
pub(crate) use unix_common::{begin_cli_output, end_cli_output};
+mod client_state;
+pub(crate) use client_state::{create_private_state_file, replace_file, sync_parent_directory};
+
#[cfg(not(unix))]
pub(crate) fn begin_cli_output() {}
diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs
index a7926c22..dff67357 100644
--- a/src/platform/unix_common.rs
+++ b/src/platform/unix_common.rs
@@ -1,5 +1,24 @@
use std::path::{Path, PathBuf};
+pub(crate) fn wait_client_stream_readable(stream: &crate::ipc::LocalStream) -> std::io::Result<()> {
+ use std::os::fd::{AsFd as _, AsRawFd as _};
+ let crate::ipc::LocalStream::UdSocket(stream) = stream;
+ let mut descriptor = libc::pollfd {
+ fd: stream.as_fd().as_raw_fd(),
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ // Bound cancellation latency without polling idle connections hundreds of times per second.
+ let result = unsafe { libc::poll(&mut descriptor, 1, 100) };
+ if result < 0 {
+ let error = std::io::Error::last_os_error();
+ if error.kind() != std::io::ErrorKind::Interrupted {
+ return Err(error);
+ }
+ }
+ Ok(())
+}
+
pub(super) fn read_terminal_grid_size() -> std::io::Result<(u16, u16)> {
crossterm::terminal::window_size().map(|size| (size.columns, size.rows))
}
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index 1c5c4afb..ce0afedb 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -15,10 +15,52 @@ use std::{
mod clipboard_image;
+pub(crate) fn wait_client_stream_readable(
+ _stream: &crate::ipc::LocalStream,
+) -> std::io::Result<()> {
+ // Sync named pipes have no read timeout. The caller peeks before each read and checks its
+ // cancellation flag between polls, including when a frame arrives in several fragments.
+ std::thread::sleep(Duration::from_millis(2));
+ Ok(())
+}
+
pub(super) fn read_terminal_grid_size() -> std::io::Result<(u16, u16)> {
crossterm::terminal::size()
}
+pub(crate) fn replace_file(
+ source: &std::path::Path,
+ destination: &std::path::Path,
+) -> std::io::Result<()> {
+ use std::os::windows::ffi::OsStrExt;
+ use windows_sys::Win32::Storage::FileSystem::{
+ MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
+ };
+
+ let source = source
+ .as_os_str()
+ .encode_wide()
+ .chain(std::iter::once(0))
+ .collect::<Vec<_>>();
+ let destination = destination
+ .as_os_str()
+ .encode_wide()
+ .chain(std::iter::once(0))
+ .collect::<Vec<_>>();
+ let moved = unsafe {
+ MoveFileExW(
+ source.as_ptr(),
+ destination.as_ptr(),
+ MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
+ )
+ };
+ if moved == 0 {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+}
+
pub(crate) fn set_default_plugin_pane_pwd(
_env: &mut Vec<(String, String)>,
_cwd: &std::path::Path,
diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs
index b2955ec8..a70ef568 100644
--- a/src/protocol/endpoint.rs
+++ b/src/protocol/endpoint.rs
@@ -20,6 +20,17 @@ pub const ENDPOINT_SNAPSHOT_KIND: &str = SNAPSHOT_CODEC_V1;
pub const SURFACE_CODEC_V1: &str = "shell.surface.v1";
pub const INPUT_CODEC_V1: &str = "shell.input.semantic.v1";
pub const BLOB_CODEC_V1: &str = "shell.blob.v1";
+pub const SURFACE_INTEREST_CAPABILITY: &str = "surface_interest";
+pub const PRESENTATION_EFFECTS_FENCE_CAPABILITY: &str = "presentation_effects_fence";
+pub const PRESENTATION_EFFECTS_SYNC_KIND: &str = "endpoint.presentation.sync.v1";
+pub const PRESENTATION_EFFECTS_READY_KIND: &str = "endpoint.presentation.ready.v1";
+pub const HEALTH_CHECK_CAPABILITY: &str = "health_check";
+pub const HEALTH_PING_KIND: &str = "endpoint.health.ping.v1";
+pub const HEALTH_PONG_KIND: &str = "endpoint.health.pong.v1";
+
+fn default_true() -> bool {
+ true
+}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndpointClientHello {
@@ -31,6 +42,8 @@ pub struct EndpointClientHello {
pub direct_graphics: bool,
pub endpoint_keybindings: bool,
pub mouse_capture: bool,
+ #[serde(default = "default_true")]
+ pub surface_active: bool,
#[serde(default)]
pub snapshot_codecs: Vec<String>,
#[serde(default)]
@@ -57,6 +70,8 @@ pub struct EndpointServerWelcome {
pub blob_codec: String,
#[serde(default)]
pub methods: Vec<String>,
+ #[serde(default)]
+ pub capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<EndpointHandshakeError>,
}
@@ -95,6 +110,11 @@ impl EndpointServerWelcome {
input_codec: INPUT_CODEC_V1.into(),
blob_codec: BLOB_CODEC_V1.into(),
methods,
+ capabilities: vec![
+ SURFACE_INTEREST_CAPABILITY.into(),
+ PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(),
+ HEALTH_CHECK_CAPABILITY.into(),
+ ],
error: None,
}
}
@@ -108,6 +128,7 @@ impl EndpointServerWelcome {
input_codec: INPUT_CODEC_V1.into(),
blob_codec: BLOB_CODEC_V1.into(),
methods: Vec::new(),
+ capabilities: Vec::new(),
error: Some(EndpointHandshakeError {
code: code.into(),
message: message.into(),
@@ -130,6 +151,7 @@ mod tests {
direct_graphics: false,
endpoint_keybindings: false,
mouse_capture: true,
+ surface_active: true,
snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()],
surface_codecs: vec![SURFACE_CODEC_V1.into()],
input_codecs: vec![INPUT_CODEC_V1.into()],
@@ -192,6 +214,7 @@ mod tests {
assert_eq!(welcome.surface_codec, SURFACE_CODEC_V1);
assert_eq!(welcome.input_codec, INPUT_CODEC_V1);
assert_eq!(welcome.blob_codec, BLOB_CODEC_V1);
+ assert!(welcome.capabilities.is_empty());
}
#[test]
@@ -245,6 +268,27 @@ mod tests {
}
#[test]
+ fn legacy_hello_defaults_to_an_active_surface() {
+ let mut value = serde_json::to_value(hello()).unwrap();
+ value.as_object_mut().unwrap().remove("surface_active");
+ let decoded: EndpointClientHello = serde_json::from_value(value).unwrap();
+ assert!(decoded.surface_active);
+ }
+
+ #[test]
+ fn compatible_server_advertises_endpoint_lifecycle_capabilities() {
+ let welcome = EndpointServerWelcome::compatible(Vec::new());
+ assert_eq!(
+ welcome.capabilities,
+ vec![
+ SURFACE_INTEREST_CAPABILITY.to_string(),
+ PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(),
+ HEALTH_CHECK_CAPABILITY.to_string(),
+ ]
+ );
+ }
+
+ #[test]
fn required_codecs_are_explicit() {
let mut value = hello();
assert!(value.supports_required_codecs());
diff --git a/src/remote.rs b/src/remote.rs
index 19904ef2..ba935ec4 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -1,10 +1,16 @@
+mod args;
mod attach;
#[cfg(unix)]
mod host_unix;
+mod process;
+mod restart_policy;
+mod saved;
+pub(crate) use args::*;
pub(crate) use attach::*;
#[cfg(unix)]
pub(crate) use host_unix::run_remote_client_bridge;
+pub(crate) use saved::*;
#[cfg(windows)]
pub(crate) fn run_remote_client_bridge() -> std::io::Result<()> {
diff --git a/src/remote/args.rs b/src/remote/args.rs
new file mode 100644
index 00000000..476acf70
--- /dev/null
+++ b/src/remote/args.rs
@@ -0,0 +1,126 @@
+pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
+pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum RemoteKeybindings {
+ Local,
+ Server,
+}
+
+impl RemoteKeybindings {
+ pub(super) fn parse(value: &str) -> Result<Self, String> {
+ match value {
+ "local" => Ok(Self::Local),
+ "server" => Ok(Self::Server),
+ _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()),
+ }
+ }
+
+ pub(super) fn as_str(self) -> &'static str {
+ match self {
+ Self::Local => "local",
+ Self::Server => "server",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct RemoteLaunch {
+ pub(crate) target: String,
+ pub(crate) keybindings: RemoteKeybindings,
+ pub(crate) live_handoff: bool,
+}
+
+pub(crate) fn extract_remote_args(
+ args: &[String],
+) -> Result<(Vec<String>, Option<RemoteLaunch>), String> {
+ let mut cleaned = Vec::with_capacity(args.len());
+ if let Some(program) = args.first() {
+ cleaned.push(program.clone());
+ }
+
+ let mut remote_target = None;
+ let mut keybindings = RemoteKeybindings::Local;
+ let mut keybindings_seen = false;
+ let mut live_handoff = false;
+ let mut index = 1;
+ while index < args.len() {
+ let arg = &args[index];
+ if arg == "--" {
+ cleaned.extend_from_slice(&args[index..]);
+ break;
+ }
+ if arg == "--handoff" {
+ live_handoff = true;
+ index += 1;
+ continue;
+ }
+ if arg == "--remote" {
+ if remote_target.is_some() {
+ return Err("--remote can only be specified once".to_string());
+ }
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --remote".to_string());
+ };
+ remote_target = Some(validate_remote_target(value)?.to_owned());
+ index += 2;
+ continue;
+ }
+ if let Some(value) = arg.strip_prefix("--remote=") {
+ if remote_target.is_some() {
+ return Err("--remote can only be specified once".to_string());
+ }
+ remote_target = Some(validate_remote_target(value)?.to_owned());
+ index += 1;
+ continue;
+ }
+ if arg == "--remote-keybindings" {
+ if keybindings_seen {
+ return Err("--remote-keybindings can only be specified once".to_string());
+ }
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --remote-keybindings".to_string());
+ };
+ keybindings = RemoteKeybindings::parse(value)?;
+ keybindings_seen = true;
+ index += 2;
+ continue;
+ }
+ if let Some(value) = arg.strip_prefix("--remote-keybindings=") {
+ if keybindings_seen {
+ return Err("--remote-keybindings can only be specified once".to_string());
+ }
+ keybindings = RemoteKeybindings::parse(value)?;
+ keybindings_seen = true;
+ index += 1;
+ continue;
+ }
+
+ cleaned.push(arg.clone());
+ index += 1;
+ }
+
+ let remote = remote_target.map(|target| RemoteLaunch {
+ target,
+ keybindings,
+ live_handoff,
+ });
+ if remote.is_none() && keybindings_seen {
+ return Err("--remote-keybindings requires --remote".to_string());
+ }
+ if remote.is_none() && live_handoff {
+ cleaned.push("--handoff".to_string());
+ }
+
+ Ok((cleaned, remote))
+}
+
+pub(crate) fn validate_remote_target(target: &str) -> Result<&str, String> {
+ if target.is_empty() {
+ return Err("missing value for --remote".to_string());
+ }
+ if target.starts_with('-') {
+ return Err("--remote target must not start with '-'".to_string());
+ }
+ Ok(target)
+}
diff --git a/src/remote/attach.rs b/src/remote/attach.rs
index 641aad00..81810411 100644
--- a/src/remote/attach.rs
+++ b/src/remote/attach.rs
@@ -1,15 +1,13 @@
//! Remote thin-client launcher over SSH command stdio.
-use super::shell_quote;
+use super::{args::*, process::wait_with_output_timeout, restart_policy::*, shell_quote};
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{self, IsTerminal, Write as _};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
-use interprocess::local_socket::traits::Listener as _;
-#[cfg(windows)]
-use interprocess::local_socket::traits::Stream as _;
+use interprocess::local_socket::traits::{Listener as _, Stream as _};
use interprocess::local_socket::ListenerNonblockingMode;
use interprocess::TryClone as _;
use serde::Deserialize;
@@ -21,144 +19,16 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
-#[cfg(windows)]
const BRIDGE_IO_POLL: Duration = Duration::from_millis(1);
const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
+const NONINTERACTIVE_SSH_COMMAND_TIMEOUT: Duration = Duration::from_secs(15);
const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION;
const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
const SSH_CONTROL_SOCKET_NAME: &str = "ctl";
-pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
-
-pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS";
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum RemoteKeybindings {
- Local,
- Server,
-}
-
-impl RemoteKeybindings {
- fn parse(value: &str) -> Result<Self, String> {
- match value {
- "local" => Ok(Self::Local),
- "server" => Ok(Self::Server),
- _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()),
- }
- }
-
- fn as_str(self) -> &'static str {
- match self {
- Self::Local => "local",
- Self::Server => "server",
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct RemoteLaunch {
- pub(crate) target: String,
- pub(crate) keybindings: RemoteKeybindings,
- pub(crate) live_handoff: bool,
-}
-
-pub(crate) fn extract_remote_args(
- args: &[String],
-) -> Result<(Vec<String>, Option<RemoteLaunch>), String> {
- let mut cleaned = Vec::with_capacity(args.len());
- if let Some(program) = args.first() {
- cleaned.push(program.clone());
- }
-
- let mut remote_target = None;
- let mut keybindings = RemoteKeybindings::Local;
- let mut keybindings_seen = false;
- let mut live_handoff = false;
- let mut index = 1;
- while index < args.len() {
- let arg = &args[index];
- if arg == "--" {
- cleaned.extend_from_slice(&args[index..]);
- break;
- }
- if arg == "--handoff" {
- live_handoff = true;
- index += 1;
- continue;
- }
- if arg == "--remote" {
- if remote_target.is_some() {
- return Err("--remote can only be specified once".to_string());
- }
- let Some(value) = args.get(index + 1) else {
- return Err("missing value for --remote".to_string());
- };
- remote_target = Some(validate_remote_target(value)?.to_owned());
- index += 2;
- continue;
- }
- if let Some(value) = arg.strip_prefix("--remote=") {
- if remote_target.is_some() {
- return Err("--remote can only be specified once".to_string());
- }
- remote_target = Some(validate_remote_target(value)?.to_owned());
- index += 1;
- continue;
- }
- if arg == "--remote-keybindings" {
- if keybindings_seen {
- return Err("--remote-keybindings can only be specified once".to_string());
- }
- let Some(value) = args.get(index + 1) else {
- return Err("missing value for --remote-keybindings".to_string());
- };
- keybindings = RemoteKeybindings::parse(value)?;
- keybindings_seen = true;
- index += 2;
- continue;
- }
- if let Some(value) = arg.strip_prefix("--remote-keybindings=") {
- if keybindings_seen {
- return Err("--remote-keybindings can only be specified once".to_string());
- }
- keybindings = RemoteKeybindings::parse(value)?;
- keybindings_seen = true;
- index += 1;
- continue;
- }
-
- cleaned.push(arg.clone());
- index += 1;
- }
-
- let remote = remote_target.map(|target| RemoteLaunch {
- target,
- keybindings,
- live_handoff,
- });
- if remote.is_none() && keybindings_seen {
- return Err("--remote-keybindings requires --remote".to_string());
- }
- if remote.is_none() && live_handoff {
- cleaned.push("--handoff".to_string());
- }
-
- Ok((cleaned, remote))
-}
-
-fn validate_remote_target(target: &str) -> Result<&str, String> {
- if target.is_empty() {
- return Err("missing value for --remote".to_string());
- }
- if target.starts_with('-') {
- return Err("--remote target must not start with '-'".to_string());
- }
- Ok(target)
-}
-
pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
let session_name = crate::session::active_name()
.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string());
@@ -177,13 +47,22 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
.config
.remote
.manage_ssh_config;
- let remote_ssh = RemoteSsh::new(remote.target.clone(), manage_ssh_config);
- let prepared_remote = prepare_remote_herdr(&remote_ssh, remote.live_handoff)?;
+ let require_surface_interest = crate::client::endpoint::EndpointCatalog::load()
+ .map(|catalog| catalog.contains_enabled_target_session(&remote.target, &session_name))
+ .unwrap_or(false);
+ let remote_ssh = RemoteSsh::new(
+ remote.target.clone(),
+ manage_ssh_config,
+ session_name.clone(),
+ );
+ let prepared_remote =
+ prepare_remote_herdr(&remote_ssh, remote.live_handoff, require_surface_interest)?;
ensure_remote_server_ready(
&remote_ssh,
&prepared_remote.remote_herdr,
prepared_remote.stop_after_install_approved,
remote.live_handoff,
+ require_surface_interest,
)?;
let _bridge = SshStdioBridge::start(
@@ -192,11 +71,68 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
local_socket.clone(),
session_name,
remote_ssh.options(),
+ false,
)?;
run_client_process(&local_socket, &reattach_command, remote.keybindings)
}
+pub(crate) fn prepare_saved_ssh(target: &str, session_name: &str) -> io::Result<()> {
+ super::validate_remote_target(target)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
+ crate::session::validate_name(session_name)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
+ let manage_ssh_config = crate::config::Config::load()
+ .config
+ .remote
+ .manage_ssh_config;
+ let ssh = RemoteSsh::new(
+ target.to_owned(),
+ manage_ssh_config,
+ session_name.to_owned(),
+ );
+ let prepared = prepare_remote_herdr(&ssh, true, true)?;
+ ensure_remote_server_ready(
+ &ssh,
+ &prepared.remote_herdr,
+ prepared.stop_after_install_approved,
+ true,
+ true,
+ )?;
+
+ // The bridge already owns daemon startup. EOF closes only this temporary attachment,
+ // leaving the named server running even when no local TUI is open yet.
+ let output = ssh.sh_output(&format!(
+ "{} </dev/null",
+ remote_bridge_command(&prepared.remote_herdr, session_name)
+ ))?;
+ if !output.status.success() {
+ return Err(command_failed("remote server startup failed", &output));
+ }
+ match remote_server_status(&ssh, &prepared.remote_herdr, true)? {
+ RemoteServerStatus::Running {
+ endpoint_protocol_generation,
+ surface_interest,
+ health_check,
+ detached_server_daemon,
+ ..
+ } if remote_server_restart_reason(
+ endpoint_protocol_generation,
+ detached_server_daemon,
+ true,
+ surface_interest,
+ health_check,
+ )
+ .is_none() =>
+ {
+ Ok(())
+ }
+ _ => Err(io::Error::other(
+ "remote server is not ready for saved machines",
+ )),
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
struct RemotePlatform {
os: &'static str,
@@ -244,7 +180,7 @@ impl RemotePlatform {
}
#[derive(Debug, Clone)]
-struct RemoteHerdr {
+pub(super) struct RemoteHerdr {
install_suffix: String,
shell_path: String,
platform: RemotePlatform,
@@ -392,13 +328,13 @@ struct RemoteReleaseAsset {
sha256: Option<String>,
}
-struct PreparedRemoteHerdr {
- remote_herdr: RemoteHerdr,
+pub(super) struct PreparedRemoteHerdr {
+ pub(super) remote_herdr: RemoteHerdr,
stop_after_install_approved: bool,
}
#[derive(Clone)]
-struct ManagedSshOptions {
+pub(super) struct ManagedSshOptions {
config_path: PathBuf,
control_path: Option<PathBuf>,
}
@@ -415,13 +351,15 @@ impl Drop for ManagedSshConfig {
}
}
-struct RemoteSsh {
+pub(super) struct RemoteSsh {
target: String,
+ session_name: String,
managed_config: Option<ManagedSshConfig>,
+ noninteractive: bool,
}
impl RemoteSsh {
- fn new(target: String, manage_ssh_config: bool) -> Self {
+ fn new(target: String, manage_ssh_config: bool, session_name: String) -> Self {
let managed_config = if manage_ssh_config {
write_managed_ssh_config()
.inspect_err(|err| {
@@ -434,7 +372,18 @@ impl RemoteSsh {
Self {
target,
+ session_name,
managed_config,
+ noninteractive: false,
+ }
+ }
+
+ pub(super) fn new_noninteractive(target: String) -> Self {
+ Self {
+ target,
+ session_name: crate::session::DEFAULT_SESSION_NAME.into(),
+ managed_config: None,
+ noninteractive: true,
}
}
@@ -442,12 +391,19 @@ impl RemoteSsh {
&self.target
}
- fn options(&self) -> Option<&ManagedSshOptions> {
+ fn destination(&self) -> String {
+ format!("{} (session {})", self.target, self.session_name)
+ }
+
+ pub(super) fn options(&self) -> Option<&ManagedSshOptions> {
self.managed_config.as_ref().map(|config| &config.options)
}
fn command(&self) -> Command {
let mut command = self.base_command();
+ if self.noninteractive {
+ apply_noninteractive_ssh_options(&mut command);
+ }
command.arg("-T").arg(&self.target);
command
}
@@ -475,13 +431,26 @@ impl RemoteSsh {
"ssh bootstrap stdin missing",
))
};
- let output = child.wait_with_output()?;
+ let output = if self.noninteractive {
+ wait_with_output_timeout(child, NONINTERACTIVE_SSH_COMMAND_TIMEOUT)?
+ } else {
+ child.wait_with_output()?
+ };
write_result?;
Ok(output)
}
- fn user_shell_output(&self, command: &str) -> io::Result<Output> {
- self.command().arg(command).output()
+ fn user_shell_output(&self, remote_command: &str) -> io::Result<Output> {
+ let mut command = self.command();
+ command
+ .arg(remote_command)
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ if self.noninteractive {
+ wait_with_output_timeout(command.spawn()?, NONINTERACTIVE_SSH_COMMAND_TIMEOUT)
+ } else {
+ command.output()
+ }
}
fn install_herdr(&self, remote_herdr: &RemoteHerdr, source_path: &Path) -> io::Result<()> {
@@ -601,6 +570,24 @@ impl Drop for RemoteSsh {
}
}
+fn apply_noninteractive_ssh_options(command: &mut Command) {
+ command
+ .arg("-o")
+ .arg("BatchMode=yes")
+ .arg("-o")
+ .arg("NumberOfPasswordPrompts=0")
+ .arg("-o")
+ .arg("StrictHostKeyChecking=yes")
+ .arg("-o")
+ .arg("ConnectTimeout=10")
+ .arg("-o")
+ .arg("ConnectionAttempts=1")
+ .arg("-o")
+ .arg("ServerAliveInterval=15")
+ .arg("-o")
+ .arg("ServerAliveCountMax=4");
+}
+
fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshOptions>) {
let Some(options) = options else {
return;
@@ -640,9 +627,10 @@ impl InstallSource {
}
}
-fn prepare_remote_herdr(
+pub(super) fn prepare_remote_herdr(
ssh: &RemoteSsh,
live_handoff_enabled: bool,
+ require_surface_interest: bool,
) -> io::Result<PreparedRemoteHerdr> {
let platform = detect_remote_platform(ssh)?;
let remote_herdr = RemoteHerdr::for_platform(platform);
@@ -651,14 +639,20 @@ fn prepare_remote_herdr(
if override_binary.is_none() {
for candidate in &remote_binary_candidates {
- if remote_binary_supports_endpoint(ssh, candidate).unwrap_or(false) {
+ if remote_binary_supports_endpoint_requirement(ssh, candidate, require_surface_interest)
+ .unwrap_or(false)
+ {
return Ok(PreparedRemoteHerdr {
remote_herdr: candidate.clone(),
stop_after_install_approved: false,
});
}
}
- if remote_binary_supports_endpoint(ssh, &remote_herdr)? {
+ if remote_binary_supports_endpoint_requirement(
+ ssh,
+ &remote_herdr,
+ require_surface_interest,
+ )? {
return Ok(PreparedRemoteHerdr {
remote_herdr,
stop_after_install_approved: false,
@@ -676,10 +670,11 @@ fn prepare_remote_herdr(
ssh,
status_probe_herdr,
live_handoff_enabled,
+ require_surface_interest,
)?;
}
confirm_remote_install(
- ssh.target(),
+ &ssh.destination(),
&remote_herdr,
&install_source_description(&remote_herdr.platform, override_binary.as_deref()),
)?;
@@ -688,11 +683,10 @@ fn prepare_remote_herdr(
source.cleanup();
install_result?;
- if !remote_binary_supports_endpoint(ssh, &remote_herdr)? {
+ if !remote_binary_supports_endpoint_requirement(ssh, &remote_herdr, require_surface_interest)? {
return Err(io::Error::other(format!(
- "installed remote herdr at {}, but it does not support endpoint generation {}",
- remote_herdr.shell_path,
- crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION
+ "installed remote herdr at {}, but it does not support saved SSH endpoint federation",
+ remote_herdr.shell_path
)));
}
warn_if_remote_bin_not_on_path(ssh)?;
@@ -703,6 +697,25 @@ fn prepare_remote_herdr(
})
}
+pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteHerdr> {
+ let platform = detect_remote_platform(ssh)?;
+ let remote_herdr = RemoteHerdr::for_platform(platform);
+ let candidates = remote_binary_candidates(ssh, &remote_herdr)?;
+ for candidate in candidates {
+ if remote_binary_supports_endpoint_requirement(ssh, &candidate, true)? {
+ return Ok(candidate);
+ }
+ }
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ format!(
+ "matching Herdr is not ready on {}; run `herdr --remote {}` interactively to install or update it",
+ ssh.target(),
+ ssh.target()
+ ),
+ ))
+}
+
fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result<RemotePlatform> {
let output = ssh.sh_output("uname -s\nuname -m\n")?;
if !output.status.success() {
@@ -869,6 +882,9 @@ fn remote_client_status(
);
let output = ssh.sh_output(&command)?;
if !output.status.success() {
+ if output.status.code() == Some(255) {
+ return Err(command_failed("remote SSH connection failed", &output));
+ }
return Ok(None);
}
Ok(parse_client_status_json(&String::from_utf8_lossy(
@@ -876,13 +892,13 @@ fn remote_client_status(
)))
}
-fn remote_binary_supports_endpoint(
+fn remote_binary_supports_endpoint_requirement(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
+ require_surface_interest: bool,
) -> io::Result<bool> {
Ok(remote_client_status(ssh, remote_herdr)?
- .and_then(|status| status.endpoint_protocol_generation)
- == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION))
+ .is_some_and(|status| status.supports_endpoint_requirement(require_surface_interest)))
}
fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
@@ -986,23 +1002,30 @@ enum RemoteServerStatus {
Running {
version: Option<String>,
endpoint_protocol_generation: Option<u32>,
+ surface_interest: bool,
+ health_check: bool,
live_handoff: bool,
detached_server_daemon: bool,
},
NotRunning,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RemoteServerRestartReason {
- EndpointProtocolMissing,
- DaemonDetachMissing,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RemoteInstallRunningServerPlan {
- KeepRunning,
- LiveHandoff,
- StopRequired(RemoteServerRestartReason),
+impl RemoteServerStatus {
+ fn with_endpoint_negotiation(
+ mut self,
+ negotiation: &crate::client::endpoint::EndpointNegotiation,
+ ) -> Self {
+ if let Self::Running {
+ surface_interest,
+ health_check,
+ ..
+ } = &mut self
+ {
+ *surface_interest = negotiation.supports_surface_interest();
+ *health_check = negotiation.supports_health_check();
+ }
+ self
+ }
}
fn ensure_remote_server_ready(
@@ -1010,11 +1033,14 @@ fn ensure_remote_server_ready(
remote_herdr: &RemoteHerdr,
stop_after_install_approved: bool,
live_handoff_enabled: bool,
+ require_surface_interest: bool,
) -> io::Result<()> {
- let status = remote_server_status(ssh, remote_herdr)?;
+ let status = remote_server_status(ssh, remote_herdr, require_surface_interest)?;
let RemoteServerStatus::Running {
version,
endpoint_protocol_generation,
+ surface_interest,
+ health_check,
live_handoff,
detached_server_daemon,
} = status
@@ -1022,9 +1048,13 @@ fn ensure_remote_server_ready(
return Ok(());
};
- let Some(reason) =
- remote_server_restart_reason(endpoint_protocol_generation, detached_server_daemon)
- else {
+ let Some(reason) = remote_server_restart_reason(
+ endpoint_protocol_generation,
+ detached_server_daemon,
+ require_surface_interest,
+ surface_interest,
+ health_check,
+ ) else {
return Ok(());
};
@@ -1043,33 +1073,20 @@ fn ensure_remote_server_ready(
return Ok(());
}
- if confirm_remote_server_stop(ssh.target(), version.as_deref(), reason)? {
+ if confirm_remote_server_stop(&ssh.destination(), version.as_deref(), reason)? {
stop_remote_server(ssh, remote_herdr)?;
}
Ok(())
}
-fn remote_server_restart_reason(
- endpoint_protocol_generation: Option<u32>,
- detached_server_daemon: bool,
-) -> Option<RemoteServerRestartReason> {
- if endpoint_protocol_generation != Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)
- {
- return Some(RemoteServerRestartReason::EndpointProtocolMissing);
- }
- if !detached_server_daemon {
- return Some(RemoteServerRestartReason::DaemonDetachMissing);
- }
- None
-}
-
fn confirm_remote_install_with_running_server(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
live_handoff_enabled: bool,
+ require_surface_interest: bool,
) -> io::Result<bool> {
- let target = ssh.target();
- let status = match remote_server_status(ssh, remote_herdr) {
+ let target = ssh.destination();
+ let status = match remote_server_status(ssh, remote_herdr, require_surface_interest) {
Ok(status) => status,
Err(err) => {
if !io::stdin().is_terminal() {
@@ -1098,6 +1115,8 @@ fn confirm_remote_install_with_running_server(
let RemoteServerStatus::Running {
version,
endpoint_protocol_generation,
+ surface_interest,
+ health_check,
live_handoff,
detached_server_daemon,
} = &status
@@ -1107,8 +1126,11 @@ fn confirm_remote_install_with_running_server(
let plan = remote_install_running_server_plan(
*endpoint_protocol_generation,
*detached_server_daemon,
+ *surface_interest,
+ *health_check,
*live_handoff,
live_handoff_enabled,
+ require_surface_interest,
);
if plan == RemoteInstallRunningServerPlan::KeepRunning {
@@ -1172,37 +1194,57 @@ fn confirm_remote_install_with_running_server(
Ok(true)
}
-fn remote_install_running_server_plan(
- endpoint_protocol_generation: Option<u32>,
- detached_server_daemon: bool,
- live_handoff: bool,
- live_handoff_enabled: bool,
-) -> RemoteInstallRunningServerPlan {
- let Some(reason) =
- remote_server_restart_reason(endpoint_protocol_generation, detached_server_daemon)
- else {
- return RemoteInstallRunningServerPlan::KeepRunning;
- };
-
- if live_handoff_enabled && live_handoff {
- return RemoteInstallRunningServerPlan::LiveHandoff;
- }
-
- RemoteInstallRunningServerPlan::StopRequired(reason)
-}
-
fn remote_server_status(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
+ require_surface_interest: bool,
) -> io::Result<RemoteServerStatus> {
- let command = format!("{} status server --json", remote_herdr.shell_path);
+ let command = remote_session_command(remote_herdr, &ssh.session_name, "status server --json");
let output = ssh.sh_output(&command)?;
if !output.status.success() {
return Err(command_failed("remote server status failed", &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
- parse_remote_server_status_json(stdout.trim())
+ let status = parse_remote_server_status_json(stdout.trim())?;
+ if require_surface_interest
+ && matches!(
+ status,
+ RemoteServerStatus::Running {
+ endpoint_protocol_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION
+ ),
+ surface_interest: true,
+ health_check: true,
+ ..
+ }
+ )
+ {
+ // Older status helpers omit newer capabilities. Ask the live endpoint rather than
+ // assuming that the installed binary and the running daemon support the same features.
+ let negotiation = probe_remote_endpoint(ssh, remote_herdr)?;
+ return Ok(status.with_endpoint_negotiation(&negotiation));
+ }
+ Ok(status)
+}
+
+fn probe_remote_endpoint(
+ ssh: &RemoteSsh,
+ remote_herdr: &RemoteHerdr,
+) -> io::Result<crate::client::endpoint::EndpointNegotiation> {
+ let path = local_forward_socket_path(ssh.target(), &ssh.session_name);
+ let _bridge = SshStdioBridge::start(
+ ssh.target.clone(),
+ remote_herdr.clone(),
+ path.clone(),
+ ssh.session_name.clone(),
+ None,
+ true,
+ )?;
+ let mut stream = crate::ipc::connect_local_stream(&path)?;
+ // Use the saved client's noninteractive path. This metadata-only attachment never
+ // acquires a surface or sends pane input.
+ crate::client::probe_endpoint_negotiation(&mut stream)
}
#[derive(Debug, Deserialize)]
@@ -1213,6 +1255,27 @@ struct RemoteClientStatusJson {
protocol: Option<u32>,
#[serde(default)]
endpoint_protocol_generation: Option<u32>,
+ #[serde(default)]
+ endpoint_capabilities: Vec<String>,
+}
+
+impl RemoteClientStatusJson {
+ fn supports_endpoint_requirement(&self, require_surface_interest: bool) -> bool {
+ self.endpoint_protocol_generation
+ == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)
+ && (!require_surface_interest
+ || [
+ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY,
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY,
+ crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY,
+ ]
+ .iter()
+ .all(|required| {
+ self.endpoint_capabilities
+ .iter()
+ .any(|capability| capability == required)
+ }))
+ }
}
#[derive(Debug, Deserialize)]
@@ -1229,6 +1292,10 @@ struct RemoteServerCapabilitiesJson {
detached_server_daemon: bool,
#[serde(default)]
endpoint_protocol_generation: Option<u32>,
+ #[serde(default)]
+ surface_interest: bool,
+ #[serde(default)]
+ health_check: bool,
}
fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> {
@@ -1241,6 +1308,7 @@ fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> {
status.version.is_some()
|| status.protocol.is_some()
|| status.endpoint_protocol_generation.is_some()
+ || !status.endpoint_capabilities.is_empty()
})
}
@@ -1261,6 +1329,12 @@ fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatu
endpoint_protocol_generation: capabilities
.as_ref()
.and_then(|capabilities| capabilities.endpoint_protocol_generation),
+ surface_interest: capabilities
+ .as_ref()
+ .is_some_and(|capabilities| capabilities.surface_interest),
+ health_check: capabilities
+ .as_ref()
+ .is_some_and(|capabilities| capabilities.health_check),
live_handoff: capabilities
.as_ref()
.is_some_and(|capabilities| capabilities.live_handoff),
@@ -1275,8 +1349,14 @@ fn confirm_remote_server_stop(
version: Option<&str>,
reason: RemoteServerRestartReason,
) -> io::Result<bool> {
+ let required_upgrade = matches!(
+ reason,
+ RemoteServerRestartReason::EndpointProtocol
+ | RemoteServerRestartReason::SurfaceInterest
+ | RemoteServerRestartReason::HealthCheck
+ );
if !io::stdin().is_terminal() {
- if reason == RemoteServerRestartReason::EndpointProtocolMissing {
+ if required_upgrade {
return Err(io::Error::other(format!(
"remote herdr server on {target} needs one final update before this client can attach; run from an interactive terminal to approve updating it"
)));
@@ -1296,19 +1376,27 @@ fn confirm_remote_server_stop(
eprintln!();
match reason {
- RemoteServerRestartReason::EndpointProtocolMissing => {
+ RemoteServerRestartReason::EndpointProtocol => {
eprintln!(
"the remote server predates Herdr's stable endpoint protocol and must update before this client can attach."
);
}
- RemoteServerRestartReason::DaemonDetachMissing => {
+ RemoteServerRestartReason::SurfaceInterest => {
+ eprintln!(
+ "the remote server must restart before it can join saved SSH endpoint federation."
+ );
+ }
+ RemoteServerRestartReason::HealthCheck => {
+ eprintln!("the remote server must restart to enable saved SSH endpoint health checks.");
+ }
+ RemoteServerRestartReason::DaemonDetach => {
eprintln!(
"the remote server was started by a herdr build that may not survive SSH connection loss. restart it so network drops disconnect only this client."
);
}
}
- let prompt = if reason == RemoteServerRestartReason::EndpointProtocolMissing {
+ let prompt = if required_upgrade {
"update the remote server and continue attaching? [Y/n] "
} else {
"restart the remote server now? [y/N] "
@@ -1316,16 +1404,10 @@ fn confirm_remote_server_stop(
eprint!("{prompt}");
io::stderr().flush()?;
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer == "y" || answer == "yes" {
+ if read_remote_confirmation(&mut io::stdin().lock(), required_upgrade)? {
return Ok(true);
}
- if answer.is_empty() && reason == RemoteServerRestartReason::EndpointProtocolMissing {
- return Ok(true);
- }
- if reason == RemoteServerRestartReason::EndpointProtocolMissing {
+ if required_upgrade {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"remote herdr server stop cancelled",
@@ -1335,10 +1417,19 @@ fn confirm_remote_server_stop(
Ok(false)
}
-fn remote_live_handoff_command(remote_herdr: &RemoteHerdr, protocol: u32, version: &str) -> String {
- format!(
- "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}",
- remote_herdr.shell_path, remote_herdr.shell_path, protocol, version
+fn remote_live_handoff_command(
+ remote_herdr: &RemoteHerdr,
+ session_name: &str,
+ protocol: u32,
+ version: &str,
+) -> String {
+ remote_session_command(
+ remote_herdr,
+ session_name,
+ &format!(
+ "server live-handoff --import-exe {} --expected-protocol {} --expected-version {}",
+ remote_herdr.shell_path, protocol, version
+ ),
)
}
@@ -1353,7 +1444,7 @@ fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io
.version
.filter(|version| !version.is_empty())
.ok_or_else(|| io::Error::other("prepared remote herdr did not report its version"))?;
- let command = remote_live_handoff_command(remote_herdr, protocol, &version);
+ let command = remote_live_handoff_command(remote_herdr, &ssh.session_name, protocol, &version);
let output = ssh.sh_output(&command)?;
if !output.status.success() {
return Err(command_failed("remote server live handoff failed", &output));
@@ -1367,7 +1458,7 @@ fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io
}
fn stop_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> {
- let command = format!("{} server stop", remote_herdr.shell_path);
+ let command = remote_session_command(remote_herdr, &ssh.session_name, "server stop");
let output = ssh.sh_output(&command)?;
if !output.status.success() {
return Err(command_failed("remote server stop failed", &output));
@@ -1384,7 +1475,7 @@ fn stop_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result
fn wait_for_remote_server_shutdown(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> {
let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT;
loop {
- if remote_server_status(ssh, remote_herdr)? == RemoteServerStatus::NotRunning {
+ if remote_server_status(ssh, remote_herdr, false)? == RemoteServerStatus::NotRunning {
return Ok(());
}
if Instant::now() >= deadline {
@@ -1577,6 +1668,25 @@ fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
))
}
+fn read_remote_confirmation(reader: &mut impl io::BufRead, default: bool) -> io::Result<bool> {
+ let mut answer = String::new();
+ if reader.read_line(&mut answer)? == 0 {
+ return Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "remote setup cancelled",
+ ));
+ }
+ match answer.trim().to_ascii_lowercase().as_str() {
+ "y" | "yes" => Ok(true),
+ "n" | "no" => Ok(false),
+ "" => Ok(default),
+ _ => Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "remote setup cancelled: expected yes or no",
+ )),
+ }
+}
+
fn confirm_remote_install(
target: &str,
remote_herdr: &RemoteHerdr,
@@ -1601,10 +1711,7 @@ fn confirm_remote_install(
);
io::stderr().flush()?;
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer == "n" || answer == "no" {
+ if !read_remote_confirmation(&mut io::stdin().lock(), true)? {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"remote herdr installation cancelled",
@@ -1614,16 +1721,24 @@ fn confirm_remote_install(
Ok(())
}
-fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String {
- let mut command = format!("exec {}", remote_herdr.shell_path);
+fn remote_session_command(remote_herdr: &RemoteHerdr, session_name: &str, args: &str) -> String {
+ let mut command = remote_herdr.shell_path.clone();
if session_name != crate::session::DEFAULT_SESSION_NAME {
command.push_str(" --session ");
command.push_str(&shell_quote(session_name));
}
- command.push_str(" remote-client-bridge");
+ command.push(' ');
+ command.push_str(args);
command
}
+fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String {
+ format!(
+ "exec {}",
+ remote_session_command(remote_herdr, session_name, "remote-client-bridge")
+ )
+}
+
fn reattach_command(
program: &str,
target: &str,
@@ -1658,7 +1773,7 @@ fn command_failed(context: &str, output: &Output) -> io::Error {
}
}
-struct SshStdioBridge {
+pub(super) struct SshStdioBridge {
local_socket: PathBuf,
socket_identity: crate::ipc::SocketFileIdentity,
should_stop: Arc<AtomicBool>,
@@ -1666,12 +1781,13 @@ struct SshStdioBridge {
}
impl SshStdioBridge {
- fn start(
+ pub(super) fn start(
target: String,
remote_herdr: RemoteHerdr,
local_socket: PathBuf,
session_name: String,
ssh_options: Option<&ManagedSshOptions>,
+ noninteractive: bool,
) -> io::Result<Self> {
crate::ipc::prepare_socket_path(&local_socket, |path| {
format!("remote bridge is already listening at {}", path.display())
@@ -1712,16 +1828,25 @@ impl SshStdioBridge {
&remote_herdr,
&session_name,
thread_ssh_options.as_ref(),
+ noninteractive,
&thread_stop,
) {
- eprintln!("herdr: remote bridge failed: {err}");
+ if noninteractive {
+ tracing::warn!(error = %err, "saved SSH endpoint bridge failed");
+ } else {
+ eprintln!("herdr: remote bridge failed: {err}");
+ }
}
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(BRIDGE_ACCEPT_POLL);
}
Err(err) => {
- eprintln!("herdr: remote bridge listener failed: {err}");
+ if noninteractive {
+ tracing::warn!(error = %err, "saved SSH endpoint listener failed");
+ } else {
+ eprintln!("herdr: remote bridge listener failed: {err}");
+ }
break;
}
}
@@ -1813,79 +1938,31 @@ fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> {
})
}
-#[cfg(unix)]
-fn bridge_connection(
- stream: crate::ipc::LocalStream,
- target: &str,
- remote_herdr: &RemoteHerdr,
- session_name: &str,
- ssh_options: Option<&ManagedSshOptions>,
- _bridge_stop: &Arc<AtomicBool>,
-) -> io::Result<()> {
- let mut command = Command::new("ssh");
- apply_managed_ssh_options(&mut command, ssh_options);
- command
- .arg("-T")
- .arg(target)
- .arg(remote_bridge_command(remote_herdr, session_name))
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::inherit());
-
- let mut child = command
- .spawn()
- .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?;
- let mut child_stdin = child
- .stdin
- .take()
- .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?;
- let mut child_stdout = child
- .stdout
- .take()
- .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?;
- let mut stream_to_child = stream.try_clone()?;
- let mut child_to_stream = stream;
-
- let upload = thread::spawn(move || {
- let _ = copy_flush(&mut stream_to_child, &mut child_stdin);
- });
- let download = thread::spawn(move || {
- let _ = copy_flush(&mut child_stdout, &mut child_to_stream);
- let _ = crate::ipc::shutdown_local_stream_write(&child_to_stream);
- });
-
- let status = child.wait()?;
- let _ = upload.join();
- let _ = download.join();
-
- if status.success() {
- Ok(())
- } else {
- Err(io::Error::new(
- io::ErrorKind::ConnectionAborted,
- format!("ssh bridge exited with {status}"),
- ))
- }
-}
-
-#[cfg(windows)]
fn bridge_connection(
stream: crate::ipc::LocalStream,
target: &str,
remote_herdr: &RemoteHerdr,
session_name: &str,
ssh_options: Option<&ManagedSshOptions>,
+ noninteractive: bool,
bridge_stop: &Arc<AtomicBool>,
) -> io::Result<()> {
let mut command = Command::new("ssh");
apply_managed_ssh_options(&mut command, ssh_options);
+ if noninteractive {
+ apply_noninteractive_ssh_options(&mut command);
+ }
command
.arg("-T")
.arg(target)
.arg(remote_bridge_command(remote_herdr, session_name))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
- .stderr(Stdio::inherit());
+ .stderr(if noninteractive {
+ Stdio::null()
+ } else {
+ Stdio::inherit()
+ });
let mut child = command
.spawn()
@@ -2018,33 +2095,12 @@ fn bridge_connection(
}
}
-#[cfg(unix)]
-fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> {
- let mut buffer = [0_u8; 16 * 1024];
- let mut total = 0;
-
- loop {
- let bytes_read = match reader.read(&mut buffer) {
- Ok(0) => return Ok(total),
- Ok(bytes_read) => bytes_read,
- Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
- Err(err) => return Err(err),
- };
-
- writer.write_all(&buffer[..bytes_read])?;
- writer.flush()?;
- total += bytes_read as u64;
- }
-}
-
-#[cfg(windows)]
fn terminate_bridge_child(mut child: std::process::Child, message: &'static str) -> io::Result<()> {
let _ = child.kill();
let _ = child.wait();
Err(io::Error::new(io::ErrorKind::BrokenPipe, message))
}
-#[cfg(windows)]
fn copy_reader_to_local_stream<R: io::Read>(
reader: &mut R,
stream: &mut crate::ipc::LocalStream,
@@ -2082,7 +2138,6 @@ fn copy_reader_to_local_stream<R: io::Read>(
}
}
-#[cfg(windows)]
fn copy_local_stream_to_writer<W: io::Write>(
mut stream: crate::ipc::LocalStream,
writer: &mut W,
@@ -2207,6 +2262,7 @@ mod tests {
socket.clone(),
"default".to_string(),
None,
+ false,
)
.expect("start bridge listener");
@@ -2253,9 +2309,8 @@ mod tests {
let _ = std::fs::remove_file(socket);
}
- #[cfg(windows)]
#[test]
- fn windows_bridge_drop_while_waiting_for_client_is_bounded() {
+ fn bridge_drop_while_waiting_for_client_is_bounded() {
let socket = local_forward_socket_path("drop-test", "default");
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
@@ -2267,6 +2322,7 @@ mod tests {
socket.clone(),
"default".to_string(),
None,
+ false,
)
.expect("start bridge listener");
let started = Instant::now();
@@ -2362,7 +2418,9 @@ mod tests {
.expect("Unix managed config has a control path");
let ssh = RemoteSsh {
target: "example".to_string(),
+ session_name: crate::session::DEFAULT_SESSION_NAME.into(),
managed_config: Some(managed_config),
+ noninteractive: false,
};
let command = ssh.command();
@@ -2400,7 +2458,9 @@ mod tests {
let ssh = RemoteSsh {
target: "example".to_string(),
+ session_name: crate::session::DEFAULT_SESSION_NAME.into(),
managed_config: Some(managed_config),
+ noninteractive: false,
};
let args = ssh
.command()
@@ -2428,10 +2488,106 @@ mod tests {
}
#[test]
+ fn noninteractive_ssh_command_cannot_prompt_or_accept_unknown_hosts() {
+ let ssh = RemoteSsh::new_noninteractive("example".into());
+ let args = ssh
+ .command()
+ .get_args()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect::<Vec<_>>();
+ for required in [
+ "BatchMode=yes",
+ "NumberOfPasswordPrompts=0",
+ "StrictHostKeyChecking=yes",
+ "ConnectTimeout=10",
+ "ConnectionAttempts=1",
+ "ServerAliveInterval=15",
+ "ServerAliveCountMax=4",
+ ] {
+ assert!(args.iter().any(|arg| arg == required), "missing {required}");
+ }
+ assert!(!args.iter().any(|arg| arg == "-F"));
+ assert!(ssh.options().is_none());
+ }
+
+ #[test]
+ fn remote_setup_approval_requires_input_and_rejects_unrecognized_answers() {
+ for default in [false, true] {
+ for input in ["", "maybe\n"] {
+ assert_eq!(
+ read_remote_confirmation(&mut input.as_bytes(), default)
+ .unwrap_err()
+ .kind(),
+ io::ErrorKind::Interrupted
+ );
+ }
+ assert_eq!(
+ read_remote_confirmation(&mut "\n".as_bytes(), default).unwrap(),
+ default
+ );
+ assert!(read_remote_confirmation(&mut "YES\n".as_bytes(), default).unwrap());
+ assert!(!read_remote_confirmation(&mut "no\n".as_bytes(), default).unwrap());
+ }
+ }
+
+ #[test]
+ fn saved_machine_compatibility_uses_capabilities_not_release_or_private_protocol() {
+ let mut status = RemoteClientStatusJson {
+ version: Some("0.1.0".into()),
+ protocol: Some(1),
+ endpoint_protocol_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
+ ),
+ endpoint_capabilities: vec![
+ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(),
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(),
+ crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(),
+ ],
+ };
+ assert!(status.supports_endpoint_requirement(true));
+ for index in 0..status.endpoint_capabilities.len() {
+ let removed = status.endpoint_capabilities.remove(index);
+ assert!(!status.supports_endpoint_requirement(true));
+ assert!(status.supports_endpoint_requirement(false));
+ status.endpoint_capabilities.insert(index, removed);
+ }
+ status.endpoint_protocol_generation = None;
+ assert!(!status.supports_endpoint_requirement(true));
+ }
+
+ #[test]
+ fn saved_machine_server_commands_are_scoped_to_the_explicit_session() {
+ let herdr =
+ RemoteHerdr::for_platform(RemotePlatform::from_uname("Linux", "x86_64").unwrap());
+ for command in [
+ "status server --json",
+ "server stop",
+ "remote-client-bridge",
+ ] {
+ assert_eq!(
+ remote_session_command(&herdr, "agents", command),
+ format!("{} --session agents {command}", herdr.shell_path)
+ );
+ assert_eq!(
+ remote_session_command(&herdr, crate::session::DEFAULT_SESSION_NAME, command),
+ format!("{} {command}", herdr.shell_path)
+ );
+ }
+ assert!(
+ remote_live_handoff_command(&herdr, "agents", 19, "0.7.9").starts_with(&format!(
+ "{} --session agents server live-handoff",
+ herdr.shell_path
+ ))
+ );
+ }
+
+ #[test]
fn remote_ssh_command_is_plain_without_managed_config() {
let ssh = RemoteSsh {
target: "example".to_string(),
+ session_name: crate::session::DEFAULT_SESSION_NAME.into(),
managed_config: None,
+ noninteractive: false,
};
let command = ssh.command();
@@ -2898,27 +3054,91 @@ mod tests {
#[test]
fn parse_client_status_json_reads_last_json_record() {
let status = parse_client_status_json(
- "wrapper output\n{\"version\":\"0.8.0\",\"protocol\":20,\"endpoint_protocol_generation\":1}\n{\"wrapper\":true}\n",
+ "wrapper output\n{\"version\":\"0.8.0\",\"protocol\":20,\"endpoint_protocol_generation\":1,\"endpoint_capabilities\":[\"surface_interest\",\"health_check\"]}\n{\"wrapper\":true}\n",
)
.unwrap();
assert_eq!(status.version.as_deref(), Some("0.8.0"));
assert_eq!(status.protocol, Some(20));
assert_eq!(status.endpoint_protocol_generation, Some(1));
+ assert_eq!(
+ status.endpoint_capabilities,
+ vec!["surface_interest", "health_check"]
+ );
assert!(
parse_client_status_json(r#"{"endpoint_protocol_generation":"unknown"}"#).is_none()
);
}
#[test]
+ fn saved_machine_setup_handoffs_old_server_missing_presentation_fence() {
+ // Captured from Rohan after installing a new binary while the old daemon stayed alive.
+ let installed = parse_client_status_json(
+ r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","presentation_effects_fence","health_check"]}"#,
+ )
+ .unwrap();
+ let running_binary = parse_client_status_json(
+ r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","health_check"]}"#,
+ )
+ .unwrap();
+ assert!(installed.supports_endpoint_requirement(true));
+ assert!(!running_binary.supports_endpoint_requirement(true));
+ for (live_capabilities, expected) in [
+ (
+ running_binary.endpoint_capabilities,
+ RemoteInstallRunningServerPlan::LiveHandoff,
+ ),
+ (
+ installed.endpoint_capabilities,
+ RemoteInstallRunningServerPlan::KeepRunning,
+ ),
+ ] {
+ let live_negotiation = crate::client::endpoint::EndpointNegotiation::new(
+ vec!["client_shell.surface.set".into()],
+ live_capabilities,
+ );
+ let RemoteServerStatus::Running {
+ endpoint_protocol_generation,
+ surface_interest,
+ health_check,
+ live_handoff,
+ detached_server_daemon,
+ ..
+ } = parse_remote_server_status_json(
+ r#"{"status":"running","running":true,"version":"0.8.2","protocol":22,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"#,
+ )
+ .unwrap()
+ .with_endpoint_negotiation(&live_negotiation) else {
+ panic!("captured server must be running");
+ };
+
+ assert_eq!(
+ remote_install_running_server_plan(
+ endpoint_protocol_generation,
+ detached_server_daemon,
+ surface_interest,
+ health_check,
+ live_handoff,
+ true,
+ true,
+ ),
+ expected,
+ "setup must follow the running server's negotiated capabilities",
+ );
+ }
+ }
+
+ #[test]
fn parse_remote_server_status_json_reads_running_server() {
assert_eq!(
parse_remote_server_status_json(
- r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1}}"#
+ r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"#
)
.unwrap(),
RemoteServerStatus::Running {
version: Some("0.6.0".into()),
endpoint_protocol_generation: Some(1),
+ surface_interest: true,
+ health_check: true,
live_handoff: true,
detached_server_daemon: true
}
@@ -2935,6 +3155,8 @@ mod tests {
RemoteServerStatus::Running {
version: Some("0.6.0".into()),
endpoint_protocol_generation: None,
+ surface_interest: false,
+ health_check: false,
live_handoff: false,
detached_server_daemon: false
}
@@ -3109,88 +3331,17 @@ mod tests {
}
#[test]
- fn remote_server_restart_reason_requires_one_update_for_pre_floor_server() {
- assert_eq!(
- remote_server_restart_reason(None, true),
- Some(RemoteServerRestartReason::EndpointProtocolMissing)
- );
- }
-
- #[test]
- fn remote_server_restart_reason_allows_compatible_server() {
- assert_eq!(
- remote_server_restart_reason(
- Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
- true
- ),
- None
- );
- }
-
- #[test]
- fn remote_server_restart_reason_requires_restart_for_old_daemon() {
- assert_eq!(
- remote_server_restart_reason(
- Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
- false
- ),
- Some(RemoteServerRestartReason::DaemonDetachMissing)
- );
- }
-
- #[test]
- fn remote_install_plan_keeps_compatible_running_server() {
- assert_eq!(
- remote_install_running_server_plan(
- Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
- true,
- false,
- false
- ),
- RemoteInstallRunningServerPlan::KeepRunning
- );
- }
-
- #[test]
- fn remote_install_plan_requires_stop_for_old_daemon() {
- assert_eq!(
- remote_install_running_server_plan(
- Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
- false,
- false,
- false
- ),
- RemoteInstallRunningServerPlan::StopRequired(
- RemoteServerRestartReason::DaemonDetachMissing
- )
- );
- }
-
- #[test]
- fn remote_install_plan_requires_stop_for_pre_floor_server() {
- assert_eq!(
- remote_install_running_server_plan(None, true, false, false),
- RemoteInstallRunningServerPlan::StopRequired(
- RemoteServerRestartReason::EndpointProtocolMissing
- )
- );
- }
-
- #[test]
- fn remote_install_plan_uses_live_handoff_for_pre_floor_server() {
- assert_eq!(
- remote_install_running_server_plan(None, true, true, true),
- RemoteInstallRunningServerPlan::LiveHandoff
- );
- }
-
- #[test]
fn remote_live_handoff_uses_prepared_binary_identity() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
- let command = remote_live_handoff_command(&remote_herdr, 19, "0.7.9");
+ let command = remote_live_handoff_command(
+ &remote_herdr,
+ crate::session::DEFAULT_SESSION_NAME,
+ 19,
+ "0.7.9",
+ );
assert!(command.contains("--expected-protocol 19"));
assert!(command.contains("--expected-version 0.7.9"));
assert!(!command.contains(&format!(
diff --git a/src/remote/process.rs b/src/remote/process.rs
new file mode 100644
index 00000000..d3db5c9f
--- /dev/null
+++ b/src/remote/process.rs
@@ -0,0 +1,87 @@
+use std::io::{self, Read as _};
+use std::process::Output;
+use std::thread;
+use std::time::{Duration, Instant};
+
+const POLL_INTERVAL: Duration = Duration::from_millis(50);
+
+pub(super) fn wait_with_output_timeout(
+ mut child: std::process::Child,
+ timeout: Duration,
+) -> io::Result<Output> {
+ let stdout = child
+ .stdout
+ .take()
+ .ok_or_else(|| io::Error::other("SSH command stdout was not captured"))?;
+ let stderr = child
+ .stderr
+ .take()
+ .ok_or_else(|| io::Error::other("SSH command stderr was not captured"))?;
+ let stdout = thread::spawn(move || {
+ let mut stdout = stdout;
+ let mut bytes = Vec::new();
+ stdout.read_to_end(&mut bytes).map(|_| bytes)
+ });
+ let stderr = thread::spawn(move || {
+ let mut stderr = stderr;
+ let mut bytes = Vec::new();
+ stderr.read_to_end(&mut bytes).map(|_| bytes)
+ });
+ let started = Instant::now();
+ let status = loop {
+ match child.try_wait() {
+ Ok(Some(status)) => break status,
+ Ok(None) => {}
+ Err(error) => {
+ let _ = child.kill();
+ let _ = child.wait();
+ let _ = stdout.join();
+ let _ = stderr.join();
+ return Err(error);
+ }
+ }
+ if started.elapsed() >= timeout {
+ let _ = child.kill();
+ let _ = child.wait();
+ let _ = stdout.join();
+ let _ = stderr.join();
+ return Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ "noninteractive SSH command timed out",
+ ));
+ }
+ thread::sleep(POLL_INTERVAL);
+ };
+ let stdout = stdout
+ .join()
+ .map_err(|_| io::Error::other("SSH stdout reader panicked"))??;
+ let stderr = stderr
+ .join()
+ .map_err(|_| io::Error::other("SSH stderr reader panicked"))??;
+ Ok(Output {
+ status,
+ stdout,
+ stderr,
+ })
+}
+
+#[cfg(all(test, unix))]
+mod tests {
+ use super::*;
+ use std::process::{Command, Stdio};
+
+ #[test]
+ fn timeout_kills_the_child() {
+ let mut command = Command::new("sh");
+ command
+ .arg("-c")
+ .arg("exec sleep 10")
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ let started = Instant::now();
+ let error = wait_with_output_timeout(command.spawn().unwrap(), Duration::from_millis(25))
+ .unwrap_err();
+ assert_eq!(error.kind(), io::ErrorKind::TimedOut);
+ assert!(started.elapsed() < Duration::from_secs(1));
+ }
+}
diff --git a/src/remote/restart_policy.rs b/src/remote/restart_policy.rs
new file mode 100644
index 00000000..757efcc5
--- /dev/null
+++ b/src/remote/restart_policy.rs
@@ -0,0 +1,161 @@
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum RemoteServerRestartReason {
+ EndpointProtocol,
+ SurfaceInterest,
+ HealthCheck,
+ DaemonDetach,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum RemoteInstallRunningServerPlan {
+ KeepRunning,
+ LiveHandoff,
+ StopRequired(RemoteServerRestartReason),
+}
+
+pub(super) fn remote_server_restart_reason(
+ endpoint_protocol_generation: Option<u32>,
+ detached_server_daemon: bool,
+ require_surface_interest: bool,
+ surface_interest: bool,
+ health_check: bool,
+) -> Option<RemoteServerRestartReason> {
+ if endpoint_protocol_generation != Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)
+ {
+ return Some(RemoteServerRestartReason::EndpointProtocol);
+ }
+ if require_surface_interest && !surface_interest {
+ return Some(RemoteServerRestartReason::SurfaceInterest);
+ }
+ if require_surface_interest && !health_check {
+ return Some(RemoteServerRestartReason::HealthCheck);
+ }
+ if !detached_server_daemon {
+ return Some(RemoteServerRestartReason::DaemonDetach);
+ }
+ None
+}
+
+pub(super) fn remote_install_running_server_plan(
+ endpoint_protocol_generation: Option<u32>,
+ detached_server_daemon: bool,
+ surface_interest: bool,
+ health_check: bool,
+ live_handoff: bool,
+ live_handoff_enabled: bool,
+ require_surface_interest: bool,
+) -> RemoteInstallRunningServerPlan {
+ let Some(reason) = remote_server_restart_reason(
+ endpoint_protocol_generation,
+ detached_server_daemon,
+ require_surface_interest,
+ surface_interest,
+ health_check,
+ ) else {
+ return RemoteInstallRunningServerPlan::KeepRunning;
+ };
+
+ if live_handoff_enabled && live_handoff {
+ return RemoteInstallRunningServerPlan::LiveHandoff;
+ }
+ RemoteInstallRunningServerPlan::StopRequired(reason)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn pre_floor_server_requires_update() {
+ assert_eq!(
+ remote_server_restart_reason(None, true, false, false, false),
+ Some(RemoteServerRestartReason::EndpointProtocol)
+ );
+ }
+
+ #[test]
+ fn compatible_server_can_keep_running() {
+ assert_eq!(
+ remote_server_restart_reason(
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ true,
+ false,
+ false,
+ false
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn saved_endpoint_requires_surface_interest() {
+ assert_eq!(
+ remote_server_restart_reason(
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ true,
+ true,
+ false,
+ false
+ ),
+ Some(RemoteServerRestartReason::SurfaceInterest)
+ );
+ }
+
+ #[test]
+ fn saved_endpoint_requires_health_checks() {
+ assert_eq!(
+ remote_server_restart_reason(
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ true,
+ true,
+ true,
+ false
+ ),
+ Some(RemoteServerRestartReason::HealthCheck)
+ );
+ }
+
+ #[test]
+ fn saved_endpoint_accepts_complete_lifecycle_support() {
+ assert_eq!(
+ remote_server_restart_reason(
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ true,
+ true,
+ true,
+ true
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn old_daemon_requires_restart() {
+ assert_eq!(
+ remote_server_restart_reason(
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ false,
+ false,
+ false,
+ false
+ ),
+ Some(RemoteServerRestartReason::DaemonDetach)
+ );
+ }
+
+ #[test]
+ fn saved_endpoint_can_explicitly_live_handoff_for_surface_support() {
+ assert_eq!(
+ remote_install_running_server_plan(
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ true,
+ false,
+ false,
+ true,
+ true,
+ true
+ ),
+ RemoteInstallRunningServerPlan::LiveHandoff
+ );
+ }
+}
diff --git a/src/remote/saved.rs b/src/remote/saved.rs
new file mode 100644
index 00000000..47684758
--- /dev/null
+++ b/src/remote/saved.rs
@@ -0,0 +1,136 @@
+use std::io;
+use std::path::PathBuf;
+
+use super::attach::{find_installed_remote_herdr, RemoteSsh, SshStdioBridge};
+
+pub(crate) struct SavedSshBridge {
+ _bridge: SshStdioBridge,
+}
+
+pub(crate) struct SavedSshStream {
+ pub(crate) stream: crate::ipc::LocalStream,
+ pub(crate) bridge: SavedSshBridge,
+}
+
+pub(crate) fn connect_saved_ssh(
+ profile_id: &str,
+ target: &str,
+ session: &str,
+) -> io::Result<SavedSshStream> {
+ validate_profile_path_id(profile_id)?;
+ crate::session::validate_name(session)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
+ let ssh = RemoteSsh::new_noninteractive(target.to_owned());
+ let remote_herdr = find_installed_remote_herdr(&ssh)?;
+ let path = saved_bridge_path(profile_id);
+ let bridge = SshStdioBridge::start(
+ target.to_owned(),
+ remote_herdr,
+ path.clone(),
+ session.to_owned(),
+ ssh.options(),
+ true,
+ )?;
+ let stream = crate::ipc::connect_local_stream(&path)?;
+ Ok(SavedSshStream {
+ stream,
+ bridge: SavedSshBridge { _bridge: bridge },
+ })
+}
+
+pub(crate) fn saved_ssh_bootstrap_command(target: &str, session: &str) -> String {
+ format!(
+ "herdr --remote {} --session {} --handoff",
+ super::shell_quote(target),
+ super::shell_quote(session)
+ )
+}
+
+pub(crate) fn saved_ssh_failure_needs_attention(error: &io::Error) -> bool {
+ if matches!(
+ error.kind(),
+ io::ErrorKind::InvalidInput
+ | io::ErrorKind::InvalidData
+ | io::ErrorKind::NotFound
+ | io::ErrorKind::PermissionDenied
+ | io::ErrorKind::Unsupported
+ ) {
+ return true;
+ }
+ let message = error.to_string().to_ascii_lowercase();
+ [
+ "permission denied",
+ "host key verification failed",
+ "remote host identification has changed",
+ "no matching host key",
+ "unsupported remote platform",
+ "not ready",
+ "install or update",
+ "protocol",
+ "handshake",
+ ]
+ .iter()
+ .any(|needle| message.contains(needle))
+}
+
+fn saved_bridge_path(profile_id: &str) -> PathBuf {
+ let pid = std::process::id();
+ let readable = format!("herdr-ssh-{pid}-{profile_id}.sock");
+ let short = format!("herdr-s-{pid}-{}.sock", &profile_id[..16]);
+ crate::platform::remote_bridge_endpoint_path(&readable, &short)
+}
+
+fn validate_profile_path_id(profile_id: &str) -> io::Result<()> {
+ if profile_id.len() == 32
+ && profile_id
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ Ok(())
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "invalid SSH endpoint profile id",
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bridge_paths_use_profile_identity_not_target_or_session() {
+ let first = saved_bridge_path("0123456789abcdef0123456789abcdef");
+ let second = saved_bridge_path("fedcba9876543210fedcba9876543210");
+ assert_ne!(first, second);
+ assert!(!first.to_string_lossy().contains("example.com"));
+ assert!(!first.to_string_lossy().contains("default"));
+ }
+
+ #[test]
+ fn bootstrap_command_preserves_the_explicit_remote_session() {
+ assert_eq!(
+ saved_ssh_bootstrap_command("build host", "agent work"),
+ "herdr --remote 'build host' --session 'agent work' --handoff"
+ );
+ }
+
+ #[test]
+ fn prompt_and_compatibility_failures_require_attention() {
+ for message in [
+ "Permission denied (publickey)",
+ "Host key verification failed",
+ "matching Herdr is not ready; install or update",
+ "handshake rejected",
+ ] {
+ assert!(saved_ssh_failure_needs_attention(&io::Error::other(
+ message
+ )));
+ }
+ assert!(!saved_ssh_failure_needs_attention(&io::Error::new(
+ io::ErrorKind::TimedOut,
+ "network timed out"
+ )));
+ }
+}
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
index bd364a70..e6fdaaa4 100644
--- a/src/server/autodetect.rs
+++ b/src/server/autodetect.rs
@@ -142,7 +142,7 @@ fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result<bool> {
}
}
-fn validate_running_server_compatibility() -> io::Result<()> {
+fn validate_running_server_compatibility(saved_federation: bool) -> io::Result<()> {
let Some(status) = read_server_status()? else {
return Err(io::Error::other(format!(
"a herdr server is listening, but its status API is unavailable.\n\n{}\nIf that fails, stop the old server process manually.",
@@ -150,16 +150,23 @@ fn validate_running_server_compatibility() -> io::Result<()> {
)));
};
- let endpoint_generation = status
- .capabilities
- .as_ref()
- .and_then(|capabilities| capabilities.endpoint_protocol_generation);
- if endpoint_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) {
+ let capabilities = status.capabilities.as_ref();
+ let endpoint_generation =
+ capabilities.and_then(|capabilities| capabilities.endpoint_protocol_generation);
+ let surface_interest = capabilities.is_some_and(|capabilities| capabilities.surface_interest);
+ if endpoint_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)
+ && (!saved_federation || surface_interest)
+ {
return Ok(());
}
+ let requirement = if saved_federation && !surface_interest {
+ "saved SSH machines require surface lifecycle support"
+ } else {
+ "the stable endpoint generation is incompatible"
+ };
Err(io::Error::other(format!(
- "This session predates Herdr's stable endpoint protocol and needs one final server update.\n\nserver: v{} endpoint generation {}\nclient: v{} endpoint generation {}\n\n{}",
+ "This session needs one final server update before Herdr can attach ({requirement}).\n\nserver: v{} endpoint generation {}\nclient: v{} endpoint generation {}\n\n{}",
status.version.as_deref().unwrap_or("unknown"),
endpoint_generation
.map(|value| value.to_string())
@@ -285,18 +292,27 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu
/// 1. Check if a server is listening on the client socket
/// 2. If no server → spawn server daemon → wait for socket readiness
/// 3. Run the thin client (which connects to the server)
-pub fn auto_detect_launch() -> io::Result<()> {
+pub fn auto_detect_launch(saved_federation: bool) -> io::Result<()> {
let socket_path = client_socket_path();
info!(path = %socket_path.display(), "auto-detect launch starting");
- if is_server_listening_at(&socket_path) {
- validate_running_server_compatibility()?;
+ let startup = if is_server_listening_at(&socket_path) {
info!("server already running, attaching as client");
+ if saved_federation {
+ Ok(())
+ } else {
+ validate_running_server_compatibility(false)
+ }
} else {
info!("no server running, spawning server daemon");
- spawn_server_daemon()?;
- wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)?;
- info!("server ready, attaching as client");
+ spawn_server_daemon()
+ .and_then(|_| wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT))
+ };
+ if let Err(error) = startup {
+ if !saved_federation {
+ return Err(error);
+ }
+ tracing::warn!(%error, "Local startup failed; keeping saved machines available");
}
// Now attach as a thin client.
@@ -522,7 +538,7 @@ test "$sid" = "$$"
let path = dir.join("api.sock");
std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &path);
- let err = validate_running_server_compatibility().unwrap_err();
+ let err = validate_running_server_compatibility(false).unwrap_err();
assert!(
err.to_string().contains("status API is unavailable"),
@@ -558,7 +574,7 @@ test "$sid" = "$$"
stream.flush().unwrap();
});
- let err = validate_running_server_compatibility().unwrap_err();
+ let err = validate_running_server_compatibility(false).unwrap_err();
let message = err.to_string();
let _ = handle.join();
diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs
index 34be0519..0af1fad1 100644
--- a/src/server/client_commands.rs
+++ b/src/server/client_commands.rs
@@ -13,6 +13,7 @@ pub(crate) const MAX_ENDPOINT_REQUEST_ID_BYTES: usize = 128;
const ENDPOINT_RESPONSE_CHUNK_BYTES: usize = 512 * 1024;
const CLIENT_SHELL_METHODS: &[&str] = &[
+ "client_shell.surface.set",
"command.invoke",
"integration.install",
"integration.list",
@@ -77,6 +78,30 @@ pub(crate) fn error_response(id: String, code: &str, message: impl Into<String>)
})
}
+pub(crate) fn success_message_with_result(
+ boot_id: String,
+ request_id: String,
+ result: crate::api::schema::ResponseResult,
+) -> crate::protocol::ServerMessage {
+ let response = serde_json::to_string(&crate::api::schema::SuccessResponse {
+ id: request_id.clone(),
+ result,
+ })
+ .unwrap_or_else(|_| {
+ error_response(
+ request_id.clone(),
+ "serialization_error",
+ "failed to serialize endpoint response",
+ )
+ });
+ crate::protocol::ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id,
+ request_id,
+ final_chunk: true,
+ data: response.into_bytes(),
+ }
+}
+
pub(crate) fn error_message(
boot_id: String,
request_id: String,
@@ -312,6 +337,11 @@ mod tests {
#[test]
fn client_shell_lane_excludes_api_front_door_and_lifecycle_methods() {
+ assert!(supports_client_shell_method(
+ &Method::ClientShellSurfaceSet(crate::api::schema::ClientShellSurfaceSetParams {
+ active: false,
+ })
+ ));
assert!(supports_client_shell_method(&Method::ServerReloadConfig(
crate::api::schema::EmptyParams::default(),
)));
diff --git a/src/server/client_endpoint_control.rs b/src/server/client_endpoint_control.rs
new file mode 100644
index 00000000..09970004
--- /dev/null
+++ b/src/server/client_endpoint_control.rs
@@ -0,0 +1,24 @@
+pub(crate) fn response(kind: &str, data: String) -> Option<crate::protocol::ServerMessage> {
+ (kind == crate::protocol::endpoint::HEALTH_PING_KIND).then(|| {
+ crate::protocol::ServerMessage::EndpointControl {
+ kind: crate::protocol::endpoint::HEALTH_PONG_KIND.into(),
+ data,
+ }
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn health_ping_echoes_a_pong_without_entering_server_state() {
+ let pong = response(crate::protocol::endpoint::HEALTH_PING_KIND, "probe".into()).unwrap();
+ assert!(matches!(
+ pong,
+ crate::protocol::ServerMessage::EndpointControl { kind, data }
+ if kind == crate::protocol::endpoint::HEALTH_PONG_KIND && data == "probe"
+ ));
+ assert!(response("future.control", String::new()).is_none());
+ }
+}
diff --git a/src/server/client_shell_graphics.rs b/src/server/client_shell_graphics.rs
index 578350b2..00355230 100644
--- a/src/server/client_shell_graphics.rs
+++ b/src/server/client_shell_graphics.rs
@@ -1,6 +1,48 @@
use crate::kitty_graphics::surface::DeliveryCache;
use crate::protocol::{ClientShellPopupSurface, SurfaceGraphicsScene};
+pub(crate) fn collect_retained(
+ app: &crate::app::App,
+ surface: &crate::protocol::PaneSurfaceFrame,
+ target: crate::ui::TabSurfaceTarget,
+ cell_size: crate::kitty_graphics::HostCellSize,
+ delivered: &DeliveryCache,
+ client_id: u64,
+) -> Option<(SurfaceGraphicsScene, DeliveryCache)> {
+ let rect = |rect: crate::protocol::SurfaceRect| {
+ ratatui::layout::Rect::new(rect.x, rect.y, rect.width, rect.height)
+ };
+ let pane_infos = surface
+ .panes
+ .iter()
+ .map(|pane| {
+ let (workspace_index, id) = app.parse_pane_id(&pane.pane_id)?;
+ if workspace_index != target.workspace_index {
+ return None;
+ }
+ Some(crate::layout::PaneInfo {
+ id,
+ rect: rect(pane.rect),
+ inner_rect: rect(pane.inner_rect),
+ scrollbar_rect: pane.scrollbar_rect.map(rect),
+ borders: ratatui::widgets::Borders::NONE,
+ is_focused: pane.focused,
+ })
+ })
+ .collect::<Option<Vec<_>>>()?;
+ // Image clipping uses the retained content rectangles, not borders or split handles.
+ Some(collect(
+ app,
+ &pane_infos,
+ &[],
+ None,
+ Some(target),
+ cell_size,
+ delivered,
+ client_id,
+ ))
+}
+
pub(crate) fn collect(
app: &crate::app::App,
pane_infos: &[crate::layout::PaneInfo],
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index 25a963e7..c57d0f86 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -124,6 +124,11 @@ pub(crate) struct ClientWriter {
}
impl ClientWriter {
+ /// Drops render-lane work that has not yet been claimed by the writer.
+ pub(crate) fn discard_pending_render(&self) {
+ self.render.queue.discard_pending_render();
+ }
+
#[cfg(test)]
pub(crate) fn test_fill_render(&self, data: Vec<u8>) {
self.render.try_send(data).unwrap();
@@ -304,6 +309,13 @@ impl ClientWriterQueue {
Ok(())
}
+ fn discard_pending_render(&self) {
+ let mut state = self.lock_state();
+ state.render = None;
+ state.ordered.clear();
+ self.ready.notify_all();
+ }
+
fn send_ordered(&self, data: Vec<u8>) -> Result<(), TrySendError<Vec<u8>>> {
let mut state = self.lock_state();
if !state.writer_alive {
@@ -382,6 +394,7 @@ pub(crate) enum ServerEvent {
direct_graphics: bool,
endpoint_keybindings: bool,
mouse_capture: bool,
+ surface_active: bool,
writer: ClientWriter,
},
/// A client sent an input message.
@@ -483,6 +496,8 @@ pub(crate) enum ServerEvent {
ClientShellFocus { client_id: u64, focused: bool },
/// A client-owned shell updated its local mouse-capture preference.
ClientShellMouseCapture { client_id: u64, enabled: bool },
+ /// The committed shell asks the server to replay presentation effects before input resumes.
+ ClientShellPresentationSync { client_id: u64, token: String },
/// A client-owned shell invoked one endpoint operation through this connection.
ClientShellEndpointRequest {
client_id: u64,
@@ -744,6 +759,7 @@ pub(crate) fn handle_client_handshake(
hello.direct_graphics,
hello.endpoint_keybindings,
hello.mouse_capture,
+ hello.surface_active,
)),
)
}
@@ -831,33 +847,39 @@ pub(crate) fn handle_client_handshake(
}
// Notify the main loop about the new client.
- let connected =
- if let Some((pixel_mouse, direct_graphics, endpoint_keybindings, mouse_capture)) =
- shell_options
- {
- ServerEvent::ClientShellConnected {
- client_id,
- surface_cols: client_cols,
- surface_rows: client_rows,
- cell_width_px,
- cell_height_px,
- pixel_mouse,
- direct_graphics,
- endpoint_keybindings,
- mouse_capture,
- writer,
- }
- } else {
- ServerEvent::ClientConnected {
- client_id,
- cols: client_cols,
- rows: client_rows,
- cell_width_px,
- cell_height_px,
- pixel_mouse: terminal_pixel_mouse,
- writer,
- }
- };
+ let endpoint_control_writer = shell_options.as_ref().map(|_| writer.control.clone());
+ let connected = if let Some((
+ pixel_mouse,
+ direct_graphics,
+ endpoint_keybindings,
+ mouse_capture,
+ surface_active,
+ )) = shell_options
+ {
+ ServerEvent::ClientShellConnected {
+ client_id,
+ surface_cols: client_cols,
+ surface_rows: client_rows,
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse,
+ direct_graphics,
+ endpoint_keybindings,
+ mouse_capture,
+ surface_active,
+ writer,
+ }
+ } else {
+ ServerEvent::ClientConnected {
+ client_id,
+ cols: client_cols,
+ rows: client_rows,
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse: terminal_pixel_mouse,
+ writer,
+ }
+ };
if let Err(err) = server_event_tx.blocking_send(connected) {
match err.0 {
ServerEvent::ClientConnected { writer, .. }
@@ -869,7 +891,13 @@ pub(crate) fn handle_client_handshake(
}
// Enter read loop — read client messages and forward to main loop.
- client_read_loop(stream, client_id, server_event_tx, should_quit)
+ client_read_loop_with_endpoint_controls(
+ stream,
+ client_id,
+ server_event_tx,
+ should_quit,
+ endpoint_control_writer.as_ref(),
+ )
}
fn send_shutdown_to_unregistered_client(writer: &ClientWriter) {
@@ -924,11 +952,22 @@ fn write_framed_bytes(stream: &mut LocalStream, data: &[u8]) -> bool {
}
/// The client read loop — reads messages from the client and forwards to the server event channel.
+#[cfg(test)]
fn client_read_loop(
+ stream: LocalStream,
+ client_id: u64,
+ server_event_tx: &mpsc::Sender<ServerEvent>,
+ should_quit: &Arc<AtomicBool>,
+) -> io::Result<()> {
+ client_read_loop_with_endpoint_controls(stream, client_id, server_event_tx, should_quit, None)
+}
+
+fn client_read_loop_with_endpoint_controls(
mut stream: LocalStream,
client_id: u64,
server_event_tx: &mpsc::Sender<ServerEvent>,
should_quit: &Arc<AtomicBool>,
+ endpoint_control_writer: Option<&ClientControlWriter>,
) -> io::Result<()> {
while !should_quit.load(Ordering::Acquire) {
let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE)
@@ -1242,8 +1281,29 @@ fn client_read_loop(
},
}
}
- ClientMessage::EndpointControl { kind, .. } => {
- debug!(client_id, %kind, "ignoring unknown endpoint control message");
+ ClientMessage::EndpointControl { kind, data }
+ if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND =>
+ {
+ ServerEvent::ClientShellPresentationSync {
+ client_id,
+ token: data,
+ }
+ }
+ ClientMessage::EndpointControl { kind, data } => {
+ let Some(response) = crate::server::client_endpoint_control::response(&kind, data)
+ else {
+ debug!(client_id, %kind, "ignoring unknown endpoint control message");
+ continue;
+ };
+ let Some(writer) = endpoint_control_writer else {
+ continue;
+ };
+ let mut framed = Vec::new();
+ if protocol::write_message(&mut framed, &response).is_err()
+ || writer.send(framed).is_err()
+ {
+ break;
+ }
continue;
}
ClientMessage::Detach => {
@@ -1356,6 +1416,7 @@ mod tests {
direct_graphics: true,
endpoint_keybindings: true,
mouse_capture: true,
+ surface_active: true,
snapshot_codecs: vec![crate::protocol::endpoint::SNAPSHOT_CODEC_V1.into()],
surface_codecs: vec![crate::protocol::endpoint::SURFACE_CODEC_V1.into()],
input_codecs: vec![crate::protocol::endpoint::INPUT_CODEC_V1.into()],
@@ -1767,6 +1828,7 @@ mod tests {
direct_graphics,
endpoint_keybindings,
mouse_capture,
+ surface_active,
writer,
} => {
assert_eq!(client_id, 43);
@@ -1776,6 +1838,7 @@ mod tests {
assert!(direct_graphics);
assert!(endpoint_keybindings);
assert!(mouse_capture);
+ assert!(surface_active);
drop(writer);
}
other => panic!("expected ClientShellConnected, got {other:?}"),
diff --git a/src/server/clients.rs b/src/server/clients.rs
index 4f1baadb..972342ac 100644
--- a/src/server/clients.rs
+++ b/src/server/clients.rs
@@ -159,6 +159,8 @@ pub(crate) struct ClientConnection {
pub(crate) host_keyboard_report_all_active: Option<bool>,
/// Whether an ordinary render was skipped because the render channel was full.
pub(crate) render_pending: bool,
+ /// Whether this connection receives pane surfaces and may affect presentation state.
+ pub(crate) shell_surface_active: bool,
/// Whether this shell wants host mouse capture without pane demand.
pub(crate) shell_mouse_capture: bool,
/// Last host mouse capture mode sent to this client.
@@ -179,6 +181,9 @@ pub(crate) struct ClientConnection {
pub(crate) shell_projection_revision: u64,
/// Whether this shell is waiting for one ordered endpoint command response.
pub(crate) shell_endpoint_command_in_flight: bool,
+ /// Surface projection epoch that owned the in-flight command. Deferred navigation may run
+ /// only if this exact presentation lease is still active when its response arrives.
+ pub(crate) shell_endpoint_command_surface_revision: Option<u64>,
/// Request id and buffered response for a deferred worktree-created navigation.
pub(crate) shell_deferred_navigation_request_id: Option<String>,
pub(crate) shell_deferred_navigation_response: Option<Vec<u8>>,
@@ -230,6 +235,7 @@ impl ClientConnection {
outer_terminal_focus: None,
host_keyboard_report_all_active: None,
render_pending: false,
+ shell_surface_active: true,
shell_mouse_capture: false,
host_mouse_capture_active: None,
host_sgr_pixels_active: None,
@@ -240,6 +246,7 @@ impl ClientConnection {
shell_snapshot: None,
shell_projection_revision: 0,
shell_endpoint_command_in_flight: false,
+ shell_endpoint_command_surface_revision: None,
shell_deferred_navigation_request_id: None,
shell_deferred_navigation_response: None,
shell_uses_endpoint_keybindings: false,
@@ -451,12 +458,16 @@ impl ClientConnection {
pub(crate) fn is_shell_client(&self) -> bool {
matches!(self.mode, ClientConnectionMode::ClientShell)
}
+
+ pub(crate) fn is_active_shell_client(&self) -> bool {
+ self.is_shell_client() && self.shell_surface_active
+ }
}
pub(crate) fn latest_shell_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> {
clients
.iter()
- .filter(|(_, client)| client.is_shell_client())
+ .filter(|(_, client)| client.is_active_shell_client())
.max_by_key(|(_, client)| client.last_activity)
.map(|(&client_id, _)| client_id)
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 04a1b17b..abaca0aa 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -73,11 +73,13 @@ use crate::server::terminal_attach::paste_payload_for_runtime;
mod bootstrap;
mod client_views;
+mod endpoint_requests;
mod lifecycle;
mod notifications;
mod pane_graphics;
mod render;
mod retained_surface;
+mod surface_interest;
pub use bootstrap::run_server;
use lifecycle::wait_for_live_handoff_response_write;
@@ -910,6 +912,9 @@ impl HeadlessServer {
let Some(client) = self.clients.get_mut(&client_id) else {
return false;
};
+ if !client.shell_surface_active {
+ return false;
+ }
client.last_activity = stamp;
let changed = self.foreground_client_id != Some(client_id);
@@ -929,13 +934,13 @@ impl HeadlessServer {
fn app_client_count(&self) -> usize {
self.clients
.values()
- .filter(|client| client.is_shell_client() && client.writer.is_some())
+ .filter(|client| client.is_active_shell_client() && client.writer.is_some())
.count()
}
fn client_supports_direct_graphics(&self, client_id: u64) -> bool {
self.clients.get(&client_id).is_some_and(|client| {
- client.is_shell_client()
+ client.is_active_shell_client()
&& client.writer.is_some()
&& client.direct_graphics
&& client.pixel_mouse
@@ -992,12 +997,14 @@ impl HeadlessServer {
let disconnected_focus = self
.clients
.get(&client_id)
- .filter(|client| client.is_shell_client() && client.outer_terminal_focus == Some(true))
+ .filter(|client| {
+ client.is_active_shell_client() && client.outer_terminal_focus == Some(true)
+ })
.and_then(|_| self.shell_focus_target(client_id));
let should_release_focus = disconnected_focus.as_ref().is_some_and(|target| {
!self.clients.iter().any(|(&other_id, client)| {
other_id != client_id
- && client.is_shell_client()
+ && client.is_active_shell_client()
&& client.outer_terminal_focus == Some(true)
&& self.shell_tab_id_for_client(other_id).as_deref()
== Some(target.tab_id.as_str())
@@ -1008,7 +1015,8 @@ impl HeadlessServer {
self.tab_geometry_controllers
.retain(|_, controller_id| *controller_id != client_id);
if let Some(mut removed) = removed {
- self.release_client_shell_inputs(client_id, &mut removed);
+ let held_inputs = removed.drain_shell_held_inputs();
+ self.release_client_shell_inputs(client_id, held_inputs);
crate::server::clipboard_image::remove_files(removed.staged_clipboard_files);
if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode {
self.terminal_attach_owners.remove(&terminal_id);
@@ -1033,8 +1041,12 @@ impl HeadlessServer {
}
}
- fn release_client_shell_inputs(&mut self, client_id: u64, client: &mut ClientConnection) {
- for held in client.drain_shell_held_inputs() {
+ fn release_client_shell_inputs(
+ &mut self,
+ client_id: u64,
+ held_inputs: Vec<crate::server::clients::ClientShellHeldInput>,
+ ) {
+ for held in held_inputs {
let result = match held.target {
ClientShellInputTarget::Pane(pane_id) => {
let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id)
@@ -1181,16 +1193,18 @@ impl HeadlessServer {
protocol::ClientClipboardImageTarget::Pane(pane_id) => {
!self.handoff_in_progress
&& self.app.state.popup_pane.is_none()
- && self.clients.get(&client_id).is_some_and(|client| {
- matches!(client.mode, ClientConnectionMode::ClientShell)
- })
+ && self
+ .clients
+ .get(&client_id)
+ .is_some_and(ClientConnection::is_active_shell_client)
&& self.app.parse_pane_id(pane_id).is_some()
}
protocol::ClientClipboardImageTarget::Popup(terminal_id) => {
!self.handoff_in_progress
- && self.clients.get(&client_id).is_some_and(|client| {
- matches!(client.mode, ClientConnectionMode::ClientShell)
- })
+ && self
+ .clients
+ .get(&client_id)
+ .is_some_and(ClientConnection::is_active_shell_client)
&& self
.app
.state
@@ -1237,9 +1251,10 @@ impl HeadlessServer {
}
protocol::ClientClipboardImageTarget::Pane(pane_id) => {
if self.handoff_in_progress
- || !self.clients.get(&client_id).is_some_and(|client| {
- matches!(client.mode, ClientConnectionMode::ClientShell)
- })
+ || !self
+ .clients
+ .get(&client_id)
+ .is_some_and(ClientConnection::is_active_shell_client)
{
return false;
}
@@ -1273,9 +1288,10 @@ impl HeadlessServer {
}
protocol::ClientClipboardImageTarget::Popup(terminal_id) => {
if self.handoff_in_progress
- || !self.clients.get(&client_id).is_some_and(|client| {
- matches!(client.mode, ClientConnectionMode::ClientShell)
- })
+ || !self
+ .clients
+ .get(&client_id)
+ .is_some_and(ClientConnection::is_active_shell_client)
{
return false;
}
@@ -1929,6 +1945,7 @@ impl HeadlessServer {
direct_graphics,
endpoint_keybindings,
mouse_capture,
+ surface_active,
writer,
} => {
if self.handoff_in_progress {
@@ -1950,6 +1967,7 @@ impl HeadlessServer {
rows = surface_rows,
cell_width_px,
cell_height_px,
+ surface_active,
render_encoding = ?protocol::RenderEncoding::SemanticFrame,
"client connected"
);
@@ -1972,6 +1990,7 @@ impl HeadlessServer {
connection.direct_graphics = direct_graphics;
connection.shell_uses_endpoint_keybindings = endpoint_keybindings;
connection.shell_mouse_capture = mouse_capture;
+ connection.shell_surface_active = surface_active;
connection.shell_projection_revision = 1;
let config_diagnostic = if endpoint_keybindings {
self.server_config_diagnostic.as_deref()
@@ -2002,7 +2021,9 @@ impl HeadlessServer {
self.popup_owner_tab_id = self.shell_tab_id_for_client(client_id);
}
self.send_to_client(client_id, snapshot_message);
- self.foreground_client_id = Some(client_id);
+ if surface_active {
+ self.foreground_client_id = Some(client_id);
+ }
if first_app_client {
self.app.mark_git_status_refresh_due(Instant::now());
}
@@ -2224,6 +2245,9 @@ impl HeadlessServer {
client.cell_size = observed;
}
client.pixel_mouse = pixel_mouse && observed.is_known();
+ if !client.shell_surface_active {
+ return false;
+ }
client.request_repaint();
self.promote_client_to_foreground(client_id);
self.resize_shell_tab_if_controller(client_id, true);
@@ -2239,7 +2263,7 @@ impl HeadlessServer {
if !client.update_host_theme(&update) {
return false;
}
- if self.foreground_client_id != Some(client_id) {
+ if !client.shell_surface_active || self.foreground_client_id != Some(client_id) {
return false;
}
let mut changed = self.app.set_host_terminal_appearance_state(
@@ -2256,15 +2280,14 @@ impl HeadlessServer {
let Some(client) = self.clients.get(&client_id) else {
return false;
};
- if !matches!(client.mode, ClientConnectionMode::ClientShell)
- || client.outer_terminal_focus == Some(focused)
+ if !client.is_active_shell_client() || client.outer_terminal_focus == Some(focused)
{
return false;
}
let tab_id = self.shell_tab_id_for_client(client_id);
let another_focused_viewer = self.clients.iter().any(|(&other_id, client)| {
other_id != client_id
- && client.is_shell_client()
+ && client.is_active_shell_client()
&& client.outer_terminal_focus == Some(true)
&& self.shell_tab_id_for_client(other_id) == tab_id
});
@@ -2308,15 +2331,38 @@ impl HeadlessServer {
client.host_mouse_capture_active = None;
true
}
+ ServerEvent::ClientShellPresentationSync { client_id, token } => {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ if !client.is_active_shell_client() {
+ return false;
+ }
+ client.host_mouse_capture_active = None;
+ client.host_sgr_pixels_active = None;
+ client.host_keyboard_report_all_active = None;
+ self.sent_window_title = None;
+ self.stream_host_mouse_capture_mode();
+ self.stream_direct_terminal_keyboard_mode();
+ self.sync_window_title();
+ self.send_to_client(
+ client_id,
+ ServerMessage::EndpointControl {
+ kind: crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND.into(),
+ data: token,
+ },
+ )
+ }
ServerEvent::ClientShellPaneInput {
client_id,
pane_id,
events,
} => {
if self.handoff_in_progress
- || !self.clients.get(&client_id).is_some_and(|client| {
- matches!(client.mode, ClientConnectionMode::ClientShell)
- })
+ || !self
+ .clients
+ .get(&client_id)
+ .is_some_and(ClientConnection::is_active_shell_client)
{
return false;
}
@@ -2400,9 +2446,10 @@ impl HeadlessServer {
events,
} => {
if self.handoff_in_progress
- || !self.clients.get(&client_id).is_some_and(|client| {
- matches!(client.mode, ClientConnectionMode::ClientShell)
- })
+ || !self
+ .clients
+ .get(&client_id)
+ .is_some_and(ClientConnection::is_active_shell_client)
{
return false;
}
@@ -2494,96 +2541,8 @@ impl HeadlessServer {
ServerEvent::ClientShellEndpointRequest {
client_id,
boot_id,
- mut request,
- } => {
- let Some(client) = self.clients.get(&client_id) else {
- return false;
- };
- if !matches!(client.mode, ClientConnectionMode::ClientShell) {
- self.remove_client_and_resize_if_needed(client_id);
- return true;
- }
- let request_id = request.id.clone();
- if !crate::server::client_commands::supports_client_shell_method(&request.method) {
- let message = crate::server::client_commands::error_message(
- boot_id,
- request_id,
- "unsupported_endpoint_command",
- "this method is not available through the client shell command lane",
- );
- self.send_to_client(client_id, message);
- return false;
- }
- if boot_id != self.client_shell_boot_id {
- let message = crate::server::client_commands::error_message(
- boot_id,
- request_id,
- "stale_boot",
- "endpoint command targeted an earlier server boot",
- );
- self.send_to_client(client_id, message);
- return false;
- }
- if client.shell_endpoint_command_in_flight {
- self.send_to_client(
- client_id,
- ServerMessage::ServerShutdown {
- reason: Some("client sent concurrent endpoint commands".into()),
- },
- );
- self.remove_client_and_resize_if_needed(client_id);
- return true;
- }
-
- let api_request_id = format!(
- "endpoint:{}:{client_id}:{request_id}",
- self.client_shell_boot_id
- );
- request.id = api_request_id.clone();
- let (respond_to, response_rx) = std::sync::mpsc::channel();
- if let Err(err) = crate::server::client_commands::spawn_response_waiter(
- client_id,
- boot_id.clone(),
- request_id.clone(),
- response_rx,
- self.server_event_tx.clone(),
- ) {
- let message = crate::server::client_commands::error_message(
- boot_id,
- request_id,
- "server_unavailable",
- format!("failed to start endpoint response bridge: {err}"),
- );
- self.send_to_client(client_id, message);
- return false;
- }
- if let Some(client) = self.clients.get_mut(&client_id) {
- client.shell_endpoint_command_in_flight = true;
- let deferred_worktree = matches!(
- &request.method,
- api::schema::Method::WorktreeCreate(_)
- | api::schema::Method::WorktreeRemove(_)
- );
- let deferred_navigation = matches!(
- &request.method,
- api::schema::Method::WorktreeCreate(params) if params.focus
- );
- client.shell_deferred_navigation_request_id =
- deferred_worktree.then(|| api_request_id.clone());
- client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new);
- }
- let foreground_changed = self.promote_client_to_foreground(client_id);
- foreground_changed
- | self.handle_client_shell_api_request(
- client_id,
- api::ApiRequestMessage {
- request: *request,
- respond_to,
- response_write_complete: None,
- stream_active: None,
- },
- )
- }
+ request,
+ } => self.handle_client_shell_endpoint_request(client_id, boot_id, request),
ServerEvent::ClientShellEndpointResponseChunkReady {
client_id,
boot_id,
@@ -2591,14 +2550,16 @@ impl HeadlessServer {
final_chunk,
data,
} => {
- let valid = self.clients.get(&client_id).is_some_and(|client| {
- matches!(client.mode, ClientConnectionMode::ClientShell)
+ let command_surface_revision = self.clients.get(&client_id).and_then(|client| {
+ (matches!(client.mode, ClientConnectionMode::ClientShell)
&& client.shell_endpoint_command_in_flight
- && boot_id == self.client_shell_boot_id
+ && boot_id == self.client_shell_boot_id)
+ .then_some(client.shell_endpoint_command_surface_revision)
+ .flatten()
});
- if !valid {
+ let Some(command_surface_revision) = command_surface_revision else {
return false;
- }
+ };
let completed_deferred_response =
self.clients.get_mut(&client_id).and_then(|client| {
let response = client.shell_deferred_navigation_response.as_mut()?;
@@ -2610,6 +2571,7 @@ impl HeadlessServer {
if final_chunk {
if let Some(client) = self.clients.get_mut(&client_id) {
client.shell_endpoint_command_in_flight = false;
+ client.shell_endpoint_command_surface_revision = None;
client.shell_deferred_navigation_request_id = None;
}
}
@@ -2618,7 +2580,10 @@ impl HeadlessServer {
.and_then(Self::deferred_endpoint_navigation_tab_id);
let focus_before = self.shell_focus_target(client_id);
let focused_tabs_before = self.focused_shell_tabs();
- let navigation_changed = deferred_tab_id
+ let navigation_changed = self.clients.get(&client_id).is_some_and(|client| {
+ client.is_active_shell_client()
+ && client.shell_projection_revision == command_surface_revision
+ }) && deferred_tab_id
.as_deref()
.is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, tab_id));
let geometry_changed =
diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs
index 2d49a50a..2257a05e 100644
--- a/src/server/headless/client_views.rs
+++ b/src/server/headless/client_views.rs
@@ -388,7 +388,7 @@ impl HeadlessServer {
self.clients
.iter()
.filter(|(_, client)| {
- client.is_shell_client() && client.outer_terminal_focus == Some(true)
+ client.is_active_shell_client() && client.outer_terminal_focus == Some(true)
})
.filter_map(|(&client_id, _)| self.shell_tab_id_for_client(client_id))
.collect()
@@ -397,7 +397,7 @@ impl HeadlessServer {
pub(super) fn shell_focus_targets(&self) -> Vec<(u64, Option<ShellFocusTarget>)> {
self.clients
.iter()
- .filter(|(_, client)| client.is_shell_client())
+ .filter(|(_, client)| client.is_active_shell_client())
.map(|(&client_id, _)| (client_id, self.shell_focus_target(client_id)))
.collect()
}
@@ -608,11 +608,16 @@ impl HeadlessServer {
&mut self,
start_pending_agent_resumes: bool,
) -> bool {
- if self.app_client_count() != 1 {
+ let active_shell_count = self
+ .clients
+ .values()
+ .filter(|client| client.is_active_shell_client() && client.writer.is_some())
+ .count();
+ if active_shell_count != 1 {
return false;
}
let Some(client_id) = self.clients.iter().find_map(|(&client_id, client)| {
- (client.is_shell_client() && client.writer.is_some()).then_some(client_id)
+ (client.is_active_shell_client() && client.writer.is_some()).then_some(client_id)
}) else {
return false;
};
@@ -625,7 +630,7 @@ impl HeadlessServer {
) -> bool {
let mut viewed_tabs = HashMap::<String, Vec<u64>>::new();
for (&client_id, client) in &self.clients {
- if !client.is_shell_client() || client.writer.is_none() {
+ if !client.is_active_shell_client() || client.writer.is_none() {
continue;
}
let Some(tab_id) = self.shell_tab_id_for_client(client_id) else {
@@ -684,6 +689,13 @@ impl HeadlessServer {
client_id: u64,
start_pending_agent_resumes: bool,
) -> bool {
+ if !self
+ .clients
+ .get(&client_id)
+ .is_some_and(|client| client.shell_surface_active)
+ {
+ return false;
+ }
let Some(tab_id) = self.shell_tab_id_for_client(client_id) else {
return false;
};
@@ -698,6 +710,13 @@ impl HeadlessServer {
client_id: u64,
start_pending_agent_resumes: bool,
) -> bool {
+ if !self
+ .clients
+ .get(&client_id)
+ .is_some_and(|client| client.shell_surface_active)
+ {
+ return false;
+ }
let Some(tab_id) = self.shell_tab_id_for_client(client_id) else {
return false;
};
@@ -713,6 +732,13 @@ impl HeadlessServer {
client_id: u64,
start_pending_agent_resumes: bool,
) -> bool {
+ if !self
+ .clients
+ .get(&client_id)
+ .is_some_and(|client| client.shell_surface_active)
+ {
+ return false;
+ }
let Some(tab_id) = self.shell_tab_id_for_client(client_id) else {
return false;
};
diff --git a/src/server/headless/endpoint_requests.rs b/src/server/headless/endpoint_requests.rs
new file mode 100644
index 00000000..4f02b676
--- /dev/null
+++ b/src/server/headless/endpoint_requests.rs
@@ -0,0 +1,130 @@
+use super::*;
+
+impl HeadlessServer {
+ pub(super) fn handle_client_shell_endpoint_request(
+ &mut self,
+ client_id: u64,
+ boot_id: String,
+ mut request: Box<api::schema::Request>,
+ ) -> bool {
+ let Some(client) = self.clients.get(&client_id) else {
+ return false;
+ };
+ if !matches!(client.mode, ClientConnectionMode::ClientShell) {
+ self.remove_client_and_resize_if_needed(client_id);
+ return true;
+ }
+ let request_id = request.id.clone();
+ if !crate::server::client_commands::supports_client_shell_method(&request.method) {
+ let message = crate::server::client_commands::error_message(
+ boot_id,
+ request_id,
+ "unsupported_endpoint_command",
+ "this method is not available through the client shell command lane",
+ );
+ self.send_to_client(client_id, message);
+ return false;
+ }
+ if boot_id != self.client_shell_boot_id {
+ let message = crate::server::client_commands::error_message(
+ boot_id,
+ request_id,
+ "stale_boot",
+ "endpoint command targeted an earlier server boot",
+ );
+ self.send_to_client(client_id, message);
+ return false;
+ }
+ let surface_active = client.shell_surface_active;
+ if let api::schema::Method::ClientShellSurfaceSet(params) = &request.method {
+ let Some((changed, projection_revision)) =
+ self.set_client_shell_surface_active(client_id, params.active)
+ else {
+ return false;
+ };
+ self.send_to_client(
+ client_id,
+ crate::server::client_commands::success_message_with_result(
+ boot_id,
+ request_id,
+ api::schema::ResponseResult::ClientShellSurfaceSet {
+ active: params.active,
+ projection_revision,
+ },
+ ),
+ );
+ return changed;
+ }
+ if client.shell_endpoint_command_in_flight {
+ let message = crate::server::client_commands::error_message(
+ boot_id,
+ request_id,
+ "endpoint_busy",
+ "this endpoint is still processing another command",
+ );
+ self.send_to_client(client_id, message);
+ return false;
+ }
+ if !surface_active {
+ let message = crate::server::client_commands::error_message(
+ boot_id,
+ request_id,
+ "surface_inactive",
+ "this method requires an active client shell surface",
+ );
+ self.send_to_client(client_id, message);
+ return false;
+ }
+
+ let api_request_id = format!(
+ "endpoint:{}:{client_id}:{request_id}",
+ self.client_shell_boot_id
+ );
+ request.id = api_request_id.clone();
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ if let Err(err) = crate::server::client_commands::spawn_response_waiter(
+ client_id,
+ boot_id.clone(),
+ request_id.clone(),
+ response_rx,
+ self.server_event_tx.clone(),
+ ) {
+ let message = crate::server::client_commands::error_message(
+ boot_id,
+ request_id,
+ "server_unavailable",
+ format!("failed to start endpoint response bridge: {err}"),
+ );
+ self.send_to_client(client_id, message);
+ return false;
+ }
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.shell_endpoint_command_in_flight = true;
+ // A later source restore has a new projection revision. Keep this request's lease
+ // so a delayed worktree response cannot focus a pane after endpoint switching.
+ client.shell_endpoint_command_surface_revision = Some(client.shell_projection_revision);
+ let deferred_worktree = matches!(
+ &request.method,
+ api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_)
+ );
+ let deferred_navigation = matches!(
+ &request.method,
+ api::schema::Method::WorktreeCreate(params) if params.focus
+ );
+ client.shell_deferred_navigation_request_id =
+ deferred_worktree.then(|| api_request_id.clone());
+ client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new);
+ }
+ let foreground_changed = self.promote_client_to_foreground(client_id);
+ foreground_changed
+ | self.handle_client_shell_api_request(
+ client_id,
+ api::ApiRequestMessage {
+ request: *request,
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ },
+ )
+ }
+}
diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs
index 991d1861..5e36be01 100644
--- a/src/server/headless/render.rs
+++ b/src/server/headless/render.rs
@@ -39,7 +39,10 @@ impl HeadlessServer {
.iter()
.filter_map(|(&client_id, client)| match &client.mode {
ClientConnectionMode::ClientShell => {
- let focused = self.shell_focused_runtime(client_id);
+ let focused = client
+ .shell_surface_active
+ .then(|| self.shell_focused_runtime(client_id))
+ .flatten();
let child_requests_mouse =
focused.is_some_and(|(runtime, _)| runtime.mouse_reporting_enabled());
let sgr_pixels = client.pixel_mouse
@@ -49,8 +52,9 @@ impl HeadlessServer {
});
Some((
client_id,
- client.shell_mouse_capture || child_requests_mouse,
- sgr_pixels,
+ client.shell_surface_active
+ && (client.shell_mouse_capture || child_requests_mouse),
+ client.shell_surface_active && sgr_pixels,
))
}
ClientConnectionMode::TerminalAttach { terminal_id } => {
@@ -113,9 +117,10 @@ impl HeadlessServer {
.clients
.iter()
.filter(|(_, client)| client.is_shell_client())
- .map(|(&client_id, _)| {
- let report_all =
- self.shell_focused_runtime(client_id)
+ .map(|(&client_id, client)| {
+ let report_all = client.shell_surface_active
+ && self
+ .shell_focused_runtime(client_id)
.is_some_and(|(runtime, _)| {
let protocol = runtime.keyboard_protocol();
protocol.reports_all_keys()
@@ -224,7 +229,7 @@ impl HeadlessServer {
let mut pane_ids = HashSet::new();
if has_app_target {
for (&client_id, client) in &self.clients {
- if !client.is_shell_client() || client.writer.is_none() {
+ if !client.is_active_shell_client() || client.writer.is_none() {
continue;
}
let Some(target) = self.shell_target_for_client(client_id) else {
@@ -279,7 +284,10 @@ impl HeadlessServer {
.filter(|client| client.writer.is_some())
{
match &client.mode {
- ClientConnectionMode::ClientShell => has_app_target = true,
+ ClientConnectionMode::ClientShell if client.shell_surface_active => {
+ has_app_target = true;
+ }
+ ClientConnectionMode::ClientShell => {}
ClientConnectionMode::TerminalAttach { terminal_id }
| ClientConnectionMode::TerminalObserve { terminal_id } => {
direct_terminal_targets.insert(terminal_id.as_str());
@@ -339,7 +347,7 @@ impl HeadlessServer {
fn any_shell_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool {
self.clients.iter().any(|(&client_id, client)| {
- if !client.is_shell_client() || client.writer.is_none() {
+ if !client.is_active_shell_client() || client.writer.is_none() {
return false;
}
if self
@@ -456,6 +464,10 @@ impl HeadlessServer {
client.shell_snapshot = Some(candidate);
}
shell_projection_revision = client.shell_projection_revision;
+ if !client.shell_surface_active {
+ client.clear_deferred_render();
+ continue;
+ }
}
let shell_graphics_delivery = self
.clients
diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs
index 230704a6..a62a43f6 100644
--- a/src/server/headless/retained_surface.rs
+++ b/src/server/headless/retained_surface.rs
@@ -199,12 +199,14 @@ struct CollectedPanePatch {
mouse_reporting: bool,
sgr_pixel_mouse: bool,
alternate_screen_active: bool,
+ graphics_may_have_placements: bool,
}
struct RetainedRecipientUpdate {
client_id: u64,
surface: protocol::PaneSurfaceFrame,
patch: protocol::PaneSurfacePatch,
+ graphics_delivery: Option<crate::kitty_graphics::surface::DeliveryCache>,
}
impl HeadlessServer {
@@ -239,11 +241,20 @@ impl HeadlessServer {
{
fallback!("unsafe_state");
}
- let targets = render_targets(&self.clients, self.foreground_client_id);
- if targets.is_empty()
- || targets
- .iter()
- .any(|target| !matches!(target.4, ClientConnectionMode::ClientShell))
+ let mut targets = render_targets(&self.clients, self.foreground_client_id);
+ targets.retain(|(client_id, _, _, _, mode)| {
+ !matches!(mode, ClientConnectionMode::ClientShell)
+ || self
+ .clients
+ .get(client_id)
+ .is_some_and(|client| client.shell_surface_active)
+ });
+ if targets.is_empty() {
+ success!("no_active_surface");
+ }
+ if targets
+ .iter()
+ .any(|target| !matches!(target.4, ClientConnectionMode::ClientShell))
{
fallback!("non_shell_target");
}
@@ -266,8 +277,6 @@ impl HeadlessServer {
|| surface.frame.height != *rows
|| surface.popup.is_some()
|| !surface.graphics.assets.is_empty()
- || !surface.graphics.placements.is_empty()
- || !surface.graphics.retained_assets.is_empty()
|| !surface.frame.graphics.is_empty()
{
fallback!("baseline_mismatch");
@@ -325,6 +334,8 @@ impl HeadlessServer {
fallback!("terminal_patch");
}
};
+ let graphics_may_have_placements =
+ crate::kitty_graphics::is_enabled() && runtime.kitty_graphics_may_have_placements();
let revision = runtime.content_seq();
if revision != revision_before || !revision.is_multiple_of(2) {
fallback!("content_changed");
@@ -337,6 +348,7 @@ impl HeadlessServer {
mouse_reporting: runtime.mouse_reporting_enabled(),
sgr_pixel_mouse: runtime.sgr_pixel_mouse_enabled(),
alternate_screen_active: runtime.alternate_screen_active(),
+ graphics_may_have_placements,
});
}
@@ -349,6 +361,8 @@ impl HeadlessServer {
let mut changed_panes = Vec::with_capacity(collected.len());
let mut patch_rows = Vec::new();
let mut metadata_changed = false;
+ let mut refresh_graphics = !surface.graphics.placements.is_empty()
+ || !surface.graphics.retained_assets.is_empty();
for collected_pane in &collected {
let Some(pane) = surface
.panes
@@ -370,6 +384,7 @@ impl HeadlessServer {
) {
fallback!("hyperlink");
}
+ refresh_graphics |= collected_pane.graphics_may_have_placements;
let previous_pane = pane.clone();
let Some(rows) =
apply_rows(&mut surface.frame, pane.inner_rect, &collected_pane.patch)
@@ -405,7 +420,31 @@ impl HeadlessServer {
let cursor = retained_cursor(&self.app, &surface.panes);
let cursor_changed = cursor != surface.frame.cursor;
surface.frame.cursor = cursor.clone();
- if patch_rows.is_empty() && !cursor_changed && !metadata_changed {
+ let mut graphics_changed = false;
+ let graphics_delivery = if refresh_graphics {
+ let Some(target) = self.shell_target_for_client(client_id) else {
+ fallback!("graphics_target");
+ };
+ let client = &self.clients[&client_id];
+ let Some((graphics, delivery)) =
+ crate::server::client_shell_graphics::collect_retained(
+ &self.app,
+ &surface,
+ target,
+ client.cell_size,
+ &client.shell_graphics_delivery,
+ client_id,
+ )
+ else {
+ fallback!("graphics_geometry");
+ };
+ graphics_changed = graphics != surface.graphics;
+ surface.graphics = graphics;
+ Some(delivery)
+ } else {
+ None
+ };
+ if patch_rows.is_empty() && !cursor_changed && !metadata_changed && !graphics_changed {
continue;
}
let patch = protocol::PaneSurfacePatch {
@@ -421,6 +460,7 @@ impl HeadlessServer {
client_id,
surface,
patch,
+ graphics_delivery,
});
}
if updates.is_empty() {
@@ -435,6 +475,7 @@ impl HeadlessServer {
client_id,
surface,
patch,
+ graphics_delivery,
} = update;
let Some(client) = self.clients.get_mut(&client_id) else {
continue;
@@ -444,31 +485,53 @@ impl HeadlessServer {
deferred += 1;
continue;
};
- let Some(prepared) = client
- .render_state
- .prepare_pane_surface_patch(patch, surface)
- else {
+ // The published row patch cannot carry images. Reuse the retained text/layout
+ // in a graphics-capable surface message rather than invoking the full renderer.
+ let prepared = if graphics_delivery.is_some() {
+ client.render_state.prepare_pane_surface(surface)
+ } else {
+ client
+ .render_state
+ .prepare_pane_surface_patch(patch, surface)
+ };
+ let Some(prepared) = prepared else {
client.defer_full_render();
deferred += 1;
continue;
};
- let serialized = match Self::frame_server_message(prepared.message()) {
- Ok(serialized) => serialized,
- Err(error) => {
- warn!(
- client_id,
- %error,
- "failed to serialize retained pane surface patch"
- );
- client.defer_full_render();
- deferred += 1;
- continue;
- }
+ let max_frame_size = if graphics_delivery.is_some() {
+ MAX_GRAPHICS_FRAME_SIZE
+ } else {
+ protocol::MAX_FRAME_SIZE
};
+ let serialized =
+ match Self::frame_server_message_with_max(prepared.message(), max_frame_size) {
+ Ok(serialized) => serialized,
+ Err(error) => {
+ warn!(
+ client_id,
+ %error,
+ "failed to serialize retained pane surface patch"
+ );
+ client.defer_full_render();
+ deferred += 1;
+ continue;
+ }
+ };
crate::render_prof::counter("retained_surface.bytes", serialized.len() as u64);
match writer.render.try_send(serialized) {
Ok(()) => {
- client.clear_deferred_render();
+ let graphics_pending = graphics_delivery
+ .as_ref()
+ .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending);
+ if let Some(delivery) = graphics_delivery {
+ client.shell_graphics_delivery = delivery;
+ }
+ if graphics_pending {
+ client.defer_full_render();
+ } else {
+ client.clear_deferred_render();
+ }
client.render_state.commit_sent_frame(prepared);
sent += 1;
}
diff --git a/src/server/headless/surface_interest.rs b/src/server/headless/surface_interest.rs
new file mode 100644
index 00000000..7270be0d
--- /dev/null
+++ b/src/server/headless/surface_interest.rs
@@ -0,0 +1,86 @@
+use super::*;
+
+impl HeadlessServer {
+ /// Apply a client-shell surface lease and return the resulting projection floor.
+ ///
+ /// Every `active: true` request advances the floor, even if the server already considered
+ /// the connection active. That makes a new client activation epoch distinguishable from a
+ /// delayed same-boot PaneSurface that was prepared for an earlier epoch.
+ pub(super) fn set_client_shell_surface_active(
+ &mut self,
+ client_id: u64,
+ active: bool,
+ ) -> Option<(bool, u64)> {
+ let focus_before = self.shell_focus_targets();
+ let focused_tabs_before = self.focused_shell_tabs();
+ let (changed, projection_revision) = {
+ let client = self.clients.get_mut(&client_id)?;
+ if !client.is_shell_client() {
+ return None;
+ }
+ let changed = client.shell_surface_active != active;
+ if active {
+ client.shell_projection_revision =
+ client.shell_projection_revision.saturating_add(1);
+ // Force the next control snapshot to carry this new floor instead of reusing a
+ // same-boot cached snapshot from the prior surface epoch.
+ client.shell_snapshot = None;
+ }
+ if !changed && !active {
+ return Some((false, client.shell_projection_revision));
+ }
+ client.shell_surface_active = active;
+ client.request_repaint();
+ client.shell_graphics_delivery = Default::default();
+ // The client drops target effects while its old source frame is frozen. Reset the
+ // dedupe state whenever a viewer is (re)activated so the post-commit replay can
+ // produce mouse/keyboard modes and graphics even when runtime demand is unchanged.
+ if active {
+ client.host_mouse_capture_active = None;
+ client.host_sgr_pixels_active = None;
+ client.host_keyboard_report_all_active = None;
+ }
+ client.clear_deferred_render();
+ if !active {
+ if let Some(writer) = &client.writer {
+ writer.discard_pending_render();
+ }
+ }
+ (changed, client.shell_projection_revision)
+ };
+ let held_inputs = (!active && changed).then(|| {
+ self.clients
+ .get_mut(&client_id)
+ .expect("checked client")
+ .drain_shell_held_inputs()
+ });
+
+ if let Some(held_inputs) = held_inputs {
+ self.release_client_shell_inputs(client_id, held_inputs);
+ self.retire_direct_graphics_for_client(client_id);
+ }
+ if changed {
+ self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before);
+ }
+
+ if active {
+ self.promote_client_to_foreground(client_id);
+ // Do not emit/cache a title during a frozen target activation. A committed client
+ // explicitly requests the bounded replay after its coherent frame is visible.
+ self.sent_window_title = None;
+ self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true);
+ self.claim_shell_tab_geometry(client_id, true);
+ } else {
+ self.tab_geometry_controllers
+ .retain(|_, controller_id| *controller_id != client_id);
+ if self.foreground_client_id == Some(client_id) {
+ self.promote_latest_remaining_client();
+ self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true);
+ } else {
+ self.sync_foreground_client_state();
+ }
+ self.reapply_controlled_shell_tab_geometry(true);
+ }
+ Some((changed || active, projection_revision))
+ }
+}
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs
index e7e73e8b..5e0e5d01 100644
--- a/src/server/headless/tests/mod.rs
+++ b/src/server/headless/tests/mod.rs
@@ -2,6 +2,8 @@ use super::*;
#[path = "pane_graphics.rs"]
mod pane_graphics_tests;
+#[path = "surface_interest.rs"]
+mod surface_interest_tests;
fn client_shell_snapshot(message: ServerMessage) -> Box<crate::protocol::ClientShellSnapshot> {
let ServerMessage::EndpointControl { kind, data } = message else {
@@ -607,6 +609,7 @@ async fn client_shell_attach_seeds_workspace() {
direct_graphics: false,
endpoint_keybindings: false,
mouse_capture: false,
+ surface_active: true,
writer,
})
);
@@ -636,6 +639,7 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() {
direct_graphics: false,
endpoint_keybindings: false,
mouse_capture: false,
+ surface_active: true,
writer,
})
);
@@ -654,6 +658,26 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() {
);
assert!(server.clients[&client_id].shell_endpoint_command_in_flight);
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellEndpointRequest {
+ client_id,
+ boot_id: boot_id.clone(),
+ request: Box::new(api::schema::Request {
+ id: "client-shell:busy".into(),
+ method: api::schema::Method::IntegrationList(api::schema::EmptyParams::default()),
+ }),
+ })
+ );
+ assert!(server.clients.contains_key(&client_id));
+ let ServerMessage::ClientShellEndpointResponseChunk { data, .. } =
+ read_server_message(control_rx.recv().expect("busy endpoint response"))
+ else {
+ panic!("expected busy endpoint response");
+ };
+ let response =
+ serde_json::from_slice::<api::schema::ErrorResponse>(&data).expect("typed busy response");
+ assert_eq!(response.error.code, "endpoint_busy");
+
let response_ready = server
.server_event_rx
.recv()
@@ -751,6 +775,7 @@ async fn client_shell_receives_metadata_then_shell_free_pane_surface() {
direct_graphics: false,
endpoint_keybindings: false,
mouse_capture: false,
+ surface_active: true,
writer,
})
);
@@ -894,6 +919,7 @@ fn connect_test_shell(
direct_graphics: false,
endpoint_keybindings: false,
mouse_capture: false,
+ surface_active: true,
writer,
})
);
@@ -1219,6 +1245,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() {
direct_graphics: false,
endpoint_keybindings: false,
mouse_capture: false,
+ surface_active: true,
writer: local_writer,
})
);
@@ -1242,6 +1269,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() {
direct_graphics: false,
endpoint_keybindings: true,
mouse_capture: false,
+ surface_active: true,
writer: endpoint_writer,
})
);
@@ -1351,6 +1379,12 @@ async fn deferred_worktree_response_moves_only_its_source_client() {
.get_mut(&51)
.unwrap()
.shell_endpoint_command_in_flight = true;
+ let source_surface_revision = server.clients[&51].shell_projection_revision;
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
+ .shell_endpoint_command_surface_revision = Some(source_surface_revision);
server
.clients
.get_mut(&51)
@@ -1385,6 +1419,11 @@ async fn deferred_worktree_response_moves_only_its_source_client() {
);
assert!(server.focus_shell_client_on_tab(51, &original_tab_id));
+ // A source command begun in the old presentation epoch may finish after source-off and
+ // source-on rollback. Its response remains endpoint-local, but it must not apply deferred
+ // client navigation to the restored source.
+ assert!(server.set_client_shell_surface_active(51, false).is_some());
+ assert!(server.set_client_shell_surface_active(51, true).is_some());
server
.clients
.get_mut(&51)
@@ -1394,7 +1433,17 @@ async fn deferred_worktree_response_moves_only_its_source_client() {
.clients
.get_mut(&51)
.unwrap()
+ .shell_endpoint_command_surface_revision = Some(source_surface_revision);
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
.shell_deferred_navigation_request_id = Some("background-worktree".into());
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
+ .shell_deferred_navigation_response = Some(Vec::new());
assert!(
!server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady {
client_id: 51,
@@ -1919,6 +1968,7 @@ async fn public_api_focus_replaces_every_client_shell_projection() {
direct_graphics: false,
endpoint_keybindings: false,
mouse_capture: false,
+ surface_active: true,
writer,
})
);
@@ -2168,6 +2218,7 @@ async fn client_shell_streams_and_targets_popup_terminal_content() {
direct_graphics: false,
endpoint_keybindings: false,
mouse_capture: false,
+ surface_active: true,
writer,
})
);
@@ -2664,6 +2715,19 @@ fn retained_test_server(
std::sync::mpsc::Receiver<Vec<u8>>,
crate::layout::PaneId,
) {
+ let (server, _control_rx, render_rx, pane_id) =
+ retained_test_server_with_control(initial_screen);
+ (server, render_rx, pane_id)
+}
+
+fn retained_test_server_with_control(
+ initial_screen: &[u8],
+) -> (
+ HeadlessServer,
+ std::sync::mpsc::Receiver<Vec<u8>>,
+ std::sync::mpsc::Receiver<Vec<u8>>,
+ crate::layout::PaneId,
+) {
let mut server = test_headless_server();
let mut workspace = crate::workspace::Workspace::test_new("test");
let pane_id = workspace.focused_pane_id().expect("focused pane");
@@ -2676,7 +2740,7 @@ fn retained_test_server(
server.app.state.selected = 0;
server.app.state.mode = crate::app::Mode::Terminal;
- let (client_tx, _client_control_rx, client_rx) = test_client_writer();
+ let (client_tx, client_control_rx, client_rx) = test_client_writer();
server.clients.insert(
1,
ClientConnection::new(
@@ -2691,7 +2755,7 @@ fn retained_test_server(
server.sync_foreground_client_state();
assert!(server.claim_unowned_shell_tab_geometry(1, true));
- (server, client_rx, pane_id)
+ (server, client_control_rx, client_rx, pane_id)
}
#[test]
diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs
index d310003c..f8637e8f 100644
--- a/src/server/headless/tests/pane_graphics.rs
+++ b/src/server/headless/tests/pane_graphics.rs
@@ -6,7 +6,8 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Durati
#[tokio::test]
async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once() {
- let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics");
+ let (mut server, _control_rx, client_rx, pane_id) =
+ retained_test_server_with_control(b"client shell graphics");
let client = server.clients.get_mut(&1).unwrap();
client.mode = ClientConnectionMode::ClientShell;
client.render_state =
@@ -38,6 +39,19 @@ async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once
};
assert_eq!(second.graphics.placements, first.graphics.placements);
assert!(second.graphics.assets.is_empty());
+
+ // A post-commit typed surface.set(true) resets only this viewer's delivery cache, so the
+ // already selected target receives the asset again without relying on graphics that may have
+ // arrived during the frozen source frame.
+ assert!(server.set_client_shell_surface_active(1, true).is_some());
+ server.render_and_stream();
+ let replay = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(replay) = replay else {
+ panic!("expected post-commit graphics replay");
+ };
+ assert_eq!(replay.graphics.placements, first.graphics.placements);
+ assert_eq!(replay.graphics.assets.len(), 1);
+ assert_eq!(replay.graphics.assets[0].data, vec![1, 2, 3, 4]);
}
#[tokio::test]
@@ -77,6 +91,237 @@ async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() {
}
#[tokio::test]
+async fn first_kitty_image_updates_retained_surface_without_full_redraw() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"text before image");
+ let client = server.clients.get_mut(&1).unwrap();
+ client.mode = ClientConnectionMode::ClientShell;
+ client.render_state =
+ crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame);
+ client.cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ };
+ server.render_and_stream();
+ let ServerMessage::PaneSurface(initial) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("expected text-only baseline");
+ };
+ assert!(initial.graphics.placements.is_empty());
+
+ write_shared_test_pane(
+ &mut server,
+ pane_id,
+ b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\",
+ );
+ assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id])));
+ let ServerMessage::PaneSurface(repainted) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("expected image update without a full redraw");
+ };
+ assert_eq!(repainted.graphics.placements.len(), 1);
+ assert_eq!(repainted.graphics.assets[0].data, [255, 0, 0, 255]);
+ assert_eq!(repainted.panes[0].inner_rect, initial.panes[0].inner_rect);
+
+ // Text changes while an image is visible must reuse its uploaded pixels.
+ write_shared_test_pane(&mut server, pane_id, b"\rupdated text");
+ assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id])));
+ let ServerMessage::PaneSurface(text_update) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("expected retained text and image scene");
+ };
+ assert_eq!(
+ text_update.graphics.placements,
+ repainted.graphics.placements
+ );
+ assert!(text_update.graphics.assets.is_empty());
+ assert!(frame_text(&text_update.frame).contains("updated text"));
+
+ write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=A\x1b\\");
+ assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id])));
+ let ServerMessage::PaneSurface(deleted) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("expected image removal");
+ };
+ assert!(deleted.graphics.placements.is_empty());
+ assert!(deleted.graphics.assets.is_empty());
+
+ write_shared_test_pane(&mut server, pane_id, b"\rtext only again");
+ assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id])));
+ assert!(matches!(
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100))),
+ ServerMessage::PaneSurfacePatch(_)
+ ));
+
+ // A full output queue must not mark unsent pixels as delivered.
+ fill_render_lane(&server);
+ write_shared_test_pane(
+ &mut server,
+ pane_id,
+ b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\",
+ );
+ assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id])));
+ assert!(server.clients[&1]
+ .render_state
+ .last_pane_surface()
+ .unwrap()
+ .graphics
+ .placements
+ .is_empty());
+ let _ = receive_render(&client_rx, Duration::from_millis(100));
+ server.render_and_stream();
+ let ServerMessage::PaneSurface(recovered) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("expected deferred graphics recovery");
+ };
+ assert_eq!(recovered.graphics.assets[0].data, [255, 0, 0, 255]);
+}
+
+#[tokio::test]
+async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"\x1b[?1049h");
+ let client = server.clients.get_mut(&1).unwrap();
+ client.mode = ClientConnectionMode::ClientShell;
+ client.cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ };
+ server.render_and_stream();
+ let _ = receive_render(&client_rx, Duration::from_millis(100));
+ let sources = HashSet::from([pane_id]);
+ // Yazi-style virtual placement: uploading the image and drawing its Unicode cell
+ // can happen in separate PTY reads, with no text dirty rows when upload completes.
+ for bytes in [
+ b"\x1b_Ga=t,f=32,t=d,i=1193046,s=1,v=1,q=2;/wAA/w".as_slice(),
+ b"==\x1b\\",
+ b"\x1b_Ga=p,U=1,i=1193046,c=1,r=1,q=2\x1b\\",
+ ] {
+ write_shared_test_pane(&mut server, pane_id, bytes);
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ for frame in client_rx.try_iter() {
+ if let ServerMessage::PaneSurface(surface) = read_server_message(frame) {
+ assert!(surface.graphics.placements.is_empty());
+ }
+ }
+ }
+ write_shared_test_pane(
+ &mut server,
+ pane_id,
+ "\x1b[2;3H\x1b[38;2;18;52;86m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes(),
+ );
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ let ServerMessage::PaneSurface(surface) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("virtual image must arrive without a tab switch");
+ };
+ assert_eq!(surface.graphics.placements.len(), 1);
+ assert_eq!(surface.graphics.assets[0].data, [255, 0, 0, 255]);
+ // Replacing pixels under the same image ID must invalidate the delivered asset.
+ write_shared_test_pane(
+ &mut server,
+ pane_id,
+ b"\x1b_Ga=t,f=32,t=d,i=1193046,s=1,v=1,q=2;AP8A/w==\x1b\\",
+ );
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ let ServerMessage::PaneSurface(replaced) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("updated image pixels must arrive");
+ };
+ assert_eq!(replaced.graphics.assets[0].data, [0, 255, 0, 255]);
+ assert_ne!(
+ surface.graphics.assets[0].key,
+ replaced.graphics.assets[0].key
+ );
+}
+
+#[tokio::test]
+#[ignore = "manual retained text/image scaling profile"]
+async fn render_scale_profile_retained_graphics() {
+ use ratatui::layout::Direction;
+ for retained in [false, true] {
+ for with_image in [false, true] {
+ for count in [1, 15] {
+ let (mut server, client_rx, root) = retained_test_server(b"populated terminal\r\n");
+ let mut pane_ids = vec![root];
+ for index in 1..count {
+ let workspace = &mut server.app.state.workspaces[0];
+ workspace.tabs[0]
+ .layout
+ .focus_pane(pane_ids[(index - 1) / 2]);
+ let id = workspace.test_split(if index % 2 == 0 {
+ Direction::Vertical
+ } else {
+ Direction::Horizontal
+ });
+ workspace.insert_test_runtime(
+ id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ 80,
+ 24,
+ b"populated terminal\r\n",
+ ),
+ );
+ pane_ids.push(id);
+ }
+ let client = server.clients.get_mut(&1).unwrap();
+ client.mode = ClientConnectionMode::ClientShell;
+ client.cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ };
+ if with_image {
+ write_shared_test_pane(
+ &mut server,
+ root,
+ b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\",
+ );
+ }
+ server.render_and_stream();
+ let _ = receive_render(&client_rx, Duration::from_millis(100));
+ let sources = pane_ids.iter().copied().collect();
+ let mut samples = Vec::new();
+ for sample in 0..110 {
+ for id in &pane_ids {
+ write_shared_test_pane(
+ &mut server,
+ *id,
+ format!("\x1b[H{sample:03}").as_bytes(),
+ );
+ }
+ let started = Instant::now();
+ if retained {
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ } else {
+ server.render_and_stream();
+ }
+ let elapsed = started.elapsed();
+ for frame in client_rx.try_iter() {
+ if let ServerMessage::PaneSurface(surface) = read_server_message(frame) {
+ assert!(surface.graphics.assets.is_empty());
+ }
+ }
+ if sample >= 10 {
+ samples.push(elapsed);
+ }
+ }
+ samples.sort_unstable();
+ println!(
+ "retained={retained} 80x24 panes={count} image={with_image} median_us={} p95_us={}",
+ samples[50].as_micros(),
+ samples[94].as_micros()
+ );
+ }
+ }
+ }
+}
+
+#[tokio::test]
async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_runtime() {
let (mut server, client_rx, _pane_id) =
retained_test_server(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\");
diff --git a/src/server/headless/tests/surface_interest.rs b/src/server/headless/tests/surface_interest.rs
new file mode 100644
index 00000000..591f6a8f
--- /dev/null
+++ b/src/server/headless/tests/surface_interest.rs
@@ -0,0 +1,725 @@
+use super::*;
+
+use std::sync::{Arc, Mutex};
+
+use crate::client::endpoint::{
+ ClientEndpointId, ClientEndpointStatus, EndpointNegotiation, EndpointRegistry,
+ EndpointTransport, ProfileId, SavedSshEndpoint,
+};
+
+#[derive(Clone)]
+struct CapturingEndpointTransport(Arc<Mutex<Vec<crate::protocol::ClientMessage>>>);
+
+impl EndpointTransport for CapturingEndpointTransport {
+ fn send(&mut self, message: &crate::protocol::ClientMessage) -> std::io::Result<()> {
+ self.0.lock().unwrap().push(message.clone());
+ Ok(())
+ }
+}
+
+fn lifecycle_negotiation() -> EndpointNegotiation {
+ EndpointNegotiation::new(
+ vec!["client_shell.surface.set".into()],
+ vec![
+ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(),
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(),
+ ],
+ )
+}
+
+fn lifecycle_resize() -> crate::protocol::ClientMessage {
+ crate::protocol::ClientMessage::ClientShellResize {
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 },
+ pixel_mouse: false,
+ }
+}
+
+#[tokio::test]
+async fn metadata_only_shell_is_isolated_until_surface_activation() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"");
+ let pane_id = server.app.session_snapshot().focused_pane_id.unwrap();
+ let workspace_id = server.app.session_snapshot().focused_workspace_id.unwrap();
+ let original_size = server.effective_size;
+ let (writer, control_rx, render_rx) = test_client_writer();
+ let client_id = 52;
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id,
+ surface_cols: 101,
+ surface_rows: 37,
+ cell_width_px: 9,
+ cell_height_px: 18,
+ pixel_mouse: true,
+ direct_graphics: false,
+ endpoint_keybindings: true,
+ mouse_capture: true,
+ surface_active: false,
+ writer,
+ })
+ );
+ assert!(matches!(
+ read_server_message(control_rx.recv().expect("metadata snapshot")),
+ ServerMessage::EndpointControl { .. }
+ ));
+ assert_eq!(server.foreground_client_id, None);
+ assert_eq!(server.effective_size, original_size);
+
+ server.render_and_stream();
+ assert!(render_rx.try_recv().is_err());
+ assert!(server.clients[&client_id]
+ .render_state
+ .last_pane_surface()
+ .is_none());
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id,
+ pane_id,
+ events: vec![crate::protocol::ClientPaneInputEvent::Paste(
+ "blocked".into()
+ )],
+ })
+ );
+ assert!(input_rx.try_recv().is_err());
+
+ let boot_id = server.client_shell_boot_id.clone();
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellEndpointRequest {
+ client_id,
+ boot_id: boot_id.clone(),
+ request: Box::new(api::schema::Request {
+ id: "inactive-mutation".into(),
+ method: api::schema::Method::WorkspaceFocus(api::schema::WorkspaceTarget {
+ workspace_id,
+ }),
+ }),
+ })
+ );
+ let ServerMessage::ClientShellEndpointResponseChunk { data, .. } =
+ read_server_message(control_rx.recv().expect("inactive mutation response"))
+ else {
+ panic!("expected endpoint response");
+ };
+ let error = serde_json::from_slice::<api::schema::ErrorResponse>(&data).unwrap();
+ assert_eq!(error.error.code, "surface_inactive");
+
+ assert!(
+ server.send_to_client_shells(ServerMessage::SemanticNotification(
+ crate::protocol::SemanticNotification {
+ kind: crate::protocol::SemanticNotificationKind::Custom,
+ title: "metadata event".into(),
+ body: None,
+ sound: None,
+ agent: None,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: None,
+ position: None,
+ },
+ ))
+ );
+ assert!(matches!(
+ read_server_message(control_rx.recv().expect("metadata notification")),
+ ServerMessage::SemanticNotification(_)
+ ));
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellEndpointRequest {
+ client_id,
+ boot_id: boot_id.clone(),
+ request: Box::new(api::schema::Request {
+ id: "activate-surface".into(),
+ method: api::schema::Method::ClientShellSurfaceSet(
+ api::schema::ClientShellSurfaceSetParams { active: true },
+ ),
+ }),
+ })
+ );
+ let ServerMessage::ClientShellEndpointResponseChunk { data, .. } =
+ read_server_message(control_rx.recv().expect("surface activation response"))
+ else {
+ panic!("expected typed surface activation response");
+ };
+ let activation_ack = serde_json::from_slice::<api::schema::SuccessResponse>(&data).unwrap();
+ let api::schema::ResponseResult::ClientShellSurfaceSet {
+ active: true,
+ projection_revision: activation_floor,
+ } = activation_ack.result
+ else {
+ panic!("expected typed surface activation result");
+ };
+ assert_eq!(server.foreground_client_id, Some(client_id));
+ assert_eq!(server.effective_size, (101, 37));
+
+ server.render_and_stream();
+ let ServerMessage::PaneSurface(surface) =
+ read_server_message(render_rx.recv().expect("activated surface"))
+ else {
+ panic!("expected pane surface");
+ };
+ assert_eq!((surface.frame.width, surface.frame.height), (101, 37));
+ assert!(surface.projection_revision >= activation_floor);
+ assert_eq!(surface.surface_revision, 1);
+ server
+ .clients
+ .get_mut(&client_id)
+ .expect("surface client")
+ .shell_endpoint_command_in_flight = true;
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellEndpointRequest {
+ client_id,
+ boot_id: boot_id.clone(),
+ request: Box::new(api::schema::Request {
+ id: "deactivate-surface".into(),
+ method: api::schema::Method::ClientShellSurfaceSet(
+ api::schema::ClientShellSurfaceSetParams { active: false },
+ ),
+ }),
+ })
+ );
+ let _ = control_rx.recv().expect("surface deactivation response");
+ assert!(server.clients.contains_key(&client_id));
+ let (_, runtime_pane_id) = server.app.parse_pane_id(&surface.panes[0].pane_id).unwrap();
+ server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, runtime_pane_id)
+ .unwrap()
+ .test_process_pty_bytes(b"REACTIVATED");
+ assert!(
+ server.render_retained_pane_surface_and_stream(&std::collections::HashSet::from([
+ runtime_pane_id
+ ]))
+ );
+ assert!(render_rx.try_recv().is_err());
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellEndpointRequest {
+ client_id,
+ boot_id,
+ request: Box::new(api::schema::Request {
+ id: "reactivate-surface".into(),
+ method: api::schema::Method::ClientShellSurfaceSet(
+ api::schema::ClientShellSurfaceSetParams { active: true },
+ ),
+ }),
+ })
+ );
+ let data = loop {
+ let message =
+ read_server_message(control_rx.recv().expect("surface reactivation response"));
+ match message {
+ ServerMessage::ClientShellEndpointResponseChunk {
+ request_id, data, ..
+ } if request_id == "reactivate-surface" => break data,
+ ServerMessage::EndpointControl { .. }
+ | ServerMessage::ClientShellEndpointResponseChunk { .. } => continue,
+ other => panic!("unexpected surface reactivation message: {other:?}"),
+ }
+ };
+ let reactivation_ack = serde_json::from_slice::<api::schema::SuccessResponse>(&data).unwrap();
+ let api::schema::ResponseResult::ClientShellSurfaceSet {
+ active: true,
+ projection_revision: reactivation_floor,
+ } = reactivation_ack.result
+ else {
+ panic!("expected typed surface reactivation result");
+ };
+ assert!(reactivation_floor > activation_floor);
+ server.render_and_stream();
+ let ServerMessage::PaneSurface(surface) =
+ read_server_message(render_rx.recv().expect("reactivated surface"))
+ else {
+ panic!("expected replacement pane surface");
+ };
+ assert!(frame_text(&surface.frame).contains("REACTIVATED"));
+ assert!(surface.projection_revision >= reactivation_floor);
+ assert_eq!(surface.surface_revision, 2);
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn presentation_sync_epoch_replays_modes_and_title() {
+ let mut server = test_headless_server();
+ let (writer, control_rx, _render_rx) = test_client_writer();
+ let client_id = 63;
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id,
+ surface_cols: 80,
+ surface_rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: true,
+ mouse_capture: true,
+ surface_active: true,
+ writer,
+ })
+ );
+ let _ = control_rx.recv().expect("initial snapshot");
+ server.api_window_title = Some("target title".into());
+ {
+ let client = server.clients.get_mut(&client_id).unwrap();
+ client.host_mouse_capture_active = Some(false);
+ client.host_sgr_pixels_active = Some(false);
+ client.host_keyboard_report_all_active = Some(false);
+ }
+
+ let boot_id = server.client_shell_boot_id.clone();
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellEndpointRequest {
+ client_id,
+ boot_id,
+ request: Box::new(api::schema::Request {
+ id: "post-commit-reassert".into(),
+ method: api::schema::Method::ClientShellSurfaceSet(
+ api::schema::ClientShellSurfaceSetParams { active: true },
+ ),
+ }),
+ })
+ );
+ let _ = control_rx
+ .recv()
+ .expect("typed surface reassertion acknowledgement");
+ server.stream_host_mouse_capture_mode();
+ server.stream_direct_terminal_keyboard_mode();
+ server.sync_window_title();
+ assert_eq!(
+ server.clients[&client_id].host_mouse_capture_active,
+ Some(true),
+ "the target mode is sent after, not during, the frozen handoff"
+ );
+ assert_eq!(
+ server.clients[&client_id].host_keyboard_report_all_active,
+ Some(false)
+ );
+ let messages = (0..3)
+ .map(|_| read_server_message(control_rx.recv().expect("reassertion effect")))
+ .collect::<Vec<_>>();
+ assert!(messages
+ .iter()
+ .any(|message| matches!(message, ServerMessage::MouseCapture { enabled: true, .. })));
+ assert!(messages.iter().any(|message| matches!(
+ message,
+ ServerMessage::ClientShellKeyboardReportAll { enabled: false }
+ )));
+ assert!(messages.iter().any(|message| matches!(
+ message,
+ ServerMessage::WindowTitle { title: Some(title) } if title == "target title"
+ )));
+ shutdown_test_runtimes(&mut server);
+}
+
+fn dispatch_lifecycle_messages(
+ server: &mut HeadlessServer,
+ client_id: u64,
+ messages: Vec<crate::protocol::ClientMessage>,
+) {
+ for message in messages {
+ let event = match message {
+ crate::protocol::ClientMessage::ClientShellResize {
+ cell_width_px,
+ cell_height_px,
+ surface_size,
+ pixel_mouse,
+ } => ServerEvent::ClientShellResize {
+ client_id,
+ cell_width_px,
+ cell_height_px,
+ surface_cols: surface_size.cols,
+ surface_rows: surface_size.rows,
+ pixel_mouse,
+ },
+ crate::protocol::ClientMessage::ClientShellFocus { focused } => {
+ ServerEvent::ClientShellFocus { client_id, focused }
+ }
+ crate::protocol::ClientMessage::ClientShellEndpointRequest { boot_id, request } => {
+ ServerEvent::ClientShellEndpointRequest {
+ client_id,
+ boot_id,
+ request: Box::new(serde_json::from_str(&request).unwrap()),
+ }
+ }
+ other => panic!("unhandled lifecycle message: {other:?}"),
+ };
+ server.handle_server_event(event);
+ }
+}
+
+/// A real two-server/client lifecycle harness. Both source-off and target-on traverse the
+/// production HeadlessServer endpoint request path; the client test only routes its emitted wire
+/// messages and never authors an acknowledgement, snapshot, or surface response.
+#[tokio::test]
+async fn two_headless_servers_drive_atomic_endpoint_handoff() {
+ let mut source_server = test_headless_server();
+ let _source_input = install_focused_test_runtime(&mut source_server, b"local source");
+ let (source_writer, source_control, _source_render) = test_client_writer();
+ let source_client_id = 78;
+ assert!(
+ source_server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id: source_client_id,
+ surface_cols: 80,
+ surface_rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: true,
+ mouse_capture: true,
+ surface_active: true,
+ writer: source_writer,
+ })
+ );
+ let source_snapshot = client_shell_snapshot(read_server_message(
+ source_control
+ .recv()
+ .expect("real source metadata snapshot"),
+ ));
+
+ let mut target_server = test_headless_server();
+ let _target_input = install_focused_test_runtime(&mut target_server, b"remote target");
+ let (target_writer, target_control, target_render) = test_client_writer();
+ let target_client_id = 79;
+ assert!(
+ target_server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id: target_client_id,
+ surface_cols: 80,
+ surface_rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: true,
+ mouse_capture: true,
+ surface_active: false,
+ writer: target_writer,
+ })
+ );
+ let remote_snapshot = client_shell_snapshot(read_server_message(
+ target_control
+ .recv()
+ .expect("real target metadata snapshot"),
+ ));
+
+ let profile = SavedSshEndpoint {
+ id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Remote".into(),
+ target: "dev@example.com".into(),
+ session: "main".into(),
+ enabled: true,
+ };
+ let target_id = ClientEndpointId::Ssh(profile.id.clone());
+ let mut shell = crate::client::ClientShellState::new(
+ crate::client::ClientShellConfig::from_config(&crate::config::Config::default()),
+ );
+ shell.set_endpoint_catalog(&[profile]);
+ shell.set_snapshot(source_snapshot);
+ shell.set_endpoint_status(&target_id, ClientEndpointStatus::Online);
+ shell.set_endpoint_snapshot(&target_id, remote_snapshot);
+
+ let source_sent = Arc::new(Mutex::new(Vec::new()));
+ let target_sent = Arc::new(Mutex::new(Vec::new()));
+ let mut endpoints = EndpointRegistry::new(
+ CapturingEndpointTransport(source_sent.clone()),
+ 1,
+ lifecycle_negotiation(),
+ );
+ endpoints.insert(
+ target_id.clone(),
+ CapturingEndpointTransport(target_sent.clone()),
+ 7,
+ lifecycle_negotiation(),
+ false,
+ );
+ let mut activation = crate::client::endpoint::PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target_id.clone(),
+ None,
+ lifecycle_resize(),
+ 41,
+ std::time::Instant::now(),
+ )
+ .unwrap();
+
+ // Route the source-off-first client messages through a second real HeadlessServer. Its
+ // typed response is the only source acknowledgement supplied to the activation state.
+ let mut source_release_request_id = None;
+ for message in std::mem::take(&mut *source_sent.lock().unwrap()) {
+ match message {
+ crate::protocol::ClientMessage::ClientShellFocus { focused } => {
+ assert!(
+ source_server.handle_server_event(ServerEvent::ClientShellFocus {
+ client_id: source_client_id,
+ focused,
+ })
+ );
+ }
+ crate::protocol::ClientMessage::ClientShellEndpointRequest { boot_id, request } => {
+ let request = serde_json::from_str::<api::schema::Request>(&request).unwrap();
+ if matches!(
+ request.method,
+ api::schema::Method::ClientShellSurfaceSet(
+ api::schema::ClientShellSurfaceSetParams { active: false }
+ )
+ ) {
+ source_release_request_id = Some(request.id.clone());
+ }
+ assert!(source_server.handle_server_event(
+ ServerEvent::ClientShellEndpointRequest {
+ client_id: source_client_id,
+ boot_id,
+ request: Box::new(request),
+ }
+ ));
+ }
+ other => panic!("unexpected source lifecycle message: {other:?}"),
+ }
+ }
+ let source_release_request_id = source_release_request_id.expect("client source-off request");
+ let source_release_data = loop {
+ let message = read_server_message(source_control.recv().expect("source typed release ack"));
+ match message {
+ ServerMessage::ClientShellEndpointResponseChunk {
+ request_id, data, ..
+ } if request_id == source_release_request_id => break data,
+ ServerMessage::EndpointControl { .. }
+ | ServerMessage::MouseCapture { .. }
+ | ServerMessage::ClientShellKeyboardReportAll { .. }
+ | ServerMessage::WindowTitle { .. }
+ | ServerMessage::ClientShellEndpointResponseChunk { .. } => continue,
+ other => panic!("unexpected source release message: {other:?}"),
+ }
+ };
+ assert_eq!(
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ &source_release_request_id,
+ &source_release_data,
+ &mut endpoints,
+ ),
+ crate::client::endpoint::SurfaceActivationProgress::Pending
+ );
+
+ dispatch_lifecycle_messages(
+ &mut target_server,
+ target_client_id,
+ std::mem::take(&mut *target_sent.lock().unwrap()),
+ );
+ assert_eq!(
+ target_server.clients[&target_client_id].outer_terminal_focus,
+ Some(true)
+ );
+ assert_eq!(
+ source_server.clients[&source_client_id].outer_terminal_focus,
+ Some(false)
+ );
+ let ServerMessage::ClientShellEndpointResponseChunk {
+ request_id, data, ..
+ } = read_server_message(target_control.recv().expect("target typed activation ack"))
+ else {
+ panic!("expected target activation acknowledgement");
+ };
+ assert_eq!(
+ activation.receive_response(&target_id, 7, &request_id, &data, &mut endpoints),
+ crate::client::endpoint::SurfaceActivationProgress::Pending
+ );
+
+ target_server.render_and_stream();
+ let coherent_snapshot = client_shell_snapshot(read_server_message(
+ target_control.recv().expect("target replacement snapshot"),
+ ));
+ let snapshot_progress = activation.receive_snapshot(&target_id, 7, &coherent_snapshot);
+ shell.set_endpoint_snapshot(&target_id, coherent_snapshot);
+ assert_eq!(
+ snapshot_progress,
+ crate::client::endpoint::SurfaceActivationProgress::Pending
+ );
+ let ServerMessage::PaneSurface(coherent_surface) =
+ read_server_message(target_render.recv().expect("target replacement surface"))
+ else {
+ panic!("expected target pane surface");
+ };
+ assert_eq!(
+ activation.receive_surface(&target_id, 7, coherent_surface),
+ crate::client::endpoint::SurfaceActivationProgress::Ready
+ );
+
+ assert!(matches!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(crate::client::endpoint::ActivationCompletion::AwaitingPresentationSync {
+ endpoint,
+ ..
+ }) if endpoint == target_id
+ ));
+ assert!(
+ !endpoints.active_surface_available(),
+ "target input remains fenced while presentation effects resynchronize"
+ );
+
+ let sync_request = target_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|message| match message {
+ crate::protocol::ClientMessage::ClientShellEndpointRequest { boot_id, request } => {
+ let request = serde_json::from_str::<api::schema::Request>(request).ok()?;
+ request
+ .id
+ .ends_with(":presentation-sync")
+ .then(|| (boot_id.clone(), Box::new(request)))
+ }
+ _ => None,
+ })
+ .expect("client presentation synchronization request");
+ assert!(
+ target_server.handle_server_event(ServerEvent::ClientShellEndpointRequest {
+ client_id: target_client_id,
+ boot_id: sync_request.0,
+ request: sync_request.1,
+ })
+ );
+ let (sync_request_id, sync_data) = loop {
+ let message = read_server_message(target_control.recv().expect("presentation sync ack"));
+ if let ServerMessage::ClientShellEndpointResponseChunk {
+ request_id, data, ..
+ } = message
+ {
+ if request_id.ends_with(":presentation-sync") {
+ break (request_id, data);
+ }
+ }
+ };
+ assert_eq!(
+ activation.receive_response(&target_id, 7, &sync_request_id, &sync_data, &mut endpoints,),
+ crate::client::endpoint::SurfaceActivationProgress::Pending
+ );
+ target_server.render_and_stream();
+ let sync_snapshot = loop {
+ let message =
+ read_server_message(target_control.recv().expect("presentation sync snapshot"));
+ if let ServerMessage::EndpointControl { kind, data } = message {
+ if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND {
+ break serde_json::from_str::<crate::protocol::ClientShellSnapshot>(&data).unwrap();
+ }
+ }
+ };
+ let sync_progress = activation.receive_snapshot(&target_id, 7, &sync_snapshot);
+ shell.set_endpoint_snapshot_for_generation(&target_id, 7, Box::new(sync_snapshot));
+ assert_eq!(
+ sync_progress,
+ crate::client::endpoint::SurfaceActivationProgress::Pending
+ );
+ let ServerMessage::PaneSurface(sync_surface) =
+ read_server_message(target_render.recv().expect("presentation sync surface"))
+ else {
+ panic!("expected synchronized target surface");
+ };
+ assert_eq!(
+ activation.receive_surface(&target_id, 7, sync_surface),
+ crate::client::endpoint::SurfaceActivationProgress::Ready
+ );
+ assert_eq!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(crate::client::endpoint::ActivationCompletion::AwaitingPresentationEffects)
+ );
+ let effects_token = target_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|message| match message {
+ crate::protocol::ClientMessage::EndpointControl { kind, data }
+ if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND =>
+ {
+ Some(data.clone())
+ }
+ _ => None,
+ })
+ .expect("client presentation effects fence");
+ assert!(
+ target_server.handle_server_event(ServerEvent::ClientShellPresentationSync {
+ client_id: target_client_id,
+ token: effects_token.clone(),
+ })
+ );
+ let mut replayed_mouse = false;
+ let mut replayed_keyboard = false;
+ loop {
+ match read_server_message(target_control.recv().expect("presentation effect or fence")) {
+ ServerMessage::MouseCapture { .. } => replayed_mouse = true,
+ ServerMessage::ClientShellKeyboardReportAll { .. } => replayed_keyboard = true,
+ ServerMessage::EndpointControl { kind, data }
+ if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND =>
+ {
+ assert_eq!(data, effects_token);
+ assert_eq!(
+ activation.receive_presentation_effects_ready(&target_id, 7, &data),
+ crate::client::endpoint::SurfaceActivationProgress::Ready
+ );
+ break;
+ }
+ ServerMessage::WindowTitle { .. } => {}
+ other => panic!("unexpected presentation fence message: {other:?}"),
+ }
+ }
+ assert!(replayed_mouse);
+ assert!(replayed_keyboard);
+ assert_eq!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(crate::client::endpoint::ActivationCompletion::Activated)
+ );
+ endpoints.unfreeze_input();
+ assert_eq!(endpoints.active_id(), &target_id);
+ assert!(endpoints.active_surface_available());
+ assert!(shell.endpoint_is_active(&target_id));
+
+ target_sent.lock().unwrap().clear();
+ let mut returning = crate::client::endpoint::PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ ClientEndpointId::Local,
+ None,
+ lifecycle_resize(),
+ 42,
+ std::time::Instant::now(),
+ )
+ .unwrap();
+ dispatch_lifecycle_messages(
+ &mut target_server,
+ target_client_id,
+ std::mem::take(&mut *target_sent.lock().unwrap()),
+ );
+ loop {
+ if let ServerMessage::ClientShellEndpointResponseChunk {
+ request_id, data, ..
+ } = read_server_message(target_control.recv().unwrap())
+ {
+ returning.receive_response(&target_id, 7, &request_id, &data, &mut endpoints);
+ break;
+ }
+ }
+ dispatch_lifecycle_messages(
+ &mut source_server,
+ source_client_id,
+ std::mem::take(&mut *source_sent.lock().unwrap()),
+ );
+ assert_eq!(
+ source_server.clients[&source_client_id].outer_terminal_focus,
+ Some(true),
+ "returning to Local must restore focus without a host focus event"
+ );
+ assert_eq!(
+ target_server.clients[&target_client_id].outer_terminal_focus,
+ Some(false)
+ );
+ shutdown_test_runtimes(&mut source_server);
+ shutdown_test_runtimes(&mut target_server);
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 8b155df1..5d249587 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -3,6 +3,7 @@ pub mod autodetect;
#[cfg(unix)]
pub(crate) mod client_accept;
pub(crate) mod client_commands;
+mod client_endpoint_control;
pub(crate) mod client_shell;
pub(crate) mod client_shell_graphics;
pub(crate) mod client_transport;
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index 4fa80326..57d45b92 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -423,6 +423,10 @@ impl TerminalRuntime {
self.0.visible_hyperlinks(area)
}
+ pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool {
+ self.0.kitty_graphics_may_have_placements()
+ }
+
pub fn kitty_image_placements_with_data_filter<F>(
&self,
needs_data: F,
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 3da1e9f9..178bdb1f 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -129,6 +129,7 @@ pub(crate) fn resolved_token_spans(
.iter()
.map(|token| match &token.kind {
ResolvedTokenKind::StateText(text)
+ | ResolvedTokenKind::Machine(text)
| ResolvedTokenKind::Workspace(text)
| ResolvedTokenKind::Tab(text)
| ResolvedTokenKind::Pane(text)
@@ -236,7 +237,8 @@ pub(crate) fn resolved_token_spans(
truncate_end(text, budgets[index]),
apply_token_style(workspace_style, token.style),
)),
- ResolvedTokenKind::Tab(text)
+ ResolvedTokenKind::Machine(text)
+ | ResolvedTokenKind::Tab(text)
| ResolvedTokenKind::Pane(text)
| ResolvedTokenKind::Agent(text)
| ResolvedTokenKind::Branch(text) => spans.push(Span::styled(
diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs
index eaec154b..911c87f9 100644
--- a/src/ui/sidebar/tokens.rs
+++ b/src/ui/sidebar/tokens.rs
@@ -13,6 +13,7 @@ pub(crate) struct ResolvedToken {
pub(crate) enum ResolvedTokenKind {
StateIcon,
StateText(String),
+ Machine(String),
Workspace(String),
Tab(String),
Pane(String),
@@ -35,6 +36,7 @@ impl ResolvedToken {
}
pub(crate) struct AgentTokenContext<'a> {
+ pub(crate) machine: Option<&'a str>,
pub(crate) workspace: &'a str,
pub(crate) tab: Option<&'a str>,
pub(crate) pane: Option<&'a str>,
@@ -63,6 +65,9 @@ pub(crate) fn agent_rows(
AgentSidebarToken::StateText => {
Some(ResolvedTokenKind::StateText(state_text.to_string()))
}
+ AgentSidebarToken::Machine => context
+ .machine
+ .map(|value| ResolvedTokenKind::Machine(value.to_string())),
AgentSidebarToken::Workspace => {
Some(ResolvedTokenKind::Workspace(context.workspace.to_string()))
}
@@ -190,6 +195,7 @@ mod tests {
fn context(entry: &Entry) -> AgentTokenContext<'_> {
AgentTokenContext {
+ machine: None,
workspace: &entry.workspace,
tab: entry.tab.as_deref(),
pane: entry.pane.as_deref(),
@@ -232,6 +238,35 @@ mod tests {
}
#[test]
+ fn machine_token_only_resolves_for_multi_machine_rows() {
+ let entry = entry();
+ let config = AgentsSidebarConfig {
+ rows: vec![vec![
+ AgentSidebarToken::Machine,
+ AgentSidebarToken::Workspace,
+ ]],
+ ..Default::default()
+ };
+
+ assert_eq!(
+ agent_rows(&config, context(&entry), "working"),
+ vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace(
+ "repo".into()
+ ))]]
+ );
+
+ let mut remote_context = context(&entry);
+ remote_context.machine = Some("Build");
+ assert_eq!(
+ agent_rows(&config, remote_context, "working"),
+ vec![vec![
+ ResolvedToken::unstyled(ResolvedTokenKind::Machine("Build".into())),
+ ResolvedToken::unstyled(ResolvedTokenKind::Workspace("repo".into())),
+ ]]
+ );
+ }
+
+ #[test]
fn state_text_and_arbitrary_values_are_independent_tokens() {
let mut entry = entry();
entry
diff --git a/src/update.rs b/src/update.rs
index ae307bc7..f836fb80 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -2852,6 +2852,8 @@ mod tests {
endpoint_protocol_generation: Some(
crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
),
+ surface_interest: true,
+ health_check: true,
}),
};
let missing_baseline = crate::api::RuntimeStatus {
@@ -2924,6 +2926,8 @@ mod tests {
endpoint_protocol_generation: Some(
crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
),
+ surface_interest: true,
+ health_check: true,
}),
},
};
@@ -3180,6 +3184,8 @@ mod tests {
endpoint_protocol_generation: Some(
crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
),
+ surface_interest: true,
+ health_check: true,
}),
},
};