aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/headless')
-rw-r--r--src/server/headless/bootstrap.rs218
-rw-r--r--src/server/headless/lifecycle.rs399
-rw-r--r--src/server/headless/notifications.rs674
-rw-r--r--src/server/headless/pane_graphics.rs284
-rw-r--r--src/server/headless/render.rs606
-rw-r--r--src/server/headless/retained_surface.rs613
-rw-r--r--src/server/headless/tests/mod.rs5025
-rw-r--r--src/server/headless/tests/pane_graphics.rs827
8 files changed, 7936 insertions, 710 deletions
diff --git a/src/server/headless/bootstrap.rs b/src/server/headless/bootstrap.rs
new file mode 100644
index 00000000..638b1b2d
--- /dev/null
+++ b/src/server/headless/bootstrap.rs
@@ -0,0 +1,218 @@
+use super::*;
+
+/// Run the headless server. This is the entry point called from main.rs.
+pub fn run_server() -> io::Result<()> {
+ init_logging();
+ crate::platform::raise_server_nofile_limit();
+
+ let args: Vec<String> = std::env::args().collect();
+ if args.get(2).map(String::as_str) == Some("--handoff-import") {
+ let socket_path = args
+ .get(3)
+ .map(PathBuf::from)
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?;
+ let token = args
+ .get(4)
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?;
+ return run_handoff_import_server(&socket_path, token);
+ }
+
+ let loaded_config = config::Config::load();
+ let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let event_hub = api::EventHub::default();
+ let should_quit = Arc::new(AtomicBool::new(false));
+
+ // Start the JSON API socket server.
+ let _api_server = match api::start_server_with_stop_control(
+ api_tx.clone(),
+ event_hub.clone(),
+ should_quit.clone(),
+ ) {
+ Ok(server) => server,
+ Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
+ eprintln!("error: herdr server is already running");
+ eprintln!("api socket: {}", api::socket_path().display());
+ std::process::exit(1);
+ }
+ Err(err) => return Err(err),
+ };
+
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .map_err(io::Error::other)?;
+
+ let result = rt.block_on(async {
+ // Create the App (with AppState, event channels, etc.).
+ let mut app = app::App::new(
+ &loaded_config.config,
+ app::AppPolicy::PRODUCTION,
+ config::config_diagnostic_summary(&loaded_config.diagnostics),
+ api_rx,
+ event_hub,
+ );
+ seed_startup_workspace_if_empty(&mut app);
+
+ // Create the headless server.
+ let mut server = match HeadlessServer::new(
+ app,
+ &loaded_config.diagnostics,
+ Some(api_tx.clone()),
+ Some(_api_server),
+ should_quit,
+ ) {
+ Ok(server) => server,
+ Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
+ eprintln!("error: herdr server is already running");
+ eprintln!("client socket: {}", client_socket_path().display());
+ std::process::exit(1);
+ }
+ Err(err) => return Err(err),
+ };
+
+ info!(
+ api_socket = %api::socket_path().display(),
+ client_socket = %client_socket_path().display(),
+ "herdr server started"
+ );
+ print_ready_message(&api::socket_path(), &client_socket_path());
+ server.app.run_plugin_startup_hooks();
+
+ server.run().await
+ });
+
+ rt.shutdown_timeout(Duration::from_millis(100));
+ crate::logging::shutdown("server");
+ result
+}
+
+fn seed_startup_workspace_if_empty(app: &mut app::App) {
+ let Some(cwd) = take_startup_cwd() else {
+ return;
+ };
+
+ if !app.state.workspaces.is_empty() {
+ info!(
+ cwd = %cwd.display(),
+ "restored session already has workspaces; ignoring startup cwd"
+ );
+ return;
+ }
+
+ match app.create_workspace_with_options(cwd.clone(), true) {
+ Ok(_) => {
+ info!(cwd = %cwd.display(), "created startup workspace");
+ }
+ Err(err) => {
+ warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace");
+ app.state.mode = app::Mode::Navigate;
+ }
+ }
+}
+
+fn take_startup_cwd() -> Option<PathBuf> {
+ let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?;
+ std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR);
+ (!cwd.is_empty()).then(|| PathBuf::from(cwd))
+}
+
+#[cfg(unix)]
+fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> {
+ let loaded_config = config::Config::load();
+ let mut received = crate::server::handoff::receive(socket_path, token)?;
+ crate::server::handoff::log_import_result(received.manifest.panes.len());
+
+ let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let event_hub = api::EventHub::default();
+ let should_quit = Arc::new(AtomicBool::new(false));
+
+ let mut imports = HashMap::new();
+ for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) {
+ let pane_id = pane.pane_id;
+ imports.insert(
+ pane_id,
+ crate::handoff_runtime::ImportedHandoffRuntime {
+ master_fd: fd,
+ state: pane,
+ },
+ );
+ }
+
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .map_err(io::Error::other)?;
+
+ let result = rt.block_on(async {
+ let app = app::App::new_from_handoff(
+ &loaded_config.config,
+ config::config_diagnostic_summary(&loaded_config.diagnostics),
+ api_rx,
+ event_hub.clone(),
+ &received.manifest.snapshot,
+ &mut imports,
+ )?;
+ crate::server::handoff::report_restored(&mut received.stream)?;
+ if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") {
+ return Err(io::Error::other(
+ "test handoff import failure after restored",
+ ));
+ }
+ wait_for_old_public_sockets_to_close(Duration::from_secs(5))?;
+
+ let api_server = api::start_server_with_stop_control(
+ api_tx.clone(),
+ event_hub.clone(),
+ should_quit.clone(),
+ )?;
+ let mut server = HeadlessServer::new(
+ app,
+ &loaded_config.diagnostics,
+ Some(api_tx.clone()),
+ Some(api_server),
+ should_quit,
+ )?;
+ // Carried across before any client attaches, so the first title sent is
+ // the override rather than the configured one it replaced.
+ server.api_window_title = received.manifest.api_window_title.take();
+ crate::server::handoff::report_ready(&mut received.stream)?;
+ crate::server::handoff::wait_committed(&mut received.stream)?;
+ server.app.assume_handoff_ownership();
+ server.app.unpause_handoff_readers();
+ server.pending_handoff_repaint_nudge = true;
+ if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) {
+ warn!(err = %err, "failed to report handoff ownership; continuing as owner");
+ }
+ info!("handoff import server started");
+ print_ready_message(&api::socket_path(), &client_socket_path());
+ server.app.run_plugin_startup_hooks();
+ server.run().await
+ });
+
+ rt.shutdown_timeout(Duration::from_millis(100));
+ crate::logging::shutdown("server");
+ result
+}
+
+#[cfg(not(unix))]
+fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> {
+ Err(io::Error::other("live handoff is only supported on Unix"))
+}
+
+fn print_ready_message(api_socket: &Path, client_socket: &Path) {
+ eprintln!("herdr server running; you can use any herdr CLI command in another terminal.");
+ eprintln!("api socket: {}", api_socket.display());
+ eprintln!("client socket: {}", client_socket.display());
+ eprintln!(
+ "logs: {}",
+ crate::session::data_dir()
+ .join("herdr-server.log")
+ .display()
+ );
+ eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`.");
+}
+
+/// Initialize logging for the server process.
+fn init_logging() {
+ crate::logging::init_file_logging("herdr-server.log");
+}
diff --git a/src/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs
new file mode 100644
index 00000000..ab1448c3
--- /dev/null
+++ b/src/server/headless/lifecycle.rs
@@ -0,0 +1,399 @@
+use super::*;
+
+const LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(6);
+
+pub(super) fn wait_for_live_handoff_response_write(
+ response_write_complete: Option<std::sync::mpsc::Receiver<()>>,
+) {
+ let Some(response_write_complete) = response_write_complete else {
+ return;
+ };
+
+ match response_write_complete.recv_timeout(LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT) {
+ Ok(()) => {}
+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
+ warn!("timed out waiting for live handoff response write; old server exiting");
+ }
+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
+ warn!("live handoff response writer disconnected; old server exiting");
+ }
+ }
+}
+
+impl HeadlessServer {
+ #[cfg(unix)]
+ pub(super) fn perform_live_handoff(
+ &mut self,
+ params: crate::api::schema::ServerLiveHandoffParams,
+ ) -> io::Result<()> {
+ info!("starting live handoff");
+ let import_exe = params.import_exe.as_deref().map(std::path::PathBuf::from);
+ let socket_path = crate::server::handoff::handoff_socket_path();
+ let token = format!(
+ "{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos()
+ );
+ let listener = match crate::server::handoff::bind_listener(&socket_path) {
+ Ok(listener) => listener,
+ Err(err) => {
+ self.handoff_in_progress = false;
+ return Err(err);
+ }
+ };
+
+ let mut pane_by_terminal = HashMap::new();
+ for ws in &self.app.state.workspaces {
+ for tab in &ws.tabs {
+ for (pane_id, pane) in &tab.panes {
+ pane_by_terminal.insert(pane.attached_terminal_id.clone(), pane_id.raw());
+ }
+ }
+ }
+ if pane_by_terminal.len() > crate::server::handoff::MAX_FDS_PER_HANDOFF {
+ let _ = std::fs::remove_file(&socket_path);
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!(
+ "live handoff supports at most {} panes in one update; close panes or restart herdr normally",
+ crate::server::handoff::MAX_FDS_PER_HANDOFF
+ ),
+ ));
+ }
+
+ self.handoff_in_progress = true;
+ self.disconnect_all_clients_for_handoff();
+ let _ = reject_pending_client_connections(&self.client_listener);
+
+ let mut paused_terminal_ids = Vec::new();
+ for terminal_id in pane_by_terminal.keys() {
+ if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) {
+ if let Err(err) = runtime.pause_handoff_reader(Duration::from_secs(2)) {
+ self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
+ return Err(err);
+ }
+ paused_terminal_ids.push(terminal_id.clone());
+ }
+ }
+
+ let snapshot = crate::persist::capture(
+ &self.app.state.workspaces,
+ &self.app.state.terminals,
+ &self.app.terminal_runtimes,
+ self.app.state.active,
+ self.app.state.selected,
+ );
+
+ let mut handoff_entries = Vec::new();
+ for (terminal_id, runtime) in self.app.terminal_runtimes.iter() {
+ let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else {
+ continue;
+ };
+ let mut handoff_runtime = runtime.handoff_runtime_state(pane_id);
+ let has_agent_session = self
+ .app
+ .state
+ .terminals
+ .get(terminal_id)
+ .is_some_and(|terminal| terminal.persisted_agent_session.is_some());
+ if !has_agent_session {
+ handoff_runtime.initial_history_ansi = runtime.handoff_history_ansi();
+ }
+ handoff_entries.push((terminal_id.clone(), handoff_runtime));
+ }
+
+ let panes = handoff_entries
+ .iter()
+ .map(|(_, runtime)| runtime.clone())
+ .collect();
+ let manifest = crate::server::handoff::manifest_for(
+ snapshot,
+ panes,
+ params.expected_protocol,
+ params.expected_version,
+ self.api_window_title.clone(),
+ );
+ let mut import_child = match crate::server::handoff::spawn_handoff_import(
+ import_exe.as_deref(),
+ &socket_path,
+ &token,
+ ) {
+ Ok(child) => child,
+ Err(err) => {
+ self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
+ return Err(err);
+ }
+ };
+ let child_pid = import_child.id();
+ info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server");
+
+ let mut fds = Vec::new();
+ let duplicate_result = (|| {
+ for (terminal_id, _) in &handoff_entries {
+ let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else {
+ continue;
+ };
+ fds.push(runtime.duplicate_handoff_fd()?);
+ }
+ Ok::<(), io::Error>(())
+ })();
+ if let Err(err) = duplicate_result {
+ for fd in fds {
+ let _ = unsafe { libc::close(fd) };
+ }
+ crate::server::handoff::cleanup_failed_import_child(&mut import_child);
+ self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
+ return Err(err);
+ }
+
+ let mut stream = match crate::server::handoff::accept_and_validate_on(
+ listener,
+ &socket_path,
+ &token,
+ &manifest,
+ ) {
+ Ok(stream) => stream,
+ Err(err) => {
+ for fd in fds {
+ let _ = unsafe { libc::close(fd) };
+ }
+ crate::server::handoff::cleanup_failed_import_child(&mut import_child);
+ self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
+ return Err(err);
+ }
+ };
+
+ let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds);
+ for fd in fds {
+ let _ = unsafe { libc::close(fd) };
+ }
+ if let Err(err) = send_result {
+ crate::server::handoff::cleanup_failed_import_child(&mut import_child);
+ self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
+ return Err(err);
+ }
+
+ if let Some(api_server) = &self.api_server {
+ let _ = api_server.remove_socket_file_if_owned();
+ } else {
+ let _ = std::fs::remove_file(crate::api::socket_path());
+ }
+ let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity);
+ if let Err(err) = crate::server::handoff::wait_ready(&mut stream) {
+ crate::server::handoff::cleanup_failed_import_child(&mut import_child);
+ match self.wait_then_restore_public_sockets_after_failed_handoff() {
+ Ok(()) => {
+ self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
+ }
+ Err(restore_err) => {
+ self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
+ return Err(io::Error::other(format!(
+ "handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}"
+ )));
+ }
+ }
+ return Err(io::Error::other(format!(
+ "handoff replacement server did not become ready: {err}"
+ )));
+ }
+ if let Err(err) = crate::server::handoff::report_committed(&mut stream) {
+ crate::server::handoff::cleanup_failed_import_child(&mut import_child);
+ match self.wait_then_restore_public_sockets_after_failed_handoff() {
+ Ok(()) => {
+ self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
+ }
+ Err(restore_err) => {
+ self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
+ return Err(io::Error::other(format!(
+ "handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}"
+ )));
+ }
+ }
+ return Err(err);
+ }
+
+ for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() {
+ if !pane_by_terminal.contains_key(&terminal_id) {
+ continue;
+ }
+ debug!(terminal = %terminal_id, "preserving pane runtime for handoff");
+ runtime.preserve_for_handoff();
+ }
+ crate::server::handoff::wait_owned_ack(&mut stream);
+
+ Ok(())
+ }
+
+ pub(super) fn finish_live_handoff_shutdown(&mut self) {
+ self.shutting_down = true;
+ self.app.state.should_quit = true;
+ self.app.policy.persist_session = false;
+ info!("live handoff completed; old server exiting");
+ }
+
+ #[cfg(not(unix))]
+ pub(super) fn perform_live_handoff(
+ &mut self,
+ _params: crate::api::schema::ServerLiveHandoffParams,
+ ) -> io::Result<()> {
+ Err(io::Error::other("live handoff is only supported on Unix"))
+ }
+
+ #[cfg(unix)]
+ fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> {
+ let api_tx = self
+ .api_tx
+ .clone()
+ .ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?;
+ let api_server = api::start_server_with_stop_control(
+ api_tx,
+ self.app.event_hub.clone(),
+ self.should_quit.clone(),
+ )?;
+
+ let client_path = client_socket_path();
+ prepare_socket_path(&client_path)?;
+ let listener = bind_local_listener(&client_path)?;
+ restrict_socket_permissions(&client_path)?;
+ let client_socket_identity = socket_file_identity(&client_path)?;
+ listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
+
+ self.api_server = Some(api_server);
+ self.client_listener = listener;
+ self.client_socket_path = client_path;
+ self.client_socket_identity = client_socket_identity;
+ Ok(())
+ }
+
+ #[cfg(unix)]
+ fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> {
+ let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2);
+ wait_for_old_public_sockets_to_close(timeout)?;
+ self.restore_public_sockets_after_failed_handoff()
+ }
+
+ #[cfg(unix)]
+ fn rollback_handoff_before_commit(
+ &mut self,
+ socket_path: &Path,
+ paused_terminal_ids: &[crate::terminal::TerminalId],
+ ) {
+ for terminal_id in paused_terminal_ids {
+ if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) {
+ runtime.set_handoff_reader_paused(false);
+ }
+ }
+ self.handoff_in_progress = false;
+ let _ = std::fs::remove_file(socket_path);
+ }
+
+ #[cfg(unix)]
+ pub(super) fn nudge_handoff_panes_on_first_client_attach(&mut self) {
+ if !self.pending_handoff_repaint_nudge {
+ return;
+ }
+ self.pending_handoff_repaint_nudge = false;
+ self.app
+ .terminal_runtimes
+ .nudge_child_redraw_after_handoff();
+ }
+
+ #[cfg(not(unix))]
+ pub(super) fn nudge_handoff_panes_on_first_client_attach(&mut self) {}
+ /// Initiates graceful shutdown.
+ pub(super) fn initiate_shutdown(&mut self) {
+ if self.shutting_down {
+ return;
+ }
+ info!("server shutdown initiated");
+ self.shutting_down = true;
+
+ // Clear client-local host graphics, then send ServerShutdown to all connected clients.
+ let shutdown_msg = ServerMessage::ServerShutdown {
+ reason: Some("server is shutting down".to_owned()),
+ };
+ self.send_to_all_clients(shutdown_msg);
+
+ // Give client writer threads a moment to flush the shutdown message.
+ // A short sleep ensures the message is written to the socket before
+ // we close the connections.
+ std::thread::sleep(Duration::from_millis(50));
+
+ // Signal the main loop to exit.
+ self.should_quit.store(true, Ordering::Release);
+ self.app.state.should_quit = true;
+ }
+
+ /// Completes the shutdown sequence: send ServerShutdown to clients,
+ /// close client connections, remove socket files, and clean up.
+ pub(super) async fn complete_shutdown(&mut self) -> io::Result<()> {
+ info!("completing server shutdown");
+ self.reject_late_client_connections().await;
+
+ // Send ServerShutdown to all remaining clients.
+ if !self.clients.is_empty() {
+ let shutdown_msg = ServerMessage::ServerShutdown {
+ reason: Some("server is shutting down".to_owned()),
+ };
+ self.send_to_all_clients(shutdown_msg);
+
+ // Give writer threads a moment to flush before closing.
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ // Reject only the requests already queued when shutdown reached cleanup.
+ self.reject_queued_api_requests_for_shutdown();
+
+ // Close all client connections.
+ let staged_files = self
+ .clients
+ .drain()
+ .flat_map(|(_, client)| client.staged_clipboard_files)
+ .collect::<Vec<_>>();
+ crate::server::clipboard_image::remove_files(staged_files);
+
+ // Remove socket files.
+ self.cleanup_sockets()?;
+
+ Ok(())
+ }
+
+ /// Removes socket files created by the server.
+ pub(super) fn cleanup_sockets(&self) -> io::Result<()> {
+ if let Err(err) =
+ remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity)
+ {
+ if err.kind() != io::ErrorKind::NotFound {
+ warn!(
+ path = %self.client_socket_path.display(),
+ err = %err,
+ "failed to remove client socket on shutdown"
+ );
+ }
+ }
+ Ok(())
+ }
+}
+
+#[cfg(unix)]
+pub(super) fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> {
+ let deadline = Instant::now() + timeout;
+ let api_socket = api::socket_path();
+ let client_socket = client_socket_path();
+ while Instant::now() < deadline {
+ let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok();
+ let client_open =
+ client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok();
+ if !api_open && !client_open {
+ return Ok(());
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ "old server sockets did not close before handoff import bind",
+ ))
+}
diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs
new file mode 100644
index 00000000..3587b5cf
--- /dev/null
+++ b/src/server/headless/notifications.rs
@@ -0,0 +1,674 @@
+use super::*;
+
+impl HeadlessServer {
+ fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState {
+ self.app
+ .state
+ .workspaces
+ .iter()
+ .find_map(|ws| {
+ ws.tabs.iter().find_map(|tab| {
+ let pane = tab.panes.get(&pane_id)?;
+ self.app
+ .state
+ .terminals
+ .get(&pane.attached_terminal_id)
+ .map(|terminal| terminal.state)
+ })
+ })
+ .unwrap_or(crate::detect::AgentState::Unknown)
+ }
+
+ fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option<String> {
+ self.app.state.workspaces.iter().find_map(|ws| {
+ ws.tabs.iter().find_map(|tab| {
+ let pane = tab.panes.get(&pane_id)?;
+ self.app
+ .state
+ .terminals
+ .get(&pane.attached_terminal_id)
+ .and_then(|terminal| terminal.effective_agent_label())
+ .map(str::to_string)
+ })
+ })
+ }
+
+ fn forward_semantic_agent_notification(
+ &mut self,
+ update: &crate::app::actions::PaneStateUpdate,
+ ) -> bool {
+ if update.suppress_completion {
+ return false;
+ }
+ self.forward_semantic_agent_transition(
+ update.ws_idx,
+ update.pane_id,
+ update.previous_state,
+ update.state,
+ update.previous_agent_label.as_deref(),
+ update.agent_label.as_deref(),
+ update.known_agent.or(update.previous_known_agent),
+ )
+ }
+
+ pub(super) fn forward_semantic_agent_transition(
+ &mut self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ previous_state: crate::detect::AgentState,
+ state: crate::detect::AgentState,
+ previous_agent_label: Option<&str>,
+ agent_label: Option<&str>,
+ known_agent: Option<crate::detect::Agent>,
+ ) -> bool {
+ let Some(kind) = crate::app::actions::notification_toast_for_state_change_with_agent_labels(
+ false,
+ previous_state,
+ state,
+ previous_agent_label,
+ agent_label,
+ ) else {
+ return false;
+ };
+ let Some(workspace) = self.app.state.workspaces.get(ws_idx) else {
+ return false;
+ };
+ let Some(tab_idx) = workspace.find_tab_index_for_pane(pane_id) else {
+ return false;
+ };
+ let Some(tab_number) = workspace.public_tab_number(tab_idx) else {
+ return false;
+ };
+ let Some(public_pane_id) = self.app.public_pane_id(ws_idx, pane_id) else {
+ return false;
+ };
+ let Some(agent_label) = agent_label.or(previous_agent_label) else {
+ return false;
+ };
+ let (semantic_kind, event_text, sound) = match kind {
+ crate::app::state::ToastKind::NeedsAttention => (
+ protocol::SemanticNotificationKind::NeedsAttention,
+ "needs attention",
+ Some(protocol::SemanticNotificationSound::Request),
+ ),
+ crate::app::state::ToastKind::Finished => (
+ protocol::SemanticNotificationKind::Finished,
+ "finished",
+ Some(protocol::SemanticNotificationSound::Done),
+ ),
+ crate::app::state::ToastKind::UpdateInstalled => (
+ protocol::SemanticNotificationKind::UpdateInstalled,
+ "updated",
+ None,
+ ),
+ };
+ let workspace_id = workspace.id.clone();
+ let tab_id = crate::workspace::public_tab_id_for_number(&workspace_id, tab_number);
+ let workspace_label =
+ workspace.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes);
+ let context =
+ crate::app::actions::notification_context(workspace, &workspace_label, ws_idx, pane_id);
+ let agent = known_agent
+ .map(crate::detect::agent_label)
+ .map(str::to_owned);
+ self.send_to_client_shells(ServerMessage::SemanticNotification(
+ protocol::SemanticNotification {
+ kind: semantic_kind,
+ title: format!("{agent_label} {event_text}"),
+ body: non_empty_body(&context),
+ sound,
+ agent,
+ workspace_id: Some(workspace_id),
+ tab_id: Some(tab_id),
+ pane_id: Some(public_pane_id),
+ position: None,
+ },
+ ))
+ }
+
+ fn forward_pane_state_update_notifications_to_clients(
+ &mut self,
+ update: &crate::app::actions::PaneStateUpdate,
+ ) {
+ if self.app.state.toast_config.delay_seconds != 0 {
+ return;
+ }
+
+ let is_active_tab = self
+ .app
+ .state
+ .pane_is_in_active_tab(update.ws_idx, update.pane_id);
+ let suppress_active_tab_notifications =
+ self.active_tab_suppresses_notifications(is_active_tab);
+
+ if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) {
+ if let Some(sound) =
+ crate::app::actions::notification_sound_for_state_change_with_agent_labels(
+ suppress_active_tab_notifications,
+ update.previous_state,
+ update.state,
+ update.previous_agent_label.as_deref(),
+ update.agent_label.as_deref(),
+ )
+ {
+ self.send_notify_to_foreground_client(
+ protocol::NotifyKind::Sound,
+ sound_notify_message(sound),
+ None,
+ );
+ }
+ }
+
+ if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
+ return;
+ }
+ let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update(
+ suppress_active_tab_notifications,
+ update,
+ ) else {
+ return;
+ };
+ let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else {
+ return;
+ };
+ let Some(agent_label) = update.agent_label.as_deref() else {
+ return;
+ };
+ let event_text = match kind {
+ crate::app::state::ToastKind::NeedsAttention => "needs attention",
+ crate::app::state::ToastKind::Finished => "finished",
+ crate::app::state::ToastKind::UpdateInstalled => "updated",
+ };
+ let workspace_label =
+ ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes);
+ let context = crate::app::actions::notification_context(
+ ws,
+ &workspace_label,
+ update.ws_idx,
+ update.pane_id,
+ );
+ self.send_notify_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
+ format!("{agent_label} {event_text}"),
+ non_empty_body(&context),
+ );
+ }
+
+ pub(super) fn forward_agent_notification_delivery(
+ &mut self,
+ delivery: &crate::app::state::AgentNotificationDelivery,
+ ) {
+ if let Some(sound) = delivery.sound {
+ self.send_notify_to_foreground_client(
+ protocol::NotifyKind::Sound,
+ sound_notify_message(sound),
+ None,
+ );
+ }
+
+ if should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
+ if let Some(toast) = &delivery.client_notification {
+ self.send_notify_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
+ &toast.title,
+ non_empty_body(&toast.context),
+ );
+ }
+ }
+ }
+
+ pub(super) fn send_notify_to_foreground_client(
+ &mut self,
+ kind: protocol::NotifyKind,
+ message: impl Into<String>,
+ body: Option<String>,
+ ) -> bool {
+ self.send_to_foreground_client(ServerMessage::Notify {
+ kind,
+ message: message.into(),
+ body,
+ })
+ }
+
+ pub(super) fn send_flat_toast_to_foreground_client(
+ &mut self,
+ kind: protocol::NotifyKind,
+ message: impl AsRef<str>,
+ ) -> bool {
+ let (title, body) = crate::terminal_notify::split_message(message.as_ref());
+ self.send_notify_to_foreground_client(kind, title, body.map(str::to_string))
+ }
+
+ pub(super) fn handle_notification_show_api(
+ &mut self,
+ id: String,
+ params: api::schema::NotificationShowParams,
+ ) -> String {
+ use api::schema::NotificationShowReason;
+
+ let Some(title) = sanitize_notification_text(&params.title, 80) else {
+ return serde_json::to_string(&api::schema::ErrorResponse {
+ id,
+ error: api::schema::ErrorBody {
+ code: "invalid_params".into(),
+ message: "notification title is empty".into(),
+ },
+ })
+ .unwrap_or_else(|_| "{}".to_string());
+ };
+
+ let body = params
+ .body
+ .as_deref()
+ .and_then(|body| sanitize_notification_text(body, 240));
+ let has_client_shell = self.clients.values().any(ClientConnection::is_shell_client);
+ if !has_client_shell {
+ let reason = if self.app.state.toast_config.delivery == config::ToastDelivery::Off {
+ NotificationShowReason::Disabled
+ } else {
+ NotificationShowReason::NoForegroundClient
+ };
+ return notification_show_result(id, false, reason);
+ }
+ if self.app.api_notification_rate_limited(Instant::now()) {
+ return notification_show_result(id, false, NotificationShowReason::RateLimited);
+ }
+ let sound = match params.sound {
+ api::schema::NotificationShowSound::None => None,
+ api::schema::NotificationShowSound::Done => {
+ Some(protocol::SemanticNotificationSound::Done)
+ }
+ api::schema::NotificationShowSound::Request => {
+ Some(protocol::SemanticNotificationSound::Request)
+ }
+ };
+ let shown = self.send_to_client_shells(ServerMessage::SemanticNotification(
+ protocol::SemanticNotification {
+ kind: protocol::SemanticNotificationKind::Custom,
+ title,
+ body,
+ sound,
+ agent: None,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: None,
+ position: params.position,
+ },
+ ));
+ if shown {
+ self.app.mark_api_notification_shown(Instant::now());
+ }
+ notification_show_result(
+ id,
+ shown,
+ if shown {
+ NotificationShowReason::Shown
+ } else {
+ NotificationShowReason::NoForegroundClient
+ },
+ )
+ }
+
+ /// Handles a single internal event with forwarding logic for clipboard,
+ /// sound, and toast notifications to connected clients.
+ ///
+ /// ALL internal events MUST be routed through this method to ensure
+ /// clipboard/notify forwarding is never bypassed. Do not call
+ /// `self.app.handle_internal_event()` directly for any internal event
+ /// in the headless server — use this method instead.
+ ///
+ /// Returns true if the event changed visual state (requiring a re-render).
+ pub(super) fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool {
+ match &ev {
+ AppEvent::TerminalBell { pane_id, count } => {
+ if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) {
+ debug!(
+ pane = pane_id.raw(),
+ count, "dropped terminal bell without a foreground client"
+ );
+ }
+ false
+ }
+ AppEvent::ClipboardWrite { content } => {
+ // Clipboard writes are client-local side effects. Forward them only to
+ // the foreground client instead of broadcasting to every attached client.
+ let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice());
+ self.send_to_foreground_client(ServerMessage::Clipboard { data });
+ false
+ }
+ AppEvent::StateChanged { pane_id, agent, .. } => {
+ // Capture toast before handling.
+ let toast_before = self.app.state.toast.clone();
+ let pane_id_val = *pane_id;
+ let agent_val = *agent;
+
+ // Find the previous effective state of this pane before the event
+ // is processed. Notifications must follow effective state changes,
+ // not raw fallback reports that may be masked by hook authority.
+ let prev_state = self.pane_effective_state(pane_id_val);
+ let prev_agent_label = self.pane_effective_agent_label(pane_id_val);
+
+ // Handle the state change (updates pane state, sets toast on AppState).
+ // Headless mode disables local sound playback separately from the
+ // sound policy so reloads can keep server-side notification policy live.
+ self.sync_foreground_client_state();
+ let pane_updates = self.app.handle_internal_event_with_pane_updates(ev);
+ let suppress_completion = pane_updates
+ .iter()
+ .any(|update| update.pane_id == pane_id_val && update.suppress_completion);
+ for update in pane_updates
+ .iter()
+ .filter(|update| update.pane_id == pane_id_val)
+ {
+ self.forward_semantic_agent_notification(update);
+ }
+
+ // Forward sound notification to clients when server-side sound policy allows it.
+ let is_active_tab = self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ .is_some_and(|ws| {
+ ws.find_tab_index_for_pane(pane_id_val)
+ .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
+ });
+
+ let suppress_active_tab_notifications =
+ self.active_tab_suppresses_notifications(is_active_tab);
+
+ let next_state = self.pane_effective_state(pane_id_val);
+ let next_agent_label = self.pane_effective_agent_label(pane_id_val);
+
+ if !suppress_completion
+ && self.app.state.toast_config.delay_seconds == 0
+ && self.app.state.sound.allows(agent_val)
+ {
+ if let Some(sound) =
+ crate::app::actions::notification_sound_for_state_change_with_agent_labels(
+ suppress_active_tab_notifications,
+ prev_state,
+ next_state,
+ prev_agent_label.as_deref(),
+ next_agent_label.as_deref(),
+ )
+ {
+ self.send_notify_to_foreground_client(
+ protocol::NotifyKind::Sound,
+ sound_notify_message(sound),
+ None,
+ );
+ }
+ }
+
+ let toast_msg = if !suppress_completion
+ && self.app.state.toast_config.delay_seconds == 0
+ && should_forward_toast_to_clients(self.app.state.toast_config.delivery)
+ {
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ self.app
+ .state
+ .toast
+ .as_ref()
+ .map(|toast| format!("{}: {}", toast.title, toast.context))
+ } else {
+ toast_message_from_state_change(
+ &self.app.state,
+ &self.app.terminal_runtimes,
+ pane_id_val,
+ suppress_active_tab_notifications,
+ prev_state,
+ next_state,
+ prev_agent_label.as_deref(),
+ )
+ }
+ } else {
+ None
+ };
+
+ if let Some(msg) = toast_msg {
+ self.send_flat_toast_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
+ msg,
+ );
+ }
+
+ true
+ }
+ AppEvent::HookStateReported {
+ pane_id,
+ agent_label,
+ ..
+ } => {
+ // Hook reports can be stale or no-op after sequence rejection.
+ // Forward only effective state changes observed after handling.
+ let toast_before = self.app.state.toast.clone();
+ let pane_id_val = *pane_id;
+ let agent_val = crate::detect::parse_agent_label(agent_label);
+
+ // Capture the previous effective state for this pane. Hook reports
+ // are already folded into pane.state; raw hook transitions must not
+ // produce a second notification path.
+ let prev_state = self.pane_effective_state(pane_id_val);
+ let prev_agent_label = self.pane_effective_agent_label(pane_id_val);
+
+ self.sync_foreground_client_state();
+ let pane_updates = self.app.handle_internal_event_with_pane_updates(ev);
+ let suppress_completion = pane_updates
+ .iter()
+ .any(|update| update.pane_id == pane_id_val && update.suppress_completion);
+ for update in pane_updates
+ .iter()
+ .filter(|update| update.pane_id == pane_id_val)
+ {
+ self.forward_semantic_agent_notification(update);
+ }
+
+ // Forward sound notification based on the effective transition when
+ // server-side sound policy allows it.
+ let is_active_tab = self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ .is_some_and(|ws| {
+ ws.find_tab_index_for_pane(pane_id_val)
+ .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
+ });
+
+ let suppress_active_tab_notifications =
+ self.active_tab_suppresses_notifications(is_active_tab);
+
+ let next_state = self.pane_effective_state(pane_id_val);
+ let next_agent_label = self.pane_effective_agent_label(pane_id_val);
+
+ if !suppress_completion
+ && self.app.state.toast_config.delay_seconds == 0
+ && self.app.state.sound.allows(agent_val)
+ {
+ if let Some(sound) =
+ crate::app::actions::notification_sound_for_state_change_with_agent_labels(
+ suppress_active_tab_notifications,
+ prev_state,
+ next_state,
+ prev_agent_label.as_deref(),
+ next_agent_label.as_deref(),
+ )
+ {
+ self.send_notify_to_foreground_client(
+ protocol::NotifyKind::Sound,
+ sound_notify_message(sound),
+ None,
+ );
+ }
+ }
+
+ let toast_msg = if !suppress_completion
+ && self.app.state.toast_config.delay_seconds == 0
+ && should_forward_toast_to_clients(self.app.state.toast_config.delivery)
+ {
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ self.app
+ .state
+ .toast
+ .as_ref()
+ .map(|toast| format!("{}: {}", toast.title, toast.context))
+ } else {
+ toast_message_from_state_change(
+ &self.app.state,
+ &self.app.terminal_runtimes,
+ pane_id_val,
+ suppress_active_tab_notifications,
+ prev_state,
+ next_state,
+ prev_agent_label.as_deref(),
+ )
+ }
+ } else {
+ None
+ };
+
+ if let Some(msg) = toast_msg {
+ self.send_flat_toast_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
+ msg,
+ );
+ }
+
+ true
+ }
+ AppEvent::UpdateReady {
+ version,
+ install_command,
+ } => {
+ let toast_before = self.app.state.toast.clone();
+ let version = version.clone();
+ let install_command = install_command.clone();
+
+ self.app.handle_internal_event(ev);
+ self.send_to_client_shells(ServerMessage::SemanticNotification(
+ protocol::SemanticNotification {
+ kind: protocol::SemanticNotificationKind::UpdateInstalled,
+ title: format!("Herdr v{version} available"),
+ body: Some(crate::update::update_install_instruction(&install_command)),
+ sound: None,
+ agent: None,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: None,
+ position: None,
+ },
+ ));
+
+ let toast_msg =
+ if should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ self.app
+ .state
+ .toast
+ .as_ref()
+ .map(|toast| format!("{}: {}", toast.title, toast.context))
+ } else {
+ Some(format!(
+ "v{version} available: {}",
+ crate::update::update_install_instruction(&install_command)
+ ))
+ }
+ } else {
+ None
+ };
+
+ if let Some(msg) = toast_msg {
+ self.send_flat_toast_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
+ msg,
+ );
+ }
+
+ true
+ }
+ AppEvent::PaneDied { pane_id } => {
+ let pane_id_val = *pane_id;
+ let terminal_id = self.app.state.workspaces.iter().find_map(|ws| {
+ ws.tabs.iter().find_map(|tab| {
+ tab.panes
+ .get(pane_id)
+ .map(|pane| pane.attached_terminal_id.to_string())
+ })
+ });
+ if let Some(update) = self
+ .app
+ .state
+ .publish_pane_process_exit_if_agent(pane_id_val)
+ {
+ self.app.emit_pane_state_update(&update);
+ self.forward_semantic_agent_notification(&update);
+ self.forward_pane_state_update_notifications_to_clients(&update);
+ }
+
+ self.app.handle_internal_event(ev);
+
+ if self.app.find_pane(pane_id_val).is_none() {
+ if let Some(terminal_id) = terminal_id {
+ self.shutdown_terminal_stream_clients(
+ &terminal_id,
+ format!("terminal {terminal_id} exited"),
+ );
+ }
+ }
+
+ true
+ }
+ _ => self.app.handle_internal_event_with_render_impact(ev),
+ }
+ }
+
+ /// Drains internal events, forwarding clipboard, sound, and toast
+ /// notifications to connected clients instead of processing them locally.
+ ///
+ /// The server has no host terminal or audio subsystem, so we:
+ /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the
+ /// foreground client only.
+ /// - Detect when a sound would be played and forward as
+ /// `ServerMessage::Notify { kind: Sound }` to the foreground client.
+ /// - Detect when a toast is set on AppState and forward as
+ /// `ServerMessage::Notify` to the foreground client for terminal/system delivery.
+ pub(super) fn drain_internal_events_with_forwarding(&mut self) -> bool {
+ self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT)
+ .1
+ }
+
+ pub(super) fn drain_all_internal_events_with_forwarding(&mut self) -> bool {
+ let mut changed = false;
+ loop {
+ let (had_event, batch_changed) =
+ self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT);
+ changed |= batch_changed;
+ if !had_event || self.should_quit.load(Ordering::Acquire) {
+ break;
+ }
+ }
+ changed
+ }
+
+ pub(super) fn drain_internal_events_with_forwarding_up_to(
+ &mut self,
+ limit: usize,
+ ) -> (bool, bool) {
+ let mut had_event = false;
+ let mut changed = false;
+ for _ in 0..limit {
+ let Ok(ev) = self.app.event_rx.try_recv() else {
+ break;
+ };
+ had_event = true;
+ changed |= self.handle_internal_event_with_forwarding(ev);
+ }
+ (had_event, changed)
+ }
+}
diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs
index 151b26ee..bce53b72 100644
--- a/src/server/headless/pane_graphics.rs
+++ b/src/server/headless/pane_graphics.rs
@@ -1,21 +1,7 @@
use super::{HeadlessServer, RenderImpact};
use crate::api;
use crate::protocol::{ServerMessage, MAX_GRAPHICS_FRAME_SIZE};
-use crate::server::clients::{render_targets, ClientConnectionMode, DeferredRender};
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub(super) enum RetainedGraphicsOutcome {
- Sent,
- Deferred,
- Fallback,
-}
-
-pub(super) fn frame_pane_graphics(bytes: Vec<u8>) -> Vec<u8> {
- if bytes.is_empty() {
- return bytes;
- }
- [b"\x1b7".as_slice(), &bytes, b"\x1b8"].concat()
-}
+use crate::server::clients::ClientConnectionMode;
impl HeadlessServer {
pub(super) fn pane_graphics_runtime_active(&self) -> bool {
@@ -76,7 +62,6 @@ impl HeadlessServer {
.and_then(crate::app::pane_graphics::Layer::direct_lease)
.map(|lease| {
(
- slot.host_image_id,
lease.path().to_string_lossy().into_owned(),
lease.len() as u64,
lease.fingerprint(),
@@ -84,21 +69,35 @@ impl HeadlessServer {
})
})
});
- if let (Some(key), Some((image_id, path, expected_len, transfer_id))) =
+ if let (Some(key), Some((path, expected_len, transfer_id))) =
(direct_key.clone(), direct_frame)
{
- let command = self.clients.get(&client_id).and_then(|client| {
- crate::kitty_graphics::prepare_direct_file(
- &self.app.state,
- &self.app.pane_graphics,
- self.app.state.view.tab_surface(),
- client.cell_size,
- !internal_changed,
- &client.graphics_cache,
- &key,
- )
+ let prepared = self.clients.get(&client_id).and_then(|client| {
+ matches!(client.mode, ClientConnectionMode::ClientShell).then_some(())?;
+ let layer = self
+ .app
+ .pane_graphics
+ .slots
+ .get(&key)
+ .and_then(|slot| slot.layer.as_ref())?;
+ let asset = crate::kitty_graphics::surface::pane_layer_asset_key(
+ &self.app, &key, layer,
+ )?;
+ let (image_id, control) =
+ crate::kitty_graphics::surface::direct_upload_control(
+ &self.client_shell_boot_id,
+ &asset,
+ );
+ Some((
+ crate::kitty_graphics::DirectFileCommand {
+ leading: Vec::new(),
+ control,
+ },
+ Some(asset),
+ image_id,
+ ))
});
- let Some(command) = command else {
+ let Some((command, surface_asset, image_id)) = prepared else {
if self.install_inline_fallback(&key) {
if msg.respond_to.send(response).is_err() {
self.retire_direct_gate(&key);
@@ -123,6 +122,7 @@ impl HeadlessServer {
transfer_id,
leading: command.leading,
control: command.control,
+ surface_asset,
};
let send =
Self::frame_server_message_with_max(&message, MAX_GRAPHICS_FRAME_SIZE)
@@ -144,6 +144,7 @@ impl HeadlessServer {
if let Some(slot) = self.app.pane_graphics.slots.get_mut(&key) {
slot.direct_gate = Some(crate::app::pane_graphics::DirectGate {
transfer_id,
+ image_id,
client_id,
deadline: std::time::Instant::now()
+ crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT,
@@ -267,10 +268,14 @@ impl HeadlessServer {
image_id: u32,
) -> bool {
if let Some(gate) = self.app.pane_graphics.slots.values_mut().find_map(|slot| {
- (slot.host_image_id == image_id && slot.stream_is_active())
+ slot.stream_is_active()
.then_some(slot.direct_gate.as_mut())
.flatten()
- .filter(|gate| gate.client_id == client_id && gate.transfer_id == transfer_id)
+ .filter(|gate| {
+ gate.client_id == client_id
+ && gate.transfer_id == transfer_id
+ && gate.image_id == image_id
+ })
}) {
gate.written = true;
gate.deadline =
@@ -297,7 +302,7 @@ impl HeadlessServer {
slot.stream_is_active()
&& gate.client_id == client_id
&& gate.transfer_id == transfer_id
- && slot.host_image_id == image_id
+ && gate.image_id == image_id
&& (!success || gate.written)
})
.then(|| key.clone())
@@ -312,11 +317,11 @@ impl HeadlessServer {
.iter()
.any(|workspace| workspace.pane_state(key.0).is_some());
if !pane_is_live {
- self.retire_direct_gate(&key);
+ self.retire_direct_gate_with_client_notice(&key);
return false;
}
if success {
- let (gate, host_image_id) = {
+ let gate = {
let slot = self
.app
.pane_graphics
@@ -329,23 +334,8 @@ impl HeadlessServer {
if let Some(layer) = slot.layer.as_mut() {
layer.mark_resident(client_id);
}
- (
- slot.direct_gate.take().expect("matched gate"),
- slot.host_image_id,
- )
+ slot.direct_gate.take().expect("matched gate")
};
- if let (Some(client), Some(layer)) = (
- self.clients.get_mut(&client_id),
- self.app
- .pane_graphics
- .slots
- .get(&key)
- .and_then(|slot| slot.layer.as_ref()),
- ) {
- client
- .graphics_cache
- .trust_pane_layer(&key, host_image_id, layer);
- }
if gate.respond_to.send(gate.success_response).is_err() {
self.retire_direct_gate(&key);
return true;
@@ -362,9 +352,6 @@ impl HeadlessServer {
self.retire_all_direct_graphics();
return true;
}
- if let Some(client) = self.clients.get_mut(&client_id) {
- client.graphics_cache.forget_pane_layer(&key, image_id);
- }
let gate = self
.app
.pane_graphics
@@ -404,7 +391,7 @@ impl HeadlessServer {
*client_id,
ServerMessage::GraphicsTransmissionRetired {
transfer_id: gate.transfer_id,
- image_id: slot.host_image_id,
+ image_id: gate.image_id,
},
);
}
@@ -421,8 +408,53 @@ impl HeadlessServer {
!expired.is_empty()
}
+ fn retire_direct_gate_with_client_notice(&mut self, key: &crate::app::pane_graphics::Key) {
+ if let Some((client_id, transfer_id, image_id)) = self
+ .app
+ .pane_graphics
+ .slots
+ .get(key)
+ .and_then(|slot| slot.direct_gate.as_ref())
+ .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id))
+ {
+ self.send_to_client(
+ client_id,
+ ServerMessage::GraphicsTransmissionRetired {
+ transfer_id,
+ image_id,
+ },
+ );
+ }
+ self.retire_direct_gate(key);
+ }
+
+ pub(super) fn retain_live_pane_graphics(&mut self) -> bool {
+ let dead_direct = self
+ .app
+ .pane_graphics
+ .slots
+ .iter()
+ .filter(|((pane_id, _), slot)| {
+ slot.direct_gate.is_some()
+ && !self
+ .app
+ .state
+ .workspaces
+ .iter()
+ .any(|workspace| workspace.pane_state(*pane_id).is_some())
+ })
+ .map(|(key, _)| key.clone())
+ .collect::<Vec<_>>();
+ let changed = !dead_direct.is_empty();
+ for key in dead_direct {
+ self.retire_direct_gate_with_client_notice(&key);
+ }
+ let retained = self.app.pane_graphics.retain_live_panes(&self.app.state);
+ changed || retained
+ }
+
pub(super) fn retire_all_direct_graphics(&mut self) {
- let keys = self
+ let retired = self
.app
.pane_graphics
.slots
@@ -432,9 +464,26 @@ impl HeadlessServer {
.as_ref()
.is_some_and(crate::app::pane_graphics::Layer::terminal_only)
})
- .map(|(key, _)| key.clone())
+ .map(|(key, slot)| {
+ let notification = slot
+ .direct_gate
+ .as_ref()
+ .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id));
+ (key.clone(), notification)
+ })
.collect::<Vec<_>>();
- for key in keys {
+ for (_, notification) in &retired {
+ if let Some((client_id, transfer_id, image_id)) = notification {
+ self.send_to_client(
+ *client_id,
+ ServerMessage::GraphicsTransmissionRetired {
+ transfer_id: *transfer_id,
+ image_id: *image_id,
+ },
+ );
+ }
+ }
+ for (key, _) in retired {
self.retire_direct_gate(&key);
}
}
@@ -460,127 +509,4 @@ impl HeadlessServer {
self.retire_direct_gate(&key);
}
}
-
- pub(super) fn render_retained_graphics_update_and_stream(&mut self) -> RetainedGraphicsOutcome {
- crate::render_prof::event("retained_graphics.attempt");
- if self.app.full_redraw_pending {
- crate::render_prof::event("retained_graphics_fallback.full_redraw_pending");
- return RetainedGraphicsOutcome::Fallback;
- }
-
- let render_targets = render_targets(&self.clients, self.foreground_client_id);
- let mut app_view_size = None;
- for (_, terminal_size, _, _, mode) in &render_targets {
- if !matches!(mode, ClientConnectionMode::App) {
- continue;
- }
- if app_view_size.is_some_and(|size| size != *terminal_size) {
- crate::render_prof::event("retained_graphics_fallback.mixed_app_geometry");
- return RetainedGraphicsOutcome::Fallback;
- }
- app_view_size = Some(*terminal_size);
- }
- let mut deferred = false;
- let mut prepared = Vec::new();
-
- for (client_id, (cols, rows), cell_size, _is_foreground, mode) in render_targets {
- if !matches!(mode, ClientConnectionMode::App) {
- continue;
- }
- let Some(client) = self.clients.get_mut(&client_id) else {
- crate::render_prof::event("retained_graphics_fallback.client_missing");
- return RetainedGraphicsOutcome::Fallback;
- };
- if client.deferred_render() != DeferredRender::None {
- deferred = true;
- continue;
- }
- if client.graphics_surface_reset_pending || !cell_size.is_known() {
- crate::render_prof::event("retained_graphics_fallback.client_state");
- return RetainedGraphicsOutcome::Fallback;
- }
- let Some(last_frame) = client.render_state.last_frame() else {
- crate::render_prof::event("retained_graphics_fallback.no_last_frame");
- return RetainedGraphicsOutcome::Fallback;
- };
- if last_frame.width != cols || last_frame.height != rows {
- crate::render_prof::event("retained_graphics_fallback.frame_size_mismatch");
- return RetainedGraphicsOutcome::Fallback;
- }
- if client.writer.is_none() {
- crate::render_prof::event("retained_graphics_fallback.writer_missing");
- return RetainedGraphicsOutcome::Fallback;
- }
-
- let mut next_graphics_cache = client.graphics_cache.clone();
- let encode_started = crate::render_prof::timer();
- let encoded = crate::kitty_graphics::encode_local_pane_graphics(
- &self.app.state,
- &self.app.pane_graphics,
- &self.app.terminal_runtimes,
- self.app.state.view.tab_surface(),
- cell_size,
- Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET),
- &mut next_graphics_cache,
- );
- crate::render_prof::duration_since("retained_graphics.graphics_encode", encode_started);
- prepared.push((client_id, encoded, next_graphics_cache));
- }
-
- let mut broken_clients = Vec::new();
- for (client_id, encoded, next_graphics_cache) in prepared {
- let Some(client) = self.clients.get_mut(&client_id) else {
- continue;
- };
- let serialized = if encoded.bytes.is_empty() {
- None
- } else {
- match Self::frame_server_message_with_max(
- &ServerMessage::Graphics {
- bytes: frame_pane_graphics(encoded.bytes),
- },
- MAX_GRAPHICS_FRAME_SIZE,
- ) {
- Ok(serialized) => Some(serialized),
- Err(_) => {
- crate::render_prof::event("retained_graphics_fallback.oversized");
- return RetainedGraphicsOutcome::Fallback;
- }
- }
- };
- let result = match (serialized, client.writer.as_ref()) {
- (None, _) => Ok(()),
- (Some(bytes), Some(writer)) => writer.render.try_send(bytes),
- (Some(bytes), None) => Err(std::sync::mpsc::TrySendError::Disconnected(bytes)),
- };
- match result {
- Ok(()) => {
- client.graphics_cache = next_graphics_cache;
- if encoded.incomplete {
- client.defer_full_render();
- deferred = true;
- } else {
- client.clear_deferred_render();
- }
- crate::render_prof::event("retained_graphics.sent");
- }
- Err(std::sync::mpsc::TrySendError::Full(_)) => {
- client.defer_full_render();
- deferred = true;
- }
- Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
- broken_clients.push(client_id)
- }
- }
- }
- for client_id in broken_clients {
- self.remove_client_and_resize_if_needed(client_id);
- }
-
- if deferred {
- RetainedGraphicsOutcome::Deferred
- } else {
- RetainedGraphicsOutcome::Sent
- }
- }
}
diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs
new file mode 100644
index 00000000..cab79de2
--- /dev/null
+++ b/src/server/headless/render.rs
@@ -0,0 +1,606 @@
+use super::*;
+
+impl HeadlessServer {
+ fn focused_pane_graphics_demand(&self) -> bool {
+ self.app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ .and_then(crate::workspace::Workspace::focused_pane_id)
+ .is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id))
+ }
+
+ pub(super) fn stream_host_mouse_capture_mode(&mut self) {
+ let endpoint_shell_mouse = self.app.state.popup_pane.is_some()
+ || self
+ .app
+ .state
+ .focused_pane_requests_mouse_capture_from(&self.app.terminal_runtimes);
+ let pixel_mouse_requested = self
+ .clients
+ .values()
+ .any(|client| client.is_shell_client() && client.pixel_mouse);
+ let shell_sgr_pixels = pixel_mouse_requested
+ && self.focused_pane_graphics_demand()
+ && self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| {
+ self.app
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(crate::workspace::Workspace::focused_pane_id)
+ .and_then(|pane_id| {
+ self.app.state.runtime_for_pane_in_workspace(
+ &self.app.terminal_runtimes,
+ ws_idx,
+ pane_id,
+ )
+ })
+ })
+ .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled);
+ let requested = self
+ .clients
+ .iter()
+ .filter_map(|(&client_id, client)| match &client.mode {
+ ClientConnectionMode::ClientShell => Some((
+ client_id,
+ client.shell_mouse_capture || endpoint_shell_mouse,
+ shell_sgr_pixels && client.pixel_mouse,
+ )),
+ ClientConnectionMode::TerminalAttach { terminal_id } => {
+ let runtime = self.runtime_for_terminal_id_string(terminal_id);
+ let child_requests_mouse = runtime
+ .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled);
+ let sgr_pixels = child_requests_mouse
+ && client.pixel_mouse
+ && runtime
+ .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled);
+ Some((client_id, child_requests_mouse, sgr_pixels))
+ }
+ ClientConnectionMode::TerminalPending
+ | ClientConnectionMode::TerminalObserve { .. } => None,
+ })
+ .collect::<Vec<_>>();
+
+ let mut broken_clients = Vec::new();
+ for (client_id, enabled, sgr_pixels) in requested {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ continue;
+ };
+ if client.host_mouse_capture_active == Some(enabled)
+ && client.host_sgr_pixels_active == Some(sgr_pixels)
+ {
+ continue;
+ }
+ let Some(writer) = &client.writer else {
+ continue;
+ };
+ let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture {
+ enabled,
+ sgr_pixels,
+ }) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(err = %err, "failed to serialize mouse capture mode for client");
+ continue;
+ }
+ };
+ if writer.control.send(serialized).is_err() {
+ debug!(
+ client_id,
+ "client writer channel closed during mouse capture update"
+ );
+ broken_clients.push(client_id);
+ continue;
+ }
+ client.host_mouse_capture_active = Some(enabled);
+ client.host_sgr_pixels_active = Some(sgr_pixels);
+ }
+
+ for client_id in broken_clients {
+ self.remove_client_and_resize_if_needed(client_id);
+ }
+ }
+
+ pub(super) fn stream_direct_terminal_keyboard_mode(&mut self) {
+ let shell_report_all = {
+ let runtime = if self.app.state.popup_pane.is_some() {
+ self.app.popup_runtime()
+ } else {
+ self.app.state.active.and_then(|workspace_index| {
+ self.app
+ .state
+ .focused_runtime_in_workspace(&self.app.terminal_runtimes, workspace_index)
+ })
+ };
+ runtime.is_some_and(|runtime| {
+ let protocol = runtime.keyboard_protocol();
+ protocol.reports_all_keys()
+ || (protocol.reports_event_types() && runtime.modify_other_keys_level() > 0)
+ })
+ };
+ let serialized_shell =
+ Self::frame_server_message(&ServerMessage::ClientShellKeyboardReportAll {
+ enabled: shell_report_all,
+ });
+ let mut broken_clients = Vec::new();
+ for (&client_id, client) in &mut self.clients {
+ if !client.is_shell_client()
+ || client.host_keyboard_report_all_active == Some(shell_report_all)
+ {
+ continue;
+ }
+ let Some(writer) = &client.writer else {
+ continue;
+ };
+ let Ok(serialized) = serialized_shell.as_ref() else {
+ warn!("failed to serialize client shell keyboard report-all mode");
+ break;
+ };
+ if writer.control.send(serialized.clone()).is_err() {
+ broken_clients.push(client_id);
+ continue;
+ }
+ client.host_keyboard_report_all_active = Some(shell_report_all);
+ }
+
+ let requested = self
+ .clients
+ .iter()
+ .filter_map(|(&client_id, client)| {
+ let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else {
+ return None;
+ };
+ let (flags, modify_other_keys_level) = self
+ .runtime_for_terminal_id_string(terminal_id)
+ .map_or((0, 0), |runtime| {
+ let flags = match runtime.keyboard_protocol() {
+ crate::input::KeyboardProtocol::Legacy => 0,
+ crate::input::KeyboardProtocol::Kitty { flags } => flags,
+ };
+ (flags, runtime.modify_other_keys_level())
+ });
+ Some((client_id, flags, modify_other_keys_level))
+ })
+ .collect::<Vec<_>>();
+
+ for (client_id, flags, modify_other_keys_level) in requested {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ continue;
+ };
+ if client.host_keyboard_protocol_active == Some((flags, modify_other_keys_level)) {
+ continue;
+ }
+ let Some(writer) = &client.writer else {
+ continue;
+ };
+ let serialized =
+ match Self::frame_server_message(&ServerMessage::DirectTerminalKeyboardProtocol {
+ flags,
+ modify_other_keys_level,
+ }) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(err = %err, "failed to serialize direct terminal keyboard mode");
+ continue;
+ }
+ };
+ if writer.control.send(serialized).is_err() {
+ debug!(
+ client_id,
+ "client writer channel closed during direct terminal keyboard update"
+ );
+ broken_clients.push(client_id);
+ continue;
+ }
+ client.host_keyboard_protocol_active = Some((flags, modify_other_keys_level));
+ }
+
+ for client_id in broken_clients {
+ self.remove_client_and_resize_if_needed(client_id);
+ }
+ }
+
+ pub(super) fn has_pending_presentation_work(
+ &self,
+ needs_full_render: bool,
+ needs_graphics_render: bool,
+ ) -> bool {
+ needs_full_render || needs_graphics_render || self.app.render_dirty.has_immediate_work()
+ }
+
+ pub(super) fn sync_immediate_pty_sources(&self) {
+ let (has_app_target, direct_terminal_targets) = self.pty_render_targets();
+ let mut pane_ids = if has_app_target {
+ self.app.state.app_surface_pane_ids()
+ } else {
+ HashSet::new()
+ };
+ if !direct_terminal_targets.is_empty() {
+ for workspace in &self.app.state.workspaces {
+ for tab in &workspace.tabs {
+ pane_ids.extend(tab.panes.iter().filter_map(|(&pane_id, pane)| {
+ direct_terminal_targets
+ .contains(pane.attached_terminal_id.as_str())
+ .then_some(pane_id)
+ }));
+ }
+ }
+ if let Some(popup) = &self.app.state.popup_pane {
+ if direct_terminal_targets.contains(popup.terminal_id.as_str()) {
+ pane_ids.insert(popup.pane_id);
+ }
+ }
+ }
+ self.app.render_dirty.set_immediate_pty_sources(pane_ids);
+ }
+
+ fn pty_render_targets(&self) -> (bool, HashSet<&str>) {
+ let mut has_app_target = false;
+ let mut direct_terminal_targets = HashSet::new();
+ for client in self
+ .clients
+ .values()
+ .filter(|client| client.writer.is_some())
+ {
+ match &client.mode {
+ ClientConnectionMode::ClientShell => has_app_target = true,
+ ClientConnectionMode::TerminalAttach { terminal_id }
+ | ClientConnectionMode::TerminalObserve { terminal_id } => {
+ direct_terminal_targets.insert(terminal_id.as_str());
+ }
+ ClientConnectionMode::TerminalPending => {}
+ }
+ }
+ (has_app_target, direct_terminal_targets)
+ }
+
+ fn pty_source_visible_to_render_targets(
+ &self,
+ pane_id: crate::layout::PaneId,
+ has_app_target: bool,
+ direct_terminal_targets: &HashSet<&str>,
+ ) -> bool {
+ let terminal_id = self.terminal_id_for_pane(pane_id);
+ (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id)))
+ || terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str()))
+ }
+
+ pub(super) fn pty_sources_visible_to_any_render_target(
+ &self,
+ sources: &HashSet<crate::layout::PaneId>,
+ ) -> bool {
+ let (has_app_target, direct_terminal_targets) = self.pty_render_targets();
+ if !has_app_target && direct_terminal_targets.is_empty() {
+ return false;
+ }
+
+ sources.iter().copied().any(|pane_id| {
+ self.pty_source_visible_to_render_targets(
+ pane_id,
+ has_app_target,
+ &direct_terminal_targets,
+ )
+ })
+ }
+
+ fn terminal_id_for_pane(
+ &self,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<&crate::terminal::TerminalId> {
+ if let Some(popup) = self
+ .app
+ .state
+ .popup_pane
+ .as_ref()
+ .filter(|popup| popup.pane_id == pane_id)
+ {
+ return Some(&popup.terminal_id);
+ }
+ self.app
+ .find_pane(pane_id)
+ .map(|(_, pane)| &pane.attached_terminal_id)
+ }
+
+ fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool {
+ if self
+ .app
+ .state
+ .popup_pane
+ .as_ref()
+ .is_some_and(|popup| popup.pane_id == pane_id)
+ {
+ return true;
+ }
+ let Some(workspace) = self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ else {
+ return false;
+ };
+ let Some(tab) = workspace.active_tab() else {
+ return false;
+ };
+ if !tab.panes.contains_key(&pane_id) {
+ return false;
+ }
+ !tab.zoomed || tab.layout.focused() == pane_id
+ }
+
+ pub(super) fn render_and_stream(&mut self) {
+ let full_started = crate::render_prof::timer();
+ let render_targets = render_targets(&self.clients, self.foreground_client_id);
+
+ if render_targets.is_empty() {
+ let (cols, rows) = self.effective_size;
+ let area = Rect::new(0, 0, cols, rows);
+ let resize_panes = self.app.state.view.pane_infos.is_empty();
+ if resize_panes {
+ crate::ui::compute_view_with_runtime_registry(
+ &mut self.app.state,
+ &self.app.terminal_runtimes,
+ area,
+ );
+ } else {
+ crate::ui::compute_view_without_resizing_panes(
+ &mut self.app.state,
+ &self.app.terminal_runtimes,
+ area,
+ );
+ }
+ self.app.full_redraw_pending = false;
+ crate::render_prof::duration_since("full_render.total", full_started);
+ debug!(
+ cols,
+ rows, resize_panes, "updated geometry with no attached clients"
+ );
+ return;
+ }
+
+ let shell_snapshot_template = render_targets
+ .iter()
+ .any(|(_, _, _, _, mode)| matches!(mode, ClientConnectionMode::ClientShell))
+ .then(|| client_shell_snapshot(&self.app, &self.client_shell_boot_id, 0, None));
+ let mut broken_clients: Vec<u64> = Vec::new();
+ for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets {
+ let area = Rect::new(0, 0, cols, rows);
+ let mut shell_projection_revision = 0;
+ if matches!(mode, ClientConnectionMode::ClientShell) {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ continue;
+ };
+ let Some(mut candidate) = shell_snapshot_template.clone() else {
+ continue;
+ };
+ candidate.config_diagnostic = if client.shell_uses_endpoint_keybindings {
+ self.server_config_diagnostic.clone()
+ } else {
+ self.server_config_diagnostic_without_keybindings.clone()
+ };
+ candidate.revision = client.shell_projection_revision;
+ if client.shell_snapshot.as_ref() != Some(&candidate) {
+ client.shell_projection_revision =
+ client.shell_projection_revision.saturating_add(1);
+ candidate.revision = client.shell_projection_revision;
+ let message = ServerMessage::ClientShellSnapshot(Box::new(candidate.clone()));
+ let framed = match Self::frame_server_message(&message) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to frame client shell replacement");
+ broken_clients.push(client_id);
+ continue;
+ }
+ };
+ let Some(writer) = client.writer.as_ref() else {
+ broken_clients.push(client_id);
+ continue;
+ };
+ if writer.control.send(framed).is_err() {
+ broken_clients.push(client_id);
+ continue;
+ }
+ client.shell_snapshot = Some(candidate);
+ }
+ shell_projection_revision = client.shell_projection_revision;
+ }
+ let shell_graphics_delivery = self
+ .clients
+ .get(&client_id)
+ .map(|client| client.shell_graphics_delivery.clone())
+ .unwrap_or_default();
+ let mut surface_parts = None;
+ let frame = match mode {
+ ClientConnectionMode::ClientShell => {
+ let render_started = crate::render_prof::timer();
+ let render_cell_size = if cell_size.is_known() {
+ cell_size
+ } else {
+ crate::kitty_graphics::HostCellSize::default()
+ };
+ let crate::server::client_shell::RenderedPaneSurface {
+ frame,
+ panes,
+ splits,
+ popup,
+ graphics,
+ graphics_delivery: next_graphics_delivery,
+ } = render_client_shell_pane_surface(
+ &mut self.app,
+ area,
+ is_foreground,
+ render_cell_size,
+ &shell_graphics_delivery,
+ client_id,
+ );
+ crate::render_prof::duration_since(
+ "full_render.render_tab_surface_virtual",
+ render_started,
+ );
+ surface_parts = Some((panes, splits, popup, graphics, next_graphics_delivery));
+ frame
+ }
+ ClientConnectionMode::TerminalPending => continue,
+ ClientConnectionMode::TerminalAttach { terminal_id }
+ | ClientConnectionMode::TerminalObserve { terminal_id } => {
+ let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else {
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some(format!(
+ "terminal attach ended: terminal {terminal_id} not found"
+ )),
+ },
+ );
+ broken_clients.push(client_id);
+ continue;
+ };
+ let render_started = crate::render_prof::timer();
+ let (buffer, cursor) =
+ crate::server::render_stream::render_terminal_virtual(runtime, area);
+ crate::render_prof::duration_since(
+ "full_render.render_terminal_virtual",
+ render_started,
+ );
+ let hyperlinks_started = crate::render_prof::timer();
+ let hyperlinks = runtime.visible_hyperlinks(area);
+ crate::render_prof::duration_since(
+ "full_render.visible_hyperlinks",
+ hyperlinks_started,
+ );
+ let frame_started = crate::render_prof::timer();
+ let frame = FrameData::from_ratatui_buffer_with_hyperlinks(
+ &buffer,
+ cursor,
+ &hyperlinks,
+ );
+ crate::render_prof::duration_since("full_render.frame_build", frame_started);
+ frame
+ }
+ };
+
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ continue;
+ };
+ let Some(writer) = client.writer.as_ref().cloned() else {
+ crate::render_prof::event("full_render.writer_missing");
+ continue;
+ };
+ let has_graphics = surface_parts
+ .as_ref()
+ .is_some_and(|(_, _, _, graphics, _)| {
+ !graphics.assets.is_empty()
+ || !graphics.placements.is_empty()
+ || !graphics.retained_assets.is_empty()
+ });
+ let mut next_shell_graphics_delivery = None;
+ let prepared = if let Some((panes, splits, popup, graphics, delivery)) = surface_parts {
+ next_shell_graphics_delivery = Some(delivery);
+ client
+ .render_state
+ .prepare_pane_surface(protocol::PaneSurfaceFrame {
+ boot_id: self.client_shell_boot_id.clone(),
+ projection_revision: shell_projection_revision,
+ surface_revision: 0,
+ frame,
+ panes,
+ splits,
+ popup,
+ graphics,
+ })
+ } else {
+ client.render_state.prepare_frame(frame)
+ };
+ let Some(mut prepared) = prepared else {
+ client.clear_deferred_render();
+ crate::render_prof::event("full_render.skip_identical");
+ continue;
+ };
+ let max = if has_graphics {
+ MAX_GRAPHICS_FRAME_SIZE
+ } else {
+ crate::protocol::MAX_FRAME_SIZE
+ };
+ let mut shell_assets_deferred = false;
+ let serialized = match Self::frame_server_message_with_max(prepared.message(), max) {
+ Ok(frame) => frame,
+ Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => {
+ warn!(
+ client_id,
+ claimed, max, "dropping graphics assets from oversized pane surface"
+ );
+ if !prepared.strip_pane_surface_assets() {
+ crate::render_prof::event("full_render.serialize_oversized");
+ continue;
+ }
+ next_shell_graphics_delivery = None;
+ shell_assets_deferred = true;
+ match Self::frame_server_message(prepared.message()) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to serialize pane surface without assets");
+ broken_clients.push(client_id);
+ crate::render_prof::event("full_render.serialize_error");
+ continue;
+ }
+ }
+ }
+ Err(protocol::FramingError::Oversized { claimed, max }) => {
+ warn!(
+ client_id,
+ claimed, max, "skipping oversized frame for client"
+ );
+ crate::render_prof::event("full_render.serialize_oversized");
+ continue;
+ }
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to serialize frame");
+ broken_clients.push(client_id);
+ crate::render_prof::event("full_render.serialize_error");
+ continue;
+ }
+ };
+ let shell_graphics_pending = next_shell_graphics_delivery
+ .as_ref()
+ .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending);
+ match writer.render.try_send(serialized) {
+ Ok(()) => {
+ if let Some(delivery) = next_shell_graphics_delivery {
+ client.shell_graphics_delivery = delivery;
+ }
+ client.render_state.commit_sent_frame(prepared);
+ if shell_graphics_pending || shell_assets_deferred {
+ client.defer_full_render();
+ } else {
+ client.clear_deferred_render();
+ }
+ crate::render_prof::event("full_render.sent");
+ }
+ Err(std::sync::mpsc::TrySendError::Full(_)) => {
+ client.defer_full_render();
+ }
+ Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
+ broken_clients.push(client_id);
+ }
+ }
+ }
+
+ if !broken_clients.is_empty() {
+ for client_id in broken_clients {
+ self.remove_client_and_resize_if_needed(client_id);
+ }
+ }
+
+ let (cols, rows) = self.effective_size;
+ // Full-frame recovery is tracked per connection. A slow client must not
+ // keep responsive peers on the global full-render path while it waits
+ // for its render slot to drain.
+ self.app.full_redraw_pending = false;
+ crate::render_prof::duration_since("full_render.total", full_started);
+ debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)");
+ }
+}
diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs
new file mode 100644
index 00000000..b15c4319
--- /dev/null
+++ b/src/server/headless/retained_surface.rs
@@ -0,0 +1,613 @@
+use super::*;
+
+fn rect_fits_frame(rect: protocol::SurfaceRect, frame: &FrameData) -> bool {
+ rect.x.saturating_add(rect.width) <= frame.width
+ && rect.y.saturating_add(rect.height) <= frame.height
+}
+
+fn patch_intersects_hyperlinks(
+ frame: &FrameData,
+ area: protocol::SurfaceRect,
+ patch: &crate::pane::TerminalDirtyPatch,
+) -> bool {
+ if frame.hyperlinks.is_empty() || !rect_fits_frame(area, frame) {
+ return false;
+ }
+ let width = usize::from(frame.width);
+ patch
+ .rows
+ .iter()
+ .filter(|(local_y, _)| *local_y < area.height)
+ .any(|(local_y, _)| {
+ let start = usize::from(area.y + *local_y) * width + usize::from(area.x);
+ let end = start + usize::from(area.width);
+ end > frame.cells.len()
+ || frame.cells[start..end]
+ .iter()
+ .any(|cell| cell.hyperlink.is_some())
+ })
+}
+
+fn apply_patch_row(frame: &mut FrameData, row: &protocol::PaneSurfacePatchRow) -> Option<bool> {
+ if row.y >= frame.height
+ || row.x.saturating_add(u16::try_from(row.cells.len()).ok()?) > frame.width
+ {
+ return None;
+ }
+ let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x);
+ let end = start + row.cells.len();
+ if end > frame.cells.len() {
+ return None;
+ }
+ let changed = frame.cells[start..end] != row.cells;
+ frame.cells[start..end].clone_from_slice(&row.cells);
+ Some(changed)
+}
+
+fn apply_rows(
+ frame: &mut FrameData,
+ area: protocol::SurfaceRect,
+ patch: &crate::pane::TerminalDirtyPatch,
+) -> Option<Vec<protocol::PaneSurfacePatchRow>> {
+ if !rect_fits_frame(area, frame) {
+ return None;
+ }
+ let mut rows = Vec::new();
+ for (local_y, cells) in &patch.rows {
+ if *local_y >= area.height {
+ continue;
+ }
+ let width = usize::from(area.width);
+ if cells.len() < width {
+ return None;
+ }
+ let y = area.y + *local_y;
+ let frame_start = usize::from(y) * usize::from(frame.width) + usize::from(area.x);
+ let frame_end = frame_start.checked_add(width)?;
+ let existing = frame.cells.get(frame_start..frame_end)?;
+ let desired = &cells[..width];
+ let mut offset = 0;
+ while offset < width {
+ if existing[offset] == desired[offset] {
+ offset += 1;
+ continue;
+ }
+ let start = offset;
+ offset += 1;
+ while offset < width && existing[offset] != desired[offset] {
+ offset += 1;
+ }
+ // Include the following cell so a wide-to-narrow (or
+ // narrow-to-wide) transition repaints content covered by the old
+ // grapheme width even when that logical neighbor is unchanged.
+ let end = offset.saturating_add(1).min(width);
+ rows.push(protocol::PaneSurfacePatchRow {
+ x: area.x.checked_add(u16::try_from(start).ok()?)?,
+ y,
+ cells: desired[start..end].to_vec(),
+ });
+ offset = end;
+ }
+ }
+ for row in &rows {
+ apply_patch_row(frame, row)?;
+ }
+ Some(rows)
+}
+
+fn retained_scrollbar_patch(
+ app: &app::App,
+ frame: &mut FrameData,
+ pane: &mut protocol::PaneSurfacePane,
+ alternate_screen_active: bool,
+ metrics: Option<crate::pane::ScrollMetrics>,
+) -> Option<Vec<protocol::PaneSurfacePatchRow>> {
+ let next_rect = metrics
+ .filter(|metrics| metrics.max_offset_from_bottom > 0)
+ .filter(|_| app.state.pane_scrollbars && !alternate_screen_active)
+ .and_then(|_| {
+ let rect = protocol::SurfaceRect {
+ x: pane.inner_rect.x.checked_add(pane.inner_rect.width)?,
+ y: pane.inner_rect.y,
+ width: 1,
+ height: pane.inner_rect.height,
+ };
+ (rect_fits_frame(rect, frame)
+ && rect.x >= pane.rect.x
+ && rect.x < pane.rect.x.saturating_add(pane.rect.width))
+ .then_some(rect)
+ });
+ let patch_rect = next_rect.or(pane.scrollbar_rect);
+ pane.scrollbar_rect = next_rect;
+ let Some(rect) = patch_rect else {
+ return Some(Vec::new());
+ };
+
+ let track = Rect::new(0, 0, 1, rect.height);
+ let mut buffer = ratatui::buffer::Buffer::empty(track);
+ if let (Some(metrics), Some(_)) = (metrics, next_rect) {
+ crate::ui::render_pane_scrollbar_buffer(
+ &mut buffer,
+ metrics,
+ track,
+ &app.state.palette,
+ pane.focused,
+ );
+ }
+ let cells = buffer
+ .content
+ .iter()
+ .map(protocol::CellData::from_ratatui_cell)
+ .collect::<Vec<_>>();
+ let mut rows = Vec::new();
+ for (offset, cell) in cells.into_iter().enumerate() {
+ let row = protocol::PaneSurfacePatchRow {
+ x: rect.x,
+ y: rect.y.checked_add(u16::try_from(offset).ok()?)?,
+ cells: vec![cell],
+ };
+ if apply_patch_row(frame, &row)? {
+ rows.push(row);
+ }
+ }
+ Some(rows)
+}
+
+fn retained_cursor(
+ app: &app::App,
+ panes: &[protocol::PaneSurfacePane],
+) -> Option<protocol::CursorState> {
+ let pane = panes.iter().find(|pane| pane.focused)?;
+ let (workspace_index, pane_id) = app.parse_pane_id(&pane.pane_id)?;
+ if !app.state.pane_exposes_host_cursor(workspace_index, pane_id) {
+ return None;
+ }
+ let runtime = app.state.runtime_for_pane_in_workspace(
+ &app.terminal_runtimes,
+ workspace_index,
+ pane_id,
+ )?;
+ if runtime.synchronized_output_active() {
+ return None;
+ }
+ let area = Rect::new(
+ pane.inner_rect.x,
+ pane.inner_rect.y,
+ pane.inner_rect.width,
+ pane.inner_rect.height,
+ );
+ runtime
+ .cursor_state(area, true)
+ .map(|cursor| protocol::CursorState {
+ x: cursor.x,
+ y: cursor.y,
+ visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime),
+ shape: cursor.shape,
+ })
+}
+
+struct RetainedRecipient {
+ client_id: u64,
+ surface: protocol::PaneSurfaceFrame,
+}
+
+struct CollectedPanePatch {
+ pane_id: String,
+ patch: crate::pane::TerminalDirtyPatch,
+ content_revision: u64,
+ scroll_metrics: Option<crate::pane::ScrollMetrics>,
+ mouse_reporting: bool,
+ sgr_pixel_mouse: bool,
+ alternate_screen_active: bool,
+}
+
+struct RetainedRecipientUpdate {
+ client_id: u64,
+ surface: protocol::PaneSurfaceFrame,
+ patch: protocol::PaneSurfacePatch,
+}
+
+impl HeadlessServer {
+ /// Applies terminal dirty rows to the committed origin-relative pane surface.
+ /// Any presentation or geometry uncertainty falls back to the complete renderer.
+ pub(super) fn render_retained_pane_surface_and_stream(
+ &mut self,
+ pty_sources: &HashSet<crate::layout::PaneId>,
+ ) -> bool {
+ crate::render_prof::event("retained_surface.attempt");
+ let started = crate::render_prof::timer();
+ macro_rules! fallback {
+ ($reason:literal) => {{
+ crate::render_prof::event(concat!("retained_surface.fallback.", $reason));
+ crate::render_prof::duration_since("retained_surface.total", started);
+ return false;
+ }};
+ }
+ macro_rules! success {
+ ($reason:literal) => {{
+ crate::render_prof::event("retained_surface.success");
+ crate::render_prof::event(concat!("retained_surface.success.", $reason));
+ crate::render_prof::duration_since("retained_surface.total", started);
+ return true;
+ }};
+ }
+
+ if pty_sources.is_empty()
+ || self.app.full_redraw_pending
+ || self.app.state.popup_pane.is_some()
+ || self.app.state.reveal_hidden_cursor_for_cjk_ime
+ {
+ 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))
+ {
+ fallback!("non_shell_target");
+ }
+
+ let mut recipients = Vec::with_capacity(targets.len());
+ for (client_id, (cols, rows), _, _, _) in &targets {
+ let Some(client) = self.clients.get(client_id) else {
+ fallback!("client_missing");
+ };
+ if client.deferred_render() != DeferredRender::None {
+ crate::render_prof::event("retained_surface.recipient_deferred");
+ continue;
+ }
+ let Some(surface) = client.render_state.last_pane_surface() else {
+ fallback!("no_baseline");
+ };
+ if surface.boot_id != self.client_shell_boot_id
+ || surface.projection_revision != client.shell_projection_revision
+ || surface.frame.width != *cols
+ || 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");
+ }
+ recipients.push(RetainedRecipient {
+ client_id: *client_id,
+ surface: surface.clone(),
+ });
+ }
+ if recipients.is_empty() {
+ success!("all_recipients_deferred");
+ }
+
+ let mut collected = Vec::with_capacity(pty_sources.len());
+ for source in pty_sources {
+ let mut public_pane_id = None;
+ let mut width = 0u16;
+ let mut height = 0u16;
+ for recipient in &recipients {
+ let Some(pane) = recipient.surface.panes.iter().find(|pane| {
+ self.app
+ .parse_pane_id(&pane.pane_id)
+ .is_some_and(|(_, pane_id)| pane_id == *source)
+ }) else {
+ fallback!("source_not_visible");
+ };
+ public_pane_id.get_or_insert_with(|| pane.pane_id.clone());
+ width = width.max(pane.inner_rect.width);
+ height = height.max(pane.inner_rect.height);
+ }
+ let Some(public_pane_id) = public_pane_id else {
+ fallback!("pane_missing");
+ };
+ let Some((workspace_index, pane_id)) = self.app.parse_pane_id(&public_pane_id) else {
+ fallback!("pane_missing");
+ };
+ let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
+ &self.app.terminal_runtimes,
+ workspace_index,
+ pane_id,
+ ) else {
+ fallback!("runtime_missing");
+ };
+ let revision_before = runtime.content_seq();
+ if !revision_before.is_multiple_of(2) {
+ fallback!("unstable_content");
+ }
+ let patch = match runtime.collect_dirty_patch(width, height) {
+ crate::pane::TerminalDirtyPatchOutcome::Clean => {
+ crate::render_prof::event("retained_surface.pane_clean");
+ crate::pane::TerminalDirtyPatch { rows: Vec::new() }
+ }
+ crate::pane::TerminalDirtyPatchOutcome::Patch(patch) => patch,
+ crate::pane::TerminalDirtyPatchOutcome::Fallback => {
+ fallback!("terminal_patch");
+ }
+ };
+ let revision = runtime.content_seq();
+ if revision != revision_before || !revision.is_multiple_of(2) {
+ fallback!("content_changed");
+ }
+ collected.push(CollectedPanePatch {
+ pane_id: public_pane_id,
+ patch,
+ content_revision: revision,
+ scroll_metrics: runtime.scroll_metrics(),
+ mouse_reporting: runtime.mouse_reporting_enabled(),
+ sgr_pixel_mouse: runtime.sgr_pixel_mouse_enabled(),
+ alternate_screen_active: runtime.alternate_screen_active(),
+ });
+ }
+
+ let mut updates = Vec::with_capacity(recipients.len());
+ for recipient in recipients {
+ let client_id = recipient.client_id;
+ let mut surface = recipient.surface;
+ let projection_revision = surface.projection_revision;
+ let base_surface_revision = surface.surface_revision;
+ let mut changed_panes = Vec::with_capacity(collected.len());
+ let mut patch_rows = Vec::new();
+ let mut metadata_changed = false;
+ for collected_pane in &collected {
+ let Some(pane) = surface
+ .panes
+ .iter_mut()
+ .find(|pane| pane.pane_id == collected_pane.pane_id)
+ else {
+ fallback!("pane_missing");
+ };
+ // Alternate-screen transitions change whether the pane reserves
+ // a scrollbar gutter. Recompute layout and resize the runtime
+ // through the complete renderer before retaining further rows.
+ if pane.alternate_screen_active != collected_pane.alternate_screen_active {
+ fallback!("alternate_screen_geometry");
+ }
+ if patch_intersects_hyperlinks(
+ &surface.frame,
+ pane.inner_rect,
+ &collected_pane.patch,
+ ) {
+ fallback!("hyperlink");
+ }
+ let previous_pane = pane.clone();
+ let Some(rows) =
+ apply_rows(&mut surface.frame, pane.inner_rect, &collected_pane.patch)
+ else {
+ fallback!("invalid_patch");
+ };
+ patch_rows.extend(rows);
+ let Some(scrollbar_rows) = retained_scrollbar_patch(
+ &self.app,
+ &mut surface.frame,
+ pane,
+ collected_pane.alternate_screen_active,
+ collected_pane.scroll_metrics,
+ ) else {
+ fallback!("scrollbar_patch");
+ };
+ patch_rows.extend(scrollbar_rows);
+ pane.content_revision = collected_pane.content_revision;
+ pane.mouse_reporting = collected_pane.mouse_reporting;
+ pane.sgr_pixel_mouse = collected_pane.sgr_pixel_mouse;
+ pane.alternate_screen_active = collected_pane.alternate_screen_active;
+ pane.scroll = collected_pane.scroll_metrics.map(|metrics| {
+ protocol::PaneSurfaceScrollMetrics {
+ offset_from_bottom: metrics.offset_from_bottom as u64,
+ max_offset_from_bottom: metrics.max_offset_from_bottom as u64,
+ viewport_rows: metrics.viewport_rows as u64,
+ }
+ });
+ metadata_changed |= *pane != previous_pane;
+ changed_panes.push(pane.clone());
+ }
+
+ 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 {
+ continue;
+ }
+ let patch = protocol::PaneSurfacePatch {
+ boot_id: self.client_shell_boot_id.clone(),
+ projection_revision,
+ base_surface_revision,
+ surface_revision: 0,
+ rows: patch_rows,
+ panes: changed_panes,
+ cursor,
+ };
+ updates.push(RetainedRecipientUpdate {
+ client_id,
+ surface,
+ patch,
+ });
+ }
+ if updates.is_empty() {
+ success!("unchanged");
+ }
+
+ let mut sent = 0u64;
+ let mut deferred = 0u64;
+ let mut disconnected = Vec::new();
+ for update in updates {
+ let RetainedRecipientUpdate {
+ client_id,
+ surface,
+ patch,
+ } = update;
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ continue;
+ };
+ let Some(writer) = client.writer.as_ref().cloned() else {
+ client.defer_full_render();
+ deferred += 1;
+ continue;
+ };
+ let Some(prepared) = client
+ .render_state
+ .prepare_pane_surface_patch(patch, surface)
+ 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;
+ }
+ };
+ crate::render_prof::counter("retained_surface.bytes", serialized.len() as u64);
+ match writer.render.try_send(serialized) {
+ Ok(()) => {
+ client.clear_deferred_render();
+ client.render_state.commit_sent_frame(prepared);
+ sent += 1;
+ }
+ Err(std::sync::mpsc::TrySendError::Full(_)) => {
+ client.defer_full_render();
+ deferred += 1;
+ }
+ Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
+ disconnected.push(client_id);
+ }
+ }
+ }
+ for client_id in disconnected {
+ self.remove_client_and_resize_if_needed(client_id);
+ }
+ crate::render_prof::counter("retained_surface.recipients.sent", sent);
+ crate::render_prof::counter("retained_surface.recipients.deferred", deferred);
+ if sent > 0 {
+ success!("sent");
+ }
+ success!("recovery_queued");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn cell(symbol: &str) -> protocol::CellData {
+ protocol::CellData {
+ symbol: symbol.to_owned(),
+ fg: 0,
+ bg: 0,
+ modifier: 0,
+ skip: false,
+ hyperlink: None,
+ }
+ }
+
+ #[test]
+ fn retained_rows_send_only_changed_cell_spans() {
+ let mut frame = FrameData {
+ width: 6,
+ height: 2,
+ cells: vec![cell(" "); 12],
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let patch = crate::pane::TerminalDirtyPatch {
+ rows: vec![(0, vec![cell(" "), cell("x"), cell("y"), cell(" ")])],
+ };
+
+ let rows = apply_rows(
+ &mut frame,
+ protocol::SurfaceRect {
+ x: 1,
+ y: 1,
+ width: 4,
+ height: 1,
+ },
+ &patch,
+ )
+ .expect("valid patch");
+
+ assert_eq!(
+ rows,
+ vec![protocol::PaneSurfacePatchRow {
+ x: 2,
+ y: 1,
+ cells: vec![cell("x"), cell("y"), cell(" ")],
+ }]
+ );
+ assert_eq!(frame.cells[8], cell("x"));
+ assert_eq!(frame.cells[9], cell("y"));
+ }
+
+ #[test]
+ fn retained_rows_include_the_cell_after_a_width_transition() {
+ let mut frame = FrameData {
+ width: 3,
+ height: 1,
+ cells: vec![cell("界"), cell("z"), cell("q")],
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let patch = crate::pane::TerminalDirtyPatch {
+ rows: vec![(0, vec![cell("x"), cell("z"), cell("q")])],
+ };
+
+ let rows = apply_rows(
+ &mut frame,
+ protocol::SurfaceRect {
+ x: 0,
+ y: 0,
+ width: 3,
+ height: 1,
+ },
+ &patch,
+ )
+ .expect("valid patch");
+
+ assert_eq!(
+ rows,
+ vec![protocol::PaneSurfacePatchRow {
+ x: 0,
+ y: 0,
+ cells: vec![cell("x"), cell("z")],
+ }]
+ );
+ }
+
+ #[test]
+ fn retained_rows_omit_unchanged_full_dirty_rows() {
+ let mut frame = FrameData {
+ width: 4,
+ height: 2,
+ cells: vec![cell(" "); 8],
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let patch = crate::pane::TerminalDirtyPatch {
+ rows: vec![(0, vec![cell(" "); 4]), (1, vec![cell(" "); 4])],
+ };
+
+ let rows = apply_rows(
+ &mut frame,
+ protocol::SurfaceRect {
+ x: 0,
+ y: 0,
+ width: 4,
+ height: 2,
+ },
+ &patch,
+ )
+ .expect("valid patch");
+
+ assert!(rows.is_empty());
+ }
+}
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs
new file mode 100644
index 00000000..102f2fc6
--- /dev/null
+++ b/src/server/headless/tests/mod.rs
@@ -0,0 +1,5025 @@
+use super::*;
+
+#[path = "pane_graphics.rs"]
+mod pane_graphics_tests;
+
+fn test_headless_server() -> HeadlessServer {
+ test_headless_server_with_event_hub(api::EventHub::default())
+}
+
+fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServer {
+ let config = crate::config::Config::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let app = crate::app::App::new(
+ &config,
+ crate::app::AppPolicy::TEST,
+ None,
+ api_rx,
+ event_hub,
+ );
+
+ let dir = std::env::temp_dir().join(format!(
+ "hh-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ let _ = fs::create_dir_all(&dir);
+ let socket_path = dir.join("client.sock");
+ let _ = fs::remove_file(&socket_path);
+ let listener = bind_local_listener(&socket_path).expect("bind test listener");
+ let client_socket_identity =
+ socket_file_identity(&socket_path).expect("test listener socket identity");
+ #[cfg(unix)]
+ listener
+ .set_nonblocking(ListenerNonblockingMode::Accept)
+ .expect("set listener nonblocking");
+ let (server_event_tx, server_event_rx) = mpsc::channel(64);
+ let should_quit = Arc::new(AtomicBool::new(false));
+ #[cfg(windows)]
+ spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
+ let server_keybindings = app_keybindings(&app);
+ let headless_size = app.state.headless_size;
+
+ HeadlessServer {
+ app,
+ #[cfg(unix)]
+ api_tx: None,
+ api_server: None,
+ #[cfg(unix)]
+ client_listener: listener,
+ client_socket_path: socket_path,
+ client_socket_identity,
+ clients: HashMap::new(),
+ #[cfg(unix)]
+ next_client_id: 1,
+ foreground_client_id: None,
+ client_shell_boot_id: "test-boot".into(),
+ sent_window_title: None,
+ api_window_title: None,
+ server_keybindings,
+ server_config_diagnostic: None,
+ server_config_diagnostic_without_keybindings: None,
+ terminal_attach_owners: HashMap::new(),
+ pending_alt_screen_reads: Vec::new(),
+ deferred_alt_screen_reads: Vec::new(),
+ next_activity_stamp: 1,
+ headless_size,
+ effective_size: headless_size,
+ shutting_down: false,
+ handoff_in_progress: false,
+ #[cfg(unix)]
+ pending_handoff_repaint_nudge: false,
+ should_quit,
+ server_event_rx,
+ server_event_tx,
+ }
+}
+
+fn shutdown_test_runtimes(server: &mut HeadlessServer) {
+ for (_, runtime) in server.app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+}
+
+fn read_server_message(bytes: Vec<u8>) -> ServerMessage {
+ let mut cursor = std::io::Cursor::new(bytes);
+ protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message")
+}
+
+fn frame_text(frame: &FrameData) -> String {
+ frame
+ .cells
+ .chunks(usize::from(frame.width))
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n")
+}
+
+fn read_server_shutdown_reason(bytes: Vec<u8>) -> Option<String> {
+ match read_server_message(bytes) {
+ ServerMessage::ServerShutdown { reason } => reason,
+ other => panic!("expected shutdown, got {other:?}"),
+ }
+}
+
+#[test]
+fn completed_handoff_disables_only_old_server_session_persistence() {
+ let mut server = test_headless_server();
+ server.app.policy = crate::app::AppPolicy::PRODUCTION;
+
+ server.finish_live_handoff_shutdown();
+
+ assert!(!server.app.policy.persist_session);
+ assert!(server.app.policy.restore_session);
+ assert!(server.app.policy.persist_plugin_registry);
+ assert!(server.app.policy.background_updates);
+}
+
+#[test]
+fn default_headless_size_is_effective_without_clients() {
+ let server = test_headless_server();
+
+ assert_eq!(
+ server.headless_size,
+ (
+ crate::config::DEFAULT_HEADLESS_COLS,
+ crate::config::DEFAULT_HEADLESS_ROWS
+ )
+ );
+ assert_eq!(server.effective_size, server.headless_size);
+}
+
+#[tokio::test]
+async fn headless_api_reads_latest_title_without_spinner_event_flooding() {
+ let event_hub = api::EventHub::default();
+ let mut server = test_headless_server_with_event_hub(event_hub.clone());
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+ server.app.state.sidebar_agents.rows = vec![vec![
+ crate::config::AgentSidebarToken::TerminalTitleStripped,
+ ]];
+ let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .detected_agent = Some(crate::detect::Agent::Claude);
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07");
+ server
+ .app
+ .terminal_runtimes
+ .insert(terminal_id.clone(), runtime);
+ server.app.render_dirty.request_terminal_title(pane_id);
+
+ let first = headless_pane_list(&mut server).pop().unwrap();
+ assert_eq!(first.terminal_title.as_deref(), Some("â ‹ task"));
+ assert_eq!(first.terminal_title_stripped.as_deref(), Some("task"));
+ assert_eq!(pane_updated_events(&event_hub), 1);
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .unwrap()
+ .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\");
+ server.app.render_dirty.request_terminal_title(pane_id);
+ let second = headless_pane_list(&mut server).pop().unwrap();
+ assert_eq!(second.terminal_title.as_deref(), Some("â ™ task"));
+ assert_eq!(second.terminal_title_stripped.as_deref(), Some("task"));
+ assert_eq!(pane_updated_events(&event_hub), 1);
+}
+
+fn headless_pane_list(server: &mut HeadlessServer) -> Vec<api::schema::PaneInfo> {
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "list-titles".into(),
+ method: api::schema::Method::PaneList(api::schema::PaneListParams::default()),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+ let response: api::schema::SuccessResponse =
+ serde_json::from_str(&response_rx.recv().unwrap()).unwrap();
+ let api::schema::ResponseResult::PaneList { panes } = response.result else {
+ panic!("expected pane list");
+ };
+ panes
+}
+
+fn pane_updated_events(event_hub: &api::EventHub) -> usize {
+ event_hub
+ .events_after(0)
+ .iter()
+ .filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated)
+ .count()
+}
+
+#[test]
+fn server_stop_interrupts_server_event_backlog() {
+ let mut server = test_headless_server();
+ for client_id in 1..=64 {
+ server
+ .server_event_tx
+ .try_send(ServerEvent::ClientDisconnected { client_id })
+ .unwrap();
+ }
+
+ server.should_quit.store(true, Ordering::Release);
+
+ assert!(!server.drain_server_events());
+ assert!(server.server_event_rx.try_recv().is_ok());
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn headless_api_request_drains_all_pending_internal_events_before_reading_state() {
+ let mut server = test_headless_server();
+ for i in 0..=crate::app::APP_EVENT_DRAIN_LIMIT {
+ server
+ .app
+ .event_tx
+ .try_send(AppEvent::UpdateReady {
+ version: format!("4.0.{i}"),
+ install_command: "herdr install".into(),
+ })
+ .unwrap();
+ }
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ assert!(
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "headless_stop_after_events".into(),
+ method: api::schema::Method::ServerStop(api::schema::EmptyParams::default()),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ })
+ );
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+
+ assert_eq!(response["result"]["type"], "ok");
+ let expected_version = format!("4.0.{}", crate::app::APP_EVENT_DRAIN_LIMIT);
+ assert_eq!(
+ server.app.state.update_available.as_deref(),
+ Some(expected_version.as_str())
+ );
+ assert!(server.app.event_rx.try_recv().is_err());
+}
+
+fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver<Vec<u8>>) {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+
+ let (client_tx, control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.promote_client_to_foreground(1);
+ drain_window_titles(&control_rx);
+ (server, control_rx)
+}
+
+/// The test client writer drains its queue on a background thread, so
+/// reading a pushed message needs a timeout rather than `try_recv`.
+fn next_window_title(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> Option<Option<String>> {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
+ let Ok(bytes) = control_rx.recv_timeout(remaining) else {
+ return None;
+ };
+ if let ServerMessage::WindowTitle { title } = read_server_message(bytes) {
+ return Some(title);
+ }
+ }
+ None
+}
+
+fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) {
+ while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {}
+}
+
+fn no_window_title(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> bool {
+ while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) {
+ if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) {
+ return false;
+ }
+ }
+ true
+}
+
+#[test]
+fn window_title_waits_for_a_foreground_client_to_exist() {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")];
+ server.app.state.active = Some(0);
+ server.app.configure_window_title("{workspace}");
+
+ // The server renders before the first client attaches. Nothing was
+ // delivered, so nothing may be recorded as delivered either.
+ server.sync_window_title();
+ assert_eq!(server.sent_window_title, None);
+
+ let (client_tx, control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.promote_client_to_foreground(1);
+ server.sync_window_title();
+
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd".to_string()))
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() {
+ let (mut server, first_control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&first_control_rx),
+ Some(Some("herd".to_string()))
+ );
+
+ // ClientConnected assigns the foreground client directly rather than
+ // going through promote_client_to_foreground, so the cache must notice
+ // the new client on its own.
+ let (client_tx, second_control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_window_title();
+
+ assert_eq!(
+ next_window_title(&second_control_rx),
+ Some(Some("herd".to_string()))
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn configured_window_title_reaches_the_foreground_client_once_per_change() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}/{tab}");
+
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd/1".to_string()))
+ );
+
+ // An unchanged title must not re-emit an OSC on every render.
+ server.sync_window_title();
+ assert!(no_window_title(&control_rx));
+
+ server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into());
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd/build".to_string()))
+ );
+
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("{terminal_title}");
+ server.app.state.ensure_test_terminals();
+ let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = server.app.state.workspaces[0]
+ .terminal_id(pane_id)
+ .expect("terminal")
+ .clone();
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ runtime.test_process_pty_bytes("\x1b]0;â ‹ building\x07".as_bytes());
+ server
+ .app
+ .terminal_runtimes
+ .insert(terminal_id.clone(), runtime);
+
+ assert_eq!(
+ server.sync_terminal_title_sources(&HashSet::from([pane_id])),
+ (false, true)
+ );
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("building".to_string()))
+ );
+
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("runtime")
+ .test_process_pty_bytes("\x1b]0;â ™ building\x07".as_bytes());
+ assert_eq!(
+ server.sync_terminal_title_sources(&HashSet::from([pane_id])),
+ (false, true)
+ );
+ assert!(no_window_title(&control_rx));
+
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() {
+ let (mut server, _control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+
+ // A detached client keeps its entry but loses its writer, so nothing
+ // reaches a terminal even though the targeted send reports success.
+ if let Some(client) = server.clients.get_mut(&1) {
+ client.writer = None;
+ }
+ server.sync_window_title();
+ assert!(server.sent_window_title.is_none());
+
+ // Attaching again has to deliver the title rather than skip it as sent.
+ let (client_tx, control_rx, _render_rx) = test_client_writer();
+ if let Some(client) = server.clients.get_mut(&1) {
+ client.writer = Some(client_tx);
+ }
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd".to_string()))
+ );
+
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn empty_window_title_config_leaves_the_outer_title_alone() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("");
+
+ server.sync_window_title();
+
+ assert!(no_window_title(&control_rx));
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn api_window_title_wins_until_it_is_cleared() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+
+ server.handle_client_window_title_api("set".into(), Some("herdr api".into()));
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herdr api".to_string()))
+ );
+
+ server.app.state.workspaces[0].custom_name = Some("ops".into());
+ server.sync_window_title();
+ assert!(no_window_title(&control_rx));
+
+ // Clearing hands the title back to ui.window_title, not to "herdr".
+ server.handle_client_window_title_api("clear".into(), None);
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("ops".to_string()))
+ );
+
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("");
+
+ server.handle_client_window_title_api("set".into(), Some("herdr api".into()));
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herdr api".to_string()))
+ );
+
+ server.handle_client_window_title_api("clear".into(), None);
+ assert_eq!(next_window_title(&control_rx), Some(None));
+
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn a_newly_promoted_client_gets_the_window_title_again() {
+ let (mut server, first_control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&first_control_rx),
+ Some(Some("herd".to_string()))
+ );
+
+ // A second terminal starts on whatever its shell or ssh left behind.
+ let (client_tx, second_control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.promote_client_to_foreground(2);
+ server.sync_window_title();
+
+ assert_eq!(
+ next_window_title(&second_control_rx),
+ Some(Some("herd".to_string()))
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+fn test_client_writer() -> (
+ ClientWriter,
+ std::sync::mpsc::Receiver<Vec<u8>>,
+ std::sync::mpsc::Receiver<Vec<u8>>,
+) {
+ let (control_tx, control_rx) = std::sync::mpsc::channel();
+ let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1);
+ (
+ ClientWriter::test_channel(control_tx, render_tx),
+ control_rx,
+ render_rx,
+ )
+}
+
+#[tokio::test]
+async fn client_shell_attach_seeds_workspace() {
+ let mut server = test_headless_server();
+ server.app.state.workspaces.clear();
+ server.app.state.active = None;
+ server.app.state.mode = crate::app::Mode::Navigate;
+ let (writer, _control_rx, _render_rx) = test_client_writer();
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id: 6,
+ surface_cols: 80,
+ surface_rows: 23,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ writer,
+ })
+ );
+
+ assert_eq!(server.app.state.mode, crate::app::Mode::Terminal);
+ assert_eq!(server.app.state.workspaces.len(), 1);
+ assert_eq!(server.app.state.active, Some(0));
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_endpoint_request_uses_the_selected_connection() {
+ let mut server = test_headless_server();
+ let (writer, control_rx, _render_rx) = test_client_writer();
+ let client_id = 41;
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id,
+ surface_cols: 80,
+ surface_rows: 23,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ writer,
+ })
+ );
+ let _initial_snapshot = control_rx.recv().expect("initial shell snapshot");
+ 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: "client-shell:1".into(),
+ method: api::schema::Method::IntegrationList(api::schema::EmptyParams::default(),),
+ }),
+ })
+ );
+ assert!(server.clients[&client_id].shell_endpoint_command_in_flight);
+
+ let response_ready = server
+ .server_event_rx
+ .recv()
+ .await
+ .expect("endpoint response ready");
+ assert!(!server.handle_server_event(response_ready));
+ assert!(!server.clients[&client_id].shell_endpoint_command_in_flight);
+
+ match read_server_message(control_rx.recv().expect("endpoint response")) {
+ ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id: response_boot_id,
+ request_id,
+ final_chunk,
+ data,
+ } => {
+ assert_eq!(response_boot_id, boot_id);
+ assert_eq!(request_id, "client-shell:1");
+ assert!(final_chunk);
+ let response = serde_json::from_slice::<api::schema::SuccessResponse>(&data)
+ .expect("success response");
+ assert_eq!(response.id, "client-shell:1");
+ assert!(matches!(
+ response.result,
+ api::schema::ResponseResult::IntegrationList { .. }
+ ));
+ }
+ other => panic!("expected client shell endpoint response, got {other:?}"),
+ }
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_receives_metadata_then_shell_free_pane_surface() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("shell-only-label");
+ let pane_id = workspace.focused_pane_id().expect("focused pane");
+ workspace.insert_test_runtime(
+ pane_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ 80,
+ 23,
+ b"\x1b[?1003h\x1b[?1006h\x1b[?1016hCLIENT_SHELL_LIVE",
+ ),
+ );
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+ server.app.state.product_announcement = Some(crate::app::state::ProductAnnouncementState {
+ version: "0.8.2".into(),
+ id: "client-shell".into(),
+ title: "Client shell".into(),
+ body: "announcement".into(),
+ scroll: 0,
+ preview: true,
+ });
+ server.server_config_diagnostic_without_keybindings = Some("endpoint config warning".into());
+
+ let (writer, control_rx, render_rx) = test_client_writer();
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id: 7,
+ surface_cols: 80,
+ surface_rows: 23,
+ cell_width_px: 10,
+ cell_height_px: 20,
+ pixel_mouse: true,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ writer,
+ })
+ );
+ match read_server_message(control_rx.recv().expect("shell snapshot")) {
+ ServerMessage::ClientShellSnapshot(snapshot) => {
+ assert_eq!(snapshot.workspaces.len(), 1);
+ assert_eq!(snapshot.workspaces[0].label, "shell-only-label");
+ assert_eq!(
+ snapshot.config_diagnostic.as_deref(),
+ Some("endpoint config warning")
+ );
+ assert_eq!(
+ snapshot.product_announcement.as_ref().map(|announcement| (
+ announcement.version.as_str(),
+ announcement.id.as_str(),
+ announcement.preview,
+ )),
+ Some(("0.8.2", "client-shell", true))
+ );
+ }
+ other => panic!("expected client shell snapshot, got {other:?}"),
+ }
+
+ server.render_and_stream();
+ let initial_surface = match read_server_message(render_rx.recv().expect("pane surface")) {
+ ServerMessage::PaneSurface(surface) => {
+ assert_eq!((surface.frame.width, surface.frame.height), (80, 23));
+ let text = frame_text(&surface.frame);
+ assert!(text.contains("CLIENT_SHELL_LIVE"), "surface: {text:?}");
+ assert!(!text.contains("shell-only-label"), "surface: {text:?}");
+ assert_eq!(surface.panes.len(), 1);
+ assert_eq!(surface.panes[0].rect.x, 0);
+ assert_eq!(surface.panes[0].rect.y, 0);
+ assert!(surface.panes[0].sgr_pixel_mouse);
+ assert_eq!(
+ surface.panes[0].pixel_width,
+ u32::from(surface.panes[0].inner_rect.width) * 10
+ );
+ assert_eq!(
+ surface.panes[0].pixel_height,
+ u32::from(surface.panes[0].inner_rect.height) * 20
+ );
+ surface
+ }
+ other => panic!("expected pane surface, got {other:?}"),
+ };
+
+ server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("pane runtime")
+ .test_process_pty_bytes(b"\rPATCHED");
+ let sources = std::collections::HashSet::from([pane_id]);
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ match read_server_message(render_rx.recv().expect("pane surface patch")) {
+ ServerMessage::PaneSurfacePatch(patch) => {
+ assert_eq!(
+ patch.base_surface_revision,
+ initial_surface.surface_revision
+ );
+ assert_eq!(patch.surface_revision, initial_surface.surface_revision + 1);
+ assert_eq!(patch.panes.len(), 1);
+ assert!(!patch.rows.is_empty());
+ assert!(patch
+ .rows
+ .iter()
+ .flat_map(|row| &row.cells)
+ .any(|cell| cell.symbol == "P"));
+ }
+ other => panic!("expected pane surface patch, got {other:?}"),
+ }
+ server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("pane runtime")
+ .test_process_pty_bytes(b"\x1b[?1003l\x1b[?1006l\x1b[?1016l");
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ match read_server_message(render_rx.recv().expect("metadata-only pane surface patch")) {
+ ServerMessage::PaneSurfacePatch(patch) => {
+ assert_eq!(patch.panes.len(), 1);
+ assert!(!patch.panes[0].mouse_reporting);
+ assert!(!patch.panes[0].sgr_pixel_mouse);
+ }
+ other => panic!("expected metadata-only pane surface patch, got {other:?}"),
+ }
+ let retained = server.clients[&7]
+ .render_state
+ .last_pane_surface()
+ .expect("committed retained surface")
+ .clone();
+ server
+ .clients
+ .get_mut(&7)
+ .unwrap()
+ .render_state
+ .request_repaint();
+ server.render_and_stream();
+ let full = match read_server_message(render_rx.recv().expect("full comparison surface")) {
+ ServerMessage::PaneSurface(surface) => surface,
+ other => panic!("expected full comparison surface, got {other:?}"),
+ };
+ assert!(full.surface_revision > retained.surface_revision);
+ assert_eq!(retained.frame, full.frame);
+ assert_eq!(retained.panes, full.panes);
+ assert_eq!(retained.splits, full.splits);
+ shutdown_test_runtimes(&mut server);
+}
+
+fn install_shared_view_test_runtime(server: &mut HeadlessServer) -> crate::layout::PaneId {
+ let mut workspace = crate::workspace::Workspace::test_new("shared-view");
+ let pane_id = workspace.focused_pane_id().expect("focused pane");
+ workspace.insert_test_runtime(
+ pane_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"BASE"),
+ );
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+ pane_id
+}
+
+fn connect_test_shell(
+ server: &mut HeadlessServer,
+ client_id: u64,
+ surface_cols: u16,
+ surface_rows: u16,
+) -> (
+ std::sync::mpsc::Receiver<Vec<u8>>,
+ std::sync::mpsc::Receiver<Vec<u8>>,
+) {
+ let (writer, control, render) = test_client_writer();
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id,
+ surface_cols,
+ surface_rows,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ writer,
+ })
+ );
+ (control, render)
+}
+
+fn connect_matching_test_shell(
+ server: &mut HeadlessServer,
+ client_id: u64,
+) -> (
+ std::sync::mpsc::Receiver<Vec<u8>>,
+ std::sync::mpsc::Receiver<Vec<u8>>,
+) {
+ connect_test_shell(server, client_id, 80, 23)
+}
+
+fn write_shared_test_pane(
+ server: &mut HeadlessServer,
+ pane_id: crate::layout::PaneId,
+ bytes: &[u8],
+) {
+ server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("pane runtime")
+ .test_process_pty_bytes(bytes);
+}
+
+fn recv_pane_surface(
+ receiver: &std::sync::mpsc::Receiver<Vec<u8>>,
+ context: &str,
+) -> crate::protocol::PaneSurfaceFrame {
+ match read_server_message(
+ receiver
+ .recv()
+ .unwrap_or_else(|error| panic!("{context}: {error}")),
+ ) {
+ ServerMessage::PaneSurface(surface) => surface,
+ other => panic!("{context}: expected pane surface, got {other:?}"),
+ }
+}
+
+fn recv_pane_surface_patch(
+ receiver: &std::sync::mpsc::Receiver<Vec<u8>>,
+ context: &str,
+) -> crate::protocol::PaneSurfacePatch {
+ match read_server_message(
+ receiver
+ .recv()
+ .unwrap_or_else(|error| panic!("{context}: {error}")),
+ ) {
+ ServerMessage::PaneSurfacePatch(patch) => patch,
+ other => panic!("{context}: expected pane surface patch, got {other:?}"),
+ }
+}
+
+#[tokio::test]
+async fn different_size_shells_receive_geometry_specific_patches_from_one_dirty_collection() {
+ let mut server = test_headless_server();
+ let pane_id = install_shared_view_test_runtime(&mut server);
+ let (large_control, large_render) = connect_test_shell(&mut server, 7, 80, 23);
+ let (small_control, small_render) = connect_test_shell(&mut server, 8, 68, 17);
+ let _ = large_control.recv().expect("large snapshot");
+ let _ = small_control.recv().expect("small snapshot");
+ server.render_and_stream();
+ let large_initial = recv_pane_surface(&large_render, "large initial surface");
+ let small_initial = recv_pane_surface(&small_render, "small initial surface");
+ assert_eq!(
+ (large_initial.frame.width, large_initial.frame.height),
+ (80, 23)
+ );
+ assert_eq!(
+ (small_initial.frame.width, small_initial.frame.height),
+ (68, 17)
+ );
+ assert_ne!(
+ large_initial.panes[0].inner_rect,
+ small_initial.panes[0].inner_rect
+ );
+
+ write_shared_test_pane(&mut server, pane_id, b"\rMIXED");
+ assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id])));
+
+ let large_patch = recv_pane_surface_patch(&large_render, "large retained patch");
+ let small_patch = recv_pane_surface_patch(&small_render, "small retained patch");
+ assert_eq!(
+ large_patch.base_surface_revision,
+ large_initial.surface_revision
+ );
+ assert_eq!(
+ small_patch.base_surface_revision,
+ small_initial.surface_revision
+ );
+ assert!(large_patch.rows.iter().all(|row| {
+ row.x
+ .saturating_add(u16::try_from(row.cells.len()).unwrap_or(u16::MAX))
+ <= large_initial.frame.width
+ && row.y < large_initial.frame.height
+ }));
+ assert!(small_patch.rows.iter().all(|row| {
+ row.x
+ .saturating_add(u16::try_from(row.cells.len()).unwrap_or(u16::MAX))
+ <= small_initial.frame.width
+ && row.y < small_initial.frame.height
+ }));
+ assert_eq!(large_patch.rows, small_patch.rows);
+ assert_ne!(
+ large_patch.panes[0].inner_rect,
+ small_patch.panes[0].inner_rect
+ );
+ assert!(frame_text(
+ &server.clients[&7]
+ .render_state
+ .last_pane_surface()
+ .expect("large retained surface")
+ .frame
+ )
+ .contains("MIXED"));
+ assert!(frame_text(
+ &server.clients[&8]
+ .render_state
+ .last_pane_surface()
+ .expect("small retained surface")
+ .frame
+ )
+ .contains("MIXED"));
+
+ write_shared_test_pane(&mut server, pane_id, b"\x1b[?1049hALT");
+ assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id])));
+ server.render_and_stream();
+ let large_alt = recv_pane_surface(&large_render, "large alternate-screen surface");
+ let small_alt = recv_pane_surface(&small_render, "small alternate-screen surface");
+ assert!(large_alt.panes[0].alternate_screen_active);
+ assert!(small_alt.panes[0].alternate_screen_active);
+ assert_eq!(
+ large_alt.panes[0].inner_rect.width,
+ large_initial.panes[0].inner_rect.width + 1
+ );
+ assert_eq!(
+ small_alt.panes[0].inner_rect.width,
+ small_initial.panes[0].inner_rect.width + 1
+ );
+
+ write_shared_test_pane(&mut server, pane_id, b"\x1b[?1049l");
+ assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id])));
+ server.render_and_stream();
+ let large_main = recv_pane_surface(&large_render, "large restored main-screen surface");
+ let small_main = recv_pane_surface(&small_render, "small restored main-screen surface");
+ assert!(!large_main.panes[0].alternate_screen_active);
+ assert!(!small_main.panes[0].alternate_screen_active);
+ assert_eq!(
+ large_main.panes[0].inner_rect,
+ large_initial.panes[0].inner_rect
+ );
+ assert_eq!(
+ small_main.panes[0].inner_rect,
+ small_initial.panes[0].inner_rect
+ );
+
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn backpressured_shell_does_not_disable_retained_patches_for_responsive_peer() {
+ let mut server = test_headless_server();
+ let pane_id = install_shared_view_test_runtime(&mut server);
+ let (responsive_control, responsive_render) = connect_matching_test_shell(&mut server, 7);
+ let (slow_control, slow_render) = connect_matching_test_shell(&mut server, 8);
+ let _ = responsive_control.recv().expect("responsive snapshot");
+ let _ = slow_control.recv().expect("slow snapshot");
+ server.render_and_stream();
+ let _ = responsive_render
+ .recv()
+ .expect("responsive initial surface");
+ let _ = slow_render.recv().expect("slow initial surface");
+
+ let sources = HashSet::from([pane_id]);
+ write_shared_test_pane(&mut server, pane_id, b"\rONE");
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ assert!(matches!(
+ read_server_message(responsive_render.recv().expect("responsive first patch")),
+ ServerMessage::PaneSurfacePatch(_)
+ ));
+
+ write_shared_test_pane(&mut server, pane_id, b"\rTWO");
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ assert!(matches!(
+ read_server_message(responsive_render.recv().expect("responsive second patch")),
+ ServerMessage::PaneSurfacePatch(_)
+ ));
+ assert_eq!(server.clients[&8].deferred_render(), DeferredRender::Full);
+
+ write_shared_test_pane(&mut server, pane_id, b"\rTHREE");
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ assert!(matches!(
+ read_server_message(responsive_render.recv().expect("responsive third patch")),
+ ServerMessage::PaneSurfacePatch(_)
+ ));
+
+ assert!(matches!(
+ read_server_message(slow_render.recv().expect("slow queued first patch")),
+ ServerMessage::PaneSurfacePatch(_)
+ ));
+ assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 8 }));
+ server.render_and_stream();
+ assert!(matches!(
+ read_server_message(slow_render.recv().expect("slow full recovery surface")),
+ ServerMessage::PaneSurface(_)
+ ));
+
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn full_render_backpressure_does_not_disable_responsive_peer_patches() {
+ let mut server = test_headless_server();
+ let pane_id = install_shared_view_test_runtime(&mut server);
+ let (responsive_control, responsive_render) = connect_matching_test_shell(&mut server, 7);
+ let (slow_control, slow_render) = connect_matching_test_shell(&mut server, 8);
+ let _ = responsive_control.recv().expect("responsive snapshot");
+ let _ = slow_control.recv().expect("slow snapshot");
+ server.render_and_stream();
+ let _ = responsive_render
+ .recv()
+ .expect("responsive initial surface");
+ // Keep the slow client's initial surface queued, then force another full
+ // replacement for both clients.
+ server.clients.get_mut(&7).unwrap().request_repaint();
+ server.clients.get_mut(&8).unwrap().request_repaint();
+ server.app.full_redraw_pending = true;
+ server.render_and_stream();
+ let _ = responsive_render
+ .recv()
+ .expect("responsive full replacement");
+ assert_eq!(server.clients[&8].deferred_render(), DeferredRender::Full);
+ assert!(!server.app.full_redraw_pending);
+
+ write_shared_test_pane(&mut server, pane_id, b"\rPATCH");
+ assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id])));
+ assert!(matches!(
+ read_server_message(responsive_render.recv().expect("responsive retained patch")),
+ ServerMessage::PaneSurfacePatch(_)
+ ));
+
+ let _ = slow_render.recv().expect("slow queued initial surface");
+ assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 8 }));
+ server.render_and_stream();
+ assert!(matches!(
+ read_server_message(slow_render.recv().expect("slow full recovery surface")),
+ ServerMessage::PaneSurface(_)
+ ));
+
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_config_diagnostics_follow_keybinding_ownership() {
+ let mut server = test_headless_server();
+ server.server_config_diagnostic = Some("server keybinding warning\ntheme warning".into());
+ server.server_config_diagnostic_without_keybindings = Some("theme warning".into());
+
+ let (local_writer, local_control, _local_render) = test_client_writer();
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id: 13,
+ surface_cols: 80,
+ surface_rows: 23,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ writer: local_writer,
+ })
+ );
+ let ServerMessage::ClientShellSnapshot(local_snapshot) =
+ read_server_message(local_control.recv().expect("local shell snapshot"))
+ else {
+ panic!("expected local shell snapshot");
+ };
+ assert_eq!(
+ local_snapshot.config_diagnostic.as_deref(),
+ Some("theme warning")
+ );
+
+ let (endpoint_writer, endpoint_control, _endpoint_render) = test_client_writer();
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id: 14,
+ surface_cols: 80,
+ surface_rows: 23,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: true,
+ mouse_capture: false,
+ writer: endpoint_writer,
+ })
+ );
+ let ServerMessage::ClientShellSnapshot(endpoint_snapshot) =
+ read_server_message(endpoint_control.recv().expect("endpoint shell snapshot"))
+ else {
+ panic!("expected endpoint shell snapshot");
+ };
+ assert_eq!(
+ endpoint_snapshot.config_diagnostic.as_deref(),
+ Some("server keybinding warning\ntheme warning")
+ );
+
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_replaces_projection_and_focuses_stable_ids() {
+ let mut server = test_headless_server();
+ let first = crate::workspace::Workspace::test_new("first");
+ let second = crate::workspace::Workspace::test_new("second");
+ server.app.state.workspaces = vec![first, second];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+ let second_id = server.app.session_snapshot().workspaces[1]
+ .workspace_id
+ .clone();
+
+ let (writer, control_rx, render_rx) = test_client_writer();
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id: 9,
+ surface_cols: 80,
+ surface_rows: 23,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ writer,
+ })
+ );
+ let initial_revision = match read_server_message(control_rx.recv().expect("initial snapshot")) {
+ ServerMessage::ClientShellSnapshot(snapshot) => snapshot.revision,
+ other => panic!("expected initial shell snapshot, got {other:?}"),
+ };
+
+ let _ = server.app.handle_api_request(crate::api::schema::Request {
+ id: "test.client.shell.workspace.focus".into(),
+ method: crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id: second_id.clone(),
+ }),
+ });
+ assert_eq!(server.app.state.active, Some(1));
+ server.render_and_stream();
+
+ let replacement = match read_server_message(control_rx.recv().expect("replacement snapshot")) {
+ ServerMessage::ClientShellSnapshot(snapshot) => snapshot,
+ other => panic!("expected replacement shell snapshot, got {other:?}"),
+ };
+ assert!(replacement.revision > initial_revision);
+ assert_eq!(
+ replacement.focused_workspace_id.as_deref(),
+ Some(second_id.as_str())
+ );
+ match read_server_message(render_rx.recv().expect("replacement pane surface")) {
+ ServerMessage::PaneSurface(surface) => {
+ assert_eq!(surface.projection_revision, replacement.revision);
+ }
+ other => panic!("expected replacement pane surface, got {other:?}"),
+ }
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_input_targets_runtime_without_server_shell_classification() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1000h\x1b[?1006h");
+ let pane_id = server.app.session_snapshot().focused_pane_id.unwrap();
+ server.clients.insert(
+ 11,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::ClientShell,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 11,
+ pane_id,
+ events: vec![
+ crate::protocol::ClientPaneInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('c'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ shifted_codepoint: None,
+ generated_text: None,
+ tracks_release: true,
+ physical_key_id: None,
+ },
+ crate::protocol::ClientPaneInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('c'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Release,
+ repeat_count: 1,
+ shifted_codepoint: None,
+ generated_text: None,
+ tracks_release: true,
+ physical_key_id: None,
+ },
+ crate::protocol::ClientPaneInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('x'),
+ modifiers: crossterm::event::KeyModifiers::ALT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ shifted_codepoint: None,
+ generated_text: None,
+ tracks_release: true,
+ physical_key_id: None,
+ },
+ crate::protocol::ClientPaneInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Down(
+ crate::protocol::ClientMouseButton::Left,
+ ),
+ position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 },
+ geometry: None,
+ modifiers: 0,
+ lines: 3,
+ },
+ ],
+ })
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("targeted pane interrupt"),
+ Bytes::from_static(&[0x03])
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("targeted pane alt key"),
+ Bytes::from_static(b"\x1bx")
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("targeted pane mouse click"),
+ Bytes::from_static(b"\x1b[<0;3;2M")
+ );
+ assert_eq!(server.foreground_client_id, Some(11));
+ let pane_id = server.app.session_snapshot().focused_pane_id.unwrap();
+ assert!(server.paste_client_clipboard_image_path(
+ 11,
+ crate::protocol::ClientClipboardImageTarget::Pane(pane_id.clone()),
+ "/tmp/client-image.png".into(),
+ ));
+ assert_eq!(
+ input_rx.try_recv().expect("targeted clipboard image path"),
+ Bytes::from_static(b"/tmp/client-image.png")
+ );
+ assert!(!server.paste_client_clipboard_image_path(
+ 11,
+ crate::protocol::ClientClipboardImageTarget::DirectTerminal,
+ "/tmp/wrong-target.png".into(),
+ ));
+ assert!(!server.paste_client_clipboard_image_path(
+ 11,
+ crate::protocol::ClientClipboardImageTarget::Popup("missing-popup".into()),
+ "/tmp/wrong-target.png".into(),
+ ));
+ assert!(input_rx.try_recv().is_err());
+
+ let (workspace_index, runtime_pane_id) = server
+ .app
+ .parse_pane_id(&pane_id)
+ .expect("runtime pane target");
+ let runtime = server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(
+ &server.app.terminal_runtimes,
+ workspace_index,
+ runtime_pane_id,
+ )
+ .expect("focused runtime");
+ assert_eq!(runtime.current_size(), (24, 79));
+ assert!(input_rx.try_recv().is_err(), "legacy release emitted bytes");
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_hidden_pane_rejects_presses_but_accepts_releases() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("hidden-input");
+ let hidden_tab = workspace.test_add_tab(Some("hidden"));
+ let hidden_pane = workspace.tabs[hidden_tab].root_pane;
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[>3u",
+ 4,
+ );
+ workspace.insert_test_runtime(hidden_pane, runtime);
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ let pane_id = server.app.public_pane_id(0, hidden_pane).unwrap();
+ server.clients.insert(
+ 11,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ let key = |kind| crate::protocol::ClientPaneInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('x'),
+ modifiers: 0,
+ kind,
+ repeat_count: 1,
+ shifted_codepoint: None,
+ generated_text: None,
+ tracks_release: true,
+ physical_key_id: Some(0x2d),
+ };
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 11,
+ pane_id: pane_id.clone(),
+ events: vec![key(crate::protocol::ClientKeyKind::Press)],
+ })
+ );
+ assert!(input_rx.try_recv().is_err());
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 11,
+ pane_id,
+ events: vec![key(crate::protocol::ClientKeyKind::Release)],
+ })
+ );
+ assert!(!input_rx.recv().await.expect("encoded release").is_empty());
+ assert_eq!(server.foreground_client_id, None);
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_streams_and_targets_popup_terminal_content() {
+ let mut server = test_headless_server();
+ let mut pane_input = install_focused_test_runtime(&mut server, b"base-pane");
+ let (popup_runtime, mut popup_input) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 40,
+ 12,
+ 0,
+ b"POPUP_SHELL_LIVE\x1b_Ga=T,f=32,t=d,i=9,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\",
+ 4,
+ );
+ let (_, popup_terminal_id) = server.app.install_test_popup_runtime(popup_runtime);
+
+ let (writer, control_rx, render_rx) = test_client_writer();
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id: 12,
+ surface_cols: 80,
+ surface_rows: 23,
+ cell_width_px: 10,
+ cell_height_px: 20,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ writer,
+ })
+ );
+ assert!(matches!(
+ read_server_message(control_rx.recv().expect("shell snapshot")),
+ ServerMessage::ClientShellSnapshot(_)
+ ));
+
+ server.render_and_stream();
+ let ServerMessage::PaneSurface(surface) =
+ read_server_message(render_rx.recv().expect("popup surface"))
+ else {
+ panic!("expected pane surface");
+ };
+ let popup = surface.popup.as_deref().expect("popup terminal surface");
+ assert_eq!(popup.terminal_id, popup_terminal_id.as_str());
+ assert!(frame_text(&popup.frame).contains("POPUP_SHELL_LIVE"));
+ assert_eq!((popup.frame.width, popup.frame.height), (37, 9));
+ assert_eq!(surface.graphics.assets.len(), 1);
+ assert_eq!(surface.graphics.placements.len(), 1);
+ assert!(matches!(
+ surface.graphics.placements[0].asset.source,
+ crate::protocol::SurfaceGraphicsSource::Terminal {
+ target: crate::protocol::SurfaceGraphicsTarget::Popup { .. },
+ image_id: 9,
+ }
+ ));
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 12,
+ pane_id: server.app.session_snapshot().focused_pane_id.unwrap(),
+ events: vec![crate::protocol::ClientPaneInputEvent::TextCommit(
+ "must-not-leak".into(),
+ )],
+ })
+ );
+ assert!(pane_input.try_recv().is_err());
+
+ assert!(server.handle_server_event(ServerEvent::ClientShellResize {
+ client_id: 12,
+ surface_cols: 60,
+ surface_rows: 15,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ }));
+ assert_eq!(
+ server
+ .app
+ .terminal_runtimes
+ .get(&popup_terminal_id)
+ .expect("popup runtime")
+ .current_size(),
+ (5, 27)
+ );
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellPopupInput {
+ client_id: 12,
+ terminal_id: popup_terminal_id.to_string(),
+ events: vec![crate::protocol::ClientPaneInputEvent::TextCommit(
+ "typed".into()
+ )],
+ })
+ );
+ assert_eq!(
+ popup_input.try_recv().expect("popup input"),
+ Bytes::from_static(b"typed")
+ );
+ assert!(server.paste_client_clipboard_image_path(
+ 12,
+ crate::protocol::ClientClipboardImageTarget::Popup(popup_terminal_id.to_string()),
+ "/tmp/popup-image.png".into(),
+ ));
+ assert_eq!(
+ popup_input.try_recv().expect("popup clipboard image path"),
+ Bytes::from_static(b"/tmp/popup-image.png")
+ );
+ assert!(!server.paste_client_clipboard_image_path(
+ 12,
+ crate::protocol::ClientClipboardImageTarget::Popup("stale-popup".into()),
+ "/tmp/wrong-popup.png".into(),
+ ));
+ assert!(popup_input.try_recv().is_err());
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellPopupInput {
+ client_id: 12,
+ terminal_id: "stale-popup".into(),
+ events: vec![crate::protocol::ClientPaneInputEvent::TextCommit(
+ "wrong".into()
+ )],
+ })
+ );
+ assert!(popup_input.try_recv().is_err());
+
+ assert!(server.app.close_popup_pane());
+ server.render_and_stream();
+ let ServerMessage::PaneSurface(surface) =
+ read_server_message(render_rx.recv().expect("popup close surface"))
+ else {
+ panic!("expected pane surface after popup close");
+ };
+ assert!(surface.popup.is_none());
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_release_under_popup_renders_when_it_resets_scrollback() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("popup-release-scroll");
+ let pane_id = workspace.tabs[0].root_pane;
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 2,
+ 10_000,
+ b"one\r\ntwo\r\nthree\r\n\x1b[>3u",
+ 4,
+ );
+ runtime.scroll_up(1);
+ assert!(runtime
+ .scroll_metrics()
+ .is_some_and(|metrics| metrics.offset_from_bottom > 0));
+ workspace.insert_test_runtime(pane_id, runtime);
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap();
+ let popup_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"");
+ server.app.install_test_popup_runtime(popup_runtime);
+ server.clients.insert(
+ 11,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::ClientShell,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(11);
+
+ let render_impact =
+ server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput {
+ client_id: 11,
+ pane_id: public_pane_id,
+ events: vec![crate::protocol::ClientPaneInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('x'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Release,
+ repeat_count: 1,
+ shifted_codepoint: None,
+ generated_text: None,
+ tracks_release: true,
+ physical_key_id: Some(0x2d),
+ }],
+ });
+
+ assert_eq!(render_impact, RenderImpact::Full);
+ assert!(!input_rx.recv().await.expect("encoded release").is_empty());
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_text_input_renders_only_when_resetting_scrollback() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("scrolled-input");
+ let pane_id = workspace.tabs[0].root_pane;
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 2,
+ 10_000,
+ b"one\r\ntwo\r\nthree\r\n",
+ 4,
+ );
+ runtime.scroll_up(1);
+ assert!(runtime
+ .scroll_metrics()
+ .is_some_and(|metrics| metrics.offset_from_bottom > 0));
+ workspace.insert_test_runtime(pane_id, runtime);
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap();
+ server.clients.insert(
+ 11,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::ClientShell,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(11);
+
+ let render_impact =
+ server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput {
+ client_id: 11,
+ pane_id: public_pane_id.clone(),
+ events: vec![crate::protocol::ClientPaneInputEvent::TextCommit(
+ "x".to_owned(),
+ )],
+ });
+
+ assert_eq!(render_impact, RenderImpact::Full);
+ assert_eq!(
+ input_rx.try_recv().expect("text must reach the PTY"),
+ Bytes::from_static(b"x")
+ );
+ assert_eq!(
+ server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .and_then(|runtime| runtime.scroll_metrics())
+ .map(|metrics| metrics.offset_from_bottom),
+ Some(0)
+ );
+
+ let render_impact =
+ server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput {
+ client_id: 11,
+ pane_id: public_pane_id,
+ events: vec![crate::protocol::ClientPaneInputEvent::TextCommit(
+ "y".to_owned(),
+ )],
+ });
+ assert_eq!(render_impact, RenderImpact::None);
+ assert_eq!(
+ input_rx.try_recv().expect("second text must reach the PTY"),
+ Bytes::from_static(b"y")
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_mouse_motion_delivers_without_render_when_foreground() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h");
+ let pane_id = server.app.session_snapshot().focused_pane_id.unwrap();
+ server.clients.insert(
+ 11,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::ClientShell,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(11);
+
+ let render_impact =
+ server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput {
+ client_id: 11,
+ pane_id,
+ events: vec![crate::protocol::ClientPaneInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 },
+ geometry: None,
+ modifiers: 0,
+ lines: 0,
+ }],
+ });
+
+ assert_eq!(render_impact, RenderImpact::None);
+ assert!(
+ input_rx.try_recv().is_ok(),
+ "motion must still reach the PTY"
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_mouse_motion_promotes_and_requests_render() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h");
+ let pane_id = server.app.session_snapshot().focused_pane_id.unwrap();
+ server.clients.insert(
+ 11,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::ClientShell,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+
+ let render_impact =
+ server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput {
+ client_id: 11,
+ pane_id,
+ events: vec![crate::protocol::ClientPaneInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 },
+ geometry: None,
+ modifiers: 0,
+ lines: 0,
+ }],
+ });
+
+ assert_eq!(render_impact, RenderImpact::Full);
+ assert_eq!(server.foreground_client_id, Some(11));
+ assert!(
+ input_rx.try_recv().is_ok(),
+ "motion must still reach the PTY"
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+fn install_focused_test_runtime(
+ server: &mut HeadlessServer,
+ terminal_bytes: &[u8],
+) -> tokio::sync::mpsc::Receiver<Bytes> {
+ let mut workspace = crate::workspace::Workspace::test_new("focus-reporting");
+ let pane_id = workspace.tabs[0].root_pane;
+ let (runtime, input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ terminal_bytes,
+ 4,
+ );
+ workspace.insert_test_runtime(pane_id, runtime);
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+ input_rx
+}
+
+fn retained_test_server(
+ initial_screen: &[u8],
+) -> (
+ HeadlessServer,
+ 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");
+ workspace.insert_test_runtime(
+ pane_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen),
+ );
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+
+ let (client_tx, _client_control_rx, client_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+
+ (server, client_rx, pane_id)
+}
+
+#[test]
+fn server_keybinding_filter_keeps_whole_config_failures() {
+ assert!(!config::is_keybinding_config_diagnostic(
+ "config parse error: invalid value at `keys.new_tab = @`; using defaults"
+ ));
+ assert!(!config::is_keybinding_config_diagnostic(
+ "config read error: permission denied at keys.toml; using defaults"
+ ));
+ assert!(config::is_keybinding_config_diagnostic(
+ "unsafe direct keybinding: keys.close_pane would intercept typing"
+ ));
+}
+
+#[test]
+fn client_shell_host_theme_follows_foreground_client() {
+ let mut server = test_headless_server();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(1);
+
+ let dark = protocol::ClientHostColor {
+ r: 20,
+ g: 30,
+ b: 40,
+ };
+ let blue = protocol::ClientHostColor {
+ r: 10,
+ g: 20,
+ b: 200,
+ };
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellHostTheme {
+ client_id: 1,
+ update: protocol::ClientHostThemeUpdate::DefaultColor {
+ kind: protocol::ClientHostDefaultColorKind::Background,
+ color: dark,
+ },
+ })
+ );
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellHostTheme {
+ client_id: 1,
+ update: protocol::ClientHostThemeUpdate::PaletteColors(vec![(4, blue)]),
+ })
+ );
+ server.handle_server_event(ServerEvent::ClientShellHostTheme {
+ client_id: 1,
+ update: protocol::ClientHostThemeUpdate::Appearance(protocol::ClientHostAppearance::Dark),
+ });
+ assert_eq!(
+ server.app.state.host_terminal_theme.background,
+ Some(dark.into())
+ );
+ assert_eq!(
+ server.app.state.host_terminal_theme.palette[4],
+ Some(blue.into())
+ );
+ assert_eq!(
+ server.app.state.host_terminal_appearance,
+ Some(crate::terminal_theme::HostAppearance::Dark)
+ );
+ assert!(server.app.state.host_terminal_appearance_explicit);
+
+ let light = protocol::ClientHostColor {
+ r: 240,
+ g: 230,
+ b: 220,
+ };
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellHostTheme {
+ client_id: 2,
+ update: protocol::ClientHostThemeUpdate::DefaultColor {
+ kind: protocol::ClientHostDefaultColorKind::Background,
+ color: light,
+ },
+ })
+ );
+ assert_eq!(
+ server.app.state.host_terminal_theme.background,
+ Some(dark.into())
+ );
+
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+ assert_eq!(
+ server.app.state.host_terminal_theme.background,
+ Some(light.into())
+ );
+ assert_eq!(
+ server.app.state.host_terminal_appearance,
+ Some(crate::terminal_theme::HostAppearance::Light)
+ );
+ assert!(!server.app.state.host_terminal_appearance_explicit);
+}
+
+#[test]
+fn terminal_clients_store_known_cell_geometry_independently_of_pixel_mouse() {
+ let mut server = test_headless_server();
+
+ let (writer, _control_rx, _render_rx) = test_client_writer();
+ assert!(!server.handle_server_event(ServerEvent::ClientConnected {
+ client_id: 7,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: true,
+ writer,
+ }));
+ assert!(!server.clients[&7].pixel_mouse);
+ assert_eq!(
+ server.clients[&7].cell_size,
+ crate::kitty_graphics::HostCellSize::default()
+ );
+
+ let (writer, _control_rx, _render_rx) = test_client_writer();
+ assert!(!server.handle_server_event(ServerEvent::ClientConnected {
+ client_id: 8,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 10,
+ cell_height_px: 20,
+ pixel_mouse: false,
+ writer,
+ }));
+ assert!(!server.clients[&8].pixel_mouse);
+ assert_eq!(
+ server.clients[&8].cell_size,
+ crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ }
+ );
+}
+
+#[test]
+fn terminal_attach_rejects_missing_terminal_and_removes_client() {
+ let mut server = test_headless_server();
+ let (writer, control_rx, _render_rx) = test_client_writer();
+
+ assert!(!server.handle_server_event(ServerEvent::ClientConnected {
+ client_id: 7,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ writer,
+ }));
+ assert!(matches!(
+ server.clients.get(&7).map(|client| &client.mode),
+ Some(ClientConnectionMode::TerminalPending)
+ ));
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientAttachTerminal {
+ client_id: 7,
+ terminal_id: "term_missing".to_owned(),
+ takeover: false,
+ })
+ );
+ assert!(!server.clients.contains_key(&7));
+ let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
+ assert_eq!(
+ reason,
+ Some("terminal attach failed: terminal term_missing not found".to_owned())
+ );
+}
+
+fn with_terminal_session_test_server(
+ test: impl FnOnce(&mut HeadlessServer, crate::terminal::TerminalId, String, String),
+) {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("test runtime");
+ let _runtime_guard = rt.enter();
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("test");
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone();
+ let terminal_id_string = terminal_id.to_string();
+ let public_pane_id = format!("{}:p1", workspace.id);
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+ server.app.terminal_runtimes.insert(
+ terminal_id.clone(),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
+ );
+
+ test(&mut server, terminal_id, terminal_id_string, public_pane_id);
+
+ drop(server);
+ drop(_runtime_guard);
+ rt.shutdown_timeout(Duration::from_millis(100));
+}
+
+fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) {
+ let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id);
+}
+
+fn connect_pending_terminal_client_with_control_rx(
+ server: &mut HeadlessServer,
+ client_id: u64,
+) -> std::sync::mpsc::Receiver<Vec<u8>> {
+ let (writer, control_rx, _render_rx) = test_client_writer();
+ assert!(!server.handle_server_event(ServerEvent::ClientConnected {
+ client_id,
+ cols: 100,
+ rows: 30,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ writer,
+ }));
+ assert!(matches!(
+ server.clients.get(&client_id).map(|client| &client.mode),
+ Some(ClientConnectionMode::TerminalPending)
+ ));
+ control_rx
+}
+
+#[test]
+fn explicit_agent_history_read_requires_idle_on_alternate_screen() {
+ with_terminal_session_test_server(
+ |server, terminal_id, _terminal_id_string, public_pane_id| {
+ let terminal = server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("terminal");
+ terminal.detected_agent = Some(crate::detect::Agent::Claude);
+ terminal.state = crate::detect::AgentState::Working;
+ server.app.terminal_runtimes.insert(
+ terminal_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ 80,
+ 24,
+ b"\x1b[?1049hworking",
+ ),
+ );
+ let request = api::schema::Request {
+ id: "read".into(),
+ method: api::schema::Method::AgentRead(api::schema::AgentReadParams {
+ target: public_pane_id.clone(),
+ source: api::schema::ReadSource::Recent,
+ lines: Some(200),
+ format: api::schema::ReadFormat::Text,
+ strip_ansi: true,
+ }),
+ };
+
+ assert_eq!(
+ server.agent_read_not_idle_error(&request),
+ Some(api::schema::ErrorBody {
+ code: "agent_not_idle".into(),
+ message: format!(
+ "cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible"
+ ),
+ })
+ );
+
+ let mut default_request = request.clone();
+ let api::schema::Method::AgentRead(params) = &mut default_request.method else {
+ unreachable!();
+ };
+ params.lines = None;
+ assert_eq!(server.agent_read_not_idle_error(&default_request), None);
+
+ let mut visible_request = request;
+ let api::schema::Method::AgentRead(params) = &mut visible_request.method else {
+ unreachable!();
+ };
+ params.source = api::schema::ReadSource::Visible;
+ assert_eq!(server.agent_read_not_idle_error(&visible_request), None);
+ },
+ );
+}
+
+#[test]
+fn terminal_attach_disconnect_restores_client_shell_pane_size() {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("test runtime");
+ let _runtime_guard = rt.enter();
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("test");
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone();
+ let terminal_id_string = terminal_id.to_string();
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.terminal_runtimes.insert(
+ terminal_id.clone(),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
+ );
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+ let expected_shell_size = server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("runtime")
+ .current_size();
+
+ connect_pending_terminal_client(&mut server, 2);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientAttachTerminal {
+ client_id: 2,
+ terminal_id: terminal_id_string,
+ takeover: false,
+ })
+ );
+ assert_eq!(server.foreground_client_id, Some(1));
+ assert!(server
+ .app
+ .state
+ .direct_attach_resize_locks
+ .contains(&terminal_id));
+ assert_eq!(
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("runtime")
+ .current_size(),
+ (30, 100)
+ );
+
+ assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 }));
+ assert!(!server
+ .app
+ .state
+ .direct_attach_resize_locks
+ .contains(&terminal_id));
+ assert_eq!(
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("runtime")
+ .current_size(),
+ expected_shell_size
+ );
+
+ drop(server);
+ drop(_runtime_guard);
+ rt.shutdown_timeout(Duration::from_millis(100));
+}
+
+#[test]
+fn terminal_observe_allows_multiple_clients_without_attach_ownership() {
+ with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
+ let initial_size = server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("runtime")
+ .current_size();
+
+ for client_id in [7, 8] {
+ connect_pending_terminal_client(server, client_id);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientObserveTerminal {
+ client_id,
+ target: terminal_id_string.clone(),
+ })
+ );
+ }
+
+ assert!(server.terminal_attach_owners.is_empty());
+ assert!(!server
+ .app
+ .state
+ .direct_attach_resize_locks
+ .contains(&terminal_id));
+ assert_eq!(
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("runtime")
+ .current_size(),
+ initial_size
+ );
+ assert_eq!(
+ terminal_stream_client_ids(&server.clients, &terminal_id_string).len(),
+ 2
+ );
+ });
+}
+
+#[test]
+fn direct_terminal_observer_keeps_hidden_pty_source_renderable_with_client_shell() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("test");
+ let background_tab = workspace.test_add_tab(Some("background"));
+ let background_pane = workspace.tabs[background_tab].root_pane;
+ let hidden_pane = workspace.tabs[0].root_pane;
+ let terminal_id = workspace
+ .terminal_id(background_pane)
+ .expect("background terminal id")
+ .to_string();
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.ensure_test_terminals();
+
+ let (shell_writer, _shell_control_rx, _shell_render_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(shell_writer),
+ ),
+ );
+ assert!(!server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane])));
+
+ let (observer_writer, _observer_control_rx, _observer_render_rx) = test_client_writer();
+ server.clients.insert(
+ 2,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::TerminalObserve { terminal_id },
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(observer_writer),
+ ),
+ );
+
+ assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane])));
+ server.sync_immediate_pty_sources();
+ assert!(server.app.render_dirty.request_pty(background_pane));
+ assert!(server.has_pending_presentation_work(false, false));
+ assert!(server.app.render_dirty.request_pty(hidden_pane));
+}
+
+#[test]
+fn terminal_observe_resolves_public_pane_id() {
+ with_terminal_session_test_server(|server, terminal_id, _, public_pane_id| {
+ connect_pending_terminal_client(server, 7);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientObserveTerminal {
+ client_id: 7,
+ target: public_pane_id,
+ })
+ );
+
+ assert!(matches!(
+ server.clients.get(&7).map(|client| &client.mode),
+ Some(ClientConnectionMode::TerminalObserve { terminal_id: observed })
+ if observed == &terminal_id.to_string()
+ ));
+ });
+}
+
+#[test]
+fn terminal_control_resolves_public_pane_id_and_takes_ownership() {
+ with_terminal_session_test_server(|server, terminal_id, terminal_id_string, public_pane_id| {
+ connect_pending_terminal_client(server, 7);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientControlTerminal {
+ client_id: 7,
+ target: public_pane_id,
+ takeover: false,
+ })
+ );
+
+ assert!(matches!(
+ server.clients.get(&7).map(|client| &client.mode),
+ Some(ClientConnectionMode::TerminalAttach { terminal_id: attached })
+ if attached == &terminal_id_string
+ ));
+ assert_eq!(
+ server.terminal_attach_owners.get(&terminal_id_string),
+ Some(&7)
+ );
+ assert!(server
+ .app
+ .state
+ .direct_attach_resize_locks
+ .contains(&terminal_id));
+ });
+}
+
+#[test]
+fn terminal_control_rejects_attach_during_alt_screen_read() {
+ with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
+ let (respond_to, _response_rx) = std::sync::mpsc::channel();
+ server.pending_alt_screen_reads.push(
+ crate::server::alt_screen_read::PendingAltScreenRead::start(
+ terminal_id,
+ "read".into(),
+ respond_to,
+ "fallback".into(),
+ api::schema::PaneReadResult {
+ pane_id: "w1:p1".into(),
+ workspace_id: "w1".into(),
+ tab_id: "w1:t1".into(),
+ source: api::schema::ReadSource::Recent,
+ format: api::schema::ReadFormat::Text,
+ text: String::new(),
+ revision: 0,
+ truncated: false,
+ },
+ 120,
+ false,
+ crate::terminal::ScreenSnapshot {
+ cols: 80,
+ rows: Vec::new(),
+ },
+ 0,
+ Instant::now(),
+ ),
+ );
+ let control_rx = connect_pending_terminal_client_with_control_rx(server, 7);
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientControlTerminal {
+ client_id: 7,
+ target: terminal_id_string.clone(),
+ takeover: false,
+ })
+ );
+ assert!(!server.clients.contains_key(&7));
+ assert!(!server
+ .terminal_attach_owners
+ .contains_key(&terminal_id_string));
+ let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
+ assert_eq!(
+ reason,
+ Some(format!(
+ "terminal attach failed: terminal {terminal_id_string} has a read in progress; retry"
+ ))
+ );
+ });
+}
+
+#[test]
+fn terminal_control_rejects_second_controller_without_takeover() {
+ with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
+ connect_pending_terminal_client(server, 7);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientControlTerminal {
+ client_id: 7,
+ target: terminal_id_string.clone(),
+ takeover: false,
+ })
+ );
+
+ connect_pending_terminal_client(server, 8);
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientControlTerminal {
+ client_id: 8,
+ target: terminal_id_string.clone(),
+ takeover: false,
+ })
+ );
+
+ assert!(server.clients.contains_key(&7));
+ assert!(!server.clients.contains_key(&8));
+ assert_eq!(
+ server.terminal_attach_owners.get(&terminal_id_string),
+ Some(&7)
+ );
+ });
+}
+
+#[test]
+fn terminal_control_takeover_replaces_existing_controller() {
+ with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
+ connect_pending_terminal_client(server, 7);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientControlTerminal {
+ client_id: 7,
+ target: terminal_id_string.clone(),
+ takeover: false,
+ })
+ );
+
+ connect_pending_terminal_client(server, 8);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientControlTerminal {
+ client_id: 8,
+ target: terminal_id_string.clone(),
+ takeover: true,
+ })
+ );
+
+ assert!(!server.clients.contains_key(&7));
+ assert!(server.clients.contains_key(&8));
+ assert_eq!(
+ server.terminal_attach_owners.get(&terminal_id_string),
+ Some(&8)
+ );
+ });
+}
+
+#[test]
+fn terminal_observe_can_coexist_with_terminal_control() {
+ with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
+ connect_pending_terminal_client(server, 7);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientControlTerminal {
+ client_id: 7,
+ target: terminal_id_string.clone(),
+ takeover: false,
+ })
+ );
+
+ connect_pending_terminal_client(server, 8);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientObserveTerminal {
+ client_id: 8,
+ target: terminal_id_string.clone(),
+ })
+ );
+
+ assert_eq!(
+ server.terminal_attach_owners.get(&terminal_id_string),
+ Some(&7)
+ );
+ assert!(matches!(
+ server.clients.get(&8).map(|client| &client.mode),
+ Some(ClientConnectionMode::TerminalObserve { terminal_id })
+ if terminal_id == &terminal_id_string
+ ));
+ assert_eq!(
+ terminal_stream_client_ids(&server.clients, &terminal_id_string).len(),
+ 2
+ );
+ });
+}
+
+#[test]
+fn terminal_control_detach_sends_shutdown_before_removal() {
+ with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
+ let control_rx = connect_pending_terminal_client_with_control_rx(server, 7);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientControlTerminal {
+ client_id: 7,
+ target: terminal_id_string.clone(),
+ takeover: false,
+ })
+ );
+
+ assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 }));
+
+ assert!(!server.clients.contains_key(&7));
+ assert!(!server
+ .terminal_attach_owners
+ .contains_key(&terminal_id_string));
+ let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
+ assert_eq!(reason, Some("detached".to_owned()));
+ });
+}
+
+#[test]
+fn terminal_observe_rejects_later_attach_upgrade() {
+ with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
+ connect_pending_terminal_client(server, 7);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientObserveTerminal {
+ client_id: 7,
+ target: terminal_id_string.clone(),
+ })
+ );
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientAttachTerminal {
+ client_id: 7,
+ terminal_id: terminal_id_string,
+ takeover: true,
+ })
+ );
+
+ assert!(!server.clients.contains_key(&7));
+ assert!(server.terminal_attach_owners.is_empty());
+ assert!(!server
+ .app
+ .state
+ .direct_attach_resize_locks
+ .contains(&terminal_id));
+ });
+}
+
+#[test]
+fn terminal_attach_rejects_later_observe_and_clears_ownership() {
+ with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
+ connect_pending_terminal_client(server, 7);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientAttachTerminal {
+ client_id: 7,
+ terminal_id: terminal_id_string.clone(),
+ takeover: false,
+ })
+ );
+ assert_eq!(
+ server.terminal_attach_owners.get(&terminal_id_string),
+ Some(&7)
+ );
+ assert!(server
+ .app
+ .state
+ .direct_attach_resize_locks
+ .contains(&terminal_id));
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientObserveTerminal {
+ client_id: 7,
+ target: terminal_id_string.clone(),
+ })
+ );
+
+ assert!(!server.clients.contains_key(&7));
+ assert!(server.terminal_attach_owners.is_empty());
+ assert!(!server
+ .app
+ .state
+ .direct_attach_resize_locks
+ .contains(&terminal_id));
+ });
+}
+
+#[test]
+fn unchanged_git_refresh_does_not_request_headless_render() {
+ let mut server = test_headless_server();
+ server.app.git_refresh_in_flight = true;
+ let mut workspace = crate::workspace::Workspace::test_new("one");
+ let workspace_id = workspace.id.clone();
+ let cwd = workspace.identity_cwd.clone();
+ workspace.cached_auto_label = "cached".into();
+ workspace.cached_git_status_key = cwd.clone();
+ workspace.cached_git_branch = None;
+ server.app.state.workspaces.push(workspace);
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed {
+ results: vec![crate::workspace::WorkspaceGitStatus {
+ workspace_id,
+ resolved_identity_cwd: cwd.clone(),
+ status_cache_key: cwd,
+ demand: crate::workspace::GitStatusRefreshDemand::ALL,
+ auto_label: "cached".into(),
+ branch: None,
+ ahead_behind: None,
+ space: None,
+ }],
+ cache_updates: Vec::new(),
+ });
+
+ assert!(!changed);
+ assert!(!server.app.git_refresh_in_flight);
+}
+
+#[test]
+fn changed_git_refresh_requests_headless_render() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("one");
+ let workspace_id = workspace.id.clone();
+ let cwd = workspace.identity_cwd.clone();
+ server.app.state.workspaces.push(workspace);
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed {
+ results: vec![crate::workspace::WorkspaceGitStatus {
+ workspace_id,
+ resolved_identity_cwd: cwd.clone(),
+ status_cache_key: cwd,
+ demand: crate::workspace::GitStatusRefreshDemand::ALL,
+ auto_label: "one".into(),
+ branch: Some("changed".into()),
+ ahead_behind: None,
+ space: None,
+ }],
+ cache_updates: Vec::new(),
+ });
+
+ assert!(changed);
+}
+
+#[test]
+fn terminal_attach_client_exits_when_attached_pane_dies() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("attached");
+ let pane_id = workspace.tabs[0].root_pane;
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+ let terminal_id = server.app.state.workspaces[0]
+ .pane_state(pane_id)
+ .expect("pane")
+ .attached_terminal_id
+ .to_string();
+ let (writer, control_rx, _render_rx) = test_client_writer();
+
+ assert!(!server.handle_server_event(ServerEvent::ClientConnected {
+ client_id: 7,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ writer,
+ }));
+ assert!(
+ server.handle_server_event(ServerEvent::ClientAttachTerminal {
+ client_id: 7,
+ terminal_id: terminal_id.clone(),
+ takeover: false,
+ })
+ );
+ assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7));
+
+ assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id }));
+
+ assert!(!server.clients.contains_key(&7));
+ assert!(!server.terminal_attach_owners.contains_key(&terminal_id));
+ let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
+ assert_eq!(reason, Some(format!("terminal {terminal_id} exited")));
+}
+
+#[test]
+fn terminal_attach_scroll_moves_attached_runtime_viewport() {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("test runtime");
+ let _runtime_guard = rt.enter();
+ let mut bytes = Vec::new();
+ for line in 0..80 {
+ bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
+ }
+ let runtime = crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes);
+
+ apply_terminal_attach_scroll(
+ &runtime,
+ AttachScrollSource::Wheel,
+ AttachScrollDirection::Up,
+ 3,
+ None,
+ None,
+ 0,
+ )
+ .expect("scroll up");
+ let metrics = runtime.scroll_metrics().expect("scroll metrics");
+ assert_eq!(metrics.offset_from_bottom, 3);
+
+ apply_terminal_attach_scroll(
+ &runtime,
+ AttachScrollSource::Wheel,
+ AttachScrollDirection::Down,
+ 2,
+ None,
+ None,
+ 0,
+ )
+ .expect("scroll down");
+ let metrics = runtime.scroll_metrics().expect("scroll metrics");
+ assert_eq!(metrics.offset_from_bottom, 1);
+ drop(runtime);
+ drop(_runtime_guard);
+ rt.shutdown_timeout(Duration::from_millis(100));
+}
+
+#[test]
+fn client_pane_pixel_mouse_uses_runtime_pixel_encoding() {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("test runtime");
+ let _runtime_guard = rt.enter();
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 20,
+ 5,
+ 0,
+ b"\x1b[?1003h\x1b[?1006h\x1b[?1016h",
+ 4,
+ );
+ runtime.resize(5, 20, 10, 20);
+
+ apply_client_pane_input_events(
+ &runtime,
+ &[crate::protocol::ClientPaneInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ position: crate::protocol::ClientMousePosition::Pixels {
+ x: 21,
+ y: 22,
+ column: 2,
+ row: 1,
+ },
+ geometry: None,
+ modifiers: 0,
+ lines: 3,
+ }],
+ )
+ .expect("pixel mouse input");
+ assert_eq!(
+ input_rx.try_recv().expect("encoded pixel mouse"),
+ Bytes::from_static(b"\x1b[<35;21;22M")
+ );
+ drop(runtime);
+ drop(_runtime_guard);
+ rt.shutdown_timeout(Duration::from_millis(100));
+}
+
+#[test]
+fn client_pane_pixel_mouse_falls_back_to_canonical_cell_position() {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("test runtime");
+ let _runtime_guard = rt.enter();
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 20,
+ 5,
+ 0,
+ b"\x1b[?1003h\x1b[?1006h",
+ 4,
+ );
+ runtime.resize(5, 20, 10, 20);
+
+ apply_client_pane_input_events(
+ &runtime,
+ &[crate::protocol::ClientPaneInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ position: crate::protocol::ClientMousePosition::Pixels {
+ x: 21,
+ y: 22,
+ column: 2,
+ row: 1,
+ },
+ geometry: None,
+ modifiers: 0,
+ lines: 3,
+ }],
+ )
+ .expect("cell mouse fallback");
+ assert_eq!(
+ input_rx.try_recv().expect("encoded cell mouse"),
+ Bytes::from_static(b"\x1b[<35;3;2M")
+ );
+ drop(runtime);
+ drop(_runtime_guard);
+ rt.shutdown_timeout(Duration::from_millis(100));
+}
+
+#[test]
+fn client_pane_wheel_input_accumulates_scrollback_offset() {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("test runtime");
+ let _runtime_guard = rt.enter();
+ let mut bytes = Vec::new();
+ for line in 0..80 {
+ bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
+ }
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 20, 5, 4096, &bytes, 4,
+ );
+ let scroll = |kind| crate::protocol::ClientPaneInputEvent::Mouse {
+ kind,
+ position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 },
+ geometry: None,
+ modifiers: 0,
+ lines: 3,
+ };
+
+ apply_client_pane_input_events(
+ &runtime,
+ &[scroll(crate::protocol::ClientMouseKind::ScrollUp)],
+ )
+ .expect("first scroll up");
+ apply_client_pane_input_events(
+ &runtime,
+ &[scroll(crate::protocol::ClientMouseKind::ScrollUp)],
+ )
+ .expect("second scroll up");
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 6
+ );
+
+ apply_client_pane_input_events(
+ &runtime,
+ &[scroll(crate::protocol::ClientMouseKind::ScrollDown)],
+ )
+ .expect("scroll down");
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 3
+ );
+
+ runtime.test_process_pty_bytes(b"\x1b[?1003h\x1b[?1006h");
+ apply_client_pane_input_events(
+ &runtime,
+ &[crate::protocol::ClientPaneInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 },
+ geometry: None,
+ modifiers: 0,
+ lines: 3,
+ }],
+ )
+ .expect("reported mouse motion");
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 3
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("reported mouse motion"),
+ Bytes::from_static(b"\x1b[<35;3;2M")
+ );
+
+ apply_client_pane_input_events(
+ &runtime,
+ &[crate::protocol::ClientPaneInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left),
+ position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 },
+ geometry: None,
+ modifiers: 0,
+ lines: 3,
+ }],
+ )
+ .expect("mouse button");
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 0
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("reported mouse button"),
+ Bytes::from_static(b"\x1b[<0;3;2M")
+ );
+ drop(runtime);
+ drop(_runtime_guard);
+ rt.shutdown_timeout(Duration::from_millis(100));
+}
+
+#[test]
+fn terminal_attach_input_resets_scrolled_viewport() {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("test runtime");
+ let _runtime_guard = rt.enter();
+ let mut bytes = Vec::new();
+ for line in 0..80 {
+ bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
+ }
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 20, 5, 4096, &bytes, 4,
+ );
+
+ runtime.scroll_up(4);
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 4
+ );
+
+ apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input");
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 0
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded input"),
+ Bytes::from("x")
+ );
+
+ drop(runtime);
+ drop(_runtime_guard);
+ rt.shutdown_timeout(Duration::from_millis(100));
+}
+
+fn with_terminal_attach_runtime(
+ initial_bytes: &[u8],
+ initial_scroll: usize,
+ test: impl FnOnce(&crate::terminal::TerminalRuntime, &mut mpsc::Receiver<Bytes>),
+) {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("test runtime");
+ let _runtime_guard = rt.enter();
+ let mut bytes = initial_bytes.to_vec();
+ for line in 0..80 {
+ bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
+ }
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 20, 5, 4096, &bytes, 4,
+ );
+ if initial_scroll > 0 {
+ runtime.scroll_up(initial_scroll);
+ }
+
+ test(&runtime, &mut input_rx);
+
+ drop(runtime);
+ drop(_runtime_guard);
+ rt.shutdown_timeout(Duration::from_millis(100));
+}
+
+fn apply_terminal_attach_page_up(runtime: &crate::terminal::TerminalRuntime) {
+ apply_terminal_attach_scroll(
+ runtime,
+ AttachScrollSource::PageKey {
+ input: b"\x1b[5~".to_vec(),
+ },
+ AttachScrollDirection::Up,
+ 4,
+ None,
+ None,
+ 0,
+ )
+ .expect("page key");
+}
+
+fn client_page_key(
+ code: crate::protocol::ClientKeyCode,
+ modifiers: crossterm::event::KeyModifiers,
+ kind: crate::protocol::ClientKeyKind,
+) -> crate::protocol::ClientPaneInputEvent {
+ crate::protocol::ClientPaneInputEvent::Key {
+ code,
+ modifiers: modifiers.bits(),
+ kind,
+ repeat_count: 1,
+ shifted_codepoint: None,
+ generated_text: None,
+ tracks_release: true,
+ physical_key_id: None,
+ }
+}
+
+#[test]
+fn client_plain_page_keys_scroll_shell_transcript_by_pane_height() {
+ with_terminal_attach_runtime(b"", 0, |runtime, input_rx| {
+ apply_client_pane_input_events(
+ runtime,
+ &[client_page_key(
+ crate::protocol::ClientKeyCode::PageUp,
+ crossterm::event::KeyModifiers::empty(),
+ crate::protocol::ClientKeyKind::Press,
+ )],
+ )
+ .expect("pane PageUp");
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 5
+ );
+
+ apply_client_pane_input_events(
+ runtime,
+ &[client_page_key(
+ crate::protocol::ClientKeyCode::PageUp,
+ crossterm::event::KeyModifiers::empty(),
+ crate::protocol::ClientKeyKind::Release,
+ )],
+ )
+ .expect("pane PageUp release");
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 5
+ );
+
+ apply_client_pane_input_events(
+ runtime,
+ &[client_page_key(
+ crate::protocol::ClientKeyCode::PageDown,
+ crossterm::event::KeyModifiers::empty(),
+ crate::protocol::ClientKeyKind::Press,
+ )],
+ )
+ .expect("pane PageDown");
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 0
+ );
+ assert!(input_rx.try_recv().is_err(), "page keys reached the shell");
+ });
+}
+
+#[test]
+fn client_page_keys_forward_when_modified_or_owned_by_application() {
+ with_terminal_attach_runtime(b"", 0, |runtime, input_rx| {
+ apply_client_pane_input_events(
+ runtime,
+ &[client_page_key(
+ crate::protocol::ClientKeyCode::PageUp,
+ crossterm::event::KeyModifiers::CONTROL,
+ crate::protocol::ClientKeyKind::Press,
+ )],
+ )
+ .expect("modified pane PageUp");
+ assert!(
+ input_rx.try_recv().is_ok(),
+ "modified PageUp was not forwarded"
+ );
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 0
+ );
+ });
+
+ with_terminal_attach_runtime(b"\x1b[?1h", 0, |runtime, input_rx| {
+ apply_client_pane_input_events(
+ runtime,
+ &[client_page_key(
+ crate::protocol::ClientKeyCode::PageUp,
+ crossterm::event::KeyModifiers::empty(),
+ crate::protocol::ClientKeyKind::Press,
+ )],
+ )
+ .expect("application PageUp");
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded application PageUp"),
+ Bytes::from_static(b"\x1b[5~")
+ );
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 0
+ );
+ });
+}
+
+#[test]
+fn client_popup_plain_page_key_remains_popup_input() {
+ with_terminal_attach_runtime(b"", 0, |runtime, input_rx| {
+ apply_client_popup_input_events(
+ runtime,
+ &[client_page_key(
+ crate::protocol::ClientKeyCode::PageUp,
+ crossterm::event::KeyModifiers::empty(),
+ crate::protocol::ClientKeyKind::Press,
+ )],
+ )
+ .expect("popup PageUp");
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded popup PageUp"),
+ Bytes::from_static(b"\x1b[5~")
+ );
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 0
+ );
+ });
+}
+
+#[test]
+fn terminal_attach_paste_uses_plain_text_when_runtime_did_not_enable_brackets() {
+ with_terminal_attach_runtime(b"", 0, |runtime, input_rx| {
+ apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec())
+ .expect("attach paste");
+
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded paste"),
+ Bytes::from_static(b"line one\nline two")
+ );
+ });
+}
+
+#[test]
+fn terminal_attach_paste_preserves_brackets_when_runtime_enabled_them() {
+ with_terminal_attach_runtime(b"\x1b[?2004h", 0, |runtime, input_rx| {
+ apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec())
+ .expect("attach paste");
+
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded paste"),
+ Bytes::from_static(b"\x1b[200~line one\nline two\x1b[201~")
+ );
+ });
+}
+
+#[test]
+fn terminal_attach_page_key_host_scrolls_plain_terminal() {
+ with_terminal_attach_runtime(b"", 0, |runtime, input_rx| {
+ apply_terminal_attach_page_up(runtime);
+
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 4
+ );
+ assert!(input_rx.try_recv().is_err());
+ });
+}
+
+#[test]
+fn terminal_attach_page_key_forwards_when_mouse_reporting() {
+ with_terminal_attach_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| {
+ apply_terminal_attach_page_up(runtime);
+
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 0
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded page key"),
+ Bytes::from_static(b"\x1b[5~")
+ );
+ });
+}
+
+#[test]
+fn terminal_attach_page_key_forwards_when_application_cursor() {
+ with_terminal_attach_runtime(b"\x1b[?1h", 3, |runtime, input_rx| {
+ apply_terminal_attach_page_up(runtime);
+
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 0
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded page key"),
+ Bytes::from_static(b"\x1b[5~")
+ );
+ });
+}
+
+#[test]
+fn terminal_attach_page_key_host_scrolls_shell_like_decckm_with_bracketed_paste() {
+ with_terminal_attach_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| {
+ apply_terminal_attach_page_up(runtime);
+
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 4
+ );
+ assert!(input_rx.try_recv().is_err());
+ });
+}
+
+#[test]
+fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() {
+ with_terminal_attach_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| {
+ apply_terminal_attach_page_up(runtime);
+
+ assert_eq!(
+ runtime
+ .scroll_metrics()
+ .expect("scroll metrics")
+ .offset_from_bottom,
+ 0
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded page key"),
+ Bytes::from_static(b"\x1b[5~")
+ );
+ });
+}
+
+#[test]
+fn headless_scheduled_tasks_expire_agent_metadata() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("metadata");
+ let pane_id = workspace.tabs[0].root_pane;
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+
+ assert!(
+ server.handle_internal_event_with_forwarding(AppEvent::HookStateReported {
+ pane_id,
+ source: "custom:pi".into(),
+ agent_label: "pi".into(),
+ state: crate::detect::AgentState::Working,
+ message: None,
+ seq: None,
+ session_ref: None,
+ })
+ );
+ assert!(
+ server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported {
+ pane_id,
+ source: "user:pi-display".into(),
+ agent_label: Some("pi".into()),
+ applies_to_source: Some("custom:pi".into()),
+ title: Some("short lived".into()),
+ display_agent: None,
+ state_labels: HashMap::new(),
+ clear_title: false,
+ clear_display_agent: false,
+ clear_state_labels: false,
+ seq: None,
+ // Expiry is advanced with the captured deadline below; keep the
+ // pre-expiry assertion independent of wall-clock scheduling.
+ ttl: Some(Duration::from_secs(60)),
+ })
+ );
+
+ let deadline = server
+ .app
+ .agent_metadata_deadline
+ .expect("metadata deadline");
+ let terminal_id = server.app.state.workspaces[0]
+ .pane_state(pane_id)
+ .expect("pane")
+ .attached_terminal_id
+ .clone();
+ assert_eq!(
+ server
+ .app
+ .state
+ .terminals
+ .get(&terminal_id)
+ .expect("terminal")
+ .effective_title()
+ .as_deref(),
+ Some("short lived")
+ );
+
+ assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false));
+
+ assert_eq!(server.app.agent_metadata_deadline, None);
+ assert_eq!(
+ server
+ .app
+ .state
+ .terminals
+ .get(&terminal_id)
+ .expect("terminal")
+ .effective_title(),
+ None
+ );
+ assert!(server
+ .app
+ .event_hub
+ .events_after(0)
+ .iter()
+ .any(|(_, event)| {
+ event.event == crate::api::schema::EventKind::PaneAgentStatusChanged
+ && matches!(
+ &event.data,
+ crate::api::schema::EventData::PaneAgentStatusChanged {
+ title,
+ ..
+ } if title.is_none()
+ )
+ }));
+}
+
+#[test]
+fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() {
+ let mut server = test_headless_server();
+ let now = Instant::now();
+ server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1));
+
+ assert!(!server.handle_scheduled_tasks_headless(now, false));
+ assert_eq!(server.app.next_agent_manifest_update_check, None);
+}
+
+#[cfg(unix)]
+#[tokio::test]
+async fn headless_scheduled_tasks_start_pending_agent_resume_without_foreground_client() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("restored");
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.ensure_test_terminals();
+ server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("test terminal should exist")
+ .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
+ });
+
+ server.render_and_stream();
+ assert_ne!(server.app.state.view.terminal_area, Rect::default());
+
+ let now = Instant::now();
+ assert!(!server.handle_scheduled_tasks_headless(now, false));
+ assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
+ let deadline = server
+ .app
+ .pending_agent_resume_deadline
+ .expect("clientless resume should wait briefly for a host theme");
+
+ assert!(server.handle_scheduled_tasks_headless(deadline, false));
+ assert!(server.app.terminal_runtimes.get(&terminal_id).is_some());
+ assert!(server
+ .app
+ .state
+ .terminals
+ .get(&terminal_id)
+ .expect("test terminal should still exist")
+ .pending_agent_resume_plan
+ .is_none());
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn terminal_attach_resize_uses_known_cell_geometry_without_pixel_mouse() {
+ with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| {
+ let mut client = ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ );
+ client.mode = ClientConnectionMode::TerminalAttach {
+ terminal_id: terminal_id.clone(),
+ };
+ server.clients.insert(1, client);
+
+ assert!(server.handle_server_event(ServerEvent::ClientResize {
+ client_id: 1,
+ cols: 100,
+ rows: 30,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ pixel_mouse: false,
+ }));
+ assert_eq!(
+ server
+ .runtime_for_terminal_id_string(&terminal_id)
+ .unwrap()
+ .pixel_size(),
+ Some((800, 480))
+ );
+ assert_eq!(
+ server.clients[&1].cell_size,
+ crate::kitty_graphics::HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ }
+ );
+ assert!(!server.clients[&1].pixel_mouse);
+
+ assert!(server.handle_server_event(ServerEvent::ClientResize {
+ client_id: 1,
+ cols: 100,
+ rows: 30,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ }));
+ assert_eq!(
+ server
+ .runtime_for_terminal_id_string(&terminal_id)
+ .unwrap()
+ .pixel_size(),
+ None
+ );
+ assert_eq!(
+ server.clients[&1].cell_size,
+ crate::kitty_graphics::HostCellSize::default()
+ );
+ assert!(!server.clients[&1].pixel_mouse);
+ });
+}
+
+#[test]
+fn pending_terminal_resize_does_not_take_shell_foreground_or_geometry() {
+ let mut server = test_headless_server();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (100, 30),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::TerminalPending,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::TerminalAnsi,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.resize_shared_runtime_to_effective_size();
+ let shell_size = server.effective_size;
+
+ assert!(server.handle_server_event(ServerEvent::ClientResize {
+ client_id: 2,
+ cols: 200,
+ rows: 60,
+ cell_width_px: 10,
+ cell_height_px: 20,
+ pixel_mouse: false,
+ }));
+
+ assert_eq!(server.foreground_client_id, Some(1));
+ assert_eq!(server.effective_size, shell_size);
+ assert_eq!(server.clients[&2].terminal_size, (200, 60));
+}
+
+#[test]
+fn client_shell_streams_focused_pane_report_all_demand() {
+ with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| {
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.app.state.active = Some(0);
+ server
+ .runtime_for_terminal_id_string(&terminal_id)
+ .expect("focused runtime")
+ .test_process_pty_bytes(b"\x1b[>15u");
+
+ server.stream_direct_terminal_keyboard_mode();
+
+ assert!(matches!(
+ read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("shell keyboard mode message")
+ ),
+ ServerMessage::ClientShellKeyboardReportAll { enabled: true }
+ ));
+ });
+}
+
+#[tokio::test]
+async fn client_shell_release_cleanup_does_not_promote_and_survives_disconnect() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>3u");
+ let pane_id = server.app.session_snapshot().focused_pane_id.unwrap();
+ for client_id in [1, 2] {
+ server.clients.insert(
+ client_id,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ client_id,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ }
+ let key = |kind| crate::protocol::ClientPaneInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('x'),
+ modifiers: 0,
+ kind,
+ repeat_count: 1,
+ shifted_codepoint: None,
+ generated_text: (kind == crate::protocol::ClientKeyKind::Press).then(|| "x".to_owned()),
+ tracks_release: true,
+ physical_key_id: Some(0x2d),
+ };
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 1,
+ pane_id: pane_id.clone(),
+ events: vec![key(crate::protocol::ClientKeyKind::Press)],
+ })
+ );
+ assert!(!input_rx.recv().await.expect("encoded press").is_empty());
+ assert!(server.promote_client_to_foreground(2));
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 1,
+ pane_id: pane_id.clone(),
+ events: vec![key(crate::protocol::ClientKeyKind::Release)],
+ })
+ );
+ assert!(!input_rx.recv().await.expect("encoded release").is_empty());
+ assert_eq!(server.foreground_client_id, Some(2));
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 1,
+ pane_id,
+ events: vec![key(crate::protocol::ClientKeyKind::Press)],
+ })
+ );
+ assert!(!input_rx
+ .recv()
+ .await
+ .expect("second encoded press")
+ .is_empty());
+ assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 1 }));
+ assert!(!input_rx
+ .recv()
+ .await
+ .expect("disconnect synthesized release")
+ .is_empty());
+ shutdown_test_runtimes(&mut server);
+}
+
+#[test]
+fn client_shell_mouse_capture_combines_local_preference_with_endpoint_demand() {
+ let mut server = test_headless_server();
+ let (writer, control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(writer),
+ ),
+ );
+
+ server.stream_host_mouse_capture_mode();
+ assert!(matches!(
+ read_server_message(control_rx.recv().expect("initial mouse mode")),
+ ServerMessage::MouseCapture {
+ enabled: false,
+ sgr_pixels: false
+ }
+ ));
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellMouseCapture {
+ client_id: 1,
+ enabled: true,
+ })
+ );
+ server.stream_host_mouse_capture_mode();
+ assert!(matches!(
+ read_server_message(control_rx.recv().expect("preferred mouse mode")),
+ ServerMessage::MouseCapture {
+ enabled: true,
+ sgr_pixels: false
+ }
+ ));
+}
+
+#[test]
+fn client_shell_focus_promotes_and_reaches_reporting_pane() {
+ with_terminal_session_test_server(|server, terminal_id, _other_terminal_id, _pane_id| {
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1004h",
+ 4,
+ );
+ server
+ .app
+ .terminal_runtimes
+ .insert(terminal_id.clone(), runtime);
+ server.app.state.active = Some(0);
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (100, 30),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+ assert_eq!(
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("focused runtime")
+ .current_size(),
+ (30, 99)
+ );
+
+ assert!(server.handle_server_event(ServerEvent::ClientShellFocus {
+ client_id: 1,
+ focused: true,
+ }));
+ assert_eq!(server.foreground_client_id, Some(1));
+ assert_eq!(server.app.state.outer_terminal_focus, Some(true));
+ assert_eq!(
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("focused runtime")
+ .current_size(),
+ (24, 79)
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("focus gained input"),
+ Bytes::from_static(b"\x1b[I")
+ );
+
+ assert!(server.handle_server_event(ServerEvent::ClientShellFocus {
+ client_id: 1,
+ focused: false,
+ }));
+ assert_eq!(server.app.state.outer_terminal_focus, Some(false));
+ assert_eq!(
+ input_rx.try_recv().expect("focus lost input"),
+ Bytes::from_static(b"\x1b[O")
+ );
+ });
+}
+
+#[test]
+fn direct_terminal_streams_child_keyboard_and_mouse_modes() {
+ with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| {
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::TerminalAttach {
+ terminal_id: terminal_id.clone(),
+ },
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::TerminalAnsi,
+ Some(client_tx),
+ ),
+ );
+ server
+ .clients
+ .get_mut(&1)
+ .expect("direct attach client")
+ .pixel_mouse = true;
+ server
+ .runtime_for_terminal_id_string(&terminal_id)
+ .expect("attached runtime")
+ .test_process_pty_bytes(b"\x1b[>15u\x1b[?1000h");
+
+ server.stream_direct_terminal_keyboard_mode();
+ assert!(matches!(
+ read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("keyboard mode message")
+ ),
+ ServerMessage::DirectTerminalKeyboardProtocol {
+ flags: 15,
+ modify_other_keys_level: 0
+ }
+ ));
+
+ server
+ .runtime_for_terminal_id_string(&terminal_id)
+ .expect("attached runtime")
+ .test_process_pty_bytes(b"\x1b[<u\x1b[>3u\x1b[>4;1m");
+ server.stream_direct_terminal_keyboard_mode();
+ assert!(matches!(
+ read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("modifyOtherKeys mode-one keyboard message")
+ ),
+ ServerMessage::DirectTerminalKeyboardProtocol {
+ flags: 3,
+ modify_other_keys_level: 1
+ }
+ ));
+
+ server
+ .runtime_for_terminal_id_string(&terminal_id)
+ .expect("attached runtime")
+ .test_process_pty_bytes(b"\x1b[>4;2m");
+ server.stream_direct_terminal_keyboard_mode();
+ assert!(matches!(
+ read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("modifyOtherKeys mode-two keyboard message")
+ ),
+ ServerMessage::DirectTerminalKeyboardProtocol {
+ flags: 3,
+ modify_other_keys_level: 2
+ }
+ ));
+
+ server
+ .runtime_for_terminal_id_string(&terminal_id)
+ .expect("attached runtime")
+ .test_process_pty_bytes(b"\x1b[<u");
+ server.stream_direct_terminal_keyboard_mode();
+ assert!(matches!(
+ read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("modifyOtherKeys-only keyboard mode message")
+ ),
+ ServerMessage::DirectTerminalKeyboardProtocol {
+ flags: 0,
+ modify_other_keys_level: 2
+ }
+ ));
+
+ server.stream_host_mouse_capture_mode();
+ assert!(matches!(
+ read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("mouse capture message")
+ ),
+ ServerMessage::MouseCapture {
+ enabled: true,
+ sgr_pixels: false
+ }
+ ));
+
+ server
+ .runtime_for_terminal_id_string(&terminal_id)
+ .expect("attached runtime")
+ .test_process_pty_bytes(b"\x1b[?1016h");
+ server.stream_host_mouse_capture_mode();
+ assert!(matches!(
+ read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("pixel mouse capture message")
+ ),
+ ServerMessage::MouseCapture {
+ enabled: true,
+ sgr_pixels: true
+ }
+ ));
+
+ server
+ .runtime_for_terminal_id_string(&terminal_id)
+ .expect("attached runtime")
+ .test_process_pty_bytes(b"\x1b[?1000l\x1b[?1016l");
+ server.stream_host_mouse_capture_mode();
+ assert!(matches!(
+ read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("child mouse disable message")
+ ),
+ ServerMessage::MouseCapture {
+ enabled: false,
+ sgr_pixels: false
+ }
+ ));
+ });
+}
+
+#[test]
+fn direct_terminal_mouse_uses_runtime_protocol_encoding() {
+ with_terminal_session_test_server(|server, runtime_terminal_id, terminal_id, _pane_id| {
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1000h\x1b[?1006h",
+ 4,
+ );
+ server
+ .app
+ .terminal_runtimes
+ .insert(runtime_terminal_id, runtime);
+ server.clients.insert(
+ 1,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::TerminalAttach {
+ terminal_id: terminal_id.clone(),
+ },
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::TerminalAnsi,
+ None,
+ ),
+ );
+
+ assert!(server.handle_server_event(ServerEvent::ClientAttachMouse {
+ client_id: 1,
+ kind: protocol::ClientMouseKind::Down(protocol::ClientMouseButton::Left),
+ position: protocol::ClientMousePosition::Cell { column: 10, row: 5 },
+ geometry: None,
+ modifiers: 0,
+ lines: 1,
+ }));
+ assert_eq!(
+ input_rx.try_recv().expect("encoded direct mouse input"),
+ Bytes::from_static(b"\x1b[<0;11;6M")
+ );
+ });
+}
+
+#[test]
+fn direct_terminal_pixel_mouse_uses_runtime_tracking_and_coordinates() {
+ with_terminal_session_test_server(|server, runtime_terminal_id, terminal_id, _pane_id| {
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1000h\x1b[?1006h\x1b[?1016h",
+ 4,
+ );
+ runtime.resize(24, 80, 10, 20);
+ server
+ .app
+ .terminal_runtimes
+ .insert(runtime_terminal_id, runtime);
+ server.clients.insert(
+ 1,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::TerminalAttach {
+ terminal_id: terminal_id.clone(),
+ },
+ (80, 24),
+ crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ },
+ 1,
+ RenderEncoding::TerminalAnsi,
+ None,
+ ),
+ );
+ let client = server.clients.get_mut(&1).expect("direct attach client");
+ client.pixel_mouse = true;
+ client.host_sgr_pixels_active = Some(true);
+
+ assert!(!server.handle_server_event(ServerEvent::ClientAttachMouse {
+ client_id: 1,
+ kind: protocol::ClientMouseKind::Down(protocol::ClientMouseButton::Left),
+ position: protocol::ClientMousePosition::Pixels {
+ x: 21,
+ y: 22,
+ column: 3,
+ row: 1,
+ },
+ geometry: Some(protocol::ClientMouseGeometry {
+ cols: 80,
+ rows: 24,
+ width_px: 800,
+ height_px: 480,
+ }),
+ modifiers: 0,
+ lines: 1,
+ }));
+ assert!(input_rx.try_recv().is_err());
+
+ assert!(server.handle_server_event(ServerEvent::ClientAttachMouse {
+ client_id: 1,
+ kind: protocol::ClientMouseKind::Down(protocol::ClientMouseButton::Left),
+ position: protocol::ClientMousePosition::Pixels {
+ x: 21,
+ y: 22,
+ column: 2,
+ row: 1,
+ },
+ geometry: Some(protocol::ClientMouseGeometry {
+ cols: 80,
+ rows: 24,
+ width_px: 805,
+ height_px: 485,
+ }),
+ modifiers: 0,
+ lines: 1,
+ }));
+ assert_eq!(
+ input_rx
+ .try_recv()
+ .expect("proportionally mapped direct pixel mouse input"),
+ Bytes::from_static(b"\x1b[<0;21;22M")
+ );
+
+ assert!(server.handle_server_event(ServerEvent::ClientAttachMouse {
+ client_id: 1,
+ kind: protocol::ClientMouseKind::Moved,
+ position: protocol::ClientMousePosition::Pixels {
+ x: 21,
+ y: 22,
+ column: 2,
+ row: 1,
+ },
+ geometry: Some(protocol::ClientMouseGeometry {
+ cols: 80,
+ rows: 24,
+ width_px: 800,
+ height_px: 480,
+ }),
+ modifiers: 0,
+ lines: 1,
+ }));
+ assert!(input_rx.try_recv().is_err());
+
+ assert!(server.handle_server_event(ServerEvent::ClientAttachMouse {
+ client_id: 1,
+ kind: protocol::ClientMouseKind::Down(protocol::ClientMouseButton::Left),
+ position: protocol::ClientMousePosition::Pixels {
+ x: 21,
+ y: 22,
+ column: 2,
+ row: 1,
+ },
+ geometry: Some(protocol::ClientMouseGeometry {
+ cols: 80,
+ rows: 24,
+ width_px: 800,
+ height_px: 480,
+ }),
+ modifiers: 0,
+ lines: 1,
+ }));
+ assert_eq!(
+ input_rx
+ .try_recv()
+ .expect("encoded direct pixel mouse input"),
+ Bytes::from_static(b"\x1b[<0;21;22M")
+ );
+ });
+}
+
+#[test]
+fn client_config_reload_request_refreshes_attached_clients() {
+ let mut server = test_headless_server();
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.app.state.request_client_config_reload = true;
+
+ server.drain_client_config_reload_request();
+
+ match read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("client config reload message"),
+ ) {
+ ServerMessage::ReloadSoundConfig => {}
+ other => panic!("expected ReloadSoundConfig, got {other:?}"),
+ }
+ assert!(!server.app.state.request_client_config_reload);
+}
+
+#[test]
+fn terminal_bell_targets_foreground_client_only() {
+ let mut server = test_headless_server();
+ let (background_tx, background_control_rx, _background_rx) = test_client_writer();
+ let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(background_tx),
+ ),
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(foreground_tx),
+ ),
+ );
+ server.foreground_client_id = Some(2);
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::TerminalBell {
+ pane_id: crate::layout::PaneId::from_raw(1),
+ count: 3,
+ });
+
+ assert!(!changed);
+ match read_server_message(
+ foreground_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("foreground terminal bell message"),
+ ) {
+ ServerMessage::TerminalBell { count } => assert_eq!(count, 3),
+ other => panic!("expected terminal bell message, got {other:?}"),
+ }
+ assert!(
+ background_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "background client should not receive terminal bells"
+ );
+
+ server.foreground_client_id = None;
+ server.handle_internal_event_with_forwarding(AppEvent::TerminalBell {
+ pane_id: crate::layout::PaneId::from_raw(1),
+ count: 1,
+ });
+ assert!(
+ foreground_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "bells without a foreground client must not be retained"
+ );
+}
+
+#[test]
+fn clipboard_write_targets_foreground_client_only() {
+ let mut server = test_headless_server();
+ let (background_tx, background_control_rx, _background_rx) = test_client_writer();
+ let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(background_tx),
+ ),
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(foreground_tx),
+ ),
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite {
+ content: b"test".to_vec(),
+ });
+
+ assert!(!changed);
+ match read_server_message(
+ foreground_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("foreground clipboard message"),
+ ) {
+ ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="),
+ other => panic!("expected clipboard message, got {other:?}"),
+ }
+ assert!(
+ background_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "background client should not receive clipboard writes"
+ );
+}
+
+#[test]
+fn clipboard_write_without_foreground_client_does_not_change_visual_state() {
+ let mut server = test_headless_server();
+ server.foreground_client_id = None;
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite {
+ content: b"test".to_vec(),
+ });
+
+ assert!(!changed);
+}
+
+#[test]
+fn clipboard_write_failed_foreground_send_removes_client_without_visual_change() {
+ let mut server = test_headless_server();
+ let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
+ drop(foreground_control_rx);
+ foreground_tx.test_close();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(foreground_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite {
+ content: b"test".to_vec(),
+ });
+
+ assert!(!changed);
+ assert!(
+ !server.clients.contains_key(&1),
+ "failed targeted send should remove the broken foreground client"
+ );
+}
+
+#[test]
+fn semantic_notifications_broadcast_only_to_client_shells() {
+ let mut server = test_headless_server();
+ let (shell_one_tx, shell_one_control, _shell_one_frames) = test_client_writer();
+ let (shell_two_tx, shell_two_control, _shell_two_frames) = test_client_writer();
+ let (terminal_tx, terminal_control, _terminal_frames) = test_client_writer();
+ for (client_id, writer) in [(1, shell_one_tx), (2, shell_two_tx)] {
+ server.clients.insert(
+ client_id,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::ClientShell,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ client_id,
+ RenderEncoding::SemanticFrame,
+ Some(writer),
+ ),
+ );
+ }
+ server.clients.insert(
+ 3,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::TerminalPending,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 3,
+ RenderEncoding::TerminalAnsi,
+ Some(terminal_tx),
+ ),
+ );
+ let event = protocol::SemanticNotification {
+ kind: protocol::SemanticNotificationKind::Custom,
+ title: "hello".into(),
+ body: None,
+ sound: None,
+ agent: None,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: None,
+ position: None,
+ };
+ assert!(server.send_to_client_shells(ServerMessage::SemanticNotification(event.clone())));
+ for receiver in [shell_one_control, shell_two_control] {
+ assert_eq!(
+ read_server_message(
+ receiver
+ .recv_timeout(Duration::from_millis(100))
+ .expect("semantic notification")
+ ),
+ ServerMessage::SemanticNotification(event.clone())
+ );
+ }
+ assert!(terminal_control
+ .recv_timeout(Duration::from_millis(50))
+ .is_err());
+}
+
+#[test]
+fn notification_show_uses_client_shell_policy_independent_of_server_delivery() {
+ let mut server = test_headless_server();
+ server.app.state.toast_config.delivery = config::ToastDelivery::Off;
+ let (shell_tx, shell_control, _shell_frames) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::ClientShell,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(shell_tx),
+ ),
+ );
+ let response = server.handle_notification_show_api(
+ "notify-shell".into(),
+ api::schema::NotificationShowParams {
+ title: "plugin title".into(),
+ body: Some("plugin body".into()),
+ position: Some(crate::config::ToastHerdrPosition::TopLeft),
+ sound: api::schema::NotificationShowSound::Done,
+ },
+ );
+ let response: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert!(matches!(
+ response.result,
+ api::schema::ResponseResult::NotificationShow { shown: true, .. }
+ ));
+ assert_eq!(
+ read_server_message(
+ shell_control
+ .recv_timeout(Duration::from_millis(100))
+ .expect("semantic plugin notification")
+ ),
+ ServerMessage::SemanticNotification(protocol::SemanticNotification {
+ kind: protocol::SemanticNotificationKind::Custom,
+ title: "plugin title".into(),
+ body: Some("plugin body".into()),
+ sound: Some(protocol::SemanticNotificationSound::Done),
+ agent: None,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: None,
+ position: Some(crate::config::ToastHerdrPosition::TopLeft),
+ })
+ );
+}
+
+#[test]
+fn client_local_notifications_target_foreground_client_only() {
+ let mut server = test_headless_server();
+ let (background_tx, background_control_rx, _background_rx) = test_client_writer();
+ let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(background_tx),
+ ),
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(foreground_tx),
+ ),
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+
+ assert!(server.send_to_foreground_client(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: "pi finished".to_string(),
+ body: Some("workspace 1".to_string()),
+ }));
+
+ match read_server_message(
+ foreground_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("foreground toast message"),
+ ) {
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
+ assert_eq!(kind, protocol::NotifyKind::Toast);
+ assert_eq!(message, "pi finished");
+ assert_eq!(body.as_deref(), Some("workspace 1"));
+ }
+ other => panic!("expected toast notify, got {other:?}"),
+ }
+ assert!(
+ background_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "background client should not receive client-local notifications"
+ );
+}
+
+#[test]
+fn oversized_paste_rejection_notifies_only_the_sending_client() {
+ let mut server = test_headless_server();
+ let (sender_writer, sender_control_rx, _sender_render_rx) = test_client_writer();
+ let (foreground_writer, foreground_control_rx, _foreground_render_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(sender_writer),
+ ),
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(foreground_writer),
+ ),
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientPasteRejected {
+ client_id: 1,
+ size: 5_000_012,
+ max: 1_048_576,
+ })
+ );
+
+ match read_server_message(
+ sender_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("sending client rejection notification"),
+ ) {
+ ServerMessage::ClientShellError { message } => assert_eq!(
+ message,
+ "Paste rejected: Input message is 5000012 bytes; Herdr's limit is 1048576 bytes"
+ ),
+ other => panic!("expected client shell paste error, got {other:?}"),
+ }
+ let (shell_writer, shell_control_rx, _shell_render_rx) = test_client_writer();
+ server.clients.insert(
+ 3,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::ClientShell,
+ (100, 30),
+ crate::kitty_graphics::HostCellSize::default(),
+ 3,
+ RenderEncoding::SemanticFrame,
+ Some(shell_writer),
+ ),
+ );
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientPasteRejected {
+ client_id: 3,
+ size: 7_000_000,
+ max: 1_048_576,
+ })
+ );
+ match read_server_message(
+ shell_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("client shell rejection error"),
+ ) {
+ ServerMessage::ClientShellError { message } => assert_eq!(
+ message,
+ "Paste rejected: Input message is 7000000 bytes; Herdr's limit is 1048576 bytes"
+ ),
+ other => panic!("expected client shell paste error, got {other:?}"),
+ }
+ assert!(
+ foreground_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "foreground client must not receive another client's rejection"
+ );
+ assert_eq!(server.foreground_client_id, Some(2));
+ assert_eq!(server.clients.len(), 3);
+ assert!(server.app.state.toast.is_none());
+}
+
+#[test]
+fn update_notification_reaches_client_shell_independent_of_delivery() {
+ let mut server = test_headless_server();
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady {
+ version: "9.9.9".to_string(),
+ install_command: "herdr update".into(),
+ });
+
+ assert!(changed);
+ assert!(matches!(
+ read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("semantic update notification")
+ ),
+ ServerMessage::SemanticNotification(protocol::SemanticNotification {
+ kind: protocol::SemanticNotificationKind::UpdateInstalled,
+ ..
+ })
+ ));
+}
+
+#[test]
+fn update_notification_is_semantic_for_system_delivery() {
+ let mut server = test_headless_server();
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady {
+ version: "9.9.9".to_string(),
+ install_command: "herdr update".into(),
+ });
+
+ assert!(changed);
+ match read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("semantic update notification"),
+ ) {
+ ServerMessage::SemanticNotification(notification) => {
+ assert_eq!(
+ notification.kind,
+ protocol::SemanticNotificationKind::UpdateInstalled
+ );
+ assert_eq!(notification.title, "Herdr v9.9.9 available");
+ assert_eq!(
+ notification.body.as_deref(),
+ Some("detach, run `herdr update`, then follow its restart guidance")
+ );
+ }
+ other => panic!("expected semantic update notification, got {other:?}"),
+ }
+}
+
+#[test]
+fn notification_show_api_forwards_one_semantic_client_notification() {
+ let mut server = test_headless_server();
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams {
+ title: "build failed".into(),
+ body: Some("api workspace".into()),
+ position: Some(crate::config::ToastHerdrPosition::TopLeft),
+ sound: api::schema::NotificationShowSound::Request,
+ }),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+
+ assert!(changed);
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ parsed.result,
+ api::schema::ResponseResult::NotificationShow {
+ shown: true,
+ reason: api::schema::NotificationShowReason::Shown,
+ }
+ );
+ match read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("semantic api notification"),
+ ) {
+ ServerMessage::SemanticNotification(notification) => {
+ assert_eq!(notification.title, "build failed");
+ assert_eq!(notification.body.as_deref(), Some("api workspace"));
+ assert_eq!(
+ notification.sound,
+ Some(protocol::SemanticNotificationSound::Request)
+ );
+ }
+ other => panic!("expected semantic api notification, got {other:?}"),
+ }
+}
+
+#[test]
+fn notification_show_api_preserves_colon_in_forwarded_title() {
+ let mut server = test_headless_server();
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams {
+ title: "build: failed".into(),
+ body: Some("api workspace".into()),
+ position: None,
+ sound: api::schema::NotificationShowSound::None,
+ }),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+
+ assert!(changed);
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ parsed.result,
+ api::schema::ResponseResult::NotificationShow {
+ shown: true,
+ reason: api::schema::NotificationShowReason::Shown,
+ }
+ );
+ match read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("semantic api notification"),
+ ) {
+ ServerMessage::SemanticNotification(notification) => {
+ assert_eq!(notification.title, "build: failed");
+ assert_eq!(notification.body.as_deref(), Some("api workspace"));
+ }
+ other => panic!("expected semantic api notification, got {other:?}"),
+ }
+}
+
+#[test]
+fn notification_show_api_validates_empty_title_before_disabled_delivery() {
+ let mut server = test_headless_server();
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::Off;
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams {
+ title: "\n\t".into(),
+ body: None,
+ position: None,
+ sound: api::schema::NotificationShowSound::None,
+ }),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+
+ assert!(changed);
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(parsed.error.code, "invalid_params");
+ assert_eq!(parsed.error.message, "notification title is empty");
+}
+
+#[test]
+fn notification_show_api_reports_no_foreground_client() {
+ let mut server = test_headless_server();
+ server.foreground_client_id = None;
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams {
+ title: "build failed".into(),
+ body: None,
+ position: None,
+ sound: api::schema::NotificationShowSound::Request,
+ }),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+
+ assert!(changed);
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ parsed.result,
+ api::schema::ResponseResult::NotificationShow {
+ shown: false,
+ reason: api::schema::NotificationShowReason::NoForegroundClient,
+ }
+ );
+}
+
+#[test]
+fn notification_show_api_includes_sound_in_semantic_event() {
+ let mut server = test_headless_server();
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ assert!(
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(
+ api::schema::NotificationShowParams {
+ title: "build failed".into(),
+ body: None,
+ position: None,
+ sound: api::schema::NotificationShowSound::Done,
+ },
+ ),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ })
+ );
+
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ parsed.result,
+ api::schema::ResponseResult::NotificationShow {
+ shown: true,
+ reason: api::schema::NotificationShowReason::Shown,
+ }
+ );
+ match read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("semantic api notification"),
+ ) {
+ ServerMessage::SemanticNotification(notification) => {
+ assert_eq!(notification.title, "build failed");
+ assert_eq!(
+ notification.sound,
+ Some(protocol::SemanticNotificationSound::Done)
+ );
+ }
+ other => panic!("expected semantic api notification, got {other:?}"),
+ }
+}
+
+#[test]
+fn startup_idle_does_not_forward_completion() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("active");
+ let pane_id = workspace.tabs[0].root_pane;
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(0);
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
+ server.app.state.toast_config.delay_seconds = 0;
+ server.app.state.sound.enabled = true;
+
+ assert!(
+ server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected {
+ pane_id,
+ agent: crate::detect::Agent::Pi,
+ observed_at: Instant::now(),
+ })
+ );
+
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ while client_control_rx
+ .recv_timeout(Duration::from_millis(20))
+ .is_ok()
+ {}
+
+ assert!(
+ server.handle_internal_event_with_forwarding(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(crate::detect::Agent::Pi),
+ state: crate::detect::AgentState::Idle,
+ visible_blocker: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: Instant::now(),
+ })
+ );
+ assert!(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "startup readiness should not forward a completion notification"
+ );
+}
+
+#[test]
+fn stale_api_agent_report_does_not_forward_done_sound() {
+ let mut server = test_headless_server();
+ let background = crate::workspace::Workspace::test_new("background");
+ let pane_id = background.tabs[0].root_pane;
+ let public_pane_id = format!("{}:p1", background.id);
+ let foreground = crate::workspace::Workspace::test_new("foreground");
+ server.app.state.workspaces = vec![background, foreground];
+ server.app.state.ensure_test_terminals();
+ let terminal_id = server.app.state.workspaces[0]
+ .pane_state(pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_detected_state(
+ Some(crate::detect::Agent::Pi),
+ crate::detect::AgentState::Idle,
+ );
+ server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ session_ref: crate::agent_resume::AgentSessionRef::path(
+ std::env::current_dir()
+ .unwrap()
+ .join("headless-pi-session.jsonl")
+ .display()
+ .to_string(),
+ )
+ .unwrap(),
+ });
+ server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::detect::AgentState::Working,
+ None,
+ Some(20),
+ );
+ server.app.state.active = Some(1);
+ server.app.state.selected = 1;
+ server.app.state.mode = crate::app::Mode::Terminal;
+
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "stale".into(),
+ method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams {
+ pane_id: public_pane_id,
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ state: api::schema::PaneAgentState::Idle,
+ message: None,
+ seq: Some(19),
+ agent_session_id: None,
+ agent_session_path: None,
+ }),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+
+ assert!(changed);
+ assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok());
+ assert_eq!(
+ server.app.state.terminals.get(&terminal_id).unwrap().state,
+ crate::detect::AgentState::Working
+ );
+ assert!(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "stale idle report must not forward a done sound"
+ );
+}
+
+/// Verify that calls to the app's internal-event methods only occur inside
+/// `handle_internal_event_with_forwarding`. This ensures the forwarding
+/// bypass cannot be reintroduced.
+#[test]
+fn no_handle_internal_event_bypass_in_module() {
+ let source = include_str!("../../headless.rs");
+
+ // Find all lines containing handle_internal_event
+ let mut bypass_lines: Vec<String> = Vec::new();
+ let mut inside_forwarding_method = false;
+ let mut forwarding_method_brace_depth = 0u32;
+
+ for (i, line) in source.lines().enumerate() {
+ let line_num = i + 1;
+
+ // Track when we're inside handle_internal_event_with_forwarding
+ if line.contains("fn handle_internal_event_with_forwarding") {
+ inside_forwarding_method = true;
+ forwarding_method_brace_depth = 0;
+ }
+
+ if inside_forwarding_method {
+ // Count braces to track when we exit the method
+ for ch in line.chars() {
+ match ch {
+ '{' => forwarding_method_brace_depth += 1,
+ '}' => {
+ forwarding_method_brace_depth =
+ forwarding_method_brace_depth.saturating_sub(1);
+ if forwarding_method_brace_depth == 0 {
+ inside_forwarding_method = false;
+ }
+ }
+ _ => {}
+ }
+ }
+ } else if (line.contains("self.app.handle_internal_event(")
+ || line.contains("self.app.handle_internal_event_with_render_impact("))
+ && !line.trim().starts_with("///")
+ && !line.contains("contains(")
+ {
+ // Internal-event call outside the forwarding method.
+ bypass_lines.push(format!("line {}: {}", line_num, line.trim()));
+ }
+ }
+
+ assert!(
+ bypass_lines.is_empty(),
+ "Found direct calls to self.app.handle_internal_event outside \
+ handle_internal_event_with_forwarding (bypass risk):\n {}",
+ bypass_lines.join("\n ")
+ );
+}
diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs
index bd6e3cd7..ecdbc2b7 100644
--- a/src/server/headless/tests/pane_graphics.rs
+++ b/src/server/headless/tests/pane_graphics.rs
@@ -5,61 +5,170 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Durati
}
#[tokio::test]
-async fn cold_redraw_advances_one_bounded_layer_after_each_send() {
- let (mut server, client_rx, pane_id) = retained_test_server(b"cold redraw");
- server.app.state.kitty_graphics_enabled = true;
- server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize {
+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 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,
};
- const LAYERS: usize = 8;
- for index in 0..LAYERS {
- set_named_graphics_layer(
- &mut server,
- pane_id,
- &format!("layer-{index:02}"),
- vec![index as u8; 1024 * 1024],
- index as i32,
- );
- }
+ set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]);
- fill_render_lane(&server);
server.render_and_stream();
- assert!(server.clients[&1].graphics_cache.is_empty());
- let _older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap();
-
- for expected in 1..=LAYERS {
- server.render_and_stream();
- let bytes = client_rx.recv_timeout(Duration::from_secs(5)).unwrap();
- assert!(bytes.len() <= MAX_GRAPHICS_FRAME_SIZE + 4);
- let frame = read_server_frame(bytes);
- assert_eq!(
- frame
- .graphics
- .windows(4)
- .filter(|part| *part == b"a=t,")
- .count(),
- 1
- );
- assert_eq!(
- server.clients[&1].graphics_cache.test_image_count(),
- expected
- );
- }
- assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None);
+ let first = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(first) = first else {
+ panic!("expected client shell pane surface");
+ };
+ assert_eq!(first.graphics.placements.len(), 1);
+ assert_eq!(first.graphics.assets.len(), 1);
+ assert_eq!(first.graphics.assets[0].data, vec![1, 2, 3, 4]);
+ assert!(matches!(
+ first.graphics.placements[0].asset.source,
+ crate::protocol::SurfaceGraphicsSource::PaneLayer { .. }
+ ));
+
+ server.clients.get_mut(&1).unwrap().request_repaint();
+ server.render_and_stream();
+ let second = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(second) = second else {
+ panic!("expected replacement client shell pane surface");
+ };
+ assert_eq!(second.graphics.placements, first.graphics.placements);
+ assert!(second.graphics.assets.is_empty());
}
-fn enable_graphics_and_render(
- server: &mut HeadlessServer,
- client_rx: &std::sync::mpsc::Receiver<Vec<u8>>,
-) -> FrameData {
- server.app.state.kitty_graphics_enabled = true;
- server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize {
+#[tokio::test]
+async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics");
+ 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,
+ };
+ set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]);
+
+ server.render_and_stream();
+ let _first = receive_render(&client_rx, Duration::from_millis(100));
+ server.app.pane_graphics.slots.clear();
+ server.app.pane_graphics.mark_changed();
+ server.clients.get_mut(&1).unwrap().request_repaint();
+ server.render_and_stream();
+ let removed = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(removed) = removed else {
+ panic!("expected removed client shell scene");
+ };
+ assert!(removed.graphics.placements.is_empty());
+
+ set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]);
+ server.clients.get_mut(&1).unwrap().request_repaint();
+ server.render_and_stream();
+ let restored = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(restored) = restored else {
+ panic!("expected restored client shell scene");
+ };
+ assert_eq!(restored.graphics.assets.len(), 1);
+ assert_eq!(restored.graphics.assets[0].data, vec![1, 2, 3, 4]);
+}
+
+#[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\\");
+ 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();
- read_server_frame(receive_render(client_rx, Duration::from_millis(100)))
+ let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(surface) = message else {
+ panic!("expected client shell pane surface");
+ };
+ assert_eq!(surface.graphics.placements.len(), 1);
+ assert_eq!(surface.graphics.assets.len(), 1);
+ assert!(matches!(
+ surface.graphics.placements[0].asset.source,
+ crate::protocol::SurfaceGraphicsSource::Terminal {
+ target: crate::protocol::SurfaceGraphicsTarget::Pane { .. },
+ image_id: 7,
+ }
+ ));
+ assert_eq!(surface.graphics.assets[0].data, vec![255, 0, 0, 255]);
+}
+
+#[tokio::test]
+async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() {
+ 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\\\x1b_Ga=T,f=32,t=d,i=8,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\",
+ );
+ 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 message = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(surface) = message else {
+ panic!("expected client shell pane surface");
+ };
+ assert_eq!(surface.graphics.placements.len(), 2);
+ assert_eq!(surface.graphics.assets.len(), 2);
+ assert_ne!(
+ surface.graphics.assets[0].key,
+ surface.graphics.assets[1].key
+ );
+ assert_eq!(
+ surface.graphics.assets[0].data,
+ surface.graphics.assets[1].data
+ );
+
+ server.clients.get_mut(&1).unwrap().request_repaint();
+ server.render_and_stream();
+ let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(surface) = message else {
+ panic!("expected replacement client shell pane surface");
+ };
+ assert_eq!(surface.graphics.placements.len(), 2);
+ assert!(surface.graphics.assets.is_empty());
+}
+
+#[tokio::test]
+async fn full_client_shell_render_lane_does_not_commit_graphics_delivery() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics");
+ 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,
+ };
+ set_graphics_layer(&mut server, pane_id, vec![5, 6, 7, 8]);
+
+ fill_render_lane(&server);
+ server.render_and_stream();
+ let _older = receive_render(&client_rx, Duration::from_millis(100));
+ server.render_and_stream();
+ let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(surface) = message else {
+ panic!("expected client shell pane surface");
+ };
+ assert_eq!(surface.graphics.assets.len(), 1);
+ assert_eq!(surface.graphics.assets[0].data, vec![5, 6, 7, 8]);
}
fn graphics_key(pane_id: crate::layout::PaneId) -> crate::app::pane_graphics::Key {
@@ -259,151 +368,6 @@ async fn pixel_mouse_activation_requires_graphics_demand_not_direct_transport()
}
#[tokio::test]
-async fn pixel_input_metadata_cannot_resize_authoritative_client_state() {
- let (mut server, _client_rx, pane_id) =
- retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h");
- set_graphics_layer(&mut server, pane_id, vec![1]);
- let client = server.clients.get_mut(&1).unwrap();
- client.pixel_mouse = true;
- client.host_sgr_pixels_active = Some(true);
- server.foreground_client_id = None;
- assert!(!server.handle_server_event(ServerEvent::ClientInputPixels {
- client_id: 1,
- data: b"\x1b[<0;500;300M".to_vec(),
- geometry: crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(),
- }));
- server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize {
- width_px: 10,
- height_px: 20,
- };
- for (geometry, data) in [
- (
- crate::input::mouse::HostGeometry::new(100, 30, 1_000, 600).unwrap(),
- b"\x1b[<0;500;300M".as_slice(),
- ),
- (
- crate::input::mouse::HostGeometry::new(80, 24, 960, 480).unwrap(),
- b"\x1b[<0;500;300M",
- ),
- (
- crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(),
- b"\x1b[<0;0;1M",
- ),
- ] {
- assert!(!server.handle_server_event(ServerEvent::ClientInputPixels {
- client_id: 1,
- data: data.to_vec(),
- geometry,
- }));
- }
- assert_eq!(server.clients[&1].terminal_size, (80, 24));
- assert_eq!(
- (server.effective_size, server.foreground_client_id),
- ((80, 24), None)
- );
-}
-
-#[test]
-fn direct_eligibility_is_installed_with_the_client_connection() {
- let mut server = test_headless_server();
- let (writer, _control_rx, _render_rx) = test_client_writer();
-
- assert!(server.handle_server_event(ServerEvent::ClientConnected {
- client_id: 7,
- cols: 80,
- rows: 24,
- cell_width_px: 10,
- cell_height_px: 20,
- render_encoding: RenderEncoding::SemanticFrame,
- keybindings: None,
- direct_attach_requested: false,
- direct_graphics: true,
- writer,
- }));
-
- let client = server.clients.get(&7).expect("connected client");
- assert!(client.direct_graphics);
- assert_eq!(server.foreground_client_id, Some(7));
- assert!(server.app.direct_graphics_available);
-}
-
-#[tokio::test]
-async fn focus_repaint_preserves_uploaded_graphics() {
- let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
- let (client_2_writer, _client_2_control_rx, client_2_rx) = test_client_writer();
- server.clients.insert(
- 2,
- ClientConnection::new(
- (80, 24),
- crate::kitty_graphics::HostCellSize {
- width_px: 10,
- height_px: 20,
- },
- crate::terminal_theme::TerminalTheme::default(),
- Some(false),
- 0,
- RenderEncoding::SemanticFrame,
- Some(client_2_writer),
- ),
- );
- set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]);
- let initial = enable_graphics_and_render(&mut server, &client_rx);
- let initial_graphics = String::from_utf8_lossy(&initial.graphics);
- assert!(initial_graphics.contains("a=t"));
- assert!(initial_graphics.contains("a=p"));
- let client_2_initial =
- read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100)));
- assert!(String::from_utf8_lossy(&client_2_initial.graphics).contains("a=t"));
-
- assert!(server.handle_server_event(ServerEvent::ClientInput {
- client_id: 2,
- data: b"\x1b[I".to_vec(),
- }));
- assert_eq!(server.foreground_client_id, Some(2));
- server.render_and_stream();
-
- let focused = read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100)));
- let focused_graphics = String::from_utf8_lossy(&focused.graphics);
- assert!(focused_graphics.contains("a=p"));
- assert!(!focused_graphics.contains("a=t"));
-}
-
-#[tokio::test]
-async fn resize_replays_placement_without_retransmitting_or_closing_stream() {
- let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
- set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]);
- set_stream_owner(&mut server, pane_id, "owner-resize");
- let initial = enable_graphics_and_render(&mut server, &client_rx);
- assert!(String::from_utf8_lossy(&initial.graphics).contains("a=t"));
-
- for (cols, rows, cell_width_px, cell_height_px) in
- [(100, 30, 10, 20), (100, 30, 12, 24), (100, 30, 12, 24)]
- {
- assert!(server.handle_server_event(ServerEvent::ClientResize {
- client_id: 1,
- cols,
- rows,
- cell_width_px,
- cell_height_px,
- }));
- server.render_and_stream();
- let frame = read_server_frame(receive_render(&client_rx, Duration::from_millis(100)));
- let graphics = String::from_utf8_lossy(&frame.graphics);
- assert!(!graphics.contains("a=t"));
- assert!(graphics.contains("a=p"));
- }
- assert_eq!(
- server
- .app
- .pane_graphics
- .slots
- .get(&graphics_key(pane_id))
- .and_then(|slot| slot.stream_owner.as_deref()),
- Some("owner-resize")
- );
-}
-
-#[tokio::test]
async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() {
let (mut server, _client_rx, pane_id) = retained_test_server(b"aaaa");
set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]);
@@ -426,88 +390,6 @@ async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() {
assert!(server.app.pane_graphics.slots.is_empty());
}
-#[tokio::test]
-async fn retained_update_sends_only_graphics_message() {
- let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
- let baseline = enable_graphics_and_render(&mut server, &client_rx);
- set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]);
-
- assert_eq!(
- server.render_retained_graphics_update_and_stream(),
- RetainedGraphicsOutcome::Sent
- );
- match read_server_message(
- client_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("graphics-only update"),
- ) {
- ServerMessage::Graphics { bytes } => {
- assert!(bytes.windows(3).any(|window| window == b"\x1b_G"));
- }
- other => panic!("expected graphics-only message, got {other:?}"),
- }
- assert_frame_data_eq(
- server
- .clients
- .get(&1)
- .unwrap()
- .render_state
- .last_frame()
- .expect("semantic baseline"),
- &baseline,
- );
-}
-
-#[tokio::test]
-async fn retained_graphics_stays_ordered_after_an_older_render() {
- let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
- let _ = enable_graphics_and_render(&mut server, &client_rx);
- fill_render_lane(&server);
- set_graphics_layer(&mut server, pane_id, vec![4, 5, 6]);
- let older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap();
- assert_eq!(
- server.render_retained_graphics_update_and_stream(),
- RetainedGraphicsOutcome::Sent
- );
- let graphics = client_rx.recv_timeout(Duration::from_secs(1)).unwrap();
- assert!(matches!(
- read_server_message(older),
- ServerMessage::ReloadSoundConfig
- ));
- assert!(matches!(
- read_server_message(graphics),
- ServerMessage::Graphics { .. }
- ));
-}
-
-#[tokio::test]
-async fn retained_update_falls_back_for_mixed_app_geometry() {
- let (mut server, client_rx, _pane_id) = retained_test_server(b"aaaa");
- let _ = enable_graphics_and_render(&mut server, &client_rx);
-
- let (writer, _control_rx, _render_rx) = test_client_writer();
- server.clients.insert(
- 2,
- ClientConnection::new(
- (60, 20),
- crate::kitty_graphics::HostCellSize {
- width_px: 10,
- height_px: 20,
- },
- crate::terminal_theme::TerminalTheme::default(),
- None,
- 2,
- RenderEncoding::SemanticFrame,
- Some(writer),
- ),
- );
-
- assert_eq!(
- server.render_retained_graphics_update_and_stream(),
- RetainedGraphicsOutcome::Fallback
- );
-}
-
#[test]
fn stream_open_gate_is_owned_by_the_layer_and_cancels_on_removal() {
let mut server = test_headless_server();
@@ -730,248 +612,103 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() {
#[cfg(unix)]
#[tokio::test]
-async fn hidden_large_direct_frame_uploads_then_replays_placement_without_closing_stream() {
- let (mut server, client_rx, _) = retained_test_server(b"active");
- enable_graphics_and_render(&mut server, &client_rx);
- let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser"));
- let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane;
- let pane_number = server.app.state.workspaces[0]
- .public_pane_number(pane_id)
- .unwrap();
- let public_pane_id = crate::workspace::public_pane_id_for_number(
- &server.app.state.workspaces[0].id,
- pane_number,
- );
- server.clients.get_mut(&1).unwrap().direct_graphics = true;
+async fn client_shell_direct_graphics_uploads_without_server_authored_coordinates() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"client shell direct");
+ server.app.state.kitty_graphics_enabled = true;
+ 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,
+ };
+ client.direct_graphics = true;
+ client.pixel_mouse = true;
server.app.direct_graphics_available = true;
set_stream_owner(&mut server, pane_id, "browser");
-
- let image_width = 2_048;
- let image_height = 2_049;
- let expected_len = u64::from(image_width) * u64::from(image_height) * 4;
- assert!(expected_len > api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES as u64);
- let path = sparse_direct_frame(
- &server,
- "hidden-large-frame.rgba",
- image_width,
- image_height,
- );
- let (message, response_rx) = direct_stream_message(
- "hidden-frame",
- &public_pane_id,
- "browser",
- path,
- image_width,
- image_height,
- );
+ let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap();
+ let path = sparse_direct_frame(&server, "client-shell-direct.rgba", 1, 1);
+ let (message, response_rx) =
+ direct_stream_message("shell-direct", &public_pane_id, "browser", path, 1, 1);
assert_eq!(
server.handle_pane_graphics_stream_frame(message),
RenderImpact::None
);
- let (transfer_id, image_id, control, leading) = match read_server_message(
+ let (transfer_id, image_id, asset) = match read_server_message(
client_rx
.recv_timeout(Duration::from_secs(1))
- .expect("hidden direct upload"),
+ .expect("client shell direct upload"),
) {
ServerMessage::GraphicsFile {
transfer_id,
image_id,
- control,
leading,
- expected_len: sent_len,
+ control,
+ surface_asset: Some(asset),
..
} => {
- assert_eq!(sent_len, expected_len);
- (transfer_id, image_id, control, leading)
+ assert!(leading.is_empty());
+ assert!(control.starts_with("a=t,"), "{control}");
+ assert!(!control.contains("\u{1b}["), "{control}");
+ (transfer_id, image_id, asset)
}
- other => panic!("expected graphics file, got {other:?}"),
+ other => panic!("expected client shell graphics file, got {other:?}"),
};
- assert!(leading.is_empty());
- assert!(control.starts_with("a=t,"), "{control}");
- assert!(!control.contains("p="), "{control}");
- assert!(response_rx.try_recv().is_err());
-
- server.app.state.workspaces[0].switch_tab(background_tab);
- server.render_and_stream();
- let frame = read_server_frame(
- client_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("frame while upload is pending"),
+ assert_eq!(
+ image_id,
+ crate::kitty_graphics::surface::host_image_id(&server.client_shell_boot_id, &asset)
);
- assert!(!frame.graphics.windows(4).any(|bytes| bytes == b"a=p,"));
-
- server.app.state.workspaces[0].switch_tab(0);
- server.render_and_stream();
- let _hidden_again = read_server_frame(
- client_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("frame after hiding pending upload"),
+ let (pending, _) = crate::server::client_shell_graphics::collect(
+ &server.app,
+ &[],
+ &[],
+ None,
+ crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ },
+ &crate::kitty_graphics::surface::DeliveryCache::default(),
+ 1,
);
+ assert_eq!(pending.retained_assets, vec![asset.clone()]);
+
server.start_direct_graphics_response(1, transfer_id, image_id);
assert!(server.complete_direct_graphics(1, transfer_id, image_id, true));
assert!(serde_json::from_str::<api::schema::SuccessResponse>(
&response_rx.recv_timeout(Duration::from_secs(1)).unwrap()
)
.is_ok());
- let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)];
- assert!(slot.stream_is_active());
- assert!(slot.layer.as_ref().unwrap().terminal_only());
-
- server.app.state.workspaces[0].switch_tab(background_tab);
+ server.clients.get_mut(&1).unwrap().request_repaint();
server.render_and_stream();
- let frame = read_server_frame(
- client_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("placement replay after tab switch"),
- );
- let graphics = String::from_utf8_lossy(&frame.graphics);
- assert!(graphics.contains("a=p,"), "{graphics:?}");
- assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}");
- assert!(!graphics.contains("a=t,"), "{graphics:?}");
-
- let next_path = sparse_direct_frame(
- &server,
- "visible-next-frame.rgba",
- image_width,
- image_height,
- );
- let (message, next_response_rx) = direct_stream_message(
- "visible-frame",
- &public_pane_id,
- "browser",
- next_path,
- image_width,
- image_height,
- );
- assert_eq!(
- server.handle_pane_graphics_stream_frame(message),
- RenderImpact::None
- );
- match read_server_message(
+ let surface = read_server_message(
client_rx
.recv_timeout(Duration::from_secs(1))
- .expect("next visible direct frame"),
- ) {
- ServerMessage::GraphicsFile { control, .. } => {
- assert!(control.starts_with("a=T,"), "{control}");
- }
- other => panic!("expected graphics file, got {other:?}"),
- }
- assert!(next_response_rx.try_recv().is_err());
- assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active());
-}
-
-#[cfg(unix)]
-#[tokio::test]
-async fn hidden_small_direct_frame_preserves_owned_inline_fallback() {
- let (mut server, client_rx, _) = retained_test_server(b"active");
- enable_graphics_and_render(&mut server, &client_rx);
- let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser"));
- let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane;
- let pane_number = server.app.state.workspaces[0]
- .public_pane_number(pane_id)
- .unwrap();
- let public_pane_id = crate::workspace::public_pane_id_for_number(
- &server.app.state.workspaces[0].id,
- pane_number,
- );
- server.clients.get_mut(&1).unwrap().direct_graphics = true;
- server.app.direct_graphics_available = true;
- set_stream_owner(&mut server, pane_id, "browser");
-
- let path = sparse_direct_frame(&server, "hidden-small-frame.rgba", 1, 1);
- let (message, response_rx) =
- direct_stream_message("hidden-small", &public_pane_id, "browser", path, 1, 1);
- assert_eq!(
- server.handle_pane_graphics_stream_frame(message),
- RenderImpact::Graphics
+ .expect("resident client shell scene"),
);
- assert!(serde_json::from_str::<api::schema::SuccessResponse>(
- &response_rx.recv_timeout(Duration::from_secs(1)).unwrap()
- )
- .is_ok());
- assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
- let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)];
- assert!(slot.stream_is_active());
- assert_eq!(
- slot.layer.as_ref().unwrap().inline_data(),
- Some([0; 4].as_slice())
- );
-}
-
-#[cfg(unix)]
-#[tokio::test]
-async fn direct_frame_during_internal_redraw_uploads_without_placement() {
- let (mut server, client_rx, pane_id) = retained_test_server(b"active");
- enable_graphics_and_render(&mut server, &client_rx);
- let pane_number = server.app.state.workspaces[0]
- .public_pane_number(pane_id)
- .unwrap();
- let public_pane_id = crate::workspace::public_pane_id_for_number(
- &server.app.state.workspaces[0].id,
- pane_number,
- );
- server.clients.get_mut(&1).unwrap().direct_graphics = true;
- server.app.direct_graphics_available = true;
- set_stream_owner(&mut server, pane_id, "browser");
- server
- .app
- .event_tx
- .try_send(AppEvent::UpdateReady {
- version: "9.9.9".into(),
- install_command: "herdr update".into(),
- })
- .unwrap();
-
- let image_width = 2_048;
- let image_height = 2_049;
- let path = sparse_direct_frame(&server, "redraw-frame.rgba", image_width, image_height);
- let (message, response_rx) = direct_stream_message(
- "redraw",
- &public_pane_id,
- "browser",
- path,
- image_width,
- image_height,
- );
- assert_eq!(
- server.handle_pane_graphics_stream_frame(message),
- RenderImpact::Full
- );
- let (transfer_id, image_id) = match read_server_message(
- client_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("direct upload during redraw"),
- ) {
- ServerMessage::GraphicsFile {
- control,
- leading,
- transfer_id,
- image_id,
- ..
- } => {
- assert!(leading.is_empty());
- assert!(control.starts_with("a=t,"), "{control}");
- (transfer_id, image_id)
- }
- other => panic!("expected graphics file, got {other:?}"),
+ let ServerMessage::PaneSurface(surface) = surface else {
+ panic!("expected client shell pane surface");
};
- server.start_direct_graphics_response(1, transfer_id, image_id);
- assert!(server.complete_direct_graphics(1, transfer_id, image_id, true));
- assert!(response_rx.recv_timeout(Duration::from_secs(1)).is_ok());
- assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active());
-
- server.render_and_stream();
- let frame = read_server_frame(
- client_rx
- .recv_timeout(Duration::from_secs(1))
- .expect("placement after redraw upload acknowledgement"),
+ assert_eq!(surface.graphics.placements.len(), 1);
+ assert!(surface.graphics.assets.is_empty());
+ assert_eq!(surface.graphics.placements[0].asset, asset);
+ assert_eq!(surface.graphics.retained_assets, vec![asset.clone()]);
+
+ let (hidden, _) = crate::server::client_shell_graphics::collect(
+ &server.app,
+ &[],
+ &[],
+ None,
+ crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ },
+ &crate::kitty_graphics::surface::DeliveryCache::default(),
+ 1,
);
- let graphics = String::from_utf8_lossy(&frame.graphics);
- assert!(graphics.contains("a=p,"), "{graphics:?}");
- assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}");
- assert!(!graphics.contains("a=t,"), "{graphics:?}");
+ assert!(hidden.placements.is_empty());
+ assert_eq!(hidden.retained_assets, vec![asset]);
}
#[cfg(unix)]
@@ -1029,6 +766,7 @@ fn direct_gate_server_with_file(
slot.stream_active = Some(active_gate());
slot.direct_gate = Some(crate::app::pane_graphics::DirectGate {
transfer_id: lease.fingerprint(),
+ image_id: (1 << 31) | 900,
client_id: 7,
deadline: std::time::Instant::now() + Duration::from_secs(1),
written: true,
@@ -1042,10 +780,8 @@ fn direct_gate_server_with_file(
#[cfg(unix)]
fn direct_ids(server: &HeadlessServer, key: &crate::app::pane_graphics::Key) -> (u64, u32) {
let slot = &server.app.pane_graphics.slots[key];
- (
- slot.direct_gate.as_ref().unwrap().transfer_id,
- slot.host_image_id,
- )
+ let gate = slot.direct_gate.as_ref().unwrap();
+ (gate.transfer_id, gate.image_id)
}
#[cfg(unix)]
@@ -1058,8 +794,6 @@ fn add_direct_client(server: &mut HeadlessServer, client_id: u64) {
width_px: 10,
height_px: 20,
},
- crate::terminal_theme::TerminalTheme::default(),
- None,
1,
RenderEncoding::SemanticFrame,
Some(writer),
@@ -1131,50 +865,6 @@ fn matching_terminal_ok_releases_producer_and_acknowledges() {
#[cfg(unix)]
#[test]
-fn explicit_terminal_error_acks_only_after_owned_inline_fallback() {
- let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
- add_direct_client(&mut server, 7);
- let (transfer_id, image_id) = direct_ids(&server, &key);
- let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap();
- server
- .clients
- .get_mut(&7)
- .unwrap()
- .graphics_cache
- .trust_pane_layer(&key, image_id, layer);
- assert!(server.complete_direct_graphics(7, transfer_id, image_id, false));
-
- let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap();
- assert_eq!(
- (
- response_rx.recv().unwrap(),
- layer.inline_data(),
- server.clients[&7].direct_graphics,
- server.clients[&7].pixel_mouse,
- ),
- ("ack".into(), Some([1, 2, 3, 4].as_slice()), false, true)
- );
- assert!(server.clients[&7].graphics_cache.is_empty());
-}
-
-#[cfg(unix)]
-#[test]
-fn large_direct_terminal_error_closes_without_acknowledging_or_copying() {
- let len = crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES + 4;
- let (mut server, key, response_rx) = direct_gate_server_with_file(len, None);
- add_direct_client(&mut server, 7);
- let (transfer_id, image_id) = direct_ids(&server, &key);
-
- assert!(server.complete_direct_graphics(7, transfer_id, image_id, false));
- assert!(!server.app.pane_graphics.slots.contains_key(&key));
- assert!(matches!(
- response_rx.try_recv(),
- Err(std::sync::mpsc::TryRecvError::Disconnected)
- ));
-}
-
-#[cfg(unix)]
-#[test]
fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() {
for error in [
std::sync::mpsc::TrySendError::Full(Vec::new()),
@@ -1217,6 +907,43 @@ fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires()
#[cfg(unix)]
#[test]
+fn eligibility_loss_cancels_the_queued_direct_upload() {
+ let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
+ let (writer, control_rx, _render_rx) = test_client_writer();
+ let mut client = ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ },
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(writer),
+ );
+ client.direct_graphics = true;
+ client.pixel_mouse = true;
+ server.clients.insert(7, client);
+ let (transfer_id, image_id) = direct_ids(&server, &key);
+
+ server.retire_all_direct_graphics();
+
+ assert!(matches!(
+ read_server_message(
+ control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("direct retirement")
+ ),
+ ServerMessage::GraphicsTransmissionRetired {
+ transfer_id: retired_transfer,
+ image_id: retired_image,
+ } if retired_transfer == transfer_id && retired_image == image_id
+ ));
+ assert!(!server.app.pane_graphics.slots.contains_key(&key));
+ assert!(response_rx.recv().is_err());
+}
+
+#[cfg(unix)]
+#[test]
fn client_loss_retires_only_its_direct_stream() {
let (mut pending, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
pending.retire_direct_graphics_for_client(8);
@@ -1242,6 +969,44 @@ fn client_loss_retires_only_its_direct_stream() {
#[cfg(unix)]
#[test]
+fn pane_removal_cancels_the_pending_client_upload() {
+ let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
+ let (writer, control_rx, _render_rx) = test_client_writer();
+ let mut client = ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ },
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(writer),
+ );
+ client.direct_graphics = true;
+ client.pixel_mouse = true;
+ server.clients.insert(7, client);
+ let (transfer_id, image_id) = direct_ids(&server, &key);
+ server.app.state.workspaces.clear();
+
+ assert!(server.retain_live_pane_graphics());
+
+ assert!(matches!(
+ read_server_message(
+ control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("pane removal retirement")
+ ),
+ ServerMessage::GraphicsTransmissionRetired {
+ transfer_id: retired_transfer,
+ image_id: retired_image,
+ } if retired_transfer == transfer_id && retired_image == image_id
+ ));
+ assert!(!server.app.pane_graphics.slots.contains_key(&key));
+ assert!(response_rx.recv().is_err());
+}
+
+#[cfg(unix)]
+#[test]
fn pane_removal_and_shutdown_drop_direct_without_ack() {
let setups: [fn(&mut HeadlessServer); 2] = [
|server| server.app.state.workspaces.clear(),