//! Headless server mode — runs the herdr event loop without a real terminal. //! //! The server: //! - Does not enter raw mode or read stdin //! - Creates and listens on both `herdr.sock` (existing JSON API) and //! `herdr-client.sock` (new binary protocol) //! - Initializes AppState and all PTYs from session restore or fresh state //! - Runs the main event loop (drain events, drain API requests, scheduled tasks) //! - Renders to a virtual ratatui Buffer in memory //! - Accepts client connections on the client socket //! - Streams frames to connected clients after each render //! - Routes client input events through the existing input pipeline //! - Continues running after client disconnect //! - Handles stale socket cleanup, explicit server stop, minimum terminal size, //! and pane spawn failure during restore use std::collections::HashMap; use std::io; use std::os::unix::net::UnixListener; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use crossterm::event::{KeyModifiers, MouseEventKind}; use ratatui::layout::Rect; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; use base64::Engine; use bytes::Bytes; use crate::api; use crate::app; use crate::config; use crate::events::AppEvent; use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity}; use crate::protocol::{ self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, }; use crate::server::client_accept::{ accept_pending_client_connections, reject_pending_client_connections, }; use crate::server::client_transport::ServerEvent; use crate::server::clients::{ events_include_interaction, latest_app_client, render_targets, terminal_attach_client_ids, ClientConnection, ClientConnectionMode, }; use crate::server::keybindings::{app_keybindings, apply_keybindings}; use crate::server::notifications::{ should_forward_toast_to_clients, toast_message_from_state_change, toast_notify_kind, }; use crate::server::socket_paths::{ client_socket_path, prepare_socket_path, restrict_socket_permissions, }; use crate::server::terminal_attach::paste_payload_for_runtime; #[cfg(test)] use crate::protocol::RenderEncoding; #[cfg(test)] use crate::server::client_transport::ClientWriter; #[cfg(test)] use std::fs; // --------------------------------------------------------------------------- // Loop event enum for the headless server event loop // --------------------------------------------------------------------------- /// Events that the headless server event loop can process. enum LoopEvent { Timer, Internal(AppEvent), Api(api::ApiRequestMessage), ServerEvent(ServerEvent), RenderRequested, } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Default shared runtime size (columns, rows) when no clients are attached. const MIN_COLS: u16 = 80; const MIN_ROWS: u16 = 24; /// Timeout for in-flight API requests during shutdown. #[allow(dead_code)] const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5); /// How often the idle headless loop wakes to poll the std UnixListener for new /// client connections. /// /// The listener is non-blocking and not integrated into `tokio::select!`, so /// a low-frequency wake is required to notice new thin-client attaches while /// otherwise idle. Keep this much slower than the old resize-poll cadence to /// avoid reintroducing the idle CPU spin. const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250); // --------------------------------------------------------------------------- // Headless server // --------------------------------------------------------------------------- /// The headless server — runs the herdr event loop without a real terminal. pub struct HeadlessServer { app: app::App, api_tx: Option, api_server: Option, client_listener: UnixListener, client_socket_path: PathBuf, client_socket_identity: SocketFileIdentity, clients: HashMap, next_client_id: u64, /// The client currently driving the shared pane runtime size, theme, and input keybindings. foreground_client_id: Option, /// Server-owned keybindings, restored when foreground clients use server mode. server_keybindings: crate::config::LiveKeybindConfig, /// Full server config warning shown to clients that use server keybindings. server_config_diagnostic: Option, /// Server config warning with keybinding diagnostics removed for local-keybinding clients. server_config_diagnostic_without_keybindings: Option, /// Writable direct attach owner per terminal id string. terminal_attach_owners: HashMap, /// Monotonic activity counter used to pick the most recently active client. next_activity_stamp: u64, /// Shared pane runtime size derived from the foreground client, /// or MIN_COLS × MIN_ROWS when no clients are connected. effective_size: (u16, u16), /// Flag set when shutdown is initiated. shutting_down: bool, /// Flag set while exporting live PTYs to a replacement server. handoff_in_progress: bool, /// Imported panes get one app-safe resize nudge after the first client attaches. pending_handoff_repaint_nudge: bool, /// Flag set by Ctrl+C or `server stop` signal. should_quit: Arc, /// Channel for receiving server events from client connection threads. server_event_rx: mpsc::Receiver, /// Sender for server events (cloned for each client thread). server_event_tx: mpsc::Sender, } fn apply_terminal_attach_scroll( runtime: &crate::terminal::TerminalRuntime, source: AttachScrollSource, direction: AttachScrollDirection, lines: u16, column: Option, row: Option, modifiers: u8, ) -> Result<(), String> { let wheel_kind = match direction { AttachScrollDirection::Up => MouseEventKind::ScrollUp, AttachScrollDirection::Down => MouseEventKind::ScrollDown, }; if let AttachScrollSource::PageKey { input } = source { let host_scroll = runtime.input_state().is_some_and(|input_state| { !input_state.alternate_screen && !input_state.mouse_reporting_enabled() }); if host_scroll { match direction { AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), } return Ok(()); } return apply_terminal_attach_input(runtime, input); } match runtime.wheel_routing() { Some(crate::pane::WheelRouting::MouseReport) => { runtime.scroll_reset(); let column = column.unwrap_or(0); let row = row.unwrap_or(0); let Some(bytes) = runtime.encode_mouse_wheel( wheel_kind, column, row, KeyModifiers::from_bits_truncate(modifiers), ) else { return Err(format!( "failed to encode terminal attach mouse wheel event: {wheel_kind:?}" )); }; runtime .try_send_bytes(Bytes::from(bytes)) .map_err(|err| format!("terminal attach mouse wheel input failed: {err}"))?; } Some(crate::pane::WheelRouting::AlternateScroll) => { runtime.scroll_reset(); let Some(bytes) = runtime.encode_alternate_scroll(wheel_kind) else { return Ok(()); }; runtime .try_send_bytes(Bytes::from(bytes)) .map_err(|err| format!("terminal attach alternate scroll input failed: {err}"))?; } Some(crate::pane::WheelRouting::HostScroll) | None => match direction { AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), }, } Ok(()) } fn apply_terminal_attach_input( runtime: &crate::terminal::TerminalRuntime, data: Vec, ) -> Result<(), String> { runtime.scroll_reset(); runtime .try_send_bytes(Bytes::from(data)) .map_err(|err| format!("terminal attach input failed: {err}")) } impl HeadlessServer { /// Creates and starts the headless server. /// /// This: /// 1. Prepares the client socket path (cleans up stale sockets) /// 2. Binds the client socket listener /// 3. Returns the server ready to run pub fn new( app: app::App, config_diagnostics: &[String], api_tx: Option, api_server: Option, ) -> io::Result { let client_path = client_socket_path(); prepare_socket_path(&client_path)?; let listener = UnixListener::bind(&client_path)?; restrict_socket_permissions(&client_path)?; let client_socket_identity = socket_file_identity(&client_path)?; info!(path = %client_path.display(), "client protocol socket listening"); // Set non-blocking on the listener so we can poll it from the event loop. listener.set_nonblocking(true)?; let should_quit = Arc::new(AtomicBool::new(false)); // Channel for server events from client threads. let (server_event_tx, server_event_rx) = mpsc::channel(64); let server_keybindings = app_keybindings(&app); let (server_config_diagnostic, server_config_diagnostic_without_keybindings) = server_config_diagnostic_summaries(config_diagnostics); Ok(Self { app, api_tx, api_server, client_listener: listener, client_socket_path: client_path, client_socket_identity, clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, server_keybindings, server_config_diagnostic, server_config_diagnostic_without_keybindings, terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, handoff_in_progress: false, pending_handoff_repaint_nudge: false, should_quit, server_event_rx, server_event_tx, }) } /// Runs the headless server event loop until shutdown. /// /// This is the server's main loop — analogous to `App::run()` but without /// a real terminal. It: /// - Drains internal events (pane death, state changes) /// - Drains API requests (from the JSON socket) /// - Accepts new client connections /// - Reads client messages and routes input /// - Handles scheduled tasks (resize poll, animation, session save, etc.) /// - Renders virtually and streams frames to clients pub async fn run(&mut self) -> io::Result<()> { crate::logging::startup("server"); // Register SIGINT handler for graceful shutdown. let should_quit = self.should_quit.clone(); let quit_notify = self.server_event_tx.clone(); ctrlc_handler(should_quit, quit_notify); // No input_rx needed — server doesn't read stdin. // We use None for input_rx so the event loop doesn't try to read from stdin. self.app.input_rx = None; let mut needs_render = true; loop { // If shutdown has been initiated, complete it and exit. if self.shutting_down { self.complete_shutdown()?; break; } // Check if we should start shutting down. if self.app.state.should_quit || self.should_quit.load(Ordering::Acquire) { self.initiate_shutdown(); continue; } // 1. Check render_dirty flag from PTY reader tasks. if self.app.render_dirty.load(Ordering::Acquire) { needs_render = true; } // 2. Drain a bounded internal-event batch. API handlers perform an // exhaustive forwarding-aware drain before reading pane/runtime state. if self.drain_internal_events_with_forwarding() { needs_render = true; } // 3. Drain API requests. if self.drain_api_requests_with_shutdown_check() { needs_render = true; } self.app.sync_focus_events(); self.app.sync_session_save_schedule(); // 4. Accept new client connections. self.accept_client_connections()?; // 5. Drain server events from client threads. if self.drain_server_events() { needs_render = true; } // 6. Handle scheduled tasks. let now = Instant::now(); if self.handle_scheduled_tasks_headless(now) { needs_render = true; } // Handle deferred requests. if self.app.state.request_complete_onboarding { self.app.state.request_complete_onboarding = false; self.app.open_settings_from_onboarding(); needs_render = true; } if self.app.state.request_new_workspace { self.app.state.request_new_workspace = false; self.app.create_workspace(); needs_render = true; } if self.app.state.request_new_tab { self.app.state.request_new_tab = false; self.app.create_tab(); needs_render = true; } if let Some(ws_idx) = self.app.state.request_new_linked_worktree.take() { self.app.open_new_linked_worktree_dialog(ws_idx); needs_render = true; } if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() { self.app.open_existing_worktree_dialog(ws_idx); needs_render = true; } if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() { if let Err(err) = self.app.create_workspace_with_options(cwd, true) { error!(err = %err, "failed to create workspace at requested cwd"); self.app.state.mode = app::Mode::Navigate; } needs_render = true; } if let Some(ws_idx) = self.app.state.request_remove_linked_worktree.take() { self.app.open_remove_linked_worktree_confirmation(ws_idx); needs_render = true; } if self.app.state.request_submit_worktree_create { self.app.state.request_submit_worktree_create = false; self.app.start_worktree_add(); needs_render = true; } if self.app.state.request_submit_worktree_open { self.app.state.request_submit_worktree_open = false; self.app.open_selected_existing_worktree(); needs_render = true; } if self.app.state.request_submit_worktree_remove { self.app.state.request_submit_worktree_remove = false; self.app.start_worktree_remove(); needs_render = true; } if self.app.state.request_reload_config { self.app.state.request_reload_config = false; self.reload_server_config(true); needs_render = true; } self.drain_client_config_reload_request(); self.stream_host_mouse_capture_mode(); self.app.sync_headless_animation_timer(now); // 7. Render virtually and stream frames. if needs_render && self.app.can_render_now(now) { self.app.render_dirty.swap(false, Ordering::AcqRel); self.render_and_stream(); self.app.last_render_at = Some(now); needs_render = false; continue; } // 8. Wait for next event. let next_deadline = self .app .next_headless_loop_deadline(now, needs_render) .map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL)) .or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL)); let event = { tokio::select! { maybe_api = self.app.api_rx.recv() => match maybe_api { Some(msg) => LoopEvent::Api(msg), None => LoopEvent::Timer, }, maybe_ev = self.app.event_rx.recv() => match maybe_ev { Some(ev) => LoopEvent::Internal(ev), None => LoopEvent::Timer, }, maybe_server_ev = self.server_event_rx.recv() => match maybe_server_ev { Some(ev) => LoopEvent::ServerEvent(ev), None => LoopEvent::Timer, }, _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer, _ = self.app.render_notify.notified() => LoopEvent::RenderRequested, } }; match event { LoopEvent::Timer => {} LoopEvent::Internal(ev) => { if self.handle_internal_event_with_forwarding(ev) { needs_render = true; } } LoopEvent::Api(msg) => { if self.handle_api_request_with_shutdown_check(msg) { needs_render = true; } } LoopEvent::ServerEvent(ev) => { if self.handle_server_event(ev) { needs_render = true; } } LoopEvent::RenderRequested => { if self.app.render_dirty.load(Ordering::Acquire) { needs_render = true; } } } } // Save session on exit. if !self.app.no_session { self.app.save_session_now(); } info!("headless server exiting"); Ok(()) } fn allocate_activity_stamp(&mut self) -> u64 { let stamp = self.next_activity_stamp; self.next_activity_stamp = self.next_activity_stamp.saturating_add(1); stamp } fn resize_shared_runtime_to_effective_size(&mut self) { if self.foreground_client_id.is_none() { return; } let Some(client_id) = self.foreground_client_id else { return; }; let Some(client) = self.clients.get(&client_id) else { return; }; let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() { crate::ui::compute_view_with_cell_size( &mut self.app.state, &self.app.terminal_runtimes, area, client.cell_size, ); } else { crate::ui::compute_view_with_runtime_registry( &mut self.app.state, &self.app.terminal_runtimes, area, ); } // Shared runtime size changes affect pane wrapping and foreground-driven // rendering semantics. Force one fresh frame to every remaining client // even if the next rendered buffer compares equal to its cached frame. for client in self.clients.values_mut() { client.request_full_redraw(); } } fn sync_foreground_client_state(&mut self) { let Some(client_id) = self.foreground_client_id else { self.effective_size = (MIN_COLS, MIN_ROWS); self.app.state.outer_terminal_focus = None; let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); self.sync_visible_server_config_diagnostic(false); return; }; let Some(client) = self.clients.get(&client_id) else { self.foreground_client_id = None; self.effective_size = (MIN_COLS, MIN_ROWS); self.app.state.outer_terminal_focus = None; let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); self.sync_visible_server_config_diagnostic(false); return; }; let terminal_size = client.terminal_size; let outer_terminal_focus = client.outer_terminal_focus; let host_terminal_theme = client.host_terminal_theme; let uses_local_keybindings = client.keybindings.is_some(); let keybindings = client .keybindings .as_deref() .unwrap_or(&self.server_keybindings) .clone(); self.effective_size = terminal_size; self.app.state.outer_terminal_focus = outer_terminal_focus; apply_keybindings(&mut self.app, &keybindings); self.sync_visible_server_config_diagnostic(uses_local_keybindings); if outer_terminal_focus == Some(true) { self.app.state.mark_active_tab_seen(); } if !host_terminal_theme.is_empty() { self.app.set_host_terminal_theme(host_terminal_theme); } } #[cfg(unix)] 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, self.app.state.agent_panel_scope, self.app.state.sidebar_width, self.app.state.sidebar_section_split, self.app.state.collapsed_space_keys.clone(), ); let mut panes = 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_pane = runtime.handoff_pane(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_pane.initial_history_ansi = runtime.handoff_history_ansi(); } panes.push(handoff_pane); } let manifest = crate::server::handoff::manifest_for( snapshot, panes, params.expected_protocol, params.expected_version, ); let child_pid = match crate::server::handoff::spawn_handoff_import( import_exe.as_deref(), &socket_path, &token, ) { Ok(child_pid) => child_pid, Err(err) => { self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); return Err(err); } }; info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server"); let mut fds = Vec::new(); let duplicate_result = (|| { for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { if !pane_by_terminal.contains_key(terminal_id) { 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) }; } 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) }; } 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 { 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) { 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) { 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); self.shutting_down = true; self.app.state.should_quit = true; self.app.no_session = true; info!("live handoff completed; old server exiting"); Ok(()) } #[cfg(not(unix))] fn perform_live_handoff( &mut self, _params: crate::api::schema::ServerLiveHandoffParams, ) -> io::Result<()> { Err(io::Error::other("live handoff is only supported on Unix")) } fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) { let visible = if uses_local_keybindings { &self.server_config_diagnostic_without_keybindings } else { &self.server_config_diagnostic }; if self.app.state.config_diagnostic == self.server_config_diagnostic || self.app.state.config_diagnostic == self.server_config_diagnostic_without_keybindings { self.app.state.config_diagnostic = visible.clone(); } } #[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(api_tx, self.app.event_hub.clone())?; let client_path = client_socket_path(); prepare_socket_path(&client_path)?; let listener = UnixListener::bind(&client_path)?; restrict_socket_permissions(&client_path)?; let client_socket_identity = socket_file_identity(&client_path)?; listener.set_nonblocking(true)?; 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)] 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))] fn nudge_handoff_panes_on_first_client_attach(&mut self) {} fn reload_server_config(&mut self, notify_success: bool) -> crate::config::ConfigReloadReport { let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); let report = self.app.apply_config_from_disk(notify_success); self.app.take_config_reloaded_from_disk(); self.server_keybindings = app_keybindings(&self.app); let (server_config_diagnostic, server_config_diagnostic_without_keybindings) = server_config_diagnostic_summaries(&report.diagnostics); self.server_config_diagnostic = server_config_diagnostic; self.server_config_diagnostic_without_keybindings = server_config_diagnostic_without_keybindings; self.sync_foreground_client_state(); report } fn foreground_client_outer_focus(&self) -> Option { let client_id = self.foreground_client_id?; self.clients.get(&client_id)?.outer_terminal_focus } fn active_tab_suppresses_notifications(&self, is_active_tab: bool) -> bool { crate::app::actions::active_tab_suppresses_notifications( is_active_tab, self.foreground_client_outer_focus(), ) } fn promote_client_to_foreground(&mut self, client_id: u64) -> bool { let stamp = self.allocate_activity_stamp(); let Some(client) = self.clients.get_mut(&client_id) else { return false; }; client.last_activity = stamp; let changed = self.foreground_client_id != Some(client_id); self.foreground_client_id = Some(client_id); self.sync_foreground_client_state(); changed } fn promote_latest_remaining_client(&mut self) -> bool { let next_foreground = latest_app_client(&self.clients); let changed = next_foreground != self.foreground_client_id; self.foreground_client_id = next_foreground; self.sync_foreground_client_state(); changed } fn remove_client(&mut self, client_id: u64) -> bool { let was_foreground = self.foreground_client_id == Some(client_id); self.send_client_graphics_cleanup(client_id); let removed = self.clients.remove(&client_id); if let Some(removed) = removed { crate::server::clipboard_image::remove_files(removed.staged_clipboard_files); if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode { self.terminal_attach_owners.remove(&terminal_id); if let Some(terminal_id) = self.terminal_id_by_string(&terminal_id) { self.app .state .direct_attach_resize_locks .remove(&terminal_id); } } } if was_foreground { self.promote_latest_remaining_client() } else { false } } fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool { if self.foreground_client_id == Some(client_id) { return true; } matches!( self.clients.get(&client_id).map(|client| &client.mode), Some(ClientConnectionMode::TerminalAttach { .. }) ) && self.foreground_client_id.is_some() } fn remove_client_and_resize_if_needed(&mut self, client_id: u64) { let needs_shared_resize = self.client_removal_needs_shared_resize(client_id); let foreground_changed = self.remove_client(client_id); if needs_shared_resize || foreground_changed { self.resize_shared_runtime_to_effective_size(); } } fn send_client_graphics_cleanup(&mut self, client_id: u64) { let (writer, bytes) = match self.clients.get_mut(&client_id) { Some(client) => { let bytes = client.graphics_cache.clear_bytes(); (client.writer.as_ref().cloned(), bytes) } None => return, }; if bytes.is_empty() { return; } let Some(writer) = writer else { return; }; let Ok(serialized) = Self::frame_server_message(&ServerMessage::Graphics { bytes }) else { return; }; let _ = writer.control.send(serialized); } fn send_all_clients_graphics_cleanup(&mut self) { let client_ids = self.clients.keys().copied().collect::>(); for client_id in client_ids { self.send_client_graphics_cleanup(client_id); } } fn update_client_host_theme_from_events( &mut self, client_id: u64, events: &[crate::raw_input::RawInputEvent], ) -> bool { let Some(client) = self.clients.get_mut(&client_id) else { return false; }; if !client.update_host_theme_from_events(events) { return false; } if self.foreground_client_id == Some(client_id) { self.app.set_host_terminal_theme(client.host_terminal_theme) } else { false } } fn update_client_outer_focus_from_events( &mut self, client_id: u64, events: &[crate::raw_input::RawInputEvent], ) { let Some(client) = self.clients.get_mut(&client_id) else { return; }; let Some(next_focus) = client.update_outer_focus_from_events(events) else { return; }; if self.foreground_client_id == Some(client_id) { self.app.state.outer_terminal_focus = Some(next_focus); } } /// Accepts pending client connections from the non-blocking listener. fn accept_client_connections(&mut self) -> io::Result<()> { if self.handoff_in_progress { return reject_pending_client_connections(&self.client_listener); } accept_pending_client_connections( &self.client_listener, &mut self.next_client_id, &self.should_quit, &self.server_event_tx, ) } /// Drains server events from the dedicated channel. /// /// Returns true if any input was processed (requiring a re-render). fn drain_server_events(&mut self) -> bool { let mut changed = false; while let Ok(ev) = self.server_event_rx.try_recv() { changed |= self.handle_server_event(ev); } changed } fn terminal_id_by_string(&self, terminal_id: &str) -> Option { self.app .state .terminals .keys() .find(|id| id.to_string() == terminal_id) .cloned() } fn runtime_for_terminal_id_string( &self, terminal_id: &str, ) -> Option<&crate::terminal::TerminalRuntime> { let terminal_id = self.terminal_id_by_string(terminal_id)?; self.app.terminal_runtimes.get(&terminal_id) } fn write_client_clipboard_image( &mut self, client_id: u64, extension: &str, data: &[u8], ) -> std::io::Result { let staged = crate::server::clipboard_image::stage(client_id, extension, data)?; if let Some(client) = self.clients.get_mut(&client_id) { client.staged_clipboard_files.push(staged.path); } info!(client_id, bytes = data.len(), path = %staged.paste_text, "staged client clipboard image"); Ok(staged.paste_text) } fn paste_client_clipboard_image_path(&mut self, client_id: u64, path: String) -> bool { if let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, .. }) = self.clients.get(&client_id) { if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { let payload = paste_payload_for_runtime(runtime, &path); if let Err(err) = runtime.try_send_bytes(Bytes::from(payload)) { warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach clipboard image paste failed"); } } return true; } let foreground_changed = self.promote_client_to_foreground(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size(); } if let Some(client) = self.clients.get_mut(&client_id) { client.request_semantic_redraw_after_input(); } self.app.route_client_events( vec![crate::raw_input::RawInputEvent::Paste(path)], self.foreground_client_id == Some(client_id), ); true } fn handle_terminal_attach_scroll( &mut self, client_id: u64, source: AttachScrollSource, direction: AttachScrollDirection, lines: u16, column: Option, row: Option, modifiers: u8, ) -> bool { let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, .. }) = self.clients.get(&client_id) else { return false; }; let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) else { return false; }; if let Err(err) = apply_terminal_attach_scroll(runtime, source, direction, lines, column, row, modifiers) { warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach scroll failed"); } true } 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) } /// 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). fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { match &ev { AppEvent::ClipboardWrite { content } => { // Clipboard writes are client-local side effects. Forward them only to // the foreground client instead of broadcasting to every attached client. if let Some(client_id) = self.foreground_client_id { let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); self.send_to_client(client_id, ServerMessage::Clipboard { data }); } // ClipboardWrite doesn't change visual state — no render needed. 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); // 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(); self.app.handle_internal_event(ev); // 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); if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, prev_state, next_state, ) { let msg = match sound { crate::sound::Sound::Done => "agent done", crate::sound::Sound::Request => "agent attention", }; self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Sound, message: msg.to_owned(), }); } } 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 { toast_message_from_state_change( &self.app.state, pane_id_val, suppress_active_tab_notifications, prev_state, next_state, ) } } else { None }; if let Some(msg) = toast_msg { self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: 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); self.sync_foreground_client_state(); self.app.handle_internal_event(ev); // 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); if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, prev_state, next_state, ) { let msg = match sound { crate::sound::Sound::Done => "agent done", crate::sound::Sound::Request => "agent attention", }; self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Sound, message: msg.to_owned(), }); } } 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 { toast_message_from_state_change( &self.app.state, pane_id_val, suppress_active_tab_notifications, prev_state, next_state, ) } } else { None }; if let Some(msg) = toast_msg { self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: 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); 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: detach, then run `{install_command}`" )) } } else { None }; if let Some(msg) = toast_msg { self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: msg, }); } true } AppEvent::PaneDied { 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()) }) }); self.app.handle_internal_event(ev); if let Some(terminal_id) = terminal_id { self.shutdown_terminal_attach_clients( &terminal_id, format!("terminal {terminal_id} exited"), ); } true } _ => { self.app.handle_internal_event(ev); true } } } /// Drains internal events, forwarding clipboard, sound, and toast /// notifications to connected clients instead of processing them locally. /// /// In the monolithic mode: /// - `ClipboardWrite` events are written to stdout via `write_osc52_bytes`. /// - Sound notifications are played locally via `sound::play`. /// - Toast notifications are set on AppState and rendered into the frame. /// /// In the headless server, there is no stdout 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. fn drain_internal_events_with_forwarding(&mut self) -> bool { self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT) .1 } 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 { break; } } changed } 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) } fn drain_client_config_reload_request(&mut self) { if !self.app.state.request_client_config_reload { return; } self.app.state.request_client_config_reload = false; self.send_to_all_clients(ServerMessage::ReloadSoundConfig); } /// Encodes a server message into a length-prefixed frame. fn frame_server_message(msg: &ServerMessage) -> Result, protocol::FramingError> { Self::frame_server_message_with_max(msg, MAX_FRAME_SIZE) } /// Encodes a server message using an explicit payload cap. fn frame_server_message_with_max( msg: &ServerMessage, max_frame_size: usize, ) -> Result, protocol::FramingError> { let mut framed = Vec::new(); protocol::write_message(&mut framed, msg)?; let payload_len = framed.len().saturating_sub(4); if payload_len > max_frame_size { return Err(protocol::FramingError::Oversized { claimed: payload_len, max: max_frame_size, }); } Ok(framed) } /// Sends a message to all connected clients. /// Broken connections are tracked and cleaned up. fn send_to_all_clients(&mut self, msg: ServerMessage) { let serialized = match Self::frame_server_message(&msg) { Ok(framed) => framed, Err(err) => { warn!(err = %err, "failed to serialize message for clients"); return; } }; let mut broken_clients: Vec = Vec::new(); for (&client_id, client) in &mut self.clients { if let Some(writer) = &client.writer { if writer.control.send(serialized.clone()).is_err() { debug!(client_id, "client writer channel closed during broadcast"); broken_clients.push(client_id); } } } // Remove broken clients. for client_id in broken_clients { self.remove_client_and_resize_if_needed(client_id); } } /// Sends a client-local side effect to the foreground client only. fn send_to_foreground_client(&mut self, msg: ServerMessage) -> bool { let Some(client_id) = self.foreground_client_id else { return false; }; self.send_to_client(client_id, msg) } /// Sends a message to a specific client. Returns false if the client /// was not found or the send failed (client removed). fn send_to_client(&mut self, client_id: u64, msg: ServerMessage) -> bool { let serialized = match Self::frame_server_message(&msg) { Ok(framed) => framed, Err(err) => { warn!(client_id, err = %err, "failed to serialize message for client"); return false; } }; if let Some(client) = self.clients.get(&client_id) { if let Some(writer) = &client.writer { if writer.control.send(serialized).is_err() { debug!( client_id, "client writer channel closed during targeted send" ); self.remove_client_and_resize_if_needed(client_id); return false; } } true } else { false } } fn shutdown_terminal_attach_clients(&mut self, terminal_id: &str, reason: String) { let client_ids = terminal_attach_client_ids(&self.clients, terminal_id); for client_id in client_ids { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some(reason.clone()), }, ); self.remove_client_and_resize_if_needed(client_id); } } fn disconnect_all_clients_for_handoff(&mut self) { let client_ids = self.clients.keys().copied().collect::>(); for client_id in client_ids { self.send_client_graphics_cleanup(client_id); self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some( "live update in progress; reconnect after handoff completes".to_owned(), ), }, ); if let Some(client) = self.clients.get_mut(&client_id) { client.writer = None; } let _ = self.remove_client(client_id); } self.foreground_client_id = None; self.sync_foreground_client_state(); self.resize_shared_runtime_to_effective_size(); } fn attach_terminal_client( &mut self, client_id: u64, terminal_id: String, takeover: bool, ) -> bool { let Some(real_terminal_id) = self.terminal_id_by_string(&terminal_id) else { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some(format!( "terminal attach failed: terminal {terminal_id} not found" )), }, ); self.remove_client_and_resize_if_needed(client_id); return false; }; if let Some(existing_owner) = self.terminal_attach_owners.get(&terminal_id).copied() { if existing_owner != client_id && !takeover { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some(format!( "terminal attach failed: terminal {terminal_id} already has an attached client; retry with --takeover" )), }, ); self.remove_client_and_resize_if_needed(client_id); return false; } if existing_owner != client_id { self.send_to_client( existing_owner, ServerMessage::ServerShutdown { reason: Some("terminal attach taken over".to_owned()), }, ); self.remove_client_and_resize_if_needed(existing_owner); } } let stamp = self.allocate_activity_stamp(); let Some(client) = self.clients.get_mut(&client_id) else { return false; }; let (cols, rows) = client.terminal_size; let cell_size = client.cell_size; client.mode = ClientConnectionMode::TerminalAttach { terminal_id: terminal_id.clone(), }; client.render_state.reset_baseline(); client.last_activity = stamp; let was_foreground = self.foreground_client_id == Some(client_id); if was_foreground { self.promote_latest_remaining_client(); } info!(client_id, cols, rows, terminal_id = %terminal_id, "terminal attach client connected"); self.terminal_attach_owners .insert(terminal_id.clone(), client_id); self.app .state .direct_attach_resize_locks .insert(real_terminal_id.clone()); if let Some(runtime) = self.app.terminal_runtimes.get(&real_terminal_id) { runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px); } true } /// Handles a server event. Returns true if the event requires a re-render. fn handle_server_event(&mut self, ev: ServerEvent) -> bool { if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) { return false; } match ev { ServerEvent::ClientConnected { client_id, cols, rows, cell_width_px, cell_height_px, keybindings, writer, render_encoding, } => { if self.handoff_in_progress { if let Ok(message) = Self::frame_server_message(&ServerMessage::ServerShutdown { reason: Some( "live update in progress; reconnect after handoff completes" .to_owned(), ), }) { let _ = writer.control.send(message); } return false; } info!( client_id, cols, rows, cell_width_px, cell_height_px, ?render_encoding, "client connected" ); let last_activity = self.allocate_activity_stamp(); self.clients.insert( client_id, ClientConnection::new_with_mode( ClientConnectionMode::App, keybindings, (cols, rows), crate::kitty_graphics::HostCellSize { width_px: cell_width_px, height_px: cell_height_px, }, crate::terminal_theme::TerminalTheme::default(), None, last_activity, render_encoding, Some(writer), ), ); self.foreground_client_id = Some(client_id); self.sync_foreground_client_state(); self.resize_shared_runtime_to_effective_size(); self.nudge_handoff_panes_on_first_client_attach(); true } ServerEvent::ClientAttachTerminal { client_id, terminal_id, takeover, } => self.attach_terminal_client(client_id, terminal_id, takeover), ServerEvent::ClientAttachScroll { client_id, source, direction, lines, column, row, modifiers, } => self.handle_terminal_attach_scroll( client_id, source, direction, lines, column, row, modifiers, ), ServerEvent::ClientInput { client_id, data } => { if self.handoff_in_progress { debug!( client_id, len = data.len(), "ignored client input during handoff" ); return false; } debug!(client_id, len = data.len(), "client input received"); if let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, .. }) = self.clients.get(&client_id) { if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { if let Err(err) = apply_terminal_attach_input(runtime, data) { warn!(client_id, terminal_id = %terminal_id, err = %err); } } return true; } let events = crate::raw_input::parse_raw_input_bytes_sync(&data); let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw( &events, self.app.state.redraw_on_focus_gained, ); if let Some(client) = self.clients.get_mut(&client_id) { if host_surface_redraw { client.request_full_redraw(); } else { // Ensure semantic clients receive one post-input frame even if the // semantic buffer compares equal. Terminal-ANSI clients must keep their // server-side blit baseline; resetting it here forces a full redraw on // every keypress and makes remote sessions feel extremely slow. client.request_semantic_redraw_after_input(); } } self.update_client_outer_focus_from_events(client_id, &events); let interaction = events_include_interaction(&events); let foreground_changed = if interaction { self.promote_client_to_foreground(client_id) } else { false }; if foreground_changed { self.resize_shared_runtime_to_effective_size(); } let theme_changed = self.update_client_host_theme_from_events(client_id, &events); self.app .route_client_events(events, self.foreground_client_id == Some(client_id)); if self.app.take_config_reloaded_from_disk() { self.reload_server_config(false); } else { self.sync_foreground_client_state(); } // Check if the detach keybind was triggered during input processing. if self.app.state.detach_requested { self.app.state.detach_requested = false; info!(client_id, "client detach requested via keybind"); // Clear client-local host graphics before sending ServerShutdown // so the outer terminal does not retain stale images. self.send_client_graphics_cleanup(client_id); // Send a ServerShutdown with "detached" reason to this client // so it exits cleanly (not with a connection-lost error). // The client will close its connection after receiving this, // which triggers a ClientDisconnected event that removes it. self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some("detached".to_owned()), }, ); // Don't remove the client here — let the client disconnect // naturally after receiving the ServerShutdown. The client's // read loop will see EOF and the server will get a // ClientDisconnected event which handles cleanup. // // However, we do need to stop sending frames to this client // since it's detaching. Drop the writer channel so no more // frames are queued for this client. if let Some(client) = self.clients.get_mut(&client_id) { client.writer = None; } // No re-render needed for remaining clients. false } else { foreground_changed || theme_changed || interaction } } ServerEvent::ClientClipboardImage { client_id, extension, data, } => { debug!( client_id, len = data.len(), extension = %extension, "client clipboard image received" ); match self.write_client_clipboard_image(client_id, &extension, &data) { Ok(path) => self.paste_client_clipboard_image_path(client_id, path), Err(err) => { warn!(client_id, err = %err, "failed to stage client clipboard image"); true } } } ServerEvent::ClientResize { client_id, cols, rows, cell_width_px, cell_height_px, } => { info!( client_id, cols, rows, cell_width_px, cell_height_px, "client resize" ); let direct_terminal_id = if let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, terminal_size, cell_size, render_state, .. }) = self.clients.get_mut(&client_id) { *terminal_size = (cols, rows); *cell_size = crate::kitty_graphics::HostCellSize { width_px: cell_width_px, height_px: cell_height_px, }; render_state.reset_baseline(); Some(terminal_id.clone()) } else { None }; if let Some(terminal_id) = direct_terminal_id { if let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) { runtime.resize(rows, cols, cell_width_px, cell_height_px); } return true; } if let Some(client) = self.clients.get_mut(&client_id) { client.terminal_size = (cols, rows); client.cell_size = crate::kitty_graphics::HostCellSize { width_px: cell_width_px, height_px: cell_height_px, }; } self.promote_client_to_foreground(client_id); self.resize_shared_runtime_to_effective_size(); true } ServerEvent::ClientDetach { client_id } => { info!(client_id, "client detached"); self.remove_client_and_resize_if_needed(client_id); true } ServerEvent::ClientDisconnected { client_id } => { info!(client_id, "client disconnected"); self.remove_client_and_resize_if_needed(client_id); true } ServerEvent::ClientWriterDrained { client_id } => { let Some(client) = self.clients.get_mut(&client_id) else { return false; }; if client.render_pending { client.render_pending = false; true } else { false } } ServerEvent::QuitSignal => { // The quit check at the top of the loop handles this. // No render needed — the next iteration will initiate shutdown. false } } } fn ignore_client_event_during_handoff(ev: &ServerEvent) -> bool { !matches!( ev, ServerEvent::ClientConnected { .. } | ServerEvent::ClientDisconnected { .. } | ServerEvent::ClientWriterDrained { .. } | ServerEvent::QuitSignal ) } /// Drains API requests with shutdown awareness. /// /// During shutdown, remaining requests get a `server_unavailable` error. fn drain_api_requests_with_shutdown_check(&mut self) -> bool { let mut changed = false; while let Ok(msg) = self.app.api_rx.try_recv() { changed |= self.handle_api_request_with_shutdown_check(msg); } changed } /// Handles a single API request with shutdown awareness. /// /// Also forwards any toast/sound notifications that result from the API /// request to connected clients. API methods like `pane.report_agent` /// trigger internal events that may set toast state or would normally /// play sounds — in headless mode we forward these to clients instead. fn handle_api_request_with_shutdown_check(&mut self, msg: api::ApiRequestMessage) -> bool { if self.shutting_down { // During shutdown, respond with server_unavailable. let response = serde_json::to_string(&api::schema::ErrorResponse { id: msg.request.id, error: api::schema::ErrorBody { code: "server_unavailable".into(), message: "server is shutting down".into(), }, }) .unwrap_or_else(|_| { r#"{"id":"","error":{"code":"server_unavailable","message":"server is shutting down"}}"# .to_string() }); let _ = msg.respond_to.send(response); return false; } if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method { let response = match self.perform_live_handoff(params.clone()) { Ok(()) => serde_json::to_string(&api::schema::SuccessResponse { id: msg.request.id, result: api::schema::ResponseResult::Ok {}, }), Err(err) => serde_json::to_string(&api::schema::ErrorResponse { id: msg.request.id, error: api::schema::ErrorBody { code: "handoff_failed".into(), message: err.to_string(), }, }), } .unwrap_or_else(|_| "{}".to_string()); let _ = msg.respond_to.send(response); return true; } let changed = api::request_changes_ui(&msg.request); let changed = self.drain_all_internal_events_with_forwarding() || changed; // Capture toast and effective pane states before the API call so we can // forward resulting client-local notifications. API requests like // pane.report_agent trigger handle_internal_event internally, which // bypasses drain_internal_events_with_forwarding. Headless mode disables // local sound playback, so sound notifications need to be forwarded here. let toast_before = self.app.state.toast.clone(); let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> = { let terminals = &self.app.state.terminals; self.app .state .workspaces .iter() .enumerate() .flat_map(|(ws_idx, ws)| { ws.tabs.iter().flat_map(move |tab| { tab.panes.iter().filter_map(move |(&pane_id, pane)| { terminals .get(&pane.attached_terminal_id) .map(|terminal| (ws_idx, pane_id, terminal.state)) }) }) }) .collect() }; self.sync_foreground_client_state(); let response = if matches!( &msg.request.method, api::schema::Method::ServerReloadConfig(_) ) { let report = self.reload_server_config(true); serde_json::to_string(&api::schema::SuccessResponse { id: msg.request.id.clone(), result: api::schema::ResponseResult::ConfigReload { status: report.status, diagnostics: report.diagnostics, }, }) .unwrap_or_else(|err| { serde_json::to_string(&api::schema::ErrorResponse { id: String::new(), error: api::schema::ErrorBody { code: "serialization_error".into(), message: err.to_string(), }, }) .unwrap_or_else(|_| "{}".to_string()) }) } else { self.app .handle_api_request_after_internal_events_drained(msg.request) }; let _ = msg.respond_to.send(response); // Forward new toast state only when a client-local delivery mode is selected. // Herdr delivery renders the toast in-frame and must not ask clients to // show a terminal or system notification. let toast_after = self.app.state.toast.clone(); let forwarded_toast_from_state = if should_forward_toast_to_clients(self.app.state.toast_config.delivery) && toast_after.is_some() && toast_after != toast_before { if let Some(toast) = &toast_after { let msg_text = format!("{}: {}", toast.title, toast.context); debug!(msg = %msg_text, "forwarding toast notification from API request"); self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: msg_text, }); true } else { false } } else { false }; // Forward notifications for effective pane state changes that occurred // during the API request. Hook authority is already folded into // pane.state, so raw hook transitions must not produce separate sounds. for (ws_idx, pane_id, prev_state) in &pane_states_before { let pane_after = self .app .state .workspaces .get(*ws_idx) .and_then(|ws| ws.tabs.iter().find_map(|tab| tab.panes.get(pane_id))); let Some(pane_after) = pane_after else { continue; }; let Some(terminal_after) = self .app .state .terminals .get(&pane_after.attached_terminal_id) else { continue; }; let new_state = terminal_after.state; if new_state == *prev_state { continue; } let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id); let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); let agent = terminal_after.effective_known_agent(); debug!( ws_idx, pane_id = pane_id.raw(), prev_state = ?prev_state, new_state = ?new_state, agent = ?agent, "pane effective state changed during API request, checking notification" ); if !forwarded_toast_from_state && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if let Some(kind) = crate::app::actions::notification_toast_for_state_change( suppress_active_tab_notifications, *prev_state, new_state, ) { if let Some(agent_label) = self .app .state .terminals .get(&pane_after.attached_terminal_id) .and_then(|terminal| terminal.effective_agent_label()) { 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 msg_text = format!( "{} {}: {}", agent_label, event_text, crate::app::actions::notification_context( &self.app.state.workspaces[*ws_idx], *ws_idx, *pane_id, ) ); self.send_to_foreground_client(ServerMessage::Notify { kind: toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), message: msg_text, }); } } } // Forward sound notification when server-side sound policy allows it. // Clients still decide locally whether they can execute the side effect. if self.app.state.sound.allows(agent) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, *prev_state, new_state, ) { let msg_text = match sound { crate::sound::Sound::Done => "agent done", crate::sound::Sound::Request => "agent attention", }; debug!(sound = ?sound, "forwarding sound notification from API request"); self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Sound, message: msg_text.to_owned(), }); } } } changed } fn stream_host_mouse_capture_mode(&mut self) { let enabled = self .app .state .should_capture_host_mouse_from(&self.app.terminal_runtimes); let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled }) { Ok(framed) => framed, Err(err) => { warn!(err = %err, "failed to serialize mouse capture mode for clients"); return; } }; let mut broken_clients: Vec = Vec::new(); for (&client_id, client) in &mut self.clients { if !matches!(client.mode, ClientConnectionMode::App) { continue; } if client.host_mouse_capture_active == Some(enabled) { continue; } let Some(writer) = &client.writer else { continue; }; if writer.control.send(serialized.clone()).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); } for client_id in broken_clients { self.remove_client_and_resize_if_needed(client_id); } } /// Renders the current state to client-sized virtual buffers and streams /// frames to all connected clients. fn render_and_stream(&mut self) { let render_targets = render_targets(&self.clients, self.foreground_client_id); if render_targets.is_empty() { let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); let resize_panes = self.app.state.view.pane_infos.is_empty(); let _ = crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, &self.app.terminal_runtimes, area, resize_panes, crate::kitty_graphics::HostCellSize::default(), ); debug!( cols, rows, resize_panes, "rendered virtual frame with no attached clients" ); return; } let mut broken_clients: Vec = Vec::new(); for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { let area = Rect::new(0, 0, cols, rows); let is_app_client = matches!(mode, ClientConnectionMode::App); let mut frame = match mode { ClientConnectionMode::App => { let (buffer, cursor) = if self.app.state.kitty_graphics_enabled && cell_size.is_known() { crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, &self.app.terminal_runtimes, area, is_foreground, cell_size, ) } else { crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, &self.app.terminal_runtimes, area, is_foreground, crate::kitty_graphics::HostCellSize::default(), ) }; let hyperlinks = crate::server::render_stream::visible_hyperlinks( &self.app.state, &self.app.terminal_runtimes, ); FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) } ClientConnectionMode::TerminalAttach { 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 (buffer, cursor) = crate::server::render_stream::render_terminal_virtual(runtime, area); let hyperlinks = runtime.visible_hyperlinks(area); FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) } }; let Some(client) = self.clients.get_mut(&client_id) else { continue; }; let mut next_graphics_cache = client.graphics_cache.clone(); let graphics_surface_reset_pending = client.graphics_surface_reset_pending; if is_app_client && self.app.state.kitty_graphics_enabled && cell_size.is_known() { if graphics_surface_reset_pending { frame.graphics = next_graphics_cache.clear_bytes(); } frame .graphics .extend(crate::kitty_graphics::encode_local_pane_graphics( &self.app.state, &self.app.terminal_runtimes, cell_size, &mut next_graphics_cache, )); } else { frame.graphics = next_graphics_cache.clear_bytes(); } let Some(writer) = client.writer.as_ref().cloned() else { continue; }; let mut commit_graphics_cache = true; if frame.graphics.len() > MAX_GRAPHICS_FRAME_SIZE { warn!( client_id, graphics_bytes = frame.graphics.len(), max = MAX_GRAPHICS_FRAME_SIZE, "dropping oversized graphics payload for client frame" ); frame.graphics.clear(); commit_graphics_cache = false; } let Some(mut prepared) = client.render_state.prepare_frame(&frame) else { client.render_pending = false; continue; }; let mut frame_to_commit = frame.clone(); let max_frame_size = if frame.graphics.is_empty() { MAX_FRAME_SIZE } else { MAX_GRAPHICS_FRAME_SIZE }; let serialized = match Self::frame_server_message_with_max( prepared.message(), max_frame_size, ) { Ok(framed) => framed, Err(protocol::FramingError::Oversized { claimed, max }) if !frame.graphics.is_empty() => { warn!( client_id, claimed, max, "dropping graphics from oversized frame for client" ); let mut text_only_frame = frame.clone(); text_only_frame.graphics.clear(); let Some(text_only_prepared) = client.render_state.prepare_frame(&text_only_frame) else { client.render_pending = false; continue; }; let framed = match Self::frame_server_message(text_only_prepared.message()) { Ok(framed) => framed, Err(err) => { warn!(client_id, err = %err, "failed to serialize text-only frame for client"); broken_clients.push(client_id); continue; } }; prepared = text_only_prepared; frame_to_commit = text_only_frame; commit_graphics_cache = false; framed } Err(protocol::FramingError::Oversized { claimed, max }) => { warn!( client_id, claimed, max, "skipping oversized frame for client" ); continue; } Err(err) => { warn!(client_id, err = %err, "failed to serialize frame for client"); broken_clients.push(client_id); continue; } }; match writer.render.try_send(serialized) { Ok(()) => { client.render_pending = false; if commit_graphics_cache { client.graphics_cache = next_graphics_cache; client.graphics_surface_reset_pending = false; } client .render_state .commit_sent_frame(frame_to_commit, prepared); } Err(std::sync::mpsc::TrySendError::Full(_)) => { client.render_pending = true; debug!(client_id, "render queue full, deferring latest frame"); continue; } Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { debug!(client_id, "client writer channel closed, marking as broken"); broken_clients.push(client_id); continue; } } } 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; debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)"); } /// Handle scheduled tasks for the headless server. /// /// Similar to `App::handle_scheduled_tasks` but without resize polling /// (the server doesn't have a terminal to resize). fn handle_scheduled_tasks_headless(&mut self, now: Instant) -> bool { let mut changed = false; self.app.sync_headless_animation_timer(now); // No resize polling needed — server has no terminal. // Client resize messages drive size changes instead. if self .app .config_diagnostic_deadline .is_some_and(|deadline| now >= deadline) { self.app.config_diagnostic_deadline = None; self.app.state.config_diagnostic = None; changed = true; } if self .app .toast_deadline .is_some_and(|deadline| now >= deadline) { self.app.toast_deadline = None; self.app.state.toast = None; changed = true; } if self .app .next_animation_tick .is_some_and(|deadline| now >= deadline) { self.app.state.spinner_tick = self .app .state .spinner_tick .wrapping_add(app::HEADLESS_ANIMATION_TICK_STEP); self.app.next_animation_tick = Some(now + app::HEADLESS_ANIMATION_INTERVAL); changed = true; } if self .app .selection_autoscroll_deadline .is_some_and(|deadline| now >= deadline) { self.app.tick_selection_autoscroll(now); changed = true; } self.app.start_git_status_refresh_if_due(now); if self .app .next_auto_update_check .is_some_and(|deadline| now >= deadline) { self.app.run_auto_update_check(); } if self .app .session_save_deadline .is_some_and(|deadline| now >= deadline) { self.app.save_session_now(); } self.app.sync_headless_animation_timer(now); changed } /// Initiates graceful shutdown. 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. self.send_all_clients_graphics_cleanup(); 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. fn complete_shutdown(&mut self) -> io::Result<()> { info!("completing server shutdown"); // Send ServerShutdown to all remaining clients. if !self.clients.is_empty() { self.send_all_clients_graphics_cleanup(); 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)); } // Drain remaining API requests with server_unavailable. self.drain_api_requests_with_shutdown_check(); // Close all client connections. let staged_files = self .clients .drain() .flat_map(|(_, client)| client.staged_clipboard_files) .collect::>(); crate::server::clipboard_image::remove_files(staged_files); // Remove socket files. self.cleanup_sockets()?; Ok(()) } /// Removes socket files created by the server. 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(()) } } impl Drop for HeadlessServer { fn drop(&mut self) { let staged_files = self .clients .drain() .flat_map(|(_, client)| client.staged_clipboard_files) .collect::>(); crate::server::clipboard_image::remove_files(staged_files); let _ = self.cleanup_sockets(); } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Installs a Ctrl+C handler that sets the should_quit flag and wakes up /// the event loop by sending a QuitSignal on the server event channel. fn ctrlc_handler(should_quit: Arc, server_event_tx: mpsc::Sender) { let _ = ctrlc::set_handler(move || { should_quit.store(true, Ordering::Release); // Wake up the event loop so the quit flag is checked promptly. let _ = server_event_tx.try_send(ServerEvent::QuitSignal); }); } /// Sleep until a deadline, or return pending if none. async fn sleep_until_or_pending(deadline: Option) { match deadline { Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await, None => std::future::pending().await, } } fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option, Option) { let without_keybindings = diagnostics .iter() .filter(|diagnostic| !is_keybinding_config_diagnostic(diagnostic)) .cloned() .collect::>(); ( config::config_diagnostic_summary(diagnostics), config::config_diagnostic_summary(&without_keybindings), ) } fn is_keybinding_config_diagnostic(diagnostic: &str) -> bool { diagnostic.contains("keybinding") || diagnostic.contains("keys.") } // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- /// Run the headless server. This is the entry point called from main.rs. pub fn run_server() -> io::Result<()> { init_logging(); let args: Vec = 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(); // Start the JSON API socket server. let _api_server = match api::start_server(api_tx.clone(), event_hub.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 no_session = false; // Server always does session persistence. 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, no_session, config::config_diagnostic_summary(&loaded_config.diagnostics), api_rx, event_hub, ); // The server runs headless — disable local notification side effects. // Sound and terminal notifications are forwarded to connected clients // as ServerMessage::Notify instead of emitted by the server process. app.state.local_sound_playback = false; app.local_terminal_notifications = false; // Create the headless server. let mut server = match HeadlessServer::new( app, &loaded_config.diagnostics, Some(api_tx.clone()), Some(_api_server), ) { 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.run().await }); rt.shutdown_timeout(Duration::from_millis(100)); crate::logging::shutdown("server"); result } #[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 mut imports = HashMap::new(); for (pane, fd) in received.manifest.panes.iter().zip(received.fds) { imports.insert( pane.pane_id, crate::persist::ImportedPaneRuntime { master_fd: fd, child_pid: pane.child_pid, rows: pane.rows, cols: pane.cols, cell_width_px: pane.cell_width_px, cell_height_px: pane.cell_height_px, initial_history_ansi: pane.initial_history_ansi.clone(), }, ); } let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(io::Error::other)?; let result = rt.block_on(async { let mut 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, )?; app.state.local_sound_playback = false; app.local_terminal_notifications = false; 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(api_tx.clone(), event_hub.clone())?; let mut server = HeadlessServer::new( app, &loaded_config.diagnostics, Some(api_tx.clone()), Some(api_server), )?; 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.run().await }); rt.shutdown_timeout(Duration::from_millis(100)); crate::logging::shutdown("server"); result } #[cfg(unix)] fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> { use std::os::unix::net::UnixStream; 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() && UnixStream::connect(&api_socket).is_ok(); let client_open = client_socket.exists() && UnixStream::connect(&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", )) } #[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"); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::app::AppState; use crate::protocol::CursorState; fn test_headless_server() -> HeadlessServer { let config = crate::config::Config::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = crate::app::App::new(&config, true, None, api_rx, api::EventHub::default()); app.state.local_sound_playback = false; app.local_terminal_notifications = false; 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 = UnixListener::bind(&socket_path).expect("bind test listener"); let client_socket_identity = socket_file_identity(&socket_path).expect("test listener socket identity"); listener .set_nonblocking(true) .expect("set listener nonblocking"); let (server_event_tx, server_event_rx) = mpsc::channel(64); let server_keybindings = app_keybindings(&app); HeadlessServer { app, api_tx: None, api_server: None, client_listener: listener, client_socket_path: socket_path, client_socket_identity, clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, server_keybindings, server_config_diagnostic: None, server_config_diagnostic_without_keybindings: None, terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, handoff_in_progress: false, pending_handoff_repaint_nudge: false, should_quit: Arc::new(AtomicBool::new(false)), server_event_rx, server_event_tx, } } fn read_server_message(bytes: Vec) -> ServerMessage { let mut cursor = std::io::Cursor::new(bytes); protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message") } fn read_server_frame(bytes: Vec) -> FrameData { match read_server_message(bytes) { ServerMessage::Frame(frame) => frame, other => panic!("expected frame, got {other:?}"), } } fn read_server_shutdown_reason(bytes: Vec) -> Option { match read_server_message(bytes) { ServerMessage::ServerShutdown { reason } => reason, other => panic!("expected shutdown, got {other:?}"), } } #[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, }) ); 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 test_client_writer() -> ( ClientWriter, std::sync::mpsc::Receiver>, std::sync::mpsc::Receiver>, ) { let (control_tx, control_rx) = std::sync::mpsc::channel(); let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1); ( ClientWriter { control: control_tx, render: render_tx, }, control_rx, render_rx, ) } #[test] fn foreground_client_applies_client_keybindings() { let mut server = test_headless_server(); let local_config: crate::config::Config = toml::from_str( r#" [keys] prefix = "ctrl+a" new_tab = "prefix+t" "#, ) .unwrap(); let local_keybindings = local_config.live_keybinds().unwrap(); let (writer_a, _control_a, _render_a) = test_client_writer(); let (writer_b, _control_b, _render_b) = test_client_writer(); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 1, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: Some(Box::new(local_keybindings)), writer: writer_a, })); assert_eq!( server.app.state.prefix_code, crossterm::event::KeyCode::Char('a') ); assert!(server .app .state .keybinds .new_tab .bindings .iter() .any(|binding| binding.label == "prefix+t")); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 2, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: None, writer: writer_b, })); assert_eq!( server.app.state.prefix_code, crossterm::event::KeyCode::Char('b') ); assert!(server .app .state .keybinds .new_tab .bindings .iter() .any(|binding| binding.label == "prefix+c")); } #[test] fn local_keybinding_client_hides_server_keybinding_warnings() { let mut server = test_headless_server(); let diagnostics = vec![ "unsafe direct keybinding: keys.close_pane = \"x\" would intercept typing".to_owned(), "theme warning".to_owned(), ]; let (full, without_keybindings) = server_config_diagnostic_summaries(&diagnostics); server.server_config_diagnostic = full.clone(); server.server_config_diagnostic_without_keybindings = without_keybindings.clone(); server.app.state.config_diagnostic = full; let local_keybindings = crate::config::Config::default().live_keybinds().unwrap(); let (writer_a, _control_a, _render_a) = test_client_writer(); let (writer_b, _control_b, _render_b) = test_client_writer(); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 1, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: Some(Box::new(local_keybindings)), writer: writer_a, })); assert_eq!(server.app.state.config_diagnostic, without_keybindings); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 2, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: None, writer: writer_b, })); assert_eq!( server.app.state.config_diagnostic, server.server_config_diagnostic ); } #[test] fn local_keybinding_client_keeps_local_keybindings_after_settings_save() { let path = std::env::temp_dir().join(format!( "herdr-headless-settings-{}-{}.toml", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0) )); std::fs::write(&path, "onboarding = false\n").unwrap(); let _guard = crate::config::test_config_env_lock().lock().unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut server = test_headless_server(); let local_config: crate::config::Config = toml::from_str( r#" [keys] prefix = "ctrl+a" new_workspace = "prefix+n" next_tab = "" "#, ) .unwrap(); let local_keybindings = local_config.live_keybinds().unwrap(); let (writer, _control, _render) = test_client_writer(); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 1, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: Some(Box::new(local_keybindings)), writer, })); server.app.state.mode = crate::app::Mode::Settings; server.app.state.settings.section = crate::app::state::SettingsSection::Toast; server.app.state.settings.list.selected = 1; assert!(server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\r".to_vec(), })); assert_eq!( server.app.state.prefix_code, crossterm::event::KeyCode::Char('a') ); assert!(server .app .state .keybinds .new_workspace .bindings .iter() .any(|binding| binding.label == "prefix+n")); assert!(server.app.state.toast.is_none()); let content = std::fs::read_to_string(&path).unwrap(); assert!(content.contains("delivery = \"herdr\"")); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_file(path); } #[test] fn invalid_server_keybindings_do_not_cache_local_keybindings_after_settings_save() { let path = std::env::temp_dir().join(format!( "herdr-headless-invalid-settings-{}-{}.toml", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0) )); std::fs::write( &path, "onboarding = false\n[keys]\nnew_workspace = \"x\"\n[ui.toast]\ndelivery = \"off\"\n", ) .unwrap(); let _guard = crate::config::test_config_env_lock().lock().unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut server = test_headless_server(); let previous_server_config: crate::config::Config = toml::from_str("[keys]\nprefix = \"ctrl+c\"\nnew_workspace = \"prefix+m\"\n").unwrap(); server.server_keybindings = previous_server_config.live_keybinds().unwrap(); let local_config: crate::config::Config = toml::from_str( r#" [keys] prefix = "ctrl+a" new_workspace = "prefix+n" next_tab = "" "#, ) .unwrap(); let (writer_a, _control_a, _render_a) = test_client_writer(); let (writer_b, _control_b, _render_b) = test_client_writer(); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 1, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: Some(Box::new(local_config.live_keybinds().unwrap())), writer: writer_a, })); server.app.state.mode = crate::app::Mode::Settings; server.app.state.settings.section = crate::app::state::SettingsSection::Toast; server.app.state.settings.list.selected = 1; assert!(server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\r".to_vec(), })); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 2, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: None, writer: writer_b, })); assert_eq!( server.app.state.prefix_code, crossterm::event::KeyCode::Char('c') ); assert!(server .app .state .keybinds .new_workspace .bindings .iter() .any(|binding| binding.label == "prefix+m")); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_file(path); } #[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, render_encoding: RenderEncoding::TerminalAnsi, keybindings: None, writer, })); assert!(server.clients.contains_key(&7)); 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()) ); } #[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, render_encoding: RenderEncoding::TerminalAnsi, keybindings: None, 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 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)); } #[test] fn terminal_attach_page_key_host_scrolls_plain_terminal() { 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, ); apply_terminal_attach_scroll( &runtime, AttachScrollSource::PageKey { input: b"\x1b[5~".to_vec(), }, AttachScrollDirection::Up, 4, None, None, 0, ) .expect("page key scroll"); assert_eq!( runtime .scroll_metrics() .expect("scroll metrics") .offset_from_bottom, 4 ); assert!(input_rx.try_recv().is_err()); drop(runtime); drop(_runtime_guard); rt.shutdown_timeout(Duration::from_millis(100)); } #[test] fn terminal_attach_page_key_forwards_when_mouse_reporting() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("test runtime"); let _runtime_guard = rt.enter(); let mut bytes = b"\x1b[?1000h".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, ); runtime.scroll_up(3); apply_terminal_attach_scroll( &runtime, AttachScrollSource::PageKey { input: b"\x1b[5~".to_vec(), }, AttachScrollDirection::Up, 4, None, None, 0, ) .expect("page key forward"); 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~") ); drop(runtime); drop(_runtime_guard); rt.shutdown_timeout(Duration::from_millis(100)); } #[test] fn virtual_render_produces_nonempty_buffer() { let mut state = AppState::test_new(); let area = Rect::new(0, 0, 80, 24); let (buffer, _cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert_eq!(buffer.area.width, 80); assert_eq!(buffer.area.height, 24); } #[test] fn virtual_render_without_frame_cursor_keeps_cursor_hidden() { let mut state = AppState::test_new(); let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert_eq!(cursor, None); } #[tokio::test] async fn virtual_render_preserves_explicit_frame_cursor_position() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); let pane = state .view .pane_infos .iter() .find(|info| info.id == pane_id) .expect("focused pane info"); assert_eq!( cursor, Some(CursorState { x: pane.inner_rect.x + 4, y: pane.inner_rect.y, visible: true, shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0), }) ); } #[tokio::test] async fn virtual_render_preserves_hidden_focused_pane_cursor_position() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); let pane = state .view .pane_infos .iter() .find(|info| info.id == pane_id) .expect("focused pane info"); assert_eq!( cursor, Some(CursorState { x: pane.inner_rect.x + 4, y: pane.inner_rect.y, visible: false, shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0), }) ); } #[tokio::test] async fn virtual_render_exposes_hidden_pane_cursor_when_reveal_hidden_for_cjk_ime() { let mut state = AppState::test_new(); state.reveal_hidden_cursor_for_cjk_ime = true; let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); let pane = state .view .pane_infos .iter() .find(|info| info.id == pane_id) .expect("focused pane info"); assert_eq!( cursor, Some(CursorState { x: pane.inner_rect.x + 4, y: pane.inner_rect.y, visible: true, shape: state.cjk_ime_cursor_shape, }) ); } #[tokio::test] async fn virtual_render_keeps_cursor_hidden_when_scrolled_back_even_with_reveal_hidden_for_cjk_ime( ) { let mut state = AppState::test_new(); state.reveal_hidden_cursor_for_cjk_ime = true; let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; 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); ws.insert_test_runtime(pane_id, runtime); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let _ = crate::server::render_stream::render_virtual(&mut state, area, true); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let runtime = state .runtime_for_pane(&terminal_runtimes, pane_id) .expect("pane runtime after initial render"); runtime.scroll_up(6); assert!(crate::ui::pane_is_scrolled_back(runtime)); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert!( cursor.as_ref().is_none_or(|cursor| !cursor.visible), "scrolled-back focused pane should keep the cursor hidden even when reveal_hidden_cursor_for_cjk_ime is true; got {cursor:?}", ); } #[tokio::test] async fn virtual_render_fallback_cursor_when_viewport_none_and_reveal_hidden_for_cjk_ime() { let mut state = AppState::test_new(); state.reveal_hidden_cursor_for_cjk_ime = true; let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; // Feed only ?25l with no prior cursor movement — exercises the fallback // path for TUIs whose viewport has no cursor position. ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"\x1b[?25l"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); let pane = state .view .pane_infos .iter() .find(|info| info.id == pane_id) .expect("focused pane info"); assert_eq!( cursor, Some(CursorState { x: pane.inner_rect.x, y: pane.inner_rect.y, visible: true, shape: state.cjk_ime_cursor_shape, }), "fallback should anchor at pane top-left with the configured shape", ); } #[tokio::test] async fn virtual_render_skips_reveal_when_focused_pane_has_no_detected_agent() { let mut state = AppState::test_new(); state.reveal_hidden_cursor_for_cjk_ime = true; // Filter only Claude, but the test pane has no detected agent, so the // reveal must not apply. state.cjk_ime_agent_filter_configured = true; state.cjk_ime_agents = vec![crate::detect::Agent::Claude]; let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert!( cursor.as_ref().is_none_or(|cursor| !cursor.visible), "agent filter should suppress reveal when the focused pane's detected agent is not on the list; got {cursor:?}", ); } #[tokio::test] async fn virtual_render_skips_reveal_when_agent_filter_has_no_valid_entries() { let mut state = AppState::test_new(); state.reveal_hidden_cursor_for_cjk_ime = true; state.cjk_ime_agent_filter_configured = true; state.cjk_ime_agents = Vec::new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert!( cursor.as_ref().is_none_or(|cursor| !cursor.visible), "agent filter with no valid entries should suppress reveal; got {cursor:?}", ); } #[tokio::test] async fn virtual_render_omits_focused_pane_cursor_while_mobile_switcher_open() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; ws.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), ); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Navigate; let area = Rect::new(0, 0, 44, 24); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert_eq!(cursor, None); } #[tokio::test] async fn virtual_render_hides_focused_pane_cursor_while_scrolled_back() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; 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); ws.insert_test_runtime(pane_id, runtime); state.workspaces = vec![ws]; state.active = Some(0); state.selected = 0; state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); let _ = crate::server::render_stream::render_virtual(&mut state, area, true); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let runtime = state .runtime_for_pane(&terminal_runtimes, pane_id) .expect("pane runtime after initial render"); runtime.scroll_up(6); assert!(crate::ui::pane_is_scrolled_back(runtime)); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, true); assert!( cursor.as_ref().is_none_or(|cursor| !cursor.visible), "cursor: {cursor:?}" ); } #[test] fn latest_active_client_drives_shared_size_theme_and_fallback() { let mut server = test_headless_server(); server.clients.insert( 1, ClientConnection::new( (160, 45), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 0xaa, g: 0xbb, b: 0xcc, }), background: Some(crate::terminal_theme::RgbColor { r: 0x11, g: 0x22, b: 0x33, }), }, None, 1, RenderEncoding::SemanticFrame, None, ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 0x10, g: 0x20, b: 0x30, }), background: Some(crate::terminal_theme::RgbColor { r: 0xdd, g: 0xee, b: 0xff, }), }, None, 2, RenderEncoding::SemanticFrame, None, ), ); assert!(server.promote_client_to_foreground(1)); assert_eq!(server.foreground_client_id, Some(1)); assert_eq!(server.effective_size, (160, 45)); assert_eq!( server.app.state.host_terminal_theme, server.clients[&1].host_terminal_theme ); assert!(server.promote_client_to_foreground(2)); assert_eq!(server.foreground_client_id, Some(2)); assert_eq!(server.effective_size, (80, 24)); assert_eq!( server.app.state.host_terminal_theme, server.clients[&2].host_terminal_theme ); assert!(server.remove_client(2)); assert_eq!(server.foreground_client_id, Some(1)); assert_eq!(server.effective_size, (160, 45)); assert_eq!( server.app.state.host_terminal_theme, server.clients[&1].host_terminal_theme ); } #[test] fn focus_lost_updates_client_without_promoting_foreground() { let mut server = test_headless_server(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, None, ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), Some(true), 2, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(2); server.sync_foreground_client_state(); let changed = server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[O".to_vec(), }); assert!(!changed); assert_eq!(server.foreground_client_id, Some(2)); assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); assert_eq!(server.app.state.outer_terminal_focus, Some(true)); } #[test] fn focus_gained_promotes_client_to_foreground() { let mut server = test_headless_server(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, None, ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), Some(true), 2, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(2); server.sync_foreground_client_state(); let changed = server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[I".to_vec(), }); assert!(changed); assert_eq!(server.foreground_client_id, Some(1)); assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); assert_eq!(server.app.state.outer_terminal_focus, Some(true)); } #[test] fn foreground_client_focus_event_updates_app_focus_state() { let mut server = test_headless_server(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), Some(true), 1, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); let changed = server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[O".to_vec(), }); assert!(!changed); assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); assert_eq!(server.app.state.outer_terminal_focus, Some(false)); } #[test] fn render_and_stream_uses_each_client_terminal_size() { let mut server = test_headless_server(); server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; server.app.state.active = Some(0); server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; let (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer(); let (phone_tx, _phone_control_rx, phone_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(desktop_tx), ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 2, RenderEncoding::SemanticFrame, Some(phone_tx), ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); server.resize_shared_runtime_to_effective_size(); server.render_and_stream(); let desktop_frame = read_server_frame(desktop_rx.recv().expect("desktop frame")); let phone_frame = read_server_frame(phone_rx.recv().expect("phone frame")); assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40)); assert_eq!((phone_frame.width, phone_frame.height), (80, 24)); } #[tokio::test] async fn resize_shared_runtime_resizes_background_tabs() { 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 active_pane = workspace.tabs[0].root_pane; let background_pane = workspace.tabs[background_tab].root_pane; workspace.tabs[0].runtimes.insert( active_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), ); workspace.tabs[background_tab].runtimes.insert( background_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), ); 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.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); server.resize_shared_runtime_to_effective_size(); let terminal_area = server.app.state.view.terminal_area; let expected = (terminal_area.height, terminal_area.width.saturating_sub(1)); assert_eq!( server .app .state .runtime_for_pane(&server.app.terminal_runtimes, active_pane) .unwrap() .current_size(), expected ); assert_eq!( server .app .state .runtime_for_pane(&server.app.terminal_runtimes, background_pane) .unwrap() .current_size(), expected ); } #[test] fn terminal_attach_disconnect_restores_app_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.state.mode = crate::app::Mode::Terminal; 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(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); server.resize_shared_runtime_to_effective_size(); let expected_app_size = server .app .terminal_runtimes .get(&terminal_id) .expect("runtime") .current_size(); assert_ne!(expected_app_size, (24, 80)); let (writer, _control_rx, _render_rx) = test_client_writer(); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id: 2, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, keybindings: None, writer, })); assert!( server.handle_server_event(ServerEvent::ClientAttachTerminal { client_id: 2, terminal_id: terminal_id_string.clone(), 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(), (24, 80) ); 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_app_size ); drop(server); drop(_runtime_guard); rt.shutdown_timeout(Duration::from_millis(100)); } #[test] fn render_and_stream_sends_terminal_frame_for_terminal_ansi_client() { 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(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); match read_server_message( client_rx .recv_timeout(Duration::from_millis(100)) .expect("terminal frame"), ) { ServerMessage::Terminal(frame) => { assert_eq!(frame.seq, 1); assert_eq!((frame.width, frame.height), (80, 24)); assert!(frame.full); assert!(!frame.bytes.is_empty()); } other => panic!("expected terminal frame, got {other:?}"), } assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 1 ); } #[test] fn terminal_ansi_input_does_not_reset_blit_baseline() { 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(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial terminal frame"); assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 1 ); assert!(!server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: Vec::new(), })); server.render_and_stream(); assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 1 ); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); } #[test] fn outer_focus_gained_forces_terminal_ansi_full_redraw() { 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(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial terminal frame"); assert!(server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[I".to_vec(), })); server.render_and_stream(); match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) { ServerMessage::Terminal(frame) => { assert_eq!(frame.seq, 2); assert!(frame.full); } other => panic!("expected terminal frame, got {other:?}"), } } #[test] fn outer_focus_gained_does_not_force_terminal_ansi_full_redraw_when_disabled() { let mut server = test_headless_server(); server.app.state.redraw_on_focus_gained = false; let (client_tx, _client_control_rx, client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial terminal frame"); server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[I".to_vec(), }); server.render_and_stream(); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); assert_eq!(server.app.state.outer_terminal_focus, Some(true)); assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 1 ); } #[test] fn full_render_queue_does_not_advance_terminal_ansi_baseline() { let mut server = test_headless_server(); let (client_tx, _client_control_rx, client_rx) = test_client_writer(); let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("serialize dummy message"); client_tx .render .send(queued) .expect("pre-fill render queue"); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 0 ); assert!(matches!( read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), ServerMessage::ReloadSoundConfig )); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); } #[test] fn writer_drained_retries_pending_terminal_ansi_render() { let mut server = test_headless_server(); let (client_tx, _client_control_rx, client_rx) = test_client_writer(); let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("serialize dummy message"); client_tx .render .send(queued) .expect("pre-fill render queue"); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::TerminalAnsi, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.render_and_stream(); assert!(server.clients.get(&1).unwrap().render_pending); assert!(matches!( read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), ServerMessage::ReloadSoundConfig )); assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 })); server.render_and_stream(); match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) { ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 1), other => panic!("expected terminal frame, got {other:?}"), } assert_eq!( server .clients .get(&1) .unwrap() .render_state .terminal_seq() .unwrap(), 1 ); assert!(!server.clients.get(&1).unwrap().render_pending); } #[test] fn render_and_stream_skips_identical_frame_sends() { let mut server = test_headless_server(); server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; 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(), crate::terminal_theme::TerminalTheme::default(), None, 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.render_and_stream(); let first = client_rx.recv_timeout(Duration::from_millis(100)); assert!(first.is_ok(), "expected first frame to be sent"); server.render_and_stream(); assert!( client_rx.recv_timeout(Duration::from_millis(50)).is_err(), "identical frame should not be sent twice" ); } #[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(), crate::terminal_theme::TerminalTheme::default(), None, 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 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(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(background_tx), ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 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 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(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(background_tx), ), ); server.clients.insert( 2, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 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: 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 } => { assert_eq!(kind, protocol::NotifyKind::Toast); assert_eq!(message, "pi finished: 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 herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() { 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(), crate::terminal_theme::TerminalTheme::default(), None, 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!(server.app.state.toast.is_some()); assert!( client_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "herdr delivery should render in-frame instead of forwarding a client-local notification" ); } #[test] fn system_toast_delivery_forwards_system_notify_kind() { 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(), crate::terminal_theme::TerminalTheme::default(), None, 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("system toast message"), ) { ServerMessage::Notify { kind, message } => { assert_eq!(kind, protocol::NotifyKind::SystemToast); assert_eq!(message, "v9.9.9 available: detach, then run `herdr update`"); } other => panic!("expected system toast notify, got {other:?}"), } } #[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!("{}-1", 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_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(), crate::terminal_theme::TerminalTheme::default(), None, 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, custom_status: None, seq: Some(19), agent_session_id: None, agent_session_path: None, }), }, respond_to, }); 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 no direct calls to `self.app.handle_internal_event` /// exist outside of `handle_internal_event_with_forwarding` in this /// module. This ensures the forwarding bypass cannot be reintroduced. /// /// The search pattern looks for `handle_internal_event` calls that /// are NOT inside the `handle_internal_event_with_forwarding` method. #[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 = 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.trim().starts_with("///") && !line.contains("contains(") { // Direct call to handle_internal_event 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 ") ); } }