diff options
Diffstat (limited to 'src/server/headless.rs')
| -rw-r--r-- | src/server/headless.rs | 10126 |
1 files changed, 860 insertions, 9266 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs index 7b6461b7..6bae0a98 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -19,9 +19,8 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use crossterm::event::{KeyModifiers, MouseEventKind}; use interprocess::local_socket::traits::Listener as _; #[cfg(windows)] use interprocess::local_socket::traits::Stream as _; @@ -29,7 +28,9 @@ use interprocess::local_socket::traits::Stream as _; use interprocess::local_socket::ListenerNonblockingMode; use ratatui::layout::Rect; use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; +#[cfg(windows)] +use tracing::error; +use tracing::{debug, info, warn}; use base64::Engine; use bytes::Bytes; @@ -49,24 +50,41 @@ use crate::protocol::{ use crate::server::client_accept::{ accept_pending_client_connections, reject_pending_client_connections, }; +use crate::server::client_shell::{ + render_pane_surface as render_client_shell_pane_surface, + resize_popup_runtime as resize_client_shell_popup_runtime, snapshot as client_shell_snapshot, +}; use crate::server::client_transport::ServerEvent; use crate::server::clients::{ - events_include_interaction, latest_app_client, render_targets, terminal_stream_client_ids, - ClientConnection, ClientConnectionMode, DeferredRender, + latest_shell_client, render_targets, terminal_stream_client_ids, ClientConnection, + ClientConnectionMode, ClientShellInputTarget, DeferredRender, }; 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::pane_input::{ + apply_client_pane_input_events, apply_client_popup_input_events, apply_terminal_attach_input, + apply_terminal_attach_scroll, terminal_attach_mouse_position, +}; use crate::server::socket_paths::{ client_socket_path, prepare_socket_path, restrict_socket_permissions, }; use crate::server::terminal_attach::paste_payload_for_runtime; +mod bootstrap; +mod lifecycle; +mod notifications; mod pane_graphics; +mod render; +mod retained_surface; + +pub use bootstrap::run_server; +use lifecycle::wait_for_live_handoff_response_write; +#[cfg(unix)] +use lifecycle::wait_for_old_public_sockets_to_close; use crate::protocol::MAX_GRAPHICS_FRAME_SIZE; -use pane_graphics::RetainedGraphicsOutcome; #[cfg(test)] use crate::protocol::RenderEncoding; @@ -75,26 +93,6 @@ use crate::server::client_transport::ClientWriter; #[cfg(test)] use std::fs; -const LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(6); - -fn wait_for_live_handoff_response_write( - response_write_complete: Option<std::sync::mpsc::Receiver<()>>, -) { - let Some(response_write_complete) = response_write_complete else { - return; - }; - - match response_write_complete.recv_timeout(LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT) { - Ok(()) => {} - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - warn!("timed out waiting for live handoff response write; old server exiting"); - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - warn!("live handoff response writer disconnected; old server exiting"); - } - } -} - fn sound_notify_message(sound: crate::sound::Sound) -> &'static str { match sound { crate::sound::Sound::Done => "agent done", @@ -102,14 +100,16 @@ fn sound_notify_message(sound: crate::sound::Sound) -> &'static str { } } -fn notification_show_response_shown(response: &str) -> bool { - let Ok(response) = serde_json::from_str::<api::schema::SuccessResponse>(response) else { - return false; - }; - matches!( - response.result, - api::schema::ResponseResult::NotificationShow { shown: true, .. } - ) +fn notification_show_result( + id: String, + shown: bool, + reason: api::schema::NotificationShowReason, +) -> String { + serde_json::to_string(&api::schema::SuccessResponse { + id, + result: api::schema::ResponseResult::NotificationShow { shown, reason }, + }) + .unwrap_or_else(|_| "{}".to_string()) } fn non_empty_body(value: &str) -> Option<String> { @@ -143,42 +143,6 @@ impl RenderImpact { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PtyRenderState { - Clean, - Hidden, - Visible, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct RetainedRenderInput { - needs_full_render: bool, - needs_graphics_render: bool, - pty: PtyRenderState, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RetainedRenderPlan { - Full, - Graphics, - Pty, - HiddenPty, -} - -fn retained_render_plan(input: RetainedRenderInput) -> RetainedRenderPlan { - if input.needs_full_render { - RetainedRenderPlan::Full - } else if input.needs_graphics_render && input.pty != PtyRenderState::Visible { - RetainedRenderPlan::Graphics - } else { - match input.pty { - PtyRenderState::Visible => RetainedRenderPlan::Pty, - PtyRenderState::Hidden => RetainedRenderPlan::HiddenPty, - PtyRenderState::Clean => RetainedRenderPlan::Full, - } - } -} - fn record_render_impact(source: &'static str, impact: RenderImpact) { let event = match (source, impact) { ("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests", @@ -190,64 +154,6 @@ fn record_render_impact(source: &'static str, impact: RenderImpact) { crate::render_prof::event(event); } -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 // --------------------------------------------------------------------------- @@ -300,6 +206,8 @@ pub struct HeadlessServer { next_client_id: u64, /// The client currently driving the shared pane runtime size, theme, and input keybindings. foreground_client_id: Option<u64>, + /// Process-local identity used to reject shell replacements from an earlier server boot. + client_shell_boot_id: String, /// Outer window title last pushed, paired with the client that received it. /// Keying on the client means a newly attached terminal is written to even /// when the title itself has not changed, without every code path that @@ -342,86 +250,6 @@ pub struct HeadlessServer { server_event_tx: mpsc::Sender<ServerEvent>, } -fn apply_terminal_attach_scroll( - runtime: &crate::terminal::TerminalRuntime, - source: AttachScrollSource, - direction: AttachScrollDirection, - lines: u16, - column: Option<u16>, - row: Option<u16>, - 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 - .plain_page_keys_use_host_scrollback() - .unwrap_or(false); - 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 position = crate::input::mouse::Position::Cell { - column: column.unwrap_or(0), - row: row.unwrap_or(0), - }; - let Some(bytes) = runtime.encode_mouse_wheel( - wheel_kind, - position, - 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<u8>, -) -> Result<(), String> { - runtime.scroll_reset(); - if let Some(text) = crate::raw_input::complete_text_bracketed_paste(&data) { - runtime - .try_send_paste(text.to_owned()) - .map_err(|err| format!("terminal attach paste failed: {err}")) - } else { - 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, @@ -517,6 +345,14 @@ impl HeadlessServer { #[cfg(unix)] next_client_id: 1, foreground_client_id: None, + client_shell_boot_id: format!( + "{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ), sent_window_title: None, api_window_title: None, server_keybindings, @@ -540,8 +376,7 @@ impl HeadlessServer { /// 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: + /// This is the server's main runtime loop. It: /// - Drains internal events (pane death, state changes) /// - Drains API requests (from the JSON socket) /// - Accepts new client connections @@ -556,10 +391,6 @@ impl HeadlessServer { 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; let mut needs_graphics_render = false; @@ -671,12 +502,6 @@ impl HeadlessServer { crate::render_prof::event("full_render_cause.scheduled_tasks"); } - if self.handle_deferred_requests_headless() { - needs_render = true; - needs_full_render = true; - needs_graphics_render = false; - } - self.poll_pending_alt_screen_reads(now); if self.process_deferred_alt_screen_reads() { needs_render = true; @@ -684,14 +509,14 @@ impl HeadlessServer { needs_graphics_render = false; } - if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() { + if latest_shell_client(&self.clients).is_some() && self.app.ensure_default_workspace() { needs_render = true; needs_full_render = true; needs_graphics_render = false; crate::render_prof::event("full_render_cause.default_workspace"); } - if self.app.pane_graphics.retain_live_panes(&self.app.state) { + if self.retain_live_pane_graphics() { needs_render = true; needs_graphics_render = true; } @@ -703,7 +528,7 @@ impl HeadlessServer { self.drain_client_config_reload_request(); self.sync_immediate_pty_sources(); self.stream_host_mouse_capture_mode(); - self.stream_host_keyboard_enhancement_flags(); + self.stream_direct_terminal_keyboard_mode(); // 7. Render virtually and stream frames. Hidden-only PTY work keeps a // bounded classification cadence without delaying presentation work @@ -747,53 +572,22 @@ impl HeadlessServer { needs_render = false; continue; } - 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 pty = if !pty_dirty { - PtyRenderState::Clean - } else if self.pty_sources_visible_to_any_render_target(&render_request.pty_sources) + let hidden_only = pty_dirty + && !needs_full_render + && !needs_graphics_render + && !self.pty_sources_visible_to_any_render_target(&render_request.pty_sources); + if hidden_only { + crate::render_prof::event("render.skipped.hidden_sources"); + } else if !needs_full_render + && !needs_graphics_render + && self.render_retained_pane_surface_and_stream(&render_request.pty_sources) { - PtyRenderState::Visible + crate::render_prof::event("retained_surface.invoke"); } else { - PtyRenderState::Hidden - }; - let mut deferred_graphics = false; - let render_plan = retained_render_plan(RetainedRenderInput { - needs_full_render, - needs_graphics_render, - pty, - }); - let rendered_retained = match render_plan { - RetainedRenderPlan::Full => false, - RetainedRenderPlan::Graphics => { - match self.render_retained_graphics_update_and_stream() { - RetainedGraphicsOutcome::Sent => true, - RetainedGraphicsOutcome::Deferred => { - deferred_graphics = true; - false - } - RetainedGraphicsOutcome::Fallback => false, - } - } - RetainedRenderPlan::Pty => self.render_retained_pty_update_and_stream(), - RetainedRenderPlan::HiddenPty => { - crate::render_prof::event("render.skipped.hidden_sources"); - true - } - }; - if deferred_graphics { - needs_render = false; - continue; - } - if !rendered_retained { crate::render_prof::event("full_render.invoke"); self.render_and_stream(); } - self.app - .record_render_attempt(now, render_plan != RetainedRenderPlan::HiddenPty); + self.app.record_render_attempt(now, !hidden_only); needs_render = false; needs_full_render = false; needs_graphics_render = false; @@ -841,7 +635,10 @@ impl HeadlessServer { LoopEvent::Internal(ev) => { self.handle_internal_event_with_forwarding(ev); } - LoopEvent::ServerEvent(ServerEvent::ClientConnected { writer, .. }) => { + LoopEvent::ServerEvent( + ServerEvent::ClientConnected { writer, .. } + | ServerEvent::ClientShellConnected { writer, .. }, + ) => { if let Ok(message) = Self::frame_server_message(&ServerMessage::ServerShutdown { reason: Some("server is shutting down".to_owned()), @@ -915,7 +712,7 @@ impl HeadlessServer { } // Save session on exit. - if !self.app.no_session { + if self.app.policy.persist_session { self.app.save_session_now(); } @@ -923,175 +720,6 @@ impl HeadlessServer { Ok(()) } - fn handle_deferred_requests_headless(&mut self) -> bool { - let mut needs_render = false; - - if self.app.state.request_complete_onboarding { - self.app.state.request_complete_onboarding = false; - self.app.open_settings_from_onboarding(); - needs_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; - let response = self.headless_workspace_create("headless.workspace.create", None, None); - if let Err(error) = response { - error!( - code = %error.code, - message = %error.message, - "failed to create workspace" - ); - } - needs_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; - let label = self.app.state.requested_new_tab_name.take(); - let response = self.headless_tab_create("headless.tab.create", label); - if let Err(error) = response { - error!( - code = %error.code, - message = %error.message, - "failed to create tab" - ); - } - needs_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; - 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; - crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); - } - - if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() { - let response = self.headless_workspace_create( - "headless.workspace.create_cwd", - Some(cwd.display().to_string()), - None, - ); - if let Err(error) = response { - error!( - code = %error.code, - message = %error.message, - "failed to create workspace at requested cwd" - ); - self.app.state.mode = app::Mode::Navigate; - } - needs_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; - 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; - 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; - 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; - 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; - crate::render_prof::event("full_render_cause.config_reload"); - } - - needs_render - } - - fn headless_workspace_create( - &mut self, - id: &'static str, - cwd: Option<String>, - label: Option<String>, - ) -> Result<(), api::schema::ErrorBody> { - self.dispatch_headless_runtime_mutation( - id, - api::schema::Method::WorkspaceCreate(api::schema::WorkspaceCreateParams { - cwd, - focus: true, - label, - env: Default::default(), - }), - ) - } - - fn headless_tab_create( - &mut self, - id: &'static str, - label: Option<String>, - ) -> Result<(), api::schema::ErrorBody> { - self.dispatch_headless_runtime_mutation( - id, - api::schema::Method::TabCreate(api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label, - env: Default::default(), - }), - ) - } - - fn dispatch_headless_runtime_mutation( - &mut self, - id: &'static str, - method: api::schema::Method, - ) -> Result<(), api::schema::ErrorBody> { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - self.handle_api_request_with_shutdown_check_inner( - api::ApiRequestMessage { - request: api::schema::Request { - id: id.to_string(), - method, - }, - respond_to, - response_write_complete: None, - stream_active: None, - }, - true, - ); - match response_rx.recv_timeout(Duration::from_secs(5)) { - Ok(response) => serde_json::from_str::<api::schema::ErrorResponse>(&response) - .map(|response| Err(response.error)) - .unwrap_or(Ok(())), - Err(err) => Err(api::schema::ErrorBody { - code: "internal_error".into(), - message: format!("headless runtime mutation response failed: {err}"), - }), - } - } - fn allocate_activity_stamp(&mut self) -> u64 { let stamp = self.next_activity_stamp; self.next_activity_stamp = self.next_activity_stamp.saturating_add(1); @@ -1119,14 +747,31 @@ impl HeadlessServer { let Some(client) = self.clients.get(&client_id) else { return; }; + let client_shell = matches!(client.mode, ClientConnectionMode::ClientShell); + let cell_size = client.cell_size; 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() { + if client_shell { + let cell_size = if cell_size.is_known() { + cell_size + } else { + Default::default() + }; + self.app.state.view.terminal_area = area; + let _ = resize_client_shell_popup_runtime(&self.app, area, cell_size); + crate::ui::compute_tab_surface( + &self.app.state, + &self.app.terminal_runtimes, + area, + true, + cell_size, + ); + } else if self.app.state.kitty_graphics_enabled && cell_size.is_known() { crate::ui::compute_view_with_cell_size( &mut self.app.state, &self.app.terminal_runtimes, area, - client.cell_size, + cell_size, ); } else { crate::ui::compute_view_with_runtime_registry( @@ -1158,11 +803,11 @@ impl HeadlessServer { } } - fn sync_headless_view_geometry(&mut self) { + fn sync_runtime_view_geometry(&mut self) { crate::ui::compute_view_without_resizing_panes( &mut self.app.state, &self.app.terminal_runtimes, - Rect::new(0, 0, self.headless_size.0, self.headless_size.1), + Rect::new(0, 0, self.effective_size.0, self.effective_size.1), ); } @@ -1180,7 +825,7 @@ impl HeadlessServer { self.effective_size = self.headless_size; self.app.state.outer_terminal_focus = None; self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default(); - self.sync_headless_view_geometry(); + self.sync_runtime_view_geometry(); let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); self.sync_visible_server_config_diagnostic(false); @@ -1191,7 +836,7 @@ impl HeadlessServer { self.effective_size = self.headless_size; self.app.state.outer_terminal_focus = None; self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default(); - self.sync_headless_view_geometry(); + self.sync_runtime_view_geometry(); let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); self.sync_visible_server_config_diagnostic(false); @@ -1199,7 +844,6 @@ impl HeadlessServer { }; let terminal_size = client.terminal_size; - let outer_terminal_focus = client.outer_terminal_focus; let host_cell_size = if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() { client.cell_size @@ -1209,18 +853,15 @@ impl HeadlessServer { 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(); + let outer_terminal_focus = client.outer_terminal_focus; self.effective_size = terminal_size; + self.sync_runtime_view_geometry(); self.app.state.outer_terminal_focus = outer_terminal_focus; self.app.state.host_cell_size = host_cell_size; - apply_keybindings(&mut self.app, &keybindings); - self.sync_visible_server_config_diagnostic(uses_local_keybindings); + let server_keybindings = self.server_keybindings.clone(); + apply_keybindings(&mut self.app, &server_keybindings); + self.sync_visible_server_config_diagnostic(false); if outer_terminal_focus == Some(true) { self.app.state.mark_active_tab_seen(); } @@ -1231,230 +872,6 @@ impl HeadlessServer { 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, - self.api_window_title.clone(), - ); - let mut import_child = match crate::server::handoff::spawn_handoff_import( - import_exe.as_deref(), - &socket_path, - &token, - ) { - Ok(child) => child, - Err(err) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - }; - let child_pid = import_child.id(); - info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server"); - - let mut fds = Vec::new(); - let duplicate_result = (|| { - for (terminal_id, _) in &handoff_entries { - let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else { - continue; - }; - fds.push(runtime.duplicate_handoff_fd()?); - } - Ok::<(), io::Error>(()) - })(); - if let Err(err) = duplicate_result { - for fd in fds { - let _ = unsafe { libc::close(fd) }; - } - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - - let mut stream = match crate::server::handoff::accept_and_validate_on( - listener, - &socket_path, - &token, - &manifest, - ) { - Ok(stream) => stream, - Err(err) => { - for fd in fds { - let _ = unsafe { libc::close(fd) }; - } - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - }; - - let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds); - for fd in fds { - let _ = unsafe { libc::close(fd) }; - } - if let Err(err) = send_result { - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - - if let Some(api_server) = &self.api_server { - let _ = api_server.remove_socket_file_if_owned(); - } else { - let _ = std::fs::remove_file(crate::api::socket_path()); - } - let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity); - if let Err(err) = crate::server::handoff::wait_ready(&mut stream) { - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - match self.wait_then_restore_public_sockets_after_failed_handoff() { - Ok(()) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - } - Err(restore_err) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(io::Error::other(format!( - "handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}" - ))); - } - } - return Err(io::Error::other(format!( - "handoff replacement server did not become ready: {err}" - ))); - } - if let Err(err) = crate::server::handoff::report_committed(&mut stream) { - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - match self.wait_then_restore_public_sockets_after_failed_handoff() { - Ok(()) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - } - Err(restore_err) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(io::Error::other(format!( - "handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}" - ))); - } - } - return Err(err); - } - - for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() { - if !pane_by_terminal.contains_key(&terminal_id) { - continue; - } - debug!(terminal = %terminal_id, "preserving pane runtime for handoff"); - runtime.preserve_for_handoff(); - } - crate::server::handoff::wait_owned_ack(&mut stream); - - Ok(()) - } - - fn finish_live_handoff_shutdown(&mut self) { - self.shutting_down = true; - self.app.state.should_quit = true; - self.app.no_session = true; - info!("live handoff completed; old server exiting"); - } - - #[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 @@ -1468,68 +885,6 @@ impl HeadlessServer { } } - #[cfg(unix)] - fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { - let api_tx = self - .api_tx - .clone() - .ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?; - let api_server = api::start_server_with_stop_control( - api_tx, - self.app.event_hub.clone(), - self.should_quit.clone(), - )?; - - let client_path = client_socket_path(); - prepare_socket_path(&client_path)?; - let listener = bind_local_listener(&client_path)?; - restrict_socket_permissions(&client_path)?; - let client_socket_identity = socket_file_identity(&client_path)?; - listener.set_nonblocking(ListenerNonblockingMode::Accept)?; - - self.api_server = Some(api_server); - self.client_listener = listener; - self.client_socket_path = client_path; - self.client_socket_identity = client_socket_identity; - Ok(()) - } - - #[cfg(unix)] - fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { - let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2); - wait_for_old_public_sockets_to_close(timeout)?; - self.restore_public_sockets_after_failed_handoff() - } - - #[cfg(unix)] - fn rollback_handoff_before_commit( - &mut self, - socket_path: &Path, - paused_terminal_ids: &[crate::terminal::TerminalId], - ) { - for terminal_id in paused_terminal_ids { - if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { - runtime.set_handoff_reader_paused(false); - } - } - self.handoff_in_progress = false; - let _ = std::fs::remove_file(socket_path); - } - - #[cfg(unix)] - 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); @@ -1572,7 +927,7 @@ impl HeadlessServer { } fn promote_latest_remaining_client(&mut self) -> bool { - let next_foreground = latest_app_client(&self.clients); + let next_foreground = latest_shell_client(&self.clients); let changed = next_foreground != self.foreground_client_id; self.foreground_client_id = next_foreground; self.sync_foreground_client_state(); @@ -1582,7 +937,7 @@ impl HeadlessServer { fn app_client_count(&self) -> usize { self.clients .values() - .filter(|client| client.is_full_app_client() && client.writer.is_some()) + .filter(|client| client.is_shell_client() && client.writer.is_some()) .count() } @@ -1590,7 +945,10 @@ impl HeadlessServer { self.app_client_count() == 1 && self.foreground_client_id.is_some_and(|id| { self.clients.get(&id).is_some_and(|client| { - client.is_full_app_client() && client.writer.is_some() && client.direct_graphics + client.is_shell_client() + && client.writer.is_some() + && client.direct_graphics + && client.pixel_mouse }) }) } @@ -1602,10 +960,9 @@ impl HeadlessServer { fn remove_client(&mut self, client_id: u64) -> bool { self.retire_direct_graphics_for_client(client_id); let was_foreground = self.foreground_client_id == Some(client_id); - self.app.clear_input_source(client_id); - self.send_client_graphics_cleanup(client_id); let removed = self.clients.remove(&client_id); - if let Some(removed) = removed { + if let Some(mut removed) = removed { + self.release_client_shell_inputs(client_id, &mut 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); @@ -1624,6 +981,36 @@ impl HeadlessServer { } } + fn release_client_shell_inputs(&mut self, client_id: u64, client: &mut ClientConnection) { + for held in client.drain_shell_held_inputs() { + let result = match held.target { + ClientShellInputTarget::Pane(pane_id) => { + let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) + else { + continue; + }; + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + continue; + }; + apply_client_pane_input_events(runtime, &[held.release]) + } + ClientShellInputTarget::Popup(terminal_id) => { + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + continue; + }; + apply_client_popup_input_events(runtime, &[held.release]) + } + }; + if let Err(err) = result { + warn!(client_id, err = %err, "client shell teardown release failed"); + } + } + } + fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool { if self.foreground_client_id == Some(client_id) { return true; @@ -1645,77 +1032,6 @@ impl HeadlessServer { } } - 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; - }; - writer.replace_with_cleanup(serialized); - } - - fn send_all_clients_graphics_cleanup(&mut self) { - let client_ids = self.clients.keys().copied().collect::<Vec<_>>(); - 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<()> { @@ -1766,7 +1082,9 @@ impl HeadlessServer { async fn reject_late_client_connections(&mut self) { self.server_event_rx.close(); while let Some(event) = self.server_event_rx.recv().await { - if let ServerEvent::ClientConnected { writer, .. } = event { + if let ServerEvent::ClientConnected { writer, .. } + | ServerEvent::ClientShellConnected { writer, .. } = event + { if let Ok(message) = Self::frame_server_message(&ServerMessage::ServerShutdown { reason: Some("server is shutting down".to_owned()), }) { @@ -1803,47 +1121,142 @@ impl HeadlessServer { .map(|resolved| resolved.terminal_id) } - fn write_client_clipboard_image( - &mut self, + fn client_clipboard_image_target_is_valid( + &self, + client_id: u64, + target: &protocol::ClientClipboardImageTarget, + ) -> bool { + match target { + protocol::ClientClipboardImageTarget::DirectTerminal => { + self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::TerminalAttach { .. }) + }) + } + protocol::ClientClipboardImageTarget::Pane(pane_id) => { + !self.handoff_in_progress + && self.app.state.popup_pane.is_none() + && self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + && self.app.parse_pane_id(pane_id).is_some() + } + protocol::ClientClipboardImageTarget::Popup(terminal_id) => { + !self.handoff_in_progress + && self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + && self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.terminal_id.as_str() == terminal_id) + } + } + } + + fn stage_client_clipboard_image( + &self, client_id: u64, extension: &str, data: &[u8], - ) -> std::io::Result<String> { + ) -> std::io::Result<crate::server::clipboard_image::StagedClipboardImage> { 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) + Ok(staged) } - 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"); + fn paste_client_clipboard_image_path( + &mut self, + client_id: u64, + target: protocol::ClientClipboardImageTarget, + path: String, + ) -> bool { + match target { + protocol::ClientClipboardImageTarget::DirectTerminal => { + let Some(ClientConnection { + mode: ClientConnectionMode::TerminalAttach { terminal_id }, + .. + }) = self.clients.get(&client_id) + else { + return false; + }; + 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"); + } } + true + } + protocol::ClientClipboardImageTarget::Pane(pane_id) => { + if self.handoff_in_progress + || self.app.state.popup_pane.is_some() + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) + else { + return false; + }; + let foreground_changed = self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + return foreground_changed; + }; + if let Err(err) = apply_client_pane_input_events( + runtime, + &[protocol::ClientPaneInputEvent::Paste(path)], + ) { + warn!(client_id, pane_id, err = %err, "client shell clipboard image paste failed"); + } + true + } + protocol::ClientClipboardImageTarget::Popup(terminal_id) => { + if self.handoff_in_progress + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let Some(popup_terminal_id) = self + .app + .state + .popup_pane + .as_ref() + .map(|popup| popup.terminal_id.clone()) + else { + return false; + }; + if popup_terminal_id.as_str() != terminal_id { + return false; + } + let foreground_changed = self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { + return foreground_changed; + }; + if let Err(err) = apply_client_popup_input_events( + runtime, + &[protocol::ClientPaneInputEvent::Paste(path)], + ) { + warn!(client_id, terminal_id, err = %err, "client shell popup clipboard image paste failed"); + } + true } - 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( @@ -1897,7 +1310,6 @@ impl HeadlessServer { 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); @@ -1947,230 +1359,54 @@ impl HeadlessServer { 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<String> { - self.app.state.workspaces.iter().find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - let pane = tab.panes.get(&pane_id)?; - self.app - .state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label()) - .map(str::to_string) - }) - }) - } - - fn forward_pane_state_update_notifications_to_clients( + fn handle_terminal_attach_mouse( &mut self, - update: &crate::app::actions::PaneStateUpdate, - ) { - if self.app.state.toast_config.delay_seconds != 0 { - return; - } - - let is_active_tab = self - .app - .state - .pane_is_in_active_tab(update.ws_idx, update.pane_id); - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - update.previous_state, - update.state, - update.previous_agent_label.as_deref(), - update.agent_label.as_deref(), - ) - { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - } - - if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - return; + client_id: u64, + kind: protocol::ClientMouseKind, + position: protocol::ClientMousePosition, + geometry: Option<protocol::ClientMouseGeometry>, + modifiers: u8, + lines: u16, + ) -> bool { + if self.handoff_in_progress { + return false; } - let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( - suppress_active_tab_notifications, - update, - ) else { - return; + let Some(client) = self.clients.get(&client_id) else { + return false; }; - let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else { - return; + let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else { + return false; }; - let Some(agent_label) = update.agent_label.as_deref() else { - return; + let terminal_id = terminal_id.clone(); + let terminal_size = client.terminal_size; + let cell_size = client.cell_size; + let pixel_mouse = client.pixel_mouse; + let host_sgr_pixels_active = client.host_sgr_pixels_active == Some(true); + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + return false; }; - 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 Some(position) = terminal_attach_mouse_position( + runtime, + terminal_size, + cell_size, + pixel_mouse, + host_sgr_pixels_active, + position, + geometry, + ) else { + return false; }; - 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<String>, - body: Option<String>, - ) -> bool { - self.send_to_foreground_client(ServerMessage::Notify { + let event = protocol::ClientPaneInputEvent::Mouse { kind, - message: message.into(), - body, - }) - } - - fn send_flat_toast_to_foreground_client( - &mut self, - kind: protocol::NotifyKind, - message: impl AsRef<str>, - ) -> bool { - let (title, body) = crate::terminal_notify::split_message(message.as_ref()); - self.send_notify_to_foreground_client(kind, title, body.map(str::to_string)) - } - - 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()); + position, + geometry: None, + modifiers, + lines: lines.max(1), }; - - 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 => {} + if let Err(err) = apply_client_pane_input_events(runtime, &[event]) { + warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach mouse input failed"); } - - 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()) + true } /// Pulls only titles reported dirty by the PTY parser. A focused pane title @@ -2292,367 +1528,6 @@ impl HeadlessServer { .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::TerminalBell { pane_id, count } => { - if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { - debug!( - pane = pane_id.raw(), - count, "dropped terminal bell without a foreground client" - ); - } - false - } - AppEvent::ClipboardWrite { content } => { - // Clipboard writes are client-local side effects. Forward them only to - // the foreground client instead of broadcasting to every attached client. - let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); - if self.send_to_foreground_client(ServerMessage::Clipboard { data }) { - self.app.show_clipboard_feedback(); - } - true - } - AppEvent::PrefixInputSource { active } => { - // Input-source switching is a client-local host side effect; forward it to the - // foreground client (which owns the real TIS switch + run-loop pump), like clipboard. - self.send_to_foreground_client(ServerMessage::PrefixInputSource { - active: *active, - }); - 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(); - let suppress_completion = self - .app - .handle_internal_event_with_pane_updates(ev) - .iter() - .any(|update| update.pane_id == pane_id_val && update.suppress_completion); - - // Forward sound notification to clients when server-side sound policy allows it. - let is_active_tab = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .is_some_and(|ws| { - ws.find_tab_index_for_pane(pane_id_val) - .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) - }); - - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); - - if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && self.app.state.sound.allows(agent_val) - { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - } - - let toast_msg = if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && should_forward_toast_to_clients(self.app.state.toast_config.delivery) - { - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else { - toast_message_from_state_change( - &self.app.state, - &self.app.terminal_runtimes, - pane_id_val, - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - ) - } - } else { - None - }; - - if let Some(msg) = toast_msg { - self.send_flat_toast_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - msg, - ); - } - - true - } - AppEvent::HookStateReported { - pane_id, - agent_label, - .. - } => { - // Hook reports can be stale or no-op after sequence rejection. - // Forward only effective state changes observed after handling. - let toast_before = self.app.state.toast.clone(); - let pane_id_val = *pane_id; - let agent_val = crate::detect::parse_agent_label(agent_label); - - // Capture the previous effective state for this pane. Hook reports - // are already folded into pane.state; raw hook transitions must not - // produce a second notification path. - let prev_state = self.pane_effective_state(pane_id_val); - let prev_agent_label = self.pane_effective_agent_label(pane_id_val); - - self.sync_foreground_client_state(); - let suppress_completion = self - .app - .handle_internal_event_with_pane_updates(ev) - .iter() - .any(|update| update.pane_id == pane_id_val && update.suppress_completion); - - // Forward sound notification based on the effective transition when - // server-side sound policy allows it. - let is_active_tab = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .is_some_and(|ws| { - ws.find_tab_index_for_pane(pane_id_val) - .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) - }); - - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); - - if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && self.app.state.sound.allows(agent_val) - { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - } - - let toast_msg = if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && should_forward_toast_to_clients(self.app.state.toast_config.delivery) - { - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else { - toast_message_from_state_change( - &self.app.state, - &self.app.terminal_runtimes, - pane_id_val, - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - ) - } - } else { - None - }; - - if let Some(msg) = toast_msg { - self.send_flat_toast_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - msg, - ); - } - - true - } - AppEvent::UpdateReady { - version, - install_command, - } => { - let toast_before = self.app.state.toast.clone(); - let version = version.clone(); - let install_command = install_command.clone(); - - self.app.handle_internal_event(ev); - - 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_with_render_impact(ev), - } - } - - /// 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 || self.should_quit.load(Ordering::Acquire) { - 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; @@ -2710,6 +1585,39 @@ impl HeadlessServer { } } + /// Sends an ephemeral semantic event to every connected client-rendered shell. + fn send_to_client_shells(&mut self, msg: ServerMessage) -> bool { + let serialized = match Self::frame_server_message(&msg) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize message for client shells"); + return false; + } + }; + let client_ids = self + .clients + .iter() + .filter_map(|(&client_id, client)| { + matches!(client.mode, ClientConnectionMode::ClientShell).then_some(client_id) + }) + .collect::<Vec<_>>(); + let mut sent = false; + for client_id in client_ids { + let Some(client) = self.clients.get(&client_id) else { + continue; + }; + let Some(writer) = &client.writer else { + continue; + }; + if writer.control.send(serialized.clone()).is_ok() { + sent = true; + } else { + self.remove_client_and_resize_if_needed(client_id); + } + } + sent + } + /// 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 { @@ -2781,7 +1689,6 @@ impl HeadlessServer { fn disconnect_all_clients_for_handoff(&mut self) { let client_ids = self.clients.keys().copied().collect::<Vec<_>>(); for client_id in client_ids { - self.send_client_graphics_cleanup(client_id); self.send_to_client( client_id, ServerMessage::ServerShutdown { @@ -2883,7 +1790,6 @@ impl HeadlessServer { 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); @@ -2907,92 +1813,12 @@ impl HeadlessServer { } 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<crate::raw_input::RawInputEvent>, - ) -> bool { - let source_was_foreground = self.foreground_client_id == Some(client_id); - let source_is_full_app = self - .clients + self.clients .get(&client_id) - .is_some_and(ClientConnection::is_full_app_client); - let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw( - &events, - self.app.state.redraw_on_focus_gained, - ); - let render_neutral_mouse_motion = - events_are_render_neutral_mouse_motion(&events, self.app.state.mode); - if let Some(client) = self.clients.get_mut(&client_id) { - if host_surface_redraw { - client.request_repaint(); - client.defer_full_render(); - } else if !render_neutral_mouse_motion { - // 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(); - } - } - if source_is_full_app { - self.update_client_outer_focus_from_events(client_id, &events); - if events - .iter() - .any(|event| matches!(event, crate::raw_input::RawInputEvent::OuterFocusLost)) - { - // Focus loss is not a teardown, so the pending URL click stays. - self.app.release_input_source_headless(client_id); - } - } - let events = events_for_app_routing(events, source_was_foreground, source_is_full_app); - 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); - // Client-local theme reports were applied above; routing them again would update every - // pane once per palette entry instead of once per captured batch. - self.app.route_client_events_from(client_id, events, false); - 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 && !render_neutral_mouse_motion) - } + .is_some_and(|client| matches!(client.mode, ClientConnectionMode::TerminalPending)) } + /// Handles a server event. Returns true if the event requires a re-render. fn handle_server_event(&mut self, ev: ServerEvent) -> bool { if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) { return false; @@ -3005,11 +1831,55 @@ impl HeadlessServer { rows, cell_width_px, cell_height_px, - keybindings, + pixel_mouse, 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; + } + info!( + client_id, + cols, rows, cell_width_px, cell_height_px, "direct terminal client connected" + ); + let last_activity = self.allocate_activity_stamp(); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + let pixel_mouse = pixel_mouse && observed.is_known(); + let mut connection = ClientConnection::new_with_mode( + ClientConnectionMode::TerminalPending, + (cols, rows), + observed, + last_activity, + protocol::RenderEncoding::TerminalAnsi, + Some(writer), + ); + connection.pixel_mouse = pixel_mouse; + self.clients.insert(client_id, connection); + false + } + ServerEvent::ClientShellConnected { + client_id, + surface_cols, + surface_rows, + cell_width_px, + cell_height_px, + pixel_mouse, direct_graphics, + endpoint_keybindings, + mouse_capture, + writer, } => { if self.handoff_in_progress { if let Ok(message) = @@ -3024,38 +1894,53 @@ impl HeadlessServer { } return false; } - let first_app_client = !direct_attach_requested && self.app_client_count() == 0; info!( client_id, - cols, - rows, + cols = surface_cols, + rows = surface_rows, cell_width_px, cell_height_px, - ?render_encoding, + render_encoding = ?protocol::RenderEncoding::SemanticFrame, "client connected" ); + self.app.ensure_default_workspace(); + let first_app_client = self.app_client_count() == 0; let last_activity = self.allocate_activity_stamp(); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; let mut connection = 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, + ClientConnectionMode::ClientShell, + (surface_cols, surface_rows), + observed, last_activity, - render_encoding, - direct_attach_requested, + protocol::RenderEncoding::SemanticFrame, Some(writer), ); + connection.pixel_mouse = pixel_mouse && observed.is_known(); connection.direct_graphics = direct_graphics; - connection.pixel_mouse = direct_graphics; + connection.shell_uses_endpoint_keybindings = endpoint_keybindings; + connection.shell_mouse_capture = mouse_capture; + connection.shell_projection_revision = 1; + let config_diagnostic = if endpoint_keybindings { + self.server_config_diagnostic.as_deref() + } else { + self.server_config_diagnostic_without_keybindings.as_deref() + }; + let snapshot = client_shell_snapshot( + &self.app, + &self.client_shell_boot_id, + connection.shell_projection_revision, + config_diagnostic, + ); + connection.shell_snapshot = Some(snapshot.clone()); self.clients.insert(client_id, connection); - if !direct_attach_requested { - self.foreground_client_id = Some(client_id); - } + self.send_to_client( + client_id, + ServerMessage::ClientShellSnapshot(Box::new(snapshot)), + ); + self.foreground_client_id = Some(client_id); if first_app_client { self.app.mark_git_status_refresh_due(Instant::now()); } @@ -3099,120 +1984,65 @@ impl HeadlessServer { } => self.handle_terminal_attach_scroll( client_id, source, direction, lines, column, row, modifiers, ), - ServerEvent::ClientInputPixels { + ServerEvent::ClientAttachMouse { client_id, - data, + kind, + position, geometry, - } => { - let coordinates_valid = crate::input::mouse::parse_report(&data) - .and_then(|(x, y)| geometry.cell(x, y)) - .is_some(); - let valid = coordinates_valid - && self.clients.get(&client_id).is_some_and(|client| { - let cell = client.cell_size; - client.is_full_app_client() - && client.host_sgr_pixels_active == Some(true) - && client.terminal_size == (geometry.cols, geometry.rows) - && cell.is_known() - && cell.width_px == geometry.width_px / u32::from(geometry.cols) - && cell.height_px == geometry.height_px / u32::from(geometry.rows) - }); - if !valid || self.handoff_in_progress || !self.focused_pane_graphics_demand() { - return false; - } - let foreground_changed = self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } - self.app - .route_client_pixel_mouse(client_id, &data, geometry) - || foreground_changed - } + modifiers, + lines, + } => self.handle_terminal_attach_mouse( + client_id, kind, position, geometry, modifiers, lines, + ), ServerEvent::ClientInput { client_id, data } => { if self.handoff_in_progress { debug!( client_id, len = data.len(), - "ignored client input during handoff" + "ignored direct terminal input during handoff" ); return false; } - debug!(client_id, len = data.len(), "client input received"); - if let Some(ClientConnection { + 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 { .. }) - ) { + else { 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; + 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); + } } - let events = events - .iter() - .map(crate::protocol::ClientInputEvent::to_raw_input_event) - .collect(); - self.handle_client_input_events(client_id, events) + true } ServerEvent::ClientPasteRejected { client_id, size, max, } => { - self.send_to_client( - client_id, + let detail = format!("Input message is {size} bytes; Herdr's limit is {max} bytes"); + let message = if matches!( + self.clients.get(&client_id).map(|client| &client.mode), + Some(ClientConnectionMode::ClientShell) + ) { + ServerMessage::ClientShellError { + message: format!("Paste rejected: {detail}"), + } + } else { ServerMessage::Notify { kind: protocol::NotifyKind::Toast, message: "Paste rejected".to_owned(), - body: Some(format!( - "Input message is {size} bytes; Herdr's limit is {max} bytes" - )), - }, - ); + body: Some(detail), + } + }; + self.send_to_client(client_id, message); false } ServerEvent::ClientClipboardImage { client_id, + target, extension, data, } => { @@ -3222,14 +2052,28 @@ impl HeadlessServer { extension = %extension, "client clipboard image received" ); - if matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { .. }) - ) { + if !self.client_clipboard_image_target_is_valid(client_id, &target) { return false; } - match self.write_client_clipboard_image(client_id, &extension, &data) { - Ok(path) => self.paste_client_clipboard_image_path(client_id, path), + match self.stage_client_clipboard_image(client_id, &extension, &data) { + Ok(staged) => { + let routed = self.paste_client_clipboard_image_path( + client_id, + target, + staged.paste_text, + ); + if routed { + if let Some(client) = self.clients.get_mut(&client_id) { + client.staged_clipboard_files.push(staged.path); + } else { + crate::server::clipboard_image::remove_files(vec![staged.path]); + return false; + } + } else { + crate::server::clipboard_image::remove_files(vec![staged.path]); + } + routed + } Err(err) => { warn!(client_id, err = %err, "failed to stage client clipboard image"); true @@ -3242,27 +2086,29 @@ impl HeadlessServer { rows, cell_width_px, cell_height_px, + pixel_mouse, } => { info!( client_id, - cols, rows, cell_width_px, cell_height_px, "client resize" + cols, rows, cell_width_px, cell_height_px, pixel_mouse, "client resize" ); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + let pixel_mouse = pixel_mouse && observed.is_known(); let direct_terminal_id = if let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, terminal_size, cell_size, + pixel_mouse: client_pixel_mouse, render_state, .. }) = self.clients.get_mut(&client_id) { *terminal_size = (cols, rows); - let observed = crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }; - if observed.is_known() { - *cell_size = observed; - } + *cell_size = observed; + *client_pixel_mouse = pixel_mouse; render_state.request_repaint(); Some((terminal_id.clone(), *cell_size)) } else { @@ -3275,38 +2121,366 @@ impl HeadlessServer { return true; } if let Some(ClientConnection { - mode: ClientConnectionMode::TerminalObserve { .. }, + mode: + ClientConnectionMode::TerminalObserve { .. } + | ClientConnectionMode::TerminalPending, terminal_size, cell_size, + pixel_mouse: client_pixel_mouse, render_state, .. }) = self.clients.get_mut(&client_id) { *terminal_size = (cols, rows); - let observed = crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }; - if observed.is_known() { - *cell_size = observed; - } + *cell_size = observed; + *client_pixel_mouse = pixel_mouse; render_state.request_repaint(); return true; } - if let Some(client) = self.clients.get_mut(&client_id) { - client.terminal_size = (cols, rows); - let observed = crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }; - if observed.is_known() { - client.cell_size = observed; - } + false + } + ServerEvent::ClientShellResize { + client_id, + surface_cols, + surface_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) { + return false; + } + client.terminal_size = (surface_cols, surface_rows); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + if observed.is_known() { + client.cell_size = observed; } + client.pixel_mouse = pixel_mouse && observed.is_known(); + client.request_repaint(); self.promote_client_to_foreground(client_id); self.resize_shared_runtime_to_effective_size(); true } + ServerEvent::ClientShellHostTheme { client_id, update } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) { + return false; + } + if !client.update_host_theme(&update) { + return false; + } + if self.foreground_client_id != Some(client_id) { + return false; + } + 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 + } + ServerEvent::ClientShellFocus { client_id, focused } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) + || client.outer_terminal_focus == Some(focused) + { + return false; + } + client.outer_terminal_focus = Some(focused); + if focused { + self.promote_client_to_foreground(client_id); + self.resize_shared_runtime_to_effective_size_before_input(); + self.app + .send_outer_focus_event(crate::ghostty::FocusEvent::Gained); + true + } else if self.foreground_client_id == Some(client_id) { + self.app.state.outer_terminal_focus = Some(false); + self.app + .send_outer_focus_event(crate::ghostty::FocusEvent::Lost); + true + } else { + false + } + } + ServerEvent::ClientShellMouseCapture { client_id, enabled } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) + || client.shell_mouse_capture == enabled + { + return false; + } + client.shell_mouse_capture = enabled; + client.host_mouse_capture_active = None; + true + } + ServerEvent::ClientShellPaneInput { + client_id, + pane_id, + events, + } => { + if self.handoff_in_progress + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| { + client.pixel_mouse && client.host_sgr_pixels_active == Some(true) + }); + let mut events = events; + let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) + else { + return false; + }; + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + return false; + }; + super::pane_input::downgrade_ineligible_pixel_mouse( + &mut events, + pixel_mouse, + runtime.current_size(), + runtime.pixel_size(), + ); + if self.app.state.popup_pane.is_some() + || !self + .app + .state + .pane_visible_on_active_surface(workspace_index, runtime_pane_id) + { + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + return false; + }; + let releases = events + .into_iter() + .filter(client_pane_input_releases_press) + .collect::<Vec<_>>(); + if releases.is_empty() { + return false; + } + if let Some(client) = self.clients.get_mut(&client_id) { + client.track_shell_input( + ClientShellInputTarget::Pane(pane_id.clone()), + &releases, + ); + } + let scroll_before = runtime.scroll_metrics(); + if let Err(err) = apply_client_pane_input_events(runtime, &releases) { + warn!(client_id, pane_id, err = %err, "targeted client shell release failed"); + } + return runtime.scroll_metrics() != scroll_before; + } + let interaction = client_pane_input_has_interaction(&events); + if let Some(client) = self.clients.get_mut(&client_id) { + client + .track_shell_input(ClientShellInputTarget::Pane(pane_id.clone()), &events); + } + let foreground_changed = + interaction && self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + return foreground_changed; + }; + let scroll_before = runtime.scroll_metrics(); + if let Err(err) = apply_client_pane_input_events(runtime, &events) { + warn!(client_id, pane_id, err = %err, "targeted client shell input failed"); + } + foreground_changed || runtime.scroll_metrics() != scroll_before + } + ServerEvent::ClientShellPopupInput { + client_id, + terminal_id, + events, + } => { + if self.handoff_in_progress + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| { + client.pixel_mouse && client.host_sgr_pixels_active == Some(true) + }); + let mut events = events; + let Some(popup_terminal_id) = self + .app + .state + .popup_pane + .as_ref() + .map(|popup| popup.terminal_id.clone()) + else { + return false; + }; + if popup_terminal_id.as_str() != terminal_id { + return false; + } + let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { + return false; + }; + super::pane_input::downgrade_ineligible_pixel_mouse( + &mut events, + pixel_mouse, + runtime.current_size(), + runtime.pixel_size(), + ); + let interaction = client_pane_input_has_interaction(&events); + if let Some(client) = self.clients.get_mut(&client_id) { + client.track_shell_input( + ClientShellInputTarget::Popup(terminal_id.clone()), + &events, + ); + } + let foreground_changed = + interaction && self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { + return foreground_changed; + }; + let scroll_before = runtime.scroll_metrics(); + if let Err(err) = apply_client_popup_input_events(runtime, &events) { + warn!(client_id, terminal_id, err = %err, "targeted client popup input failed"); + } + foreground_changed || runtime.scroll_metrics() != scroll_before + } + ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request, + } => { + let Some(client) = self.clients.get(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) { + self.remove_client_and_resize_if_needed(client_id); + return true; + } + let request_id = request.id.clone(); + if !crate::server::client_commands::supports_client_shell_method(&request.method) { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "unsupported_endpoint_command", + "this method is not available through the client shell command lane", + ); + self.send_to_client(client_id, message); + return false; + } + if boot_id != self.client_shell_boot_id { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "stale_boot", + "endpoint command targeted an earlier server boot", + ); + self.send_to_client(client_id, message); + return false; + } + if client.shell_endpoint_command_in_flight { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some("client sent concurrent endpoint commands".into()), + }, + ); + self.remove_client_and_resize_if_needed(client_id); + return true; + } + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + if let Err(err) = crate::server::client_commands::spawn_response_waiter( + client_id, + boot_id.clone(), + request_id.clone(), + response_rx, + self.server_event_tx.clone(), + ) { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "server_unavailable", + format!("failed to start endpoint response bridge: {err}"), + ); + self.send_to_client(client_id, message); + return false; + } + if let Some(client) = self.clients.get_mut(&client_id) { + client.shell_endpoint_command_in_flight = true; + } + let foreground_changed = self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + foreground_changed + | self.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: *request, + respond_to, + response_write_complete: None, + stream_active: None, + }) + } + ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id, + request_id, + final_chunk, + data, + } => { + let valid = self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + && client.shell_endpoint_command_in_flight + && boot_id == self.client_shell_boot_id + }); + if !valid { + return false; + } + if final_chunk { + if let Some(client) = self.clients.get_mut(&client_id) { + client.shell_endpoint_command_in_flight = false; + } + } + self.send_to_client( + client_id, + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk, + data, + }, + ); + false + } ServerEvent::ClientDetach { client_id } => { info!(client_id, "client detached"); self.send_terminal_stream_detach_shutdown(client_id); @@ -3344,6 +2518,8 @@ impl HeadlessServer { !matches!( ev, ServerEvent::ClientConnected { .. } + | ServerEvent::ClientShellConnected { .. } + | ServerEvent::ClientShellEndpointResponseChunkReady { .. } | ServerEvent::ClientDisconnected { .. } | ServerEvent::ClientWriterDrained { .. } | ServerEvent::QuitSignal @@ -3754,6 +2930,14 @@ impl HeadlessServer { .handle_deferred_worktree_api_request(msg.request, msg.respond_to); return changed | deferred_changed; } + if self.foreground_client_id.is_some_and(|client_id| { + self.clients + .get(&client_id) + .is_some_and(|client| matches!(client.mode, ClientConnectionMode::ClientShell)) + }) { + self.app.state.view.terminal_area = + Rect::new(0, 0, self.effective_size.0, self.effective_size.1); + } let mut response = if matches!( &msg.request.method, api::schema::Method::ServerReloadConfig(_) @@ -3861,13 +3045,9 @@ impl HeadlessServer { let Some(pane_after) = pane_after else { continue; }; + let terminal_id = pane_after.attached_terminal_id.clone(); - let Some(terminal_after) = self - .app - .state - .terminals - .get(&pane_after.attached_terminal_id) - else { + let Some(terminal_after) = self.app.state.terminals.get(&terminal_id) else { continue; }; @@ -3891,6 +3071,15 @@ impl HeadlessServer { agent = ?agent, "pane effective state changed during API request, checking notification" ); + self.forward_semantic_agent_transition( + *ws_idx, + *pane_id, + *prev_state, + new_state, + prev_agent_label.as_deref(), + agent_label.as_deref(), + agent, + ); if !forwarded_toast_from_state && self.app.state.toast_config.delay_seconds == 0 @@ -3909,7 +3098,7 @@ impl HeadlessServer { .app .state .terminals - .get(&pane_after.attached_terminal_id) + .get(&terminal_id) .and_then(|terminal| terminal.effective_agent_label()) { let event_text = match kind { @@ -3960,764 +3149,16 @@ impl HeadlessServer { } } - if !skip_default_workspace && latest_app_client(&self.clients).is_some() { + if !skip_default_workspace && latest_shell_client(&self.clients).is_some() { changed |= self.app.ensure_default_workspace(); } changed } - fn focused_pane_graphics_demand(&self) -> bool { - self.app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .and_then(crate::workspace::Workspace::focused_pane_id) - .is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id)) - } - - fn stream_host_mouse_capture_mode(&mut self) { - let enabled = self - .app - .state - .should_capture_host_mouse_from(&self.app.terminal_runtimes); - let pixel_mouse_requested = self - .clients - .values() - .any(|client| client.is_full_app_client() && client.pixel_mouse); - let sgr_pixels = pixel_mouse_requested - && self.focused_pane_graphics_demand() - && self - .app - .state - .active - .and_then(|ws_idx| { - self.app - .state - .workspaces - .get(ws_idx) - .and_then(crate::workspace::Workspace::focused_pane_id) - .and_then(|pane_id| { - self.app.state.runtime_for_pane_in_workspace( - &self.app.terminal_runtimes, - ws_idx, - pane_id, - ) - }) - }) - .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); - let mut broken_clients: Vec<u64> = Vec::new(); - for (&client_id, client) in &mut self.clients { - if !client.is_full_app_client() { - continue; - } - let client_sgr_pixels = sgr_pixels && client.pixel_mouse; - if client.host_mouse_capture_active == Some(enabled) - && client.host_sgr_pixels_active == Some(client_sgr_pixels) - { - continue; - } - let Some(writer) = &client.writer else { - continue; - }; - let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { - enabled, - sgr_pixels: client_sgr_pixels, - }) { - Ok(framed) => framed, - Err(err) => { - warn!(err = %err, "failed to serialize mouse capture mode for client"); - continue; - } - }; - if writer.control.send(serialized).is_err() { - debug!( - client_id, - "client writer channel closed during mouse capture update" - ); - broken_clients.push(client_id); - continue; - } - client.host_mouse_capture_active = Some(enabled); - client.host_sgr_pixels_active = Some(client_sgr_pixels); - } - - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - } - - fn stream_host_keyboard_enhancement_flags(&mut self) { - let report_all_keys = self.app.host_keyboard_report_all_requested(); - let serialized = match Self::frame_server_message(&ServerMessage::KittyKeyboardReportAll { - enabled: report_all_keys, - }) { - Ok(framed) => framed, - Err(err) => { - warn!(err = %err, "failed to serialize keyboard enhancement flags for clients"); - return; - } - }; - - let mut broken_clients = Vec::new(); - for (&client_id, client) in &mut self.clients { - if !client.is_full_app_client() - || client.host_keyboard_report_all_active == Some(report_all_keys) - { - continue; - } - let Some(writer) = &client.writer else { - continue; - }; - if writer.control.send(serialized.clone()).is_err() { - debug!( - client_id, - "client writer channel closed during keyboard enhancement update" - ); - broken_clients.push(client_id); - continue; - } - client.host_keyboard_report_all_active = Some(report_all_keys); - } - - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - } - - fn has_pending_presentation_work( - &self, - needs_full_render: bool, - needs_graphics_render: bool, - ) -> bool { - needs_full_render || needs_graphics_render || self.app.render_dirty.has_immediate_work() - } - - fn sync_immediate_pty_sources(&self) { - let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); - let mut pane_ids = if has_app_target { - self.app.state.app_surface_pane_ids() - } else { - HashSet::new() - }; - if !direct_terminal_targets.is_empty() { - for workspace in &self.app.state.workspaces { - for tab in &workspace.tabs { - pane_ids.extend(tab.panes.iter().filter_map(|(&pane_id, pane)| { - direct_terminal_targets - .contains(pane.attached_terminal_id.as_str()) - .then_some(pane_id) - })); - } - } - if let Some(popup) = &self.app.state.popup_pane { - if direct_terminal_targets.contains(popup.terminal_id.as_str()) { - pane_ids.insert(popup.pane_id); - } - } - } - self.app.render_dirty.set_immediate_pty_sources(pane_ids); - } - - fn pty_render_targets(&self) -> (bool, HashSet<&str>) { - let mut has_app_target = false; - let mut direct_terminal_targets = HashSet::new(); - for client in self - .clients - .values() - .filter(|client| client.writer.is_some()) - { - match &client.mode { - ClientConnectionMode::App if client.is_full_app_client() => { - has_app_target = true; - } - ClientConnectionMode::TerminalAttach { terminal_id } - | ClientConnectionMode::TerminalObserve { terminal_id } => { - direct_terminal_targets.insert(terminal_id.as_str()); - } - ClientConnectionMode::App => {} - } - } - (has_app_target, direct_terminal_targets) - } - - fn pty_source_visible_to_render_targets( - &self, - pane_id: crate::layout::PaneId, - has_app_target: bool, - direct_terminal_targets: &HashSet<&str>, - ) -> bool { - let terminal_id = self.terminal_id_for_pane(pane_id); - (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id))) - || terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str())) - } - - fn pty_sources_visible_to_any_render_target( - &self, - sources: &HashSet<crate::layout::PaneId>, - ) -> bool { - let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); - if !has_app_target && direct_terminal_targets.is_empty() { - return false; - } - - sources.iter().copied().any(|pane_id| { - self.pty_source_visible_to_render_targets( - pane_id, - has_app_target, - &direct_terminal_targets, - ) - }) - } - - fn terminal_id_for_pane( - &self, - pane_id: crate::layout::PaneId, - ) -> Option<&crate::terminal::TerminalId> { - if let Some(popup) = self - .app - .state - .popup_pane - .as_ref() - .filter(|popup| popup.pane_id == pane_id) - { - return Some(&popup.terminal_id); - } - self.app - .find_pane(pane_id) - .map(|(_, pane)| &pane.attached_terminal_id) - } - - fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { - if self - .app - .state - .popup_pane - .as_ref() - .is_some_and(|popup| popup.pane_id == pane_id) - { - return true; - } - let Some(workspace) = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - else { - return false; - }; - let Some(tab) = workspace.active_tab() else { - return false; - }; - if !tab.panes.contains_key(&pane_id) { - return false; - } - !tab.zoomed || tab.layout.focused() == pane_id - } - - 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.deferred_render() != DeferredRender::None { - 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.pane_graphics, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - *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.popup_pane.is_none() - && 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.state.copy_feedback.is_none() - && !self.app.full_redraw_pending - } - - fn send_retained_frame_to_client( - &mut self, - client_id: u64, - frame: FrameData, - broken_clients: &mut Vec<u64>, - ) -> 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.clear_deferred_render(); - 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.clear_deferred_render(); - 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.defer_full_render(); - 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<u64> = 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 render_cell_size = - if self.app.state.kitty_graphics_enabled && cell_size.is_known() { - cell_size - } else { - crate::kitty_graphics::HostCellSize::default() - }; - let preserved_scroll = (!is_foreground).then_some(( - self.app.state.workspace_scroll, - self.app.state.agent_panel_scroll, - self.app.state.tab_scroll, - self.app.state.mobile_switcher_scroll, - )); - let (buffer, cursor) = - crate::server::render_stream::render_virtual_with_runtime_registry( - &mut self.app.state, - &self.app.terminal_runtimes, - area, - is_foreground, - render_cell_size, - ); - if let Some((workspace, agent_panel, tab, mobile_switcher)) = preserved_scroll { - self.app.state.workspace_scroll = workspace; - self.app.state.agent_panel_scroll = agent_panel; - self.app.state.tab_scroll = tab; - self.app.state.mobile_switcher_scroll = mobile_switcher; - } - 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 mut reset_graphics = Vec::new(); - let mut encoded = if is_app_client - && self.app.state.kitty_graphics_enabled - && cell_size.is_known() - { - if client.graphics_surface_reset_pending { - if self.app.pane_graphics.slots.is_empty() { - reset_graphics = next_graphics_cache.clear_bytes(); - } else { - next_graphics_cache = crate::kitty_graphics::HostGraphicsCache::default(); - } - } - let graphics_started = crate::render_prof::timer(); - let encoded = crate::kitty_graphics::encode_local_pane_graphics( - &self.app.state, - &self.app.pane_graphics, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - cell_size, - Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - &mut next_graphics_cache, - ); - crate::render_prof::duration_since("full_render.graphics_encode", graphics_started); - encoded - } else if self.app.pane_graphics.slots.is_empty() { - crate::kitty_graphics::EncodedGraphics { - bytes: next_graphics_cache.clear_bytes(), - incomplete: false, - } - } else { - next_graphics_cache.clear_next() - }; - if !reset_graphics.is_empty() { - reset_graphics.extend(encoded.bytes); - encoded.bytes = reset_graphics; - } - frame.graphics = encoded.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; - encoded.incomplete = false; - } - let has_graphics = !frame.graphics.is_empty(); - let Some(mut prepared) = client.render_state.prepare_frame(frame) else { - if commit_graphics_cache { - client.graphics_cache = next_graphics_cache; - client.graphics_surface_reset_pending = false; - } - if encoded.incomplete { - client.defer_full_render(); - deferred_frame = true; - } else { - client.clear_deferred_render(); - } - crate::render_prof::event("full_render.skip_identical"); - continue; - }; - let max = if has_graphics { - MAX_GRAPHICS_FRAME_SIZE - } else { - crate::protocol::MAX_FRAME_SIZE - }; - let serialized = match Self::frame_server_message_with_max(prepared.message(), max) { - Ok(frame) => frame, - Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => { - warn!( - client_id, - claimed, max, "dropping graphics from oversized frame for client" - ); - let Some(mut text_only_frame) = prepared.into_frame() else { - crate::render_prof::event("full_render.serialize_error"); - continue; - }; - text_only_frame.graphics.clear(); - let Some(text_only_prepared) = - client.render_state.prepare_frame(text_only_frame) - else { - client.clear_deferred_render(); - crate::render_prof::event("full_render.skip_identical_text_only"); - 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"); - continue; - } - }; - prepared = text_only_prepared; - commit_graphics_cache = false; - encoded.incomplete = false; - 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"); - continue; - } - Err(err) => { - warn!(client_id, err = %err, "failed to serialize frame"); - broken_clients.push(client_id); - crate::render_prof::event("full_render.serialize_error"); - continue; - } - }; - match writer.render.try_send(serialized) { - Ok(()) => { - if commit_graphics_cache { - client.graphics_cache = next_graphics_cache; - client.graphics_surface_reset_pending = false; - } - client.render_state.commit_sent_frame(prepared); - if encoded.incomplete { - client.defer_full_render(); - deferred_frame = true; - } else { - client.clear_deferred_render(); - } - crate::render_prof::event("full_render.sent"); - } - Err(std::sync::mpsc::TrySendError::Full(_)) => { - client.defer_full_render(); - deferred_frame = true; - } - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - broken_clients.push(client_id); - } - } - } - - if !broken_clients.is_empty() { - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - } - - let (cols, rows) = self.effective_size; - 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). + /// Similar to the former App scheduler but without terminal resize polling. fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool { let mut changed = false; @@ -4763,27 +3204,6 @@ impl HeadlessServer { } } - 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 - .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); } @@ -4833,132 +3253,25 @@ impl HeadlessServer { } 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. - async fn complete_shutdown(&mut self) -> io::Result<()> { - info!("completing server shutdown"); - self.reject_late_client_connections().await; - - // Send ServerShutdown to all remaining clients. - if !self.clients.is_empty() { - 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)); - } - - // Reject only the requests already queued when shutdown reached cleanup. - self.reject_queued_api_requests_for_shutdown(); - - // Close all client connections. - let staged_files = self - .clients - .drain() - .flat_map(|(_, client)| client.staged_clipboard_files) - .collect::<Vec<_>>(); - crate::server::clipboard_image::remove_files(staged_files); - - // Remove socket files. - self.cleanup_sockets()?; - - Ok(()) - } - - /// Removes socket files created by the server. - 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(()) - } } -// Pane applications render their own motion responses through PTY output. Only Herdr modes with -// hover selection mutate the current frame directly from a plain mouse-move event. -fn events_are_render_neutral_mouse_motion( - events: &[crate::raw_input::RawInputEvent], - mode: crate::app::Mode, -) -> bool { - !events.is_empty() - && !mode.mouse_motion_changes_view() - && events.iter().all(|event| { - matches!( - event, - crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent { - kind: MouseEventKind::Moved, - .. - }) - ) - }) +fn client_pane_input_releases_press(event: &protocol::ClientPaneInputEvent) -> bool { + matches!( + event, + protocol::ClientPaneInputEvent::Key { + kind: protocol::ClientKeyKind::Release, + .. + } | protocol::ClientPaneInputEvent::Mouse { + kind: protocol::ClientMouseKind::Up(_), + .. + } + ) } -fn events_for_app_routing( - events: Vec<crate::raw_input::RawInputEvent>, - mut source_is_foreground: bool, - source_is_full_app: bool, -) -> Vec<crate::raw_input::RawInputEvent> { +fn client_pane_input_has_interaction(events: &[protocol::ClientPaneInputEvent]) -> bool { events - .into_iter() - .filter_map(|event| match event { - crate::raw_input::RawInputEvent::OuterFocusGained - | crate::raw_input::RawInputEvent::OuterFocusLost - if !source_is_full_app => - { - None - } - crate::raw_input::RawInputEvent::OuterFocusGained => { - source_is_foreground = true; - Some(event) - } - crate::raw_input::RawInputEvent::OuterFocusLost if !source_is_foreground => None, - crate::raw_input::RawInputEvent::Key(_) - | crate::raw_input::RawInputEvent::Text(_) - | crate::raw_input::RawInputEvent::Mouse(_) - | crate::raw_input::RawInputEvent::Paste(_) => { - source_is_foreground = true; - Some(event) - } - _ => Some(event), - }) - .collect() + .iter() + .any(|event| !client_pane_input_releases_press(event)) } impl Drop for HeadlessServer { @@ -5028,6736 +3341,17 @@ fn sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> { } fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) { - let without_keybindings = diagnostics - .iter() - .filter(|diagnostic| !is_keybinding_config_diagnostic(diagnostic)) - .cloned() - .collect::<Vec<_>>(); ( config::config_diagnostic_summary(diagnostics), - config::config_diagnostic_summary(&without_keybindings), + config::config_diagnostic_summary_without_keybindings(diagnostics), ) } -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<String> = std::env::args().collect(); - if args.get(2).map(String::as_str) == Some("--handoff-import") { - let socket_path = args - .get(3) - .map(PathBuf::from) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?; - let token = args - .get(4) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?; - return run_handoff_import_server(&socket_path, token); - } - - let loaded_config = config::Config::load(); - let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let event_hub = api::EventHub::default(); - let should_quit = Arc::new(AtomicBool::new(false)); - - // Start the JSON API socket server. - let _api_server = match api::start_server_with_stop_control( - api_tx.clone(), - event_hub.clone(), - should_quit.clone(), - ) { - Ok(server) => server, - Err(err) if err.kind() == io::ErrorKind::AddrInUse => { - eprintln!("error: herdr server is already running"); - eprintln!("api socket: {}", api::socket_path().display()); - std::process::exit(1); - } - Err(err) => return Err(err), - }; - - let 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. - // The prefix input-source switch is likewise forwarded to the foreground - // client (ServerMessage::PrefixInputSource), never applied in-process. - app.state.local_sound_playback = false; - app.local_terminal_notifications = false; - app.local_input_source_switch = false; - - // Create the headless server. - let mut server = match HeadlessServer::new( - app, - &loaded_config.diagnostics, - Some(api_tx.clone()), - Some(_api_server), - should_quit, - ) { - Ok(server) => server, - Err(err) if err.kind() == io::ErrorKind::AddrInUse => { - eprintln!("error: herdr server is already running"); - eprintln!("client socket: {}", client_socket_path().display()); - std::process::exit(1); - } - Err(err) => return Err(err), - }; - - info!( - api_socket = %api::socket_path().display(), - client_socket = %client_socket_path().display(), - "herdr server started" - ); - print_ready_message(&api::socket_path(), &client_socket_path()); - server.app.run_plugin_startup_hooks(); - - server.run().await - }); - - rt.shutdown_timeout(Duration::from_millis(100)); - crate::logging::shutdown("server"); - result -} - -fn seed_startup_workspace_if_empty(app: &mut app::App) { - let Some(cwd) = take_startup_cwd() else { - return; - }; - - if !app.state.workspaces.is_empty() { - info!( - cwd = %cwd.display(), - "restored session already has workspaces; ignoring startup cwd" - ); - return; - } - - match app.create_workspace_with_options(cwd.clone(), true) { - Ok(_) => { - info!(cwd = %cwd.display(), "created startup workspace"); - } - Err(err) => { - warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace"); - app.state.mode = app::Mode::Navigate; - } - } -} - -fn take_startup_cwd() -> Option<PathBuf> { - let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?; - std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR); - (!cwd.is_empty()).then(|| PathBuf::from(cwd)) -} - -#[cfg(unix)] -fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> { - let loaded_config = config::Config::load(); - let mut received = crate::server::handoff::receive(socket_path, token)?; - crate::server::handoff::log_import_result(received.manifest.panes.len()); - - let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let event_hub = api::EventHub::default(); - let should_quit = Arc::new(AtomicBool::new(false)); - - let mut imports = HashMap::new(); - for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { - let pane_id = pane.pane_id; - imports.insert( - pane_id, - crate::handoff_runtime::ImportedHandoffRuntime { - master_fd: fd, - state: pane, - }, - ); - } - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(io::Error::other)?; - - let result = rt.block_on(async { - let 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; - app.local_input_source_switch = 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_with_stop_control( - api_tx.clone(), - event_hub.clone(), - should_quit.clone(), - )?; - let mut server = HeadlessServer::new( - app, - &loaded_config.diagnostics, - Some(api_tx.clone()), - Some(api_server), - should_quit, - )?; - // Carried across before any client attaches, so the first title sent is - // the override rather than the configured one it replaced. - server.api_window_title = received.manifest.api_window_title.take(); - crate::server::handoff::report_ready(&mut received.stream)?; - crate::server::handoff::wait_committed(&mut received.stream)?; - server.app.assume_handoff_ownership(); - server.app.unpause_handoff_readers(); - server.pending_handoff_repaint_nudge = true; - if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) { - warn!(err = %err, "failed to report handoff ownership; continuing as owner"); - } - info!("handoff import server started"); - print_ready_message(&api::socket_path(), &client_socket_path()); - server.app.run_plugin_startup_hooks(); - server.run().await - }); - - rt.shutdown_timeout(Duration::from_millis(100)); - crate::logging::shutdown("server"); - result -} - -#[cfg(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::{CellData, CursorState}; - use unicode_width::UnicodeWidthStr; - - #[path = "pane_graphics.rs"] - mod pane_graphics_tests; - - #[test] - fn retained_render_plan_covers_each_render_path() { - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: true, - needs_graphics_render: true, - pty: PtyRenderState::Hidden, - }), - RetainedRenderPlan::Full - ); - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: true, - pty: PtyRenderState::Hidden, - }), - RetainedRenderPlan::Graphics - ); - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: false, - pty: PtyRenderState::Visible, - }), - RetainedRenderPlan::Pty - ); - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: false, - pty: PtyRenderState::Hidden, - }), - RetainedRenderPlan::HiddenPty - ); - } - - fn test_headless_server() -> HeadlessServer { - test_headless_server_with_event_hub(api::EventHub::default()) - } - - fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServer { - let config = crate::config::Config::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = crate::app::App::new(&config, true, None, api_rx, event_hub); - app.state.local_sound_playback = false; - app.local_terminal_notifications = false; - app.local_input_source_switch = 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); - let should_quit = Arc::new(AtomicBool::new(false)); - #[cfg(windows)] - spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); - let server_keybindings = app_keybindings(&app); - let headless_size = app.state.headless_size; - - HeadlessServer { - app, - #[cfg(unix)] - api_tx: None, - api_server: None, - #[cfg(unix)] - client_listener: listener, - client_socket_path: socket_path, - client_socket_identity, - clients: HashMap::new(), - #[cfg(unix)] - next_client_id: 1, - foreground_client_id: None, - sent_window_title: None, - api_window_title: None, - server_keybindings, - server_config_diagnostic: None, - server_config_diagnostic_without_keybindings: None, - terminal_attach_owners: HashMap::new(), - pending_alt_screen_reads: Vec::new(), - deferred_alt_screen_reads: Vec::new(), - next_activity_stamp: 1, - headless_size, - effective_size: headless_size, - shutting_down: false, - handoff_in_progress: false, - #[cfg(unix)] - pending_handoff_repaint_nudge: false, - should_quit, - server_event_rx, - server_event_tx, - } - } - - fn shutdown_test_runtimes(server: &mut HeadlessServer) { - for (_, runtime) in server.app.terminal_runtimes.drain() { - runtime.shutdown(); - } - } - - fn read_server_message(bytes: Vec<u8>) -> ServerMessage { - let mut cursor = std::io::Cursor::new(bytes); - protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message") - } - - fn read_server_frame(bytes: Vec<u8>) -> FrameData { - match protocol::read_message(&mut std::io::Cursor::new(bytes), MAX_GRAPHICS_FRAME_SIZE) - .expect("decode server frame") - { - 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::<String>() - }) - .collect::<Vec<_>>() - .join("\n") - } - - fn read_server_shutdown_reason(bytes: Vec<u8>) -> Option<String> { - match read_server_message(bytes) { - ServerMessage::ServerShutdown { reason } => reason, - other => panic!("expected shutdown, got {other:?}"), - } - } - - #[test] - fn default_headless_size_is_effective_without_clients() { - let server = test_headless_server(); - - assert_eq!( - server.headless_size, - ( - crate::config::DEFAULT_HEADLESS_COLS, - crate::config::DEFAULT_HEADLESS_ROWS - ) - ); - assert_eq!(server.effective_size, server.headless_size); - } - - #[tokio::test] - async fn headless_api_reads_latest_title_without_spinner_event_flooding() { - let event_hub = api::EventHub::default(); - let mut server = test_headless_server_with_event_hub(event_hub.clone()); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.state.sidebar_agents.rows = vec![vec![ - crate::config::AgentSidebarToken::TerminalTitleStripped, - ]]; - let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; - let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(crate::detect::Agent::Claude); - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); - runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07"); - server - .app - .terminal_runtimes - .insert(terminal_id.clone(), runtime); - server.app.render_dirty.request_terminal_title(pane_id); - - let first = headless_pane_list(&mut server).pop().unwrap(); - assert_eq!(first.terminal_title.as_deref(), Some("⠋ task")); - assert_eq!(first.terminal_title_stripped.as_deref(), Some("task")); - assert_eq!(pane_updated_events(&event_hub), 1); - let (buffer, _) = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut server.app.state, - &server.app.terminal_runtimes, - Rect::new(0, 0, 100, 30), - true, - crate::kitty_graphics::HostCellSize::default(), - ); - let rendered = buffer - .content - .iter() - .map(|cell| cell.symbol()) - .collect::<String>(); - assert!(rendered.contains("task"), "rendered frame: {rendered:?}"); - - server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\"); - server.app.render_dirty.request_terminal_title(pane_id); - let second = headless_pane_list(&mut server).pop().unwrap(); - assert_eq!(second.terminal_title.as_deref(), Some("⠙ task")); - assert_eq!(second.terminal_title_stripped.as_deref(), Some("task")); - assert_eq!(pane_updated_events(&event_hub), 1); - } - - fn headless_pane_list(server: &mut HeadlessServer) -> Vec<api::schema::PaneInfo> { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "list-titles".into(), - method: api::schema::Method::PaneList(api::schema::PaneListParams::default()), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - let response: api::schema::SuccessResponse = - serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); - let api::schema::ResponseResult::PaneList { panes } = response.result else { - panic!("expected pane list"); - }; - panes - } - - fn pane_updated_events(event_hub: &api::EventHub) -> usize { - event_hub - .events_after(0) - .iter() - .filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated) - .count() - } - - #[test] - fn server_stop_interrupts_server_event_backlog() { - let mut server = test_headless_server(); - for client_id in 1..=64 { - server - .server_event_tx - .try_send(ServerEvent::ClientDisconnected { client_id }) - .unwrap(); - } - - server.should_quit.store(true, Ordering::Release); - - assert!(!server.drain_server_events()); - assert!(server.server_event_rx.try_recv().is_ok()); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn headless_api_request_drains_all_pending_internal_events_before_reading_state() { - let mut server = test_headless_server(); - for i in 0..=crate::app::APP_EVENT_DRAIN_LIMIT { - server - .app - .event_tx - .try_send(AppEvent::UpdateReady { - version: format!("4.0.{i}"), - install_command: "herdr install".into(), - }) - .unwrap(); - } - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - assert!( - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "headless_stop_after_events".into(), - method: api::schema::Method::ServerStop(api::schema::EmptyParams::default()), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }) - ); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - - assert_eq!(response["result"]["type"], "ok"); - let expected_version = format!("4.0.{}", crate::app::APP_EVENT_DRAIN_LIMIT); - assert_eq!( - server.app.state.update_available.as_deref(), - Some(expected_version.as_str()) - ); - assert!(server.app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn headless_deferred_workspace_create_uses_runtime_events() { - let event_hub = api::EventHub::default(); - let mut server = test_headless_server_with_event_hub(event_hub.clone()); - - server.app.state.request_new_workspace = true; - - assert!(server.handle_deferred_requests_headless()); - assert!(!server.app.state.request_new_workspace); - assert_eq!( - event_hub - .events_after(0) - .into_iter() - .map(|(_, event)| event.event) - .collect::<Vec<_>>(), - vec![ - api::schema::EventKind::WorkspaceCreated, - api::schema::EventKind::TabCreated, - api::schema::EventKind::PaneCreated, - api::schema::EventKind::LayoutUpdated, - ] - ); - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn headless_deferred_named_tab_create_uses_runtime_events() { - let event_hub = api::EventHub::default(); - let mut server = test_headless_server_with_event_hub(event_hub.clone()); - server - .app - .create_workspace_with_options(std::env::temp_dir(), true) - .unwrap(); - let after_setup = event_hub.current_sequence(); - - server.app.state.request_new_tab = true; - server.app.state.requested_new_tab_name = Some("ops".into()); - - assert!(server.handle_deferred_requests_headless()); - assert!(!server.app.state.request_new_tab); - assert_eq!(server.app.state.requested_new_tab_name, None); - let events = event_hub.events_after(after_setup); - assert_eq!( - events - .iter() - .map(|(_, event)| event.event) - .collect::<Vec<_>>(), - vec![ - api::schema::EventKind::TabCreated, - api::schema::EventKind::PaneCreated, - api::schema::EventKind::LayoutUpdated, - ] - ); - let tab_created = events - .iter() - .find_map(|(_, event)| match &event.data { - api::schema::EventData::TabCreated { tab } => Some(tab), - _ => None, - }) - .expect("tab created event"); - assert_eq!(tab_created.label, "ops"); - shutdown_test_runtimes(&mut server); - } - - fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver<Vec<u8>>) { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - - let (client_tx, control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.promote_client_to_foreground(1); - drain_window_titles(&control_rx); - (server, control_rx) - } - - /// The test client writer drains its queue on a background thread, so - /// reading a pushed message needs a timeout rather than `try_recv`. - fn next_window_title( - control_rx: &std::sync::mpsc::Receiver<Vec<u8>>, - ) -> Option<Option<String>> { - let deadline = Instant::now() + Duration::from_secs(5); - while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { - let Ok(bytes) = control_rx.recv_timeout(remaining) else { - return None; - }; - if let ServerMessage::WindowTitle { title } = read_server_message(bytes) { - return Some(title); - } - } - None - } - - fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) { - while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {} - } - - fn no_window_title(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> bool { - while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) { - if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) { - return false; - } - } - true - } - - #[test] - fn window_title_waits_for_a_foreground_client_to_exist() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; - server.app.state.active = Some(0); - server.app.configure_window_title("{workspace}"); - - // The server renders before the first client attaches. Nothing was - // delivered, so nothing may be recorded as delivered either. - server.sync_window_title(); - assert_eq!(server.sent_window_title, None); - - let (client_tx, control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.promote_client_to_foreground(1); - server.sync_window_title(); - - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd".to_string())) - ); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() { - let (mut server, first_control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - server.sync_window_title(); - assert_eq!( - next_window_title(&first_control_rx), - Some(Some("herd".to_string())) - ); - - // ClientConnected assigns the foreground client directly rather than - // going through promote_client_to_foreground, so the cache must notice - // the new client on its own. - let (client_tx, second_control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(2); - server.sync_window_title(); - - assert_eq!( - next_window_title(&second_control_rx), - Some(Some("herd".to_string())) - ); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn configured_window_title_reaches_the_foreground_client_once_per_change() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}/{tab}"); - - server.sync_window_title(); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd/1".to_string())) - ); - - // An unchanged title must not re-emit an OSC on every render. - server.sync_window_title(); - assert!(no_window_title(&control_rx)); - - server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into()); - server.sync_window_title(); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd/build".to_string())) - ); - - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title("{terminal_title}"); - server.app.state.ensure_test_terminals(); - let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; - let terminal_id = server.app.state.workspaces[0] - .terminal_id(pane_id) - .expect("terminal") - .clone(); - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); - runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes()); - server - .app - .terminal_runtimes - .insert(terminal_id.clone(), runtime); - - assert_eq!( - server.sync_terminal_title_sources(&HashSet::from([pane_id])), - (false, true) - ); - assert_eq!( - next_window_title(&control_rx), - Some(Some("building".to_string())) - ); - - server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes()); - assert_eq!( - server.sync_terminal_title_sources(&HashSet::from([pane_id])), - (false, true) - ); - assert!(no_window_title(&control_rx)); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() { - let (mut server, _control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - - // A detached client keeps its entry but loses its writer, so nothing - // reaches a terminal even though the targeted send reports success. - if let Some(client) = server.clients.get_mut(&1) { - client.writer = None; - } - server.sync_window_title(); - assert!(server.sent_window_title.is_none()); - - // Attaching again has to deliver the title rather than skip it as sent. - let (client_tx, control_rx, _render_rx) = test_client_writer(); - if let Some(client) = server.clients.get_mut(&1) { - client.writer = Some(client_tx); - } - server.sync_window_title(); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd".to_string())) - ); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn empty_window_title_config_leaves_the_outer_title_alone() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title(""); - - server.sync_window_title(); - - assert!(no_window_title(&control_rx)); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn api_window_title_wins_until_it_is_cleared() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - - server.handle_client_window_title_api("set".into(), Some("herdr api".into())); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herdr api".to_string())) - ); - - server.app.state.workspaces[0].custom_name = Some("ops".into()); - server.sync_window_title(); - assert!(no_window_title(&control_rx)); - - // Clearing hands the title back to ui.window_title, not to "herdr". - server.handle_client_window_title_api("clear".into(), None); - assert_eq!( - next_window_title(&control_rx), - Some(Some("ops".to_string())) - ); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title(""); - - server.handle_client_window_title_api("set".into(), Some("herdr api".into())); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herdr api".to_string())) - ); - - server.handle_client_window_title_api("clear".into(), None); - assert_eq!(next_window_title(&control_rx), Some(None)); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn a_newly_promoted_client_gets_the_window_title_again() { - let (mut server, first_control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - server.sync_window_title(); - assert_eq!( - next_window_title(&first_control_rx), - Some(Some("herd".to_string())) - ); - - // A second terminal starts on whatever its shell or ssh left behind. - let (client_tx, second_control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.promote_client_to_foreground(2); - server.sync_window_title(); - - assert_eq!( - next_window_title(&second_control_rx), - Some(Some("herd".to_string())) - ); - shutdown_test_runtimes(&mut server); - } - - fn test_client_writer() -> ( - ClientWriter, - std::sync::mpsc::Receiver<Vec<u8>>, - std::sync::mpsc::Receiver<Vec<u8>>, - ) { - let (control_tx, control_rx) = std::sync::mpsc::channel(); - let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1); - ( - ClientWriter::test_channel(control_tx, render_tx), - control_rx, - render_rx, - ) - } - - fn retained_test_server( - initial_screen: &[u8], - ) -> ( - HeadlessServer, - std::sync::mpsc::Receiver<Vec<u8>>, - crate::layout::PaneId, - ) { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let pane_id = workspace.focused_pane_id().expect("focused pane"); - workspace.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - 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 hidden_pty_visibility_test_server( - client_sizes: &[(u16, u16)], - ) -> (HeadlessServer, crate::layout::PaneId) { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - 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; - - for (index, &terminal_size) in client_sizes.iter().enumerate() { - let client_id = index as u64 + 1; - let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - client_id, - ClientConnection::new( - terminal_size, - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - client_id, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - } - - (server, background_pane) - } - - 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() - { - if cells_equivalent_for_frame_compare( - &actual.cells, - &expected.cells, - usize::from(actual.width), - idx, - actual_cell, - expected_cell, - ) { - continue; - } - assert_eq!( - actual_cell, - expected_cell, - "cell mismatch at index {idx} (x={}, y={})", - idx % usize::from(actual.width), - idx / usize::from(actual.width), - ); - } - } - - fn cells_equivalent_for_frame_compare( - actual_cells: &[CellData], - expected_cells: &[CellData], - width: usize, - idx: usize, - actual: &CellData, - expected: &CellData, - ) -> bool { - if actual == expected { - return true; - } - if !cell_style_without_symbol_eq(actual, expected) { - return false; - } - if !matches!( - (actual.symbol.as_str(), expected.symbol.as_str()), - ("", " ") | (" ", "") - ) { - return false; - } - covered_by_previous_wide_cell(actual_cells, width, idx) - || covered_by_previous_wide_cell(expected_cells, width, idx) - } - - fn cell_style_without_symbol_eq(a: &CellData, b: &CellData) -> bool { - a.fg == b.fg - && a.bg == b.bg - && a.modifier == b.modifier - && a.skip == b.skip - && a.hyperlink == b.hyperlink - } - - fn covered_by_previous_wide_cell(cells: &[CellData], width: usize, idx: usize) -> bool { - if idx == 0 || idx.is_multiple_of(width) { - return false; - } - frame_cell_display_width(&cells[idx - 1]) > 1 - } - - fn frame_cell_display_width(cell: &CellData) -> usize { - if is_halfwidth_katakana_voiced_grapheme(&cell.symbol) { - return 2; - } - cell.symbol.width() - } - - fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool { - let mut chars = symbol.chars(); - let Some(base) = chars.next() else { - return false; - }; - let Some(mark) = chars.next() else { - return false; - }; - chars.next().is_none() - && ('\u{ff66}'..='\u{ff9d}').contains(&base) - && matches!(mark, '\u{ff9e}' | '\u{ff9f}') - } - - #[test] - fn direct_graphics_requires_one_negotiated_app_client() { - let mut server = test_headless_server(); - let (writer_a, _control_a, _render_a) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 10, - cell_height_px: 20, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: true, - writer: writer_a, - })); - assert!(server.clients[&1].direct_graphics); - assert!(server.clients[&1].pixel_mouse); - assert!(server.direct_graphics_available()); - - let (writer_b, _control_b, _render_b) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 10, - cell_height_px: 20, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer: writer_b, - })); - assert!(!server.direct_graphics_available()); - } - - #[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, - direct_graphics: 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, - direct_graphics: 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, - direct_graphics: 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, - direct_graphics: 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, - direct_graphics: 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, - direct_graphics: 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, - direct_graphics: 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, - direct_graphics: false, - 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<Vec<u8>> { - let (writer, control_rx, _render_rx) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id, - cols: 100, - rows: 30, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - control_rx - } - - #[test] - fn explicit_agent_history_read_requires_idle_on_alternate_screen() { - with_terminal_session_test_server( - |server, terminal_id, _terminal_id_string, public_pane_id| { - let terminal = server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("terminal"); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = crate::detect::AgentState::Working; - server.app.terminal_runtimes.insert( - terminal_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - 80, - 24, - b"\x1b[?1049hworking", - ), - ); - let request = api::schema::Request { - id: "read".into(), - method: api::schema::Method::AgentRead(api::schema::AgentReadParams { - target: public_pane_id.clone(), - source: api::schema::ReadSource::Recent, - lines: Some(200), - format: api::schema::ReadFormat::Text, - strip_ansi: true, - }), - }; - - assert_eq!( - server.agent_read_not_idle_error(&request), - Some(api::schema::ErrorBody { - code: "agent_not_idle".into(), - message: format!( - "cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible" - ), - }) - ); - - let mut default_request = request.clone(); - let api::schema::Method::AgentRead(params) = &mut default_request.method else { - unreachable!(); - }; - params.lines = None; - assert_eq!(server.agent_read_not_idle_error(&default_request), None); - - let mut visible_request = request; - let api::schema::Method::AgentRead(params) = &mut visible_request.method else { - unreachable!(); - }; - params.source = api::schema::ReadSource::Visible; - assert_eq!(server.agent_read_not_idle_error(&visible_request), None); - }, - ); - } - - #[test] - fn terminal_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_attach_during_alt_screen_read() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - let (respond_to, _response_rx) = std::sync::mpsc::channel(); - server.pending_alt_screen_reads.push( - crate::server::alt_screen_read::PendingAltScreenRead::start( - terminal_id, - "read".into(), - respond_to, - "fallback".into(), - api::schema::PaneReadResult { - pane_id: "w1:p1".into(), - workspace_id: "w1".into(), - tab_id: "w1:t1".into(), - source: api::schema::ReadSource::Recent, - format: api::schema::ReadFormat::Text, - text: String::new(), - revision: 0, - truncated: false, - }, - 120, - false, - crate::terminal::ScreenSnapshot { - cols: 80, - rows: Vec::new(), - }, - 0, - Instant::now(), - ), - ); - let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); - - assert!( - !server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - assert!(!server.clients.contains_key(&7)); - assert!(!server - .terminal_attach_owners - .contains_key(&terminal_id_string)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!( - reason, - Some(format!( - "terminal attach failed: terminal {terminal_id_string} has a read in progress; retry" - )) - ); - }); - } - - #[test] - fn terminal_control_rejects_second_controller_without_takeover() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - connect_pending_terminal_client(server, 8); - assert!( - !server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 8, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - assert!(server.clients.contains_key(&7)); - assert!(!server.clients.contains_key(&8)); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - }); - } - - #[test] - fn terminal_control_takeover_replaces_existing_controller() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - connect_pending_terminal_client(server, 8); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 8, - target: terminal_id_string.clone(), - takeover: true, - }) - ); - - assert!(!server.clients.contains_key(&7)); - assert!(server.clients.contains_key(&8)); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&8) - ); - }); - } - - #[test] - fn terminal_observe_can_coexist_with_terminal_control() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - connect_pending_terminal_client(server, 8); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 8, - target: terminal_id_string.clone(), - }) - ); - - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - assert!(matches!( - server.clients.get(&8).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { terminal_id }) - if terminal_id == &terminal_id_string - )); - assert_eq!( - terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), - 2 - ); - }); - } - - #[test] - fn terminal_control_detach_sends_shutdown_before_removal() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 })); - - assert!(!server.clients.contains_key(&7)); - assert!(!server - .terminal_attach_owners - .contains_key(&terminal_id_string)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!(reason, Some("detached".to_owned())); - }); - } - - #[test] - fn terminal_observe_rejects_later_attach_upgrade() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 7, - target: terminal_id_string.clone(), - }) - ); - assert!( - !server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: terminal_id_string, - takeover: true, - }) - ); - - assert!(!server.clients.contains_key(&7)); - assert!(server.terminal_attach_owners.is_empty()); - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - }); - } - - #[test] - fn terminal_attach_rejects_later_observe_and_clears_ownership() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: terminal_id_string.clone(), - takeover: false, - }) - ); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - assert!(server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - - assert!( - !server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 7, - target: terminal_id_string.clone(), - }) - ); - - assert!(!server.clients.contains_key(&7)); - assert!(server.terminal_attach_owners.is_empty()); - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - }); - } - - 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, - direct_graphics: 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, - direct_graphics: false, - 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, - direct_graphics: 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 unchanged_git_refresh_does_not_request_headless_render() { - let mut server = test_headless_server(); - server.app.git_refresh_in_flight = true; - let mut workspace = crate::workspace::Workspace::test_new("one"); - let workspace_id = workspace.id.clone(); - let cwd = workspace.identity_cwd.clone(); - workspace.cached_auto_label = "cached".into(); - workspace.cached_git_status_key = cwd.clone(); - workspace.cached_git_branch = None; - server.app.state.workspaces.push(workspace); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { - results: vec![crate::workspace::WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: cwd.clone(), - status_cache_key: cwd, - demand: crate::workspace::GitStatusRefreshDemand::ALL, - auto_label: "cached".into(), - branch: None, - ahead_behind: None, - space: None, - }], - cache_updates: Vec::new(), - }); - - assert!(!changed); - assert!(!server.app.git_refresh_in_flight); - } - - #[test] - fn changed_git_refresh_requests_headless_render() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("one"); - let workspace_id = workspace.id.clone(); - let cwd = workspace.identity_cwd.clone(); - server.app.state.workspaces.push(workspace); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { - results: vec![crate::workspace::WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: cwd.clone(), - status_cache_key: cwd, - demand: crate::workspace::GitStatusRefreshDemand::ALL, - auto_label: "one".into(), - branch: Some("changed".into()), - ahead_behind: None, - space: None, - }], - cache_updates: Vec::new(), - }); - - assert!(changed); - } - - #[test] - fn terminal_attach_client_exits_when_attached_pane_dies() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("attached"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - let terminal_id = server.app.state.workspaces[0] - .pane_state(pane_id) - .expect("pane") - .attached_terminal_id - .to_string(); - let (writer, control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - assert!( - server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: terminal_id.clone(), - takeover: false, - }) - ); - assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7)); - - assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); - - assert!(!server.clients.contains_key(&7)); - assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); - } - - #[test] - fn terminal_attach_scroll_moves_attached_runtime_viewport() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); - - apply_terminal_attach_scroll( - &runtime, - AttachScrollSource::Wheel, - AttachScrollDirection::Up, - 3, - None, - None, - 0, - ) - .expect("scroll up"); - let metrics = runtime.scroll_metrics().expect("scroll metrics"); - assert_eq!(metrics.offset_from_bottom, 3); - - apply_terminal_attach_scroll( - &runtime, - AttachScrollSource::Wheel, - AttachScrollDirection::Down, - 2, - None, - None, - 0, - ) - .expect("scroll down"); - let metrics = runtime.scroll_metrics().expect("scroll metrics"); - assert_eq!(metrics.offset_from_bottom, 1); - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - #[test] - fn terminal_attach_input_resets_scrolled_viewport() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 20, 5, 4096, &bytes, 4, - ); - - runtime.scroll_up(4); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 4 - ); - - apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded input"), - Bytes::from("x") - ); - - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - fn with_terminal_attach_runtime( - initial_bytes: &[u8], - initial_scroll: usize, - test: impl FnOnce(&crate::terminal::TerminalRuntime, &mut mpsc::Receiver<Bytes>), - ) { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = initial_bytes.to_vec(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 20, 5, 4096, &bytes, 4, - ); - if initial_scroll > 0 { - runtime.scroll_up(initial_scroll); - } - - test(&runtime, &mut input_rx); - - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - fn apply_terminal_attach_page_up(runtime: &crate::terminal::TerminalRuntime) { - apply_terminal_attach_scroll( - runtime, - AttachScrollSource::PageKey { - input: b"\x1b[5~".to_vec(), - }, - AttachScrollDirection::Up, - 4, - None, - None, - 0, - ) - .expect("page key"); - } - - #[test] - fn terminal_attach_paste_uses_plain_text_when_runtime_did_not_enable_brackets() { - with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { - apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) - .expect("attach paste"); - - assert_eq!( - input_rx.try_recv().expect("forwarded paste"), - Bytes::from_static(b"line one\nline two") - ); - }); - } - - #[test] - fn terminal_attach_paste_preserves_brackets_when_runtime_enabled_them() { - with_terminal_attach_runtime(b"\x1b[?2004h", 0, |runtime, input_rx| { - apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) - .expect("attach paste"); - - assert_eq!( - input_rx.try_recv().expect("forwarded paste"), - Bytes::from_static(b"\x1b[200~line one\nline two\x1b[201~") - ); - }); - } - - #[test] - fn terminal_attach_page_key_host_scrolls_plain_terminal() { - with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 4 - ); - assert!(input_rx.try_recv().is_err()); - }); - } - - #[test] - fn terminal_attach_page_key_forwards_when_mouse_reporting() { - with_terminal_attach_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded page key"), - Bytes::from_static(b"\x1b[5~") - ); - }); - } - - #[test] - fn terminal_attach_page_key_forwards_when_application_cursor() { - with_terminal_attach_runtime(b"\x1b[?1h", 3, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded page key"), - Bytes::from_static(b"\x1b[5~") - ); - }); - } - - #[test] - fn terminal_attach_page_key_host_scrolls_shell_like_decckm_with_bracketed_paste() { - with_terminal_attach_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 4 - ); - assert!(input_rx.try_recv().is_err()); - }); - } - - #[test] - fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() { - with_terminal_attach_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded page key"), - Bytes::from_static(b"\x1b[5~") - ); - }); - } - - #[test] - fn headless_scheduled_tasks_expire_agent_metadata() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("metadata"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::HookStateReported { - pane_id, - source: "custom:pi".into(), - agent_label: "pi".into(), - state: crate::detect::AgentState::Working, - message: None, - seq: None, - session_ref: None, - }) - ); - assert!( - server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported { - pane_id, - source: "user:pi-display".into(), - agent_label: Some("pi".into()), - applies_to_source: Some("custom:pi".into()), - title: Some("short lived".into()), - display_agent: None, - state_labels: HashMap::new(), - clear_title: false, - clear_display_agent: false, - clear_state_labels: false, - seq: None, - // Expiry is advanced with the captured deadline below; keep the - // pre-expiry assertion independent of wall-clock scheduling. - ttl: Some(Duration::from_secs(60)), - }) - ); - - let deadline = server - .app - .agent_metadata_deadline - .expect("metadata deadline"); - let terminal_id = server.app.state.workspaces[0] - .pane_state(pane_id) - .expect("pane") - .attached_terminal_id - .clone(); - assert_eq!( - server - .app - .state - .terminals - .get(&terminal_id) - .expect("terminal") - .effective_title() - .as_deref(), - Some("short lived") - ); - - assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); - - assert_eq!(server.app.agent_metadata_deadline, None); - assert_eq!( - server - .app - .state - .terminals - .get(&terminal_id) - .expect("terminal") - .effective_title(), - None - ); - assert!(server - .app - .event_hub - .events_after(0) - .iter() - .any(|(_, event)| { - event.event == crate::api::schema::EventKind::PaneAgentStatusChanged - && matches!( - &event.data, - crate::api::schema::EventData::PaneAgentStatusChanged { - title, - .. - } if title.is_none() - ) - })); - } - - #[test] - fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() { - let mut server = test_headless_server(); - let now = Instant::now(); - server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1)); - - assert!(!server.handle_scheduled_tasks_headless(now, false)); - assert_eq!(server.app.next_agent_manifest_update_check, None); - } - - #[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, - }), - ..Default::default() - }; - 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()); - } - - #[cfg(unix)] - #[tokio::test] - async fn headless_scheduled_tasks_start_pending_agent_resume_without_foreground_client() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("restored"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.ensure_test_terminals(); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - - server.render_and_stream(); - assert_ne!(server.app.state.view.terminal_area, Rect::default()); - - let now = Instant::now(); - assert!(!server.handle_scheduled_tasks_headless(now, false)); - assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); - let deadline = server - .app - .pending_agent_resume_deadline - .expect("clientless resume should wait briefly for a host theme"); - - assert!(server.handle_scheduled_tasks_headless(deadline, false)); - assert!(server.app.terminal_runtimes.get(&terminal_id).is_some()); - assert!(server - .app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_none()); - shutdown_test_runtimes(&mut server); - } - - #[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, - }), - ..Default::default() - }; - 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, - }), - ..Default::default() - }, - 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, - }), - ..Default::default() - }, - 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, - }), - ..Default::default() - }; - 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 }), - ..Default::default() - }, - 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, - }), - ..Default::default() - }, - 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, - }), - ..Default::default() - }; - 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)); - } - - #[tokio::test] - async fn foreground_focus_gained_reaches_pane_with_focus_reporting() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - - server.clients.insert(1, test_app_client(Some(false), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - assert_eq!( - input_rx.try_recv().expect("forwarded focus gained report"), - Bytes::from_static(b"\x1b[I") - ); - - assert!(!server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[O".to_vec(), - })); - assert_eq!( - input_rx.try_recv().expect("forwarded focus lost report"), - Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn outer_focus_events_do_not_reach_pane_without_focus_reporting() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b""); - server.clients.insert(1, test_app_client(Some(false), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - assert!(matches!( - input_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - )); - } - - #[tokio::test] - async fn background_focus_batch_only_forwards_events_after_promotion() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(false), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 2, - data: b"\x1b[O\x1b[I".to_vec(), - })); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - assert_eq!( - input_rx - .try_recv() - .expect("focus gained after client promotion"), - Bytes::from_static(b"\x1b[I") - ); - assert!(matches!( - input_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - )); - } - - #[tokio::test] - async fn background_client_focus_loss_releases_its_owned_keys() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>15u"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(true), 2)); - 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::Char('j'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - assert!(!server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::FocusLost], - })); - assert_eq!( - input_rx.try_recv().expect("forwarded press"), - Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - input_rx - .try_recv() - .expect("synthetic release from background client"), - Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(server.app.input_leases.is_empty()); - } - - #[tokio::test] - async fn structured_outer_focus_events_reach_reporting_pane() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - 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::FocusGained, - crate::protocol::ClientInputEvent::FocusLost, - ], - })); - assert_eq!( - input_rx.try_recv().expect("structured focus gained report"), - Bytes::from_static(b"\x1b[I") - ); - assert_eq!( - input_rx.try_recv().expect("structured focus lost report"), - Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn background_key_makes_later_focus_lost_eligible() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(true), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 2, - events: vec![ - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Release, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }, - crate::protocol::ClientInputEvent::FocusLost, - ], - })); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!( - input_rx.try_recv().expect("focus lost after promotion"), - Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn structured_non_app_focus_is_ignored_without_suppressing_keys() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - - let mut attached = test_app_client(Some(false), 2); - attached.mode = ClientConnectionMode::TerminalAttach { - terminal_id: "attached".to_owned(), - }; - server.clients.insert(2, attached); - - let mut pending = test_app_client(Some(false), 3); - pending.pending_terminal_attach = true; - server.clients.insert(3, pending); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - for client_id in [2, 3] { - assert!(!server.handle_server_event(ServerEvent::ClientInputEvents { - client_id, - events: vec![crate::protocol::ClientInputEvent::FocusGained], - })); - assert_eq!(server.foreground_client_id, Some(1)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - assert_eq!(server.clients[&client_id].outer_terminal_focus, Some(false)); - } - - assert!(matches!( - input_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - )); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 3, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Release, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - assert_eq!(server.foreground_client_id, Some(3)); - } - - #[test] - fn terminal_attach_resize_preserves_known_cell_size_when_pixels_are_omitted() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id, _pane_id| { - let mut client = ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ); - client.mode = ClientConnectionMode::TerminalAttach { - terminal_id: terminal_id.clone(), - }; - server.clients.insert(1, client); - - assert!(server.handle_server_event(ServerEvent::ClientResize { - client_id: 1, - cols: 100, - rows: 30, - cell_width_px: 0, - cell_height_px: 0, - })); - - assert_eq!( - server - .runtime_for_terminal_id_string(&terminal_id) - .unwrap() - .pixel_size(), - Some((1_000, 600)) - ); - assert_eq!( - server.clients[&1].cell_size, - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - } - ); - }); - } - - #[tokio::test] - async fn passive_mouse_motion_forwards_without_requesting_render() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - let baseline = FrameData { - cells: Vec::new(), - width: 0, - height: 0, - cursor: None, - hyperlinks: Vec::new(), - graphics: Vec::new(), - }; - let client = server.clients.get_mut(&1).unwrap(); - let prepared = client - .render_state - .prepare_frame(baseline.clone()) - .expect("new semantic baseline"); - client.render_state.commit_sent_frame(prepared); - let pane = server.app.state.view.pane_infos[0].clone(); - let column = pane.inner_rect.x + 2; - let row = pane.inner_rect.y + 3; - let input = format!("\x1b[<35;{};{}M", column + 1, row + 1).into_bytes(); - - assert!(!server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: input, - })); - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert_eq!( - server.clients[&1].render_state.last_frame(), - Some(&baseline) - ); - } - - #[test] - fn background_mouse_motion_promotes_once_then_becomes_render_neutral() { - let mut server = test_headless_server(); - server.app.state.mode = crate::app::Mode::Terminal; - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(true), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - let motion = || ServerEvent::ClientInputEvents { - client_id: 2, - events: vec![crate::protocol::ClientInputEvent::Mouse { - kind: crate::protocol::ClientMouseKind::Moved, - column: 10, - row: 5, - modifiers: 0, - }], - }; - - assert!(server.handle_server_event(motion())); - assert_eq!(server.foreground_client_id, Some(2)); - assert!(!server.handle_server_event(motion())); - } - - #[test] - fn mouse_motion_in_hover_modes_requires_render() { - let events = [crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: MouseEventKind::Moved, - column: 10, - row: 5, - modifiers: KeyModifiers::empty(), - }, - )]; - - assert!(events_are_render_neutral_mouse_motion( - &events, - crate::app::Mode::Terminal - )); - for mode in [ - crate::app::Mode::GlobalMenu, - crate::app::Mode::ContextMenu, - crate::app::Mode::Navigator, - ] { - assert!(!events_are_render_neutral_mouse_motion(&events, mode)); - } - } - - fn install_focused_test_runtime( - server: &mut HeadlessServer, - terminal_bytes: &[u8], - ) -> tokio::sync::mpsc::Receiver<Bytes> { - let mut workspace = crate::workspace::Workspace::test_new("focus-reporting"); - let pane_id = workspace.tabs[0].root_pane; - let (runtime, input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - terminal_bytes, - 4, - ); - workspace.insert_test_runtime(pane_id, runtime); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - input_rx - } - - fn test_app_client(outer_terminal_focus: Option<bool>, last_activity: u64) -> ClientConnection { - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - outer_terminal_focus, - last_activity, - RenderEncoding::SemanticFrame, - None, - ) - } - - #[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, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - - 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, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - - 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, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - - 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, - }) - ); - } - - #[tokio::test] - async fn render_and_stream_uses_each_client_terminal_size() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let active_pane = workspace.tabs[0].root_pane; - let background_tab = workspace.test_add_tab(Some("background")); - 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"active"), - ); - workspace.tabs[background_tab].runtimes.insert( - background_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"background"), - ); - 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 (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer(); - let (mobile_tx, _mobile_control_rx, mobile_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( - (44, 20), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(mobile_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 mobile_frame = read_server_frame(mobile_rx.recv().expect("mobile frame")); - - assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40)); - assert_eq!((mobile_frame.width, mobile_frame.height), (44, 20)); - let mobile_text = frame_text(&mobile_frame); - let mut mobile_rows = mobile_text.lines(); - let mobile_header = mobile_rows.by_ref().take(2).collect::<String>(); - let mobile_surface = mobile_rows.collect::<String>(); - assert!(mobile_header.contains("test"), "header: {mobile_header:?}"); - assert!( - mobile_surface.contains("active"), - "surface: {mobile_surface:?}" - ); - assert!(!mobile_surface.contains("background")); - - let foreground_terminal_area = Rect::new(26, 1, 94, 39); - let expected_pane_size = ( - foreground_terminal_area.height, - foreground_terminal_area.width.saturating_sub(1), - ); - assert_eq!( - server.app.state.view.layout, - crate::app::state::ViewLayout::Desktop - ); - assert_eq!(server.app.state.view.mobile_header_rect, Rect::default()); - assert_eq!( - server.app.state.view.terminal_area, - foreground_terminal_area - ); - assert_eq!( - server.app.state.workspaces[0].tabs[0].runtimes[&active_pane].current_size(), - expected_pane_size - ); - assert_eq!( - server.app.state.workspaces[0].tabs[background_tab].runtimes[&background_pane] - .current_size(), - expected_pane_size - ); - } - - #[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, - direct_graphics: false, - 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 render_and_stream_sends_large_terminal_frame_for_terminal_ansi_client() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (278, 85), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - server.render_and_stream(); - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial terminal frame"), - ) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 1); - assert_eq!((frame.width, frame.height), (278, 85)); - assert!(frame.full); - } - other => panic!("expected terminal frame, got {other:?}"), - } - - assert!(server.handle_server_event(ServerEvent::ClientResize { - client_id: 1, - cols: 710, - rows: 202, - cell_width_px: 0, - cell_height_px: 0, - })); - server.render_and_stream(); - - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("large terminal frame"), - ) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 2); - assert_eq!((frame.width, frame.height), (710, 202)); - assert!(frame.full); - assert!(!frame.bytes.is_empty()); - } - other => panic!("expected terminal frame, got {other:?}"), - } - - server.app.state.mode = crate::app::Mode::Navigate; - server.render_and_stream(); - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("follow-up terminal frame"), - ) { - ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 3), - other => panic!("expected terminal frame, got {other:?}"), - } - } - - #[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_repaints_terminal_ansi_without_clearing() { - 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); - assert!(!frame.bytes.windows(4).any(|bytes| bytes == b"\x1b[2J")); - } - 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[&1] - .writer - .as_ref() - .unwrap() - .test_fill_render(queued); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - - server.render_and_stream(); - - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - 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_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::None - ); - 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_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::None - ); - 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.test_fill_render(queued); - - 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.test_fill_render(queued); - - 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().deferred_render(), - DeferredRender::Full - ); - 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_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::None - ); - } - - #[test] - fn render_and_stream_skips_identical_frame_sends() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - server.render_and_stream(); - let first = client_rx.recv_timeout(Duration::from_millis(100)); - assert!(first.is_ok(), "expected first frame to be sent"); - - server.render_and_stream(); - assert!( - client_rx.recv_timeout(Duration::from_millis(50)).is_err(), - "identical frame should not be sent twice" - ); - } - - #[test] - fn visible_source_wakes_pending_hidden_work() { - let (server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]); - let visible_pane = server.app.state.workspaces[0].tabs[0].root_pane; - server.sync_immediate_pty_sources(); - - assert!(server.app.render_dirty.request_pty(background_pane)); - assert!(!server.has_pending_presentation_work(false, false)); - assert!(server.app.render_dirty.request_pty(visible_pane)); - assert!(server.has_pending_presentation_work(false, false)); - } - - #[test] - fn inactive_tab_pty_source_is_hidden_until_tab_focus() { - let (server, background_pane) = hidden_pty_visibility_test_server(&[]); - let sources = HashSet::from([background_pane]); - assert!(!server.pty_sources_visible_to_any_render_target(&sources)); - - let (mut server, background_pane) = - hidden_pty_visibility_test_server(&[(120, 40), (44, 20)]); - let sources = HashSet::from([background_pane]); - assert!(!server.pty_sources_visible_to_any_render_target(&sources)); - - server.app.state.workspaces[0].switch_tab(1); - assert!(server.pty_sources_visible_to_any_render_target(&sources)); - } - - #[tokio::test] - async fn hidden_pty_output_appears_after_switching_to_its_tab() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - workspace.insert_test_runtime( - background_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"before"), - ); - 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.render_and_stream(); - let _initial_frame = 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, background_pane) - .expect("background runtime"); - runtime.test_process_pty_bytes(b"\rhidden-update"); - assert!(server.app.render_dirty.request_pty(background_pane)); - let request = server.app.render_dirty.take(); - let pty = if server.pty_sources_visible_to_any_render_target(&request.pty_sources) { - PtyRenderState::Visible - } else { - PtyRenderState::Hidden - }; - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: false, - pty, - }), - RetainedRenderPlan::HiddenPty - ); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - - server.app.state.workspaces[0].switch_tab(background_tab); - server.render_and_stream(); - let visible_frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("frame after tab switch"), - ); - assert!(frame_text(&visible_frame).contains("hidden-update")); - } - - #[test] - fn direct_terminal_observer_keeps_hidden_pty_source_renderable_with_app_client() { - let (mut server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]); - assert!(!server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); - - let terminal_id = server.app.state.workspaces[0] - .terminal_id(background_pane) - .expect("background terminal id") - .to_string(); - let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new_with_mode( - ClientConnectionMode::TerminalObserve { terminal_id }, - None, - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - false, - Some(client_tx), - ), - ); - - assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); - - let hidden_pane = server.app.state.workspaces[0].tabs[0].root_pane; - server.sync_immediate_pty_sources(); - assert!(server.app.render_dirty.request_pty(background_pane)); - assert!(server.has_pending_presentation_work(false, false)); - assert!(server.app.render_dirty.request_pty(hidden_pane)); - } - - #[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_popup_is_visible() { - let (mut server, client_rx, _) = retained_test_server(b"tiled"); - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup-aaaa"); - let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - - server.render_and_stream(); - let initial = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial popup frame"), - ); - assert!(frame_text(&initial).contains("popup-aaaa")); - server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .test_process_pty_bytes(b"\rZ"); - - assert!(!server.render_retained_pty_update_and_stream()); - server.render_and_stream(); - let updated = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full popup fallback frame"), - ); - assert!(frame_text(&updated).contains("Zopup-aaaa")); - } - - #[tokio::test] - async fn popup_forces_host_mouse_capture_for_headless_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.app.state.mouse_capture = false; - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup"); - server.app.install_test_popup_runtime(popup_runtime); - - server.stream_host_mouse_capture_mode(); - - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("mouse capture message") - ), - ServerMessage::MouseCapture { enabled: true, .. } - )); - } - - #[tokio::test] - async fn command_mode_updates_headless_client_keyboard_flags() { - 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.mode = crate::app::Mode::Prefix; - server.stream_host_keyboard_enhancement_flags(); - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("command-mode keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: true } - )); - - server.app.state.mode = crate::app::Mode::Terminal; - server.stream_host_keyboard_enhancement_flags(); - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("IME-compatible keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: false } - )); - } - - #[tokio::test] - async fn focused_report_all_pane_updates_headless_client_keyboard_flags() { - 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), - ), - ); - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[>15u"); - server.app.install_test_popup_runtime(popup_runtime); - - server.stream_host_keyboard_enhancement_flags(); - - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: true } - )); - - assert!(server.app.close_popup_pane()); - server.app.state.mode = crate::app::Mode::Terminal; - server.stream_host_keyboard_enhancement_flags(); - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("IME-compatible keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: false } - )); - } - - #[tokio::test] - async fn virtual_render_uses_popup_cursor() { - let (mut server, _client_rx, _) = retained_test_server(b"\x1b[2;2H"); - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[4;5H"); - let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - - let (_, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut server.app.state, - &server.app.terminal_runtimes, - ratatui::layout::Rect::new(0, 0, 80, 24), - true, - crate::kitty_graphics::HostCellSize::default(), - ); - let (_, inner) = - crate::ui::popup_pane_rects(&server.app.state, server.app.state.view.terminal_area) - .unwrap(); - let expected = server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .cursor_state(inner, true) - .unwrap(); - - assert_eq!( - cursor, - Some(crate::protocol::CursorState { - x: expected.x, - y: expected.y, - visible: expected.visible, - shape: expected.shape, - }) - ); - } - - #[tokio::test] - async fn virtual_render_does_not_resize_directly_attached_popup() { - let (mut server, _client_rx, _) = retained_test_server(b"tiled"); - let popup_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(50, 13, b""); - let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - server - .app - .state - .direct_attach_resize_locks - .insert(terminal_id.clone()); - - let _ = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut server.app.state, - &server.app.terminal_runtimes, - ratatui::layout::Rect::new(0, 0, 80, 24), - true, - crate::kitty_graphics::HostCellSize::default(), - ); - - assert_eq!( - server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .current_size(), - (13, 50) - ); - } - - #[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_declines_while_copy_feedback_is_visible() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.state.copy_feedback = Some(crate::app::state::CopyFeedback { - message: "copied to clipboard".to_owned(), - }); - server.render_and_stream(); - let initial = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"), - ); - let initial_text = frame_text(&initial); - assert!( - initial_text.contains("copied to clipboard"), - "expected initial full frame to include copy feedback" - ); - - let feedback_row = initial_text - .lines() - .position(|line| line.contains("copied to clipboard")) - .expect("copy feedback row") as u16; - let inner_rect = server.app.state.view.pane_infos[0].inner_rect; - let pane_row = feedback_row - .checked_sub(inner_rect.y) - .expect("copy feedback 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 copy feedback 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_dirty_row_that_creates_plain_url() { - let (mut server, client_rx, pane_id) = retained_test_server(b"plain"); - 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"\rhttps://example.com/new"); - - 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 plain URL"), - ); - assert!( - patched.hyperlinks.is_empty(), - "retained render should not synthesize plain URL hyperlink metadata" - ); - } - - #[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[&1] - .writer - .as_ref() - .unwrap() - .test_fill_render(queued); - server.app.full_redraw_pending = true; - - server.render_and_stream(); - - assert!(server.app.full_redraw_pending); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - 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 terminal_bell_targets_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - 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); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { - pane_id: crate::layout::PaneId::from_raw(1), - count: 3, - }); - - assert!(!changed); - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground terminal bell message"), - ) { - ServerMessage::TerminalBell { count } => assert_eq!(count, 3), - other => panic!("expected terminal bell message, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive terminal bells" - ); - - server.foreground_client_id = None; - server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { - pane_id: crate::layout::PaneId::from_raw(1), - count: 1, - }); - assert!( - foreground_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "bells without a foreground client must not be retained" - ); - } - - #[test] - fn clipboard_write_targets_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - 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); - foreground_tx.test_close(); - - 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 prefix_input_source_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(); - // Drain any setup messages (e.g. mouse-capture sync) before exercising the event. - while foreground_control_rx - .recv_timeout(Duration::from_millis(20)) - .is_ok() - {} - - let changed = server - .handle_internal_event_with_forwarding(AppEvent::PrefixInputSource { active: true }); - - assert!(changed); - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground prefix input-source message"), - ) { - ServerMessage::PrefixInputSource { active } => assert!(active), - other => panic!("expected prefix input-source message, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive prefix input-source changes" - ); - } - - #[test] - fn headless_app_keeps_prefix_input_source_switch_off_process() { - // An App-internal drain (e.g. the exhaustive drain at the top of - // handle_api_request) can consume a queued PrefixInputSource intent - // before the forwarding drain sees it. The headless App must treat the - // event as inert instead of switching the host input source from the - // server process. - struct CountingPrefixInputSource(std::rc::Rc<std::cell::Cell<usize>>); - impl crate::platform::PrefixInputSource for CountingPrefixInputSource { - fn switch_to_ascii(&mut self) { - self.0.set(self.0.get() + 1); - } - fn restore(&mut self) { - self.0.set(self.0.get() + 1); - } - } - - let mut server = test_headless_server(); - let calls = std::rc::Rc::new(std::cell::Cell::new(0)); - server - .app - .set_prefix_input_source(Box::new(CountingPrefixInputSource(calls.clone()))); - - server - .app - .handle_internal_event(AppEvent::PrefixInputSource { active: true }); - server - .app - .handle_internal_event(AppEvent::PrefixInputSource { active: false }); - assert_eq!( - calls.get(), - 0, - "headless server must not apply the host input-source switch" - ); - - // Sanity: the same event does apply once the flag is on (monolithic semantics). - server.app.local_input_source_switch = true; - server - .app - .handle_internal_event(AppEvent::PrefixInputSource { active: true }); - assert_eq!(calls.get(), 1); - } - - #[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 oversized_paste_rejection_notifies_only_the_sending_client() { - let mut server = test_headless_server(); - let (sender_writer, sender_control_rx, _sender_render_rx) = test_client_writer(); - let (foreground_writer, foreground_control_rx, _foreground_render_rx) = - test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(sender_writer), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_writer), - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - assert!( - !server.handle_server_event(ServerEvent::ClientPasteRejected { - client_id: 1, - size: 5_000_012, - max: 1_048_576, - }) - ); - - match read_server_message( - sender_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("sending client rejection notification"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::Toast); - assert_eq!(message, "Paste rejected"); - assert_eq!( - body.as_deref(), - Some("Input message is 5000012 bytes; Herdr's limit is 1048576 bytes") - ); - } - other => panic!("expected paste rejection notification, got {other:?}"), - } - assert!( - foreground_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "foreground client must not receive another client's rejection" - ); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.clients.len(), 2); - assert!(server.app.state.toast.is_none()); - } - - #[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, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - 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, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("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, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed.error.code, "invalid_params"); - assert_eq!(parsed.error.message, "notification title is empty"); - } - - #[test] - fn notification_show_api_reports_no_foreground_client() { - let mut server = test_headless_server(); - server.foreground_client_id = None; - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: api::schema::NotificationShowSound::Request, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: false, - reason: api::schema::NotificationShowReason::NoForegroundClient, - } - ); - } - - #[test] - fn notification_show_api_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, - response_write_complete: None, - stream_active: None, - }) - ); - - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - 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, - response_write_complete: None, - stream_active: None, - }) - ); - - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("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 startup_idle_does_not_forward_completion() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("active"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - server.app.state.toast_config.delay_seconds = 0; - server.app.state.sound.enabled = true; - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { - pane_id, - agent: crate::detect::Agent::Pi, - observed_at: Instant::now(), - }) - ); - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(false), - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - while client_control_rx - .recv_timeout(Duration::from_millis(20)) - .is_ok() - {} - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::StateChanged { - pane_id, - agent: Some(crate::detect::Agent::Pi), - state: crate::detect::AgentState::Idle, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: Instant::now(), - }) - ); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "startup readiness should not forward a completion notification" - ); - } - - #[test] - fn 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_detected_state( - Some(crate::detect::Agent::Pi), - crate::detect::AgentState::Idle, - ); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { - source: "herdr:pi".into(), - agent: "pi".into(), - session_ref: crate::agent_resume::AgentSessionRef::path( - std::env::current_dir() - .unwrap() - .join("headless-pi-session.jsonl") - .display() - .to_string(), - ) - .unwrap(), - }); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_hook_authority( - "herdr:pi".into(), - "pi".into(), - crate::detect::AgentState::Working, - None, - Some(20), - ); - server.app.state.active = Some(1); - server.app.state.selected = 1; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - 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, - seq: Some(19), - agent_session_id: None, - agent_session_path: None, - }), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); - assert_eq!( - server.app.state.terminals.get(&terminal_id).unwrap().state, - crate::detect::AgentState::Working - ); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "stale idle report must not forward a done sound" - ); - } - - /// Verify that no direct calls to `self.app.handle_internal_event` - /// (or its `handle_internal_event_with_prefix_sync` wrapper) 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<String> = Vec::new(); - let mut inside_forwarding_method = false; - let mut forwarding_method_brace_depth = 0u32; - - for (i, line) in source.lines().enumerate() { - let line_num = i + 1; - - // Track when we're inside handle_internal_event_with_forwarding - if line.contains("fn handle_internal_event_with_forwarding") { - inside_forwarding_method = true; - forwarding_method_brace_depth = 0; - } - - if inside_forwarding_method { - // Count braces to track when we exit the method - for ch in line.chars() { - match ch { - '{' => forwarding_method_brace_depth += 1, - '}' => { - forwarding_method_brace_depth = - forwarding_method_brace_depth.saturating_sub(1); - if forwarding_method_brace_depth == 0 { - inside_forwarding_method = false; - } - } - _ => {} - } - } - } else if (line.contains("self.app.handle_internal_event(") - || line.contains("self.app.handle_internal_event_with_prefix_sync(")) - && !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 ") - ); - } -} +mod tests; |
