//! 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::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use crossterm::event::{KeyModifiers, MouseEventKind}; use interprocess::local_socket::traits::Listener as _; #[cfg(windows)] use interprocess::local_socket::traits::Stream as _; #[cfg(unix)] use interprocess::local_socket::ListenerNonblockingMode; 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::{ bind_local_listener, remove_socket_file_if_owned, socket_file_identity, LocalListener, SocketFileIdentity, }; use crate::protocol::{ self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, }; #[cfg(unix)] 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_stream_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; fn sound_notify_message(sound: crate::sound::Sound) -> &'static str { match sound { crate::sound::Sound::Done => "agent done", crate::sound::Sound::Request => "agent attention", } } fn notification_show_response_shown(response: &str) -> bool { let Ok(response) = serde_json::from_str::(response) else { return false; }; matches!( response.result, api::schema::ResponseResult::NotificationShow { shown: true, .. } ) } fn non_empty_body(value: &str) -> Option { (!value.is_empty()).then(|| value.to_owned()) } // --------------------------------------------------------------------------- // Loop event enum for the headless server event loop // --------------------------------------------------------------------------- /// Events that the headless server event loop can process. enum LoopEvent { Timer, Internal(AppEvent), Api(Box), ServerEvent(ServerEvent), RenderRequested, } fn rect_fits_frame(rect: Rect, frame: &FrameData) -> bool { rect.x.saturating_add(rect.width) <= frame.width && rect.y.saturating_add(rect.height) <= frame.height } fn apply_terminal_dirty_patch( frame: &mut FrameData, area: Rect, patch: crate::pane::TerminalDirtyPatch, ) -> bool { if !rect_fits_frame(area, frame) { return false; } let width = usize::from(frame.width); for (local_y, row_cells) in patch.rows { if local_y >= area.height || row_cells.len() != usize::from(area.width) { return false; } let frame_y = area.y + local_y; let start = usize::from(frame_y) * width + usize::from(area.x); let end = start + usize::from(area.width); if end > frame.cells.len() { return false; } frame.cells[start..end].clone_from_slice(&row_cells); } true } fn dirty_patch_intersects_hyperlinks( frame: &FrameData, area: Rect, patch: &crate::pane::TerminalDirtyPatch, ) -> bool { if frame.hyperlinks.is_empty() || !rect_fits_frame(area, frame) { return false; } let width = usize::from(frame.width); for (local_y, _) in &patch.rows { if *local_y >= area.height { return true; } let frame_y = area.y + *local_y; let start = usize::from(frame_y) * width + usize::from(area.x); let end = start + usize::from(area.width); if end > frame.cells.len() { return true; } if frame.cells[start..end] .iter() .any(|cell| cell.hyperlink.is_some()) { return true; } } false } // --------------------------------------------------------------------------- // 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 local listener 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, #[cfg(unix)] api_tx: Option, #[cfg(unix)] api_server: Option, #[cfg(unix)] client_listener: LocalListener, client_socket_path: PathBuf, client_socket_identity: SocketFileIdentity, clients: HashMap, #[cfg(unix)] 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. #[cfg(unix)] 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}")) } #[cfg(windows)] fn spawn_windows_client_accept_thread( listener: LocalListener, should_quit: Arc, server_event_tx: mpsc::Sender, ) { std::thread::spawn(move || { let mut next_client_id = 1_u64; while !should_quit.load(Ordering::Acquire) { let stream = match listener.accept() { Ok(stream) => stream, Err(err) => { if should_quit.load(Ordering::Acquire) { break; } error!(err = %err, "client listener accept failed"); std::thread::sleep(Duration::from_millis(50)); continue; } }; let client_id = next_client_id; next_client_id = next_client_id.saturating_add(1); if let Err(err) = stream.set_nonblocking(true) { warn!(err = %err, "failed to set client stream nonblocking"); continue; } let should_quit = should_quit.clone(); let server_event_tx = server_event_tx.clone(); std::thread::spawn(move || { if let Err(err) = crate::server::client_transport::handle_client_handshake( stream, client_id, &server_event_tx, &should_quit, ) { debug!(client_id, err = %err, "client handshake failed"); } }); } }); } 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 = bind_local_listener(&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 Unix so we can poll it from the event loop. #[cfg(unix)] listener.set_nonblocking(ListenerNonblockingMode::Accept)?; 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); #[cfg(windows)] spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); let server_keybindings = app_keybindings(&app); let (server_config_diagnostic, server_config_diagnostic_without_keybindings) = server_config_diagnostic_summaries(config_diagnostics); #[cfg(not(unix))] let _ = (&api_tx, &api_server); Ok(Self { app, #[cfg(unix)] api_tx, #[cfg(unix)] api_server, #[cfg(unix)] client_listener: listener, client_socket_path: client_path, client_socket_identity, clients: HashMap::new(), #[cfg(unix)] 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, #[cfg(unix)] 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; let mut needs_full_render = true; loop { crate::render_prof::event("loop.tick"); crate::render_prof::flush_if_due(); // 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; crate::render_prof::event("render.request.pty_dirty"); } // 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; needs_full_render = true; crate::render_prof::event("full_render_cause.internal_events"); } // 3. Drain API requests. if self.drain_api_requests_with_shutdown_check() { needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.api_requests"); } 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; needs_full_render = true; crate::render_prof::event("full_render_cause.server_events"); } // 6. Handle scheduled tasks. let now = Instant::now(); if self.handle_scheduled_tasks_headless(now, needs_render) { needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.scheduled_tasks"); } // 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; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_onboarding"); } if self.app.state.request_new_workspace { self.app.state.request_new_workspace = false; self.app.create_workspace(); needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_new_workspace"); } if self.app.state.request_new_tab { self.app.state.request_new_tab = false; self.app.create_tab(); needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_new_tab"); } 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; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); } if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() { self.app.open_existing_worktree_dialog(ws_idx); needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); } if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() { if let Err(err) = self.app.create_workspace_with_events(cwd, true) { error!(err = %err, "failed to create workspace at requested cwd"); self.app.state.mode = app::Mode::Navigate; } needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_workspace_cwd"); } 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; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); } if self.app.state.request_submit_worktree_create { self.app.state.request_submit_worktree_create = false; self.app.submit_worktree_create_via_api(); needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_worktree_submit"); } if self.app.state.request_submit_worktree_open { self.app.state.request_submit_worktree_open = false; self.app.submit_worktree_open_via_api(); needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_worktree_submit"); } if self.app.state.request_submit_worktree_remove { self.app.state.request_submit_worktree_remove = false; self.app.submit_worktree_remove_via_api(); needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.deferred_worktree_submit"); } if self.app.state.request_reload_config { self.app.state.request_reload_config = false; self.reload_server_config(true); needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.config_reload"); } if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() { needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.default_workspace"); } 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) { crate::render_prof::event("render.attempt"); let pty_dirty = self.app.render_dirty.swap(false, Ordering::AcqRel); if pty_dirty { crate::render_prof::event("render.attempt.pty_dirty"); } if needs_full_render { crate::render_prof::event("retained_gate.needs_full_render"); } else if !pty_dirty { crate::render_prof::event("retained_gate.not_pty_dirty"); } let rendered_retained = pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream(); if !rendered_retained { crate::render_prof::event("full_render.invoke"); self.render_and_stream(); } self.app.last_render_at = Some(now); needs_render = false; needs_full_render = false; continue; } // 8. Wait for next event. let next_deadline = self .app .next_headless_loop_deadline_with_git_refresh( now, needs_render, self.has_app_client(), ) .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(Box::new(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; needs_full_render = true; } } LoopEvent::Api(msg) => { if self.handle_api_request_with_shutdown_check(*msg) { needs_render = true; needs_full_render = true; } } LoopEvent::ServerEvent(ev) => { if self.handle_server_event(ev) { needs_render = true; needs_full_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) { self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true); } fn resize_shared_runtime_to_effective_size_before_input(&mut self) { self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(false); } fn resize_shared_runtime_to_effective_size_with_pending_agent_resumes( &mut self, start_pending_agent_resumes: bool, ) { 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(); } if !start_pending_agent_resumes { self.app.pending_agent_resume_deadline = None; return; } let now = Instant::now(); self.app.sync_pending_agent_resume_deadline(now); if self .app .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)) { 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 host_terminal_appearance = client.host_terminal_appearance; let host_terminal_appearance_explicit = client.host_terminal_appearance_explicit; 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(); } self.app.set_host_terminal_appearance_state( host_terminal_appearance, host_terminal_appearance_explicit, ); 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.sidebar_width, self.app.state.sidebar_section_split, self.app.state.collapsed_space_keys.clone(), ); let mut handoff_entries = Vec::new(); for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else { continue; }; let mut handoff_runtime = runtime.handoff_runtime_state(pane_id); let has_agent_session = self .app .state .terminals .get(terminal_id) .is_some_and(|terminal| terminal.persisted_agent_session.is_some()); if !has_agent_session { handoff_runtime.initial_history_ansi = runtime.handoff_history_ansi(); } handoff_entries.push((terminal_id.clone(), handoff_runtime)); } let panes = handoff_entries .iter() .map(|(_, runtime)| runtime.clone()) .collect(); let manifest = crate::server::handoff::manifest_for( snapshot, panes, params.expected_protocol, params.expected_version, ); let mut import_child = match crate::server::handoff::spawn_handoff_import( import_exe.as_deref(), &socket_path, &token, ) { Ok(child) => child, Err(err) => { self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); return Err(err); } }; let child_pid = import_child.id(); info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server"); let mut fds = Vec::new(); let duplicate_result = (|| { for (terminal_id, _) in &handoff_entries { let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else { continue; }; fds.push(runtime.duplicate_handoff_fd()?); } Ok::<(), io::Error>(()) })(); if let Err(err) = duplicate_result { for fd in fds { let _ = unsafe { libc::close(fd) }; } crate::server::handoff::cleanup_failed_import_child(&mut import_child); self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); return Err(err); } let mut stream = match crate::server::handoff::accept_and_validate_on( listener, &socket_path, &token, &manifest, ) { Ok(stream) => stream, Err(err) => { for fd in fds { let _ = unsafe { libc::close(fd) }; } crate::server::handoff::cleanup_failed_import_child(&mut import_child); self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); return Err(err); } }; let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds); for fd in fds { let _ = unsafe { libc::close(fd) }; } if let Err(err) = send_result { crate::server::handoff::cleanup_failed_import_child(&mut import_child); self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); return Err(err); } if let Some(api_server) = &self.api_server { let _ = api_server.remove_socket_file_if_owned(); } else { let _ = std::fs::remove_file(crate::api::socket_path()); } let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity); if let Err(err) = crate::server::handoff::wait_ready(&mut stream) { crate::server::handoff::cleanup_failed_import_child(&mut import_child); match self.wait_then_restore_public_sockets_after_failed_handoff() { Ok(()) => { self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); } Err(restore_err) => { self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); return Err(io::Error::other(format!( "handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}" ))); } } return Err(io::Error::other(format!( "handoff replacement server did not become ready: {err}" ))); } if let Err(err) = crate::server::handoff::report_committed(&mut stream) { crate::server::handoff::cleanup_failed_import_child(&mut import_child); match self.wait_then_restore_public_sockets_after_failed_handoff() { Ok(()) => { self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); } Err(restore_err) => { self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); return Err(io::Error::other(format!( "handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}" ))); } } return Err(err); } for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() { if !pane_by_terminal.contains_key(&terminal_id) { continue; } debug!(terminal = %terminal_id, "preserving pane runtime for handoff"); runtime.preserve_for_handoff(); } crate::server::handoff::wait_owned_ack(&mut stream); 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 = bind_local_listener(&client_path)?; restrict_socket_permissions(&client_path)?; let client_socket_identity = socket_file_identity(&client_path)?; listener.set_nonblocking(ListenerNonblockingMode::Accept)?; self.api_server = Some(api_server); self.client_listener = listener; self.client_socket_path = client_path; self.client_socket_identity = client_socket_identity; Ok(()) } #[cfg(unix)] fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2); wait_for_old_public_sockets_to_close(timeout)?; self.restore_public_sockets_after_failed_handoff() } #[cfg(unix)] fn rollback_handoff_before_commit( &mut self, socket_path: &Path, paused_terminal_ids: &[crate::terminal::TerminalId], ) { for terminal_id in paused_terminal_ids { if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { runtime.set_handoff_reader_paused(false); } } self.handoff_in_progress = false; let _ = std::fs::remove_file(socket_path); } #[cfg(unix)] 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 app_client_count(&self) -> usize { self.clients .values() .filter(|client| client.is_full_app_client() && client.writer.is_some()) .count() } fn has_app_client(&self) -> bool { self.app_client_count() > 0 } 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 { .. } | ClientConnectionMode::TerminalObserve { .. } ) ) && 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) { let mut changed = self.app.set_host_terminal_appearance_state( client.host_terminal_appearance, client.host_terminal_appearance_explicit, ); changed |= self.app.set_host_terminal_theme(client.host_terminal_theme); if changed { self.resize_shared_runtime_to_effective_size_before_input(); } changed } 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. #[cfg(unix)] 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, ) } /// Windows named-pipe clients can block in connect unless the server has a /// pending blocking accept. The dedicated accept thread handles that path. #[cfg(windows)] fn accept_client_connections(&mut self) -> io::Result<()> { Ok(()) } /// 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 resolve_terminal_target_id_string(&self, target: &str) -> Option { if self.terminal_id_by_string(target).is_some() { return Some(target.to_owned()); } self.app .resolve_terminal_target(target) .ok() .map(|resolved| resolved.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_before_input(); } 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 resolve_terminal_session_target( &mut self, client_id: u64, target: &str, action: &str, ) -> Option { if !self.client_is_pending_terminal_mode(client_id) { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some( format!( "terminal session {action} failed: connection is not pending terminal session" ), ), }, ); self.remove_client_and_resize_if_needed(client_id); return None; } let Some(terminal_id) = self.resolve_terminal_target_id_string(target) else { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some(format!( "terminal session {action} failed: terminal target {target} not found" )), }, ); self.remove_client_and_resize_if_needed(client_id); return None; }; Some(terminal_id) } fn observe_terminal_client(&mut self, client_id: u64, target: String) -> bool { let Some(terminal_id) = self.resolve_terminal_session_target(client_id, &target, "observe") else { return false; }; let stamp = self.allocate_activity_stamp(); let Some(client) = self.clients.get_mut(&client_id) else { return false; }; let (cols, rows) = client.terminal_size; client.mode = ClientConnectionMode::TerminalObserve { terminal_id: terminal_id.clone(), }; client.pending_terminal_attach = false; 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 observe client connected"); true } fn control_terminal_client(&mut self, client_id: u64, target: String, takeover: bool) -> bool { let Some(terminal_id) = self.resolve_terminal_session_target(client_id, &target, "control") else { return false; }; self.attach_terminal_client(client_id, terminal_id, takeover) } 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) } fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option { self.app.state.workspaces.iter().find_map(|ws| { ws.tabs.iter().find_map(|tab| { let pane = tab.panes.get(&pane_id)?; self.app .state .terminals .get(&pane.attached_terminal_id) .and_then(|terminal| terminal.effective_agent_label()) .map(str::to_string) }) }) } fn forward_pane_state_update_notifications_to_clients( &mut self, update: &crate::app::actions::PaneStateUpdate, ) { if self.app.state.toast_config.delay_seconds != 0 { return; } let is_active_tab = self .app .state .pane_is_in_active_tab(update.ws_idx, update.pane_id); let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); if self.app.state.sound.allows(update.known_agent) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change_with_agent_labels( suppress_active_tab_notifications, update.previous_state, update.state, update.previous_agent_label.as_deref(), update.agent_label.as_deref(), ) { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), None, ); } } if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) { return; } let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( suppress_active_tab_notifications, update, ) else { return; }; let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else { return; }; let Some(agent_label) = update.agent_label.as_deref() else { return; }; let event_text = match kind { crate::app::state::ToastKind::NeedsAttention => "needs attention", crate::app::state::ToastKind::Finished => "finished", crate::app::state::ToastKind::UpdateInstalled => "updated", }; let workspace_label = ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); let context = crate::app::actions::notification_context( ws, &workspace_label, update.ws_idx, update.pane_id, ); self.send_notify_to_foreground_client( toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), format!("{agent_label} {event_text}"), non_empty_body(&context), ); } fn forward_agent_notification_delivery( &mut self, delivery: &crate::app::state::AgentNotificationDelivery, ) { if let Some(sound) = delivery.sound { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), None, ); } if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if let Some(toast) = &delivery.client_notification { self.send_notify_to_foreground_client( toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), &toast.title, non_empty_body(&toast.context), ); } } } fn send_notify_to_foreground_client( &mut self, kind: protocol::NotifyKind, message: impl Into, body: Option, ) -> bool { self.send_to_foreground_client(ServerMessage::Notify { kind, message: message.into(), body, }) } fn send_flat_toast_to_foreground_client( &mut self, kind: protocol::NotifyKind, message: impl AsRef, ) -> bool { let (title, body) = crate::terminal_notify::split_message(message.as_ref()); self.send_notify_to_foreground_client(kind, title, body.map(str::to_string)) } fn handle_notification_show_api( &mut self, id: String, params: api::schema::NotificationShowParams, ) -> String { use api::schema::{NotificationShowReason, ResponseResult}; let Some(title) = sanitize_notification_text(¶ms.title, 80) else { return serde_json::to_string(&api::schema::ErrorResponse { id, error: api::schema::ErrorBody { code: "invalid_params".into(), message: "notification title is empty".into(), }, }) .unwrap_or_else(|_| "{}".to_string()); }; match self.app.state.toast_config.delivery { config::ToastDelivery::Off => { return serde_json::to_string(&api::schema::SuccessResponse { id, result: ResponseResult::NotificationShow { shown: false, reason: NotificationShowReason::Disabled, }, }) .unwrap_or_else(|_| "{}".to_string()); } config::ToastDelivery::Herdr => { let sound = params.sound; let response = self.app.handle_api_request_after_internal_events_drained( api::schema::Request { id, method: api::schema::Method::NotificationShow(params), }, ); if notification_show_response_shown(&response) { self.forward_api_notification_sound(sound); } return response; } config::ToastDelivery::Terminal | config::ToastDelivery::System => {} } let body = params .body .as_deref() .and_then(|body| sanitize_notification_text(body, 240)); if self.app.api_notification_rate_limited(Instant::now()) { return serde_json::to_string(&api::schema::SuccessResponse { id, result: ResponseResult::NotificationShow { shown: false, reason: NotificationShowReason::RateLimited, }, }) .unwrap_or_else(|_| "{}".to_string()); } let kind = toast_notify_kind(self.app.state.toast_config.delivery) .expect("terminal/system delivery has notify kind"); let shown = self.send_notify_to_foreground_client(kind, title, body); if shown { self.app.mark_api_notification_shown(Instant::now()); self.forward_api_notification_sound(params.sound); } let reason = if shown { NotificationShowReason::Shown } else { NotificationShowReason::NoForegroundClient }; serde_json::to_string(&api::schema::SuccessResponse { id, result: ResponseResult::NotificationShow { shown, reason }, }) .unwrap_or_else(|_| "{}".to_string()) } fn handle_client_window_title_api(&mut self, id: String, title: Option) -> String { use api::schema::{ClientWindowTitleReason, ResponseResult}; let title = match title { Some(title) => match sanitize_window_title_text(&title, 200) { Some(title) => Some(title), None => { return serde_json::to_string(&api::schema::ErrorResponse { id, error: api::schema::ErrorBody { code: "invalid_params".into(), message: "window title is empty".into(), }, }) .unwrap_or_else(|_| "{}".to_string()); } }, None => None, }; let set_title = title.is_some(); let changed = self.send_to_foreground_client(ServerMessage::WindowTitle { title }); let reason = match (changed, set_title) { (true, true) => ClientWindowTitleReason::Set, (true, false) => ClientWindowTitleReason::Cleared, (false, _) => ClientWindowTitleReason::NoForegroundClient, }; serde_json::to_string(&api::schema::SuccessResponse { id, result: ResponseResult::ClientWindowTitle { changed, reason }, }) .unwrap_or_else(|_| "{}".to_string()) } fn forward_api_notification_sound(&mut self, sound: api::schema::NotificationShowSound) { let Some(sound) = sound.to_sound() else { return; }; self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), None, ); } /// 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. let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); if self.send_to_foreground_client(ServerMessage::Clipboard { data }) { self.app.show_clipboard_feedback(); } true } AppEvent::StateChanged { pane_id, agent, .. } => { // Capture toast before handling. let toast_before = self.app.state.toast.clone(); let pane_id_val = *pane_id; let agent_val = *agent; // Find the previous effective state of this pane before the event // is processed. Notifications must follow effective state changes, // not raw fallback reports that may be masked by hook authority. let prev_state = self.pane_effective_state(pane_id_val); let prev_agent_label = self.pane_effective_agent_label(pane_id_val); // Handle the state change (updates pane state, sets toast on AppState). // Headless mode disables local sound playback separately from the // sound policy so reloads can keep server-side notification policy live. self.sync_foreground_client_state(); 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); let next_agent_label = self.pane_effective_agent_label(pane_id_val); if self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change_with_agent_labels( suppress_active_tab_notifications, prev_state, next_state, prev_agent_label.as_deref(), next_agent_label.as_deref(), ) { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), None, ); } } let toast_msg = if self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { self.app .state .toast .as_ref() .map(|toast| format!("{}: {}", toast.title, toast.context)) } else { toast_message_from_state_change( &self.app.state, &self.app.terminal_runtimes, pane_id_val, suppress_active_tab_notifications, prev_state, next_state, prev_agent_label.as_deref(), ) } } else { None }; if let Some(msg) = toast_msg { self.send_flat_toast_to_foreground_client( toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), msg, ); } true } AppEvent::HookStateReported { pane_id, agent_label, .. } => { // Hook reports can be stale or no-op after sequence rejection. // Forward only effective state changes observed after handling. let toast_before = self.app.state.toast.clone(); let pane_id_val = *pane_id; let agent_val = crate::detect::parse_agent_label(agent_label); // Capture the previous effective state for this pane. Hook reports // are already folded into pane.state; raw hook transitions must not // produce a second notification path. let prev_state = self.pane_effective_state(pane_id_val); let prev_agent_label = self.pane_effective_agent_label(pane_id_val); self.sync_foreground_client_state(); 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); let next_agent_label = self.pane_effective_agent_label(pane_id_val); if self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change_with_agent_labels( suppress_active_tab_notifications, prev_state, next_state, prev_agent_label.as_deref(), next_agent_label.as_deref(), ) { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), None, ); } } let toast_msg = if self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { self.app .state .toast .as_ref() .map(|toast| format!("{}: {}", toast.title, toast.context)) } else { toast_message_from_state_change( &self.app.state, &self.app.terminal_runtimes, pane_id_val, suppress_active_tab_notifications, prev_state, next_state, prev_agent_label.as_deref(), ) } } else { None }; if let Some(msg) = toast_msg { self.send_flat_toast_to_foreground_client( toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), msg, ); } true } AppEvent::UpdateReady { version, install_command, } => { let toast_before = self.app.state.toast.clone(); let version = version.clone(); let install_command = install_command.clone(); self.app.handle_internal_event(ev); let toast_msg = if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { self.app .state .toast .as_ref() .map(|toast| format!("{}: {}", toast.title, toast.context)) } else { Some(format!( "v{version} available: {}", crate::update::update_install_instruction(&install_command) )) } } else { None }; if let Some(msg) = toast_msg { self.send_flat_toast_to_foreground_client( toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), msg, ); } true } AppEvent::PaneDied { pane_id } => { let pane_id_val = *pane_id; let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { ws.tabs.iter().find_map(|tab| { tab.panes .get(pane_id) .map(|pane| pane.attached_terminal_id.to_string()) }) }); if let Some(update) = self .app .state .publish_pane_process_exit_if_agent(pane_id_val) { self.app.emit_pane_state_update(&update); self.forward_pane_state_update_notifications_to_clients(&update); } self.app.handle_internal_event(ev); if self.app.find_pane(pane_id_val).is_none() { if let Some(terminal_id) = terminal_id { self.shutdown_terminal_stream_clients( &terminal_id, format!("terminal {terminal_id} exited"), ); } } true } _ => { self.app.handle_internal_event(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_stream_clients(&mut self, terminal_id: &str, reason: String) { let client_ids = terminal_stream_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 send_terminal_stream_detach_shutdown(&mut self, client_id: u64) { if matches!( self.clients.get(&client_id).map(|client| &client.mode), Some( ClientConnectionMode::TerminalAttach { .. } | ClientConnectionMode::TerminalObserve { .. } ) ) { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some("detached".to_owned()), }, ); } } #[cfg(unix)] 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 { if !self.client_is_pending_terminal_mode(client_id) { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some( "terminal attach failed: connection is not pending terminal attach" .to_owned(), ), }, ); self.remove_client_and_resize_if_needed(client_id); return false; } 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.pending_terminal_attach = false; 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()); self.app .start_pending_agent_resume_for_terminal(&real_terminal_id, rows, cols, true); 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 } fn client_is_pending_terminal_mode(&self, client_id: u64) -> bool { self.clients.get(&client_id).is_some_and(|client| { client.pending_terminal_attach && matches!(client.mode, ClientConnectionMode::App) }) } /// Handles a server event. Returns true if the event requires a re-render. fn handle_client_input_events( &mut self, client_id: u64, events: Vec, ) -> bool { 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(); client.render_pending = true; } 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_before_input(); } 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(); } if self.app.state.detach_requested { self.app.state.detach_requested = false; info!(client_id, "client detach requested via keybind"); self.send_client_graphics_cleanup(client_id); self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some("detached".to_owned()), }, ); if let Some(client) = self.clients.get_mut(&client_id) { client.writer = None; } false } else { foreground_changed || theme_changed || interaction } } 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, direct_attach_requested, } => { 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; } let first_app_client = !direct_attach_requested && self.app_client_count() == 0; 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, direct_attach_requested, Some(writer), ), ); if !direct_attach_requested { self.foreground_client_id = Some(client_id); } if first_app_client { self.app.mark_git_status_refresh_due(Instant::now()); } 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::ClientObserveTerminal { client_id, target } => { self.observe_terminal_client(client_id, target) } ServerEvent::ClientControlTerminal { client_id, target, takeover, } => self.control_terminal_client(client_id, target, 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; } if matches!( self.clients.get(&client_id).map(|client| &client.mode), Some(ClientConnectionMode::TerminalObserve { .. }) ) { return false; } let events = if let Some(client) = self.clients.get_mut(&client_id) { let mut events = client.raw_input.push(&data); // The thin client only forwards a bare ESC after its local input timeout. if data.as_slice() == b"\x1b" { events.extend(client.raw_input.flush_timeout()); } events } else { Vec::new() }; self.handle_client_input_events(client_id, events) } ServerEvent::ClientInputEvents { client_id, events } => { if self.handoff_in_progress { debug!( client_id, len = events.len(), "ignored client input events during handoff" ); return false; } debug!( client_id, len = events.len(), "client input events received" ); if matches!( self.clients.get(&client_id).map(|client| &client.mode), Some(ClientConnectionMode::TerminalObserve { .. }) ) { return false; } let events = events .iter() .map(crate::protocol::ClientInputEvent::to_raw_input_event) .collect(); self.handle_client_input_events(client_id, events) } ServerEvent::ClientClipboardImage { client_id, extension, data, } => { debug!( client_id, len = data.len(), extension = %extension, "client clipboard image received" ); if matches!( self.clients.get(&client_id).map(|client| &client.mode), Some(ClientConnectionMode::TerminalObserve { .. }) ) { return false; } 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(ClientConnection { mode: ClientConnectionMode::TerminalObserve { .. }, 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(); 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.send_terminal_stream_detach_shutdown(client_id); 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; } if let api::schema::Method::NotificationShow(params) = &msg.request.method { let response = self.handle_notification_show_api(msg.request.id.clone(), params.clone()); let _ = msg.respond_to.send(response); return true; } match &msg.request.method { api::schema::Method::ClientWindowTitleSet(params) => { let response = self.handle_client_window_title_api( msg.request.id.clone(), Some(params.title.clone()), ); let _ = msg.respond_to.send(response); return true; } api::schema::Method::ClientWindowTitleClear(_) => { let response = self.handle_client_window_title_api(msg.request.id.clone(), None); let _ = msg.respond_to.send(response); return true; } _ => {} } let mut changed = api::request_changes_ui(&msg.request); let skip_default_workspace = matches!( &msg.request.method, api::schema::Method::ServerStop(_) | api::schema::Method::ServerLiveHandoff(_) ); changed |= self.drain_all_internal_events_with_forwarding(); // 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, Option, )> = { 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, terminal.effective_agent_label().map(str::to_string), ) }) }) }) }) .collect() }; self.sync_foreground_client_state(); if matches!( &msg.request.method, api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_) ) { let deferred_changed = self .app .handle_deferred_worktree_api_request(msg.request, msg.respond_to); return changed | deferred_changed; } 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 { debug!(title = %toast.title, body = %toast.context, "forwarding toast notification from API request"); self.send_notify_to_foreground_client( toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), &toast.title, non_empty_body(&toast.context), ); 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, prev_agent_label) 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(); let agent_label = terminal_after.effective_agent_label().map(str::to_string); 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 && self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if let Some(kind) = crate::app::actions::notification_toast_for_state_change_with_agent_labels( suppress_active_tab_notifications, *prev_state, new_state, prev_agent_label.as_deref(), agent_label.as_deref(), ) { 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 workspace_label = self.app.state.workspaces[*ws_idx].display_name_from( &self.app.state.terminals, &self.app.terminal_runtimes, ); let context = crate::app::actions::notification_context( &self.app.state.workspaces[*ws_idx], &workspace_label, *ws_idx, *pane_id, ); self.send_notify_to_foreground_client( toast_notify_kind(self.app.state.toast_config.delivery) .expect("toast forwarding requires a client notification kind"), format!("{agent_label} {event_text}"), non_empty_body(&context), ); } } } // 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.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change_with_agent_labels( suppress_active_tab_notifications, *prev_state, new_state, prev_agent_label.as_deref(), agent_label.as_deref(), ) { debug!(sound = ?sound, "forwarding sound notification from API request"); self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), None, ); } } } if !skip_default_workspace && latest_app_client(&self.clients).is_some() { changed |= self.app.ensure_default_workspace(); } 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 !client.is_full_app_client() { 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_retained_pty_update_and_stream(&mut self) -> bool { crate::render_prof::event("retained.attempt"); let retained_started = crate::render_prof::timer(); macro_rules! retained_fallback { ($reason:literal) => {{ crate::render_prof::event(concat!("retained_fallback.", $reason)); crate::render_prof::duration_since("retained.total", retained_started); return false; }}; } macro_rules! retained_success { ($reason:literal) => {{ crate::render_prof::event("retained.success"); crate::render_prof::event(concat!("retained_success.", $reason)); crate::render_prof::duration_since("retained.total", retained_started); return true; }}; } if !self.retained_pty_update_allowed_by_app_state() { retained_fallback!("unsafe_app_state"); } let render_targets = render_targets(&self.clients, self.foreground_client_id); let [(client_id, (cols, rows), cell_size, _is_foreground, mode)] = render_targets.as_slice() else { retained_fallback!("multiple_or_no_target"); }; if !matches!(mode, ClientConnectionMode::App) { retained_fallback!("not_app_client"); } let Some(client) = self.clients.get(client_id) else { retained_fallback!("client_missing"); }; if client.render_pending { retained_fallback!("render_pending"); } if self.app.state.kitty_graphics_enabled && !client.graphics_cache.is_empty() { retained_fallback!("graphics_cache_active"); } if client.graphics_surface_reset_pending { retained_fallback!("graphics_surface_reset"); } if self.app.state.kitty_graphics_enabled && cell_size.is_known() && crate::kitty_graphics::has_visible_pane_graphics( &self.app.state, &self.app.terminal_runtimes, *cell_size, ) { retained_fallback!("visible_kitty_graphics"); } let Some(mut frame) = client.render_state.last_frame().cloned() else { retained_fallback!("no_last_frame"); }; if frame.width != *cols || frame.height != *rows { retained_fallback!("frame_size_mismatch"); } frame.graphics.clear(); let Some(ws_idx) = self.app.state.active else { retained_fallback!("no_active_workspace"); }; let pane_infos = self.app.state.view.pane_infos.clone(); if pane_infos.is_empty() { retained_fallback!("no_pane_info"); } let mut touched = false; for info in pane_infos { if !rect_fits_frame(info.inner_rect, &frame) { retained_fallback!("pane_rect_outside_frame"); } let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( &self.app.terminal_runtimes, ws_idx, info.id, ) else { retained_fallback!("missing_runtime"); }; match runtime.collect_dirty_patch(info.inner_rect.width, info.inner_rect.height) { crate::pane::TerminalDirtyPatchOutcome::Clean => { crate::render_prof::event("retained.pane_clean"); } crate::pane::TerminalDirtyPatchOutcome::Fallback => { retained_fallback!("dirty_patch_fallback"); } crate::pane::TerminalDirtyPatchOutcome::Patch(patch) => { crate::render_prof::event("retained.pane_patch"); crate::render_prof::counter("retained.patch_rows", patch.rows.len() as u64); if dirty_patch_intersects_hyperlinks(&frame, info.inner_rect, &patch) { retained_fallback!("hyperlink_intersection"); } if !apply_terminal_dirty_patch(&mut frame, info.inner_rect, patch) { retained_fallback!("patch_apply_failed"); } touched = true; } } } let previous_cursor = frame.cursor.clone(); frame.cursor = crate::server::render_stream::focused_terminal_cursor( &self.app.state, &self.app.terminal_runtimes, ); let cursor_changed = frame.cursor != previous_cursor; if !touched && !cursor_changed { retained_success!("clean_no_cursor_change"); } let mut broken_clients = Vec::new(); let sent = self.send_retained_frame_to_client(*client_id, frame, &mut broken_clients); for broken_client in broken_clients { self.remove_client_and_resize_if_needed(broken_client); } if sent { retained_success!("sent"); } retained_fallback!("send_failed"); } fn retained_pty_update_allowed_by_app_state(&self) -> bool { self.app.state.mode == app::Mode::Terminal && self.app.state.selection.is_none() && self.app.state.copy_mode.is_none() && self.app.state.context_menu.is_none() && self.app.state.toast.is_none() && !self.app.full_redraw_pending } fn send_retained_frame_to_client( &mut self, client_id: u64, frame: FrameData, broken_clients: &mut Vec, ) -> bool { let Some(client) = self.clients.get_mut(&client_id) else { crate::render_prof::event("retained_send_fallback.client_missing"); return false; }; let Some(writer) = client.writer.as_ref().cloned() else { crate::render_prof::event("retained_send_fallback.writer_missing"); return false; }; let prepare_started = crate::render_prof::timer(); let Some(prepared) = client.render_state.prepare_frame(frame) else { client.render_pending = false; crate::render_prof::event("retained_send.skip_identical"); crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started); return true; }; crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started); let serialize_started = crate::render_prof::timer(); let serialized = match Self::frame_server_message(prepared.message()) { Ok(framed) => { crate::render_prof::duration_since("retained_send.serialize", serialize_started); framed } Err(protocol::FramingError::Oversized { claimed, max }) => { warn!( client_id, claimed, max, "skipping oversized retained frame for client" ); crate::render_prof::event("retained_send_fallback.serialize_oversized"); crate::render_prof::duration_since("retained_send.serialize", serialize_started); return false; } Err(err) => { warn!(client_id, err = %err, "failed to serialize retained frame for client"); broken_clients.push(client_id); crate::render_prof::event("retained_send_fallback.serialize_error"); crate::render_prof::duration_since("retained_send.serialize", serialize_started); return false; } }; crate::render_prof::counter("retained_send.bytes", serialized.len() as u64); let send_started = crate::render_prof::timer(); match writer.render.try_send(serialized) { Ok(()) => { client.render_pending = false; client.render_state.commit_sent_frame(prepared); crate::render_prof::event("retained_send.sent"); crate::render_prof::duration_since("retained_send.try_send", send_started); true } Err(std::sync::mpsc::TrySendError::Full(_)) => { client.render_pending = true; crate::render_prof::event("retained_send_fallback.queue_full"); crate::render_prof::duration_since("retained_send.try_send", send_started); debug!( client_id, "render queue full, deferring latest retained frame" ); false } Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { debug!(client_id, "client writer channel closed, marking as broken"); broken_clients.push(client_id); crate::render_prof::event("retained_send_fallback.writer_disconnected"); crate::render_prof::duration_since("retained_send.try_send", send_started); false } } } fn render_and_stream(&mut self) { let full_started = crate::render_prof::timer(); let render_targets = render_targets(&self.clients, self.foreground_client_id); if render_targets.is_empty() { let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); let resize_panes = self.app.state.view.pane_infos.is_empty(); let render_started = crate::render_prof::timer(); 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(), ); crate::render_prof::duration_since("full_render.render_virtual", render_started); self.app.full_redraw_pending = false; crate::render_prof::duration_since("full_render.total", full_started); debug!( cols, rows, resize_panes, "rendered virtual frame with no attached clients" ); return; } let mut broken_clients: Vec = Vec::new(); let mut deferred_frame = false; 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 render_started = crate::render_prof::timer(); 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(), ) }; crate::render_prof::duration_since( "full_render.render_virtual", render_started, ); let hyperlinks_started = crate::render_prof::timer(); let hyperlinks = crate::server::render_stream::visible_hyperlinks( &self.app.state, &self.app.terminal_runtimes, ); crate::render_prof::duration_since( "full_render.visible_hyperlinks", hyperlinks_started, ); let frame_started = crate::render_prof::timer(); let frame = FrameData::from_ratatui_buffer_with_hyperlinks( &buffer, cursor, &hyperlinks, ); crate::render_prof::duration_since("full_render.frame_build", frame_started); frame } ClientConnectionMode::TerminalAttach { terminal_id } | ClientConnectionMode::TerminalObserve { terminal_id } => { let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { self.send_to_client( client_id, ServerMessage::ServerShutdown { reason: Some(format!( "terminal attach ended: terminal {terminal_id} not found" )), }, ); broken_clients.push(client_id); continue; }; let render_started = crate::render_prof::timer(); let (buffer, cursor) = crate::server::render_stream::render_terminal_virtual(runtime, area); crate::render_prof::duration_since( "full_render.render_terminal_virtual", render_started, ); let hyperlinks_started = crate::render_prof::timer(); let hyperlinks = runtime.visible_hyperlinks(area); crate::render_prof::duration_since( "full_render.visible_hyperlinks", hyperlinks_started, ); let frame_started = crate::render_prof::timer(); let frame = FrameData::from_ratatui_buffer_with_hyperlinks( &buffer, cursor, &hyperlinks, ); crate::render_prof::duration_since("full_render.frame_build", frame_started); frame } }; let Some(client) = self.clients.get_mut(&client_id) else { continue; }; let 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(); } let graphics_started = crate::render_prof::timer(); frame .graphics .extend(crate::kitty_graphics::encode_local_pane_graphics( &self.app.state, &self.app.terminal_runtimes, cell_size, &mut next_graphics_cache, )); crate::render_prof::duration_since("full_render.graphics_encode", graphics_started); } else { frame.graphics = next_graphics_cache.clear_bytes(); } let Some(writer) = client.writer.as_ref().cloned() else { crate::render_prof::event("full_render.writer_missing"); 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 max_frame_size = if frame.graphics.is_empty() { MAX_FRAME_SIZE } else { MAX_GRAPHICS_FRAME_SIZE }; let has_graphics = !frame.graphics.is_empty(); let prepare_started = crate::render_prof::timer(); let Some(mut prepared) = client.render_state.prepare_frame(frame) else { client.render_pending = false; crate::render_prof::event("full_render.skip_identical"); crate::render_prof::duration_since("full_render.prepare_frame", prepare_started); continue; }; crate::render_prof::duration_since("full_render.prepare_frame", prepare_started); let serialize_started = crate::render_prof::timer(); let serialized = match Self::frame_server_message_with_max( prepared.message(), max_frame_size, ) { Ok(framed) => { crate::render_prof::duration_since("full_render.serialize", serialize_started); framed } Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => { warn!( client_id, claimed, max, "dropping graphics from oversized frame for client" ); let Some(mut text_only_frame) = prepared.into_frame() else { crate::render_prof::event("full_render.serialize_error"); crate::render_prof::duration_since( "full_render.serialize", serialize_started, ); continue; }; text_only_frame.graphics.clear(); let Some(text_only_prepared) = client.render_state.prepare_frame(text_only_frame) else { client.render_pending = false; crate::render_prof::event("full_render.skip_identical_text_only"); crate::render_prof::duration_since( "full_render.serialize", serialize_started, ); 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); crate::render_prof::event("full_render.serialize_error"); crate::render_prof::duration_since( "full_render.serialize", serialize_started, ); continue; } }; prepared = text_only_prepared; commit_graphics_cache = false; crate::render_prof::duration_since("full_render.serialize", serialize_started); framed } Err(protocol::FramingError::Oversized { claimed, max }) => { warn!( client_id, claimed, max, "skipping oversized frame for client" ); crate::render_prof::event("full_render.serialize_oversized"); crate::render_prof::duration_since("full_render.serialize", serialize_started); continue; } Err(err) => { warn!(client_id, err = %err, "failed to serialize frame for client"); broken_clients.push(client_id); crate::render_prof::event("full_render.serialize_error"); crate::render_prof::duration_since("full_render.serialize", serialize_started); continue; } }; crate::render_prof::counter("full_render.bytes", serialized.len() as u64); let send_started = crate::render_prof::timer(); 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(prepared); crate::render_prof::event("full_render.sent"); crate::render_prof::duration_since("full_render.try_send", send_started); } Err(std::sync::mpsc::TrySendError::Full(_)) => { client.render_pending = true; deferred_frame = true; crate::render_prof::event("full_render.queue_full"); crate::render_prof::duration_since("full_render.try_send", send_started); 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); crate::render_prof::event("full_render.writer_disconnected"); crate::render_prof::duration_since("full_render.try_send", send_started); 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; if !deferred_frame { self.app.full_redraw_pending = false; } crate::render_prof::duration_since("full_render.total", full_started); debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)"); } /// 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, geometry_dirty: bool) -> 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 .state .next_pending_agent_notification_deadline() .is_some_and(|deadline| now >= deadline) { let previous_toast = self.app.state.toast.clone(); let mut deliveries = self.app.state.drain_due_agent_notifications(now); if !deliveries.is_empty() { self.app .refresh_agent_notification_delivery_contexts(&mut deliveries); self.app.sync_toast_deadline(previous_toast); for delivery in &deliveries { self.forward_agent_notification_delivery(delivery); } changed = true; } } if self .app .copy_feedback_deadline .is_some_and(|deadline| now >= deadline) { self.app.copy_feedback_deadline = None; self.app.state.copy_feedback = 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; } changed |= self.app.clear_due_selection_highlight(now); if self.has_app_client() { 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 .next_agent_manifest_update_check .is_some_and(|deadline| now >= deadline) { self.app.run_agent_manifest_update_check(); } if self .app .session_save_deadline .is_some_and(|deadline| now >= deadline) { self.app.save_session_now(); } if let Some(deadline) = self .app .agent_metadata_deadline .filter(|deadline| now >= *deadline) { let previous_toast = self.app.state.toast.clone(); for update in self.app.state.expire_agent_metadata_at(deadline, now) { self.app .refresh_new_herdr_toast_context_for_update(&update, &previous_toast); self.app.emit_pane_state_update(&update); } self.app.sync_agent_metadata_deadline(); changed = true; } if geometry_dirty || self.foreground_client_id.is_none() { self.app.pending_agent_resume_deadline = None; } else { self.app.sync_pending_agent_resume_deadline(now); changed |= self .app .start_pending_agent_resumes(self.app.pending_agent_resume_due(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 sanitize_notification_text(value: &str, max_chars: usize) -> Option { let mut sanitized = String::new(); let mut previous_space = false; for ch in value.chars() { let replacement = if ch == '\n' || ch == '\r' || ch == '\t' { Some(' ') } else if ch.is_control() { None } else { Some(ch) }; let Some(ch) = replacement else { continue; }; if ch.is_whitespace() { if previous_space { continue; } previous_space = true; sanitized.push(' '); } else { previous_space = false; sanitized.push(ch); } if sanitized.chars().count() >= max_chars { break; } } let sanitized = sanitized.trim().to_string(); (!sanitized.is_empty()).then_some(sanitized) } fn sanitize_window_title_text(value: &str, max_chars: usize) -> Option { let sanitized = value .chars() .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}') && !ch.is_control()) .take(max_chars) .collect::() .trim() .to_string(); (!sanitized.is_empty()).then_some(sanitized) } 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(); crate::platform::raise_server_nofile_limit(); 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, ); seed_startup_workspace_if_empty(&mut app); // 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 } fn seed_startup_workspace_if_empty(app: &mut app::App) { let Some(cwd) = take_startup_cwd() else { return; }; if !app.state.workspaces.is_empty() { info!( cwd = %cwd.display(), "restored session already has workspaces; ignoring startup cwd" ); return; } match app.create_workspace_with_options(cwd.clone(), true) { Ok(_) => { info!(cwd = %cwd.display(), "created startup workspace"); } Err(err) => { warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace"); app.state.mode = app::Mode::Navigate; } } } fn take_startup_cwd() -> Option { let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?; std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR); (!cwd.is_empty()).then(|| PathBuf::from(cwd)) } #[cfg(unix)] fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> { let loaded_config = config::Config::load(); let mut received = crate::server::handoff::receive(socket_path, token)?; crate::server::handoff::log_import_result(received.manifest.panes.len()); let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let event_hub = api::EventHub::default(); let mut imports = HashMap::new(); for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { let pane_id = pane.pane_id; imports.insert( pane_id, crate::handoff_runtime::ImportedHandoffRuntime { master_fd: fd, state: pane, }, ); } let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(io::Error::other)?; let result = rt.block_on(async { let 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<()> { let deadline = Instant::now() + timeout; let api_socket = api::socket_path(); let client_socket = client_socket_path(); while Instant::now() < deadline { let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok(); let client_open = client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok(); if !api_open && !client_open { return Ok(()); } std::thread::sleep(Duration::from_millis(50)); } Err(io::Error::new( io::ErrorKind::TimedOut, "old server sockets did not close before handoff import bind", )) } #[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 = bind_local_listener(&socket_path).expect("bind test listener"); let client_socket_identity = socket_file_identity(&socket_path).expect("test listener socket identity"); #[cfg(unix)] listener .set_nonblocking(ListenerNonblockingMode::Accept) .expect("set listener nonblocking"); let (server_event_tx, server_event_rx) = mpsc::channel(64); #[cfg(windows)] let should_quit = Arc::new(AtomicBool::new(false)); #[cfg(windows)] spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); let server_keybindings = app_keybindings(&app); HeadlessServer { app, #[cfg(unix)] api_tx: None, #[cfg(unix)] api_server: None, #[cfg(unix)] client_listener: listener, client_socket_path: socket_path, client_socket_identity, clients: HashMap::new(), #[cfg(unix)] next_client_id: 1, foreground_client_id: None, 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, #[cfg(unix)] pending_handoff_repaint_nudge: false, #[cfg(unix)] should_quit: Arc::new(AtomicBool::new(false)), #[cfg(windows)] should_quit, 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 frame_text(frame: &FrameData) -> String { frame .cells .chunks(usize::from(frame.width)) .map(|row| { row.iter() .map(|cell| cell.symbol.as_str()) .collect::() }) .collect::>() .join("\n") } 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::test_channel(control_tx, render_tx), control_rx, render_rx, ) } fn retained_test_server( initial_screen: &[u8], ) -> ( HeadlessServer, std::sync::mpsc::Receiver>, crate::layout::PaneId, ) { let mut server = test_headless_server(); let mut workspace = crate::workspace::Workspace::test_new("test"); let pane_id = workspace.focused_pane_id().expect("focused pane"); workspace.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen), ); server.app.state.workspaces = vec![workspace]; server.app.state.active = Some(0); server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; let (client_tx, _client_control_rx, client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), 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, client_rx, pane_id) } fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) { assert_eq!( (actual.width, actual.height), (expected.width, expected.height) ); assert_eq!(actual.cursor, expected.cursor, "cursor mismatch"); assert_eq!(actual.hyperlinks, expected.hyperlinks, "hyperlink mismatch"); assert_eq!(actual.graphics, expected.graphics, "graphics mismatch"); assert_eq!( actual.cells.len(), expected.cells.len(), "cell length mismatch" ); for (idx, (actual_cell, expected_cell)) in actual.cells.iter().zip(expected.cells.iter()).enumerate() { assert_eq!( actual_cell, expected_cell, "cell mismatch at index {idx} (x={}, y={})", idx % usize::from(actual.width), idx / usize::from(actual.width), ); } } #[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)), direct_attach_requested: false, 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, direct_attach_requested: false, 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)), direct_attach_requested: false, 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, direct_attach_requested: false, 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)), direct_attach_requested: false, 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_apply_valid_subset_after_settings_save_without_caching_local_keybindings( ) { 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())), direct_attach_requested: false, 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, direct_attach_requested: false, writer: writer_b, })); assert_eq!( server.app.state.prefix_code, crossterm::event::KeyCode::Char('b') ); assert!(!server .app .state .keybinds .new_workspace .bindings .iter() .any(|binding| binding.label == "prefix+n")); assert!(server.app.state.keybinds.new_workspace.bindings.is_empty()); 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, direct_attach_requested: true, 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()) ); } fn with_terminal_session_test_server( test: impl FnOnce(&mut HeadlessServer, crate::terminal::TerminalId, String, String), ) { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("test runtime"); let _runtime_guard = rt.enter(); let mut server = test_headless_server(); let workspace = crate::workspace::Workspace::test_new("test"); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); let terminal_id_string = terminal_id.to_string(); let public_pane_id = format!("{}:p1", workspace.id); server.app.state.workspaces = vec![workspace]; server.app.state.ensure_test_terminals(); server.app.terminal_runtimes.insert( terminal_id.clone(), crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), ); test(&mut server, terminal_id, terminal_id_string, public_pane_id); drop(server); drop(_runtime_guard); rt.shutdown_timeout(Duration::from_millis(100)); } fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) { let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id); } fn connect_pending_terminal_client_with_control_rx( server: &mut HeadlessServer, client_id: u64, ) -> std::sync::mpsc::Receiver> { let (writer, control_rx, _render_rx) = test_client_writer(); assert!(server.handle_server_event(ServerEvent::ClientConnected { client_id, cols: 100, rows: 30, cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, keybindings: None, direct_attach_requested: true, writer, })); control_rx } #[test] fn terminal_observe_allows_multiple_clients_without_attach_ownership() { with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { let initial_size = server .app .terminal_runtimes .get(&terminal_id) .expect("runtime") .current_size(); for client_id in [7, 8] { connect_pending_terminal_client(server, client_id); assert!( server.handle_server_event(ServerEvent::ClientObserveTerminal { client_id, target: terminal_id_string.clone(), }) ); } assert!(server.terminal_attach_owners.is_empty()); assert!(!server .app .state .direct_attach_resize_locks .contains(&terminal_id)); assert_eq!( server .app .terminal_runtimes .get(&terminal_id) .expect("runtime") .current_size(), initial_size ); assert_eq!( terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), 2 ); }); } #[test] fn terminal_observe_resolves_public_pane_id() { with_terminal_session_test_server(|server, terminal_id, _, public_pane_id| { connect_pending_terminal_client(server, 7); assert!( server.handle_server_event(ServerEvent::ClientObserveTerminal { client_id: 7, target: public_pane_id, }) ); assert!(matches!( server.clients.get(&7).map(|client| &client.mode), Some(ClientConnectionMode::TerminalObserve { terminal_id: observed }) if observed == &terminal_id.to_string() )); }); } #[test] fn terminal_control_resolves_public_pane_id_and_takes_ownership() { with_terminal_session_test_server( |server, terminal_id, terminal_id_string, public_pane_id| { connect_pending_terminal_client(server, 7); assert!( server.handle_server_event(ServerEvent::ClientControlTerminal { client_id: 7, target: public_pane_id, takeover: false, }) ); assert!(matches!( server.clients.get(&7).map(|client| &client.mode), Some(ClientConnectionMode::TerminalAttach { terminal_id: attached }) if attached == &terminal_id_string )); assert_eq!( server.terminal_attach_owners.get(&terminal_id_string), Some(&7) ); assert!(server .app .state .direct_attach_resize_locks .contains(&terminal_id)); }, ); } #[test] fn terminal_control_rejects_second_controller_without_takeover() { with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { connect_pending_terminal_client(server, 7); assert!( server.handle_server_event(ServerEvent::ClientControlTerminal { client_id: 7, target: terminal_id_string.clone(), takeover: false, }) ); connect_pending_terminal_client(server, 8); assert!( !server.handle_server_event(ServerEvent::ClientControlTerminal { client_id: 8, target: terminal_id_string.clone(), takeover: false, }) ); assert!(server.clients.contains_key(&7)); assert!(!server.clients.contains_key(&8)); assert_eq!( server.terminal_attach_owners.get(&terminal_id_string), Some(&7) ); }); } #[test] fn terminal_control_takeover_replaces_existing_controller() { with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { connect_pending_terminal_client(server, 7); assert!( server.handle_server_event(ServerEvent::ClientControlTerminal { client_id: 7, target: terminal_id_string.clone(), takeover: false, }) ); connect_pending_terminal_client(server, 8); assert!( server.handle_server_event(ServerEvent::ClientControlTerminal { client_id: 8, target: terminal_id_string.clone(), takeover: true, }) ); assert!(!server.clients.contains_key(&7)); assert!(server.clients.contains_key(&8)); assert_eq!( server.terminal_attach_owners.get(&terminal_id_string), Some(&8) ); }); } #[test] fn terminal_observe_can_coexist_with_terminal_control() { with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { connect_pending_terminal_client(server, 7); assert!( server.handle_server_event(ServerEvent::ClientControlTerminal { client_id: 7, target: terminal_id_string.clone(), takeover: false, }) ); connect_pending_terminal_client(server, 8); assert!( server.handle_server_event(ServerEvent::ClientObserveTerminal { client_id: 8, target: terminal_id_string.clone(), }) ); assert_eq!( server.terminal_attach_owners.get(&terminal_id_string), Some(&7) ); assert!(matches!( server.clients.get(&8).map(|client| &client.mode), Some(ClientConnectionMode::TerminalObserve { terminal_id }) if terminal_id == &terminal_id_string )); assert_eq!( terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), 2 ); }); } #[test] fn terminal_control_detach_sends_shutdown_before_removal() { with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); assert!( server.handle_server_event(ServerEvent::ClientControlTerminal { client_id: 7, target: terminal_id_string.clone(), takeover: false, }) ); assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 })); assert!(!server.clients.contains_key(&7)); assert!(!server .terminal_attach_owners .contains_key(&terminal_id_string)); let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); assert_eq!(reason, Some("detached".to_owned())); }); } #[test] fn terminal_observe_rejects_later_attach_upgrade() { with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { connect_pending_terminal_client(server, 7); assert!( server.handle_server_event(ServerEvent::ClientObserveTerminal { client_id: 7, target: terminal_id_string.clone(), }) ); assert!( !server.handle_server_event(ServerEvent::ClientAttachTerminal { client_id: 7, terminal_id: terminal_id_string, takeover: true, }) ); assert!(!server.clients.contains_key(&7)); assert!(server.terminal_attach_owners.is_empty()); assert!(!server .app .state .direct_attach_resize_locks .contains(&terminal_id)); }); } #[test] fn terminal_attach_rejects_later_observe_and_clears_ownership() { with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { connect_pending_terminal_client(server, 7); assert!( server.handle_server_event(ServerEvent::ClientAttachTerminal { client_id: 7, terminal_id: terminal_id_string.clone(), takeover: false, }) ); assert_eq!( server.terminal_attach_owners.get(&terminal_id_string), Some(&7) ); assert!(server .app .state .direct_attach_resize_locks .contains(&terminal_id)); assert!( !server.handle_server_event(ServerEvent::ClientObserveTerminal { client_id: 7, target: terminal_id_string.clone(), }) ); assert!(!server.clients.contains_key(&7)); assert!(server.terminal_attach_owners.is_empty()); assert!(!server .app .state .direct_attach_resize_locks .contains(&terminal_id)); }); } fn app_client_marks_git_refresh_due_on_first_attach(render_encoding: RenderEncoding) { let mut server = test_headless_server(); server .app .state .workspaces .push(crate::workspace::Workspace::test_new("test")); let future = Instant::now() + Duration::from_secs(60); server.app.last_git_remote_status_refresh = future; 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, keybindings: None, direct_attach_requested: false, writer, })); assert!(server.has_app_client()); assert!(server .app .git_refresh_deadline() .is_some_and(|deadline| deadline <= Instant::now())); } #[test] fn terminal_ansi_app_client_enables_headless_git_refresh() { app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::TerminalAnsi); } #[test] fn pending_terminal_attach_client_does_not_enable_headless_git_refresh() { let mut server = test_headless_server(); server .app .state .workspaces .push(crate::workspace::Workspace::test_new("test")); 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, direct_attach_requested: true, writer, })); assert!(!server.has_app_client()); assert_eq!( server.app.next_headless_loop_deadline_with_git_refresh( Instant::now(), false, server.has_app_client() ), None ); } #[test] fn writerless_app_client_does_not_enable_headless_git_refresh() { let mut server = test_headless_server(); server .app .state .workspaces .push(crate::workspace::Workspace::test_new("test")); 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::SemanticFrame, keybindings: None, direct_attach_requested: false, writer, })); assert!(server.has_app_client()); server.clients.get_mut(&7).expect("client").writer = None; assert!(!server.has_app_client()); assert_eq!( server.app.next_headless_loop_deadline_with_git_refresh( Instant::now(), false, server.has_app_client() ), None ); } #[test] fn semantic_app_client_marks_git_refresh_due_on_first_attach() { app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::SemanticFrame); } #[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, direct_attach_requested: true, 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 terminal_attach_page_key_forwards_in_alternate_screen_without_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[?1049h".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 headless_scheduled_tasks_expire_agent_metadata() { let mut server = test_headless_server(); let workspace = crate::workspace::Workspace::test_new("metadata"); let pane_id = workspace.tabs[0].root_pane; server.app.state.workspaces = vec![workspace]; server.app.state.ensure_test_terminals(); assert!( server.handle_internal_event_with_forwarding(AppEvent::HookStateReported { pane_id, source: "herdr:pi".into(), agent_label: "pi".into(), state: crate::detect::AgentState::Working, message: None, custom_status: None, seq: None, session_ref: None, }) ); assert!( server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported { pane_id, source: "user:pi-display".into(), agent_label: Some("pi".into()), applies_to_source: Some("herdr:pi".into()), title: None, display_agent: None, custom_status: Some("short lived".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, clear_custom_status: false, clear_state_labels: false, seq: None, ttl: Some(Duration::from_millis(1)), }) ); let deadline = server .app .agent_metadata_deadline .expect("metadata deadline"); let terminal_id = server.app.state.workspaces[0] .pane_state(pane_id) .expect("pane") .attached_terminal_id .clone(); assert_eq!( server .app .state .terminals .get(&terminal_id) .expect("terminal") .effective_custom_status() .as_deref(), Some("short lived") ); assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); assert_eq!(server.app.agent_metadata_deadline, None); assert_eq!( server .app .state .terminals .get(&terminal_id) .expect("terminal") .effective_custom_status(), None ); assert!(server .app .event_hub .events_after(0) .iter() .any(|(_, event)| { event.event == crate::api::schema::EventKind::PaneAgentStatusChanged && matches!( &event.data, crate::api::schema::EventData::PaneAgentStatusChanged { custom_status, .. } if custom_status.is_none() ) })); } #[test] fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() { let mut server = test_headless_server(); let now = Instant::now(); server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1)); assert!(!server.handle_scheduled_tasks_headless(now, false)); assert_eq!(server.app.next_agent_manifest_update_check, None); } #[tokio::test] async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { let mut server = test_headless_server(); let workspace = crate::workspace::Workspace::test_new("restored"); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); server.app.state.view.pane_infos = workspace.tabs[0] .layout .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); server.app.state.workspaces = vec![workspace]; server.app.state.active = Some(0); server.app.state.ensure_test_terminals(); server.clients.insert( 1, ClientConnection::new( (100, 30), crate::kitty_graphics::HostCellSize::default(), server.app.state.host_terminal_theme, Some(true), 1, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(1); server.effective_size = (100, 30); server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 220, g: 220, b: 220, }), background: Some(crate::terminal_theme::RgbColor { r: 20, g: 20, b: 20, }), }; server .app .state .terminals .get_mut(&terminal_id) .expect("test terminal should exist") .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), }); server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true)); assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); assert!(server .app .state .terminals .get(&terminal_id) .expect("test terminal should still exist") .pending_agent_resume_plan .is_some()); assert!(server.app.pending_agent_resume_deadline.is_none()); } #[tokio::test] async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_without_foreground_client() { let mut server = test_headless_server(); let workspace = crate::workspace::Workspace::test_new("restored"); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); server.app.state.view.pane_infos = workspace.tabs[0] .layout .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); server.app.state.workspaces = vec![workspace]; server.app.state.active = Some(0); server.app.state.ensure_test_terminals(); server.foreground_client_id = None; server.effective_size = (80, 24); server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 220, g: 220, b: 220, }), background: Some(crate::terminal_theme::RgbColor { r: 20, g: 20, b: 20, }), }; server .app .state .terminals .get_mut(&terminal_id) .expect("test terminal should exist") .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), }); server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false)); assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); assert!(server .app .state .terminals .get(&terminal_id) .expect("test terminal should still exist") .pending_agent_resume_plan .is_some()); assert!(server.app.pending_agent_resume_deadline.is_none()); } #[tokio::test] async fn headless_pre_input_resize_does_not_start_pending_agent_resume() { let mut server = test_headless_server(); let workspace = crate::workspace::Workspace::test_new("restored"); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); server.app.state.view.pane_infos = workspace.tabs[0] .layout .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); server.app.state.workspaces = vec![workspace]; server.app.state.active = Some(0); server.app.state.ensure_test_terminals(); server.clients.insert( 1, ClientConnection::new( (100, 30), crate::kitty_graphics::HostCellSize::default(), server.app.state.host_terminal_theme, Some(true), 1, RenderEncoding::SemanticFrame, None, ), ); server.foreground_client_id = Some(1); server.effective_size = (100, 30); server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 220, g: 220, b: 220, }), background: Some(crate::terminal_theme::RgbColor { r: 20, g: 20, b: 20, }), }; server .app .state .terminals .get_mut(&terminal_id) .expect("test terminal should exist") .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), }); server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); server.resize_shared_runtime_to_effective_size_before_input(); assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); assert!(server .app .state .terminals .get(&terminal_id) .expect("test terminal should still exist") .pending_agent_resume_plan .is_some()); assert!(server.app.pending_agent_resume_deadline.is_none()); } #[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_hides_focused_pane_cursor_during_synchronized_output() { 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 runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"); 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.test_process_pty_bytes(b"\x1b[?2026h\x1b[2;3H"); assert!(runtime.synchronized_output_active()); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, area, false); assert_eq!( cursor, None, "child cursor positions are unstable while synchronized output is active" ); } #[tokio::test] async fn virtual_render_hides_focused_pane_cursor_during_synchronized_output_resize() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"); 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 initial_area = Rect::new(0, 0, 80, 24); let _ = crate::server::render_stream::render_virtual(&mut state, initial_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.test_process_pty_bytes(b"\x1b[?2026h\x1b[2;3H"); assert!(runtime.synchronized_output_active()); let resized_area = Rect::new(0, 0, 100, 30); let (_buffer, cursor) = crate::server::render_stream::render_virtual(&mut state, resized_area, true); assert_eq!( cursor, None, "pre-resize synchronized output should suppress the cursor even if resize clears the mode" ); } #[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 foreground_client_without_host_theme_clears_previous_host_theme() { let mut server = test_headless_server(); let known_theme = crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 0x10, g: 0x20, b: 0x30, }), background: Some(crate::terminal_theme::RgbColor { r: 0x40, g: 0x50, b: 0x60, }), }; server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), known_theme, None, 1, RenderEncoding::SemanticFrame, None, ), ); server.clients.insert( 2, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 2, RenderEncoding::SemanticFrame, None, ), ); assert!(server.promote_client_to_foreground(1)); assert_eq!(server.app.state.host_terminal_theme, known_theme); assert!(server.promote_client_to_foreground(2)); assert_eq!( server.app.state.host_terminal_theme, crate::terminal_theme::TerminalTheme::default() ); } #[test] fn foreground_client_appearance_controls_auto_theme() { let mut server = test_headless_server(); server.app.state.theme_runtime.auto_switch = true; server.app.state.theme_runtime.dark_name = "catppuccin".to_string(); server.app.state.theme_runtime.light_name = "catppuccin-latte".to_string(); server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme { foreground: None, background: Some(crate::terminal_theme::RgbColor { r: 0, g: 0, b: 0 }), }, None, 1, RenderEncoding::SemanticFrame, None, ), ); server.clients.insert( 2, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme { foreground: None, background: Some(crate::terminal_theme::RgbColor { r: 255, g: 255, b: 255, }), }, None, 2, RenderEncoding::SemanticFrame, None, ), ); assert!(server.promote_client_to_foreground(1)); assert_eq!(server.app.state.theme_name, "catppuccin"); assert!(server.promote_client_to_foreground(2)); assert_eq!(server.app.state.theme_name, "catppuccin-latte"); } #[test] fn color_scheme_change_event_is_inert_on_server() { let mut server = test_headless_server(); let initial_theme = crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 0x10, g: 0x20, b: 0x30, }), background: Some(crate::terminal_theme::RgbColor { r: 0x40, g: 0x50, b: 0x60, }), }; server.app.state.host_terminal_theme = initial_theme; server.clients.insert( 1, ClientConnection::new( (120, 40), crate::kitty_graphics::HostCellSize::default(), initial_theme, None, 1, RenderEncoding::SemanticFrame, None, ), ); let changed = server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec(), }); assert!(!changed); assert_eq!(server.foreground_client_id, None); assert_eq!(server.clients[&1].host_terminal_theme, initial_theme); assert_eq!(server.app.state.host_terminal_theme, initial_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 app_client_lone_escape_closes_navigate_mode() { 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::Navigate; server.clients.insert( 1, ClientConnection::new( (80, 24), 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(); assert!(server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b".to_vec(), })); assert_eq!(server.app.state.mode, crate::app::Mode::Terminal); } #[test] fn semantic_client_input_events_route_through_app_input() { let mut server = test_headless_server(); server.app.state.mode = crate::app::Mode::Onboarding; server.clients.insert( 1, ClientConnection::new( (80, 24), 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(); assert!(server.handle_server_event(ServerEvent::ClientInputEvents { client_id: 1, events: vec![crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Enter, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, }], })); assert_eq!(server.app.state.mode, crate::app::Mode::Settings); assert_eq!( server.app.state.settings.section, crate::app::state::SettingsSection::Integrations ); } #[test] fn semantic_client_escape_closes_keybind_help() { let mut server = test_headless_server(); server.app.state.mode = crate::app::Mode::KeybindHelp; server.clients.insert( 1, ClientConnection::new( (100, 30), 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(); server.resize_shared_runtime_to_effective_size(); assert!(server.handle_server_event(ServerEvent::ClientInputEvents { client_id: 1, events: vec![crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, }], })); assert_eq!(server.app.state.mode, crate::app::Mode::Navigate); } #[test] fn semantic_client_down_scrolls_keybind_help() { let mut server = test_headless_server(); server.app.state.mode = crate::app::Mode::KeybindHelp; server.clients.insert( 1, ClientConnection::new( (100, 30), 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(); server.resize_shared_runtime_to_effective_size(); assert!(server.app.state.keybind_help_max_scroll() > 0); assert!(server.handle_server_event(ServerEvent::ClientInputEvents { client_id: 1, events: vec![crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Down, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, }], })); assert_eq!(server.app.state.mode, crate::app::Mode::KeybindHelp); assert_eq!(server.app.state.keybind_help.scroll, 1); } #[tokio::test] async fn split_default_background_response_updates_theme_without_forwarding_tail() { let mut server = test_headless_server(); let mut workspace = crate::workspace::Workspace::test_new("test"); let focused = workspace.focused_pane_id().unwrap(); let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, 1); workspace.tabs[0].runtimes.insert(focused, runtime); server.app.state.workspaces = vec![workspace]; server.app.state.active = Some(0); server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; server.clients.insert( 1, ClientConnection::new( (80, 24), 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 _ = server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b]".to_vec(), }); assert!(rx.try_recv().is_err()); assert!(server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"11;#123456\x07".to_vec(), })); assert!(rx.try_recv().is_err()); assert_eq!( server.clients[&1].host_terminal_theme.background, Some(crate::terminal_theme::RgbColor { r: 0x12, g: 0x34, b: 0x56, }) ); assert_eq!( server.app.state.host_terminal_theme.background, Some(crate::terminal_theme::RgbColor { r: 0x12, g: 0x34, b: 0x56, }) ); } #[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, direct_attach_requested: true, 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:?}"), } } #[tokio::test] async fn outer_focus_gained_client_render_pending_survives_semantic_render_queue_full() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial semantic frame"); let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("serialize dummy message"); server .clients .get(&1) .unwrap() .writer .as_ref() .unwrap() .render .try_send(queued) .expect("pre-fill render queue"); assert!(server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[I".to_vec(), })); assert!(server.clients.get(&1).unwrap().render_pending); 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 )); let runtime = server .app .state .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.test_process_pty_bytes(b"\rZ"); assert!(!server.render_retained_pty_update_and_stream()); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 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::Frame(_) )); } #[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 outer_focus_gained_does_not_mark_semantic_render_pending_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::SemanticFrame, Some(client_tx), ), ); server.foreground_client_id = Some(1); assert!(server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: b"\x1b[I".to_vec(), })); assert!(!server.clients.get(&1).unwrap().render_pending); assert!(!server.app.full_redraw_pending); assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); assert_eq!(server.app.state.outer_terminal_focus, Some(true)); } #[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 .try_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 .try_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" ); } #[tokio::test] async fn retained_pty_update_streams_dirty_row_from_last_frame() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); server.render_and_stream(); let first = read_server_frame( client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial frame"), ); assert!(first.cells.iter().any(|cell| cell.symbol == "a")); let runtime = server .app .state .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.test_process_pty_bytes(b"\rZ"); assert!(server.render_retained_pty_update_and_stream()); let patched = read_server_frame( client_rx .recv_timeout(Duration::from_millis(100)) .expect("retained frame"), ); assert!(patched.cells.iter().any(|cell| cell.symbol == "Z")); assert_eq!((patched.width, patched.height), (80, 24)); } #[tokio::test] async fn retained_pty_update_declines_while_toast_is_visible() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); server.app.state.toast = Some(crate::app::state::ToastNotification { kind: crate::app::state::ToastKind::NeedsAttention, title: "pi needs attention".to_owned(), context: "background · 2".to_owned(), position: None, target: None, }); server.render_and_stream(); let initial = read_server_frame( client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial frame"), ); assert!( frame_text(&initial).contains("pi needs attention"), "expected initial full frame to include toast text" ); let toast_row = server.app.state.view.toast_hit_area.y; let inner_rect = server.app.state.view.pane_infos[0].inner_rect; let pane_row = toast_row .checked_sub(inner_rect.y) .expect("toast should overlap the pane") + 1; assert!(pane_row <= inner_rect.height); let runtime = server .app .state .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.test_process_pty_bytes(format!("\x1b[{pane_row};1Hzzzz").as_bytes()); assert!(!server.render_retained_pty_update_and_stream()); assert!( client_rx.recv_timeout(Duration::from_millis(50)).is_err(), "retained path should not stream a frame that can overwrite toast cells" ); } #[tokio::test] async fn retained_pty_update_matches_full_render_frame() { let initial = b"\x1b[6 qleft \xe4\xb8\xad"; let update = b"\r\x1b[44mZ\x1b[0m"; let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial); let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial); retained_server.render_and_stream(); let _ = retained_rx .recv_timeout(Duration::from_millis(100)) .expect("initial retained baseline"); full_server.render_and_stream(); let _ = full_rx .recv_timeout(Duration::from_millis(100)) .expect("initial full baseline"); retained_server .app .state .runtime_for_pane_in_workspace( &retained_server.app.terminal_runtimes, 0, retained_pane_id, ) .expect("retained runtime") .test_process_pty_bytes(update); full_server .app .state .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id) .expect("full runtime") .test_process_pty_bytes(update); assert!(retained_server.render_retained_pty_update_and_stream()); full_server.render_and_stream(); let retained_frame = read_server_frame( retained_rx .recv_timeout(Duration::from_millis(100)) .expect("retained frame"), ); let full_frame = read_server_frame( full_rx .recv_timeout(Duration::from_millis(100)) .expect("full frame"), ); assert_frame_data_eq(&retained_frame, &full_frame); } #[tokio::test] async fn retained_pty_update_streams_cursor_only_change() { let initial = b"abcd"; let update = b"\x1b[D"; let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial); let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial); retained_server.render_and_stream(); let _ = retained_rx .recv_timeout(Duration::from_millis(100)) .expect("initial retained baseline"); full_server.render_and_stream(); let _ = full_rx .recv_timeout(Duration::from_millis(100)) .expect("initial full baseline"); retained_server .app .state .runtime_for_pane_in_workspace( &retained_server.app.terminal_runtimes, 0, retained_pane_id, ) .expect("retained runtime") .test_process_pty_bytes(update); full_server .app .state .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id) .expect("full runtime") .test_process_pty_bytes(update); assert!(retained_server.render_retained_pty_update_and_stream()); full_server.render_and_stream(); let retained_frame = read_server_frame( retained_rx .recv_timeout(Duration::from_millis(100)) .expect("retained cursor frame"), ); let full_frame = read_server_frame( full_rx .recv_timeout(Duration::from_millis(100)) .expect("full cursor frame"), ); assert_frame_data_eq(&retained_frame, &full_frame); } #[tokio::test] async fn retained_pty_update_declines_unsafe_mode_without_consuming_dirty_rows() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial frame"); let runtime = server .app .state .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.test_process_pty_bytes(b"\rZ"); server.app.state.mode = crate::app::Mode::Navigate; assert!(!server.render_retained_pty_update_and_stream()); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); server.app.state.mode = crate::app::Mode::Terminal; assert!(server.render_retained_pty_update_and_stream()); let patched = read_server_frame( client_rx .recv_timeout(Duration::from_millis(100)) .expect("retained frame after safe mode"), ); assert!(patched.cells.iter().any(|cell| cell.symbol == "Z")); } #[tokio::test] async fn headless_full_render_clears_full_redraw_pending_for_future_retained_updates() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); server.app.full_redraw_pending = true; server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("full redraw frame"); assert!(!server.app.full_redraw_pending); let runtime = server .app .state .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.test_process_pty_bytes(b"\rZ"); assert!(server.render_retained_pty_update_and_stream()); } #[tokio::test] async fn retained_pty_update_declines_when_patch_would_stale_hyperlinks() { let (mut server, client_rx, pane_id) = retained_test_server(b"link"); server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial frame"); let inner_rect = server.app.state.view.pane_infos[0].inner_rect; let client = server.clients.get_mut(&1).unwrap(); let mut frame = client.render_state.last_frame().unwrap().clone(); frame.hyperlinks = vec!["https://example.com".to_owned()]; let hyperlink_idx = usize::from(inner_rect.y) * usize::from(frame.width) + usize::from(inner_rect.x); frame.cells[hyperlink_idx].hyperlink = Some(0); let prepared = client .render_state .prepare_frame(frame) .expect("hyperlink frame differs"); client.render_state.commit_sent_frame(prepared); let runtime = server .app .state .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.test_process_pty_bytes(b"\rplain"); assert!(!server.render_retained_pty_update_and_stream()); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); server.render_and_stream(); let full = read_server_frame( client_rx .recv_timeout(Duration::from_millis(100)) .expect("full frame after hyperlink overwrite"), ); assert!( full.cells.iter().all(|cell| cell.hyperlink.is_none()), "full render should clear overwritten hyperlink cells" ); } #[tokio::test] async fn retained_pty_update_allows_kitty_enabled_empty_graphics_cache() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); server.app.state.kitty_graphics_enabled = true; server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial frame"); let runtime = server .app .state .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.test_process_pty_bytes(b"\rZ"); assert!(server.render_retained_pty_update_and_stream()); let retained = read_server_frame( client_rx .recv_timeout(Duration::from_millis(100)) .expect("retained frame with kitty enabled"), ); assert!(retained.cells.iter().any(|cell| cell.symbol == "Z")); } #[tokio::test] async fn retained_pty_update_declines_when_graphics_cache_has_content() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); server.app.state.kitty_graphics_enabled = true; let client = server.clients.get_mut(&1).unwrap(); client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; server.render_and_stream(); let _ = client_rx .recv_timeout(Duration::from_millis(100)) .expect("initial frame"); server .clients .get_mut(&1) .unwrap() .graphics_cache .test_mark_non_empty(); let runtime = server .app .state .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.test_process_pty_bytes(b"\rZ"); assert!(!server.render_retained_pty_update_and_stream()); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); } #[tokio::test] async fn full_redraw_pending_survives_full_render_queue_full() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("serialize dummy message"); server .clients .get(&1) .unwrap() .writer .as_ref() .unwrap() .render .try_send(queued) .expect("pre-fill render queue"); server.app.full_redraw_pending = true; server.render_and_stream(); assert!(server.app.full_redraw_pending); assert!(server.clients.get(&1).unwrap().render_pending); assert!(matches!( read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), ServerMessage::ReloadSoundConfig )); let runtime = server .app .state .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.test_process_pty_bytes(b"\rZ"); assert!(!server.render_retained_pty_update_and_stream()); assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); } #[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); assert_eq!( server .app .state .copy_feedback .as_ref() .map(|feedback| feedback.message.as_str()), Some("copied to clipboard") ); match read_server_message( foreground_control_rx .recv_timeout(Duration::from_millis(100)) .expect("foreground clipboard message"), ) { ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="), other => panic!("expected clipboard message, got {other:?}"), } assert!( background_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "background client should not receive clipboard writes" ); } #[test] fn clipboard_write_without_foreground_client_does_not_show_feedback() { let mut server = test_headless_server(); server.foreground_client_id = None; let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { content: b"test".to_vec(), }); assert!(changed); assert!( server.app.state.copy_feedback.is_none(), "clipboard feedback should only show when a foreground client can receive the write" ); } #[test] fn clipboard_write_failed_foreground_send_does_not_show_feedback() { let mut server = test_headless_server(); let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); drop(foreground_control_rx); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, RenderEncoding::SemanticFrame, Some(foreground_tx), ), ); server.foreground_client_id = Some(1); let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { content: b"test".to_vec(), }); assert!(changed); assert!( server.app.state.copy_feedback.is_none(), "clipboard feedback should only show after the foreground client receives the write" ); assert!( !server.clients.contains_key(&1), "failed targeted send should remove the broken foreground client" ); } #[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".to_string(), body: Some("workspace 1".to_string()), })); match read_server_message( foreground_control_rx .recv_timeout(Duration::from_millis(100)) .expect("foreground toast message"), ) { ServerMessage::Notify { kind, message, body, } => { assert_eq!(kind, protocol::NotifyKind::Toast); assert_eq!(message, "pi finished"); assert_eq!(body.as_deref(), Some("workspace 1")); } other => panic!("expected toast notify, got {other:?}"), } assert!( background_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "background client should not receive client-local notifications" ); } #[test] fn 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, body, } => { assert_eq!(kind, protocol::NotifyKind::SystemToast); assert_eq!(message, "v9.9.9 available"); assert_eq!( body.as_deref(), Some("detach, run `herdr update`, then follow its restart guidance") ); } other => panic!("expected system toast notify, got {other:?}"), } } #[test] fn notification_show_api_forwards_system_notification_to_foreground_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::SemanticFrame, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; let (respond_to, response_rx) = std::sync::mpsc::channel(); let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { request: api::schema::Request { id: "notify".into(), method: api::schema::Method::NotificationShow( api::schema::NotificationShowParams { title: "build failed".into(), body: Some("api workspace".into()), position: Some(crate::config::ToastHerdrPosition::TopLeft), sound: api::schema::NotificationShowSound::Request, }, ), }, respond_to, }); assert!(changed); let response = response_rx .recv_timeout(Duration::from_millis(100)) .unwrap(); let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( parsed.result, api::schema::ResponseResult::NotificationShow { shown: true, reason: api::schema::NotificationShowReason::Shown, } ); let first = read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("api notification message"), ); let second = read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("api sound message"), ); match first { ServerMessage::Notify { kind, message, body, } => { assert_eq!(kind, protocol::NotifyKind::SystemToast); assert_eq!(message, "build failed"); assert_eq!(body.as_deref(), Some("api workspace")); } other => panic!("expected api notification, got {other:?}"), } match second { ServerMessage::Notify { kind, message, body, } => { assert_eq!(kind, protocol::NotifyKind::Sound); assert_eq!(message, "agent attention"); assert!(body.is_none()); } other => panic!("expected api sound, got {other:?}"), } } #[test] fn notification_show_api_preserves_colon_in_forwarded_title() { let mut server = test_headless_server(); let (client_tx, client_control_rx, _client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), 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 (respond_to, response_rx) = std::sync::mpsc::channel(); let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { request: api::schema::Request { id: "notify".into(), method: api::schema::Method::NotificationShow( api::schema::NotificationShowParams { title: "build: failed".into(), body: Some("api workspace".into()), position: None, sound: api::schema::NotificationShowSound::None, }, ), }, respond_to, }); assert!(changed); let response = response_rx .recv_timeout(Duration::from_millis(100)) .unwrap(); let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( parsed.result, api::schema::ResponseResult::NotificationShow { shown: true, reason: api::schema::NotificationShowReason::Shown, } ); match read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("api notification message"), ) { ServerMessage::Notify { kind, message, body, } => { assert_eq!(kind, protocol::NotifyKind::SystemToast); assert_eq!(message, "build: failed"); assert_eq!(body.as_deref(), Some("api workspace")); } other => panic!("expected api notification, got {other:?}"), } } #[test] fn notification_show_api_validates_empty_title_before_disabled_delivery() { let mut server = test_headless_server(); server.app.state.toast_config.delivery = crate::config::ToastDelivery::Off; let (respond_to, response_rx) = std::sync::mpsc::channel(); let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { request: api::schema::Request { id: "notify".into(), method: api::schema::Method::NotificationShow( api::schema::NotificationShowParams { title: "\n\t".into(), body: None, position: None, sound: api::schema::NotificationShowSound::None, }, ), }, respond_to, }); assert!(changed); let response = response_rx .recv_timeout(Duration::from_millis(100)) .unwrap(); let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(parsed.error.code, "invalid_params"); assert_eq!(parsed.error.message, "notification title is empty"); } #[test] fn notification_show_api_reports_no_foreground_client() { let mut server = test_headless_server(); server.foreground_client_id = None; server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; let (respond_to, response_rx) = std::sync::mpsc::channel(); let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { request: api::schema::Request { id: "notify".into(), method: api::schema::Method::NotificationShow( api::schema::NotificationShowParams { title: "build failed".into(), body: None, position: None, sound: api::schema::NotificationShowSound::Request, }, ), }, respond_to, }); assert!(changed); let response = response_rx .recv_timeout(Duration::from_millis(100)) .unwrap(); let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( parsed.result, api::schema::ResponseResult::NotificationShow { shown: false, reason: api::schema::NotificationShowReason::NoForegroundClient, } ); } #[test] fn notification_show_api_herdr_toast_expires_headless() { let mut server = test_headless_server(); server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let (respond_to, response_rx) = std::sync::mpsc::channel(); assert!( server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { request: api::schema::Request { id: "notify".into(), method: api::schema::Method::NotificationShow( api::schema::NotificationShowParams { title: "build failed".into(), body: None, position: None, sound: api::schema::NotificationShowSound::None, }, ), }, respond_to, }) ); let response = response_rx .recv_timeout(Duration::from_millis(100)) .unwrap(); let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( parsed.result, api::schema::ResponseResult::NotificationShow { shown: true, reason: api::schema::NotificationShowReason::Shown, } ); let deadline = server.app.toast_deadline.expect("api toast deadline"); assert!(server.handle_scheduled_tasks_headless(deadline, false)); assert!(server.app.state.toast.is_none()); assert!(server.app.toast_deadline.is_none()); } #[test] fn notification_show_api_forwards_sound_for_herdr_delivery() { let mut server = test_headless_server(); let (client_tx, client_control_rx, _client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( (80, 24), crate::kitty_graphics::HostCellSize::default(), 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 (respond_to, response_rx) = std::sync::mpsc::channel(); assert!( server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { request: api::schema::Request { id: "notify".into(), method: api::schema::Method::NotificationShow( api::schema::NotificationShowParams { title: "build failed".into(), body: None, position: None, sound: api::schema::NotificationShowSound::Done, }, ), }, respond_to, }) ); let response = response_rx .recv_timeout(Duration::from_millis(100)) .unwrap(); let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( parsed.result, api::schema::ResponseResult::NotificationShow { shown: true, reason: api::schema::NotificationShowReason::Shown, } ); match read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("api sound message"), ) { ServerMessage::Notify { kind, message, body, } => { assert_eq!(kind, protocol::NotifyKind::Sound); assert_eq!(message, "agent done"); assert!(body.is_none()); } other => panic!("expected api sound, got {other:?}"), } } #[test] fn delayed_agent_notification_forwards_after_deadline() { let mut server = test_headless_server(); let background = crate::workspace::Workspace::test_new("background"); let pane_id = background.tabs[0].root_pane; let foreground = crate::workspace::Workspace::test_new("foreground"); server.app.state.workspaces = vec![background, foreground]; server.app.state.ensure_test_terminals(); server.app.state.active = Some(1); server.app.state.selected = 1; server.app.state.mode = crate::app::Mode::Terminal; server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; server.app.state.toast_config.delay_seconds = 1; 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 changed = server.handle_internal_event_with_forwarding(AppEvent::StateChanged { pane_id, agent: Some(crate::detect::Agent::Pi), state: crate::detect::AgentState::Blocked, visible_blocker: false, visible_working: false, process_exited: false, observed_at: Instant::now(), }); assert!(changed); assert!(server.app.state.toast.is_none()); assert!( client_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "delayed transition should not notify immediately" ); let deadline = server .app .state .next_pending_agent_notification_deadline() .expect("pending notification deadline"); assert!(server.handle_scheduled_tasks_headless(deadline, false)); let first = read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("delayed sound message"), ); let second = read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("delayed toast message"), ); assert!(matches!( first, ServerMessage::Notify { kind: protocol::NotifyKind::Sound, .. } )); match second { ServerMessage::Notify { kind, message, body, } => { assert_eq!(kind, protocol::NotifyKind::SystemToast); assert_eq!(message, "pi needs attention"); assert_eq!(body.as_deref(), Some("background · 1")); } other => panic!("expected delayed system toast, got {other:?}"), } assert!(server.app.state.pending_agent_notifications.is_empty()); } #[test] fn delayed_active_tab_unfocused_agent_notification_forwards_after_deadline() { let mut server = test_headless_server(); let workspace = crate::workspace::Workspace::test_new("active"); let pane_id = workspace.tabs[0].root_pane; server.app.state.workspaces = vec![workspace]; server.app.state.ensure_test_terminals(); server.app.state.active = Some(0); server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; server.app.state.toast_config.delay_seconds = 1; 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(), Some(false), 1, RenderEncoding::SemanticFrame, Some(client_tx), ), ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); assert!( server.handle_internal_event_with_forwarding(AppEvent::StateChanged { pane_id, agent: Some(crate::detect::Agent::Pi), state: crate::detect::AgentState::Blocked, visible_blocker: false, visible_working: false, process_exited: false, observed_at: Instant::now(), }) ); assert!(server.app.state.toast.is_none()); assert!( client_control_rx .recv_timeout(Duration::from_millis(50)) .is_err(), "delayed transition should not notify immediately" ); let deadline = server .app .state .next_pending_agent_notification_deadline() .expect("pending notification deadline"); assert!(server.handle_scheduled_tasks_headless(deadline, false)); let first = read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("delayed sound message"), ); let second = read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) .expect("delayed toast message"), ); assert!(matches!( first, ServerMessage::Notify { kind: protocol::NotifyKind::Sound, .. } )); match second { ServerMessage::Notify { kind, message, body, } => { assert_eq!(kind, protocol::NotifyKind::SystemToast); assert_eq!(message, "pi needs attention"); assert_eq!(body.as_deref(), Some("active · 1")); } other => panic!("expected delayed system toast, 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!("{}:p1", background.id); let foreground = crate::workspace::Workspace::test_new("foreground"); server.app.state.workspaces = vec![background, foreground]; server.app.state.ensure_test_terminals(); let terminal_id = server.app.state.workspaces[0] .pane_state(pane_id) .unwrap() .attached_terminal_id .clone(); server .app .state .terminals .get_mut(&terminal_id) .unwrap() .set_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 ") ); } }