diff options
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/autodetect.rs | 15 | ||||
| -rw-r--r-- | src/server/client_commands.rs | 199 | ||||
| -rw-r--r-- | src/server/client_shell.rs | 616 | ||||
| -rw-r--r-- | src/server/client_shell_graphics.rs | 25 | ||||
| -rw-r--r-- | src/server/client_transport.rs | 1097 | ||||
| -rw-r--r-- | src/server/clients.rs | 423 | ||||
| -rw-r--r-- | src/server/headless.rs | 10126 | ||||
| -rw-r--r-- | src/server/headless/bootstrap.rs | 218 | ||||
| -rw-r--r-- | src/server/headless/lifecycle.rs | 399 | ||||
| -rw-r--r-- | src/server/headless/notifications.rs | 674 | ||||
| -rw-r--r-- | src/server/headless/pane_graphics.rs | 284 | ||||
| -rw-r--r-- | src/server/headless/render.rs | 606 | ||||
| -rw-r--r-- | src/server/headless/retained_surface.rs | 613 | ||||
| -rw-r--r-- | src/server/headless/tests/mod.rs | 5025 | ||||
| -rw-r--r-- | src/server/headless/tests/pane_graphics.rs | 827 | ||||
| -rw-r--r-- | src/server/mod.rs | 6 | ||||
| -rw-r--r-- | src/server/pane_input.rs | 515 | ||||
| -rw-r--r-- | src/server/render_scale_benchmark.rs | 218 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 529 |
19 files changed, 11441 insertions, 10974 deletions
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index 337c388f..1813ebd9 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -3,10 +3,7 @@ //! When the user runs `herdr` with no subcommand: //! 1. Check if a server is already listening on the client socket //! 2. If no server → spawn one as a background daemon → wait for socket readiness (up to 15s) -//! 3. Attach as a thin client to the server -//! -//! The `--no-session` flag bypasses server/client entirely and runs monolithically -//! (escape hatch for users who want the traditional single-process behavior). +//! 3. Attach as a client to the server use std::io; use std::path::Path; @@ -66,7 +63,7 @@ fn is_server_listening_at(socket_path: &Path) -> bool { Ok(_) => { // Server is listening. Close the test connection immediately. // The server's handshake handler will time out on this connection - // since we don't send Hello, which is fine. + // since we don't send a handshake, which is fine. true } Err(err) @@ -117,15 +114,13 @@ fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result<bool> { Err(err) => return Err(err), }; - let hello = crate::protocol::ClientMessage::Hello { + let hello = crate::protocol::ClientMessage::TerminalHello { version: crate::protocol::PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, - requested_encoding: crate::protocol::RenderEncoding::SemanticFrame, - keybindings: crate::protocol::ClientKeybindings::Server, - launch_mode: crate::protocol::ClientLaunchMode::App, + pixel_mouse: false, }; match crate::protocol::write_message(&mut stream, &hello) { @@ -281,7 +276,7 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu /// attach as a thin client. /// /// This is the entry point called from `main.rs` when the user runs `herdr` -/// without `--no-session` and without a subcommand. +/// without a subcommand. /// /// Flow: /// 1. Check if a server is listening on the client socket diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs new file mode 100644 index 00000000..eeb49c7e --- /dev/null +++ b/src/server/client_commands.rs @@ -0,0 +1,199 @@ +use std::io; +use std::sync::mpsc; + +use tokio::sync::mpsc as tokio_mpsc; + +use crate::api::schema::{ErrorBody, ErrorResponse, Method}; + +use super::client_transport::ServerEvent; + +pub(crate) const MAX_ENDPOINT_COMMAND_BYTES: usize = 1024 * 1024; +pub(crate) const MAX_ENDPOINT_BOOT_ID_BYTES: usize = 128; +pub(crate) const MAX_ENDPOINT_REQUEST_ID_BYTES: usize = 128; +const ENDPOINT_RESPONSE_CHUNK_BYTES: usize = 512 * 1024; + +pub(crate) fn supports_client_shell_method(method: &Method) -> bool { + matches!( + method, + Method::CommandInvoke(_) + | Method::IntegrationInstall(_) + | Method::IntegrationList(_) + | Method::LayoutSetSplitRatio(_) + | Method::PaneClose(_) + | Method::PaneCopyMotion(_) + | Method::PaneCopySearch(_) + | Method::PaneEditScrollback(_) + | Method::PaneFocus(_) + | Method::PaneFocusDirection(_) + | Method::PaneInputSet(_) + | Method::PaneLinkActivate(_) + | Method::PaneRename(_) + | Method::PaneResize(_) + | Method::PaneScroll(_) + | Method::PaneSelectionRead(_) + | Method::PaneSplit(_) + | Method::PaneSwap(_) + | Method::PaneZoom(_) + | Method::ProductAnnouncementDismiss(_) + | Method::ReleaseNotesDismiss(_) + | Method::ServerReloadConfig(_) + | Method::TabClose(_) + | Method::TabCreate(_) + | Method::TabFocus(_) + | Method::TabMove(_) + | Method::TabRename(_) + | Method::WorkspaceClose(_) + | Method::WorkspaceCreate(_) + | Method::WorkspaceFocus(_) + | Method::WorkspaceMove(_) + | Method::WorkspaceMoveBlock(_) + | Method::WorkspaceRename(_) + | Method::WorktreeCreate(_) + | Method::WorktreeList(_) + | Method::WorktreeOpen(_) + | Method::WorktreeRemove(_) + ) +} + +pub(crate) fn error_response(id: String, code: &str, message: impl Into<String>) -> String { + serde_json::to_string(&ErrorResponse { + id, + error: ErrorBody { + code: code.into(), + message: message.into(), + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"serialization_error","message":"failed to serialize endpoint response"}}"#.into() + }) +} + +pub(crate) fn error_message( + boot_id: String, + request_id: String, + code: &str, + message: impl Into<String>, +) -> crate::protocol::ServerMessage { + let response = error_response(request_id.clone(), code, message); + crate::protocol::ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk: true, + data: response.into_bytes(), + } +} + +pub(crate) fn spawn_response_waiter( + client_id: u64, + boot_id: String, + request_id: String, + response_rx: mpsc::Receiver<String>, + server_event_tx: tokio_mpsc::Sender<ServerEvent>, +) -> io::Result<()> { + std::thread::Builder::new() + .name("herdr-client-endpoint-response".into()) + .spawn(move || { + let response = response_rx.recv().unwrap_or_else(|_| { + error_response( + request_id.clone(), + "server_unavailable", + "endpoint command ended without a response", + ) + }); + let response = response.into_bytes(); + if response.is_empty() { + let _ = server_event_tx.blocking_send( + ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id, + request_id, + final_chunk: true, + data: Vec::new(), + }, + ); + return; + } + let chunk_count = response.len().div_ceil(ENDPOINT_RESPONSE_CHUNK_BYTES); + for (index, chunk) in response.chunks(ENDPOINT_RESPONSE_CHUNK_BYTES).enumerate() { + if server_event_tx + .blocking_send(ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id: boot_id.clone(), + request_id: request_id.clone(), + final_chunk: index + 1 == chunk_count, + data: chunk.to_vec(), + }) + .is_err() + { + break; + } + } + }) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_shell_lane_excludes_api_front_door_and_lifecycle_methods() { + assert!(supports_client_shell_method(&Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ))); + assert!(supports_client_shell_method(&Method::PaneLinkActivate( + crate::api::schema::PaneLinkActivateParams { + pane_id: "w1:p1".into(), + viewport_row: 0, + col: 0, + content_revision: None, + offset_from_bottom: None, + }, + ))); + assert!(!supports_client_shell_method(&Method::Ping( + crate::api::schema::PingParams::default(), + ))); + assert!(!supports_client_shell_method(&Method::ServerStop( + crate::api::schema::EmptyParams::default(), + ))); + } + + #[test] + fn endpoint_responses_are_chunked_without_truncation() { + let (response_tx, response_rx) = mpsc::channel(); + let (event_tx, mut event_rx) = tokio_mpsc::channel(8); + spawn_response_waiter( + 7, + "boot-a".into(), + "request-a".into(), + response_rx, + event_tx, + ) + .unwrap(); + let response = "x".repeat(ENDPOINT_RESPONSE_CHUNK_BYTES + 17); + response_tx.send(response.clone()).unwrap(); + + let mut received = Vec::new(); + loop { + let ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id, + request_id, + final_chunk, + data, + } = event_rx.blocking_recv().expect("response chunk") + else { + panic!("expected response chunk"); + }; + assert_eq!(client_id, 7); + assert_eq!(boot_id, "boot-a"); + assert_eq!(request_id, "request-a"); + received.extend(data); + if final_chunk { + break; + } + } + + assert_eq!(received, response.as_bytes()); + } +} diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs new file mode 100644 index 00000000..8f0a90ee --- /dev/null +++ b/src/server/client_shell.rs @@ -0,0 +1,616 @@ +use ratatui::layout::Rect; + +use crate::app; +use crate::protocol::{self, FrameData}; + +pub(super) fn snapshot( + app: &app::App, + boot_id: &str, + revision: u64, + config_diagnostic: Option<&str>, +) -> protocol::ClientShellSnapshot { + let snapshot = app.session_snapshot(); + let workspaces = snapshot + .workspaces + .into_iter() + .zip(&app.state.workspaces) + .enumerate() + .map(|(workspace_index, (workspace, state))| { + let mut tokens = workspace.tokens.into_iter().collect::<Vec<_>>(); + tokens.sort_by(|left, right| left.0.cmp(&right.0)); + protocol::ClientShellWorkspace { + workspace_id: workspace.workspace_id, + active_tab_id: workspace.active_tab_id, + new_workspace_cwd: app + .resolved_new_workspace_cwd_from(workspace_index) + .display() + .to_string(), + number: workspace.number, + label: workspace.label, + custom_label: state.custom_name.is_some(), + branch: state.branch(), + git_ahead_behind: state.git_ahead_behind(), + tokens, + worktree: workspace + .worktree + .map(|worktree| protocol::ClientShellWorktree { + key: worktree.repo_key, + label: worktree.repo_name, + is_linked_worktree: worktree.is_linked_worktree, + }), + focused: workspace.focused, + agent_status: workspace.agent_status, + } + }) + .collect(); + let tabs = snapshot + .tabs + .into_iter() + .zip( + app.state + .workspaces + .iter() + .flat_map(|workspace| workspace.tabs.iter()), + ) + .map(|(tab, state)| protocol::ClientShellTab { + tab_id: tab.tab_id, + workspace_id: tab.workspace_id, + number: tab.number, + label: tab.label, + custom_label: !state.is_auto_named(), + zoomed: state.zoomed, + focused: tab.focused, + agent_status: tab.agent_status, + }) + .collect(); + let panes = snapshot + .panes + .into_iter() + .map(|pane| { + let right_click_passthrough = app + .parse_pane_id(&pane.pane_id) + .and_then(|(workspace_index, pane_id)| { + app.state + .workspaces + .get(workspace_index)? + .pane_state(pane_id) + }) + .is_some_and(|pane| pane.right_click_passthrough); + protocol::ClientShellPane { + pane_id: pane.pane_id, + workspace_id: pane.workspace_id, + tab_id: pane.tab_id, + label: pane.label, + cwd: pane.cwd, + foreground_cwd: pane.foreground_cwd, + focused: pane.focused, + right_click_passthrough, + } + }) + .collect(); + let agents = snapshot + .agents + .into_iter() + .map(|agent| { + let mut state_labels = agent.state_labels.into_iter().collect::<Vec<_>>(); + state_labels.sort_by(|left, right| left.0.cmp(&right.0)); + let mut tokens = agent.tokens.into_iter().collect::<Vec<_>>(); + tokens.sort_by(|left, right| left.0.cmp(&right.0)); + protocol::ClientShellAgent { + pane_id: agent.pane_id, + workspace_id: agent.workspace_id, + tab_id: agent.tab_id, + name: agent.name, + display_agent: agent.display_agent, + agent: agent.agent, + title: agent.title, + terminal_title: agent.terminal_title, + terminal_title_stripped: agent.terminal_title_stripped, + agent_status: agent.agent_status, + state_change_seq: agent.state_change_seq, + state_labels, + tokens, + focused: agent.focused, + } + }) + .collect(); + + let agent_view_label = app + .state + .agent_view_override + .as_ref() + .map(|view| view.label.clone().unwrap_or_else(|| "filtered".to_owned())); + let agent_order = crate::ui::agent_panel_entries_from(&app.state, &app.terminal_runtimes) + .into_iter() + .filter_map(|entry| app.public_pane_id(entry.ws_idx, entry.pane_id)) + .collect(); + + let zoomed = app + .state + .active + .and_then(|index| app.state.workspaces.get(index)) + .is_some_and(|workspace| workspace.zoomed); + let tab_bar_right = app + .state + .tab_bar_right + .iter() + .filter_map(|segment| match segment { + crate::app::state::TabBarStatusSegment::Zoom if zoomed => { + Some(protocol::ClientShellTabStatusSegment { + text: "ZOOM".to_owned(), + accent: true, + }) + } + crate::app::state::TabBarStatusSegment::Text(Some(text)) if !text.is_empty() => { + Some(protocol::ClientShellTabStatusSegment { + text: text.clone(), + accent: false, + }) + } + crate::app::state::TabBarStatusSegment::Zoom + | crate::app::state::TabBarStatusSegment::Text(_) => None, + }) + .collect(); + + let product_announcement = app.state.product_announcement.as_ref().map(|announcement| { + protocol::ClientShellProductAnnouncement { + version: announcement.version.clone(), + id: announcement.id.clone(), + title: announcement.title.clone(), + body: announcement.body.clone(), + preview: announcement.preview, + } + }); + let release_notes = + app.state + .latest_release_notes + .as_ref() + .map(|notes| protocol::ClientShellReleaseNotes { + version: notes.version.clone(), + body: notes.body.clone(), + preview: notes.preview, + }); + + protocol::ClientShellSnapshot { + boot_id: boot_id.to_owned(), + revision, + config_diagnostic: config_diagnostic.map(str::to_owned), + product_announcement, + update_available: app.state.update_available.clone(), + update_install_command: app.state.update_install_command.clone(), + server_keybindings_toml: app.client_shell_keybindings_profile().map(str::to_owned), + latest_release_notes_available: app.state.latest_release_notes_available, + integration_updates_available: app.state.integration_updates_available(), + worktree_directory: app.state.worktree_directory.to_string_lossy().into_owned(), + release_notes, + focused_workspace_id: snapshot.focused_workspace_id, + focused_tab_id: snapshot.focused_tab_id, + focused_pane_id: snapshot.focused_pane_id, + tab_bar_right, + tab_bar_right_separator: app.state.tab_bar_right_separator.clone(), + agent_view_label, + agent_order, + workspaces, + tabs, + panes, + agents, + commands: app.client_shell_command_manifest(), + } +} + +pub(super) struct RenderedPaneSurface { + pub(super) frame: FrameData, + pub(super) panes: Vec<protocol::PaneSurfacePane>, + pub(super) splits: Vec<protocol::PaneSurfaceSplit>, + pub(super) popup: Option<Box<protocol::ClientShellPopupSurface>>, + pub(super) graphics: protocol::SurfaceGraphicsScene, + pub(super) graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, +} + +pub(super) fn render_pane_surface( + app: &mut app::App, + area: Rect, + is_foreground: bool, + cell_size: crate::kitty_graphics::HostCellSize, + graphics_delivery: &crate::kitty_graphics::surface::DeliveryCache, + client_id: u64, +) -> RenderedPaneSurface { + let content_revisions_before = app + .state + .active + .and_then(|workspace_index| { + let workspace = app.state.workspaces.get(workspace_index)?; + let tab = workspace.tabs.get(workspace.active_tab)?; + Some( + tab.layout + .pane_ids() + .into_iter() + .filter_map(|pane_id| { + app.state + .runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane_id, + ) + .map(|runtime| (pane_id, runtime.content_seq())) + }) + .collect::<std::collections::HashMap<_, _>>(), + ) + }) + .unwrap_or_default(); + let (buffer, cursor, hyperlinks, layout) = + crate::server::render_stream::render_tab_surface_virtual( + &app.state, + &app.terminal_runtimes, + area, + is_foreground, + cell_size, + ); + let panes = app + .state + .active + .map(|workspace_index| { + layout + .pane_infos + .iter() + .filter_map(|pane| { + app.public_pane_id(workspace_index, pane.id).map(|pane_id| { + let runtime = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane.id, + ); + let mouse_reporting = + runtime.is_some_and(|runtime| runtime.mouse_reporting_enabled()); + let sgr_pixel_mouse = + runtime.is_some_and(|runtime| runtime.sgr_pixel_mouse_enabled()); + let (pixel_width, pixel_height) = if cell_size.is_known() { + ( + u32::from(pane.inner_rect.width) * cell_size.width_px, + u32::from(pane.inner_rect.height) * cell_size.height_px, + ) + } else { + (0, 0) + }; + let content_revision = runtime.map_or(0, |runtime| { + let after = runtime.content_seq(); + if content_revisions_before.get(&pane.id).copied() == Some(after) + && after.is_multiple_of(2) + { + after + } else { + after | 1 + } + }); + protocol::PaneSurfacePane { + pane_id, + content_revision, + rect: pane.rect.into(), + inner_rect: pane.inner_rect.into(), + scrollbar_rect: pane.scrollbar_rect.map(Into::into), + scroll: runtime.and_then(|runtime| runtime.scroll_metrics()).map( + |metrics| protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: metrics.offset_from_bottom as u64, + max_offset_from_bottom: metrics.max_offset_from_bottom as u64, + viewport_rows: metrics.viewport_rows as u64, + }, + ), + focused: pane.is_focused, + mouse_reporting, + sgr_pixel_mouse, + alternate_screen_active: runtime + .is_some_and(|runtime| runtime.alternate_screen_active()), + pixel_width, + pixel_height, + } + }) + }) + .collect() + }) + .unwrap_or_default(); + let pane_frames = layout + .pane_infos + .iter() + .map(|pane| pane.rect) + .collect::<Vec<_>>(); + let splits = layout + .split_borders + .iter() + .filter_map(|split| { + let hit_rect = split_hit_rect( + split, + app.state.pane_borders, + app.state.pane_gaps, + &pane_frames, + )?; + let direction = match split.direction { + ratatui::layout::Direction::Horizontal => { + protocol::PaneSurfaceSplitDirection::Horizontal + } + ratatui::layout::Direction::Vertical => { + protocol::PaneSurfaceSplitDirection::Vertical + } + }; + Some(protocol::PaneSurfaceSplit { + direction, + pos: split.pos, + area: split.area.into(), + hit_rect: hit_rect.into(), + path: split.path.clone(), + }) + }) + .collect(); + let popup = render_popup_surface(app, area, is_foreground, cell_size); + let (graphics, next_graphics_delivery) = crate::server::client_shell_graphics::collect( + app, + &layout.pane_infos, + &layout.split_borders, + popup.as_deref(), + cell_size, + graphics_delivery, + client_id, + ); + RenderedPaneSurface { + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks), + panes, + splits, + popup, + graphics, + graphics_delivery: next_graphics_delivery, + } +} + +fn render_popup_surface( + app: &app::App, + area: Rect, + is_foreground: bool, + cell_size: crate::kitty_graphics::HostCellSize, +) -> Option<Box<protocol::ClientShellPopupSurface>> { + let popup = app.state.popup_pane.as_ref()?; + let geometry = if is_foreground { + resize_popup_runtime(app, area, cell_size)? + } else { + crate::popup_size::resolve_popup_geometry(popup.width, popup.height, area)? + }; + let runtime = app.terminal_runtimes.get(&popup.terminal_id)?; + let content_area = Rect::new(0, 0, geometry.inner.width, geometry.inner.height); + let (buffer, mut cursor) = + crate::server::render_stream::render_terminal_virtual(runtime, content_area); + if !is_foreground { + cursor = None; + } + let hyperlinks = runtime.visible_hyperlinks(content_area); + let title = app + .state + .terminals + .get(&popup.terminal_id) + .and_then(|terminal| terminal.manual_label.clone()) + .unwrap_or_else(|| "popup".to_owned()); + let (pixel_width, pixel_height) = if cell_size.is_known() { + ( + u32::from(content_area.width) * cell_size.width_px, + u32::from(content_area.height) * cell_size.height_px, + ) + } else { + (0, 0) + }; + Some(Box::new(protocol::ClientShellPopupSurface { + terminal_id: popup.terminal_id.to_string(), + title, + width: popup.width.map(client_popup_size), + height: popup.height.map(client_popup_size), + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks), + mouse_reporting: runtime.mouse_reporting_enabled(), + sgr_pixel_mouse: runtime.sgr_pixel_mouse_enabled(), + pixel_width, + pixel_height, + })) +} + +pub(super) fn resize_popup_runtime( + app: &app::App, + area: Rect, + cell_size: crate::kitty_graphics::HostCellSize, +) -> Option<crate::popup_size::PopupResolvedGeometry> { + let popup = app.state.popup_pane.as_ref()?; + let geometry = crate::popup_size::resolve_popup_geometry(popup.width, popup.height, area)?; + let runtime = app.terminal_runtimes.get(&popup.terminal_id)?; + if !app + .state + .direct_attach_resize_locks + .contains(&popup.terminal_id) + { + runtime.resize( + geometry.inner.height, + geometry.inner.width, + cell_size.width_px, + cell_size.height_px, + ); + } + Some(geometry) +} + +fn client_popup_size(size: crate::popup_size::PopupSize) -> protocol::ClientShellPopupSize { + match size { + crate::popup_size::PopupSize::Cells(cells) => protocol::ClientShellPopupSize::Cells(cells), + crate::popup_size::PopupSize::Percent(percent) => { + protocol::ClientShellPopupSize::Percent(percent) + } + } +} + +fn split_hit_rect( + split: &crate::layout::SplitBorder, + pane_borders: bool, + pane_gaps: bool, + pane_frames: &[Rect], +) -> Option<Rect> { + let hit = match (split.direction, pane_borders, pane_gaps) { + (ratatui::layout::Direction::Horizontal, true, false) => { + Rect::new(split.pos, split.area.y, 1, split.area.height) + } + (ratatui::layout::Direction::Horizontal, true, true) => { + let start = split.pos.saturating_sub(1); + Rect::new( + start, + split.area.y, + split.pos.saturating_sub(start).saturating_add(1), + split.area.height, + ) + } + (ratatui::layout::Direction::Horizontal, false, true) => Rect::new( + split.pos.checked_sub(1)?, + split.area.y, + 1, + split.area.height, + ), + (ratatui::layout::Direction::Vertical, true, false) => { + Rect::new(split.area.x, split.pos, split.area.width, 1) + } + (ratatui::layout::Direction::Vertical, true, true) => { + let start = split.pos.saturating_sub(1); + Rect::new( + split.area.x, + start, + split.area.width, + split.pos.saturating_sub(start).saturating_add(1), + ) + } + (ratatui::layout::Direction::Vertical, false, true) => { + Rect::new(split.area.x, split.pos.checked_sub(1)?, split.area.width, 1) + } + (_, false, false) => return None, + }; + if !pane_borders + && pane_frames.iter().any(|pane| { + hit.x < pane.right() + && hit.right() > pane.x + && hit.y < pane.bottom() + && hit.bottom() > pane.y + }) + { + return None; + } + Some(hit) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_projects_cached_release_and_update_facts() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = crate::app::App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.integration_recommendations.clear(); + app.state.update_available = Some("0.8.3".into()); + app.state.update_install_command = "herdr update".into(); + app.state.latest_release_notes_available = true; + app.state.latest_release_notes = Some(crate::release_notes::ReleaseNotes { + version: "0.8.3".into(), + body: "### Changed\n- Client shell".into(), + preview: true, + }); + + let snapshot = snapshot(&app, "boot", 7, None); + + assert_eq!(snapshot.update_available.as_deref(), Some("0.8.3")); + assert_eq!(snapshot.update_install_command, "herdr update"); + assert!(snapshot.latest_release_notes_available); + assert!(!snapshot.integration_updates_available); + assert_eq!( + snapshot.release_notes.as_ref().map(|notes| ( + notes.version.as_str(), + notes.body.as_str(), + notes.preview + )), + Some(("0.8.3", "### Changed\n- Client shell", true)) + ); + } + + #[test] + fn snapshot_badges_only_outdated_integrations() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = crate::app::App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.integration_recommendations = + vec![crate::integration::IntegrationRecommendation { + target: crate::api::schema::IntegrationTarget::Claude, + label: "claude", + command: "claude", + available: true, + path: std::path::PathBuf::from("claude-hook"), + state: crate::integration::IntegrationStatusKind::NotInstalled, + }]; + + assert!(!snapshot(&app, "boot", 1, None).integration_updates_available); + + app.state.integration_recommendations[0].state = + crate::integration::IntegrationStatusKind::Outdated; + assert!(snapshot(&app, "boot", 2, None).integration_updates_available); + } + + #[test] + fn split_hits_follow_released_border_and_gap_geometry() { + let horizontal = crate::layout::SplitBorder { + pos: 20, + direction: ratatui::layout::Direction::Horizontal, + ratio: 0.5, + area: Rect::new(2, 3, 40, 12), + path: vec![false], + }; + assert_eq!( + split_hit_rect(&horizontal, true, false, &[]), + Some(Rect::new(20, 3, 1, 12)) + ); + assert_eq!( + split_hit_rect(&horizontal, true, true, &[]), + Some(Rect::new(19, 3, 2, 12)) + ); + assert_eq!( + split_hit_rect(&horizontal, false, true, &[]), + Some(Rect::new(19, 3, 1, 12)) + ); + assert_eq!(split_hit_rect(&horizontal, false, false, &[]), None); + + let vertical = crate::layout::SplitBorder { + pos: 9, + direction: ratatui::layout::Direction::Vertical, + ratio: 0.5, + area: Rect::new(2, 3, 40, 12), + path: vec![true], + }; + assert_eq!( + split_hit_rect(&vertical, true, true, &[]), + Some(Rect::new(2, 8, 40, 2)) + ); + + let edge = crate::layout::SplitBorder { + pos: 0, + direction: ratatui::layout::Direction::Horizontal, + ratio: 0.5, + area: Rect::new(0, 0, 1, 4), + path: Vec::new(), + }; + assert_eq!( + split_hit_rect(&edge, true, true, &[]), + Some(Rect::new(0, 0, 1, 4)) + ); + assert_eq!(split_hit_rect(&edge, false, true, &[]), None); + assert_eq!( + split_hit_rect(&horizontal, false, true, &[Rect::new(19, 3, 1, 12)]), + None + ); + } +} diff --git a/src/server/client_shell_graphics.rs b/src/server/client_shell_graphics.rs new file mode 100644 index 00000000..b16001cf --- /dev/null +++ b/src/server/client_shell_graphics.rs @@ -0,0 +1,25 @@ +use crate::kitty_graphics::surface::DeliveryCache; +use crate::protocol::{ClientShellPopupSurface, SurfaceGraphicsScene}; + +pub(crate) fn collect( + app: &crate::app::App, + pane_infos: &[crate::layout::PaneInfo], + split_borders: &[crate::layout::SplitBorder], + popup: Option<&ClientShellPopupSurface>, + cell_size: crate::kitty_graphics::HostCellSize, + delivered: &DeliveryCache, + client_id: u64, +) -> (SurfaceGraphicsScene, DeliveryCache) { + let popup_content_size = popup.map(|popup| (popup.frame.width, popup.frame.height)); + crate::kitty_graphics::surface::collect_scene( + app, + crate::ui::TabSurfaceView { + pane_infos, + split_borders, + }, + popup_content_size, + cell_size, + delivered, + client_id, + ) +} diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index f1237c89..673e16d3 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -18,9 +18,9 @@ use tracing::{debug, warn}; use crate::ipc::LocalStream; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientInputEvent, ClientKeybindings, - ClientLaunchMode, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, - MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientMessage, ClientPaneInputEvent, + RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; /// Minimum accepted attached client size. @@ -41,8 +41,6 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4); const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB /// Maximum structured input events accepted in one client message. const MAX_INPUT_EVENT_BATCH: usize = 4096; -/// Maximum encoded mouse report accepted with pixel geometry. -const MAX_PIXEL_MOUSE_PAYLOAD: usize = 128; /// Channels owned by the server side of a client writer thread. #[derive(Clone, Debug)] @@ -54,10 +52,6 @@ pub(crate) struct ClientWriter { } impl ClientWriter { - pub(crate) fn replace_with_cleanup(&self, data: Vec<u8>) { - self.render.queue.replace_with_cleanup(data); - } - #[cfg(test)] pub(crate) fn test_fill_render(&self, data: Vec<u8>) { self.render.try_send(data).unwrap(); @@ -254,16 +248,6 @@ impl ClientWriterQueue { Ok(()) } - fn replace_with_cleanup(&self, data: Vec<u8>) { - let mut state = self.lock_state(); - state.render = None; - state.ordered.clear(); - if state.writer_alive { - state.control.push_back(data); - self.ready.notify_one(); - } - } - fn recv(&self) -> Option<ClientWriteItem> { let mut state = self.lock_state(); loop { @@ -312,10 +296,20 @@ pub(crate) enum ServerEvent { rows: u16, cell_width_px: u32, cell_height_px: u32, - render_encoding: RenderEncoding, - keybindings: Option<Box<crate::config::LiveKeybindConfig>>, - direct_attach_requested: bool, + pixel_mouse: bool, + writer: ClientWriter, + }, + /// A client-owned shell completed its dedicated handshake. + ClientShellConnected { + client_id: u64, + surface_cols: u16, + surface_rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, direct_graphics: bool, + endpoint_keybindings: bool, + mouse_capture: bool, writer: ClientWriter, }, /// A client sent an input message. @@ -332,17 +326,6 @@ pub(crate) enum ServerEvent { transfer_id: u64, image_id: u32, }, - /// One confirmed SGR pixel report with client read-time geometry. - ClientInputPixels { - client_id: u64, - data: Vec<u8>, - geometry: crate::input::mouse::HostGeometry, - }, - /// A client sent structured input events. - ClientInputEvents { - client_id: u64, - events: Vec<crate::protocol::ClientInputEvent>, - }, /// A fully decoded interactive paste exceeded the text-input limit. ClientPasteRejected { client_id: u64, @@ -352,6 +335,7 @@ pub(crate) enum ServerEvent { /// A client sent local clipboard image bytes to paste into a remote pane. ClientClipboardImage { client_id: u64, + target: crate::protocol::ClientClipboardImageTarget, extension: String, data: Vec<u8>, }, @@ -379,6 +363,15 @@ pub(crate) enum ServerEvent { row: Option<u16>, modifiers: u8, }, + /// A direct terminal attach client delivered one structured mouse event. + ClientAttachMouse { + client_id: u64, + kind: crate::protocol::ClientMouseKind, + position: crate::protocol::ClientMousePosition, + geometry: Option<crate::protocol::ClientMouseGeometry>, + modifiers: u8, + lines: u16, + }, /// A client sent a resize message. ClientResize { client_id: u64, @@ -386,6 +379,51 @@ pub(crate) enum ServerEvent { rows: u16, cell_width_px: u32, cell_height_px: u32, + pixel_mouse: bool, + }, + /// A client-owned shell recomputed its pane viewport. + ClientShellResize { + client_id: u64, + surface_cols: u16, + surface_rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, + }, + /// A client-owned shell delivered semantic input to one stable pane target. + ClientShellPaneInput { + client_id: u64, + pane_id: String, + events: Vec<ClientPaneInputEvent>, + }, + /// A client-owned shell delivered semantic input to its active popup terminal. + ClientShellPopupInput { + client_id: u64, + terminal_id: String, + events: Vec<ClientPaneInputEvent>, + }, + /// A client-owned shell published one host terminal theme observation. + ClientShellHostTheme { + client_id: u64, + update: crate::protocol::ClientHostThemeUpdate, + }, + /// A client-owned shell reported whether its outer terminal has focus. + ClientShellFocus { client_id: u64, focused: bool }, + /// A client-owned shell updated its local mouse-capture preference. + ClientShellMouseCapture { client_id: u64, enabled: bool }, + /// A client-owned shell invoked one endpoint operation through this connection. + ClientShellEndpointRequest { + client_id: u64, + boot_id: String, + request: Box<crate::api::schema::Request>, + }, + /// One chunk of a deferred endpoint operation's final response is ready. + ClientShellEndpointResponseChunkReady { + client_id: u64, + boot_id: String, + request_id: String, + final_chunk: bool, + data: Vec<u8>, }, /// A client detached gracefully. ClientDetach { client_id: u64 }, @@ -404,23 +442,6 @@ pub(crate) fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) { (clamped_cols, clamped_rows) } -fn parse_client_keybindings( - keybindings: ClientKeybindings, -) -> Result<Option<Box<crate::config::LiveKeybindConfig>>, String> { - match keybindings { - ClientKeybindings::Server => Ok(None), - ClientKeybindings::Local { keys_toml } => { - let mut config = toml::from_str::<crate::config::Config>(&keys_toml) - .map_err(|err| format!("invalid client keybindings: {err}"))?; - config.keys.command.clear(); - Ok(Some(Box::new(crate::config::LiveKeybindConfig { - prefix: config.prefix_key(), - keybinds: config.keybinds(), - }))) - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputEventLimit { WithinLimits, @@ -429,20 +450,28 @@ enum InputEventLimit { InputPayloadTooLarge { size: usize }, } -fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit { +fn pane_input_event_limit(events: &[ClientPaneInputEvent]) -> InputEventLimit { let mut expanded_events = 0usize; let mut paste_bytes = 0usize; let mut input_bytes = 0usize; for event in events { expanded_events = expanded_events.saturating_add(match event { - ClientInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)), - _ => 1, + ClientPaneInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)), + ClientPaneInputEvent::Mouse { + kind: + crate::protocol::ClientMouseKind::ScrollUp + | crate::protocol::ClientMouseKind::ScrollDown, + lines, + .. + } => usize::from((*lines).max(1)), + ClientPaneInputEvent::TextCommit(_) + | ClientPaneInputEvent::Mouse { .. } + | ClientPaneInputEvent::Paste(_) => 1, }); match event { - ClientInputEvent::Key { + ClientPaneInputEvent::Key { repeat_count, generated_text, - source, .. } => { if let Some(text) = generated_text { @@ -451,22 +480,25 @@ fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit { .saturating_mul(usize::from((*repeat_count).max(1))), ); } - if let crate::protocol::ClientKeySource::Vt { bytes } = source { - input_bytes = input_bytes.saturating_add(bytes.len()); - } } - ClientInputEvent::TextCommit(text) => { + ClientPaneInputEvent::TextCommit(text) => { input_bytes = input_bytes.saturating_add(text.len()); } - ClientInputEvent::Paste { text } => { + ClientPaneInputEvent::Mouse { .. } => {} + ClientPaneInputEvent::Paste(text) => { paste_bytes = paste_bytes.saturating_add(text.len()); } - ClientInputEvent::Mouse { .. } - | ClientInputEvent::FocusGained - | ClientInputEvent::FocusLost => {} } } + classify_input_event_size(expanded_events, paste_bytes, input_bytes) +} + +fn classify_input_event_size( + expanded_events: usize, + paste_bytes: usize, + input_bytes: usize, +) -> InputEventLimit { if expanded_events > MAX_INPUT_EVENT_BATCH { return InputEventLimit::TooManyEvents; } @@ -514,8 +546,8 @@ fn set_client_recv_timeout( /// Handles the client handshake on a blocking thread. /// -/// Reads the `Hello` message, validates the version, sends `Welcome`, -/// and then enters a read loop forwarding messages to the server event channel. +/// Reads the `TerminalHello` or `ClientShellHello` message, validates the version, +/// sends `Welcome`, and then enters a read loop forwarding messages to the server event channel. pub(crate) fn handle_client_handshake( mut stream: LocalStream, client_id: u64, @@ -537,7 +569,7 @@ pub(crate) fn handle_client_handshake( client_id, )?; - // Read the Hello message. + // Read the handshake message. let hello: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) { Ok(msg) => msg, Err(protocol::FramingError::UnexpectedEof) => { @@ -559,69 +591,83 @@ pub(crate) fn handle_client_handshake( client_rows, cell_width_px, cell_height_px, - render_encoding, - keybindings, - direct_attach_requested, - direct_graphics, + terminal_pixel_mouse, + shell_options, ) = match hello { - ClientMessage::Hello { + ClientMessage::TerminalHello { version, cols, rows, cell_width_px, cell_height_px, - requested_encoding, - keybindings, - launch_mode, + pixel_mouse, } => { - // Version check. - match protocol::check_client_version(version) { - protocol::VersionCheck::Compatible => {} - protocol::VersionCheck::Incompatible(reason) => { - // Send rejection Welcome. - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(reason), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } + if let protocol::VersionCheck::Incompatible(reason) = + protocol::check_client_version(version) + { + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::TerminalAnsi, + error: Some(reason), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + let (cols, rows) = clamp_terminal_size(cols, rows); + (cols, rows, cell_width_px, cell_height_px, pixel_mouse, None) + } + ClientMessage::ClientShellHello { + version, + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + } => { + if let protocol::VersionCheck::Incompatible(reason) = + protocol::check_client_version(version) + { + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some(reason), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + if surface_size.cols == 0 || surface_size.rows == 0 { + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some("client shell requires a non-empty pane surface".to_owned()), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); } - - let keybindings = match parse_client_keybindings(keybindings) { - Ok(keybindings) => keybindings, - Err(error) => { - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(error), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - }; - - // Clamp size. - let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); ( - clamped_cols, - clamped_rows, + surface_size.cols, + surface_size.rows, cell_width_px, cell_height_px, - requested_encoding, - keybindings, - launch_mode == ClientLaunchMode::TerminalAttach, - launch_mode == ClientLaunchMode::AppDirectGraphics, + false, + Some(( + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + )), ) } _ => { - // First message must be Hello. - debug!(client_id, "first message was not Hello, closing"); + debug!(client_id, "first message was not a handshake, closing"); let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: RenderEncoding::SemanticFrame, - error: Some("expected Hello as first message".to_owned()), + error: Some( + "expected TerminalHello or ClientShellHello as first message".to_owned(), + ), }; let _ = protocol::write_message(&mut stream, &welcome); return Ok(()); @@ -633,6 +679,11 @@ pub(crate) fn handle_client_handshake( } // Send Welcome. + let render_encoding = if shell_options.is_some() { + RenderEncoding::SemanticFrame + } else { + RenderEncoding::TerminalAnsi + }; let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: render_encoding, @@ -667,21 +718,40 @@ pub(crate) fn handle_client_handshake( } // Notify the main loop about the new client. - let connected = ServerEvent::ClientConnected { - client_id, - cols: client_cols, - rows: client_rows, - cell_width_px, - cell_height_px, - render_encoding, - keybindings, - direct_attach_requested, - direct_graphics, - writer, - }; + let connected = + if let Some((pixel_mouse, direct_graphics, endpoint_keybindings, mouse_capture)) = + shell_options + { + ServerEvent::ClientShellConnected { + client_id, + surface_cols: client_cols, + surface_rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + writer, + } + } else { + ServerEvent::ClientConnected { + client_id, + cols: client_cols, + rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse: terminal_pixel_mouse, + writer, + } + }; if let Err(err) = server_event_tx.blocking_send(connected) { - if let ServerEvent::ClientConnected { writer, .. } = err.0 { - send_shutdown_to_unregistered_client(&writer); + match err.0 { + ServerEvent::ClientConnected { writer, .. } + | ServerEvent::ClientShellConnected { writer, .. } => { + send_shutdown_to_unregistered_client(&writer); + } + _ => {} } } @@ -804,86 +874,6 @@ fn client_read_loop( ServerEvent::ClientInput { client_id, data } } } - ClientMessage::InputPixels { - data, - cols, - rows, - width_px, - height_px, - } => { - let Some(geometry) = - crate::input::mouse::HostGeometry::new(cols, rows, width_px, height_px) - else { - warn!( - client_id, - cols, - rows, - width_px, - height_px, - "invalid pixel mouse geometry from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - }; - if data.len() > MAX_PIXEL_MOUSE_PAYLOAD - || crate::input::mouse::parse_report(&data).is_none() - { - warn!( - client_id, - size = data.len(), - max = MAX_PIXEL_MOUSE_PAYLOAD, - "invalid pixel mouse report from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - } - ServerEvent::ClientInputPixels { - client_id, - data, - geometry, - } - } - ClientMessage::InputEvents { events } => match input_event_limit(&events) { - InputEventLimit::WithinLimits => { - ServerEvent::ClientInputEvents { client_id, events } - } - InputEventLimit::TooManyEvents => { - warn!( - client_id, - count = events.len(), - "oversized input event batch from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - } - InputEventLimit::PasteTooLarge { size } => { - warn!( - client_id, - size, - max = MAX_INPUT_PAYLOAD, - "oversized structured paste from client, rejecting" - ); - ServerEvent::ClientPasteRejected { - client_id, - size, - max: MAX_INPUT_PAYLOAD, - } - } - InputEventLimit::InputPayloadTooLarge { size } => { - warn!( - client_id, - size, - max = MAX_INPUT_PAYLOAD, - "oversized structured input payload from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - } - }, ClientMessage::ObserveTerminal { target } => { ServerEvent::ClientObserveTerminal { client_id, target } } @@ -912,7 +902,11 @@ fn client_read_loop( transfer_id, image_id, }, - ClientMessage::ClipboardImage { extension, data } => { + ClientMessage::ClipboardImage { + target, + extension, + data, + } => { if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { warn!( client_id, @@ -925,6 +919,7 @@ fn client_read_loop( } else { ServerEvent::ClientClipboardImage { client_id, + target, extension, data, } @@ -935,6 +930,7 @@ fn client_read_loop( rows, cell_width_px, cell_height_px, + pixel_mouse, } => { let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); ServerEvent::ClientResize { @@ -943,9 +939,169 @@ fn client_read_loop( rows: clamped_rows, cell_width_px, cell_height_px, + pixel_mouse, + } + } + ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse, + } => ServerEvent::ClientShellResize { + client_id, + surface_cols: surface_size.cols.max(1), + surface_rows: surface_size.rows.max(1), + cell_width_px, + cell_height_px, + pixel_mouse, + }, + ClientMessage::ClientShellHostTheme { update } => { + if matches!( + &update, + crate::protocol::ClientHostThemeUpdate::PaletteColors(colors) + if colors.len() > 256 + ) { + warn!(client_id, "invalid client shell host theme update, closing"); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + ServerEvent::ClientShellHostTheme { client_id, update } + } + ClientMessage::ClientShellFocus { focused } => { + ServerEvent::ClientShellFocus { client_id, focused } + } + ClientMessage::ClientShellMouseCapture { enabled } => { + ServerEvent::ClientShellMouseCapture { client_id, enabled } + } + ClientMessage::ClientShellPaneInput { pane_id, events } => { + match pane_input_event_limit(&events) { + InputEventLimit::WithinLimits => ServerEvent::ClientShellPaneInput { + client_id, + pane_id, + events, + }, + InputEventLimit::TooManyEvents => { + warn!( + client_id, + count = events.len(), + "oversized targeted pane input batch, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + InputEventLimit::PasteTooLarge { size } => { + warn!( + client_id, + size, + max = MAX_INPUT_PAYLOAD, + "oversized targeted pane paste, rejecting" + ); + ServerEvent::ClientPasteRejected { + client_id, + size, + max: MAX_INPUT_PAYLOAD, + } + } + InputEventLimit::InputPayloadTooLarge { size } => { + warn!( + client_id, + size, + max = MAX_INPUT_PAYLOAD, + "oversized targeted pane input, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } } } - ClientMessage::Detach => ServerEvent::ClientDetach { client_id }, + ClientMessage::ClientShellPopupInput { + terminal_id, + events, + } => match pane_input_event_limit(&events) { + InputEventLimit::WithinLimits => ServerEvent::ClientShellPopupInput { + client_id, + terminal_id, + events, + }, + InputEventLimit::TooManyEvents => { + warn!( + client_id, + count = events.len(), + "oversized popup input batch, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + InputEventLimit::PasteTooLarge { size } => { + warn!( + client_id, + size, + max = MAX_INPUT_PAYLOAD, + "oversized popup paste, rejecting" + ); + ServerEvent::ClientPasteRejected { + client_id, + size, + max: MAX_INPUT_PAYLOAD, + } + } + InputEventLimit::InputPayloadTooLarge { size } => { + warn!( + client_id, + size, + max = MAX_INPUT_PAYLOAD, + "oversized popup input, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + }, + ClientMessage::ClientShellEndpointRequest { boot_id, request } => { + if boot_id.len() > crate::server::client_commands::MAX_ENDPOINT_BOOT_ID_BYTES + || request.len() > crate::server::client_commands::MAX_ENDPOINT_COMMAND_BYTES + { + warn!( + client_id, + boot_id_size = boot_id.len(), + request_size = request.len(), + "oversized client shell endpoint command, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + let Ok(request) = serde_json::from_str::<crate::api::schema::Request>(&request) + else { + warn!(client_id, "invalid client shell endpoint command, closing"); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + }; + if request.id.len() > crate::server::client_commands::MAX_ENDPOINT_REQUEST_ID_BYTES + { + warn!( + client_id, + "oversized client shell endpoint request id, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request: Box::new(request), + } + } + ClientMessage::Detach => { + let _ = server_event_tx.blocking_send(ServerEvent::ClientDetach { client_id }); + break; + } ClientMessage::AttachTerminal { terminal_id, takeover, @@ -970,8 +1126,22 @@ fn client_read_loop( row, modifiers, }, - ClientMessage::Hello { .. } => { - // Duplicate Hello — ignore. + ClientMessage::AttachMouse { + kind, + position, + geometry, + modifiers, + lines, + } => ServerEvent::ClientAttachMouse { + client_id, + kind, + position, + geometry, + modifiers, + lines, + }, + ClientMessage::TerminalHello { .. } | ClientMessage::ClientShellHello { .. } => { + // Duplicate handshake — ignore. continue; } }; @@ -1262,54 +1432,6 @@ mod tests { } #[test] - fn parse_client_keybindings_accepts_local_profile() { - let keybindings = parse_client_keybindings(ClientKeybindings::Local { - keys_toml: r#" -[keys] -prefix = "ctrl+a" -new_tab = "prefix+t" - -[[keys.command]] -key = "prefix+g" -command = "lazygit" -"# - .to_owned(), - }) - .expect("valid client keybindings") - .expect("local profile"); - - assert_eq!(keybindings.prefix.0, crossterm::event::KeyCode::Char('a')); - assert!(keybindings - .keybinds - .new_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+t")); - assert!(keybindings.keybinds.custom_commands.is_empty()); - } - - #[test] - fn parse_client_keybindings_tolerates_disabled_bindings() { - let keybindings = parse_client_keybindings(ClientKeybindings::Local { - keys_toml: r#" -[keys] -new_tab = "ctrl+notakey" -"# - .to_owned(), - }) - .expect("diagnostic-only client keybindings should be accepted") - .expect("local profile"); - - assert!(keybindings.keybinds.new_tab.bindings.is_empty()); - assert!(keybindings - .keybinds - .next_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+n")); - } - - #[test] fn handshake_negotiates_terminal_ansi_encoding() { let (mut client_stream, server_stream, _path) = local_stream_pair("client-handshake-ansi"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); @@ -1321,15 +1443,13 @@ new_tab = "ctrl+notakey" protocol::write_message( &mut client_stream, - &ClientMessage::Hello { + &ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 100, rows: 30, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::TerminalAnsi, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: true, }, ) .expect("write hello"); @@ -1359,19 +1479,13 @@ new_tab = "ctrl+notakey" rows, cell_width_px, cell_height_px, - render_encoding, - keybindings, - direct_attach_requested, - direct_graphics, + pixel_mouse, writer, } => { assert_eq!(client_id, 42); assert_eq!((cols, rows), (100, 30)); assert_eq!((cell_width_px, cell_height_px), (8, 16)); - assert_eq!(render_encoding, RenderEncoding::TerminalAnsi); - assert!(keybindings.is_none()); - assert!(!direct_attach_requested); - assert!(!direct_graphics); + assert!(pixel_mouse); drop(writer); } other => panic!("expected ClientConnected, got {other:?}"), @@ -1386,59 +1500,66 @@ new_tab = "ctrl+notakey" } #[test] - fn handshake_marks_terminal_attach_launch_mode() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-handshake-terminal-attach"); + fn dedicated_client_shell_handshake_uses_surface_viewport() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-shell-handshake"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let handshake_quit = should_quit.clone(); let handle = std::thread::spawn(move || { - handle_client_handshake(server_stream, 42, &server_event_tx, &handshake_quit) + handle_client_handshake(server_stream, 43, &server_event_tx, &handshake_quit) }); protocol::write_message( &mut client_stream, - &ClientMessage::Hello { + &ClientMessage::ClientShellHello { version: PROTOCOL_VERSION, - cols: 100, - rows: 30, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::TerminalAnsi, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::TerminalAttach, + surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 29 }, + pixel_mouse: true, + direct_graphics: true, + endpoint_keybindings: true, + mouse_capture: true, }, ) - .expect("write hello"); + .expect("write shell hello"); let welcome: ServerMessage = protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome"); - match welcome { + assert!(matches!( + welcome, ServerMessage::Welcome { - version, - encoding, - error, - } => { - assert_eq!(version, PROTOCOL_VERSION); - assert_eq!(encoding, RenderEncoding::TerminalAnsi); - assert_eq!(error, None); + encoding: RenderEncoding::SemanticFrame, + error: None, + .. } - other => panic!("expected Welcome, got {other:?}"), - } - + )); match server_event_rx .blocking_recv() - .expect("client connected event") + .expect("client shell connected event") { - ServerEvent::ClientConnected { - direct_attach_requested, + ServerEvent::ClientShellConnected { + client_id, + surface_cols, + surface_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, writer, - .. } => { - assert!(direct_attach_requested); + assert_eq!(client_id, 43); + assert_eq!((surface_cols, surface_rows), (80, 29)); + assert_eq!((cell_width_px, cell_height_px), (8, 16)); + assert!(pixel_mouse); + assert!(direct_graphics); + assert!(endpoint_keybindings); + assert!(mouse_capture); drop(writer); } - other => panic!("expected ClientConnected, got {other:?}"), + other => panic!("expected ClientShellConnected, got {other:?}"), } drop(client_stream); @@ -1450,6 +1571,85 @@ new_tab = "ctrl+notakey" } #[test] + fn dedicated_client_shell_handshake_rejects_empty_surface() { + let (mut client_stream, server_stream, _path) = + local_stream_pair("client-shell-empty-surface"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let handshake_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + handle_client_handshake(server_stream, 43, &server_event_tx, &handshake_quit) + }); + + protocol::write_message( + &mut client_stream, + &ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 0, rows: 29 }, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + }, + ) + .expect("write empty shell hello"); + + let welcome: ServerMessage = + protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome"); + assert!(matches!( + welcome, + ServerMessage::Welcome { + encoding: RenderEncoding::SemanticFrame, + error: Some(error), + .. + } if error.contains("non-empty pane surface") + )); + handle + .join() + .expect("handshake thread join") + .expect("handshake thread result"); + assert!(server_event_rx.try_recv().is_err()); + } + + #[test] + fn client_read_loop_stops_after_detach() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-detach"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + + let mut messages = Vec::new(); + protocol::write_message(&mut messages, &ClientMessage::Detach).unwrap(); + protocol::write_message( + &mut messages, + &ClientMessage::ClipboardImage { + target: crate::protocol::ClientClipboardImageTarget::DirectTerminal, + extension: "png".into(), + data: vec![1, 2, 3], + }, + ) + .unwrap(); + client_stream + .write_all(&messages) + .expect("write detach and trailing message"); + + assert!(matches!( + recv_server_event(&mut server_event_rx, "detach event"), + ServerEvent::ClientDetach { client_id: 7 } + )); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + assert!(server_event_rx.try_recv().is_err()); + } + + #[test] fn client_read_loop_rejects_oversized_bracketed_paste_without_disconnect() { let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-oversized"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); @@ -1556,76 +1756,6 @@ new_tab = "ctrl+notakey" } #[test] - fn client_read_loop_disconnects_invalid_pixel_mouse_geometry() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-invalid-pixel-geometry"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - protocol::write_message( - &mut client_stream, - &ClientMessage::InputPixels { - data: b"\x1b[<35;1;1M".to_vec(), - cols: 0, - rows: 24, - width_px: 800, - height_px: 480, - }, - ) - .expect("write invalid pixel geometry"); - - assert!(matches!( - recv_server_event(&mut server_event_rx, "invalid pixel geometry disconnect"), - ServerEvent::ClientDisconnected { client_id: 7 } - )); - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] - fn client_read_loop_disconnects_invalid_pixel_mouse_report() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-invalid-pixel-report"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - protocol::write_message( - &mut client_stream, - &ClientMessage::InputPixels { - data: vec![b'x'; MAX_PIXEL_MOUSE_PAYLOAD + 1], - cols: 80, - rows: 24, - width_px: 800, - height_px: 480, - }, - ) - .expect("write invalid pixel report"); - - assert!(matches!( - recv_server_event(&mut server_event_rx, "invalid pixel report disconnect"), - ServerEvent::ClientDisconnected { client_id: 7 } - )); - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] fn client_read_loop_disconnects_marker_wrapped_invalid_utf8() { let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-invalid-utf8-paste"); @@ -1655,51 +1785,42 @@ new_tab = "ctrl+notakey" } #[test] - fn client_read_loop_forwards_input_events() { - let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-events"); + fn client_read_loop_uses_authoritative_shell_resize_surface() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-resize"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let read_quit = should_quit.clone(); let handle = std::thread::spawn(move || { client_read_loop(server_stream, 7, &server_event_tx, &read_quit) }); - let events = vec![ - 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, - }, - ClientInputEvent::FocusGained, - ]; protocol::write_message( &mut client_stream, - &ClientMessage::InputEvents { - events: events.clone(), + &ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 60, rows: 15 }, + pixel_mouse: true, }, ) - .expect("write input events"); - - match server_event_rx - .blocking_recv() - .expect("client input events event") - { - ServerEvent::ClientInputEvents { - client_id, - events: actual, - } => { - assert_eq!(client_id, 7); - assert_eq!(actual, events); + .expect("write shell resize"); + assert!(matches!( + recv_server_event(&mut server_event_rx, "shell resize"), + ServerEvent::ClientShellResize { + client_id: 7, + surface_cols: 60, + surface_rows: 15, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: true, } - other => panic!("expected ClientInputEvents, got {other:?}"), - } + )); - drop(client_stream); - should_quit.store(true, Ordering::Release); + protocol::write_message(&mut client_stream, &ClientMessage::Detach).expect("write detach"); + assert!(matches!( + recv_server_event(&mut server_event_rx, "detach event"), + ServerEvent::ClientDetach { client_id: 7 } + )); handle .join() .expect("read thread join") @@ -1707,9 +1828,8 @@ new_tab = "ctrl+notakey" } #[test] - fn client_read_loop_rejects_oversized_input_event_batch() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-oversized-events"); + fn client_read_loop_keeps_single_host_theme_updates_ordered_and_palette_bounded() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-host-theme"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let read_quit = should_quit.clone(); @@ -1717,113 +1837,64 @@ new_tab = "ctrl+notakey" client_read_loop(server_stream, 7, &server_event_tx, &read_quit) }); + let colors = (0..=u8::MAX) + .map(|index| { + ( + index, + crate::protocol::ClientHostColor { + r: index, + g: 0, + b: 0, + }, + ) + }) + .collect(); protocol::write_message( &mut client_stream, - &ClientMessage::InputEvents { - events: vec![ClientInputEvent::FocusGained; MAX_INPUT_EVENT_BATCH + 1], + &ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors), }, ) - .expect("write oversized input events"); - - match server_event_rx - .blocking_recv() - .expect("client disconnected event") - { - ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7), - other => panic!("expected ClientDisconnected, got {other:?}"), - } - - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] - fn client_read_loop_rejects_oversized_input_event_paste() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-oversized-paste"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - let maximum = vec![ - ClientInputEvent::Paste { - text: "x".repeat(MAX_INPUT_PAYLOAD / 2), - }, - ClientInputEvent::Paste { - text: "y".repeat(MAX_INPUT_PAYLOAD - (MAX_INPUT_PAYLOAD / 2)), - }, - ]; + .expect("write bounded palette update"); protocol::write_message( &mut client_stream, - &ClientMessage::InputEvents { - events: maximum.clone(), - }, - ) - .expect("write maximum-size structured paste"); - - match recv_server_event(&mut server_event_rx, "maximum-size structured paste") { - ServerEvent::ClientInputEvents { client_id, events } => { - assert_eq!(client_id, 7); - assert_eq!(events, maximum); - } - other => panic!("expected maximum-size ClientInputEvents, got {other:?}"), - } - - let oversized = vec![ - ClientInputEvent::FocusGained, - ClientInputEvent::Paste { - text: "x".repeat(MAX_INPUT_PAYLOAD / 2), - }, - ClientInputEvent::Paste { - text: "y".repeat(MAX_INPUT_PAYLOAD - (MAX_INPUT_PAYLOAD / 2) + 1), + &ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::Appearance( + crate::protocol::ClientHostAppearance::Dark, + ), }, - ClientInputEvent::FocusLost, - ClientInputEvent::Paste { - text: "tail".to_owned(), - }, - ]; - protocol::write_message( - &mut client_stream, - &ClientMessage::InputEvents { events: oversized }, ) - .expect("write oversized structured paste"); + .expect("write ordered appearance update"); - match recv_server_event(&mut server_event_rx, "oversized structured paste rejection") { - ServerEvent::ClientPasteRejected { - client_id, - size, - max, - } => { - assert_eq!(client_id, 7); - assert_eq!(size, MAX_INPUT_PAYLOAD + 5); - assert_eq!(max, MAX_INPUT_PAYLOAD); + assert!(matches!( + recv_server_event(&mut server_event_rx, "bounded palette update"), + ServerEvent::ClientShellHostTheme { + client_id: 7, + update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors), + } if colors.len() == 256 + )); + assert!(matches!( + recv_server_event(&mut server_event_rx, "ordered appearance update"), + ServerEvent::ClientShellHostTheme { + client_id: 7, + update: crate::protocol::ClientHostThemeUpdate::Appearance( + crate::protocol::ClientHostAppearance::Dark + ), } - other => panic!("expected ClientPasteRejected, got {other:?}"), - } + )); - let valid = vec![ClientInputEvent::FocusGained]; + let colors = vec![(0, crate::protocol::ClientHostColor { r: 0, g: 0, b: 0 },); 257]; protocol::write_message( &mut client_stream, - &ClientMessage::InputEvents { - events: valid.clone(), + &ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors), }, ) - .expect("write valid structured input after rejection"); - - match recv_server_event(&mut server_event_rx, "structured input after rejection") { - ServerEvent::ClientInputEvents { client_id, events } => { - assert_eq!(client_id, 7); - assert_eq!(events, valid); - } - other => panic!("expected ClientInputEvents after rejection, got {other:?}"), - } + .expect("write oversized palette update"); + assert!(matches!( + recv_server_event(&mut server_event_rx, "oversized palette disconnect"), + ServerEvent::ClientDisconnected { client_id: 7 } + )); drop(client_stream); should_quit.store(true, Ordering::Release); @@ -1834,40 +1905,18 @@ new_tab = "ctrl+notakey" } #[test] - fn structured_input_limits_charge_grouped_repeats_and_text_payloads() { - let grouped = ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), + fn pane_input_limits_charge_scroll_repeats() { + let oversized_scroll = ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::ScrollUp, + position: crate::protocol::ClientMousePosition::Cell { column: 0, row: 0 }, + geometry: None, modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: (MAX_INPUT_EVENT_BATCH + 1) as u16, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, + lines: (MAX_INPUT_EVENT_BATCH + 1) as u16, }; assert_eq!( - input_event_limit(&[grouped]), + pane_input_event_limit(&[oversized_scroll]), InputEventLimit::TooManyEvents ); - - let repeated_text = ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: MAX_INPUT_EVENT_BATCH as u16, - generated_text: Some("x".repeat((MAX_INPUT_PAYLOAD / MAX_INPUT_EVENT_BATCH) + 1)), - source: crate::protocol::ClientKeySource::Synthesized, - }; - assert!(matches!( - input_event_limit(&[repeated_text]), - InputEventLimit::InputPayloadTooLarge { size } if size > MAX_INPUT_PAYLOAD - )); - - let text = ClientInputEvent::TextCommit("x".repeat(MAX_INPUT_PAYLOAD + 1)); - assert_eq!( - input_event_limit(&[text]), - InputEventLimit::InputPayloadTooLarge { - size: MAX_INPUT_PAYLOAD + 1 - } - ); } #[test] diff --git a/src/server/clients.rs b/src/server/clients.rs index 0d3d9ae0..d380e2c3 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -1,13 +1,17 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::protocol::RenderEncoding; +use crate::protocol::{ + ClientKeyCode, ClientKeyKind, ClientMouseButton, ClientMouseKind, ClientPaneInputEvent, + RenderEncoding, +}; use crate::server::client_transport::ClientWriter; use crate::server::render_stream::ClientRenderState; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ClientConnectionMode { - App, + ClientShell, + TerminalPending, TerminalAttach { terminal_id: String }, TerminalObserve { terminal_id: String }, } @@ -20,6 +24,35 @@ pub(crate) type RenderTarget = ( ClientConnectionMode, ); +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClientShellInputTarget { + Pane(String), + Popup(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum ClientShellPressId { + PhysicalKey(u32), + SemanticKey(ClientKeyCode), + Mouse(ClientMouseButton), +} + +fn client_shell_key_press_id( + code: &ClientKeyCode, + physical_key_id: Option<u32>, +) -> ClientShellPressId { + physical_key_id.map_or_else( + || ClientShellPressId::SemanticKey(code.clone()), + ClientShellPressId::PhysicalKey, + ) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ClientShellHeldInput { + pub(crate) target: ClientShellInputTarget, + pub(crate) release: ClientPaneInputEvent, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum DeferredRender { #[default] @@ -29,48 +62,54 @@ pub(crate) enum DeferredRender { /// A connected client tracked by the server. pub(crate) struct ClientConnection { - /// Whether this connection is the full app client or a direct terminal attach. + /// Whether this connection owns the Herdr shell or one direct terminal stream. pub(crate) mode: ClientConnectionMode, - /// True after the handshake for clients that will switch into direct terminal attach mode. - pub(crate) pending_terminal_attach: bool, - /// Client-local app keybindings. None means use the server's keybindings. - pub(crate) keybindings: Option<Box<crate::config::LiveKeybindConfig>>, /// The client's terminal size after clamping. pub(crate) terminal_size: (u16, u16), /// Pixel size of one client terminal cell. pub(crate) cell_size: crate::kitty_graphics::HostCellSize, - /// Last known host terminal default colors for this client. - pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, - /// Last known host terminal appearance for this client. - pub(crate) host_terminal_appearance: Option<crate::terminal_theme::HostAppearance>, - /// True when appearance came from an explicit host color-scheme report. - pub(crate) host_terminal_appearance_explicit: bool, - /// Last reported focus state for this client's outer terminal. - pub(crate) outer_terminal_focus: Option<bool>, - /// Stateful parser for app-client input split across transport reads. - pub(crate) raw_input: crate::raw_input::RawInputFramer, /// Monotonic activity stamp used to choose the fallback foreground client. pub(crate) last_activity: u64, /// Render baseline for the negotiated client encoding. pub(crate) render_state: ClientRenderState, - /// Client-local host Kitty graphics cache. - pub(crate) graphics_cache: crate::kitty_graphics::HostGraphicsCache, + /// Image assets already included in the selected ClientShell scene. + pub(crate) shell_graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, /// Passive eligibility for audited local Kitty regular-file graphics. pub(crate) direct_graphics: bool, /// Whether this frontend preserves exact SGR pixel reports. pub(crate) pixel_mouse: bool, - /// Whether the next graphics frame must clear and rebuild host-side Kitty state. - pub(crate) graphics_surface_reset_pending: bool, + /// Last host terminal default colors reported by this client. + pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, + /// Last host light/dark appearance reported by this client. + pub(crate) host_terminal_appearance: Option<crate::terminal_theme::HostAppearance>, + /// Whether appearance came from an explicit host color-scheme report. + pub(crate) host_terminal_appearance_explicit: bool, + /// Last reported focus state for this client's outer terminal. + pub(crate) outer_terminal_focus: Option<bool>, + /// Last focused-pane report-all demand sent to a client-owned shell. + pub(crate) host_keyboard_report_all_active: Option<bool>, /// Whether an ordinary render was skipped because the render channel was full. pub(crate) render_pending: bool, + /// Whether this shell wants host mouse capture without pane demand. + pub(crate) shell_mouse_capture: bool, /// Last host mouse capture mode sent to this client. pub(crate) host_mouse_capture_active: Option<bool>, /// Last SGR pixel provenance mode sent to this client. pub(crate) host_sgr_pixels_active: Option<bool>, - /// Last Kitty report-all mode sent to this client's host terminal. - pub(crate) host_keyboard_report_all_active: Option<bool>, + /// Last keyboard protocol state sent to a directly attached terminal client. + pub(crate) host_keyboard_protocol_active: Option<(u16, u8)>, + /// Presses forwarded by this shell that need release on abrupt teardown. + shell_held_inputs: HashMap<ClientShellPressId, ClientShellHeldInput>, /// Temporary files staged from this client's local clipboard image pastes. pub(crate) staged_clipboard_files: Vec<PathBuf>, + /// Last coherent shell replacement sent to this client. + pub(crate) shell_snapshot: Option<crate::protocol::ClientShellSnapshot>, + /// Monotonic shell replacement revision for this connection. + pub(crate) shell_projection_revision: u64, + /// Whether this shell is waiting for one ordered endpoint command response. + pub(crate) shell_endpoint_command_in_flight: bool, + /// Whether this shell uses the endpoint-owned keymap rather than a client-owned keymap. + pub(crate) shell_uses_endpoint_keybindings: bool, /// Channels for sending framed ServerMessage data to the client writer thread. pub(crate) writer: Option<ClientWriter>, } @@ -80,62 +119,53 @@ impl ClientConnection { pub(crate) fn new( terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option<bool>, last_activity: u64, render_encoding: RenderEncoding, writer: Option<ClientWriter>, ) -> Self { Self::new_with_mode( - ClientConnectionMode::App, - None, + ClientConnectionMode::ClientShell, terminal_size, cell_size, - host_terminal_theme, - outer_terminal_focus, last_activity, render_encoding, - false, writer, ) } pub(crate) fn new_with_mode( mode: ClientConnectionMode, - keybindings: Option<Box<crate::config::LiveKeybindConfig>>, terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option<bool>, last_activity: u64, render_encoding: RenderEncoding, - pending_terminal_attach: bool, writer: Option<ClientWriter>, ) -> Self { Self { mode, - pending_terminal_attach, - keybindings, terminal_size, cell_size, - host_terminal_appearance: host_terminal_theme - .background - .map(crate::terminal_theme::RgbColor::inferred_appearance), - host_terminal_appearance_explicit: false, - host_terminal_theme, - outer_terminal_focus, - raw_input: crate::raw_input::RawInputFramer::default(), last_activity, render_state: ClientRenderState::new(render_encoding), - graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), + shell_graphics_delivery: crate::kitty_graphics::surface::DeliveryCache::default(), direct_graphics: false, pixel_mouse: false, - graphics_surface_reset_pending: false, + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + host_terminal_appearance: None, + host_terminal_appearance_explicit: false, + outer_terminal_focus: None, + host_keyboard_report_all_active: None, render_pending: false, + shell_mouse_capture: false, host_mouse_capture_active: None, host_sgr_pixels_active: None, - host_keyboard_report_all_active: None, + host_keyboard_protocol_active: None, + shell_held_inputs: HashMap::new(), staged_clipboard_files: Vec::new(), + shell_snapshot: None, + shell_projection_revision: 0, + shell_endpoint_command_in_flight: false, + shell_uses_endpoint_keybindings: false, writer, } } @@ -144,62 +174,151 @@ impl ClientConnection { self.render_state.request_repaint(); } - pub(crate) fn deferred_render(&self) -> DeferredRender { - if self.render_pending { - DeferredRender::Full - } else { - DeferredRender::None + pub(crate) fn track_shell_input( + &mut self, + target: ClientShellInputTarget, + events: &[ClientPaneInputEvent], + ) { + for event in events { + match event { + ClientPaneInputEvent::Key { + code, + modifiers, + kind: ClientKeyKind::Press, + shifted_codepoint, + tracks_release: true, + physical_key_id, + .. + } => { + self.shell_held_inputs.insert( + client_shell_key_press_id(code, *physical_key_id), + ClientShellHeldInput { + target: target.clone(), + release: ClientPaneInputEvent::Key { + code: code.clone(), + modifiers: *modifiers, + kind: ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: *shifted_codepoint, + generated_text: None, + tracks_release: true, + physical_key_id: *physical_key_id, + }, + }, + ); + } + ClientPaneInputEvent::Key { + code, + kind: ClientKeyKind::Release, + physical_key_id, + .. + } => { + self.shell_held_inputs + .remove(&client_shell_key_press_id(code, *physical_key_id)); + } + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Down(button), + position, + geometry, + modifiers, + .. + } + | ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Drag(button), + position, + geometry, + modifiers, + .. + } => { + let id = ClientShellPressId::Mouse(*button); + if matches!( + event, + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Down(_), + .. + } + ) || self.shell_held_inputs.contains_key(&id) + { + self.shell_held_inputs.insert( + id, + ClientShellHeldInput { + target: target.clone(), + release: ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Up(*button), + position: *position, + geometry: *geometry, + modifiers: *modifiers, + lines: 1, + }, + }, + ); + } + } + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Up(button), + .. + } => { + self.shell_held_inputs + .remove(&ClientShellPressId::Mouse(*button)); + } + ClientPaneInputEvent::Key { + kind: ClientKeyKind::Press | ClientKeyKind::Repeat, + .. + } + | ClientPaneInputEvent::TextCommit(_) + | ClientPaneInputEvent::Mouse { .. } + | ClientPaneInputEvent::Paste(_) => {} + } } } - pub(crate) fn clear_deferred_render(&mut self) { - self.render_pending = false; - } - - pub(crate) fn defer_full_render(&mut self) { - self.render_pending = true; - } - - pub(crate) fn take_deferred_render(&mut self) -> DeferredRender { - let deferred = self.deferred_render(); - self.clear_deferred_render(); - deferred - } - - pub(crate) fn is_full_app_client(&self) -> bool { - matches!(self.mode, ClientConnectionMode::App) && !self.pending_terminal_attach + pub(crate) fn drain_shell_held_inputs(&mut self) -> Vec<ClientShellHeldInput> { + self.shell_held_inputs + .drain() + .map(|(_, held)| held) + .collect() } - pub(crate) fn request_semantic_redraw_after_input(&mut self) { - self.render_state.reset_semantic_input_baseline(); - } - - pub(crate) fn update_host_theme_from_events( + pub(crate) fn update_host_theme( &mut self, - events: &[crate::raw_input::RawInputEvent], + update: &crate::protocol::ClientHostThemeUpdate, ) -> bool { let mut next_theme = self.host_terminal_theme; let mut changed = false; - for event in events { - match event { - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - next_theme = next_theme.with_color(*kind, *color); - if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) - && !self.host_terminal_appearance_explicit - { - changed |= - self.set_host_appearance(Some(color.inferred_appearance()), false); + + match update { + crate::protocol::ClientHostThemeUpdate::DefaultColor { kind, color } => { + let kind = match kind { + crate::protocol::ClientHostDefaultColorKind::Foreground => { + crate::terminal_theme::DefaultColorKind::Foreground } - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - for &(index, color) in colors { - next_theme = next_theme.with_palette_color(index, color); + crate::protocol::ClientHostDefaultColorKind::Background => { + crate::terminal_theme::DefaultColorKind::Background } + }; + let color = (*color).into(); + next_theme = next_theme.with_color(kind, color); + if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) + && !self.host_terminal_appearance_explicit + { + changed |= self.set_host_appearance(Some(color.inferred_appearance()), false); } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - changed |= self.set_host_appearance(Some(*appearance), true); + } + crate::protocol::ClientHostThemeUpdate::PaletteColors(colors) => { + for &(index, color) in colors { + next_theme = next_theme.with_palette_color(index, color.into()); } - _ => {} + } + crate::protocol::ClientHostThemeUpdate::Appearance(appearance) => { + let appearance = match appearance { + crate::protocol::ClientHostAppearance::Dark => { + crate::terminal_theme::HostAppearance::Dark + } + crate::protocol::ClientHostAppearance::Light => { + crate::terminal_theme::HostAppearance::Light + } + }; + changed |= self.set_host_appearance(Some(appearance), true); } } @@ -228,41 +347,37 @@ impl ClientConnection { true } - pub(crate) fn update_outer_focus_from_events( - &mut self, - events: &[crate::raw_input::RawInputEvent], - ) -> Option<bool> { - let next_focus = events - .iter() - .filter_map(|event| match event { - crate::raw_input::RawInputEvent::OuterFocusGained => Some(true), - crate::raw_input::RawInputEvent::OuterFocusLost => Some(false), - _ => None, - }) - .next_back()?; - - self.outer_terminal_focus = Some(next_focus); - Some(next_focus) + pub(crate) fn deferred_render(&self) -> DeferredRender { + if self.render_pending { + DeferredRender::Full + } else { + DeferredRender::None + } } -} -pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool { - events.iter().any(|event| { - matches!( - event, - crate::raw_input::RawInputEvent::Key(_) - | crate::raw_input::RawInputEvent::Text(_) - | crate::raw_input::RawInputEvent::Mouse(_) - | crate::raw_input::RawInputEvent::Paste(_) - | crate::raw_input::RawInputEvent::OuterFocusGained - ) - }) + pub(crate) fn clear_deferred_render(&mut self) { + self.render_pending = false; + } + + pub(crate) fn defer_full_render(&mut self) { + self.render_pending = true; + } + + pub(crate) fn take_deferred_render(&mut self) -> DeferredRender { + let deferred = self.deferred_render(); + self.clear_deferred_render(); + deferred + } + + pub(crate) fn is_shell_client(&self) -> bool { + matches!(self.mode, ClientConnectionMode::ClientShell) + } } -pub(crate) fn latest_app_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> { +pub(crate) fn latest_shell_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> { clients .iter() - .filter(|(_, client)| client.is_full_app_client()) + .filter(|(_, client)| client.is_shell_client()) .max_by_key(|(_, client)| client.last_activity) .map(|(&client_id, _)| client_id) } @@ -293,7 +408,7 @@ pub(crate) fn render_targets( .iter() .filter(|(_, client)| { client.writer.is_some() - && (client.is_full_app_client() + && (client.is_shell_client() || matches!( client.mode, ClientConnectionMode::TerminalAttach { .. } @@ -314,3 +429,73 @@ pub(crate) fn render_targets( targets.sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id)); targets } + +#[cfg(test)] +mod tests { + use super::*; + + fn shell_client() -> ClientConnection { + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + crate::protocol::RenderEncoding::SemanticFrame, + None, + ) + } + + #[test] + fn semantic_text_press_does_not_create_a_server_release_lease() { + let mut client = shell_client(); + client.track_shell_input( + ClientShellInputTarget::Pane("w1:p1".into()), + &[ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: Some("x".into()), + tracks_release: false, + physical_key_id: None, + }], + ); + + assert!(client.drain_shell_held_inputs().is_empty()); + } + + #[test] + fn physical_keys_with_the_same_semantic_code_keep_distinct_release_leases() { + let mut client = shell_client(); + let key = |kind, physical_key_id| ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: Some(physical_key_id), + }; + client.track_shell_input( + ClientShellInputTarget::Pane("w1:p1".into()), + &[ + key(ClientKeyKind::Press, 13), + key(ClientKeyKind::Press, 108), + key(ClientKeyKind::Release, 13), + ], + ); + + let held = client.drain_shell_held_inputs(); + assert_eq!(held.len(), 1); + assert!(matches!( + &held[0].release, + ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + kind: ClientKeyKind::Release, + physical_key_id: Some(108), + .. + } + )); + } +} 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; diff --git a/src/server/headless/bootstrap.rs b/src/server/headless/bootstrap.rs new file mode 100644 index 00000000..638b1b2d --- /dev/null +++ b/src/server/headless/bootstrap.rs @@ -0,0 +1,218 @@ +use super::*; + +/// Run the headless server. This is the entry point called from main.rs. +pub fn run_server() -> io::Result<()> { + init_logging(); + crate::platform::raise_server_nofile_limit(); + + let args: Vec<String> = std::env::args().collect(); + if args.get(2).map(String::as_str) == Some("--handoff-import") { + let socket_path = args + .get(3) + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?; + let token = args + .get(4) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?; + return run_handoff_import_server(&socket_path, token); + } + + let loaded_config = config::Config::load(); + let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let event_hub = api::EventHub::default(); + let should_quit = Arc::new(AtomicBool::new(false)); + + // Start the JSON API socket server. + let _api_server = match api::start_server_with_stop_control( + api_tx.clone(), + event_hub.clone(), + should_quit.clone(), + ) { + Ok(server) => server, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + eprintln!("error: herdr server is already running"); + eprintln!("api socket: {}", api::socket_path().display()); + std::process::exit(1); + } + Err(err) => return Err(err), + }; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + + let result = rt.block_on(async { + // Create the App (with AppState, event channels, etc.). + let mut app = app::App::new( + &loaded_config.config, + app::AppPolicy::PRODUCTION, + config::config_diagnostic_summary(&loaded_config.diagnostics), + api_rx, + event_hub, + ); + seed_startup_workspace_if_empty(&mut app); + + // Create the headless server. + let mut server = match HeadlessServer::new( + app, + &loaded_config.diagnostics, + Some(api_tx.clone()), + Some(_api_server), + should_quit, + ) { + Ok(server) => server, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + eprintln!("error: herdr server is already running"); + eprintln!("client socket: {}", client_socket_path().display()); + std::process::exit(1); + } + Err(err) => return Err(err), + }; + + info!( + api_socket = %api::socket_path().display(), + client_socket = %client_socket_path().display(), + "herdr server started" + ); + print_ready_message(&api::socket_path(), &client_socket_path()); + server.app.run_plugin_startup_hooks(); + + server.run().await + }); + + rt.shutdown_timeout(Duration::from_millis(100)); + crate::logging::shutdown("server"); + result +} + +fn seed_startup_workspace_if_empty(app: &mut app::App) { + let Some(cwd) = take_startup_cwd() else { + return; + }; + + if !app.state.workspaces.is_empty() { + info!( + cwd = %cwd.display(), + "restored session already has workspaces; ignoring startup cwd" + ); + return; + } + + match app.create_workspace_with_options(cwd.clone(), true) { + Ok(_) => { + info!(cwd = %cwd.display(), "created startup workspace"); + } + Err(err) => { + warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace"); + app.state.mode = app::Mode::Navigate; + } + } +} + +fn take_startup_cwd() -> Option<PathBuf> { + let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?; + std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR); + (!cwd.is_empty()).then(|| PathBuf::from(cwd)) +} + +#[cfg(unix)] +fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> { + let loaded_config = config::Config::load(); + let mut received = crate::server::handoff::receive(socket_path, token)?; + crate::server::handoff::log_import_result(received.manifest.panes.len()); + + let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let event_hub = api::EventHub::default(); + let should_quit = Arc::new(AtomicBool::new(false)); + + let mut imports = HashMap::new(); + for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { + let pane_id = pane.pane_id; + imports.insert( + pane_id, + crate::handoff_runtime::ImportedHandoffRuntime { + master_fd: fd, + state: pane, + }, + ); + } + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + + let result = rt.block_on(async { + let app = app::App::new_from_handoff( + &loaded_config.config, + config::config_diagnostic_summary(&loaded_config.diagnostics), + api_rx, + event_hub.clone(), + &received.manifest.snapshot, + &mut imports, + )?; + crate::server::handoff::report_restored(&mut received.stream)?; + if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") { + return Err(io::Error::other( + "test handoff import failure after restored", + )); + } + wait_for_old_public_sockets_to_close(Duration::from_secs(5))?; + + let api_server = api::start_server_with_stop_control( + api_tx.clone(), + event_hub.clone(), + should_quit.clone(), + )?; + let mut server = HeadlessServer::new( + app, + &loaded_config.diagnostics, + Some(api_tx.clone()), + Some(api_server), + should_quit, + )?; + // Carried across before any client attaches, so the first title sent is + // the override rather than the configured one it replaced. + server.api_window_title = received.manifest.api_window_title.take(); + crate::server::handoff::report_ready(&mut received.stream)?; + crate::server::handoff::wait_committed(&mut received.stream)?; + server.app.assume_handoff_ownership(); + server.app.unpause_handoff_readers(); + server.pending_handoff_repaint_nudge = true; + if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) { + warn!(err = %err, "failed to report handoff ownership; continuing as owner"); + } + info!("handoff import server started"); + print_ready_message(&api::socket_path(), &client_socket_path()); + server.app.run_plugin_startup_hooks(); + server.run().await + }); + + rt.shutdown_timeout(Duration::from_millis(100)); + crate::logging::shutdown("server"); + result +} + +#[cfg(not(unix))] +fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> { + Err(io::Error::other("live handoff is only supported on Unix")) +} + +fn print_ready_message(api_socket: &Path, client_socket: &Path) { + eprintln!("herdr server running; you can use any herdr CLI command in another terminal."); + eprintln!("api socket: {}", api_socket.display()); + eprintln!("client socket: {}", client_socket.display()); + eprintln!( + "logs: {}", + crate::session::data_dir() + .join("herdr-server.log") + .display() + ); + eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`."); +} + +/// Initialize logging for the server process. +fn init_logging() { + crate::logging::init_file_logging("herdr-server.log"); +} diff --git a/src/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs new file mode 100644 index 00000000..ab1448c3 --- /dev/null +++ b/src/server/headless/lifecycle.rs @@ -0,0 +1,399 @@ +use super::*; + +const LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(6); + +pub(super) fn wait_for_live_handoff_response_write( + response_write_complete: Option<std::sync::mpsc::Receiver<()>>, +) { + let Some(response_write_complete) = response_write_complete else { + return; + }; + + match response_write_complete.recv_timeout(LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT) { + Ok(()) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + warn!("timed out waiting for live handoff response write; old server exiting"); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + warn!("live handoff response writer disconnected; old server exiting"); + } + } +} + +impl HeadlessServer { + #[cfg(unix)] + pub(super) fn perform_live_handoff( + &mut self, + params: crate::api::schema::ServerLiveHandoffParams, + ) -> io::Result<()> { + info!("starting live handoff"); + let import_exe = params.import_exe.as_deref().map(std::path::PathBuf::from); + let socket_path = crate::server::handoff::handoff_socket_path(); + let token = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let listener = match crate::server::handoff::bind_listener(&socket_path) { + Ok(listener) => listener, + Err(err) => { + self.handoff_in_progress = false; + return Err(err); + } + }; + + let mut pane_by_terminal = HashMap::new(); + for ws in &self.app.state.workspaces { + for tab in &ws.tabs { + for (pane_id, pane) in &tab.panes { + pane_by_terminal.insert(pane.attached_terminal_id.clone(), pane_id.raw()); + } + } + } + if pane_by_terminal.len() > crate::server::handoff::MAX_FDS_PER_HANDOFF { + let _ = std::fs::remove_file(&socket_path); + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "live handoff supports at most {} panes in one update; close panes or restart herdr normally", + crate::server::handoff::MAX_FDS_PER_HANDOFF + ), + )); + } + + self.handoff_in_progress = true; + self.disconnect_all_clients_for_handoff(); + let _ = reject_pending_client_connections(&self.client_listener); + + let mut paused_terminal_ids = Vec::new(); + for terminal_id in pane_by_terminal.keys() { + if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { + if let Err(err) = runtime.pause_handoff_reader(Duration::from_secs(2)) { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + paused_terminal_ids.push(terminal_id.clone()); + } + } + + let snapshot = crate::persist::capture( + &self.app.state.workspaces, + &self.app.state.terminals, + &self.app.terminal_runtimes, + self.app.state.active, + self.app.state.selected, + ); + + let mut handoff_entries = Vec::new(); + for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { + let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else { + continue; + }; + let mut handoff_runtime = runtime.handoff_runtime_state(pane_id); + let has_agent_session = self + .app + .state + .terminals + .get(terminal_id) + .is_some_and(|terminal| terminal.persisted_agent_session.is_some()); + if !has_agent_session { + handoff_runtime.initial_history_ansi = runtime.handoff_history_ansi(); + } + handoff_entries.push((terminal_id.clone(), handoff_runtime)); + } + + let panes = handoff_entries + .iter() + .map(|(_, runtime)| runtime.clone()) + .collect(); + let manifest = crate::server::handoff::manifest_for( + snapshot, + panes, + params.expected_protocol, + params.expected_version, + self.api_window_title.clone(), + ); + let mut import_child = match crate::server::handoff::spawn_handoff_import( + import_exe.as_deref(), + &socket_path, + &token, + ) { + Ok(child) => child, + Err(err) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + }; + let child_pid = import_child.id(); + info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server"); + + let mut fds = Vec::new(); + let duplicate_result = (|| { + for (terminal_id, _) in &handoff_entries { + let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else { + continue; + }; + fds.push(runtime.duplicate_handoff_fd()?); + } + Ok::<(), io::Error>(()) + })(); + if let Err(err) = duplicate_result { + for fd in fds { + let _ = unsafe { libc::close(fd) }; + } + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + + let mut stream = match crate::server::handoff::accept_and_validate_on( + listener, + &socket_path, + &token, + &manifest, + ) { + Ok(stream) => stream, + Err(err) => { + for fd in fds { + let _ = unsafe { libc::close(fd) }; + } + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + }; + + let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds); + for fd in fds { + let _ = unsafe { libc::close(fd) }; + } + if let Err(err) = send_result { + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + + if let Some(api_server) = &self.api_server { + let _ = api_server.remove_socket_file_if_owned(); + } else { + let _ = std::fs::remove_file(crate::api::socket_path()); + } + let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity); + if let Err(err) = crate::server::handoff::wait_ready(&mut stream) { + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + match self.wait_then_restore_public_sockets_after_failed_handoff() { + Ok(()) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + } + Err(restore_err) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(io::Error::other(format!( + "handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}" + ))); + } + } + return Err(io::Error::other(format!( + "handoff replacement server did not become ready: {err}" + ))); + } + if let Err(err) = crate::server::handoff::report_committed(&mut stream) { + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + match self.wait_then_restore_public_sockets_after_failed_handoff() { + Ok(()) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + } + Err(restore_err) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(io::Error::other(format!( + "handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}" + ))); + } + } + return Err(err); + } + + for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() { + if !pane_by_terminal.contains_key(&terminal_id) { + continue; + } + debug!(terminal = %terminal_id, "preserving pane runtime for handoff"); + runtime.preserve_for_handoff(); + } + crate::server::handoff::wait_owned_ack(&mut stream); + + Ok(()) + } + + pub(super) fn finish_live_handoff_shutdown(&mut self) { + self.shutting_down = true; + self.app.state.should_quit = true; + self.app.policy.persist_session = false; + info!("live handoff completed; old server exiting"); + } + + #[cfg(not(unix))] + pub(super) fn perform_live_handoff( + &mut self, + _params: crate::api::schema::ServerLiveHandoffParams, + ) -> io::Result<()> { + Err(io::Error::other("live handoff is only supported on Unix")) + } + + #[cfg(unix)] + fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { + let api_tx = self + .api_tx + .clone() + .ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?; + let api_server = api::start_server_with_stop_control( + api_tx, + self.app.event_hub.clone(), + self.should_quit.clone(), + )?; + + let client_path = client_socket_path(); + prepare_socket_path(&client_path)?; + let listener = bind_local_listener(&client_path)?; + restrict_socket_permissions(&client_path)?; + let client_socket_identity = socket_file_identity(&client_path)?; + listener.set_nonblocking(ListenerNonblockingMode::Accept)?; + + self.api_server = Some(api_server); + self.client_listener = listener; + self.client_socket_path = client_path; + self.client_socket_identity = client_socket_identity; + Ok(()) + } + + #[cfg(unix)] + fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { + let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2); + wait_for_old_public_sockets_to_close(timeout)?; + self.restore_public_sockets_after_failed_handoff() + } + + #[cfg(unix)] + fn rollback_handoff_before_commit( + &mut self, + socket_path: &Path, + paused_terminal_ids: &[crate::terminal::TerminalId], + ) { + for terminal_id in paused_terminal_ids { + if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { + runtime.set_handoff_reader_paused(false); + } + } + self.handoff_in_progress = false; + let _ = std::fs::remove_file(socket_path); + } + + #[cfg(unix)] + pub(super) fn nudge_handoff_panes_on_first_client_attach(&mut self) { + if !self.pending_handoff_repaint_nudge { + return; + } + self.pending_handoff_repaint_nudge = false; + self.app + .terminal_runtimes + .nudge_child_redraw_after_handoff(); + } + + #[cfg(not(unix))] + pub(super) fn nudge_handoff_panes_on_first_client_attach(&mut self) {} + /// Initiates graceful shutdown. + pub(super) fn initiate_shutdown(&mut self) { + if self.shutting_down { + return; + } + info!("server shutdown initiated"); + self.shutting_down = true; + + // Clear client-local host graphics, then send ServerShutdown to all connected clients. + let shutdown_msg = ServerMessage::ServerShutdown { + reason: Some("server is shutting down".to_owned()), + }; + self.send_to_all_clients(shutdown_msg); + + // Give client writer threads a moment to flush the shutdown message. + // A short sleep ensures the message is written to the socket before + // we close the connections. + std::thread::sleep(Duration::from_millis(50)); + + // Signal the main loop to exit. + self.should_quit.store(true, Ordering::Release); + self.app.state.should_quit = true; + } + + /// Completes the shutdown sequence: send ServerShutdown to clients, + /// close client connections, remove socket files, and clean up. + pub(super) async fn complete_shutdown(&mut self) -> io::Result<()> { + info!("completing server shutdown"); + self.reject_late_client_connections().await; + + // Send ServerShutdown to all remaining clients. + if !self.clients.is_empty() { + let shutdown_msg = ServerMessage::ServerShutdown { + reason: Some("server is shutting down".to_owned()), + }; + self.send_to_all_clients(shutdown_msg); + + // Give writer threads a moment to flush before closing. + std::thread::sleep(Duration::from_millis(50)); + } + + // Reject only the requests already queued when shutdown reached cleanup. + self.reject_queued_api_requests_for_shutdown(); + + // Close all client connections. + let staged_files = self + .clients + .drain() + .flat_map(|(_, client)| client.staged_clipboard_files) + .collect::<Vec<_>>(); + crate::server::clipboard_image::remove_files(staged_files); + + // Remove socket files. + self.cleanup_sockets()?; + + Ok(()) + } + + /// Removes socket files created by the server. + pub(super) fn cleanup_sockets(&self) -> io::Result<()> { + if let Err(err) = + remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity) + { + if err.kind() != io::ErrorKind::NotFound { + warn!( + path = %self.client_socket_path.display(), + err = %err, + "failed to remove client socket on shutdown" + ); + } + } + Ok(()) + } +} + +#[cfg(unix)] +pub(super) fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> { + let deadline = Instant::now() + timeout; + let api_socket = api::socket_path(); + let client_socket = client_socket_path(); + while Instant::now() < deadline { + let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok(); + let client_open = + client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok(); + if !api_open && !client_open { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(io::Error::new( + io::ErrorKind::TimedOut, + "old server sockets did not close before handoff import bind", + )) +} diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs new file mode 100644 index 00000000..3587b5cf --- /dev/null +++ b/src/server/headless/notifications.rs @@ -0,0 +1,674 @@ +use super::*; + +impl HeadlessServer { + fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { + self.app + .state + .workspaces + .iter() + .find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + self.app + .state + .terminals + .get(&pane.attached_terminal_id) + .map(|terminal| terminal.state) + }) + }) + .unwrap_or(crate::detect::AgentState::Unknown) + } + + fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option<String> { + self.app.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + self.app + .state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label()) + .map(str::to_string) + }) + }) + } + + fn forward_semantic_agent_notification( + &mut self, + update: &crate::app::actions::PaneStateUpdate, + ) -> bool { + if update.suppress_completion { + return false; + } + self.forward_semantic_agent_transition( + update.ws_idx, + update.pane_id, + update.previous_state, + update.state, + update.previous_agent_label.as_deref(), + update.agent_label.as_deref(), + update.known_agent.or(update.previous_known_agent), + ) + } + + pub(super) fn forward_semantic_agent_transition( + &mut self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + previous_state: crate::detect::AgentState, + state: crate::detect::AgentState, + previous_agent_label: Option<&str>, + agent_label: Option<&str>, + known_agent: Option<crate::detect::Agent>, + ) -> bool { + let Some(kind) = crate::app::actions::notification_toast_for_state_change_with_agent_labels( + false, + previous_state, + state, + previous_agent_label, + agent_label, + ) else { + return false; + }; + let Some(workspace) = self.app.state.workspaces.get(ws_idx) else { + return false; + }; + let Some(tab_idx) = workspace.find_tab_index_for_pane(pane_id) else { + return false; + }; + let Some(tab_number) = workspace.public_tab_number(tab_idx) else { + return false; + }; + let Some(public_pane_id) = self.app.public_pane_id(ws_idx, pane_id) else { + return false; + }; + let Some(agent_label) = agent_label.or(previous_agent_label) else { + return false; + }; + let (semantic_kind, event_text, sound) = match kind { + crate::app::state::ToastKind::NeedsAttention => ( + protocol::SemanticNotificationKind::NeedsAttention, + "needs attention", + Some(protocol::SemanticNotificationSound::Request), + ), + crate::app::state::ToastKind::Finished => ( + protocol::SemanticNotificationKind::Finished, + "finished", + Some(protocol::SemanticNotificationSound::Done), + ), + crate::app::state::ToastKind::UpdateInstalled => ( + protocol::SemanticNotificationKind::UpdateInstalled, + "updated", + None, + ), + }; + let workspace_id = workspace.id.clone(); + let tab_id = crate::workspace::public_tab_id_for_number(&workspace_id, tab_number); + let workspace_label = + workspace.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); + let context = + crate::app::actions::notification_context(workspace, &workspace_label, ws_idx, pane_id); + let agent = known_agent + .map(crate::detect::agent_label) + .map(str::to_owned); + self.send_to_client_shells(ServerMessage::SemanticNotification( + protocol::SemanticNotification { + kind: semantic_kind, + title: format!("{agent_label} {event_text}"), + body: non_empty_body(&context), + sound, + agent, + workspace_id: Some(workspace_id), + tab_id: Some(tab_id), + pane_id: Some(public_pane_id), + position: None, + }, + )) + } + + fn forward_pane_state_update_notifications_to_clients( + &mut self, + update: &crate::app::actions::PaneStateUpdate, + ) { + if self.app.state.toast_config.delay_seconds != 0 { + return; + } + + let is_active_tab = self + .app + .state + .pane_is_in_active_tab(update.ws_idx, update.pane_id); + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + update.previous_state, + update.state, + update.previous_agent_label.as_deref(), + update.agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + return; + } + let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( + suppress_active_tab_notifications, + update, + ) else { + return; + }; + let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else { + return; + }; + let Some(agent_label) = update.agent_label.as_deref() else { + return; + }; + let event_text = match kind { + crate::app::state::ToastKind::NeedsAttention => "needs attention", + crate::app::state::ToastKind::Finished => "finished", + crate::app::state::ToastKind::UpdateInstalled => "updated", + }; + let workspace_label = + ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); + let context = crate::app::actions::notification_context( + ws, + &workspace_label, + update.ws_idx, + update.pane_id, + ); + self.send_notify_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + format!("{agent_label} {event_text}"), + non_empty_body(&context), + ); + } + + pub(super) fn forward_agent_notification_delivery( + &mut self, + delivery: &crate::app::state::AgentNotificationDelivery, + ) { + if let Some(sound) = delivery.sound { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + if let Some(toast) = &delivery.client_notification { + self.send_notify_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + &toast.title, + non_empty_body(&toast.context), + ); + } + } + } + + pub(super) fn send_notify_to_foreground_client( + &mut self, + kind: protocol::NotifyKind, + message: impl Into<String>, + body: Option<String>, + ) -> bool { + self.send_to_foreground_client(ServerMessage::Notify { + kind, + message: message.into(), + body, + }) + } + + pub(super) fn send_flat_toast_to_foreground_client( + &mut self, + kind: protocol::NotifyKind, + message: impl AsRef<str>, + ) -> bool { + let (title, body) = crate::terminal_notify::split_message(message.as_ref()); + self.send_notify_to_foreground_client(kind, title, body.map(str::to_string)) + } + + pub(super) fn handle_notification_show_api( + &mut self, + id: String, + params: api::schema::NotificationShowParams, + ) -> String { + use api::schema::NotificationShowReason; + + let Some(title) = sanitize_notification_text(¶ms.title, 80) else { + return serde_json::to_string(&api::schema::ErrorResponse { + id, + error: api::schema::ErrorBody { + code: "invalid_params".into(), + message: "notification title is empty".into(), + }, + }) + .unwrap_or_else(|_| "{}".to_string()); + }; + + let body = params + .body + .as_deref() + .and_then(|body| sanitize_notification_text(body, 240)); + let has_client_shell = self.clients.values().any(ClientConnection::is_shell_client); + if !has_client_shell { + let reason = if self.app.state.toast_config.delivery == config::ToastDelivery::Off { + NotificationShowReason::Disabled + } else { + NotificationShowReason::NoForegroundClient + }; + return notification_show_result(id, false, reason); + } + if self.app.api_notification_rate_limited(Instant::now()) { + return notification_show_result(id, false, NotificationShowReason::RateLimited); + } + let sound = match params.sound { + api::schema::NotificationShowSound::None => None, + api::schema::NotificationShowSound::Done => { + Some(protocol::SemanticNotificationSound::Done) + } + api::schema::NotificationShowSound::Request => { + Some(protocol::SemanticNotificationSound::Request) + } + }; + let shown = self.send_to_client_shells(ServerMessage::SemanticNotification( + protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::Custom, + title, + body, + sound, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: params.position, + }, + )); + if shown { + self.app.mark_api_notification_shown(Instant::now()); + } + notification_show_result( + id, + shown, + if shown { + NotificationShowReason::Shown + } else { + NotificationShowReason::NoForegroundClient + }, + ) + } + + /// Handles a single internal event with forwarding logic for clipboard, + /// sound, and toast notifications to connected clients. + /// + /// ALL internal events MUST be routed through this method to ensure + /// clipboard/notify forwarding is never bypassed. Do not call + /// `self.app.handle_internal_event()` directly for any internal event + /// in the headless server — use this method instead. + /// + /// Returns true if the event changed visual state (requiring a re-render). + pub(super) fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { + match &ev { + AppEvent::TerminalBell { pane_id, count } => { + if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { + debug!( + pane = pane_id.raw(), + count, "dropped terminal bell without a foreground client" + ); + } + false + } + AppEvent::ClipboardWrite { content } => { + // Clipboard writes are client-local side effects. Forward them only to + // the foreground client instead of broadcasting to every attached client. + let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); + self.send_to_foreground_client(ServerMessage::Clipboard { data }); + false + } + AppEvent::StateChanged { pane_id, agent, .. } => { + // Capture toast before handling. + let toast_before = self.app.state.toast.clone(); + let pane_id_val = *pane_id; + let agent_val = *agent; + + // Find the previous effective state of this pane before the event + // is processed. Notifications must follow effective state changes, + // not raw fallback reports that may be masked by hook authority. + let prev_state = self.pane_effective_state(pane_id_val); + let prev_agent_label = self.pane_effective_agent_label(pane_id_val); + + // Handle the state change (updates pane state, sets toast on AppState). + // Headless mode disables local sound playback separately from the + // sound policy so reloads can keep server-side notification policy live. + self.sync_foreground_client_state(); + let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); + let suppress_completion = pane_updates + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); + for update in pane_updates + .iter() + .filter(|update| update.pane_id == pane_id_val) + { + self.forward_semantic_agent_notification(update); + } + + // Forward sound notification to clients when server-side sound policy allows it. + let is_active_tab = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .is_some_and(|ws| { + ws.find_tab_index_for_pane(pane_id_val) + .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) + }); + + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + let next_state = self.pane_effective_state(pane_id_val); + let next_agent_label = self.pane_effective_agent_label(pane_id_val); + + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && self.app.state.sound.allows(agent_val) + { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + next_agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && should_forward_toast_to_clients(self.app.state.toast_config.delivery) + { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + toast_message_from_state_change( + &self.app.state, + &self.app.terminal_runtimes, + pane_id_val, + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + ) + } + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_flat_toast_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + msg, + ); + } + + true + } + AppEvent::HookStateReported { + pane_id, + agent_label, + .. + } => { + // Hook reports can be stale or no-op after sequence rejection. + // Forward only effective state changes observed after handling. + let toast_before = self.app.state.toast.clone(); + let pane_id_val = *pane_id; + let agent_val = crate::detect::parse_agent_label(agent_label); + + // Capture the previous effective state for this pane. Hook reports + // are already folded into pane.state; raw hook transitions must not + // produce a second notification path. + let prev_state = self.pane_effective_state(pane_id_val); + let prev_agent_label = self.pane_effective_agent_label(pane_id_val); + + self.sync_foreground_client_state(); + let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); + let suppress_completion = pane_updates + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); + for update in pane_updates + .iter() + .filter(|update| update.pane_id == pane_id_val) + { + self.forward_semantic_agent_notification(update); + } + + // Forward sound notification based on the effective transition when + // server-side sound policy allows it. + let is_active_tab = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .is_some_and(|ws| { + ws.find_tab_index_for_pane(pane_id_val) + .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) + }); + + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + let next_state = self.pane_effective_state(pane_id_val); + let next_agent_label = self.pane_effective_agent_label(pane_id_val); + + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && self.app.state.sound.allows(agent_val) + { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + next_agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && should_forward_toast_to_clients(self.app.state.toast_config.delivery) + { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + toast_message_from_state_change( + &self.app.state, + &self.app.terminal_runtimes, + pane_id_val, + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + ) + } + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_flat_toast_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + msg, + ); + } + + true + } + AppEvent::UpdateReady { + version, + install_command, + } => { + let toast_before = self.app.state.toast.clone(); + let version = version.clone(); + let install_command = install_command.clone(); + + self.app.handle_internal_event(ev); + self.send_to_client_shells(ServerMessage::SemanticNotification( + protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::UpdateInstalled, + title: format!("Herdr v{version} available"), + body: Some(crate::update::update_install_instruction(&install_command)), + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }, + )); + + let toast_msg = + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + Some(format!( + "v{version} available: {}", + crate::update::update_install_instruction(&install_command) + )) + } + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_flat_toast_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + msg, + ); + } + + true + } + AppEvent::PaneDied { pane_id } => { + let pane_id_val = *pane_id; + let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + tab.panes + .get(pane_id) + .map(|pane| pane.attached_terminal_id.to_string()) + }) + }); + if let Some(update) = self + .app + .state + .publish_pane_process_exit_if_agent(pane_id_val) + { + self.app.emit_pane_state_update(&update); + self.forward_semantic_agent_notification(&update); + self.forward_pane_state_update_notifications_to_clients(&update); + } + + self.app.handle_internal_event(ev); + + if self.app.find_pane(pane_id_val).is_none() { + if let Some(terminal_id) = terminal_id { + self.shutdown_terminal_stream_clients( + &terminal_id, + format!("terminal {terminal_id} exited"), + ); + } + } + + true + } + _ => self.app.handle_internal_event_with_render_impact(ev), + } + } + + /// Drains internal events, forwarding clipboard, sound, and toast + /// notifications to connected clients instead of processing them locally. + /// + /// The server has no host terminal or audio subsystem, so we: + /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the + /// foreground client only. + /// - Detect when a sound would be played and forward as + /// `ServerMessage::Notify { kind: Sound }` to the foreground client. + /// - Detect when a toast is set on AppState and forward as + /// `ServerMessage::Notify` to the foreground client for terminal/system delivery. + pub(super) fn drain_internal_events_with_forwarding(&mut self) -> bool { + self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT) + .1 + } + + pub(super) fn drain_all_internal_events_with_forwarding(&mut self) -> bool { + let mut changed = false; + loop { + let (had_event, batch_changed) = + self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT); + changed |= batch_changed; + if !had_event || self.should_quit.load(Ordering::Acquire) { + break; + } + } + changed + } + + pub(super) fn drain_internal_events_with_forwarding_up_to( + &mut self, + limit: usize, + ) -> (bool, bool) { + let mut had_event = false; + let mut changed = false; + for _ in 0..limit { + let Ok(ev) = self.app.event_rx.try_recv() else { + break; + }; + had_event = true; + changed |= self.handle_internal_event_with_forwarding(ev); + } + (had_event, changed) + } +} diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs index 151b26ee..bce53b72 100644 --- a/src/server/headless/pane_graphics.rs +++ b/src/server/headless/pane_graphics.rs @@ -1,21 +1,7 @@ use super::{HeadlessServer, RenderImpact}; use crate::api; use crate::protocol::{ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; -use crate::server::clients::{render_targets, ClientConnectionMode, DeferredRender}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum RetainedGraphicsOutcome { - Sent, - Deferred, - Fallback, -} - -pub(super) fn frame_pane_graphics(bytes: Vec<u8>) -> Vec<u8> { - if bytes.is_empty() { - return bytes; - } - [b"\x1b7".as_slice(), &bytes, b"\x1b8"].concat() -} +use crate::server::clients::ClientConnectionMode; impl HeadlessServer { pub(super) fn pane_graphics_runtime_active(&self) -> bool { @@ -76,7 +62,6 @@ impl HeadlessServer { .and_then(crate::app::pane_graphics::Layer::direct_lease) .map(|lease| { ( - slot.host_image_id, lease.path().to_string_lossy().into_owned(), lease.len() as u64, lease.fingerprint(), @@ -84,21 +69,35 @@ impl HeadlessServer { }) }) }); - if let (Some(key), Some((image_id, path, expected_len, transfer_id))) = + if let (Some(key), Some((path, expected_len, transfer_id))) = (direct_key.clone(), direct_frame) { - let command = self.clients.get(&client_id).and_then(|client| { - crate::kitty_graphics::prepare_direct_file( - &self.app.state, - &self.app.pane_graphics, - self.app.state.view.tab_surface(), - client.cell_size, - !internal_changed, - &client.graphics_cache, - &key, - ) + let prepared = self.clients.get(&client_id).and_then(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell).then_some(())?; + let layer = self + .app + .pane_graphics + .slots + .get(&key) + .and_then(|slot| slot.layer.as_ref())?; + let asset = crate::kitty_graphics::surface::pane_layer_asset_key( + &self.app, &key, layer, + )?; + let (image_id, control) = + crate::kitty_graphics::surface::direct_upload_control( + &self.client_shell_boot_id, + &asset, + ); + Some(( + crate::kitty_graphics::DirectFileCommand { + leading: Vec::new(), + control, + }, + Some(asset), + image_id, + )) }); - let Some(command) = command else { + let Some((command, surface_asset, image_id)) = prepared else { if self.install_inline_fallback(&key) { if msg.respond_to.send(response).is_err() { self.retire_direct_gate(&key); @@ -123,6 +122,7 @@ impl HeadlessServer { transfer_id, leading: command.leading, control: command.control, + surface_asset, }; let send = Self::frame_server_message_with_max(&message, MAX_GRAPHICS_FRAME_SIZE) @@ -144,6 +144,7 @@ impl HeadlessServer { if let Some(slot) = self.app.pane_graphics.slots.get_mut(&key) { slot.direct_gate = Some(crate::app::pane_graphics::DirectGate { transfer_id, + image_id, client_id, deadline: std::time::Instant::now() + crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT, @@ -267,10 +268,14 @@ impl HeadlessServer { image_id: u32, ) -> bool { if let Some(gate) = self.app.pane_graphics.slots.values_mut().find_map(|slot| { - (slot.host_image_id == image_id && slot.stream_is_active()) + slot.stream_is_active() .then_some(slot.direct_gate.as_mut()) .flatten() - .filter(|gate| gate.client_id == client_id && gate.transfer_id == transfer_id) + .filter(|gate| { + gate.client_id == client_id + && gate.transfer_id == transfer_id + && gate.image_id == image_id + }) }) { gate.written = true; gate.deadline = @@ -297,7 +302,7 @@ impl HeadlessServer { slot.stream_is_active() && gate.client_id == client_id && gate.transfer_id == transfer_id - && slot.host_image_id == image_id + && gate.image_id == image_id && (!success || gate.written) }) .then(|| key.clone()) @@ -312,11 +317,11 @@ impl HeadlessServer { .iter() .any(|workspace| workspace.pane_state(key.0).is_some()); if !pane_is_live { - self.retire_direct_gate(&key); + self.retire_direct_gate_with_client_notice(&key); return false; } if success { - let (gate, host_image_id) = { + let gate = { let slot = self .app .pane_graphics @@ -329,23 +334,8 @@ impl HeadlessServer { if let Some(layer) = slot.layer.as_mut() { layer.mark_resident(client_id); } - ( - slot.direct_gate.take().expect("matched gate"), - slot.host_image_id, - ) + slot.direct_gate.take().expect("matched gate") }; - if let (Some(client), Some(layer)) = ( - self.clients.get_mut(&client_id), - self.app - .pane_graphics - .slots - .get(&key) - .and_then(|slot| slot.layer.as_ref()), - ) { - client - .graphics_cache - .trust_pane_layer(&key, host_image_id, layer); - } if gate.respond_to.send(gate.success_response).is_err() { self.retire_direct_gate(&key); return true; @@ -362,9 +352,6 @@ impl HeadlessServer { self.retire_all_direct_graphics(); return true; } - if let Some(client) = self.clients.get_mut(&client_id) { - client.graphics_cache.forget_pane_layer(&key, image_id); - } let gate = self .app .pane_graphics @@ -404,7 +391,7 @@ impl HeadlessServer { *client_id, ServerMessage::GraphicsTransmissionRetired { transfer_id: gate.transfer_id, - image_id: slot.host_image_id, + image_id: gate.image_id, }, ); } @@ -421,8 +408,53 @@ impl HeadlessServer { !expired.is_empty() } + fn retire_direct_gate_with_client_notice(&mut self, key: &crate::app::pane_graphics::Key) { + if let Some((client_id, transfer_id, image_id)) = self + .app + .pane_graphics + .slots + .get(key) + .and_then(|slot| slot.direct_gate.as_ref()) + .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id)) + { + self.send_to_client( + client_id, + ServerMessage::GraphicsTransmissionRetired { + transfer_id, + image_id, + }, + ); + } + self.retire_direct_gate(key); + } + + pub(super) fn retain_live_pane_graphics(&mut self) -> bool { + let dead_direct = self + .app + .pane_graphics + .slots + .iter() + .filter(|((pane_id, _), slot)| { + slot.direct_gate.is_some() + && !self + .app + .state + .workspaces + .iter() + .any(|workspace| workspace.pane_state(*pane_id).is_some()) + }) + .map(|(key, _)| key.clone()) + .collect::<Vec<_>>(); + let changed = !dead_direct.is_empty(); + for key in dead_direct { + self.retire_direct_gate_with_client_notice(&key); + } + let retained = self.app.pane_graphics.retain_live_panes(&self.app.state); + changed || retained + } + pub(super) fn retire_all_direct_graphics(&mut self) { - let keys = self + let retired = self .app .pane_graphics .slots @@ -432,9 +464,26 @@ impl HeadlessServer { .as_ref() .is_some_and(crate::app::pane_graphics::Layer::terminal_only) }) - .map(|(key, _)| key.clone()) + .map(|(key, slot)| { + let notification = slot + .direct_gate + .as_ref() + .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id)); + (key.clone(), notification) + }) .collect::<Vec<_>>(); - for key in keys { + for (_, notification) in &retired { + if let Some((client_id, transfer_id, image_id)) = notification { + self.send_to_client( + *client_id, + ServerMessage::GraphicsTransmissionRetired { + transfer_id: *transfer_id, + image_id: *image_id, + }, + ); + } + } + for (key, _) in retired { self.retire_direct_gate(&key); } } @@ -460,127 +509,4 @@ impl HeadlessServer { self.retire_direct_gate(&key); } } - - pub(super) fn render_retained_graphics_update_and_stream(&mut self) -> RetainedGraphicsOutcome { - crate::render_prof::event("retained_graphics.attempt"); - if self.app.full_redraw_pending { - crate::render_prof::event("retained_graphics_fallback.full_redraw_pending"); - return RetainedGraphicsOutcome::Fallback; - } - - let render_targets = render_targets(&self.clients, self.foreground_client_id); - let mut app_view_size = None; - for (_, terminal_size, _, _, mode) in &render_targets { - if !matches!(mode, ClientConnectionMode::App) { - continue; - } - if app_view_size.is_some_and(|size| size != *terminal_size) { - crate::render_prof::event("retained_graphics_fallback.mixed_app_geometry"); - return RetainedGraphicsOutcome::Fallback; - } - app_view_size = Some(*terminal_size); - } - let mut deferred = false; - let mut prepared = Vec::new(); - - for (client_id, (cols, rows), cell_size, _is_foreground, mode) in render_targets { - if !matches!(mode, ClientConnectionMode::App) { - continue; - } - let Some(client) = self.clients.get_mut(&client_id) else { - crate::render_prof::event("retained_graphics_fallback.client_missing"); - return RetainedGraphicsOutcome::Fallback; - }; - if client.deferred_render() != DeferredRender::None { - deferred = true; - continue; - } - if client.graphics_surface_reset_pending || !cell_size.is_known() { - crate::render_prof::event("retained_graphics_fallback.client_state"); - return RetainedGraphicsOutcome::Fallback; - } - let Some(last_frame) = client.render_state.last_frame() else { - crate::render_prof::event("retained_graphics_fallback.no_last_frame"); - return RetainedGraphicsOutcome::Fallback; - }; - if last_frame.width != cols || last_frame.height != rows { - crate::render_prof::event("retained_graphics_fallback.frame_size_mismatch"); - return RetainedGraphicsOutcome::Fallback; - } - if client.writer.is_none() { - crate::render_prof::event("retained_graphics_fallback.writer_missing"); - return RetainedGraphicsOutcome::Fallback; - } - - let mut next_graphics_cache = client.graphics_cache.clone(); - let encode_started = crate::render_prof::timer(); - let encoded = crate::kitty_graphics::encode_local_pane_graphics( - &self.app.state, - &self.app.pane_graphics, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - cell_size, - Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - &mut next_graphics_cache, - ); - crate::render_prof::duration_since("retained_graphics.graphics_encode", encode_started); - prepared.push((client_id, encoded, next_graphics_cache)); - } - - let mut broken_clients = Vec::new(); - for (client_id, encoded, next_graphics_cache) in prepared { - let Some(client) = self.clients.get_mut(&client_id) else { - continue; - }; - let serialized = if encoded.bytes.is_empty() { - None - } else { - match Self::frame_server_message_with_max( - &ServerMessage::Graphics { - bytes: frame_pane_graphics(encoded.bytes), - }, - MAX_GRAPHICS_FRAME_SIZE, - ) { - Ok(serialized) => Some(serialized), - Err(_) => { - crate::render_prof::event("retained_graphics_fallback.oversized"); - return RetainedGraphicsOutcome::Fallback; - } - } - }; - let result = match (serialized, client.writer.as_ref()) { - (None, _) => Ok(()), - (Some(bytes), Some(writer)) => writer.render.try_send(bytes), - (Some(bytes), None) => Err(std::sync::mpsc::TrySendError::Disconnected(bytes)), - }; - match result { - Ok(()) => { - client.graphics_cache = next_graphics_cache; - if encoded.incomplete { - client.defer_full_render(); - deferred = true; - } else { - client.clear_deferred_render(); - } - crate::render_prof::event("retained_graphics.sent"); - } - Err(std::sync::mpsc::TrySendError::Full(_)) => { - client.defer_full_render(); - deferred = true; - } - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - broken_clients.push(client_id) - } - } - } - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - - if deferred { - RetainedGraphicsOutcome::Deferred - } else { - RetainedGraphicsOutcome::Sent - } - } } diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs new file mode 100644 index 00000000..cab79de2 --- /dev/null +++ b/src/server/headless/render.rs @@ -0,0 +1,606 @@ +use super::*; + +impl HeadlessServer { + fn focused_pane_graphics_demand(&self) -> bool { + self.app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .and_then(crate::workspace::Workspace::focused_pane_id) + .is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id)) + } + + pub(super) fn stream_host_mouse_capture_mode(&mut self) { + let endpoint_shell_mouse = self.app.state.popup_pane.is_some() + || self + .app + .state + .focused_pane_requests_mouse_capture_from(&self.app.terminal_runtimes); + let pixel_mouse_requested = self + .clients + .values() + .any(|client| client.is_shell_client() && client.pixel_mouse); + let shell_sgr_pixels = pixel_mouse_requested + && self.focused_pane_graphics_demand() + && self + .app + .state + .active + .and_then(|ws_idx| { + self.app + .state + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::focused_pane_id) + .and_then(|pane_id| { + self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + ws_idx, + pane_id, + ) + }) + }) + .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); + let requested = self + .clients + .iter() + .filter_map(|(&client_id, client)| match &client.mode { + ClientConnectionMode::ClientShell => Some(( + client_id, + client.shell_mouse_capture || endpoint_shell_mouse, + shell_sgr_pixels && client.pixel_mouse, + )), + ClientConnectionMode::TerminalAttach { terminal_id } => { + let runtime = self.runtime_for_terminal_id_string(terminal_id); + let child_requests_mouse = runtime + .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled); + let sgr_pixels = child_requests_mouse + && client.pixel_mouse + && runtime + .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); + Some((client_id, child_requests_mouse, sgr_pixels)) + } + ClientConnectionMode::TerminalPending + | ClientConnectionMode::TerminalObserve { .. } => None, + }) + .collect::<Vec<_>>(); + + let mut broken_clients = Vec::new(); + for (client_id, enabled, sgr_pixels) in requested { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + if client.host_mouse_capture_active == Some(enabled) + && client.host_sgr_pixels_active == Some(sgr_pixels) + { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { + enabled, + sgr_pixels, + }) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize mouse capture mode for client"); + continue; + } + }; + if writer.control.send(serialized).is_err() { + debug!( + client_id, + "client writer channel closed during mouse capture update" + ); + broken_clients.push(client_id); + continue; + } + client.host_mouse_capture_active = Some(enabled); + client.host_sgr_pixels_active = Some(sgr_pixels); + } + + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + + pub(super) fn stream_direct_terminal_keyboard_mode(&mut self) { + let shell_report_all = { + let runtime = if self.app.state.popup_pane.is_some() { + self.app.popup_runtime() + } else { + self.app.state.active.and_then(|workspace_index| { + self.app + .state + .focused_runtime_in_workspace(&self.app.terminal_runtimes, workspace_index) + }) + }; + runtime.is_some_and(|runtime| { + let protocol = runtime.keyboard_protocol(); + protocol.reports_all_keys() + || (protocol.reports_event_types() && runtime.modify_other_keys_level() > 0) + }) + }; + let serialized_shell = + Self::frame_server_message(&ServerMessage::ClientShellKeyboardReportAll { + enabled: shell_report_all, + }); + let mut broken_clients = Vec::new(); + for (&client_id, client) in &mut self.clients { + if !client.is_shell_client() + || client.host_keyboard_report_all_active == Some(shell_report_all) + { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + let Ok(serialized) = serialized_shell.as_ref() else { + warn!("failed to serialize client shell keyboard report-all mode"); + break; + }; + if writer.control.send(serialized.clone()).is_err() { + broken_clients.push(client_id); + continue; + } + client.host_keyboard_report_all_active = Some(shell_report_all); + } + + let requested = self + .clients + .iter() + .filter_map(|(&client_id, client)| { + let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else { + return None; + }; + let (flags, modify_other_keys_level) = self + .runtime_for_terminal_id_string(terminal_id) + .map_or((0, 0), |runtime| { + let flags = match runtime.keyboard_protocol() { + crate::input::KeyboardProtocol::Legacy => 0, + crate::input::KeyboardProtocol::Kitty { flags } => flags, + }; + (flags, runtime.modify_other_keys_level()) + }); + Some((client_id, flags, modify_other_keys_level)) + }) + .collect::<Vec<_>>(); + + for (client_id, flags, modify_other_keys_level) in requested { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + if client.host_keyboard_protocol_active == Some((flags, modify_other_keys_level)) { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + let serialized = + match Self::frame_server_message(&ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + }) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize direct terminal keyboard mode"); + continue; + } + }; + if writer.control.send(serialized).is_err() { + debug!( + client_id, + "client writer channel closed during direct terminal keyboard update" + ); + broken_clients.push(client_id); + continue; + } + client.host_keyboard_protocol_active = Some((flags, modify_other_keys_level)); + } + + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + + pub(super) fn has_pending_presentation_work( + &self, + needs_full_render: bool, + needs_graphics_render: bool, + ) -> bool { + needs_full_render || needs_graphics_render || self.app.render_dirty.has_immediate_work() + } + + pub(super) fn sync_immediate_pty_sources(&self) { + let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); + let mut pane_ids = if has_app_target { + self.app.state.app_surface_pane_ids() + } else { + HashSet::new() + }; + if !direct_terminal_targets.is_empty() { + for workspace in &self.app.state.workspaces { + for tab in &workspace.tabs { + pane_ids.extend(tab.panes.iter().filter_map(|(&pane_id, pane)| { + direct_terminal_targets + .contains(pane.attached_terminal_id.as_str()) + .then_some(pane_id) + })); + } + } + if let Some(popup) = &self.app.state.popup_pane { + if direct_terminal_targets.contains(popup.terminal_id.as_str()) { + pane_ids.insert(popup.pane_id); + } + } + } + self.app.render_dirty.set_immediate_pty_sources(pane_ids); + } + + fn pty_render_targets(&self) -> (bool, HashSet<&str>) { + let mut has_app_target = false; + let mut direct_terminal_targets = HashSet::new(); + for client in self + .clients + .values() + .filter(|client| client.writer.is_some()) + { + match &client.mode { + ClientConnectionMode::ClientShell => has_app_target = true, + ClientConnectionMode::TerminalAttach { terminal_id } + | ClientConnectionMode::TerminalObserve { terminal_id } => { + direct_terminal_targets.insert(terminal_id.as_str()); + } + ClientConnectionMode::TerminalPending => {} + } + } + (has_app_target, direct_terminal_targets) + } + + fn pty_source_visible_to_render_targets( + &self, + pane_id: crate::layout::PaneId, + has_app_target: bool, + direct_terminal_targets: &HashSet<&str>, + ) -> bool { + let terminal_id = self.terminal_id_for_pane(pane_id); + (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id))) + || terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str())) + } + + pub(super) fn pty_sources_visible_to_any_render_target( + &self, + sources: &HashSet<crate::layout::PaneId>, + ) -> bool { + let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); + if !has_app_target && direct_terminal_targets.is_empty() { + return false; + } + + sources.iter().copied().any(|pane_id| { + self.pty_source_visible_to_render_targets( + pane_id, + has_app_target, + &direct_terminal_targets, + ) + }) + } + + fn terminal_id_for_pane( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalId> { + if let Some(popup) = self + .app + .state + .popup_pane + .as_ref() + .filter(|popup| popup.pane_id == pane_id) + { + return Some(&popup.terminal_id); + } + self.app + .find_pane(pane_id) + .map(|(_, pane)| &pane.attached_terminal_id) + } + + fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { + if self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.pane_id == pane_id) + { + return true; + } + let Some(workspace) = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + else { + return false; + }; + let Some(tab) = workspace.active_tab() else { + return false; + }; + if !tab.panes.contains_key(&pane_id) { + return false; + } + !tab.zoomed || tab.layout.focused() == pane_id + } + + pub(super) fn render_and_stream(&mut self) { + let full_started = crate::render_prof::timer(); + let render_targets = render_targets(&self.clients, self.foreground_client_id); + + if render_targets.is_empty() { + let (cols, rows) = self.effective_size; + let area = Rect::new(0, 0, cols, rows); + let resize_panes = self.app.state.view.pane_infos.is_empty(); + if resize_panes { + crate::ui::compute_view_with_runtime_registry( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + ); + } else { + crate::ui::compute_view_without_resizing_panes( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + ); + } + self.app.full_redraw_pending = false; + crate::render_prof::duration_since("full_render.total", full_started); + debug!( + cols, + rows, resize_panes, "updated geometry with no attached clients" + ); + return; + } + + let shell_snapshot_template = render_targets + .iter() + .any(|(_, _, _, _, mode)| matches!(mode, ClientConnectionMode::ClientShell)) + .then(|| client_shell_snapshot(&self.app, &self.client_shell_boot_id, 0, None)); + let mut broken_clients: Vec<u64> = Vec::new(); + for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { + let area = Rect::new(0, 0, cols, rows); + let mut shell_projection_revision = 0; + if matches!(mode, ClientConnectionMode::ClientShell) { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + let Some(mut candidate) = shell_snapshot_template.clone() else { + continue; + }; + candidate.config_diagnostic = if client.shell_uses_endpoint_keybindings { + self.server_config_diagnostic.clone() + } else { + self.server_config_diagnostic_without_keybindings.clone() + }; + candidate.revision = client.shell_projection_revision; + if client.shell_snapshot.as_ref() != Some(&candidate) { + client.shell_projection_revision = + client.shell_projection_revision.saturating_add(1); + candidate.revision = client.shell_projection_revision; + let message = ServerMessage::ClientShellSnapshot(Box::new(candidate.clone())); + let framed = match Self::frame_server_message(&message) { + Ok(framed) => framed, + Err(err) => { + warn!(client_id, err = %err, "failed to frame client shell replacement"); + broken_clients.push(client_id); + continue; + } + }; + let Some(writer) = client.writer.as_ref() else { + broken_clients.push(client_id); + continue; + }; + if writer.control.send(framed).is_err() { + broken_clients.push(client_id); + continue; + } + client.shell_snapshot = Some(candidate); + } + shell_projection_revision = client.shell_projection_revision; + } + let shell_graphics_delivery = self + .clients + .get(&client_id) + .map(|client| client.shell_graphics_delivery.clone()) + .unwrap_or_default(); + let mut surface_parts = None; + let frame = match mode { + ClientConnectionMode::ClientShell => { + let render_started = crate::render_prof::timer(); + let render_cell_size = if cell_size.is_known() { + cell_size + } else { + crate::kitty_graphics::HostCellSize::default() + }; + let crate::server::client_shell::RenderedPaneSurface { + frame, + panes, + splits, + popup, + graphics, + graphics_delivery: next_graphics_delivery, + } = render_client_shell_pane_surface( + &mut self.app, + area, + is_foreground, + render_cell_size, + &shell_graphics_delivery, + client_id, + ); + crate::render_prof::duration_since( + "full_render.render_tab_surface_virtual", + render_started, + ); + surface_parts = Some((panes, splits, popup, graphics, next_graphics_delivery)); + frame + } + ClientConnectionMode::TerminalPending => continue, + ClientConnectionMode::TerminalAttach { terminal_id } + | ClientConnectionMode::TerminalObserve { terminal_id } => { + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some(format!( + "terminal attach ended: terminal {terminal_id} not found" + )), + }, + ); + broken_clients.push(client_id); + continue; + }; + let render_started = crate::render_prof::timer(); + let (buffer, cursor) = + crate::server::render_stream::render_terminal_virtual(runtime, area); + crate::render_prof::duration_since( + "full_render.render_terminal_virtual", + render_started, + ); + let hyperlinks_started = crate::render_prof::timer(); + let hyperlinks = runtime.visible_hyperlinks(area); + crate::render_prof::duration_since( + "full_render.visible_hyperlinks", + hyperlinks_started, + ); + let frame_started = crate::render_prof::timer(); + let frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &buffer, + cursor, + &hyperlinks, + ); + crate::render_prof::duration_since("full_render.frame_build", frame_started); + frame + } + }; + + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + let Some(writer) = client.writer.as_ref().cloned() else { + crate::render_prof::event("full_render.writer_missing"); + continue; + }; + let has_graphics = surface_parts + .as_ref() + .is_some_and(|(_, _, _, graphics, _)| { + !graphics.assets.is_empty() + || !graphics.placements.is_empty() + || !graphics.retained_assets.is_empty() + }); + let mut next_shell_graphics_delivery = None; + let prepared = if let Some((panes, splits, popup, graphics, delivery)) = surface_parts { + next_shell_graphics_delivery = Some(delivery); + client + .render_state + .prepare_pane_surface(protocol::PaneSurfaceFrame { + boot_id: self.client_shell_boot_id.clone(), + projection_revision: shell_projection_revision, + surface_revision: 0, + frame, + panes, + splits, + popup, + graphics, + }) + } else { + client.render_state.prepare_frame(frame) + }; + let Some(mut prepared) = prepared else { + client.clear_deferred_render(); + crate::render_prof::event("full_render.skip_identical"); + continue; + }; + let max = if has_graphics { + MAX_GRAPHICS_FRAME_SIZE + } else { + crate::protocol::MAX_FRAME_SIZE + }; + let mut shell_assets_deferred = false; + let serialized = match Self::frame_server_message_with_max(prepared.message(), max) { + Ok(frame) => frame, + Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => { + warn!( + client_id, + claimed, max, "dropping graphics assets from oversized pane surface" + ); + if !prepared.strip_pane_surface_assets() { + crate::render_prof::event("full_render.serialize_oversized"); + continue; + } + next_shell_graphics_delivery = None; + shell_assets_deferred = true; + match Self::frame_server_message(prepared.message()) { + Ok(framed) => framed, + Err(err) => { + warn!(client_id, err = %err, "failed to serialize pane surface without assets"); + broken_clients.push(client_id); + crate::render_prof::event("full_render.serialize_error"); + continue; + } + } + } + Err(protocol::FramingError::Oversized { claimed, max }) => { + warn!( + client_id, + claimed, max, "skipping oversized frame for client" + ); + crate::render_prof::event("full_render.serialize_oversized"); + continue; + } + Err(err) => { + warn!(client_id, err = %err, "failed to serialize frame"); + broken_clients.push(client_id); + crate::render_prof::event("full_render.serialize_error"); + continue; + } + }; + let shell_graphics_pending = next_shell_graphics_delivery + .as_ref() + .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending); + match writer.render.try_send(serialized) { + Ok(()) => { + if let Some(delivery) = next_shell_graphics_delivery { + client.shell_graphics_delivery = delivery; + } + client.render_state.commit_sent_frame(prepared); + if shell_graphics_pending || shell_assets_deferred { + client.defer_full_render(); + } else { + client.clear_deferred_render(); + } + crate::render_prof::event("full_render.sent"); + } + Err(std::sync::mpsc::TrySendError::Full(_)) => { + client.defer_full_render(); + } + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + broken_clients.push(client_id); + } + } + } + + if !broken_clients.is_empty() { + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + + let (cols, rows) = self.effective_size; + // Full-frame recovery is tracked per connection. A slow client must not + // keep responsive peers on the global full-render path while it waits + // for its render slot to drain. + self.app.full_redraw_pending = false; + crate::render_prof::duration_since("full_render.total", full_started); + debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)"); + } +} diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs new file mode 100644 index 00000000..b15c4319 --- /dev/null +++ b/src/server/headless/retained_surface.rs @@ -0,0 +1,613 @@ +use super::*; + +fn rect_fits_frame(rect: protocol::SurfaceRect, frame: &FrameData) -> bool { + rect.x.saturating_add(rect.width) <= frame.width + && rect.y.saturating_add(rect.height) <= frame.height +} + +fn patch_intersects_hyperlinks( + frame: &FrameData, + area: protocol::SurfaceRect, + patch: &crate::pane::TerminalDirtyPatch, +) -> bool { + if frame.hyperlinks.is_empty() || !rect_fits_frame(area, frame) { + return false; + } + let width = usize::from(frame.width); + patch + .rows + .iter() + .filter(|(local_y, _)| *local_y < area.height) + .any(|(local_y, _)| { + let start = usize::from(area.y + *local_y) * width + usize::from(area.x); + let end = start + usize::from(area.width); + end > frame.cells.len() + || frame.cells[start..end] + .iter() + .any(|cell| cell.hyperlink.is_some()) + }) +} + +fn apply_patch_row(frame: &mut FrameData, row: &protocol::PaneSurfacePatchRow) -> Option<bool> { + if row.y >= frame.height + || row.x.saturating_add(u16::try_from(row.cells.len()).ok()?) > frame.width + { + return None; + } + let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x); + let end = start + row.cells.len(); + if end > frame.cells.len() { + return None; + } + let changed = frame.cells[start..end] != row.cells; + frame.cells[start..end].clone_from_slice(&row.cells); + Some(changed) +} + +fn apply_rows( + frame: &mut FrameData, + area: protocol::SurfaceRect, + patch: &crate::pane::TerminalDirtyPatch, +) -> Option<Vec<protocol::PaneSurfacePatchRow>> { + if !rect_fits_frame(area, frame) { + return None; + } + let mut rows = Vec::new(); + for (local_y, cells) in &patch.rows { + if *local_y >= area.height { + continue; + } + let width = usize::from(area.width); + if cells.len() < width { + return None; + } + let y = area.y + *local_y; + let frame_start = usize::from(y) * usize::from(frame.width) + usize::from(area.x); + let frame_end = frame_start.checked_add(width)?; + let existing = frame.cells.get(frame_start..frame_end)?; + let desired = &cells[..width]; + let mut offset = 0; + while offset < width { + if existing[offset] == desired[offset] { + offset += 1; + continue; + } + let start = offset; + offset += 1; + while offset < width && existing[offset] != desired[offset] { + offset += 1; + } + // Include the following cell so a wide-to-narrow (or + // narrow-to-wide) transition repaints content covered by the old + // grapheme width even when that logical neighbor is unchanged. + let end = offset.saturating_add(1).min(width); + rows.push(protocol::PaneSurfacePatchRow { + x: area.x.checked_add(u16::try_from(start).ok()?)?, + y, + cells: desired[start..end].to_vec(), + }); + offset = end; + } + } + for row in &rows { + apply_patch_row(frame, row)?; + } + Some(rows) +} + +fn retained_scrollbar_patch( + app: &app::App, + frame: &mut FrameData, + pane: &mut protocol::PaneSurfacePane, + alternate_screen_active: bool, + metrics: Option<crate::pane::ScrollMetrics>, +) -> Option<Vec<protocol::PaneSurfacePatchRow>> { + let next_rect = metrics + .filter(|metrics| metrics.max_offset_from_bottom > 0) + .filter(|_| app.state.pane_scrollbars && !alternate_screen_active) + .and_then(|_| { + let rect = protocol::SurfaceRect { + x: pane.inner_rect.x.checked_add(pane.inner_rect.width)?, + y: pane.inner_rect.y, + width: 1, + height: pane.inner_rect.height, + }; + (rect_fits_frame(rect, frame) + && rect.x >= pane.rect.x + && rect.x < pane.rect.x.saturating_add(pane.rect.width)) + .then_some(rect) + }); + let patch_rect = next_rect.or(pane.scrollbar_rect); + pane.scrollbar_rect = next_rect; + let Some(rect) = patch_rect else { + return Some(Vec::new()); + }; + + let track = Rect::new(0, 0, 1, rect.height); + let mut buffer = ratatui::buffer::Buffer::empty(track); + if let (Some(metrics), Some(_)) = (metrics, next_rect) { + crate::ui::render_pane_scrollbar_buffer( + &mut buffer, + metrics, + track, + &app.state.palette, + pane.focused, + ); + } + let cells = buffer + .content + .iter() + .map(protocol::CellData::from_ratatui_cell) + .collect::<Vec<_>>(); + let mut rows = Vec::new(); + for (offset, cell) in cells.into_iter().enumerate() { + let row = protocol::PaneSurfacePatchRow { + x: rect.x, + y: rect.y.checked_add(u16::try_from(offset).ok()?)?, + cells: vec![cell], + }; + if apply_patch_row(frame, &row)? { + rows.push(row); + } + } + Some(rows) +} + +fn retained_cursor( + app: &app::App, + panes: &[protocol::PaneSurfacePane], +) -> Option<protocol::CursorState> { + let pane = panes.iter().find(|pane| pane.focused)?; + let (workspace_index, pane_id) = app.parse_pane_id(&pane.pane_id)?; + if !app.state.pane_exposes_host_cursor(workspace_index, pane_id) { + return None; + } + let runtime = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane_id, + )?; + if runtime.synchronized_output_active() { + return None; + } + let area = Rect::new( + pane.inner_rect.x, + pane.inner_rect.y, + pane.inner_rect.width, + pane.inner_rect.height, + ); + runtime + .cursor_state(area, true) + .map(|cursor| protocol::CursorState { + x: cursor.x, + y: cursor.y, + visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime), + shape: cursor.shape, + }) +} + +struct RetainedRecipient { + client_id: u64, + surface: protocol::PaneSurfaceFrame, +} + +struct CollectedPanePatch { + pane_id: String, + patch: crate::pane::TerminalDirtyPatch, + content_revision: u64, + scroll_metrics: Option<crate::pane::ScrollMetrics>, + mouse_reporting: bool, + sgr_pixel_mouse: bool, + alternate_screen_active: bool, +} + +struct RetainedRecipientUpdate { + client_id: u64, + surface: protocol::PaneSurfaceFrame, + patch: protocol::PaneSurfacePatch, +} + +impl HeadlessServer { + /// Applies terminal dirty rows to the committed origin-relative pane surface. + /// Any presentation or geometry uncertainty falls back to the complete renderer. + pub(super) fn render_retained_pane_surface_and_stream( + &mut self, + pty_sources: &HashSet<crate::layout::PaneId>, + ) -> bool { + crate::render_prof::event("retained_surface.attempt"); + let started = crate::render_prof::timer(); + macro_rules! fallback { + ($reason:literal) => {{ + crate::render_prof::event(concat!("retained_surface.fallback.", $reason)); + crate::render_prof::duration_since("retained_surface.total", started); + return false; + }}; + } + macro_rules! success { + ($reason:literal) => {{ + crate::render_prof::event("retained_surface.success"); + crate::render_prof::event(concat!("retained_surface.success.", $reason)); + crate::render_prof::duration_since("retained_surface.total", started); + return true; + }}; + } + + if pty_sources.is_empty() + || self.app.full_redraw_pending + || self.app.state.popup_pane.is_some() + || self.app.state.reveal_hidden_cursor_for_cjk_ime + { + fallback!("unsafe_state"); + } + let targets = render_targets(&self.clients, self.foreground_client_id); + if targets.is_empty() + || targets + .iter() + .any(|target| !matches!(target.4, ClientConnectionMode::ClientShell)) + { + fallback!("non_shell_target"); + } + + let mut recipients = Vec::with_capacity(targets.len()); + for (client_id, (cols, rows), _, _, _) in &targets { + let Some(client) = self.clients.get(client_id) else { + fallback!("client_missing"); + }; + if client.deferred_render() != DeferredRender::None { + crate::render_prof::event("retained_surface.recipient_deferred"); + continue; + } + let Some(surface) = client.render_state.last_pane_surface() else { + fallback!("no_baseline"); + }; + if surface.boot_id != self.client_shell_boot_id + || surface.projection_revision != client.shell_projection_revision + || surface.frame.width != *cols + || surface.frame.height != *rows + || surface.popup.is_some() + || !surface.graphics.assets.is_empty() + || !surface.graphics.placements.is_empty() + || !surface.graphics.retained_assets.is_empty() + || !surface.frame.graphics.is_empty() + { + fallback!("baseline_mismatch"); + } + recipients.push(RetainedRecipient { + client_id: *client_id, + surface: surface.clone(), + }); + } + if recipients.is_empty() { + success!("all_recipients_deferred"); + } + + let mut collected = Vec::with_capacity(pty_sources.len()); + for source in pty_sources { + let mut public_pane_id = None; + let mut width = 0u16; + let mut height = 0u16; + for recipient in &recipients { + let Some(pane) = recipient.surface.panes.iter().find(|pane| { + self.app + .parse_pane_id(&pane.pane_id) + .is_some_and(|(_, pane_id)| pane_id == *source) + }) else { + fallback!("source_not_visible"); + }; + public_pane_id.get_or_insert_with(|| pane.pane_id.clone()); + width = width.max(pane.inner_rect.width); + height = height.max(pane.inner_rect.height); + } + let Some(public_pane_id) = public_pane_id else { + fallback!("pane_missing"); + }; + let Some((workspace_index, pane_id)) = self.app.parse_pane_id(&public_pane_id) else { + fallback!("pane_missing"); + }; + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + pane_id, + ) else { + fallback!("runtime_missing"); + }; + let revision_before = runtime.content_seq(); + if !revision_before.is_multiple_of(2) { + fallback!("unstable_content"); + } + let patch = match runtime.collect_dirty_patch(width, height) { + crate::pane::TerminalDirtyPatchOutcome::Clean => { + crate::render_prof::event("retained_surface.pane_clean"); + crate::pane::TerminalDirtyPatch { rows: Vec::new() } + } + crate::pane::TerminalDirtyPatchOutcome::Patch(patch) => patch, + crate::pane::TerminalDirtyPatchOutcome::Fallback => { + fallback!("terminal_patch"); + } + }; + let revision = runtime.content_seq(); + if revision != revision_before || !revision.is_multiple_of(2) { + fallback!("content_changed"); + } + collected.push(CollectedPanePatch { + pane_id: public_pane_id, + patch, + content_revision: revision, + scroll_metrics: runtime.scroll_metrics(), + mouse_reporting: runtime.mouse_reporting_enabled(), + sgr_pixel_mouse: runtime.sgr_pixel_mouse_enabled(), + alternate_screen_active: runtime.alternate_screen_active(), + }); + } + + let mut updates = Vec::with_capacity(recipients.len()); + for recipient in recipients { + let client_id = recipient.client_id; + let mut surface = recipient.surface; + let projection_revision = surface.projection_revision; + let base_surface_revision = surface.surface_revision; + let mut changed_panes = Vec::with_capacity(collected.len()); + let mut patch_rows = Vec::new(); + let mut metadata_changed = false; + for collected_pane in &collected { + let Some(pane) = surface + .panes + .iter_mut() + .find(|pane| pane.pane_id == collected_pane.pane_id) + else { + fallback!("pane_missing"); + }; + // Alternate-screen transitions change whether the pane reserves + // a scrollbar gutter. Recompute layout and resize the runtime + // through the complete renderer before retaining further rows. + if pane.alternate_screen_active != collected_pane.alternate_screen_active { + fallback!("alternate_screen_geometry"); + } + if patch_intersects_hyperlinks( + &surface.frame, + pane.inner_rect, + &collected_pane.patch, + ) { + fallback!("hyperlink"); + } + let previous_pane = pane.clone(); + let Some(rows) = + apply_rows(&mut surface.frame, pane.inner_rect, &collected_pane.patch) + else { + fallback!("invalid_patch"); + }; + patch_rows.extend(rows); + let Some(scrollbar_rows) = retained_scrollbar_patch( + &self.app, + &mut surface.frame, + pane, + collected_pane.alternate_screen_active, + collected_pane.scroll_metrics, + ) else { + fallback!("scrollbar_patch"); + }; + patch_rows.extend(scrollbar_rows); + pane.content_revision = collected_pane.content_revision; + pane.mouse_reporting = collected_pane.mouse_reporting; + pane.sgr_pixel_mouse = collected_pane.sgr_pixel_mouse; + pane.alternate_screen_active = collected_pane.alternate_screen_active; + pane.scroll = collected_pane.scroll_metrics.map(|metrics| { + protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: metrics.offset_from_bottom as u64, + max_offset_from_bottom: metrics.max_offset_from_bottom as u64, + viewport_rows: metrics.viewport_rows as u64, + } + }); + metadata_changed |= *pane != previous_pane; + changed_panes.push(pane.clone()); + } + + let cursor = retained_cursor(&self.app, &surface.panes); + let cursor_changed = cursor != surface.frame.cursor; + surface.frame.cursor = cursor.clone(); + if patch_rows.is_empty() && !cursor_changed && !metadata_changed { + continue; + } + let patch = protocol::PaneSurfacePatch { + boot_id: self.client_shell_boot_id.clone(), + projection_revision, + base_surface_revision, + surface_revision: 0, + rows: patch_rows, + panes: changed_panes, + cursor, + }; + updates.push(RetainedRecipientUpdate { + client_id, + surface, + patch, + }); + } + if updates.is_empty() { + success!("unchanged"); + } + + let mut sent = 0u64; + let mut deferred = 0u64; + let mut disconnected = Vec::new(); + for update in updates { + let RetainedRecipientUpdate { + client_id, + surface, + patch, + } = update; + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + let Some(writer) = client.writer.as_ref().cloned() else { + client.defer_full_render(); + deferred += 1; + continue; + }; + let Some(prepared) = client + .render_state + .prepare_pane_surface_patch(patch, surface) + else { + client.defer_full_render(); + deferred += 1; + continue; + }; + let serialized = match Self::frame_server_message(prepared.message()) { + Ok(serialized) => serialized, + Err(error) => { + warn!( + client_id, + %error, + "failed to serialize retained pane surface patch" + ); + client.defer_full_render(); + deferred += 1; + continue; + } + }; + crate::render_prof::counter("retained_surface.bytes", serialized.len() as u64); + match writer.render.try_send(serialized) { + Ok(()) => { + client.clear_deferred_render(); + client.render_state.commit_sent_frame(prepared); + sent += 1; + } + Err(std::sync::mpsc::TrySendError::Full(_)) => { + client.defer_full_render(); + deferred += 1; + } + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + disconnected.push(client_id); + } + } + } + for client_id in disconnected { + self.remove_client_and_resize_if_needed(client_id); + } + crate::render_prof::counter("retained_surface.recipients.sent", sent); + crate::render_prof::counter("retained_surface.recipients.deferred", deferred); + if sent > 0 { + success!("sent"); + } + success!("recovery_queued"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cell(symbol: &str) -> protocol::CellData { + protocol::CellData { + symbol: symbol.to_owned(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + } + } + + #[test] + fn retained_rows_send_only_changed_cell_spans() { + let mut frame = FrameData { + width: 6, + height: 2, + cells: vec![cell(" "); 12], + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let patch = crate::pane::TerminalDirtyPatch { + rows: vec![(0, vec![cell(" "), cell("x"), cell("y"), cell(" ")])], + }; + + let rows = apply_rows( + &mut frame, + protocol::SurfaceRect { + x: 1, + y: 1, + width: 4, + height: 1, + }, + &patch, + ) + .expect("valid patch"); + + assert_eq!( + rows, + vec![protocol::PaneSurfacePatchRow { + x: 2, + y: 1, + cells: vec![cell("x"), cell("y"), cell(" ")], + }] + ); + assert_eq!(frame.cells[8], cell("x")); + assert_eq!(frame.cells[9], cell("y")); + } + + #[test] + fn retained_rows_include_the_cell_after_a_width_transition() { + let mut frame = FrameData { + width: 3, + height: 1, + cells: vec![cell("界"), cell("z"), cell("q")], + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let patch = crate::pane::TerminalDirtyPatch { + rows: vec![(0, vec![cell("x"), cell("z"), cell("q")])], + }; + + let rows = apply_rows( + &mut frame, + protocol::SurfaceRect { + x: 0, + y: 0, + width: 3, + height: 1, + }, + &patch, + ) + .expect("valid patch"); + + assert_eq!( + rows, + vec![protocol::PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![cell("x"), cell("z")], + }] + ); + } + + #[test] + fn retained_rows_omit_unchanged_full_dirty_rows() { + let mut frame = FrameData { + width: 4, + height: 2, + cells: vec![cell(" "); 8], + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let patch = crate::pane::TerminalDirtyPatch { + rows: vec![(0, vec![cell(" "); 4]), (1, vec![cell(" "); 4])], + }; + + let rows = apply_rows( + &mut frame, + protocol::SurfaceRect { + x: 0, + y: 0, + width: 4, + height: 2, + }, + &patch, + ) + .expect("valid patch"); + + assert!(rows.is_empty()); + } +} diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs new file mode 100644 index 00000000..102f2fc6 --- /dev/null +++ b/src/server/headless/tests/mod.rs @@ -0,0 +1,5025 @@ +use super::*; + +#[path = "pane_graphics.rs"] +mod pane_graphics_tests; + +fn test_headless_server() -> HeadlessServer { + test_headless_server_with_event_hub(api::EventHub::default()) +} + +fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServer { + let config = crate::config::Config::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let app = crate::app::App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); + + let dir = std::env::temp_dir().join(format!( + "hh-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = fs::create_dir_all(&dir); + let socket_path = dir.join("client.sock"); + let _ = fs::remove_file(&socket_path); + let listener = bind_local_listener(&socket_path).expect("bind test listener"); + let client_socket_identity = + socket_file_identity(&socket_path).expect("test listener socket identity"); + #[cfg(unix)] + listener + .set_nonblocking(ListenerNonblockingMode::Accept) + .expect("set listener nonblocking"); + let (server_event_tx, server_event_rx) = mpsc::channel(64); + let should_quit = Arc::new(AtomicBool::new(false)); + #[cfg(windows)] + spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); + let server_keybindings = app_keybindings(&app); + let headless_size = app.state.headless_size; + + HeadlessServer { + app, + #[cfg(unix)] + api_tx: None, + api_server: None, + #[cfg(unix)] + client_listener: listener, + client_socket_path: socket_path, + client_socket_identity, + clients: HashMap::new(), + #[cfg(unix)] + next_client_id: 1, + foreground_client_id: None, + client_shell_boot_id: "test-boot".into(), + sent_window_title: None, + api_window_title: None, + server_keybindings, + server_config_diagnostic: None, + server_config_diagnostic_without_keybindings: None, + terminal_attach_owners: HashMap::new(), + pending_alt_screen_reads: Vec::new(), + deferred_alt_screen_reads: Vec::new(), + next_activity_stamp: 1, + headless_size, + effective_size: headless_size, + shutting_down: false, + handoff_in_progress: false, + #[cfg(unix)] + pending_handoff_repaint_nudge: false, + should_quit, + server_event_rx, + server_event_tx, + } +} + +fn shutdown_test_runtimes(server: &mut HeadlessServer) { + for (_, runtime) in server.app.terminal_runtimes.drain() { + runtime.shutdown(); + } +} + +fn read_server_message(bytes: Vec<u8>) -> ServerMessage { + let mut cursor = std::io::Cursor::new(bytes); + protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message") +} + +fn frame_text(frame: &FrameData) -> String { + frame + .cells + .chunks(usize::from(frame.width)) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n") +} + +fn read_server_shutdown_reason(bytes: Vec<u8>) -> Option<String> { + match read_server_message(bytes) { + ServerMessage::ServerShutdown { reason } => reason, + other => panic!("expected shutdown, got {other:?}"), + } +} + +#[test] +fn completed_handoff_disables_only_old_server_session_persistence() { + let mut server = test_headless_server(); + server.app.policy = crate::app::AppPolicy::PRODUCTION; + + server.finish_live_handoff_shutdown(); + + assert!(!server.app.policy.persist_session); + assert!(server.app.policy.restore_session); + assert!(server.app.policy.persist_plugin_registry); + assert!(server.app.policy.background_updates); +} + +#[test] +fn default_headless_size_is_effective_without_clients() { + let server = test_headless_server(); + + assert_eq!( + server.headless_size, + ( + crate::config::DEFAULT_HEADLESS_COLS, + crate::config::DEFAULT_HEADLESS_ROWS + ) + ); + assert_eq!(server.effective_size, server.headless_size); +} + +#[tokio::test] +async fn headless_api_reads_latest_title_without_spinner_event_flooding() { + let event_hub = api::EventHub::default(); + let mut server = test_headless_server_with_event_hub(event_hub.clone()); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + server.app.state.sidebar_agents.rows = vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]]; + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(crate::detect::Agent::Claude); + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07"); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + server.app.render_dirty.request_terminal_title(pane_id); + + let first = headless_pane_list(&mut server).pop().unwrap(); + assert_eq!(first.terminal_title.as_deref(), Some("⠋ task")); + assert_eq!(first.terminal_title_stripped.as_deref(), Some("task")); + assert_eq!(pane_updated_events(&event_hub), 1); + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\"); + server.app.render_dirty.request_terminal_title(pane_id); + let second = headless_pane_list(&mut server).pop().unwrap(); + assert_eq!(second.terminal_title.as_deref(), Some("⠙ task")); + assert_eq!(second.terminal_title_stripped.as_deref(), Some("task")); + assert_eq!(pane_updated_events(&event_hub), 1); +} + +fn headless_pane_list(server: &mut HeadlessServer) -> Vec<api::schema::PaneInfo> { + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "list-titles".into(), + method: api::schema::Method::PaneList(api::schema::PaneListParams::default()), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + let response: api::schema::SuccessResponse = + serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); + let api::schema::ResponseResult::PaneList { panes } = response.result else { + panic!("expected pane list"); + }; + panes +} + +fn pane_updated_events(event_hub: &api::EventHub) -> usize { + event_hub + .events_after(0) + .iter() + .filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated) + .count() +} + +#[test] +fn server_stop_interrupts_server_event_backlog() { + let mut server = test_headless_server(); + for client_id in 1..=64 { + server + .server_event_tx + .try_send(ServerEvent::ClientDisconnected { client_id }) + .unwrap(); + } + + server.should_quit.store(true, Ordering::Release); + + assert!(!server.drain_server_events()); + assert!(server.server_event_rx.try_recv().is_ok()); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn headless_api_request_drains_all_pending_internal_events_before_reading_state() { + let mut server = test_headless_server(); + for i in 0..=crate::app::APP_EVENT_DRAIN_LIMIT { + server + .app + .event_tx + .try_send(AppEvent::UpdateReady { + version: format!("4.0.{i}"), + install_command: "herdr install".into(), + }) + .unwrap(); + } + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + assert!( + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "headless_stop_after_events".into(), + method: api::schema::Method::ServerStop(api::schema::EmptyParams::default()), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }) + ); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "ok"); + let expected_version = format!("4.0.{}", crate::app::APP_EVENT_DRAIN_LIMIT); + assert_eq!( + server.app.state.update_available.as_deref(), + Some(expected_version.as_str()) + ); + assert!(server.app.event_rx.try_recv().is_err()); +} + +fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver<Vec<u8>>) { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + + let (client_tx, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(1); + drain_window_titles(&control_rx); + (server, control_rx) +} + +/// The test client writer drains its queue on a background thread, so +/// reading a pushed message needs a timeout rather than `try_recv`. +fn next_window_title(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> Option<Option<String>> { + let deadline = Instant::now() + Duration::from_secs(5); + while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { + let Ok(bytes) = control_rx.recv_timeout(remaining) else { + return None; + }; + if let ServerMessage::WindowTitle { title } = read_server_message(bytes) { + return Some(title); + } + } + None +} + +fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) { + while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {} +} + +fn no_window_title(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> bool { + while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) { + if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) { + return false; + } + } + true +} + +#[test] +fn window_title_waits_for_a_foreground_client_to_exist() { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; + server.app.state.active = Some(0); + server.app.configure_window_title("{workspace}"); + + // The server renders before the first client attaches. Nothing was + // delivered, so nothing may be recorded as delivered either. + server.sync_window_title(); + assert_eq!(server.sent_window_title, None); + + let (client_tx, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(1); + server.sync_window_title(); + + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() { + let (mut server, first_control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + server.sync_window_title(); + assert_eq!( + next_window_title(&first_control_rx), + Some(Some("herd".to_string())) + ); + + // ClientConnected assigns the foreground client directly rather than + // going through promote_client_to_foreground, so the cache must notice + // the new client on its own. + let (client_tx, second_control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_window_title(); + + assert_eq!( + next_window_title(&second_control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn configured_window_title_reaches_the_foreground_client_once_per_change() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}/{tab}"); + + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd/1".to_string())) + ); + + // An unchanged title must not re-emit an OSC on every render. + server.sync_window_title(); + assert!(no_window_title(&control_rx)); + + server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into()); + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd/build".to_string())) + ); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{terminal_title}"); + server.app.state.ensure_test_terminals(); + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = server.app.state.workspaces[0] + .terminal_id(pane_id) + .expect("terminal") + .clone(); + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes()); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + + assert_eq!( + server.sync_terminal_title_sources(&HashSet::from([pane_id])), + (false, true) + ); + assert_eq!( + next_window_title(&control_rx), + Some(Some("building".to_string())) + ); + + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes()); + assert_eq!( + server.sync_terminal_title_sources(&HashSet::from([pane_id])), + (false, true) + ); + assert!(no_window_title(&control_rx)); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() { + let (mut server, _control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + + // A detached client keeps its entry but loses its writer, so nothing + // reaches a terminal even though the targeted send reports success. + if let Some(client) = server.clients.get_mut(&1) { + client.writer = None; + } + server.sync_window_title(); + assert!(server.sent_window_title.is_none()); + + // Attaching again has to deliver the title rather than skip it as sent. + let (client_tx, control_rx, _render_rx) = test_client_writer(); + if let Some(client) = server.clients.get_mut(&1) { + client.writer = Some(client_tx); + } + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd".to_string())) + ); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn empty_window_title_config_leaves_the_outer_title_alone() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title(""); + + server.sync_window_title(); + + assert!(no_window_title(&control_rx)); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn api_window_title_wins_until_it_is_cleared() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + + server.handle_client_window_title_api("set".into(), Some("herdr api".into())); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herdr api".to_string())) + ); + + server.app.state.workspaces[0].custom_name = Some("ops".into()); + server.sync_window_title(); + assert!(no_window_title(&control_rx)); + + // Clearing hands the title back to ui.window_title, not to "herdr". + server.handle_client_window_title_api("clear".into(), None); + assert_eq!( + next_window_title(&control_rx), + Some(Some("ops".to_string())) + ); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title(""); + + server.handle_client_window_title_api("set".into(), Some("herdr api".into())); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herdr api".to_string())) + ); + + server.handle_client_window_title_api("clear".into(), None); + assert_eq!(next_window_title(&control_rx), Some(None)); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn a_newly_promoted_client_gets_the_window_title_again() { + let (mut server, first_control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + server.sync_window_title(); + assert_eq!( + next_window_title(&first_control_rx), + Some(Some("herd".to_string())) + ); + + // A second terminal starts on whatever its shell or ssh left behind. + let (client_tx, second_control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(2); + server.sync_window_title(); + + assert_eq!( + next_window_title(&second_control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); +} + +fn test_client_writer() -> ( + ClientWriter, + std::sync::mpsc::Receiver<Vec<u8>>, + std::sync::mpsc::Receiver<Vec<u8>>, +) { + let (control_tx, control_rx) = std::sync::mpsc::channel(); + let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1); + ( + ClientWriter::test_channel(control_tx, render_tx), + control_rx, + render_rx, + ) +} + +#[tokio::test] +async fn client_shell_attach_seeds_workspace() { + let mut server = test_headless_server(); + server.app.state.workspaces.clear(); + server.app.state.active = None; + server.app.state.mode = crate::app::Mode::Navigate; + let (writer, _control_rx, _render_rx) = test_client_writer(); + + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 6, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + + assert_eq!(server.app.state.mode, crate::app::Mode::Terminal); + assert_eq!(server.app.state.workspaces.len(), 1); + assert_eq!(server.app.state.active, Some(0)); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_endpoint_request_uses_the_selected_connection() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + let client_id = 41; + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + let _initial_snapshot = control_rx.recv().expect("initial shell snapshot"); + let boot_id = server.client_shell_boot_id.clone(); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "client-shell:1".into(), + method: api::schema::Method::IntegrationList(api::schema::EmptyParams::default(),), + }), + }) + ); + assert!(server.clients[&client_id].shell_endpoint_command_in_flight); + + let response_ready = server + .server_event_rx + .recv() + .await + .expect("endpoint response ready"); + assert!(!server.handle_server_event(response_ready)); + assert!(!server.clients[&client_id].shell_endpoint_command_in_flight); + + match read_server_message(control_rx.recv().expect("endpoint response")) { + ServerMessage::ClientShellEndpointResponseChunk { + boot_id: response_boot_id, + request_id, + final_chunk, + data, + } => { + assert_eq!(response_boot_id, boot_id); + assert_eq!(request_id, "client-shell:1"); + assert!(final_chunk); + let response = serde_json::from_slice::<api::schema::SuccessResponse>(&data) + .expect("success response"); + assert_eq!(response.id, "client-shell:1"); + assert!(matches!( + response.result, + api::schema::ResponseResult::IntegrationList { .. } + )); + } + other => panic!("expected client shell endpoint response, got {other:?}"), + } + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_receives_metadata_then_shell_free_pane_surface() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("shell-only-label"); + let pane_id = workspace.focused_pane_id().expect("focused pane"); + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 80, + 23, + b"\x1b[?1003h\x1b[?1006h\x1b[?1016hCLIENT_SHELL_LIVE", + ), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + server.app.state.product_announcement = Some(crate::app::state::ProductAnnouncementState { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: "announcement".into(), + scroll: 0, + preview: true, + }); + server.server_config_diagnostic_without_keybindings = Some("endpoint config warning".into()); + + let (writer, control_rx, render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 7, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: true, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + match read_server_message(control_rx.recv().expect("shell snapshot")) { + ServerMessage::ClientShellSnapshot(snapshot) => { + assert_eq!(snapshot.workspaces.len(), 1); + assert_eq!(snapshot.workspaces[0].label, "shell-only-label"); + assert_eq!( + snapshot.config_diagnostic.as_deref(), + Some("endpoint config warning") + ); + assert_eq!( + snapshot.product_announcement.as_ref().map(|announcement| ( + announcement.version.as_str(), + announcement.id.as_str(), + announcement.preview, + )), + Some(("0.8.2", "client-shell", true)) + ); + } + other => panic!("expected client shell snapshot, got {other:?}"), + } + + server.render_and_stream(); + let initial_surface = match read_server_message(render_rx.recv().expect("pane surface")) { + ServerMessage::PaneSurface(surface) => { + assert_eq!((surface.frame.width, surface.frame.height), (80, 23)); + let text = frame_text(&surface.frame); + assert!(text.contains("CLIENT_SHELL_LIVE"), "surface: {text:?}"); + assert!(!text.contains("shell-only-label"), "surface: {text:?}"); + assert_eq!(surface.panes.len(), 1); + assert_eq!(surface.panes[0].rect.x, 0); + assert_eq!(surface.panes[0].rect.y, 0); + assert!(surface.panes[0].sgr_pixel_mouse); + assert_eq!( + surface.panes[0].pixel_width, + u32::from(surface.panes[0].inner_rect.width) * 10 + ); + assert_eq!( + surface.panes[0].pixel_height, + u32::from(surface.panes[0].inner_rect.height) * 20 + ); + surface + } + other => panic!("expected pane surface, got {other:?}"), + }; + + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("pane runtime") + .test_process_pty_bytes(b"\rPATCHED"); + let sources = std::collections::HashSet::from([pane_id]); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + match read_server_message(render_rx.recv().expect("pane surface patch")) { + ServerMessage::PaneSurfacePatch(patch) => { + assert_eq!( + patch.base_surface_revision, + initial_surface.surface_revision + ); + assert_eq!(patch.surface_revision, initial_surface.surface_revision + 1); + assert_eq!(patch.panes.len(), 1); + assert!(!patch.rows.is_empty()); + assert!(patch + .rows + .iter() + .flat_map(|row| &row.cells) + .any(|cell| cell.symbol == "P")); + } + other => panic!("expected pane surface patch, got {other:?}"), + } + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("pane runtime") + .test_process_pty_bytes(b"\x1b[?1003l\x1b[?1006l\x1b[?1016l"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + match read_server_message(render_rx.recv().expect("metadata-only pane surface patch")) { + ServerMessage::PaneSurfacePatch(patch) => { + assert_eq!(patch.panes.len(), 1); + assert!(!patch.panes[0].mouse_reporting); + assert!(!patch.panes[0].sgr_pixel_mouse); + } + other => panic!("expected metadata-only pane surface patch, got {other:?}"), + } + let retained = server.clients[&7] + .render_state + .last_pane_surface() + .expect("committed retained surface") + .clone(); + server + .clients + .get_mut(&7) + .unwrap() + .render_state + .request_repaint(); + server.render_and_stream(); + let full = match read_server_message(render_rx.recv().expect("full comparison surface")) { + ServerMessage::PaneSurface(surface) => surface, + other => panic!("expected full comparison surface, got {other:?}"), + }; + assert!(full.surface_revision > retained.surface_revision); + assert_eq!(retained.frame, full.frame); + assert_eq!(retained.panes, full.panes); + assert_eq!(retained.splits, full.splits); + shutdown_test_runtimes(&mut server); +} + +fn install_shared_view_test_runtime(server: &mut HeadlessServer) -> crate::layout::PaneId { + let mut workspace = crate::workspace::Workspace::test_new("shared-view"); + let pane_id = workspace.focused_pane_id().expect("focused pane"); + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"BASE"), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + pane_id +} + +fn connect_test_shell( + server: &mut HeadlessServer, + client_id: u64, + surface_cols: u16, + surface_rows: u16, +) -> ( + std::sync::mpsc::Receiver<Vec<u8>>, + std::sync::mpsc::Receiver<Vec<u8>>, +) { + let (writer, control, render) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id, + surface_cols, + surface_rows, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + (control, render) +} + +fn connect_matching_test_shell( + server: &mut HeadlessServer, + client_id: u64, +) -> ( + std::sync::mpsc::Receiver<Vec<u8>>, + std::sync::mpsc::Receiver<Vec<u8>>, +) { + connect_test_shell(server, client_id, 80, 23) +} + +fn write_shared_test_pane( + server: &mut HeadlessServer, + pane_id: crate::layout::PaneId, + bytes: &[u8], +) { + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("pane runtime") + .test_process_pty_bytes(bytes); +} + +fn recv_pane_surface( + receiver: &std::sync::mpsc::Receiver<Vec<u8>>, + context: &str, +) -> crate::protocol::PaneSurfaceFrame { + match read_server_message( + receiver + .recv() + .unwrap_or_else(|error| panic!("{context}: {error}")), + ) { + ServerMessage::PaneSurface(surface) => surface, + other => panic!("{context}: expected pane surface, got {other:?}"), + } +} + +fn recv_pane_surface_patch( + receiver: &std::sync::mpsc::Receiver<Vec<u8>>, + context: &str, +) -> crate::protocol::PaneSurfacePatch { + match read_server_message( + receiver + .recv() + .unwrap_or_else(|error| panic!("{context}: {error}")), + ) { + ServerMessage::PaneSurfacePatch(patch) => patch, + other => panic!("{context}: expected pane surface patch, got {other:?}"), + } +} + +#[tokio::test] +async fn different_size_shells_receive_geometry_specific_patches_from_one_dirty_collection() { + let mut server = test_headless_server(); + let pane_id = install_shared_view_test_runtime(&mut server); + let (large_control, large_render) = connect_test_shell(&mut server, 7, 80, 23); + let (small_control, small_render) = connect_test_shell(&mut server, 8, 68, 17); + let _ = large_control.recv().expect("large snapshot"); + let _ = small_control.recv().expect("small snapshot"); + server.render_and_stream(); + let large_initial = recv_pane_surface(&large_render, "large initial surface"); + let small_initial = recv_pane_surface(&small_render, "small initial surface"); + assert_eq!( + (large_initial.frame.width, large_initial.frame.height), + (80, 23) + ); + assert_eq!( + (small_initial.frame.width, small_initial.frame.height), + (68, 17) + ); + assert_ne!( + large_initial.panes[0].inner_rect, + small_initial.panes[0].inner_rect + ); + + write_shared_test_pane(&mut server, pane_id, b"\rMIXED"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + + let large_patch = recv_pane_surface_patch(&large_render, "large retained patch"); + let small_patch = recv_pane_surface_patch(&small_render, "small retained patch"); + assert_eq!( + large_patch.base_surface_revision, + large_initial.surface_revision + ); + assert_eq!( + small_patch.base_surface_revision, + small_initial.surface_revision + ); + assert!(large_patch.rows.iter().all(|row| { + row.x + .saturating_add(u16::try_from(row.cells.len()).unwrap_or(u16::MAX)) + <= large_initial.frame.width + && row.y < large_initial.frame.height + })); + assert!(small_patch.rows.iter().all(|row| { + row.x + .saturating_add(u16::try_from(row.cells.len()).unwrap_or(u16::MAX)) + <= small_initial.frame.width + && row.y < small_initial.frame.height + })); + assert_eq!(large_patch.rows, small_patch.rows); + assert_ne!( + large_patch.panes[0].inner_rect, + small_patch.panes[0].inner_rect + ); + assert!(frame_text( + &server.clients[&7] + .render_state + .last_pane_surface() + .expect("large retained surface") + .frame + ) + .contains("MIXED")); + assert!(frame_text( + &server.clients[&8] + .render_state + .last_pane_surface() + .expect("small retained surface") + .frame + ) + .contains("MIXED")); + + write_shared_test_pane(&mut server, pane_id, b"\x1b[?1049hALT"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + server.render_and_stream(); + let large_alt = recv_pane_surface(&large_render, "large alternate-screen surface"); + let small_alt = recv_pane_surface(&small_render, "small alternate-screen surface"); + assert!(large_alt.panes[0].alternate_screen_active); + assert!(small_alt.panes[0].alternate_screen_active); + assert_eq!( + large_alt.panes[0].inner_rect.width, + large_initial.panes[0].inner_rect.width + 1 + ); + assert_eq!( + small_alt.panes[0].inner_rect.width, + small_initial.panes[0].inner_rect.width + 1 + ); + + write_shared_test_pane(&mut server, pane_id, b"\x1b[?1049l"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + server.render_and_stream(); + let large_main = recv_pane_surface(&large_render, "large restored main-screen surface"); + let small_main = recv_pane_surface(&small_render, "small restored main-screen surface"); + assert!(!large_main.panes[0].alternate_screen_active); + assert!(!small_main.panes[0].alternate_screen_active); + assert_eq!( + large_main.panes[0].inner_rect, + large_initial.panes[0].inner_rect + ); + assert_eq!( + small_main.panes[0].inner_rect, + small_initial.panes[0].inner_rect + ); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn backpressured_shell_does_not_disable_retained_patches_for_responsive_peer() { + let mut server = test_headless_server(); + let pane_id = install_shared_view_test_runtime(&mut server); + let (responsive_control, responsive_render) = connect_matching_test_shell(&mut server, 7); + let (slow_control, slow_render) = connect_matching_test_shell(&mut server, 8); + let _ = responsive_control.recv().expect("responsive snapshot"); + let _ = slow_control.recv().expect("slow snapshot"); + server.render_and_stream(); + let _ = responsive_render + .recv() + .expect("responsive initial surface"); + let _ = slow_render.recv().expect("slow initial surface"); + + let sources = HashSet::from([pane_id]); + write_shared_test_pane(&mut server, pane_id, b"\rONE"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + assert!(matches!( + read_server_message(responsive_render.recv().expect("responsive first patch")), + ServerMessage::PaneSurfacePatch(_) + )); + + write_shared_test_pane(&mut server, pane_id, b"\rTWO"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + assert!(matches!( + read_server_message(responsive_render.recv().expect("responsive second patch")), + ServerMessage::PaneSurfacePatch(_) + )); + assert_eq!(server.clients[&8].deferred_render(), DeferredRender::Full); + + write_shared_test_pane(&mut server, pane_id, b"\rTHREE"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + assert!(matches!( + read_server_message(responsive_render.recv().expect("responsive third patch")), + ServerMessage::PaneSurfacePatch(_) + )); + + assert!(matches!( + read_server_message(slow_render.recv().expect("slow queued first patch")), + ServerMessage::PaneSurfacePatch(_) + )); + assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 8 })); + server.render_and_stream(); + assert!(matches!( + read_server_message(slow_render.recv().expect("slow full recovery surface")), + ServerMessage::PaneSurface(_) + )); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn full_render_backpressure_does_not_disable_responsive_peer_patches() { + let mut server = test_headless_server(); + let pane_id = install_shared_view_test_runtime(&mut server); + let (responsive_control, responsive_render) = connect_matching_test_shell(&mut server, 7); + let (slow_control, slow_render) = connect_matching_test_shell(&mut server, 8); + let _ = responsive_control.recv().expect("responsive snapshot"); + let _ = slow_control.recv().expect("slow snapshot"); + server.render_and_stream(); + let _ = responsive_render + .recv() + .expect("responsive initial surface"); + // Keep the slow client's initial surface queued, then force another full + // replacement for both clients. + server.clients.get_mut(&7).unwrap().request_repaint(); + server.clients.get_mut(&8).unwrap().request_repaint(); + server.app.full_redraw_pending = true; + server.render_and_stream(); + let _ = responsive_render + .recv() + .expect("responsive full replacement"); + assert_eq!(server.clients[&8].deferred_render(), DeferredRender::Full); + assert!(!server.app.full_redraw_pending); + + write_shared_test_pane(&mut server, pane_id, b"\rPATCH"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + assert!(matches!( + read_server_message(responsive_render.recv().expect("responsive retained patch")), + ServerMessage::PaneSurfacePatch(_) + )); + + let _ = slow_render.recv().expect("slow queued initial surface"); + assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 8 })); + server.render_and_stream(); + assert!(matches!( + read_server_message(slow_render.recv().expect("slow full recovery surface")), + ServerMessage::PaneSurface(_) + )); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_config_diagnostics_follow_keybinding_ownership() { + let mut server = test_headless_server(); + server.server_config_diagnostic = Some("server keybinding warning\ntheme warning".into()); + server.server_config_diagnostic_without_keybindings = Some("theme warning".into()); + + let (local_writer, local_control, _local_render) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 13, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer: local_writer, + }) + ); + let ServerMessage::ClientShellSnapshot(local_snapshot) = + read_server_message(local_control.recv().expect("local shell snapshot")) + else { + panic!("expected local shell snapshot"); + }; + assert_eq!( + local_snapshot.config_diagnostic.as_deref(), + Some("theme warning") + ); + + let (endpoint_writer, endpoint_control, _endpoint_render) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 14, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: false, + writer: endpoint_writer, + }) + ); + let ServerMessage::ClientShellSnapshot(endpoint_snapshot) = + read_server_message(endpoint_control.recv().expect("endpoint shell snapshot")) + else { + panic!("expected endpoint shell snapshot"); + }; + assert_eq!( + endpoint_snapshot.config_diagnostic.as_deref(), + Some("server keybinding warning\ntheme warning") + ); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_replaces_projection_and_focuses_stable_ids() { + let mut server = test_headless_server(); + let first = crate::workspace::Workspace::test_new("first"); + let second = crate::workspace::Workspace::test_new("second"); + server.app.state.workspaces = vec![first, second]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_id = server.app.session_snapshot().workspaces[1] + .workspace_id + .clone(); + + let (writer, control_rx, render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 9, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + let initial_revision = match read_server_message(control_rx.recv().expect("initial snapshot")) { + ServerMessage::ClientShellSnapshot(snapshot) => snapshot.revision, + other => panic!("expected initial shell snapshot, got {other:?}"), + }; + + let _ = server.app.handle_api_request(crate::api::schema::Request { + id: "test.client.shell.workspace.focus".into(), + method: crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: second_id.clone(), + }), + }); + assert_eq!(server.app.state.active, Some(1)); + server.render_and_stream(); + + let replacement = match read_server_message(control_rx.recv().expect("replacement snapshot")) { + ServerMessage::ClientShellSnapshot(snapshot) => snapshot, + other => panic!("expected replacement shell snapshot, got {other:?}"), + }; + assert!(replacement.revision > initial_revision); + assert_eq!( + replacement.focused_workspace_id.as_deref(), + Some(second_id.as_str()) + ); + match read_server_message(render_rx.recv().expect("replacement pane surface")) { + ServerMessage::PaneSurface(surface) => { + assert_eq!(surface.projection_revision, replacement.revision); + } + other => panic!("expected replacement pane surface, got {other:?}"), + } + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_input_targets_runtime_without_server_shell_classification() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1000h\x1b[?1006h"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![ + crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + }, + crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), + kind: crate::protocol::ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + }, + crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: crossterm::event::KeyModifiers::ALT.bits(), + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + }, + crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left, + ), + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }, + ], + }) + ); + assert_eq!( + input_rx.try_recv().expect("targeted pane interrupt"), + Bytes::from_static(&[0x03]) + ); + assert_eq!( + input_rx.try_recv().expect("targeted pane alt key"), + Bytes::from_static(b"\x1bx") + ); + assert_eq!( + input_rx.try_recv().expect("targeted pane mouse click"), + Bytes::from_static(b"\x1b[<0;3;2M") + ); + assert_eq!(server.foreground_client_id, Some(11)); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + assert!(server.paste_client_clipboard_image_path( + 11, + crate::protocol::ClientClipboardImageTarget::Pane(pane_id.clone()), + "/tmp/client-image.png".into(), + )); + assert_eq!( + input_rx.try_recv().expect("targeted clipboard image path"), + Bytes::from_static(b"/tmp/client-image.png") + ); + assert!(!server.paste_client_clipboard_image_path( + 11, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + "/tmp/wrong-target.png".into(), + )); + assert!(!server.paste_client_clipboard_image_path( + 11, + crate::protocol::ClientClipboardImageTarget::Popup("missing-popup".into()), + "/tmp/wrong-target.png".into(), + )); + assert!(input_rx.try_recv().is_err()); + + let (workspace_index, runtime_pane_id) = server + .app + .parse_pane_id(&pane_id) + .expect("runtime pane target"); + let runtime = server + .app + .state + .runtime_for_pane_in_workspace( + &server.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) + .expect("focused runtime"); + assert_eq!(runtime.current_size(), (24, 79)); + assert!(input_rx.try_recv().is_err(), "legacy release emitted bytes"); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_hidden_pane_rejects_presses_but_accepts_releases() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("hidden-input"); + let hidden_tab = workspace.test_add_tab(Some("hidden")); + let hidden_pane = workspace.tabs[hidden_tab].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[>3u", + 4, + ); + workspace.insert_test_runtime(hidden_pane, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + let pane_id = server.app.public_pane_id(0, hidden_pane).unwrap(); + server.clients.insert( + 11, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + let key = |kind| crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: Some(0x2d), + }; + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: pane_id.clone(), + events: vec![key(crate::protocol::ClientKeyKind::Press)], + }) + ); + assert!(input_rx.try_recv().is_err()); + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![key(crate::protocol::ClientKeyKind::Release)], + }) + ); + assert!(!input_rx.recv().await.expect("encoded release").is_empty()); + assert_eq!(server.foreground_client_id, None); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_streams_and_targets_popup_terminal_content() { + let mut server = test_headless_server(); + let mut pane_input = install_focused_test_runtime(&mut server, b"base-pane"); + let (popup_runtime, mut popup_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 40, + 12, + 0, + b"POPUP_SHELL_LIVE\x1b_Ga=T,f=32,t=d,i=9,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + 4, + ); + let (_, popup_terminal_id) = server.app.install_test_popup_runtime(popup_runtime); + + let (writer, control_rx, render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 12, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + assert!(matches!( + read_server_message(control_rx.recv().expect("shell snapshot")), + ServerMessage::ClientShellSnapshot(_) + )); + + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("popup surface")) + else { + panic!("expected pane surface"); + }; + let popup = surface.popup.as_deref().expect("popup terminal surface"); + assert_eq!(popup.terminal_id, popup_terminal_id.as_str()); + assert!(frame_text(&popup.frame).contains("POPUP_SHELL_LIVE")); + assert_eq!((popup.frame.width, popup.frame.height), (37, 9)); + assert_eq!(surface.graphics.assets.len(), 1); + assert_eq!(surface.graphics.placements.len(), 1); + assert!(matches!( + surface.graphics.placements[0].asset.source, + crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Popup { .. }, + image_id: 9, + } + )); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 12, + pane_id: server.app.session_snapshot().focused_pane_id.unwrap(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "must-not-leak".into(), + )], + }) + ); + assert!(pane_input.try_recv().is_err()); + + assert!(server.handle_server_event(ServerEvent::ClientShellResize { + client_id: 12, + surface_cols: 60, + surface_rows: 15, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + })); + assert_eq!( + server + .app + .terminal_runtimes + .get(&popup_terminal_id) + .expect("popup runtime") + .current_size(), + (5, 27) + ); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPopupInput { + client_id: 12, + terminal_id: popup_terminal_id.to_string(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "typed".into() + )], + }) + ); + assert_eq!( + popup_input.try_recv().expect("popup input"), + Bytes::from_static(b"typed") + ); + assert!(server.paste_client_clipboard_image_path( + 12, + crate::protocol::ClientClipboardImageTarget::Popup(popup_terminal_id.to_string()), + "/tmp/popup-image.png".into(), + )); + assert_eq!( + popup_input.try_recv().expect("popup clipboard image path"), + Bytes::from_static(b"/tmp/popup-image.png") + ); + assert!(!server.paste_client_clipboard_image_path( + 12, + crate::protocol::ClientClipboardImageTarget::Popup("stale-popup".into()), + "/tmp/wrong-popup.png".into(), + )); + assert!(popup_input.try_recv().is_err()); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPopupInput { + client_id: 12, + terminal_id: "stale-popup".into(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "wrong".into() + )], + }) + ); + assert!(popup_input.try_recv().is_err()); + + assert!(server.app.close_popup_pane()); + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("popup close surface")) + else { + panic!("expected pane surface after popup close"); + }; + assert!(surface.popup.is_none()); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_release_under_popup_renders_when_it_resets_scrollback() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("popup-release-scroll"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 2, + 10_000, + b"one\r\ntwo\r\nthree\r\n\x1b[>3u", + 4, + ); + runtime.scroll_up(1); + assert!(runtime + .scroll_metrics() + .is_some_and(|metrics| metrics.offset_from_bottom > 0)); + workspace.insert_test_runtime(pane_id, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + let popup_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b""); + server.app.install_test_popup_runtime(popup_runtime); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(11); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: public_pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: Some(0x2d), + }], + }); + + assert_eq!(render_impact, RenderImpact::Full); + assert!(!input_rx.recv().await.expect("encoded release").is_empty()); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_text_input_renders_only_when_resetting_scrollback() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("scrolled-input"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 2, + 10_000, + b"one\r\ntwo\r\nthree\r\n", + 4, + ); + runtime.scroll_up(1); + assert!(runtime + .scroll_metrics() + .is_some_and(|metrics| metrics.offset_from_bottom > 0)); + workspace.insert_test_runtime(pane_id, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(11); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: public_pane_id.clone(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "x".to_owned(), + )], + }); + + assert_eq!(render_impact, RenderImpact::Full); + assert_eq!( + input_rx.try_recv().expect("text must reach the PTY"), + Bytes::from_static(b"x") + ); + assert_eq!( + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .and_then(|runtime| runtime.scroll_metrics()) + .map(|metrics| metrics.offset_from_bottom), + Some(0) + ); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: public_pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "y".to_owned(), + )], + }); + assert_eq!(render_impact, RenderImpact::None); + assert_eq!( + input_rx.try_recv().expect("second text must reach the PTY"), + Bytes::from_static(b"y") + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_mouse_motion_delivers_without_render_when_foreground() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(11); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 0, + }], + }); + + assert_eq!(render_impact, RenderImpact::None); + assert!( + input_rx.try_recv().is_ok(), + "motion must still reach the PTY" + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_mouse_motion_promotes_and_requests_render() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 0, + }], + }); + + assert_eq!(render_impact, RenderImpact::Full); + assert_eq!(server.foreground_client_id, Some(11)); + assert!( + input_rx.try_recv().is_ok(), + "motion must still reach the PTY" + ); + shutdown_test_runtimes(&mut server); +} + +fn install_focused_test_runtime( + server: &mut HeadlessServer, + terminal_bytes: &[u8], +) -> tokio::sync::mpsc::Receiver<Bytes> { + let mut workspace = crate::workspace::Workspace::test_new("focus-reporting"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + terminal_bytes, + 4, + ); + workspace.insert_test_runtime(pane_id, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + input_rx +} + +fn retained_test_server( + initial_screen: &[u8], +) -> ( + HeadlessServer, + std::sync::mpsc::Receiver<Vec<u8>>, + crate::layout::PaneId, +) { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.focused_pane_id().expect("focused pane"); + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + + (server, client_rx, pane_id) +} + +#[test] +fn server_keybinding_filter_keeps_whole_config_failures() { + assert!(!config::is_keybinding_config_diagnostic( + "config parse error: invalid value at `keys.new_tab = @`; using defaults" + )); + assert!(!config::is_keybinding_config_diagnostic( + "config read error: permission denied at keys.toml; using defaults" + )); + assert!(config::is_keybinding_config_diagnostic( + "unsafe direct keybinding: keys.close_pane would intercept typing" + )); +} + +#[test] +fn client_shell_host_theme_follows_foreground_client() { + let mut server = test_headless_server(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + + let dark = protocol::ClientHostColor { + r: 20, + g: 30, + b: 40, + }; + let blue = protocol::ClientHostColor { + r: 10, + g: 20, + b: 200, + }; + assert!( + server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 1, + update: protocol::ClientHostThemeUpdate::DefaultColor { + kind: protocol::ClientHostDefaultColorKind::Background, + color: dark, + }, + }) + ); + assert!( + server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 1, + update: protocol::ClientHostThemeUpdate::PaletteColors(vec![(4, blue)]), + }) + ); + server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 1, + update: protocol::ClientHostThemeUpdate::Appearance(protocol::ClientHostAppearance::Dark), + }); + assert_eq!( + server.app.state.host_terminal_theme.background, + Some(dark.into()) + ); + assert_eq!( + server.app.state.host_terminal_theme.palette[4], + Some(blue.into()) + ); + assert_eq!( + server.app.state.host_terminal_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert!(server.app.state.host_terminal_appearance_explicit); + + let light = protocol::ClientHostColor { + r: 240, + g: 230, + b: 220, + }; + assert!( + !server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 2, + update: protocol::ClientHostThemeUpdate::DefaultColor { + kind: protocol::ClientHostDefaultColorKind::Background, + color: light, + }, + }) + ); + assert_eq!( + server.app.state.host_terminal_theme.background, + Some(dark.into()) + ); + + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + assert_eq!( + server.app.state.host_terminal_theme.background, + Some(light.into()) + ); + assert_eq!( + server.app.state.host_terminal_appearance, + Some(crate::terminal_theme::HostAppearance::Light) + ); + assert!(!server.app.state.host_terminal_appearance_explicit); +} + +#[test] +fn terminal_clients_store_known_cell_geometry_independently_of_pixel_mouse() { + let mut server = test_headless_server(); + + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: true, + writer, + })); + assert!(!server.clients[&7].pixel_mouse); + assert_eq!( + server.clients[&7].cell_size, + crate::kitty_graphics::HostCellSize::default() + ); + + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 8, + cols: 80, + rows: 24, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: false, + writer, + })); + assert!(!server.clients[&8].pixel_mouse); + assert_eq!( + server.clients[&8].cell_size, + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + } + ); +} + +#[test] +fn terminal_attach_rejects_missing_terminal_and_removes_client() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!(matches!( + server.clients.get(&7).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalPending) + )); + + assert!( + !server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: "term_missing".to_owned(), + takeover: false, + }) + ); + assert!(!server.clients.contains_key(&7)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!( + reason, + Some("terminal attach failed: terminal term_missing not found".to_owned()) + ); +} + +fn with_terminal_session_test_server( + test: impl FnOnce(&mut HeadlessServer, crate::terminal::TerminalId, String, String), +) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); + let terminal_id_string = terminal_id.to_string(); + let public_pane_id = format!("{}:p1", workspace.id); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.terminal_runtimes.insert( + terminal_id.clone(), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + + test(&mut server, terminal_id, terminal_id_string, public_pane_id); + + drop(server); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) { + let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id); +} + +fn connect_pending_terminal_client_with_control_rx( + server: &mut HeadlessServer, + client_id: u64, +) -> std::sync::mpsc::Receiver<Vec<u8>> { + let (writer, control_rx, _render_rx) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id, + cols: 100, + rows: 30, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!(matches!( + server.clients.get(&client_id).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalPending) + )); + control_rx +} + +#[test] +fn explicit_agent_history_read_requires_idle_on_alternate_screen() { + with_terminal_session_test_server( + |server, terminal_id, _terminal_id_string, public_pane_id| { + let terminal = server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("terminal"); + terminal.detected_agent = Some(crate::detect::Agent::Claude); + terminal.state = crate::detect::AgentState::Working; + server.app.terminal_runtimes.insert( + terminal_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 80, + 24, + b"\x1b[?1049hworking", + ), + ); + let request = api::schema::Request { + id: "read".into(), + method: api::schema::Method::AgentRead(api::schema::AgentReadParams { + target: public_pane_id.clone(), + source: api::schema::ReadSource::Recent, + lines: Some(200), + format: api::schema::ReadFormat::Text, + strip_ansi: true, + }), + }; + + assert_eq!( + server.agent_read_not_idle_error(&request), + Some(api::schema::ErrorBody { + code: "agent_not_idle".into(), + message: format!( + "cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible" + ), + }) + ); + + let mut default_request = request.clone(); + let api::schema::Method::AgentRead(params) = &mut default_request.method else { + unreachable!(); + }; + params.lines = None; + assert_eq!(server.agent_read_not_idle_error(&default_request), None); + + let mut visible_request = request; + let api::schema::Method::AgentRead(params) = &mut visible_request.method else { + unreachable!(); + }; + params.source = api::schema::ReadSource::Visible; + assert_eq!(server.agent_read_not_idle_error(&visible_request), None); + }, + ); +} + +#[test] +fn terminal_attach_disconnect_restores_client_shell_pane_size() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); + let terminal_id_string = terminal_id.to_string(); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.terminal_runtimes.insert( + terminal_id.clone(), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + let expected_shell_size = server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(); + + connect_pending_terminal_client(&mut server, 2); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 2, + terminal_id: terminal_id_string, + takeover: false, + }) + ); + assert_eq!(server.foreground_client_id, Some(1)); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + (30, 100) + ); + + assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 })); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + expected_shell_size + ); + + drop(server); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn terminal_observe_allows_multiple_clients_without_attach_ownership() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + let initial_size = server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(); + + for client_id in [7, 8] { + connect_pending_terminal_client(server, client_id); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id, + target: terminal_id_string.clone(), + }) + ); + } + + assert!(server.terminal_attach_owners.is_empty()); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + initial_size + ); + assert_eq!( + terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), + 2 + ); + }); +} + +#[test] +fn direct_terminal_observer_keeps_hidden_pty_source_renderable_with_client_shell() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let background_tab = workspace.test_add_tab(Some("background")); + let background_pane = workspace.tabs[background_tab].root_pane; + let hidden_pane = workspace.tabs[0].root_pane; + let terminal_id = workspace + .terminal_id(background_pane) + .expect("background terminal id") + .to_string(); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.ensure_test_terminals(); + + let (shell_writer, _shell_control_rx, _shell_render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(shell_writer), + ), + ); + assert!(!server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + + let (observer_writer, _observer_control_rx, _observer_render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalObserve { terminal_id }, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(observer_writer), + ), + ); + + assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + server.sync_immediate_pty_sources(); + assert!(server.app.render_dirty.request_pty(background_pane)); + assert!(server.has_pending_presentation_work(false, false)); + assert!(server.app.render_dirty.request_pty(hidden_pane)); +} + +#[test] +fn terminal_observe_resolves_public_pane_id() { + with_terminal_session_test_server(|server, terminal_id, _, public_pane_id| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target: public_pane_id, + }) + ); + + assert!(matches!( + server.clients.get(&7).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalObserve { terminal_id: observed }) + if observed == &terminal_id.to_string() + )); + }); +} + +#[test] +fn terminal_control_resolves_public_pane_id_and_takes_ownership() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, public_pane_id| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: public_pane_id, + takeover: false, + }) + ); + + assert!(matches!( + server.clients.get(&7).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalAttach { terminal_id: attached }) + if attached == &terminal_id_string + )); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + }); +} + +#[test] +fn terminal_control_rejects_attach_during_alt_screen_read() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + server.pending_alt_screen_reads.push( + crate::server::alt_screen_read::PendingAltScreenRead::start( + terminal_id, + "read".into(), + respond_to, + "fallback".into(), + api::schema::PaneReadResult { + pane_id: "w1:p1".into(), + workspace_id: "w1".into(), + tab_id: "w1:t1".into(), + source: api::schema::ReadSource::Recent, + format: api::schema::ReadFormat::Text, + text: String::new(), + revision: 0, + truncated: false, + }, + 120, + false, + crate::terminal::ScreenSnapshot { + cols: 80, + rows: Vec::new(), + }, + 0, + Instant::now(), + ), + ); + let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); + + assert!( + !server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + assert!(!server.clients.contains_key(&7)); + assert!(!server + .terminal_attach_owners + .contains_key(&terminal_id_string)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!( + reason, + Some(format!( + "terminal attach failed: terminal {terminal_id_string} has a read in progress; retry" + )) + ); + }); +} + +#[test] +fn terminal_control_rejects_second_controller_without_takeover() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + connect_pending_terminal_client(server, 8); + assert!( + !server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 8, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + assert!(server.clients.contains_key(&7)); + assert!(!server.clients.contains_key(&8)); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + }); +} + +#[test] +fn terminal_control_takeover_replaces_existing_controller() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + connect_pending_terminal_client(server, 8); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 8, + target: terminal_id_string.clone(), + takeover: true, + }) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(server.clients.contains_key(&8)); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&8) + ); + }); +} + +#[test] +fn terminal_observe_can_coexist_with_terminal_control() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + connect_pending_terminal_client(server, 8); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 8, + target: terminal_id_string.clone(), + }) + ); + + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + assert!(matches!( + server.clients.get(&8).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalObserve { terminal_id }) + if terminal_id == &terminal_id_string + )); + assert_eq!( + terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), + 2 + ); + }); +} + +#[test] +fn terminal_control_detach_sends_shutdown_before_removal() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 })); + + assert!(!server.clients.contains_key(&7)); + assert!(!server + .terminal_attach_owners + .contains_key(&terminal_id_string)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!(reason, Some("detached".to_owned())); + }); +} + +#[test] +fn terminal_observe_rejects_later_attach_upgrade() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target: terminal_id_string.clone(), + }) + ); + assert!( + !server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id_string, + takeover: true, + }) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(server.terminal_attach_owners.is_empty()); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + }); +} + +#[test] +fn terminal_attach_rejects_later_observe_and_clears_ownership() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id_string.clone(), + takeover: false, + }) + ); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + + assert!( + !server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target: terminal_id_string.clone(), + }) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(server.terminal_attach_owners.is_empty()); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + }); +} + +#[test] +fn unchanged_git_refresh_does_not_request_headless_render() { + let mut server = test_headless_server(); + server.app.git_refresh_in_flight = true; + let mut workspace = crate::workspace::Workspace::test_new("one"); + let workspace_id = workspace.id.clone(); + let cwd = workspace.identity_cwd.clone(); + workspace.cached_auto_label = "cached".into(); + workspace.cached_git_status_key = cwd.clone(); + workspace.cached_git_branch = None; + server.app.state.workspaces.push(workspace); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { + results: vec![crate::workspace::WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd.clone(), + status_cache_key: cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "cached".into(), + branch: None, + ahead_behind: None, + space: None, + }], + cache_updates: Vec::new(), + }); + + assert!(!changed); + assert!(!server.app.git_refresh_in_flight); +} + +#[test] +fn changed_git_refresh_requests_headless_render() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("one"); + let workspace_id = workspace.id.clone(); + let cwd = workspace.identity_cwd.clone(); + server.app.state.workspaces.push(workspace); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { + results: vec![crate::workspace::WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd.clone(), + status_cache_key: cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "one".into(), + branch: Some("changed".into()), + ahead_behind: None, + space: None, + }], + cache_updates: Vec::new(), + }); + + assert!(changed); +} + +#[test] +fn terminal_attach_client_exits_when_attached_pane_dies() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("attached"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .expect("pane") + .attached_terminal_id + .to_string(); + let (writer, control_rx, _render_rx) = test_client_writer(); + + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id.clone(), + takeover: false, + }) + ); + assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7)); + + assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); + + assert!(!server.clients.contains_key(&7)); + assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); +} + +#[test] +fn terminal_attach_scroll_moves_attached_runtime_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let runtime = crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Up, + 3, + None, + None, + 0, + ) + .expect("scroll up"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 3); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Down, + 2, + None, + None, + 0, + ) + .expect("scroll down"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 1); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn client_pane_pixel_mouse_uses_runtime_pixel_encoding() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, + 5, + 0, + b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", + 4, + ); + runtime.resize(5, 20, 10, 20); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("pixel mouse input"); + assert_eq!( + input_rx.try_recv().expect("encoded pixel mouse"), + Bytes::from_static(b"\x1b[<35;21;22M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn client_pane_pixel_mouse_falls_back_to_canonical_cell_position() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, + 5, + 0, + b"\x1b[?1003h\x1b[?1006h", + 4, + ); + runtime.resize(5, 20, 10, 20); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("cell mouse fallback"); + assert_eq!( + input_rx.try_recv().expect("encoded cell mouse"), + Bytes::from_static(b"\x1b[<35;3;2M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn client_pane_wheel_input_accumulates_scrollback_offset() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + let scroll = |kind| crate::protocol::ClientPaneInputEvent::Mouse { + kind, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }; + + apply_client_pane_input_events( + &runtime, + &[scroll(crate::protocol::ClientMouseKind::ScrollUp)], + ) + .expect("first scroll up"); + apply_client_pane_input_events( + &runtime, + &[scroll(crate::protocol::ClientMouseKind::ScrollUp)], + ) + .expect("second scroll up"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 6 + ); + + apply_client_pane_input_events( + &runtime, + &[scroll(crate::protocol::ClientMouseKind::ScrollDown)], + ) + .expect("scroll down"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 3 + ); + + runtime.test_process_pty_bytes(b"\x1b[?1003h\x1b[?1006h"); + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("reported mouse motion"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 3 + ); + assert_eq!( + input_rx.try_recv().expect("reported mouse motion"), + Bytes::from_static(b"\x1b[<35;3;2M") + ); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left), + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("mouse button"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("reported mouse button"), + Bytes::from_static(b"\x1b[<0;3;2M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn terminal_attach_input_resets_scrolled_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + + runtime.scroll_up(4); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + + apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded input"), + Bytes::from("x") + ); + + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +fn with_terminal_attach_runtime( + initial_bytes: &[u8], + initial_scroll: usize, + test: impl FnOnce(&crate::terminal::TerminalRuntime, &mut mpsc::Receiver<Bytes>), +) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = initial_bytes.to_vec(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + if initial_scroll > 0 { + runtime.scroll_up(initial_scroll); + } + + test(&runtime, &mut input_rx); + + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +fn apply_terminal_attach_page_up(runtime: &crate::terminal::TerminalRuntime) { + apply_terminal_attach_scroll( + runtime, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec(), + }, + AttachScrollDirection::Up, + 4, + None, + None, + 0, + ) + .expect("page key"); +} + +fn client_page_key( + code: crate::protocol::ClientKeyCode, + modifiers: crossterm::event::KeyModifiers, + kind: crate::protocol::ClientKeyKind, +) -> crate::protocol::ClientPaneInputEvent { + crate::protocol::ClientPaneInputEvent::Key { + code, + modifiers: modifiers.bits(), + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + } +} + +#[test] +fn client_plain_page_keys_scroll_shell_transcript_by_pane_height() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("pane PageUp"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 5 + ); + + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Release, + )], + ) + .expect("pane PageUp release"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 5 + ); + + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageDown, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("pane PageDown"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert!(input_rx.try_recv().is_err(), "page keys reached the shell"); + }); +} + +#[test] +fn client_page_keys_forward_when_modified_or_owned_by_application() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::CONTROL, + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("modified pane PageUp"); + assert!( + input_rx.try_recv().is_ok(), + "modified PageUp was not forwarded" + ); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + }); + + with_terminal_attach_runtime(b"\x1b[?1h", 0, |runtime, input_rx| { + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("application PageUp"); + assert_eq!( + input_rx.try_recv().expect("forwarded application PageUp"), + Bytes::from_static(b"\x1b[5~") + ); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + }); +} + +#[test] +fn client_popup_plain_page_key_remains_popup_input() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_client_popup_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("popup PageUp"); + assert_eq!( + input_rx.try_recv().expect("forwarded popup PageUp"), + Bytes::from_static(b"\x1b[5~") + ); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + }); +} + +#[test] +fn terminal_attach_paste_uses_plain_text_when_runtime_did_not_enable_brackets() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) + .expect("attach paste"); + + assert_eq!( + input_rx.try_recv().expect("forwarded paste"), + Bytes::from_static(b"line one\nline two") + ); + }); +} + +#[test] +fn terminal_attach_paste_preserves_brackets_when_runtime_enabled_them() { + with_terminal_attach_runtime(b"\x1b[?2004h", 0, |runtime, input_rx| { + apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) + .expect("attach paste"); + + assert_eq!( + input_rx.try_recv().expect("forwarded paste"), + Bytes::from_static(b"\x1b[200~line one\nline two\x1b[201~") + ); + }); +} + +#[test] +fn terminal_attach_page_key_host_scrolls_plain_terminal() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + assert!(input_rx.try_recv().is_err()); + }); +} + +#[test] +fn terminal_attach_page_key_forwards_when_mouse_reporting() { + with_terminal_attach_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + }); +} + +#[test] +fn terminal_attach_page_key_forwards_when_application_cursor() { + with_terminal_attach_runtime(b"\x1b[?1h", 3, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + }); +} + +#[test] +fn terminal_attach_page_key_host_scrolls_shell_like_decckm_with_bracketed_paste() { + with_terminal_attach_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + assert!(input_rx.try_recv().is_err()); + }); +} + +#[test] +fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() { + with_terminal_attach_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + }); +} + +#[test] +fn headless_scheduled_tasks_expire_agent_metadata() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("metadata"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::HookStateReported { + pane_id, + source: "custom:pi".into(), + agent_label: "pi".into(), + state: crate::detect::AgentState::Working, + message: None, + seq: None, + session_ref: None, + }) + ); + assert!( + server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported { + pane_id, + source: "user:pi-display".into(), + agent_label: Some("pi".into()), + applies_to_source: Some("custom:pi".into()), + title: Some("short lived".into()), + display_agent: None, + state_labels: HashMap::new(), + clear_title: false, + clear_display_agent: false, + clear_state_labels: false, + seq: None, + // Expiry is advanced with the captured deadline below; keep the + // pre-expiry assertion independent of wall-clock scheduling. + ttl: Some(Duration::from_secs(60)), + }) + ); + + let deadline = server + .app + .agent_metadata_deadline + .expect("metadata deadline"); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .expect("pane") + .attached_terminal_id + .clone(); + assert_eq!( + server + .app + .state + .terminals + .get(&terminal_id) + .expect("terminal") + .effective_title() + .as_deref(), + Some("short lived") + ); + + assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); + + assert_eq!(server.app.agent_metadata_deadline, None); + assert_eq!( + server + .app + .state + .terminals + .get(&terminal_id) + .expect("terminal") + .effective_title(), + None + ); + assert!(server + .app + .event_hub + .events_after(0) + .iter() + .any(|(_, event)| { + event.event == crate::api::schema::EventKind::PaneAgentStatusChanged + && matches!( + &event.data, + crate::api::schema::EventData::PaneAgentStatusChanged { + title, + .. + } if title.is_none() + ) + })); +} + +#[test] +fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() { + let mut server = test_headless_server(); + let now = Instant::now(); + server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1)); + + assert!(!server.handle_scheduled_tasks_headless(now, false)); + assert_eq!(server.app.next_agent_manifest_update_check, None); +} + +#[cfg(unix)] +#[tokio::test] +async fn headless_scheduled_tasks_start_pending_agent_resume_without_foreground_client() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.ensure_test_terminals(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + server.render_and_stream(); + assert_ne!(server.app.state.view.terminal_area, Rect::default()); + + let now = Instant::now(); + assert!(!server.handle_scheduled_tasks_headless(now, false)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); + let deadline = server + .app + .pending_agent_resume_deadline + .expect("clientless resume should wait briefly for a host theme"); + + assert!(server.handle_scheduled_tasks_headless(deadline, false)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_some()); + assert!(server + .app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_none()); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn terminal_attach_resize_uses_known_cell_geometry_without_pixel_mouse() { + with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| { + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ); + client.mode = ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }; + server.clients.insert(1, client); + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 1, + cols: 100, + rows: 30, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: false, + })); + assert_eq!( + server + .runtime_for_terminal_id_string(&terminal_id) + .unwrap() + .pixel_size(), + Some((800, 480)) + ); + assert_eq!( + server.clients[&1].cell_size, + crate::kitty_graphics::HostCellSize { + width_px: 8, + height_px: 16, + } + ); + assert!(!server.clients[&1].pixel_mouse); + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 1, + cols: 100, + rows: 30, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + })); + assert_eq!( + server + .runtime_for_terminal_id_string(&terminal_id) + .unwrap() + .pixel_size(), + None + ); + assert_eq!( + server.clients[&1].cell_size, + crate::kitty_graphics::HostCellSize::default() + ); + assert!(!server.clients[&1].pixel_mouse); + }); +} + +#[test] +fn pending_terminal_resize_does_not_take_shell_foreground_or_geometry() { + let mut server = test_headless_server(); + server.clients.insert( + 1, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalPending, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::TerminalAnsi, + None, + ), + ); + server.foreground_client_id = Some(1); + server.resize_shared_runtime_to_effective_size(); + let shell_size = server.effective_size; + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 2, + cols: 200, + rows: 60, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: false, + })); + + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.effective_size, shell_size); + assert_eq!(server.clients[&2].terminal_size, (200, 60)); +} + +#[test] +fn client_shell_streams_focused_pane_report_all_demand() { + with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| { + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.app.state.active = Some(0); + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("focused runtime") + .test_process_pty_bytes(b"\x1b[>15u"); + + server.stream_direct_terminal_keyboard_mode(); + + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("shell keyboard mode message") + ), + ServerMessage::ClientShellKeyboardReportAll { enabled: true } + )); + }); +} + +#[tokio::test] +async fn client_shell_release_cleanup_does_not_promote_and_survives_disconnect() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>3u"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + for client_id in [1, 2] { + server.clients.insert( + client_id, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + client_id, + RenderEncoding::SemanticFrame, + None, + ), + ); + } + let key = |kind| crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: (kind == crate::protocol::ClientKeyKind::Press).then(|| "x".to_owned()), + tracks_release: true, + physical_key_id: Some(0x2d), + }; + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id: pane_id.clone(), + events: vec![key(crate::protocol::ClientKeyKind::Press)], + }) + ); + assert!(!input_rx.recv().await.expect("encoded press").is_empty()); + assert!(server.promote_client_to_foreground(2)); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id: pane_id.clone(), + events: vec![key(crate::protocol::ClientKeyKind::Release)], + }) + ); + assert!(!input_rx.recv().await.expect("encoded release").is_empty()); + assert_eq!(server.foreground_client_id, Some(2)); + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id, + events: vec![key(crate::protocol::ClientKeyKind::Press)], + }) + ); + assert!(!input_rx + .recv() + .await + .expect("second encoded press") + .is_empty()); + assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 1 })); + assert!(!input_rx + .recv() + .await + .expect("disconnect synthesized release") + .is_empty()); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn client_shell_mouse_capture_combines_local_preference_with_endpoint_demand() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ), + ); + + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message(control_rx.recv().expect("initial mouse mode")), + ServerMessage::MouseCapture { + enabled: false, + sgr_pixels: false + } + )); + assert!( + server.handle_server_event(ServerEvent::ClientShellMouseCapture { + client_id: 1, + enabled: true, + }) + ); + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message(control_rx.recv().expect("preferred mouse mode")), + ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false + } + )); +} + +#[test] +fn client_shell_focus_promotes_and_reaches_reporting_pane() { + with_terminal_session_test_server(|server, terminal_id, _other_terminal_id, _pane_id| { + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + server.app.state.active = Some(0); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("focused runtime") + .current_size(), + (30, 99) + ); + + assert!(server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: 1, + focused: true, + })); + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.app.state.outer_terminal_focus, Some(true)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("focused runtime") + .current_size(), + (24, 79) + ); + assert_eq!( + input_rx.try_recv().expect("focus gained input"), + Bytes::from_static(b"\x1b[I") + ); + + assert!(server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: 1, + focused: false, + })); + assert_eq!(server.app.state.outer_terminal_focus, Some(false)); + assert_eq!( + input_rx.try_recv().expect("focus lost input"), + Bytes::from_static(b"\x1b[O") + ); + }); +} + +#[test] +fn direct_terminal_streams_child_keyboard_and_mouse_modes() { + with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| { + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::TerminalAnsi, + Some(client_tx), + ), + ); + server + .clients + .get_mut(&1) + .expect("direct attach client") + .pixel_mouse = true; + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[>15u\x1b[?1000h"); + + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("keyboard mode message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 15, + modify_other_keys_level: 0 + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[<u\x1b[>3u\x1b[>4;1m"); + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("modifyOtherKeys mode-one keyboard message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 3, + modify_other_keys_level: 1 + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[>4;2m"); + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("modifyOtherKeys mode-two keyboard message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 3, + modify_other_keys_level: 2 + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[<u"); + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("modifyOtherKeys-only keyboard mode message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 0, + modify_other_keys_level: 2 + } + )); + + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("mouse capture message") + ), + ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[?1016h"); + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("pixel mouse capture message") + ), + ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: true + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[?1000l\x1b[?1016l"); + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("child mouse disable message") + ), + ServerMessage::MouseCapture { + enabled: false, + sgr_pixels: false + } + )); + }); +} + +#[test] +fn direct_terminal_mouse_uses_runtime_protocol_encoding() { + with_terminal_session_test_server(|server, runtime_terminal_id, terminal_id, _pane_id| { + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1000h\x1b[?1006h", + 4, + ); + server + .app + .terminal_runtimes + .insert(runtime_terminal_id, runtime); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::TerminalAnsi, + None, + ), + ); + + assert!(server.handle_server_event(ServerEvent::ClientAttachMouse { + client_id: 1, + kind: protocol::ClientMouseKind::Down(protocol::ClientMouseButton::Left), + position: protocol::ClientMousePosition::Cell { column: 10, row: 5 }, + geometry: None, + modifiers: 0, + lines: 1, + })); + assert_eq!( + input_rx.try_recv().expect("encoded direct mouse input"), + Bytes::from_static(b"\x1b[<0;11;6M") + ); + }); +} + +#[test] +fn direct_terminal_pixel_mouse_uses_runtime_tracking_and_coordinates() { + with_terminal_session_test_server(|server, runtime_terminal_id, terminal_id, _pane_id| { + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1000h\x1b[?1006h\x1b[?1016h", + 4, + ); + runtime.resize(24, 80, 10, 20); + server + .app + .terminal_runtimes + .insert(runtime_terminal_id, runtime); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }, + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + 1, + RenderEncoding::TerminalAnsi, + None, + ), + ); + let client = server.clients.get_mut(&1).expect("direct attach client"); + client.pixel_mouse = true; + client.host_sgr_pixels_active = Some(true); + + assert!(!server.handle_server_event(ServerEvent::ClientAttachMouse { + client_id: 1, + kind: protocol::ClientMouseKind::Down(protocol::ClientMouseButton::Left), + position: protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 3, + row: 1, + }, + geometry: Some(protocol::ClientMouseGeometry { + cols: 80, + rows: 24, + width_px: 800, + height_px: 480, + }), + modifiers: 0, + lines: 1, + })); + assert!(input_rx.try_recv().is_err()); + + assert!(server.handle_server_event(ServerEvent::ClientAttachMouse { + client_id: 1, + kind: protocol::ClientMouseKind::Down(protocol::ClientMouseButton::Left), + position: protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + }, + geometry: Some(protocol::ClientMouseGeometry { + cols: 80, + rows: 24, + width_px: 805, + height_px: 485, + }), + modifiers: 0, + lines: 1, + })); + assert_eq!( + input_rx + .try_recv() + .expect("proportionally mapped direct pixel mouse input"), + Bytes::from_static(b"\x1b[<0;21;22M") + ); + + assert!(server.handle_server_event(ServerEvent::ClientAttachMouse { + client_id: 1, + kind: protocol::ClientMouseKind::Moved, + position: protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + }, + geometry: Some(protocol::ClientMouseGeometry { + cols: 80, + rows: 24, + width_px: 800, + height_px: 480, + }), + modifiers: 0, + lines: 1, + })); + assert!(input_rx.try_recv().is_err()); + + assert!(server.handle_server_event(ServerEvent::ClientAttachMouse { + client_id: 1, + kind: protocol::ClientMouseKind::Down(protocol::ClientMouseButton::Left), + position: protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + }, + geometry: Some(protocol::ClientMouseGeometry { + cols: 80, + rows: 24, + width_px: 800, + height_px: 480, + }), + modifiers: 0, + lines: 1, + })); + assert_eq!( + input_rx + .try_recv() + .expect("encoded direct pixel mouse input"), + Bytes::from_static(b"\x1b[<0;21;22M") + ); + }); +} + +#[test] +fn client_config_reload_request_refreshes_attached_clients() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.app.state.request_client_config_reload = true; + + server.drain_client_config_reload_request(); + + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("client config reload message"), + ) { + ServerMessage::ReloadSoundConfig => {} + other => panic!("expected ReloadSoundConfig, got {other:?}"), + } + assert!(!server.app.state.request_client_config_reload); +} + +#[test] +fn terminal_bell_targets_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { + pane_id: crate::layout::PaneId::from_raw(1), + count: 3, + }); + + assert!(!changed); + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground terminal bell message"), + ) { + ServerMessage::TerminalBell { count } => assert_eq!(count, 3), + other => panic!("expected terminal bell message, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive terminal bells" + ); + + server.foreground_client_id = None; + server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { + pane_id: crate::layout::PaneId::from_raw(1), + count: 1, + }); + assert!( + foreground_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "bells without a foreground client must not be retained" + ); +} + +#[test] +fn clipboard_write_targets_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + }); + + assert!(!changed); + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground clipboard message"), + ) { + ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="), + other => panic!("expected clipboard message, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive clipboard writes" + ); +} + +#[test] +fn clipboard_write_without_foreground_client_does_not_change_visual_state() { + let mut server = test_headless_server(); + server.foreground_client_id = None; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + }); + + assert!(!changed); +} + +#[test] +fn clipboard_write_failed_foreground_send_removes_client_without_visual_change() { + let mut server = test_headless_server(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + drop(foreground_control_rx); + foreground_tx.test_close(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(1); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + }); + + assert!(!changed); + assert!( + !server.clients.contains_key(&1), + "failed targeted send should remove the broken foreground client" + ); +} + +#[test] +fn semantic_notifications_broadcast_only_to_client_shells() { + let mut server = test_headless_server(); + let (shell_one_tx, shell_one_control, _shell_one_frames) = test_client_writer(); + let (shell_two_tx, shell_two_control, _shell_two_frames) = test_client_writer(); + let (terminal_tx, terminal_control, _terminal_frames) = test_client_writer(); + for (client_id, writer) in [(1, shell_one_tx), (2, shell_two_tx)] { + server.clients.insert( + client_id, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + client_id, + RenderEncoding::SemanticFrame, + Some(writer), + ), + ); + } + server.clients.insert( + 3, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalPending, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 3, + RenderEncoding::TerminalAnsi, + Some(terminal_tx), + ), + ); + let event = protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::Custom, + title: "hello".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }; + assert!(server.send_to_client_shells(ServerMessage::SemanticNotification(event.clone()))); + for receiver in [shell_one_control, shell_two_control] { + assert_eq!( + read_server_message( + receiver + .recv_timeout(Duration::from_millis(100)) + .expect("semantic notification") + ), + ServerMessage::SemanticNotification(event.clone()) + ); + } + assert!(terminal_control + .recv_timeout(Duration::from_millis(50)) + .is_err()); +} + +#[test] +fn notification_show_uses_client_shell_policy_independent_of_server_delivery() { + let mut server = test_headless_server(); + server.app.state.toast_config.delivery = config::ToastDelivery::Off; + let (shell_tx, shell_control, _shell_frames) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(shell_tx), + ), + ); + let response = server.handle_notification_show_api( + "notify-shell".into(), + api::schema::NotificationShowParams { + title: "plugin title".into(), + body: Some("plugin body".into()), + position: Some(crate::config::ToastHerdrPosition::TopLeft), + sound: api::schema::NotificationShowSound::Done, + }, + ); + let response: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!( + response.result, + api::schema::ResponseResult::NotificationShow { shown: true, .. } + )); + assert_eq!( + read_server_message( + shell_control + .recv_timeout(Duration::from_millis(100)) + .expect("semantic plugin notification") + ), + ServerMessage::SemanticNotification(protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::Custom, + title: "plugin title".into(), + body: Some("plugin body".into()), + sound: Some(protocol::SemanticNotificationSound::Done), + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(crate::config::ToastHerdrPosition::TopLeft), + }) + ); +} + +#[test] +fn client_local_notifications_target_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + assert!(server.send_to_foreground_client(ServerMessage::Notify { + kind: protocol::NotifyKind::Toast, + message: "pi finished".to_string(), + body: Some("workspace 1".to_string()), + })); + + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground toast message"), + ) { + ServerMessage::Notify { + kind, + message, + body, + } => { + assert_eq!(kind, protocol::NotifyKind::Toast); + assert_eq!(message, "pi finished"); + assert_eq!(body.as_deref(), Some("workspace 1")); + } + other => panic!("expected toast notify, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive client-local notifications" + ); +} + +#[test] +fn oversized_paste_rejection_notifies_only_the_sending_client() { + let mut server = test_headless_server(); + let (sender_writer, sender_control_rx, _sender_render_rx) = test_client_writer(); + let (foreground_writer, foreground_control_rx, _foreground_render_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(sender_writer), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_writer), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + assert!( + !server.handle_server_event(ServerEvent::ClientPasteRejected { + client_id: 1, + size: 5_000_012, + max: 1_048_576, + }) + ); + + match read_server_message( + sender_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("sending client rejection notification"), + ) { + ServerMessage::ClientShellError { message } => assert_eq!( + message, + "Paste rejected: Input message is 5000012 bytes; Herdr's limit is 1048576 bytes" + ), + other => panic!("expected client shell paste error, got {other:?}"), + } + let (shell_writer, shell_control_rx, _shell_render_rx) = test_client_writer(); + server.clients.insert( + 3, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + 3, + RenderEncoding::SemanticFrame, + Some(shell_writer), + ), + ); + assert!( + !server.handle_server_event(ServerEvent::ClientPasteRejected { + client_id: 3, + size: 7_000_000, + max: 1_048_576, + }) + ); + match read_server_message( + shell_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("client shell rejection error"), + ) { + ServerMessage::ClientShellError { message } => assert_eq!( + message, + "Paste rejected: Input message is 7000000 bytes; Herdr's limit is 1048576 bytes" + ), + other => panic!("expected client shell paste error, got {other:?}"), + } + assert!( + foreground_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "foreground client must not receive another client's rejection" + ); + assert_eq!(server.foreground_client_id, Some(2)); + assert_eq!(server.clients.len(), 3); + assert!(server.app.state.toast.is_none()); +} + +#[test] +fn update_notification_reaches_client_shell_independent_of_delivery() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { + version: "9.9.9".to_string(), + install_command: "herdr update".into(), + }); + + assert!(changed); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic update notification") + ), + ServerMessage::SemanticNotification(protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::UpdateInstalled, + .. + }) + )); +} + +#[test] +fn update_notification_is_semantic_for_system_delivery() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { + version: "9.9.9".to_string(), + install_command: "herdr update".into(), + }); + + assert!(changed); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic update notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!( + notification.kind, + protocol::SemanticNotificationKind::UpdateInstalled + ); + assert_eq!(notification.title, "Herdr v9.9.9 available"); + assert_eq!( + notification.body.as_deref(), + Some("detach, run `herdr update`, then follow its restart guidance") + ); + } + other => panic!("expected semantic update notification, got {other:?}"), + } +} + +#[test] +fn notification_show_api_forwards_one_semantic_client_notification() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "build failed".into(), + body: Some("api workspace".into()), + position: Some(crate::config::ToastHerdrPosition::TopLeft), + sound: api::schema::NotificationShowSound::Request, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: true, + reason: api::schema::NotificationShowReason::Shown, + } + ); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic api notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!(notification.title, "build failed"); + assert_eq!(notification.body.as_deref(), Some("api workspace")); + assert_eq!( + notification.sound, + Some(protocol::SemanticNotificationSound::Request) + ); + } + other => panic!("expected semantic api notification, got {other:?}"), + } +} + +#[test] +fn notification_show_api_preserves_colon_in_forwarded_title() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "build: failed".into(), + body: Some("api workspace".into()), + position: None, + sound: api::schema::NotificationShowSound::None, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: true, + reason: api::schema::NotificationShowReason::Shown, + } + ); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic api notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!(notification.title, "build: failed"); + assert_eq!(notification.body.as_deref(), Some("api workspace")); + } + other => panic!("expected semantic api notification, got {other:?}"), + } +} + +#[test] +fn notification_show_api_validates_empty_title_before_disabled_delivery() { + let mut server = test_headless_server(); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Off; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "\n\t".into(), + body: None, + position: None, + sound: api::schema::NotificationShowSound::None, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed.error.code, "invalid_params"); + assert_eq!(parsed.error.message, "notification title is empty"); +} + +#[test] +fn notification_show_api_reports_no_foreground_client() { + let mut server = test_headless_server(); + server.foreground_client_id = None; + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: api::schema::NotificationShowSound::Request, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: false, + reason: api::schema::NotificationShowReason::NoForegroundClient, + } + ); +} + +#[test] +fn notification_show_api_includes_sound_in_semantic_event() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + assert!( + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow( + api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: api::schema::NotificationShowSound::Done, + }, + ), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }) + ); + + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: true, + reason: api::schema::NotificationShowReason::Shown, + } + ); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic api notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!(notification.title, "build failed"); + assert_eq!( + notification.sound, + Some(protocol::SemanticNotificationSound::Done) + ); + } + other => panic!("expected semantic api notification, got {other:?}"), + } +} + +#[test] +fn startup_idle_does_not_forward_completion() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("active"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + server.app.state.toast_config.delay_seconds = 0; + server.app.state.sound.enabled = true; + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { + pane_id, + agent: crate::detect::Agent::Pi, + observed_at: Instant::now(), + }) + ); + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + while client_control_rx + .recv_timeout(Duration::from_millis(20)) + .is_ok() + {} + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::Pi), + state: crate::detect::AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }) + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "startup readiness should not forward a completion notification" + ); +} + +#[test] +fn stale_api_agent_report_does_not_forward_done_sound() { + let mut server = test_headless_server(); + let background = crate::workspace::Workspace::test_new("background"); + let pane_id = background.tabs[0].root_pane; + let public_pane_id = format!("{}:p1", background.id); + let foreground = crate::workspace::Workspace::test_new("foreground"); + server.app.state.workspaces = vec![background, foreground]; + server.app.state.ensure_test_terminals(); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::path( + std::env::current_dir() + .unwrap() + .join("headless-pi-session.jsonl") + .display() + .to_string(), + ) + .unwrap(), + }); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_hook_authority( + "herdr:pi".into(), + "pi".into(), + crate::detect::AgentState::Working, + None, + Some(20), + ); + server.app.state.active = Some(1); + server.app.state.selected = 1; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "stale".into(), + method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { + pane_id: public_pane_id, + source: "herdr:pi".into(), + agent: "pi".into(), + state: api::schema::PaneAgentState::Idle, + message: None, + seq: Some(19), + agent_session_id: None, + agent_session_path: None, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); + assert_eq!( + server.app.state.terminals.get(&terminal_id).unwrap().state, + crate::detect::AgentState::Working + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "stale idle report must not forward a done sound" + ); +} + +/// Verify that calls to the app's internal-event methods only occur inside +/// `handle_internal_event_with_forwarding`. This ensures the forwarding +/// bypass cannot be reintroduced. +#[test] +fn no_handle_internal_event_bypass_in_module() { + let source = include_str!("../../headless.rs"); + + // Find all lines containing handle_internal_event + let mut bypass_lines: Vec<String> = Vec::new(); + let mut inside_forwarding_method = false; + let mut forwarding_method_brace_depth = 0u32; + + for (i, line) in source.lines().enumerate() { + let line_num = i + 1; + + // Track when we're inside handle_internal_event_with_forwarding + if line.contains("fn handle_internal_event_with_forwarding") { + inside_forwarding_method = true; + forwarding_method_brace_depth = 0; + } + + if inside_forwarding_method { + // Count braces to track when we exit the method + for ch in line.chars() { + match ch { + '{' => forwarding_method_brace_depth += 1, + '}' => { + forwarding_method_brace_depth = + forwarding_method_brace_depth.saturating_sub(1); + if forwarding_method_brace_depth == 0 { + inside_forwarding_method = false; + } + } + _ => {} + } + } + } else if (line.contains("self.app.handle_internal_event(") + || line.contains("self.app.handle_internal_event_with_render_impact(")) + && !line.trim().starts_with("///") + && !line.contains("contains(") + { + // Internal-event call outside the forwarding method. + bypass_lines.push(format!("line {}: {}", line_num, line.trim())); + } + } + + assert!( + bypass_lines.is_empty(), + "Found direct calls to self.app.handle_internal_event outside \ + handle_internal_event_with_forwarding (bypass risk):\n {}", + bypass_lines.join("\n ") + ); +} diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index bd6e3cd7..ecdbc2b7 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -5,61 +5,170 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Durati } #[tokio::test] -async fn cold_redraw_advances_one_bounded_layer_after_each_send() { - let (mut server, client_rx, pane_id) = retained_test_server(b"cold redraw"); - server.app.state.kitty_graphics_enabled = true; - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { +async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once() { + let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; - const LAYERS: usize = 8; - for index in 0..LAYERS { - set_named_graphics_layer( - &mut server, - pane_id, - &format!("layer-{index:02}"), - vec![index as u8; 1024 * 1024], - index as i32, - ); - } + set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); - fill_render_lane(&server); server.render_and_stream(); - assert!(server.clients[&1].graphics_cache.is_empty()); - let _older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - - for expected in 1..=LAYERS { - server.render_and_stream(); - let bytes = client_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - assert!(bytes.len() <= MAX_GRAPHICS_FRAME_SIZE + 4); - let frame = read_server_frame(bytes); - assert_eq!( - frame - .graphics - .windows(4) - .filter(|part| *part == b"a=t,") - .count(), - 1 - ); - assert_eq!( - server.clients[&1].graphics_cache.test_image_count(), - expected - ); - } - assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + let first = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(first) = first else { + panic!("expected client shell pane surface"); + }; + assert_eq!(first.graphics.placements.len(), 1); + assert_eq!(first.graphics.assets.len(), 1); + assert_eq!(first.graphics.assets[0].data, vec![1, 2, 3, 4]); + assert!(matches!( + first.graphics.placements[0].asset.source, + crate::protocol::SurfaceGraphicsSource::PaneLayer { .. } + )); + + server.clients.get_mut(&1).unwrap().request_repaint(); + server.render_and_stream(); + let second = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(second) = second else { + panic!("expected replacement client shell pane surface"); + }; + assert_eq!(second.graphics.placements, first.graphics.placements); + assert!(second.graphics.assets.is_empty()); } -fn enable_graphics_and_render( - server: &mut HeadlessServer, - client_rx: &std::sync::mpsc::Receiver<Vec<u8>>, -) -> FrameData { - server.app.state.kitty_graphics_enabled = true; - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { +#[tokio::test] +async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { + let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); + + server.render_and_stream(); + let _first = receive_render(&client_rx, Duration::from_millis(100)); + server.app.pane_graphics.slots.clear(); + server.app.pane_graphics.mark_changed(); + server.clients.get_mut(&1).unwrap().request_repaint(); + server.render_and_stream(); + let removed = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(removed) = removed else { + panic!("expected removed client shell scene"); + }; + assert!(removed.graphics.placements.is_empty()); + + set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); + server.clients.get_mut(&1).unwrap().request_repaint(); + server.render_and_stream(); + let restored = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(restored) = restored else { + panic!("expected restored client shell scene"); + }; + assert_eq!(restored.graphics.assets.len(), 1); + assert_eq!(restored.graphics.assets[0].data, vec![1, 2, 3, 4]); +} + +#[tokio::test] +async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_runtime() { + let (mut server, client_rx, _pane_id) = + retained_test_server(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; + server.render_and_stream(); - read_server_frame(receive_render(client_rx, Duration::from_millis(100))) + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected client shell pane surface"); + }; + assert_eq!(surface.graphics.placements.len(), 1); + assert_eq!(surface.graphics.assets.len(), 1); + assert!(matches!( + surface.graphics.placements[0].asset.source, + crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Pane { .. }, + image_id: 7, + } + )); + assert_eq!(surface.graphics.assets[0].data, vec![255, 0, 0, 255]); +} + +#[tokio::test] +async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { + let (mut server, client_rx, _pane_id) = retained_test_server( + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\\x1b_Ga=T,f=32,t=d,i=8,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + + server.render_and_stream(); + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected client shell pane surface"); + }; + assert_eq!(surface.graphics.placements.len(), 2); + assert_eq!(surface.graphics.assets.len(), 2); + assert_ne!( + surface.graphics.assets[0].key, + surface.graphics.assets[1].key + ); + assert_eq!( + surface.graphics.assets[0].data, + surface.graphics.assets[1].data + ); + + server.clients.get_mut(&1).unwrap().request_repaint(); + server.render_and_stream(); + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected replacement client shell pane surface"); + }; + assert_eq!(surface.graphics.placements.len(), 2); + assert!(surface.graphics.assets.is_empty()); +} + +#[tokio::test] +async fn full_client_shell_render_lane_does_not_commit_graphics_delivery() { + let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + set_graphics_layer(&mut server, pane_id, vec![5, 6, 7, 8]); + + fill_render_lane(&server); + server.render_and_stream(); + let _older = receive_render(&client_rx, Duration::from_millis(100)); + server.render_and_stream(); + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected client shell pane surface"); + }; + assert_eq!(surface.graphics.assets.len(), 1); + assert_eq!(surface.graphics.assets[0].data, vec![5, 6, 7, 8]); } fn graphics_key(pane_id: crate::layout::PaneId) -> crate::app::pane_graphics::Key { @@ -259,151 +368,6 @@ async fn pixel_mouse_activation_requires_graphics_demand_not_direct_transport() } #[tokio::test] -async fn pixel_input_metadata_cannot_resize_authoritative_client_state() { - let (mut server, _client_rx, pane_id) = - retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h"); - set_graphics_layer(&mut server, pane_id, vec![1]); - let client = server.clients.get_mut(&1).unwrap(); - client.pixel_mouse = true; - client.host_sgr_pixels_active = Some(true); - server.foreground_client_id = None; - assert!(!server.handle_server_event(ServerEvent::ClientInputPixels { - client_id: 1, - data: b"\x1b[<0;500;300M".to_vec(), - geometry: crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(), - })); - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - for (geometry, data) in [ - ( - crate::input::mouse::HostGeometry::new(100, 30, 1_000, 600).unwrap(), - b"\x1b[<0;500;300M".as_slice(), - ), - ( - crate::input::mouse::HostGeometry::new(80, 24, 960, 480).unwrap(), - b"\x1b[<0;500;300M", - ), - ( - crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(), - b"\x1b[<0;0;1M", - ), - ] { - assert!(!server.handle_server_event(ServerEvent::ClientInputPixels { - client_id: 1, - data: data.to_vec(), - geometry, - })); - } - assert_eq!(server.clients[&1].terminal_size, (80, 24)); - assert_eq!( - (server.effective_size, server.foreground_client_id), - ((80, 24), None) - ); -} - -#[test] -fn direct_eligibility_is_installed_with_the_client_connection() { - let mut server = test_headless_server(); - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 10, - cell_height_px: 20, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: true, - writer, - })); - - let client = server.clients.get(&7).expect("connected client"); - assert!(client.direct_graphics); - assert_eq!(server.foreground_client_id, Some(7)); - assert!(server.app.direct_graphics_available); -} - -#[tokio::test] -async fn focus_repaint_preserves_uploaded_graphics() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let (client_2_writer, _client_2_control_rx, client_2_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - crate::terminal_theme::TerminalTheme::default(), - Some(false), - 0, - RenderEncoding::SemanticFrame, - Some(client_2_writer), - ), - ); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - let initial = enable_graphics_and_render(&mut server, &client_rx); - let initial_graphics = String::from_utf8_lossy(&initial.graphics); - assert!(initial_graphics.contains("a=t")); - assert!(initial_graphics.contains("a=p")); - let client_2_initial = - read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100))); - assert!(String::from_utf8_lossy(&client_2_initial.graphics).contains("a=t")); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 2, - data: b"\x1b[I".to_vec(), - })); - assert_eq!(server.foreground_client_id, Some(2)); - server.render_and_stream(); - - let focused = read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100))); - let focused_graphics = String::from_utf8_lossy(&focused.graphics); - assert!(focused_graphics.contains("a=p")); - assert!(!focused_graphics.contains("a=t")); -} - -#[tokio::test] -async fn resize_replays_placement_without_retransmitting_or_closing_stream() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - set_stream_owner(&mut server, pane_id, "owner-resize"); - let initial = enable_graphics_and_render(&mut server, &client_rx); - assert!(String::from_utf8_lossy(&initial.graphics).contains("a=t")); - - for (cols, rows, cell_width_px, cell_height_px) in - [(100, 30, 10, 20), (100, 30, 12, 24), (100, 30, 12, 24)] - { - assert!(server.handle_server_event(ServerEvent::ClientResize { - client_id: 1, - cols, - rows, - cell_width_px, - cell_height_px, - })); - server.render_and_stream(); - let frame = read_server_frame(receive_render(&client_rx, Duration::from_millis(100))); - let graphics = String::from_utf8_lossy(&frame.graphics); - assert!(!graphics.contains("a=t")); - assert!(graphics.contains("a=p")); - } - assert_eq!( - server - .app - .pane_graphics - .slots - .get(&graphics_key(pane_id)) - .and_then(|slot| slot.stream_owner.as_deref()), - Some("owner-resize") - ); -} - -#[tokio::test] async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() { let (mut server, _client_rx, pane_id) = retained_test_server(b"aaaa"); set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); @@ -426,88 +390,6 @@ async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() { assert!(server.app.pane_graphics.slots.is_empty()); } -#[tokio::test] -async fn retained_update_sends_only_graphics_message() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let baseline = enable_graphics_and_render(&mut server, &client_rx); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Sent - ); - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("graphics-only update"), - ) { - ServerMessage::Graphics { bytes } => { - assert!(bytes.windows(3).any(|window| window == b"\x1b_G")); - } - other => panic!("expected graphics-only message, got {other:?}"), - } - assert_frame_data_eq( - server - .clients - .get(&1) - .unwrap() - .render_state - .last_frame() - .expect("semantic baseline"), - &baseline, - ); -} - -#[tokio::test] -async fn retained_graphics_stays_ordered_after_an_older_render() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let _ = enable_graphics_and_render(&mut server, &client_rx); - fill_render_lane(&server); - set_graphics_layer(&mut server, pane_id, vec![4, 5, 6]); - let older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Sent - ); - let graphics = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - read_server_message(older), - ServerMessage::ReloadSoundConfig - )); - assert!(matches!( - read_server_message(graphics), - ServerMessage::Graphics { .. } - )); -} - -#[tokio::test] -async fn retained_update_falls_back_for_mixed_app_geometry() { - let (mut server, client_rx, _pane_id) = retained_test_server(b"aaaa"); - let _ = enable_graphics_and_render(&mut server, &client_rx); - - let (writer, _control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (60, 20), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(writer), - ), - ); - - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Fallback - ); -} - #[test] fn stream_open_gate_is_owned_by_the_layer_and_cancels_on_removal() { let mut server = test_headless_server(); @@ -730,248 +612,103 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() { #[cfg(unix)] #[tokio::test] -async fn hidden_large_direct_frame_uploads_then_replays_placement_without_closing_stream() { - let (mut server, client_rx, _) = retained_test_server(b"active"); - enable_graphics_and_render(&mut server, &client_rx); - let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser")); - let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane; - let pane_number = server.app.state.workspaces[0] - .public_pane_number(pane_id) - .unwrap(); - let public_pane_id = crate::workspace::public_pane_id_for_number( - &server.app.state.workspaces[0].id, - pane_number, - ); - server.clients.get_mut(&1).unwrap().direct_graphics = true; +async fn client_shell_direct_graphics_uploads_without_server_authored_coordinates() { + let (mut server, client_rx, pane_id) = retained_test_server(b"client shell direct"); + server.app.state.kitty_graphics_enabled = true; + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + client.direct_graphics = true; + client.pixel_mouse = true; server.app.direct_graphics_available = true; set_stream_owner(&mut server, pane_id, "browser"); - - let image_width = 2_048; - let image_height = 2_049; - let expected_len = u64::from(image_width) * u64::from(image_height) * 4; - assert!(expected_len > api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES as u64); - let path = sparse_direct_frame( - &server, - "hidden-large-frame.rgba", - image_width, - image_height, - ); - let (message, response_rx) = direct_stream_message( - "hidden-frame", - &public_pane_id, - "browser", - path, - image_width, - image_height, - ); + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + let path = sparse_direct_frame(&server, "client-shell-direct.rgba", 1, 1); + let (message, response_rx) = + direct_stream_message("shell-direct", &public_pane_id, "browser", path, 1, 1); assert_eq!( server.handle_pane_graphics_stream_frame(message), RenderImpact::None ); - let (transfer_id, image_id, control, leading) = match read_server_message( + let (transfer_id, image_id, asset) = match read_server_message( client_rx .recv_timeout(Duration::from_secs(1)) - .expect("hidden direct upload"), + .expect("client shell direct upload"), ) { ServerMessage::GraphicsFile { transfer_id, image_id, - control, leading, - expected_len: sent_len, + control, + surface_asset: Some(asset), .. } => { - assert_eq!(sent_len, expected_len); - (transfer_id, image_id, control, leading) + assert!(leading.is_empty()); + assert!(control.starts_with("a=t,"), "{control}"); + assert!(!control.contains("\u{1b}["), "{control}"); + (transfer_id, image_id, asset) } - other => panic!("expected graphics file, got {other:?}"), + other => panic!("expected client shell graphics file, got {other:?}"), }; - assert!(leading.is_empty()); - assert!(control.starts_with("a=t,"), "{control}"); - assert!(!control.contains("p="), "{control}"); - assert!(response_rx.try_recv().is_err()); - - server.app.state.workspaces[0].switch_tab(background_tab); - server.render_and_stream(); - let frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("frame while upload is pending"), + assert_eq!( + image_id, + crate::kitty_graphics::surface::host_image_id(&server.client_shell_boot_id, &asset) ); - assert!(!frame.graphics.windows(4).any(|bytes| bytes == b"a=p,")); - - server.app.state.workspaces[0].switch_tab(0); - server.render_and_stream(); - let _hidden_again = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("frame after hiding pending upload"), + let (pending, _) = crate::server::client_shell_graphics::collect( + &server.app, + &[], + &[], + None, + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + &crate::kitty_graphics::surface::DeliveryCache::default(), + 1, ); + assert_eq!(pending.retained_assets, vec![asset.clone()]); + server.start_direct_graphics_response(1, transfer_id, image_id); assert!(server.complete_direct_graphics(1, transfer_id, image_id, true)); assert!(serde_json::from_str::<api::schema::SuccessResponse>( &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() ) .is_ok()); - let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)]; - assert!(slot.stream_is_active()); - assert!(slot.layer.as_ref().unwrap().terminal_only()); - - server.app.state.workspaces[0].switch_tab(background_tab); + server.clients.get_mut(&1).unwrap().request_repaint(); server.render_and_stream(); - let frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("placement replay after tab switch"), - ); - let graphics = String::from_utf8_lossy(&frame.graphics); - assert!(graphics.contains("a=p,"), "{graphics:?}"); - assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}"); - assert!(!graphics.contains("a=t,"), "{graphics:?}"); - - let next_path = sparse_direct_frame( - &server, - "visible-next-frame.rgba", - image_width, - image_height, - ); - let (message, next_response_rx) = direct_stream_message( - "visible-frame", - &public_pane_id, - "browser", - next_path, - image_width, - image_height, - ); - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::None - ); - match read_server_message( + let surface = read_server_message( client_rx .recv_timeout(Duration::from_secs(1)) - .expect("next visible direct frame"), - ) { - ServerMessage::GraphicsFile { control, .. } => { - assert!(control.starts_with("a=T,"), "{control}"); - } - other => panic!("expected graphics file, got {other:?}"), - } - assert!(next_response_rx.try_recv().is_err()); - assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active()); -} - -#[cfg(unix)] -#[tokio::test] -async fn hidden_small_direct_frame_preserves_owned_inline_fallback() { - let (mut server, client_rx, _) = retained_test_server(b"active"); - enable_graphics_and_render(&mut server, &client_rx); - let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser")); - let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane; - let pane_number = server.app.state.workspaces[0] - .public_pane_number(pane_id) - .unwrap(); - let public_pane_id = crate::workspace::public_pane_id_for_number( - &server.app.state.workspaces[0].id, - pane_number, - ); - server.clients.get_mut(&1).unwrap().direct_graphics = true; - server.app.direct_graphics_available = true; - set_stream_owner(&mut server, pane_id, "browser"); - - let path = sparse_direct_frame(&server, "hidden-small-frame.rgba", 1, 1); - let (message, response_rx) = - direct_stream_message("hidden-small", &public_pane_id, "browser", path, 1, 1); - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::Graphics + .expect("resident client shell scene"), ); - assert!(serde_json::from_str::<api::schema::SuccessResponse>( - &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() - ) - .is_ok()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)]; - assert!(slot.stream_is_active()); - assert_eq!( - slot.layer.as_ref().unwrap().inline_data(), - Some([0; 4].as_slice()) - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn direct_frame_during_internal_redraw_uploads_without_placement() { - let (mut server, client_rx, pane_id) = retained_test_server(b"active"); - enable_graphics_and_render(&mut server, &client_rx); - let pane_number = server.app.state.workspaces[0] - .public_pane_number(pane_id) - .unwrap(); - let public_pane_id = crate::workspace::public_pane_id_for_number( - &server.app.state.workspaces[0].id, - pane_number, - ); - server.clients.get_mut(&1).unwrap().direct_graphics = true; - server.app.direct_graphics_available = true; - set_stream_owner(&mut server, pane_id, "browser"); - server - .app - .event_tx - .try_send(AppEvent::UpdateReady { - version: "9.9.9".into(), - install_command: "herdr update".into(), - }) - .unwrap(); - - let image_width = 2_048; - let image_height = 2_049; - let path = sparse_direct_frame(&server, "redraw-frame.rgba", image_width, image_height); - let (message, response_rx) = direct_stream_message( - "redraw", - &public_pane_id, - "browser", - path, - image_width, - image_height, - ); - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::Full - ); - let (transfer_id, image_id) = match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("direct upload during redraw"), - ) { - ServerMessage::GraphicsFile { - control, - leading, - transfer_id, - image_id, - .. - } => { - assert!(leading.is_empty()); - assert!(control.starts_with("a=t,"), "{control}"); - (transfer_id, image_id) - } - other => panic!("expected graphics file, got {other:?}"), + let ServerMessage::PaneSurface(surface) = surface else { + panic!("expected client shell pane surface"); }; - server.start_direct_graphics_response(1, transfer_id, image_id); - assert!(server.complete_direct_graphics(1, transfer_id, image_id, true)); - assert!(response_rx.recv_timeout(Duration::from_secs(1)).is_ok()); - assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active()); - - server.render_and_stream(); - let frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("placement after redraw upload acknowledgement"), + assert_eq!(surface.graphics.placements.len(), 1); + assert!(surface.graphics.assets.is_empty()); + assert_eq!(surface.graphics.placements[0].asset, asset); + assert_eq!(surface.graphics.retained_assets, vec![asset.clone()]); + + let (hidden, _) = crate::server::client_shell_graphics::collect( + &server.app, + &[], + &[], + None, + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + &crate::kitty_graphics::surface::DeliveryCache::default(), + 1, ); - let graphics = String::from_utf8_lossy(&frame.graphics); - assert!(graphics.contains("a=p,"), "{graphics:?}"); - assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}"); - assert!(!graphics.contains("a=t,"), "{graphics:?}"); + assert!(hidden.placements.is_empty()); + assert_eq!(hidden.retained_assets, vec![asset]); } #[cfg(unix)] @@ -1029,6 +766,7 @@ fn direct_gate_server_with_file( slot.stream_active = Some(active_gate()); slot.direct_gate = Some(crate::app::pane_graphics::DirectGate { transfer_id: lease.fingerprint(), + image_id: (1 << 31) | 900, client_id: 7, deadline: std::time::Instant::now() + Duration::from_secs(1), written: true, @@ -1042,10 +780,8 @@ fn direct_gate_server_with_file( #[cfg(unix)] fn direct_ids(server: &HeadlessServer, key: &crate::app::pane_graphics::Key) -> (u64, u32) { let slot = &server.app.pane_graphics.slots[key]; - ( - slot.direct_gate.as_ref().unwrap().transfer_id, - slot.host_image_id, - ) + let gate = slot.direct_gate.as_ref().unwrap(); + (gate.transfer_id, gate.image_id) } #[cfg(unix)] @@ -1058,8 +794,6 @@ fn add_direct_client(server: &mut HeadlessServer, client_id: u64) { width_px: 10, height_px: 20, }, - crate::terminal_theme::TerminalTheme::default(), - None, 1, RenderEncoding::SemanticFrame, Some(writer), @@ -1131,50 +865,6 @@ fn matching_terminal_ok_releases_producer_and_acknowledges() { #[cfg(unix)] #[test] -fn explicit_terminal_error_acks_only_after_owned_inline_fallback() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - let (transfer_id, image_id) = direct_ids(&server, &key); - let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); - server - .clients - .get_mut(&7) - .unwrap() - .graphics_cache - .trust_pane_layer(&key, image_id, layer); - assert!(server.complete_direct_graphics(7, transfer_id, image_id, false)); - - let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); - assert_eq!( - ( - response_rx.recv().unwrap(), - layer.inline_data(), - server.clients[&7].direct_graphics, - server.clients[&7].pixel_mouse, - ), - ("ack".into(), Some([1, 2, 3, 4].as_slice()), false, true) - ); - assert!(server.clients[&7].graphics_cache.is_empty()); -} - -#[cfg(unix)] -#[test] -fn large_direct_terminal_error_closes_without_acknowledging_or_copying() { - let len = crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES + 4; - let (mut server, key, response_rx) = direct_gate_server_with_file(len, None); - add_direct_client(&mut server, 7); - let (transfer_id, image_id) = direct_ids(&server, &key); - - assert!(server.complete_direct_graphics(7, transfer_id, image_id, false)); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(matches!( - response_rx.try_recv(), - Err(std::sync::mpsc::TryRecvError::Disconnected) - )); -} - -#[cfg(unix)] -#[test] fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() { for error in [ std::sync::mpsc::TrySendError::Full(Vec::new()), @@ -1217,6 +907,43 @@ fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() #[cfg(unix)] #[test] +fn eligibility_loss_cancels_the_queued_direct_upload() { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let (writer, control_rx, _render_rx) = test_client_writer(); + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ); + client.direct_graphics = true; + client.pixel_mouse = true; + server.clients.insert(7, client); + let (transfer_id, image_id) = direct_ids(&server, &key); + + server.retire_all_direct_graphics(); + + assert!(matches!( + read_server_message( + control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("direct retirement") + ), + ServerMessage::GraphicsTransmissionRetired { + transfer_id: retired_transfer, + image_id: retired_image, + } if retired_transfer == transfer_id && retired_image == image_id + )); + assert!(!server.app.pane_graphics.slots.contains_key(&key)); + assert!(response_rx.recv().is_err()); +} + +#[cfg(unix)] +#[test] fn client_loss_retires_only_its_direct_stream() { let (mut pending, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); pending.retire_direct_graphics_for_client(8); @@ -1242,6 +969,44 @@ fn client_loss_retires_only_its_direct_stream() { #[cfg(unix)] #[test] +fn pane_removal_cancels_the_pending_client_upload() { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let (writer, control_rx, _render_rx) = test_client_writer(); + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ); + client.direct_graphics = true; + client.pixel_mouse = true; + server.clients.insert(7, client); + let (transfer_id, image_id) = direct_ids(&server, &key); + server.app.state.workspaces.clear(); + + assert!(server.retain_live_pane_graphics()); + + assert!(matches!( + read_server_message( + control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("pane removal retirement") + ), + ServerMessage::GraphicsTransmissionRetired { + transfer_id: retired_transfer, + image_id: retired_image, + } if retired_transfer == transfer_id && retired_image == image_id + )); + assert!(!server.app.pane_graphics.slots.contains_key(&key)); + assert!(response_rx.recv().is_err()); +} + +#[cfg(unix)] +#[test] fn pane_removal_and_shutdown_drop_direct_without_ack() { let setups: [fn(&mut HeadlessServer); 2] = [ |server| server.app.state.workspaces.clear(), diff --git a/src/server/mod.rs b/src/server/mod.rs index 8566b4bd..8b155df1 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -2,6 +2,9 @@ mod alt_screen_read; pub mod autodetect; #[cfg(unix)] pub(crate) mod client_accept; +pub(crate) mod client_commands; +pub(crate) mod client_shell; +pub(crate) mod client_shell_graphics; pub(crate) mod client_transport; pub(crate) mod clients; pub(crate) mod clipboard_image; @@ -10,6 +13,9 @@ pub(crate) mod handoff; pub mod headless; pub(crate) mod keybindings; pub(crate) mod notifications; +pub(crate) mod pane_input; +#[cfg(test)] +mod render_scale_benchmark; pub(crate) mod render_stream; pub mod socket_paths; pub(crate) mod terminal_attach; diff --git a/src/server/pane_input.rs b/src/server/pane_input.rs new file mode 100644 index 00000000..4eb64cab --- /dev/null +++ b/src/server/pane_input.rs @@ -0,0 +1,515 @@ +use bytes::Bytes; +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; + +use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientPaneInputEvent}; + +pub(super) fn downgrade_ineligible_pixel_mouse( + events: &mut [ClientPaneInputEvent], + pixel_mouse: bool, + runtime_size: (u16, u16), + runtime_pixels: Option<(u32, u32)>, +) { + let (runtime_rows, runtime_cols) = runtime_size; + for event in events { + let ClientPaneInputEvent::Mouse { + position, geometry, .. + } = event + else { + continue; + }; + let crate::protocol::ClientMousePosition::Pixels { x, y, column, row } = *position else { + continue; + }; + let exact = pixel_mouse + && geometry.is_some_and(|geometry| { + (runtime_rows, runtime_cols) == (geometry.rows, geometry.cols) + && runtime_pixels == Some((geometry.width_px, geometry.height_px)) + && column < geometry.cols + && row < geometry.rows + && x > 0 + && y > 0 + && x <= geometry.width_px + && y <= geometry.height_px + }); + if !exact { + *position = crate::protocol::ClientMousePosition::Cell { column, row }; + *geometry = None; + } + } +} + +pub(super) fn terminal_attach_mouse_position( + runtime: &crate::terminal::TerminalRuntime, + terminal_size: (u16, u16), + cell_size: crate::kitty_graphics::HostCellSize, + pixel_mouse: bool, + host_sgr_pixels_active: bool, + position: crate::protocol::ClientMousePosition, + geometry: Option<crate::protocol::ClientMouseGeometry>, +) -> Option<crate::protocol::ClientMousePosition> { + let runtime_size = runtime.current_size(); + let cell_fallback = |column, row| { + (column < runtime_size.1 && row < runtime_size.0) + .then_some(crate::protocol::ClientMousePosition::Cell { column, row }) + }; + let (x, y, column, row) = match position { + crate::protocol::ClientMousePosition::Cell { column, row } => { + return cell_fallback(column, row); + } + crate::protocol::ClientMousePosition::Pixels { x, y, column, row } => (x, y, column, row), + }; + let Some(geometry) = geometry else { + return cell_fallback(column, row); + }; + let host_geometry = crate::input::mouse::HostGeometry::new( + geometry.cols, + geometry.rows, + geometry.width_px, + geometry.height_px, + )?; + if host_geometry.cell(x, y) != Some((column, row)) { + return None; + } + let exact = (|| { + let average_width = (geometry.width_px / u32::from(geometry.cols)).max(1); + let average_height = (geometry.height_px / u32::from(geometry.rows)).max(1); + let (child_width_px, child_height_px) = runtime.pixel_size()?; + if !pixel_mouse + || !host_sgr_pixels_active + || !runtime.sgr_pixel_mouse_enabled() + || terminal_size != (geometry.cols, geometry.rows) + || runtime_size != (geometry.rows, geometry.cols) + || !cell_size.is_known() + || average_width != cell_size.width_px + || average_height != cell_size.height_px + { + return None; + } + let crate::input::mouse::Position::Pixels { x, y } = (crate::input::mouse::HostPixels { + x, + y, + geometry: host_geometry, + }) + .pane_position( + ratatui::layout::Rect::new(0, 0, geometry.cols, geometry.rows), + child_width_px, + child_height_px, + )? + else { + return None; + }; + Some(crate::protocol::ClientMousePosition::Pixels { x, y, column, row }) + })(); + exact.or_else(|| cell_fallback(column, row)) +} + +pub(super) 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> { + apply_scroll( + runtime, + source, + direction, + lines, + crate::input::mouse::Position::Cell { + column: column.unwrap_or(0), + row: row.unwrap_or(0), + }, + modifiers, + ) +} + +fn apply_scroll( + runtime: &crate::terminal::TerminalRuntime, + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + position: crate::input::mouse::Position, + 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 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(()) +} + +pub(super) 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}")) + } +} + +pub(super) fn apply_client_pane_input_events( + runtime: &crate::terminal::TerminalRuntime, + events: &[ClientPaneInputEvent], +) -> Result<(), String> { + apply_client_terminal_input_events(runtime, events, true) +} + +pub(super) fn apply_client_popup_input_events( + runtime: &crate::terminal::TerminalRuntime, + events: &[ClientPaneInputEvent], +) -> Result<(), String> { + apply_client_terminal_input_events(runtime, events, false) +} + +fn apply_client_terminal_input_events( + runtime: &crate::terminal::TerminalRuntime, + events: &[ClientPaneInputEvent], + host_page_keys: bool, +) -> Result<(), String> { + for event in events { + if let ClientPaneInputEvent::Mouse { + kind, + position, + modifiers, + lines, + .. + } = event + { + let kind = kind.to_crossterm(); + let modifiers = KeyModifiers::from_bits_truncate(*modifiers); + let position = match position { + crate::protocol::ClientMousePosition::Cell { column, row } => { + crate::input::mouse::Position::Cell { + column: *column, + row: *row, + } + } + crate::protocol::ClientMousePosition::Pixels { x, y, column, row } => { + if runtime.sgr_pixel_mouse_enabled() { + crate::input::mouse::Position::Pixels { x: *x, y: *y } + } else { + crate::input::mouse::Position::Cell { + column: *column, + row: *row, + } + } + } + }; + let bytes = match kind { + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + let direction = if kind == MouseEventKind::ScrollUp { + AttachScrollDirection::Up + } else { + AttachScrollDirection::Down + }; + apply_scroll( + runtime, + AttachScrollSource::Wheel, + direction, + (*lines).max(1), + position, + modifiers.bits(), + )?; + continue; + } + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => runtime + .encode_mouse_wheel(kind, position, modifiers) + .unwrap_or_default(), + MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { + runtime + .encode_mouse_button(kind, position, modifiers) + .unwrap_or_default() + } + MouseEventKind::Moved => runtime + .encode_mouse_motion(kind, position, modifiers) + .unwrap_or_default(), + }; + if !bytes.is_empty() { + if kind != MouseEventKind::Moved { + runtime.scroll_reset(); + } + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("targeted pane mouse input failed: {err}"))?; + } + continue; + } + + match event.to_raw_input_event() { + crate::raw_input::RawInputEvent::Key(key) => { + let key_event = key.as_key_event(); + if host_page_keys + && matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown) + && key_event.modifiers.is_empty() + && runtime.plain_page_keys_use_host_scrollback() == Some(true) + { + match key_event.kind { + KeyEventKind::Release => continue, + KeyEventKind::Press | KeyEventKind::Repeat => { + let lines = runtime.current_size().0.max(1) as usize; + if key_event.code == KeyCode::PageUp { + runtime.scroll_up(lines); + } else { + runtime.scroll_down(lines); + } + continue; + } + } + } + + runtime.scroll_reset(); + let bytes = runtime.encode_terminal_key(key); + if !bytes.is_empty() { + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("targeted pane key input failed: {err}"))?; + } + } + crate::raw_input::RawInputEvent::Text(text) => { + runtime.scroll_reset(); + runtime + .try_send_bytes(Bytes::copy_from_slice(text.as_str().as_bytes())) + .map_err(|err| format!("targeted pane text input failed: {err}"))?; + } + crate::raw_input::RawInputEvent::Paste(text) => { + runtime.scroll_reset(); + runtime + .try_send_paste(text) + .map_err(|err| format!("targeted pane paste failed: {err}"))?; + } + crate::raw_input::RawInputEvent::Mouse(_) + | crate::raw_input::RawInputEvent::OuterFocusGained + | crate::raw_input::RawInputEvent::OuterFocusLost + | crate::raw_input::RawInputEvent::HostDefaultColor { .. } + | crate::raw_input::RawInputEvent::HostPaletteColors { .. } + | crate::raw_input::RawInputEvent::HostColorSchemeChanged(_) + | crate::raw_input::RawInputEvent::HostCellSizeReport { .. } + | crate::raw_input::RawInputEvent::Unsupported => { + return Err("non-pane input reached targeted pane input".to_owned()); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn terminal_attach_stale_geometry_falls_back_to_the_canonical_cell() { + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b""); + let position = crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }; + + assert_eq!( + terminal_attach_mouse_position( + &runtime, + (20, 5), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + true, + false, + position, + Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + ), + Some(crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }) + ); + assert_eq!( + terminal_attach_mouse_position( + &runtime, + (20, 5), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + true, + false, + crate::protocol::ClientMousePosition::Pixels { + x: 120, + y: 80, + column: 12, + row: 4, + }, + Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + ), + None + ); + assert_eq!( + terminal_attach_mouse_position( + &runtime, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + false, + false, + crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }, + None, + ), + Some(crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }) + ); + } + + #[test] + fn ineligible_shell_pixel_mouse_uses_its_canonical_cell_position() { + let mut events = vec![ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left), + position: crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + modifiers: 0, + lines: 1, + }]; + + downgrade_ineligible_pixel_mouse(&mut events, false, (5, 20), Some((200, 100))); + + assert!(matches!( + events.as_slice(), + [ClientPaneInputEvent::Mouse { + position: crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }, + .. + }] + )); + } + + #[test] + fn eligible_shell_pixel_mouse_remains_exact() { + let position = crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }; + let mut events = vec![ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + modifiers: 0, + lines: 1, + }]; + + downgrade_ineligible_pixel_mouse(&mut events, true, (5, 20), Some((200, 100))); + + assert!(matches!( + events.as_slice(), + [ClientPaneInputEvent::Mouse { + position: current, + .. + }] if *current == position + )); + } + + #[test] + fn stale_shell_pixel_geometry_downgrades_to_its_canonical_cell() { + let mut events = vec![ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + modifiers: 0, + lines: 1, + }]; + + downgrade_ineligible_pixel_mouse(&mut events, true, (6, 20), Some((200, 120))); + + assert!(matches!( + events.as_slice(), + [ClientPaneInputEvent::Mouse { + position: crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }, + geometry: None, + .. + }] + )); + } +} diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs new file mode 100644 index 00000000..e533bd03 --- /dev/null +++ b/src/server/render_scale_benchmark.rs @@ -0,0 +1,218 @@ +use std::hint::black_box; +use std::time::{Duration, Instant}; + +use ratatui::layout::{Direction, Rect}; + +use crate::app::{App, AppPolicy}; +use crate::client::{ClientShellConfig, ClientShellState}; +use crate::config::Config; +use crate::kitty_graphics::HostCellSize; +use crate::protocol::PaneSurfaceFrame; +use crate::terminal::TerminalRuntime; +use crate::workspace::Workspace; + +const COLS: u16 = 120; +const ROWS: u16 = 40; +const SAMPLE_COUNT: usize = 40; +const WARMUP_COUNT: usize = 5; +const CARDINALITIES: [usize; 3] = [1, 15, 50]; + +#[derive(Clone, Copy)] +struct StageStats { + median_us: u128, + p95_us: u128, + max_us: u128, +} + +struct PipelineStats { + server: StageStats, + client: StageStats, + total: StageStats, +} + +struct RenderPipeline { + app: App, + client: ClientShellState, + graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, +} + +impl RenderPipeline { + fn new(workspaces: Vec<Workspace>) -> Self { + let config = Config::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &config, + AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = workspaces; + app.state.active = Some(0); + app.state.selected = 0; + app.state.pane_scrollbars = true; + + let mut client = ClientShellState::new(ClientShellConfig::from_config(&config)); + client.set_snapshot(Box::new(super::client_shell::snapshot( + &app, + "bench-boot", + 1, + None, + ))); + + Self { + app, + client, + graphics_delivery: crate::kitty_graphics::surface::DeliveryCache::default(), + } + } + + fn render_once(&mut self) -> (Duration, Duration) { + let surface_size = self.client.surface_size(COLS, ROWS); + let started = Instant::now(); + let rendered = super::client_shell::render_pane_surface( + &mut self.app, + Rect::new(0, 0, surface_size.cols, surface_size.rows), + true, + HostCellSize { + width_px: 1, + height_px: 1, + }, + &self.graphics_delivery, + 1, + ); + let server_elapsed = started.elapsed(); + self.graphics_delivery = rendered.graphics_delivery; + + let started = Instant::now(); + self.client.set_pane_surface(PaneSurfaceFrame { + boot_id: "bench-boot".into(), + projection_revision: 1, + surface_revision: 0, + frame: rendered.frame, + panes: rendered.panes, + splits: rendered.splits, + popup: rendered.popup, + graphics: rendered.graphics, + }); + black_box( + self.client + .compose(COLS, ROWS) + .expect("benchmark pipeline should compose a complete frame"), + ); + let client_elapsed = started.elapsed(); + + (server_elapsed, client_elapsed) + } +} + +fn history() -> String { + (0..2_000).map(|line| format!("line-{line}\r\n")).collect() +} + +fn runtime(history: &str) -> TerminalRuntime { + TerminalRuntime::test_with_scrollback_bytes(COLS, ROWS, 1024 * 1024, history.as_bytes()) +} + +fn workspaces(workspace_count: usize) -> Vec<Workspace> { + let history = history(); + (0..workspace_count) + .map(|index| { + let mut workspace = Workspace::test_new(&format!("bench-{}", index + 1)); + let root_pane = workspace.tabs[0].root_pane; + workspace.insert_test_runtime(root_pane, runtime(&history)); + workspace + }) + .collect() +} + +fn active_panes(pane_count: usize) -> Vec<Workspace> { + let history = history(); + let mut workspace = Workspace::test_new("bench"); + let root_pane = workspace.tabs[0].root_pane; + workspace.insert_test_runtime(root_pane, runtime(&history)); + let mut pane_ids = vec![root_pane]; + + for index in 1..pane_count { + let target = pane_ids[(index - 1) / 2]; + workspace.tabs[0].layout.focus_pane(target); + let direction = if index % 2 == 0 { + Direction::Vertical + } else { + Direction::Horizontal + }; + let pane_id = workspace.test_split(direction); + workspace.insert_test_runtime(pane_id, runtime(&history)); + pane_ids.push(pane_id); + } + + vec![workspace] +} + +fn summarize(mut samples: Vec<Duration>) -> StageStats { + samples.sort_unstable(); + StageStats { + median_us: samples[SAMPLE_COUNT / 2].as_micros(), + p95_us: samples[(SAMPLE_COUNT - 1) * 95 / 100].as_micros(), + max_us: samples[SAMPLE_COUNT - 1].as_micros(), + } +} + +fn profile(build: fn(usize) -> Vec<Workspace>, count: usize) -> PipelineStats { + let mut pipeline = RenderPipeline::new(build(count)); + for _ in 0..WARMUP_COUNT { + black_box(pipeline.render_once()); + } + + let mut server = Vec::with_capacity(SAMPLE_COUNT); + let mut client = Vec::with_capacity(SAMPLE_COUNT); + let mut total = Vec::with_capacity(SAMPLE_COUNT); + for _ in 0..SAMPLE_COUNT { + let (server_elapsed, client_elapsed) = pipeline.render_once(); + server.push(server_elapsed); + client.push(client_elapsed); + total.push(server_elapsed + client_elapsed); + } + + PipelineStats { + server: summarize(server), + client: summarize(client), + total: summarize(total), + } +} + +fn print_stage( + label: &str, + rows: &[(usize, PipelineStats)], + stage: fn(&PipelineStats) -> StageStats, +) { + let baseline = stage(&rows[0].1); + println!(" {label}"); + println!(" count median_us p95_us max_us median_vs_1x p95_vs_1x"); + for (count, pipeline) in rows { + let stats = stage(pipeline); + println!( + " {count:>10} {:>9} {:>6} {:>6} {:>12.2} {:>9.2}", + stats.median_us, + stats.p95_us, + stats.max_us, + stats.median_us as f64 / baseline.median_us.max(1) as f64, + stats.p95_us as f64 / baseline.p95_us.max(1) as f64, + ); + } +} + +fn print_profiles(label: &str, build: fn(usize) -> Vec<Workspace>) { + let rows = CARDINALITIES.map(|count| (count, profile(build, count))); + println!("{label}"); + print_stage("server pane surface", &rows, |stats| stats.server); + print_stage("client shell composition", &rows, |stats| stats.client); + print_stage("combined pipeline", &rows, |stats| stats.total); +} + +#[tokio::test(flavor = "current_thread")] +#[ignore = "manual client-rendered pipeline scaling profile"] +async fn render_scale_profile() { + print_profiles("background workspaces (one pane each)", workspaces); + print_profiles("active panes (one workspace)", active_panes); +} diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index f14deb5e..3d9e1141 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -4,15 +4,20 @@ use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize}; use ratatui::layout::{Position, Rect, Size}; use crate::app::state::AppState; -use crate::app::Mode; use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit}; -use crate::protocol::{CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame}; +use crate::protocol::{ + CursorState, FrameData, PaneSurfaceFrame, PaneSurfacePatch, RenderEncoding, ServerMessage, + TerminalFrame, +}; use crate::terminal::TerminalRuntimeRegistry; /// Per-client render baseline for the negotiated render encoding. pub(crate) enum ClientRenderState { /// Semantic clients compare full frame data and skip identical frames. - Semantic { last_frame: Option<FrameData> }, + Semantic { + last_surface: Option<Box<PaneSurfaceFrame>>, + surface_revision: u64, + }, /// Terminal-ANSI clients keep a terminal diff encoder and sequence number. TerminalAnsi { blit_encoder: BlitEncoder, @@ -24,7 +29,10 @@ pub(crate) enum ClientRenderState { impl ClientRenderState { pub(crate) fn new(render_encoding: RenderEncoding) -> Self { match render_encoding { - RenderEncoding::SemanticFrame => Self::Semantic { last_frame: None }, + RenderEncoding::SemanticFrame => Self::Semantic { + last_surface: None, + surface_revision: 0, + }, RenderEncoding::TerminalAnsi => Self::TerminalAnsi { blit_encoder: BlitEncoder::new(), seq: 0, @@ -35,7 +43,7 @@ impl ClientRenderState { pub(crate) fn reset_baseline(&mut self) { match self { - Self::Semantic { last_frame } => *last_frame = None, + Self::Semantic { last_surface, .. } => *last_surface = None, Self::TerminalAnsi { blit_encoder, repaint_pending, @@ -49,31 +57,16 @@ impl ClientRenderState { pub(crate) fn request_repaint(&mut self) { match self { - Self::Semantic { last_frame } => *last_frame = None, + Self::Semantic { last_surface, .. } => *last_surface = None, Self::TerminalAnsi { repaint_pending, .. } => *repaint_pending = true, } } - pub(crate) fn reset_semantic_input_baseline(&mut self) { - if let Self::Semantic { last_frame } = self { - *last_frame = None; - } - } - pub(crate) fn prepare_frame(&mut self, frame: FrameData) -> Option<PreparedRender> { match self { - Self::Semantic { last_frame } => { - if last_frame.as_ref() == Some(&frame) { - crate::render_prof::event("prepare_frame.semantic.skip_current"); - return None; - } - crate::render_prof::event("prepare_frame.semantic.changed"); - Some(PreparedRender::Semantic { - message: ServerMessage::Frame(frame), - }) - } + Self::Semantic { .. } => None, Self::TerminalAnsi { blit_encoder, seq, @@ -111,21 +104,89 @@ impl ClientRenderState { } } - pub(crate) fn last_frame(&self) -> Option<&FrameData> { + pub(crate) fn last_pane_surface(&self) -> Option<&PaneSurfaceFrame> { match self { - Self::Semantic { last_frame } => last_frame.as_ref(), - Self::TerminalAnsi { blit_encoder, .. } => blit_encoder.last_frame(), + Self::Semantic { last_surface, .. } => last_surface.as_deref(), + Self::TerminalAnsi { .. } => None, } } + pub(crate) fn prepare_pane_surface( + &mut self, + mut surface: PaneSurfaceFrame, + ) -> Option<PreparedRender> { + let Self::Semantic { + last_surface, + surface_revision, + } = self + else { + return None; + }; + if surface.graphics.assets.is_empty() + && last_surface.as_deref().is_some_and(|last| { + last.projection_revision == surface.projection_revision + && last.frame == surface.frame + && last.panes == surface.panes + && last.splits == surface.splits + && last.popup == surface.popup + && last.graphics.placements == surface.graphics.placements + && last.graphics.retained_assets == surface.graphics.retained_assets + }) + { + return None; + } + surface.surface_revision = surface_revision.saturating_add(1); + let mut committed_surface = surface.clone(); + committed_surface.graphics.assets.clear(); + Some(PreparedRender::Semantic { + message: ServerMessage::PaneSurface(surface), + committed_surface: Box::new(committed_surface), + }) + } + + pub(crate) fn prepare_pane_surface_patch( + &mut self, + mut patch: PaneSurfacePatch, + mut committed_surface: PaneSurfaceFrame, + ) -> Option<PreparedRender> { + let Self::Semantic { + last_surface, + surface_revision, + } = self + else { + return None; + }; + let last = last_surface.as_deref()?; + if last.boot_id != patch.boot_id + || last.projection_revision != patch.projection_revision + || last.surface_revision != patch.base_surface_revision + { + return None; + } + let next_revision = surface_revision.saturating_add(1); + patch.surface_revision = next_revision; + committed_surface.surface_revision = next_revision; + committed_surface.graphics.assets.clear(); + Some(PreparedRender::Semantic { + message: ServerMessage::PaneSurfacePatch(patch), + committed_surface: Box::new(committed_surface), + }) + } + pub(crate) fn commit_sent_frame(&mut self, prepared: PreparedRender) { match (self, prepared) { ( - Self::Semantic { last_frame }, + Self::Semantic { + last_surface, + surface_revision, + }, PreparedRender::Semantic { - message: ServerMessage::Frame(frame), + committed_surface, .. }, - ) => *last_frame = Some(frame), + ) => { + *surface_revision = committed_surface.surface_revision; + *last_surface = Some(committed_surface); + } ( Self::TerminalAnsi { blit_encoder, @@ -145,14 +206,6 @@ impl ClientRenderState { _ => {} } } - - #[cfg(test)] - pub(crate) fn terminal_seq(&self) -> Option<u64> { - match self { - Self::Semantic { .. } => None, - Self::TerminalAnsi { seq, .. } => Some(*seq), - } - } } fn insert_graphics_before_sync_end(encoded: &mut Vec<u8>, graphics: &[u8]) { @@ -171,6 +224,7 @@ fn insert_graphics_before_sync_end(encoded: &mut Vec<u8>, graphics: &[u8]) { pub(crate) enum PreparedRender { Semantic { message: ServerMessage, + committed_surface: Box<PaneSurfaceFrame>, }, TerminalAnsi { message: ServerMessage, @@ -182,18 +236,23 @@ pub(crate) enum PreparedRender { impl PreparedRender { pub(crate) fn message(&self) -> &ServerMessage { match self { - Self::Semantic { message } | Self::TerminalAnsi { message, .. } => message, + Self::Semantic { message, .. } | Self::TerminalAnsi { message, .. } => message, } } - pub(crate) fn into_frame(self) -> Option<FrameData> { - match self { - Self::Semantic { - message: ServerMessage::Frame(frame), - } => Some(frame), - Self::TerminalAnsi { frame, .. } => Some(frame), - _ => None, + pub(crate) fn strip_pane_surface_assets(&mut self) -> bool { + let Self::Semantic { + message: ServerMessage::PaneSurface(surface), + .. + } = self + else { + return false; + }; + if surface.graphics.assets.is_empty() { + return false; } + surface.graphics.assets.clear(); + true } } @@ -280,88 +339,44 @@ impl Backend for CursorTrackingBackend { } } -/// Renders the AppState to an in-memory ratatui Buffer. -/// -/// This produces the same output as the monolithic binary's terminal draw, -/// but writes to a `Buffer` instead of stdout. Cursor visibility is captured -/// from explicit frame cursor intent rather than incidental backend state. -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn render_virtual( - app_state: &mut AppState, - area: Rect, - resize_panes: bool, -) -> (ratatui::buffer::Buffer, Option<CursorState>) { - let terminal_runtimes = TerminalRuntimeRegistry::new(); - render_virtual_with_runtime_registry( - app_state, - &terminal_runtimes, - area, - resize_panes, - crate::kitty_graphics::HostCellSize::default(), - ) -} +pub(crate) type RenderedTabSurface = ( + ratatui::buffer::Buffer, + Option<CursorState>, + Vec<((u16, u16), String, String)>, + crate::ui::TabSurfaceLayout, +); -pub(crate) fn render_virtual_with_runtime_registry( - app_state: &mut AppState, +/// Renders only the active tab's pane surface at an origin-relative client viewport. +pub(crate) fn render_tab_surface_virtual( + app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, -) -> (ratatui::buffer::Buffer, Option<CursorState>) { - let popup_visible = app_state.popup_pane.is_some(); - let pre_compute_suppresses_focused_terminal_cursor = - !popup_visible && focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes); - if resize_panes { - crate::ui::compute_view_with_cell_size(app_state, terminal_runtimes, area, cell_size); - } else { - crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area); - } - let suppress_focused_terminal_cursor = pre_compute_suppresses_focused_terminal_cursor - || (!popup_visible - && focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes)); +) -> RenderedTabSurface { + let layout = + crate::ui::compute_tab_surface(app_state, terminal_runtimes, area, resize_panes, cell_size); + let surface = crate::ui::TabSurfaceView { + pane_infos: &layout.pane_infos, + split_borders: &layout.split_borders, + }; + let cursor = crate::ui::tab_surface_cursor(app_state, terminal_runtimes, surface); + let hyperlinks = crate::ui::tab_surface_hyperlinks(app_state, terminal_runtimes, surface); let backend = CursorTrackingBackend::new(area.width, area.height); let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail"); - terminal .draw(|frame| { - crate::ui::render_with_runtime_registry(app_state, terminal_runtimes, frame); + crate::ui::render_tab_surface(app_state, terminal_runtimes, surface, frame); }) .expect("render to TestBackend should never fail"); - let buffer = terminal.backend().buffer().clone(); - let cursor = if popup_visible { - popup_terminal_cursor(app_state, terminal_runtimes) - } else if suppress_focused_terminal_cursor { - None - } else { - focused_terminal_cursor(app_state, terminal_runtimes).or_else(|| { - (!focused_terminal_owns_host_cursor(app_state, terminal_runtimes)) - .then(|| terminal.backend().rendered_cursor()) - .flatten() - }) - }; - - (buffer, cursor) -} - -fn popup_terminal_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Option<CursorState> { - let popup = app_state.popup_pane.as_ref()?; - let runtime = terminal_runtimes.get(&popup.terminal_id)?; - if runtime.synchronized_output_active() { - return None; - } - let (_, inner) = crate::ui::popup_pane_rects(app_state, app_state.view.terminal_area)?; - let cursor = runtime.cursor_state(inner, true)?; - Some(CursorState { - x: cursor.x, - y: cursor.y, - visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime), - shape: cursor.shape, - }) + ( + terminal.backend().buffer().clone(), + cursor, + hyperlinks, + layout, + ) } /// Renders one server-owned terminal directly for `terminal attach` clients. @@ -398,242 +413,66 @@ pub(crate) fn render_terminal_virtual( (buffer, cursor) } -pub(crate) fn visible_hyperlinks( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Vec<((u16, u16), String, String)> { - crate::ui::tab_surface_hyperlinks(app_state, terminal_runtimes, app_state.view.tab_surface()) -} - -pub(crate) fn focused_terminal_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Option<CursorState> { - crate::ui::tab_surface_cursor(app_state, terminal_runtimes, app_state.view.tab_surface()) -} - -fn focused_terminal_owns_host_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> bool { - if app_state.mode != Mode::Terminal { - return false; - } - - let Some(ws_idx) = app_state.active else { - return false; - }; - let Some(info) = app_state - .view - .pane_infos - .iter() - .find(|info| info.is_focused) - else { - return false; - }; - if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { - return false; - } - - app_state - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - .is_some() -} - -fn focused_terminal_suppresses_host_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> bool { - if app_state.mode != Mode::Terminal { - return false; - } - - let Some(ws_idx) = app_state.active else { - return false; - }; - let Some(info) = app_state - .view - .pane_infos - .iter() - .find(|info| info.is_focused) - else { - return false; - }; - if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { - return false; - } - - app_state - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - .is_some_and(crate::terminal::TerminalRuntime::synchronized_output_active) -} - #[cfg(test)] -mod render_scale_benchmark { - use std::hint::black_box; - use std::time::Instant; - - use ratatui::layout::Direction; - +mod tests { use super::*; - use crate::app::Mode; - use crate::terminal::TerminalRuntime; - use crate::workspace::Workspace; - - const AREA: Rect = Rect::new(0, 0, 120, 40); - const SAMPLE_COUNT: usize = 40; - const WARMUP_COUNT: usize = 5; - - #[derive(Clone, Copy)] - struct RenderStats { - median_us: u128, - p95_us: u128, - max_us: u128, - } - - fn history() -> String { - (0..2_000).map(|line| format!("line-{line}\r\n")).collect() - } - - fn runtime(history: &str) -> TerminalRuntime { - TerminalRuntime::test_with_scrollback_bytes( - AREA.width, - AREA.height, - 1024 * 1024, - history.as_bytes(), - ) - } - - fn app_with_workspaces(workspace_count: usize) -> AppState { - let history = history(); - let workspaces = (0..workspace_count) - .map(|index| { - let mut workspace = Workspace::test_new(&format!("bench-{}", index + 1)); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0] - .runtimes - .insert(root_pane, runtime(&history)); - workspace - }) - .collect(); - app_with(workspaces) - } - - fn app_with_active_panes(pane_count: usize) -> AppState { - let history = history(); - let mut workspace = Workspace::test_new("bench"); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0] - .runtimes - .insert(root_pane, runtime(&history)); - let mut pane_ids = vec![root_pane]; - - for index in 1..pane_count { - let target = pane_ids[(index - 1) / 2]; - workspace.tabs[0].layout.focus_pane(target); - let direction = if index % 2 == 0 { - Direction::Vertical - } else { - Direction::Horizontal - }; - let pane_id = workspace.test_split(direction); - workspace.tabs[0] - .runtimes - .insert(pane_id, runtime(&history)); - pane_ids.push(pane_id); - } - - app_with(vec![workspace]) - } - - fn app_with(workspaces: Vec<Workspace>) -> AppState { - let mut app = AppState::test_new(); - app.mode = Mode::Terminal; - app.pane_scrollbars = true; - app.workspaces = workspaces; - app.active = Some(0); - app.selected = 0; - app - } - - fn profile(mut app: AppState) -> RenderStats { - for _ in 0..WARMUP_COUNT { - black_box(render_virtual(&mut app, AREA, true)); - } - - let mut samples = Vec::with_capacity(SAMPLE_COUNT); - for _ in 0..SAMPLE_COUNT { - let started = Instant::now(); - black_box(render_virtual(&mut app, AREA, true)); - samples.push(started.elapsed().as_micros()); - } - samples.sort_unstable(); - - RenderStats { - median_us: samples[SAMPLE_COUNT / 2], - p95_us: samples[(SAMPLE_COUNT - 1) * 95 / 100], - max_us: samples[SAMPLE_COUNT - 1], - } - } - - fn profile_cardinalities(build: fn(usize) -> AppState) -> [(usize, RenderStats); 3] { - [1, 15, 50].map(|count| (count, profile(build(count)))) - } - - fn print_profiles(label: &str, profiles: [(usize, RenderStats); 3]) { - let baseline_median_us = profiles[0].1.median_us as f64; - let baseline_p95_us = profiles[0].1.p95_us as f64; - println!("{label}"); - println!(" count median_us p95_us max_us median_vs_1x p95_vs_1x"); - for (count, stats) in profiles { - println!( - "{count:>10} {:>9} {:>6} {:>6} {:>12.2} {:>9.2}", - stats.median_us, - stats.p95_us, - stats.max_us, - stats.median_us as f64 / baseline_median_us, - stats.p95_us as f64 / baseline_p95_us, - ); + use crate::protocol::ClientShellPopupSurface; + + fn popup_surface(content: &str) -> PaneSurfaceFrame { + let pane = ratatui::buffer::Buffer::with_lines(["pane"]); + let popup = ratatui::buffer::Buffer::with_lines([content]); + PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + surface_revision: 1, + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&pane, None, &[]), + panes: Vec::new(), + splits: Vec::new(), + popup: Some(Box::new(ClientShellPopupSurface { + terminal_id: "popup-terminal".into(), + title: "popup".into(), + width: None, + height: None, + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&popup, None, &[]), + mouse_reporting: false, + sgr_pixel_mouse: false, + pixel_width: 0, + pixel_height: 0, + })), + graphics: crate::protocol::SurfaceGraphicsScene::default(), } } - fn assert_full_render_avoids_aggregate_input_state(mut app: AppState, scenario: &str) { - crate::pane::reset_aggregate_input_state_reads(); - black_box(render_virtual(&mut app, AREA, true)); - assert_eq!( - crate::pane::aggregate_input_state_reads(), - 0, - "full render collected aggregate input state for {scenario}", - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn aggregate_input_state_counter_records_reads() { - let runtime = TerminalRuntime::test_with_screen_bytes(80, 24, b""); - crate::pane::reset_aggregate_input_state_reads(); - black_box(runtime.input_state()); - assert_eq!(crate::pane::aggregate_input_state_reads(), 1); - } - - #[tokio::test(flavor = "current_thread")] - async fn full_render_avoids_aggregate_input_state_reads() { - assert_full_render_avoids_aggregate_input_state( - app_with_workspaces(15), - "background workspaces", - ); - assert_full_render_avoids_aggregate_input_state(app_with_active_panes(15), "active panes"); - } - - #[tokio::test(flavor = "current_thread")] - #[ignore = "manual full-render scaling profile"] - async fn render_scale_profile() { - print_profiles( - "background-workspace resize/layout (one pane each)", - profile_cardinalities(app_with_workspaces), - ); - print_profiles( - "active panes (one workspace)", - profile_cardinalities(app_with_active_panes), - ); + #[test] + fn popup_only_surface_changes_are_not_deduplicated() { + let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame); + let prepared = state + .prepare_pane_surface(popup_surface("first")) + .expect("initial surface"); + state.commit_sent_frame(prepared); + + assert!(state + .prepare_pane_surface(popup_surface("second")) + .is_some()); + } + + #[test] + fn forced_full_surface_keeps_the_connection_revision_monotonic() { + let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame); + let prepared = state + .prepare_pane_surface(popup_surface("first")) + .expect("initial surface"); + state.commit_sent_frame(prepared); + state.request_repaint(); + + let prepared = state + .prepare_pane_surface(popup_surface("replacement")) + .expect("forced replacement surface"); + assert!(matches!( + prepared.message(), + ServerMessage::PaneSurface(surface) if surface.surface_revision == 2 + )); + state.commit_sent_frame(prepared); + assert_eq!(state.last_pane_surface().unwrap().surface_revision, 2); } } |
