aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/server
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/server')
-rw-r--r--src/server/autodetect.rs15
-rw-r--r--src/server/client_commands.rs199
-rw-r--r--src/server/client_shell.rs616
-rw-r--r--src/server/client_shell_graphics.rs25
-rw-r--r--src/server/client_transport.rs1097
-rw-r--r--src/server/clients.rs423
-rw-r--r--src/server/headless.rs10126
-rw-r--r--src/server/headless/bootstrap.rs218
-rw-r--r--src/server/headless/lifecycle.rs399
-rw-r--r--src/server/headless/notifications.rs674
-rw-r--r--src/server/headless/pane_graphics.rs284
-rw-r--r--src/server/headless/render.rs606
-rw-r--r--src/server/headless/retained_surface.rs613
-rw-r--r--src/server/headless/tests/mod.rs5025
-rw-r--r--src/server/headless/tests/pane_graphics.rs827
-rw-r--r--src/server/mod.rs6
-rw-r--r--src/server/pane_input.rs515
-rw-r--r--src/server/render_scale_benchmark.rs218
-rw-r--r--src/server/render_stream.rs529
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(&params.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(&params.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);
}
}