diff options
Diffstat (limited to 'src/server/headless')
| -rw-r--r-- | src/server/headless/bootstrap.rs | 218 | ||||
| -rw-r--r-- | src/server/headless/lifecycle.rs | 399 | ||||
| -rw-r--r-- | src/server/headless/notifications.rs | 674 | ||||
| -rw-r--r-- | src/server/headless/pane_graphics.rs | 284 | ||||
| -rw-r--r-- | src/server/headless/render.rs | 606 | ||||
| -rw-r--r-- | src/server/headless/retained_surface.rs | 613 | ||||
| -rw-r--r-- | src/server/headless/tests/mod.rs | 5025 | ||||
| -rw-r--r-- | src/server/headless/tests/pane_graphics.rs | 827 |
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(¶ms.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(), |
