aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/headless')
-rw-r--r--src/server/headless/client_views.rs789
-rw-r--r--src/server/headless/notifications.rs49
-rw-r--r--src/server/headless/render.rs260
-rw-r--r--src/server/headless/retained_surface.rs6
-rw-r--r--src/server/headless/tests/mod.rs848
-rw-r--r--src/server/headless/tests/pane_graphics.rs8
6 files changed, 1836 insertions, 124 deletions
diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs
new file mode 100644
index 00000000..3a0f895c
--- /dev/null
+++ b/src/server/headless/client_views.rs
@@ -0,0 +1,789 @@
+use super::*;
+use crate::server::client_shell::resize_popup_runtime;
+use crate::server::clients::ClientShellTopology;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(super) struct ShellFocusTarget {
+ pub(super) tab_id: String,
+ pub(super) workspace_index: usize,
+ pub(super) pane_id: crate::layout::PaneId,
+}
+
+fn classify_shell_focus_transition<'a>(
+ before: Option<&'a ShellFocusTarget>,
+ after: Option<&'a ShellFocusTarget>,
+ focused_tabs_before: &HashSet<String>,
+ focused_tabs_after: &HashSet<String>,
+) -> (Option<&'a ShellFocusTarget>, Option<&'a ShellFocusTarget>) {
+ if before == after {
+ return (None, None);
+ }
+ if before.map(|target| target.tab_id.as_str()) == after.map(|target| target.tab_id.as_str()) {
+ return match (before, after) {
+ (Some(before), Some(after)) if focused_tabs_after.contains(&after.tab_id) => {
+ (Some(before), Some(after))
+ }
+ _ => (None, None),
+ };
+ }
+ let lost = before.filter(|target| {
+ focused_tabs_before.contains(&target.tab_id) && !focused_tabs_after.contains(&target.tab_id)
+ });
+ let gained = after.filter(|target| {
+ !focused_tabs_before.contains(&target.tab_id) && focused_tabs_after.contains(&target.tab_id)
+ });
+ (lost, gained)
+}
+
+pub(super) fn forward_proxied_api_response(
+ proxy: Option<(
+ std::sync::mpsc::Sender<String>,
+ std::sync::mpsc::Receiver<String>,
+ )>,
+) -> bool {
+ let Some((respond_to, response_rx)) = proxy else {
+ return false;
+ };
+ let Ok(response) = response_rx.recv() else {
+ return false;
+ };
+ let succeeded = serde_json::from_str::<api::schema::SuccessResponse>(&response).is_ok();
+ let _ = respond_to.send(response);
+ succeeded
+}
+
+impl HeadlessServer {
+ pub(super) fn default_shell_target(&self) -> Option<crate::ui::TabSurfaceTarget> {
+ let workspace_index = self.app.state.active?;
+ let workspace = self.app.state.workspaces.get(workspace_index)?;
+ Some(crate::ui::TabSurfaceTarget {
+ workspace_index,
+ tab_index: workspace.active_tab_index(),
+ })
+ }
+
+ pub(super) fn shell_target_for_client(
+ &self,
+ client_id: u64,
+ ) -> Option<crate::ui::TabSurfaceTarget> {
+ let tab_id = self
+ .clients
+ .get(&client_id)?
+ .shell_location
+ .as_ref()
+ .and_then(crate::server::clients::ClientShellLocation::focused_tab_id);
+ tab_id
+ .and_then(|tab_id| self.app.parse_tab_id(tab_id))
+ .map(|(workspace_index, tab_index)| crate::ui::TabSurfaceTarget {
+ workspace_index,
+ tab_index,
+ })
+ .or_else(|| self.default_shell_target())
+ }
+
+ fn tab_id_for_target(&self, target: crate::ui::TabSurfaceTarget) -> Option<String> {
+ self.app
+ .public_tab_id(target.workspace_index, target.tab_index)
+ }
+
+ pub(super) fn shell_tab_id_for_client(&self, client_id: u64) -> Option<String> {
+ self.shell_target_for_client(client_id)
+ .and_then(|target| self.tab_id_for_target(target))
+ }
+
+ fn client_shell_topology(&self) -> ClientShellTopology {
+ let focused_workspace_id = self
+ .app
+ .state
+ .active
+ .map(|workspace_index| self.app.public_workspace_id(workspace_index));
+ let fallback_workspace_id = self
+ .app
+ .state
+ .workspaces
+ .first()
+ .map(|_| self.app.public_workspace_id(0));
+ let mut active_tab_ids = HashMap::new();
+ let mut tab_workspace_ids = HashMap::new();
+ for (workspace_index, workspace) in self.app.state.workspaces.iter().enumerate() {
+ let workspace_id = self.app.public_workspace_id(workspace_index);
+ if let Some(tab_id) = self
+ .app
+ .public_tab_id(workspace_index, workspace.active_tab_index())
+ {
+ active_tab_ids.insert(workspace_id.clone(), tab_id);
+ }
+ for tab_index in 0..workspace.tabs.len() {
+ if let Some(tab_id) = self.app.public_tab_id(workspace_index, tab_index) {
+ tab_workspace_ids.insert(tab_id, workspace_id.clone());
+ }
+ }
+ }
+ ClientShellTopology {
+ focused_workspace_id,
+ fallback_workspace_id,
+ active_tab_ids,
+ tab_workspace_ids,
+ }
+ }
+
+ pub(super) fn reconcile_client_shell_locations(&mut self) {
+ if self.app.state.popup_pane.is_none() {
+ self.popup_owner_tab_id = None;
+ } else if self
+ .popup_owner_tab_id
+ .as_deref()
+ .is_some_and(|tab_id| self.app.parse_tab_id(tab_id).is_none())
+ {
+ self.popup_owner_tab_id = None;
+ self.app.close_popup_pane();
+ } else if self.popup_owner_tab_id.is_none() {
+ self.popup_owner_tab_id = self
+ .default_shell_target()
+ .and_then(|target| self.tab_id_for_target(target));
+ }
+ let topology = self.client_shell_topology();
+ let live_clients = self.clients.keys().copied().collect::<HashSet<_>>();
+ self.tab_geometry_controllers.retain(|tab_id, client_id| {
+ topology.tab_workspace_ids.contains_key(tab_id) && live_clients.contains(client_id)
+ });
+ for client in self
+ .clients
+ .values_mut()
+ .filter(|client| client.is_shell_client())
+ {
+ let location = client.shell_location.get_or_insert_with(|| {
+ crate::server::clients::ClientShellLocation {
+ focused_workspace_id: topology.focused_workspace_id.clone(),
+ active_tab_ids: topology.active_tab_ids.clone(),
+ }
+ });
+ location.reconcile(&topology);
+ }
+ }
+
+ pub(super) fn focus_all_shell_clients_on_default_target(&mut self) {
+ let Some(target) = self.default_shell_target() else {
+ return;
+ };
+ let workspace_id = self.app.public_workspace_id(target.workspace_index);
+ let Some(tab_id) = self.tab_id_for_target(target) else {
+ return;
+ };
+ let focus_before = self.shell_focus_targets();
+ let focused_tabs_before = self.focused_shell_tabs();
+ for client in self
+ .clients
+ .values_mut()
+ .filter(|client| client.is_shell_client())
+ {
+ if let Some(location) = client.shell_location.as_mut() {
+ location.focus_tab(workspace_id.clone(), tab_id.clone());
+ }
+ }
+ let (lost, gained) =
+ self.shell_location_focus_transitions(focus_before, &focused_tabs_before);
+ self.app.accept_current_focus_with_api_events();
+ self.send_shell_focus_transitions(&lost, &gained);
+ }
+
+ pub(super) fn focus_shell_client_on_tab(&mut self, client_id: u64, tab_id: &str) -> bool {
+ let Some((workspace_index, _)) = self.app.parse_tab_id(tab_id) else {
+ return false;
+ };
+ let workspace_id = self.app.public_workspace_id(workspace_index);
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ let Some(location) = client.shell_location.as_mut() else {
+ return false;
+ };
+ location.focus_tab(workspace_id, tab_id.to_owned());
+ true
+ }
+
+ fn set_default_shell_target_from_client(&mut self, client_id: u64) -> bool {
+ let Some(target) = self.shell_target_for_client(client_id) else {
+ return false;
+ };
+ if self.default_shell_target() == Some(target) {
+ return false;
+ }
+ self.app
+ .state
+ .switch_workspace_tab(target.workspace_index, target.tab_index)
+ }
+
+ fn focus_shell_client_on_default_target(&mut self, client_id: u64) -> bool {
+ let Some(tab_id) = self
+ .default_shell_target()
+ .and_then(|target| self.tab_id_for_target(target))
+ else {
+ return false;
+ };
+ self.focus_shell_client_on_tab(client_id, &tab_id)
+ }
+
+ fn shell_locations_may_need_reconcile(method: &api::schema::Method) -> bool {
+ use api::schema::Method;
+
+ matches!(
+ method,
+ Method::CommandInvoke(_)
+ | Method::PaneClose(_)
+ | Method::PaneEditScrollback(_)
+ | Method::PaneSplit(_)
+ | Method::TabClose(_)
+ | Method::TabCreate(_)
+ | Method::WorkspaceClose(_)
+ | Method::WorkspaceCreate(_)
+ | Method::WorktreeCreate(_)
+ | Method::WorktreeOpen(_)
+ | Method::WorktreeRemove(_)
+ )
+ }
+
+ fn shell_endpoint_claims_geometry(method: &api::schema::Method) -> bool {
+ use api::schema::Method;
+
+ matches!(
+ method,
+ Method::CommandInvoke(_)
+ | 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::PaneSplit(_)
+ | Method::PaneSwap(_)
+ | Method::PaneZoom(_)
+ | 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::WorktreeOpen(_)
+ | Method::WorktreeRemove(_)
+ )
+ }
+
+ pub(super) fn deferred_endpoint_navigation_tab_id(response: &[u8]) -> Option<String> {
+ let response = serde_json::from_slice::<serde_json::Value>(response).ok()?;
+ if response.pointer("/result/type")?.as_str()? != "worktree_created" {
+ return None;
+ }
+ response
+ .pointer("/result/tab/tab_id")?
+ .as_str()
+ .map(str::to_owned)
+ }
+
+ fn apply_shell_navigation_request(
+ &mut self,
+ client_id: u64,
+ method: &api::schema::Method,
+ ) -> bool {
+ match method {
+ api::schema::Method::WorkspaceFocus(target) => {
+ let Some(workspace_index) = self.app.parse_workspace_id(&target.workspace_id)
+ else {
+ return false;
+ };
+ let workspace_id = self.app.public_workspace_id(workspace_index);
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ let Some(location) = client.shell_location.as_mut() else {
+ return false;
+ };
+ location.focus_workspace(workspace_id);
+ true
+ }
+ api::schema::Method::TabFocus(target) => {
+ self.focus_shell_client_on_tab(client_id, &target.tab_id)
+ }
+ api::schema::Method::PaneFocus(target) => self
+ .app
+ .parse_pane_id(&target.pane_id)
+ .and_then(|(workspace_index, pane_id)| {
+ let tab_index = self.app.state.workspaces[workspace_index]
+ .find_tab_index_for_pane(pane_id)?;
+ self.app.public_tab_id(workspace_index, tab_index)
+ })
+ .is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, &tab_id)),
+ api::schema::Method::CommandInvoke(params) => params
+ .tab_id
+ .as_deref()
+ .is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, tab_id)),
+ _ => false,
+ }
+ }
+
+ fn focus_target_for_surface(
+ &self,
+ target: crate::ui::TabSurfaceTarget,
+ ) -> Option<ShellFocusTarget> {
+ let pane_id = self
+ .app
+ .state
+ .workspaces
+ .get(target.workspace_index)?
+ .tabs
+ .get(target.tab_index)?
+ .layout
+ .focused();
+ Some(ShellFocusTarget {
+ tab_id: self.tab_id_for_target(target)?,
+ workspace_index: target.workspace_index,
+ pane_id,
+ })
+ }
+
+ pub(super) fn shell_focus_target(&self, client_id: u64) -> Option<ShellFocusTarget> {
+ self.focus_target_for_surface(self.shell_target_for_client(client_id)?)
+ }
+
+ pub(super) fn focused_shell_tabs(&self) -> HashSet<String> {
+ self.clients
+ .iter()
+ .filter(|(_, client)| {
+ client.is_shell_client() && client.outer_terminal_focus == Some(true)
+ })
+ .filter_map(|(&client_id, _)| self.shell_tab_id_for_client(client_id))
+ .collect()
+ }
+
+ pub(super) fn shell_focus_targets(&self) -> Vec<(u64, Option<ShellFocusTarget>)> {
+ self.clients
+ .iter()
+ .filter(|(_, client)| client.is_shell_client())
+ .map(|(&client_id, _)| (client_id, self.shell_focus_target(client_id)))
+ .collect()
+ }
+
+ pub(super) fn send_shell_focus_target(
+ &self,
+ target: &ShellFocusTarget,
+ event: crate::ghostty::FocusEvent,
+ ) {
+ self.app
+ .send_pane_focus_event(target.workspace_index, target.pane_id, event);
+ }
+
+ fn shell_location_focus_transitions(
+ &self,
+ focus_before: Vec<(u64, Option<ShellFocusTarget>)>,
+ focused_tabs_before: &HashSet<String>,
+ ) -> (
+ HashMap<String, ShellFocusTarget>,
+ HashMap<String, ShellFocusTarget>,
+ ) {
+ let focused_tabs_after = self.focused_shell_tabs();
+ let mut lost = HashMap::<String, ShellFocusTarget>::new();
+ let mut gained = HashMap::<String, ShellFocusTarget>::new();
+ for (client_id, before) in focus_before {
+ let after = self.shell_focus_target(client_id);
+ let (lost_target, gained_target) = classify_shell_focus_transition(
+ before.as_ref(),
+ after.as_ref(),
+ focused_tabs_before,
+ &focused_tabs_after,
+ );
+ if let Some(target) = lost_target {
+ lost.entry(target.tab_id.clone())
+ .or_insert_with(|| target.clone());
+ }
+ if let Some(target) = gained_target {
+ gained
+ .entry(target.tab_id.clone())
+ .or_insert_with(|| target.clone());
+ }
+ }
+ (lost, gained)
+ }
+
+ fn send_shell_focus_transitions(
+ &self,
+ lost: &HashMap<String, ShellFocusTarget>,
+ gained: &HashMap<String, ShellFocusTarget>,
+ ) {
+ for target in lost.values() {
+ self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Lost);
+ }
+ for target in gained.values() {
+ self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Gained);
+ }
+ }
+
+ pub(super) fn finish_shell_location_reconciliation(
+ &mut self,
+ focus_before: Vec<(u64, Option<ShellFocusTarget>)>,
+ focused_tabs_before: &HashSet<String>,
+ ) {
+ let (lost, gained) =
+ self.shell_location_focus_transitions(focus_before, focused_tabs_before);
+ self.app.accept_current_focus_without_events();
+ self.send_shell_focus_transitions(&lost, &gained);
+ }
+
+ pub(super) fn send_shell_navigation_focus_events(
+ &self,
+ before: Option<&ShellFocusTarget>,
+ after: Option<&ShellFocusTarget>,
+ focused_tabs_before: &HashSet<String>,
+ focused_tabs_after: &HashSet<String>,
+ ) {
+ let (lost, gained) =
+ classify_shell_focus_transition(before, after, focused_tabs_before, focused_tabs_after);
+ if let Some(target) = lost {
+ self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Lost);
+ }
+ if let Some(target) = gained {
+ self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Gained);
+ }
+ }
+
+ pub(super) fn shell_client_views_pane(
+ &self,
+ client_id: u64,
+ workspace_index: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> bool {
+ let Some(target) = self.shell_target_for_client(client_id) else {
+ return false;
+ };
+ if target.workspace_index != workspace_index {
+ return false;
+ }
+ let Some(tab) = self
+ .app
+ .state
+ .workspaces
+ .get(workspace_index)
+ .and_then(|workspace| workspace.tabs.get(target.tab_index))
+ else {
+ return false;
+ };
+ if tab.zoomed {
+ tab.layout.focused() == pane_id
+ } else {
+ tab.layout.pane_ids().contains(&pane_id)
+ }
+ }
+
+ fn finish_shell_tab_geometry_change(&mut self, start_pending_agent_resumes: bool) {
+ for client in self.clients.values_mut() {
+ client.request_repaint();
+ }
+ if !start_pending_agent_resumes {
+ self.app.pending_agent_resume_deadline = None;
+ return;
+ }
+ let now = Instant::now();
+ self.app.sync_pending_agent_resume_deadline(now);
+ if self
+ .app
+ .start_pending_agent_resumes(self.app.pending_agent_resume_due(now))
+ {
+ for client in self.clients.values_mut() {
+ client.request_repaint();
+ }
+ }
+ }
+
+ pub(super) fn apply_shell_tab_geometry(
+ &mut self,
+ client_id: u64,
+ start_pending_agent_resumes: bool,
+ ) -> bool {
+ let Some(target) = self.shell_target_for_client(client_id) else {
+ return false;
+ };
+ self.apply_shell_tab_geometry_to_target(client_id, target, start_pending_agent_resumes)
+ }
+
+ fn apply_shell_tab_geometry_to_target(
+ &mut self,
+ client_id: u64,
+ target: crate::ui::TabSurfaceTarget,
+ start_pending_agent_resumes: bool,
+ ) -> bool {
+ let Some(client) = self.clients.get(&client_id) else {
+ return false;
+ };
+ let (cols, rows) = client.terminal_size;
+ let cell_size = if client.cell_size.is_known() {
+ client.cell_size
+ } else {
+ crate::kitty_graphics::HostCellSize::default()
+ };
+ let area = Rect::new(0, 0, cols, rows);
+ if self.app_client_count() == 1 {
+ for (workspace_index, workspace) in self.app.state.workspaces.iter().enumerate() {
+ for tab_index in 0..workspace.tabs.len() {
+ crate::ui::resize_tab_surface(
+ &self.app.state,
+ &self.app.terminal_runtimes,
+ workspace_index,
+ tab_index,
+ area,
+ cell_size,
+ );
+ }
+ }
+ } else {
+ crate::ui::compute_tab_surface_for(
+ &self.app.state,
+ &self.app.terminal_runtimes,
+ Some(target),
+ area,
+ true,
+ cell_size,
+ );
+ }
+ if self
+ .popup_owner_tab_id
+ .as_deref()
+ .is_some_and(|owner| self.tab_id_for_target(target).as_deref() == Some(owner))
+ {
+ let _ = resize_popup_runtime(&self.app, Rect::new(0, 0, cols, rows), cell_size);
+ }
+ self.finish_shell_tab_geometry_change(start_pending_agent_resumes);
+ true
+ }
+
+ pub(super) fn resize_tabs_for_only_shell_client(
+ &mut self,
+ start_pending_agent_resumes: bool,
+ ) -> bool {
+ if self.app_client_count() != 1 {
+ return false;
+ }
+ let Some(client_id) = self.clients.iter().find_map(|(&client_id, client)| {
+ (client.is_shell_client() && client.writer.is_some()).then_some(client_id)
+ }) else {
+ return false;
+ };
+ self.apply_shell_tab_geometry(client_id, start_pending_agent_resumes)
+ }
+
+ pub(super) fn claim_shell_tab_geometry(
+ &mut self,
+ client_id: u64,
+ start_pending_agent_resumes: bool,
+ ) -> bool {
+ let Some(tab_id) = self.shell_tab_id_for_client(client_id) else {
+ return false;
+ };
+ if self.tab_geometry_controllers.insert(tab_id, client_id) == Some(client_id) {
+ return false;
+ }
+ self.apply_shell_tab_geometry(client_id, start_pending_agent_resumes)
+ }
+
+ pub(super) fn claim_unowned_shell_tab_geometry(
+ &mut self,
+ client_id: u64,
+ start_pending_agent_resumes: bool,
+ ) -> bool {
+ let Some(tab_id) = self.shell_tab_id_for_client(client_id) else {
+ return false;
+ };
+ if self.tab_geometry_controllers.contains_key(&tab_id) {
+ return false;
+ }
+ self.tab_geometry_controllers.insert(tab_id, client_id);
+ self.apply_shell_tab_geometry(client_id, start_pending_agent_resumes)
+ }
+
+ pub(super) fn resize_shell_tab_if_controller(
+ &mut self,
+ client_id: u64,
+ start_pending_agent_resumes: bool,
+ ) -> bool {
+ let Some(tab_id) = self.shell_tab_id_for_client(client_id) else {
+ return false;
+ };
+ if self.tab_geometry_controllers.get(&tab_id) != Some(&client_id) {
+ return false;
+ }
+ self.apply_shell_tab_geometry(client_id, start_pending_agent_resumes)
+ }
+
+ pub(super) fn shell_geometry_controller_for_terminal(
+ &self,
+ terminal_id: &str,
+ ) -> Option<(u64, crate::ui::TabSurfaceTarget)> {
+ let target = if self
+ .app
+ .state
+ .popup_pane
+ .as_ref()
+ .is_some_and(|popup| popup.terminal_id.as_str() == terminal_id)
+ {
+ self.popup_owner_tab_id
+ .as_deref()
+ .and_then(|tab_id| self.app.parse_tab_id(tab_id))
+ .map(|(workspace_index, tab_index)| crate::ui::TabSurfaceTarget {
+ workspace_index,
+ tab_index,
+ })?
+ } else {
+ self.app.state.workspaces.iter().enumerate().find_map(
+ |(workspace_index, workspace)| {
+ workspace
+ .tabs
+ .iter()
+ .enumerate()
+ .find_map(|(tab_index, tab)| {
+ tab.panes
+ .values()
+ .any(|pane| pane.attached_terminal_id.as_str() == terminal_id)
+ .then_some(crate::ui::TabSurfaceTarget {
+ workspace_index,
+ tab_index,
+ })
+ })
+ },
+ )?
+ };
+ let tab_id = self.tab_id_for_target(target)?;
+ self.tab_geometry_controllers
+ .get(&tab_id)
+ .copied()
+ .map(|client_id| (client_id, target))
+ }
+
+ pub(super) fn restore_shell_tab_geometry(
+ &mut self,
+ client_id: u64,
+ target: crate::ui::TabSurfaceTarget,
+ ) -> bool {
+ self.apply_shell_tab_geometry_to_target(client_id, target, true)
+ }
+
+ /// Applies a public socket request, including its session-wide focus projection.
+ pub(super) fn handle_api_request_with_shutdown_check(
+ &mut self,
+ mut msg: api::ApiRequestMessage,
+ ) -> bool {
+ let target_before = self.default_shell_target();
+ let popup_before = self.app.state.popup_pane.is_some();
+ let explicit_public_focus_target = match &msg.request.method {
+ api::schema::Method::WorkspaceFocus(params) => self
+ .app
+ .parse_workspace_id(&params.workspace_id)
+ .and_then(|workspace_index| {
+ let workspace = self.app.state.workspaces.get(workspace_index)?;
+ Some(crate::ui::TabSurfaceTarget {
+ workspace_index,
+ tab_index: workspace.active_tab_index(),
+ })
+ }),
+ api::schema::Method::TabFocus(params) => {
+ self.app
+ .parse_tab_id(&params.tab_id)
+ .map(|(workspace_index, tab_index)| crate::ui::TabSurfaceTarget {
+ workspace_index,
+ tab_index,
+ })
+ }
+ api::schema::Method::PaneFocus(params) => self
+ .app
+ .parse_pane_id(&params.pane_id)
+ .and_then(|(workspace_index, pane_id)| {
+ let workspace = self.app.state.workspaces.get(workspace_index)?;
+ Some(crate::ui::TabSurfaceTarget {
+ workspace_index,
+ tab_index: workspace.find_tab_index_for_pane(pane_id)?,
+ })
+ }),
+ _ => None,
+ };
+ let create_focus_requested = match &msg.request.method {
+ api::schema::Method::WorkspaceCreate(params) => params.focus,
+ api::schema::Method::TabCreate(params) => params.focus,
+ _ => false,
+ };
+ let inspect_worktree_open = matches!(
+ &msg.request.method,
+ api::schema::Method::WorktreeOpen(params) if params.focus
+ );
+ let response_proxy = inspect_worktree_open.then(|| {
+ let (proxy_tx, proxy_rx) = std::sync::mpsc::channel();
+ let original = std::mem::replace(&mut msg.respond_to, proxy_tx);
+ (original, proxy_rx)
+ });
+ let reconcile = Self::shell_locations_may_need_reconcile(&msg.request.method);
+ let changed = self.handle_api_request_with_shutdown_check_inner(msg, false);
+ let worktree_open_succeeded = forward_proxied_api_response(response_proxy);
+ let target_changed = self.default_shell_target() != target_before;
+ let public_focus_succeeded = explicit_public_focus_target
+ .is_some_and(|target| self.default_shell_target() == Some(target))
+ || (create_focus_requested && target_changed)
+ || worktree_open_succeeded;
+ if public_focus_succeeded {
+ self.focus_all_shell_clients_on_default_target();
+ }
+ if reconcile || target_changed || self.app.state.popup_pane.is_some() != popup_before {
+ self.reconcile_client_shell_locations();
+ }
+ changed
+ }
+
+ pub(super) fn handle_client_shell_api_request(
+ &mut self,
+ client_id: u64,
+ msg: api::ApiRequestMessage,
+ ) -> bool {
+ let focus_before = self.shell_focus_target(client_id);
+ let focused_tabs_before = self.focused_shell_tabs();
+ let method_claims_geometry = Self::shell_endpoint_claims_geometry(&msg.request.method);
+ let reconcile = Self::shell_locations_may_need_reconcile(&msg.request.method);
+ let all_focus_before = reconcile.then(|| self.shell_focus_targets());
+ let navigation_changed =
+ self.apply_shell_navigation_request(client_id, &msg.request.method);
+ self.set_default_shell_target_from_client(client_id);
+ let popup_before = self.app.state.popup_pane.is_some();
+ let popup_owner = self.shell_tab_id_for_client(client_id);
+ let changed = self.handle_api_request_with_shutdown_check_inner(msg, false);
+ self.focus_shell_client_on_default_target(client_id);
+ if !popup_before && self.app.state.popup_pane.is_some() {
+ self.popup_owner_tab_id = popup_owner;
+ }
+ if reconcile || self.app.state.popup_pane.is_some() != popup_before {
+ self.reconcile_client_shell_locations();
+ }
+ if let Some(all_focus_before) = all_focus_before {
+ self.finish_shell_location_reconciliation(all_focus_before, &focused_tabs_before);
+ } else {
+ let focus_after = self.shell_focus_target(client_id);
+ let focused_tabs_after = self.focused_shell_tabs();
+ self.app.accept_current_focus_without_events();
+ self.send_shell_navigation_focus_events(
+ focus_before.as_ref(),
+ focus_after.as_ref(),
+ &focused_tabs_before,
+ &focused_tabs_after,
+ );
+ }
+ let geometry_changed = method_claims_geometry
+ && (self.claim_shell_tab_geometry(client_id, false)
+ || self.resize_shell_tab_if_controller(client_id, false));
+ changed | navigation_changed | geometry_changed
+ }
+}
diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs
index 3587b5cf..92020d63 100644
--- a/src/server/headless/notifications.rs
+++ b/src/server/headless/notifications.rs
@@ -320,7 +320,20 @@ impl HeadlessServer {
/// 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 {
+ pub(super) fn handle_internal_event_with_forwarding(&mut self, mut ev: AppEvent) -> bool {
+ let mut focused_worktree_response = if let AppEvent::WorktreeAddFinished(result) = &mut ev {
+ result
+ .api_request
+ .as_mut()
+ .filter(|request| request.focus)
+ .map(|request| {
+ let (proxy_tx, proxy_rx) = std::sync::mpsc::channel();
+ let original = std::mem::replace(&mut request.respond_to, proxy_tx);
+ (original, proxy_rx)
+ })
+ } else {
+ None
+ };
match &ev {
AppEvent::TerminalBell { pane_id, count } => {
if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) {
@@ -592,7 +605,39 @@ impl HeadlessServer {
true
}
+ AppEvent::WorktreeAddFinished(result) => {
+ let deferred_request_id = result
+ .api_request
+ .as_ref()
+ .map(|request| request.id.as_str());
+ let shell_navigation_pending = deferred_request_id.is_some_and(|request_id| {
+ self.clients.values().any(|client| {
+ client.shell_deferred_navigation_request_id.as_deref() == Some(request_id)
+ })
+ });
+ let changed = self.app.handle_internal_event_with_render_impact(ev);
+ let api_focus_succeeded = super::client_views::forward_proxied_api_response(
+ focused_worktree_response.take(),
+ );
+ self.reconcile_client_shell_locations();
+ if shell_navigation_pending {
+ self.app.accept_current_focus_without_events();
+ } else if api_focus_succeeded {
+ self.focus_all_shell_clients_on_default_target();
+ }
+ changed
+ }
+ AppEvent::WorktreeRemoveFinished(_) => {
+ let focus_before = self.shell_focus_targets();
+ let focused_tabs_before = self.focused_shell_tabs();
+ let changed = self.app.handle_internal_event_with_render_impact(ev);
+ self.reconcile_client_shell_locations();
+ self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before);
+ changed
+ }
AppEvent::PaneDied { pane_id } => {
+ let focus_before = self.shell_focus_targets();
+ let focused_tabs_before = self.focused_shell_tabs();
let pane_id_val = *pane_id;
let terminal_id = self.app.state.workspaces.iter().find_map(|ws| {
ws.tabs.iter().find_map(|tab| {
@@ -612,6 +657,8 @@ impl HeadlessServer {
}
self.app.handle_internal_event(ev);
+ self.reconcile_client_shell_locations();
+ self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before);
if self.app.find_pane(pane_id_val).is_none() {
if let Some(terminal_id) = terminal_id {
diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs
index 19581abc..991d1861 100644
--- a/src/server/headless/render.rs
+++ b/src/server/headless/render.rs
@@ -1,55 +1,58 @@
use super::*;
impl HeadlessServer {
- fn focused_pane_graphics_demand(&self) -> bool {
+ fn shell_focused_runtime(
+ &self,
+ client_id: u64,
+ ) -> Option<(&crate::terminal::TerminalRuntime, crate::layout::PaneId)> {
+ if self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id) {
+ if let Some(popup) = &self.app.state.popup_pane {
+ return self
+ .app
+ .terminal_runtimes
+ .get(&popup.terminal_id)
+ .map(|runtime| (runtime, popup.pane_id));
+ }
+ }
+ let target = self.shell_target_for_client(client_id)?;
+ let tab = self
+ .app
+ .state
+ .workspaces
+ .get(target.workspace_index)?
+ .tabs
+ .get(target.tab_index)?;
+ let pane_id = tab.layout.focused();
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))
+ .runtime_for_pane_in_workspace(
+ &self.app.terminal_runtimes,
+ target.workspace_index,
+ pane_id,
+ )
+ .map(|runtime| (runtime, 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::ClientShell => {
+ let focused = self.shell_focused_runtime(client_id);
+ let child_requests_mouse =
+ focused.is_some_and(|(runtime, _)| runtime.mouse_reporting_enabled());
+ let sgr_pixels = client.pixel_mouse
+ && focused.is_some_and(|(runtime, pane_id)| {
+ self.app.pane_graphics.active_for_pane(pane_id)
+ && runtime.sgr_pixel_mouse_enabled()
+ });
+ Some((
+ client_id,
+ client.shell_mouse_capture || child_requests_mouse,
+ sgr_pixels,
+ ))
+ }
ClientConnectionMode::TerminalAttach { terminal_id } => {
let runtime = self.runtime_for_terminal_id_string(terminal_id);
let child_requests_mouse = runtime
@@ -106,45 +109,49 @@ impl HeadlessServer {
}
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 shell_modes = self
+ .clients
+ .iter()
+ .filter(|(_, client)| client.is_shell_client())
+ .map(|(&client_id, _)| {
+ let report_all =
+ self.shell_focused_runtime(client_id)
+ .is_some_and(|(runtime, _)| {
+ let protocol = runtime.keyboard_protocol();
+ protocol.reports_all_keys()
+ || (protocol.reports_event_types()
+ && runtime.modify_other_keys_level() > 0)
+ });
+ (client_id, report_all)
})
- };
- let serialized_shell =
- Self::frame_server_message(&ServerMessage::ClientShellKeyboardReportAll {
- enabled: shell_report_all,
- });
+ .collect::<Vec<_>>();
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)
- {
+ for (client_id, report_all) in shell_modes {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ continue;
+ };
+ if client.host_keyboard_report_all_active == Some(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;
+ let serialized = match Self::frame_server_message(
+ &ServerMessage::ClientShellKeyboardReportAll {
+ enabled: report_all,
+ },
+ ) {
+ Ok(serialized) => serialized,
+ Err(err) => {
+ warn!(err = %err, "failed to serialize client shell keyboard report-all mode");
+ continue;
+ }
};
- if writer.control.send(serialized.clone()).is_err() {
+ if writer.control.send(serialized).is_err() {
broken_clients.push(client_id);
continue;
}
- client.host_keyboard_report_all_active = Some(shell_report_all);
+ client.host_keyboard_report_all_active = Some(report_all);
}
let requested = self
@@ -214,11 +221,36 @@ impl HeadlessServer {
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()
- };
+ let mut pane_ids = HashSet::new();
+ if has_app_target {
+ for (&client_id, client) in &self.clients {
+ if !client.is_shell_client() || client.writer.is_none() {
+ continue;
+ }
+ let Some(target) = self.shell_target_for_client(client_id) else {
+ continue;
+ };
+ let Some(tab) = self
+ .app
+ .state
+ .workspaces
+ .get(target.workspace_index)
+ .and_then(|workspace| workspace.tabs.get(target.tab_index))
+ else {
+ continue;
+ };
+ if tab.zoomed {
+ pane_ids.insert(tab.layout.focused());
+ } else {
+ pane_ids.extend(tab.layout.pane_ids());
+ }
+ if self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id) {
+ if let Some(popup) = &self.app.state.popup_pane {
+ pane_ids.insert(popup.pane_id);
+ }
+ }
+ }
+ }
if !direct_terminal_targets.is_empty() {
for workspace in &self.app.state.workspaces {
for tab in &workspace.tabs {
@@ -265,7 +297,7 @@ impl HeadlessServer {
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)))
+ (has_app_target && (terminal_id.is_none() || self.any_shell_surface_contains_pane(pane_id)))
|| terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str()))
}
@@ -305,31 +337,34 @@ impl HeadlessServer {
.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 any_shell_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool {
+ self.clients.iter().any(|(&client_id, client)| {
+ if !client.is_shell_client() || client.writer.is_none() {
+ return false;
+ }
+ if self
+ .app
+ .state
+ .popup_pane
+ .as_ref()
+ .is_some_and(|popup| popup.pane_id == pane_id)
+ {
+ return self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id);
+ }
+ let Some(target) = self.shell_target_for_client(client_id) else {
+ return false;
+ };
+ let Some(tab) = self
+ .app
+ .state
+ .workspaces
+ .get(target.workspace_index)
+ .and_then(|workspace| workspace.tabs.get(target.tab_index))
+ else {
+ return false;
+ };
+ tab.panes.contains_key(&pane_id) && (!tab.zoomed || tab.layout.focused() == pane_id)
+ })
}
pub(super) fn render_and_stream(&mut self) {
@@ -362,21 +397,28 @@ impl HeadlessServer {
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 {
+ for (client_id, (cols, rows), cell_size, _is_foreground, mode) in render_targets {
let area = Rect::new(0, 0, cols, rows);
+ let shell_target = self.shell_target_for_client(client_id);
+ let shell_tab_id = self.shell_tab_id_for_client(client_id);
+ let shell_shows_popup = shell_tab_id.as_deref() == self.popup_owner_tab_id.as_deref();
let mut shell_projection_revision = 0;
if matches!(mode, ClientConnectionMode::ClientShell) {
+ let location = self
+ .clients
+ .get(&client_id)
+ .and_then(|client| client.shell_location.clone());
let Some(client) = self.clients.get_mut(&client_id) else {
continue;
};
- let Some(mut candidate) = shell_snapshot_template.clone() else {
- continue;
- };
+ let mut candidate = client_shell_snapshot(
+ &self.app,
+ &self.client_shell_boot_id,
+ client.shell_projection_revision,
+ None,
+ location.as_ref(),
+ );
candidate.config_diagnostic = if client.shell_uses_endpoint_keybindings {
self.server_config_diagnostic.clone()
} else {
@@ -438,8 +480,10 @@ impl HeadlessServer {
graphics_delivery: next_graphics_delivery,
} = render_client_shell_pane_surface(
&mut self.app,
+ shell_target,
area,
- is_foreground,
+ false,
+ shell_shows_popup,
render_cell_size,
&shell_graphics_delivery,
client_id,
diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs
index b15c4319..230704a6 100644
--- a/src/server/headless/retained_surface.rs
+++ b/src/server/headless/retained_surface.rs
@@ -292,14 +292,14 @@ impl HeadlessServer {
.parse_pane_id(&pane.pane_id)
.is_some_and(|(_, pane_id)| pane_id == *source)
}) else {
- fallback!("source_not_visible");
+ continue;
};
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");
+ continue;
};
let Some((workspace_index, pane_id)) = self.app.parse_pane_id(&public_pane_id) else {
fallback!("pane_missing");
@@ -355,7 +355,7 @@ impl HeadlessServer {
.iter_mut()
.find(|pane| pane.pane_id == collected_pane.pane_id)
else {
- fallback!("pane_missing");
+ continue;
};
// Alternate-screen transitions change whether the pane reserves
// a scrollbar gutter. Recompute layout and resize the runtime
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs
index 93c7420c..2fb6fd55 100644
--- a/src/server/headless/tests/mod.rs
+++ b/src/server/headless/tests/mod.rs
@@ -64,6 +64,8 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
+ tab_geometry_controllers: HashMap::new(),
+ popup_owner_tab_id: None,
client_shell_boot_id: "test-boot".into(),
sent_window_title: None,
api_window_title: None,
@@ -1052,6 +1054,57 @@ async fn different_size_shells_receive_geometry_specific_patches_from_one_dirty_
}
#[tokio::test]
+async fn retained_patches_only_reach_shells_viewing_the_dirty_tab() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("divergent-retained");
+ let first_pane = workspace.tabs[0].root_pane;
+ let second_tab = workspace.test_add_tab(Some("second"));
+ let second_pane = workspace.tabs[second_tab].root_pane;
+ workspace.insert_test_runtime(
+ first_pane,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"FIRST"),
+ );
+ workspace.insert_test_runtime(
+ second_pane,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"SECOND"),
+ );
+ 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 second_tab_id = server.app.public_tab_id(0, second_tab).unwrap();
+
+ let (first_control, first_render) = connect_matching_test_shell(&mut server, 7);
+ let (second_control, second_render) = connect_matching_test_shell(&mut server, 8);
+ let _ = first_control.recv().expect("first snapshot");
+ let _ = second_control.recv().expect("second snapshot");
+ assert!(server.focus_shell_client_on_tab(8, &second_tab_id));
+ assert!(server.claim_shell_tab_geometry(8, false));
+ assert!(
+ server.pty_sources_visible_to_any_render_target(&HashSet::from([first_pane, second_pane,]))
+ );
+ server.render_and_stream();
+ let _ = recv_pane_surface(&first_render, "first baseline");
+ let _ = recv_pane_surface(&second_render, "second baseline");
+
+ server.app.state.workspaces[0].test_runtimes[&first_pane]
+ .test_process_pty_bytes(b"\rFIRST_PATCH");
+ assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([first_pane])));
+ let first_patch = recv_pane_surface_patch(&first_render, "first patch");
+ assert_eq!(first_patch.panes.len(), 1);
+ assert!(second_render.try_recv().is_err());
+
+ server.app.state.workspaces[0].test_runtimes[&second_pane]
+ .test_process_pty_bytes(b"\rSECOND_PATCH");
+ assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([second_pane])));
+ let second_patch = recv_pane_surface_patch(&second_render, "second patch");
+ assert_eq!(second_patch.panes.len(), 1);
+ assert!(first_render.try_recv().is_err());
+
+ 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);
@@ -1200,7 +1253,542 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() {
}
#[tokio::test]
-async fn client_shell_replaces_projection_and_focuses_stable_ids() {
+async fn client_shell_tab_focus_changes_only_the_source_connection() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("independent-tabs");
+ let second_tab = workspace.test_add_tab(Some("second"));
+ 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;
+ let tab_ids = server
+ .app
+ .session_snapshot()
+ .tabs
+ .into_iter()
+ .map(|tab| tab.tab_id)
+ .collect::<Vec<_>>();
+ let first_tab_id = tab_ids[0].clone();
+ let second_tab_id = tab_ids[second_tab].clone();
+
+ let (first_control, _first_render) = connect_test_shell(&mut server, 7, 100, 30);
+ let (second_control, _second_render) = connect_test_shell(&mut server, 8, 80, 24);
+ let first_initial = client_shell_snapshot(read_server_message(
+ first_control.recv().expect("first snapshot"),
+ ));
+ let second_initial = client_shell_snapshot(read_server_message(
+ second_control.recv().expect("second snapshot"),
+ ));
+ assert_eq!(
+ first_initial.focused_tab_id.as_deref(),
+ Some(first_tab_id.as_str())
+ );
+ assert_eq!(
+ second_initial.focused_tab_id.as_deref(),
+ Some(first_tab_id.as_str())
+ );
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellEndpointRequest {
+ client_id: 8,
+ boot_id: server.client_shell_boot_id.clone(),
+ request: Box::new(api::schema::Request {
+ id: "focus-second".into(),
+ method: api::schema::Method::TabFocus(api::schema::TabTarget {
+ tab_id: second_tab_id.clone(),
+ }),
+ }),
+ })
+ );
+ let response_ready = server
+ .server_event_rx
+ .recv()
+ .await
+ .expect("focus response ready");
+ assert!(!server.handle_server_event(response_ready));
+ let _ = second_control.recv().expect("focus response");
+
+ server.render_and_stream();
+
+ assert!(
+ first_control.try_recv().is_err(),
+ "another shell must not receive a navigation replacement"
+ );
+ let second_replacement = client_shell_snapshot(read_server_message(
+ second_control.recv().expect("second replacement snapshot"),
+ ));
+ assert_eq!(
+ second_replacement.focused_tab_id.as_deref(),
+ Some(second_tab_id.as_str())
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn deferred_worktree_response_moves_only_its_source_client() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("deferred-worktree");
+ let created_tab = workspace.test_add_tab(Some("created"));
+ 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;
+ let created_tab_id = server.app.public_tab_id(0, created_tab).unwrap();
+
+ let (source_control, _) = connect_matching_test_shell(&mut server, 51);
+ let (other_control, _) = connect_matching_test_shell(&mut server, 52);
+ let _ = source_control.recv().expect("source snapshot");
+ let _ = other_control.recv().expect("other snapshot");
+ let original_tab_id = server.shell_tab_id_for_client(51).unwrap();
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
+ .shell_endpoint_command_in_flight = true;
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
+ .shell_deferred_navigation_response = Some(Vec::new());
+ let response = serde_json::json!({
+ "id": "create-worktree",
+ "result": {
+ "type": "worktree_created",
+ "tab": { "tab_id": created_tab_id }
+ }
+ });
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady {
+ client_id: 51,
+ boot_id: server.client_shell_boot_id.clone(),
+ request_id: "create-worktree".into(),
+ final_chunk: true,
+ data: serde_json::to_vec(&response).unwrap(),
+ })
+ );
+ assert_eq!(
+ server.shell_tab_id_for_client(51).as_deref(),
+ response
+ .pointer("/result/tab/tab_id")
+ .and_then(serde_json::Value::as_str)
+ );
+ assert_eq!(
+ server.shell_tab_id_for_client(52).as_deref(),
+ Some(original_tab_id.as_str())
+ );
+
+ assert!(server.focus_shell_client_on_tab(51, &original_tab_id));
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
+ .shell_endpoint_command_in_flight = true;
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
+ .shell_deferred_navigation_request_id = Some("background-worktree".into());
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady {
+ client_id: 51,
+ boot_id: server.client_shell_boot_id.clone(),
+ request_id: "background-worktree".into(),
+ final_chunk: true,
+ data: serde_json::to_vec(&response).unwrap(),
+ })
+ );
+ assert_eq!(
+ server.shell_tab_id_for_client(51).as_deref(),
+ Some(original_tab_id.as_str())
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_local_navigation_does_not_emit_global_focus_transitions() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("independent-focus");
+ let first_pane = workspace.tabs[0].root_pane;
+ let second_tab = workspace.test_add_tab(Some("second"));
+ let second_pane = workspace.tabs[second_tab].root_pane;
+ let (first_runtime, mut first_input) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1004h",
+ 4,
+ );
+ let (second_runtime, mut second_input) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1004h",
+ 4,
+ );
+ workspace.insert_test_runtime(first_pane, first_runtime);
+ workspace.insert_test_runtime(second_pane, second_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;
+ let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap();
+
+ let (first_control, _) = connect_matching_test_shell(&mut server, 61);
+ let (second_control, _) = connect_matching_test_shell(&mut server, 62);
+ let _ = first_control.recv().expect("first snapshot");
+ let _ = second_control.recv().expect("second snapshot");
+ assert!(server.focus_shell_client_on_tab(62, &second_tab_id));
+ server.clients.get_mut(&61).unwrap().outer_terminal_focus = Some(true);
+ server.clients.get_mut(&62).unwrap().outer_terminal_focus = Some(true);
+
+ let (respond_to, _response_rx) = std::sync::mpsc::channel();
+ server.handle_client_shell_api_request(
+ 62,
+ crate::api::ApiRequestMessage {
+ request: crate::api::schema::Request {
+ id: "focus-own-tab".into(),
+ method: crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget {
+ tab_id: second_tab_id,
+ }),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ },
+ );
+ server.app.sync_focus_events();
+ assert!(first_input.try_recv().is_err());
+ assert!(second_input.try_recv().is_err());
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn public_focus_moves_shell_focus_between_tabs() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("public-focus-events");
+ let first_pane = workspace.tabs[0].root_pane;
+ let second_tab = workspace.test_add_tab(Some("second"));
+ let second_pane = workspace.tabs[second_tab].root_pane;
+ let (first_runtime, mut first_input) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1004h",
+ 4,
+ );
+ let (second_runtime, mut second_input) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1004h",
+ 4,
+ );
+ workspace.insert_test_runtime(first_pane, first_runtime);
+ workspace.insert_test_runtime(second_pane, second_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;
+ let second_tab_id = server
+ .app
+ .public_tab_id(0, second_tab)
+ .expect("second tab id");
+
+ let (first_control, _) = connect_matching_test_shell(&mut server, 63);
+ let (second_control, _) = connect_matching_test_shell(&mut server, 64);
+ let _ = first_control.recv().expect("first snapshot");
+ let _ = second_control.recv().expect("second snapshot");
+ assert!(server.focus_shell_client_on_tab(64, &second_tab_id));
+ server.clients.get_mut(&63).unwrap().outer_terminal_focus = Some(true);
+ server.clients.get_mut(&64).unwrap().outer_terminal_focus = Some(true);
+ assert!(server.app.state.switch_workspace_tab(0, second_tab));
+
+ server.focus_all_shell_clients_on_default_target();
+
+ assert_eq!(
+ server.shell_tab_id_for_client(63).as_deref(),
+ Some(second_tab_id.as_str())
+ );
+ assert_eq!(
+ first_input.try_recv().expect("previous tab focus lost"),
+ Bytes::from_static(b"\x1b[O")
+ );
+ assert!(
+ second_input.try_recv().is_err(),
+ "focus gain was duplicated"
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn repeated_layout_action_reapplies_controller_geometry() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("layout-geometry");
+ let first_pane = workspace.tabs[0].root_pane;
+ let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
+ workspace.insert_test_runtime(
+ first_pane,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
+ );
+ workspace.insert_test_runtime(
+ second_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;
+ let tab_id = server.app.public_tab_id(0, 0).expect("tab id");
+
+ let (control, _) = connect_test_shell(&mut server, 65, 100, 30);
+ let _ = control.recv().expect("snapshot");
+ let before = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size();
+ let (respond_to, _response_rx) = std::sync::mpsc::channel();
+
+ assert!(server.handle_client_shell_api_request(
+ 65,
+ crate::api::ApiRequestMessage {
+ request: crate::api::schema::Request {
+ id: "resize-layout".into(),
+ method: crate::api::schema::Method::LayoutSetSplitRatio(
+ crate::api::schema::LayoutSetSplitRatioParams {
+ tab_id: Some(tab_id),
+ pane_id: None,
+ path: Vec::new(),
+ ratio: 0.8,
+ },
+ ),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ },
+ ));
+
+ let after = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size();
+ assert_ne!(after, before);
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn client_shell_tabs_render_accept_input_and_resize_independently() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("independent-geometry");
+ let first_pane = workspace.tabs[0].root_pane;
+ let second_tab = workspace.test_add_tab(Some("second"));
+ let second_pane = workspace.tabs[second_tab].root_pane;
+ workspace.insert_test_runtime(
+ first_pane,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"FIRST_TAB"),
+ );
+ let (second_runtime, mut second_input) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"SECOND_TAB",
+ 4,
+ );
+ workspace.insert_test_runtime(second_pane, second_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;
+ let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap();
+ let second_pane_id = server.app.public_pane_id(0, second_pane).unwrap();
+ let initial_second_size =
+ server.app.state.workspaces[0].test_runtimes[&second_pane].current_size();
+
+ let (first_control, first_render) = connect_test_shell(&mut server, 21, 100, 30);
+ let _ = first_control.recv().expect("first snapshot");
+ let first_size = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size();
+ let singleton_second_size =
+ server.app.state.workspaces[0].test_runtimes[&second_pane].current_size();
+ assert_ne!(singleton_second_size, initial_second_size);
+ assert_eq!(singleton_second_size, first_size);
+
+ let (second_control, second_render) = connect_test_shell(&mut server, 22, 70, 20);
+ let _ = second_control.recv().expect("second snapshot");
+
+ assert!(server.focus_shell_client_on_tab(22, &second_tab_id));
+ assert!(server.claim_shell_tab_geometry(22, false));
+ let second_size = server.app.state.workspaces[0].test_runtimes[&second_pane].current_size();
+ assert_ne!(first_size, second_size);
+ assert_eq!(
+ server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(),
+ first_size
+ );
+
+ server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 22,
+ pane_id: second_pane_id,
+ events: vec![crate::protocol::ClientPaneInputEvent::TextCommit(
+ "typed".into(),
+ )],
+ });
+ assert_eq!(
+ second_input.try_recv().expect("second tab input"),
+ Bytes::from_static(b"typed")
+ );
+
+ server.render_and_stream();
+ let first_surface = match read_server_message(first_render.recv().expect("first surface")) {
+ ServerMessage::PaneSurface(surface) => surface,
+ other => panic!("expected first pane surface, got {other:?}"),
+ };
+ let second_surface = match read_server_message(second_render.recv().expect("second surface")) {
+ ServerMessage::PaneSurface(surface) => surface,
+ other => panic!("expected second pane surface, got {other:?}"),
+ };
+ assert!(frame_text(&first_surface.frame).contains("FIRST_TAB"));
+ assert!(frame_text(&second_surface.frame).contains("SECOND_TAB"));
+
+ assert!(server.handle_server_event(ServerEvent::ClientShellResize {
+ client_id: 22,
+ surface_cols: 60,
+ surface_rows: 16,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ }));
+ let resized_second = server.app.state.workspaces[0].test_runtimes[&second_pane].current_size();
+ assert_ne!(resized_second, second_size);
+ assert_eq!(
+ server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(),
+ first_size
+ );
+
+ assert!(server.focus_shell_client_on_tab(21, &second_tab_id));
+ assert!(server.claim_shell_tab_geometry(21, false));
+ assert_ne!(
+ server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(),
+ resized_second
+ );
+
+ server.remove_client_and_resize_if_needed(21);
+ let singleton_first = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size();
+ let singleton_second =
+ server.app.state.workspaces[0].test_runtimes[&second_pane].current_size();
+ assert_ne!(singleton_first, first_size);
+ assert_eq!(singleton_first, singleton_second);
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn public_background_tab_create_preserves_client_locations() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("background-create");
+ let second_tab = workspace.test_add_tab(Some("second"));
+ 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;
+ let workspace_id = server.app.public_workspace_id(0);
+ let first_tab_id = server.app.public_tab_id(0, 0).unwrap();
+ let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap();
+
+ let (first_control, _) = connect_matching_test_shell(&mut server, 71);
+ let (second_control, _) = connect_matching_test_shell(&mut server, 72);
+ let _ = first_control.recv().expect("first snapshot");
+ let _ = second_control.recv().expect("second snapshot");
+ assert!(server.focus_shell_client_on_tab(71, &second_tab_id));
+
+ let (respond_to, _response_rx) = std::sync::mpsc::channel();
+ server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage {
+ request: crate::api::schema::Request {
+ id: "create-background-tab".into(),
+ method: crate::api::schema::Method::TabCreate(crate::api::schema::TabCreateParams {
+ workspace_id: Some(workspace_id),
+ cwd: None,
+ focus: false,
+ label: Some("background".into()),
+ env: std::collections::HashMap::new(),
+ }),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+
+ assert_eq!(
+ server.shell_tab_id_for_client(71).as_deref(),
+ Some(second_tab_id.as_str())
+ );
+ assert_eq!(
+ server.shell_tab_id_for_client(72).as_deref(),
+ Some(first_tab_id.as_str())
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn public_workspace_focus_preserves_each_clients_remembered_tabs() {
+ let mut server = test_headless_server();
+ let mut first = crate::workspace::Workspace::test_new("first");
+ let second_tab = first.test_add_tab(Some("second"));
+ 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 first_workspace_id = server.app.public_workspace_id(0);
+ let second_workspace_id = server.app.public_workspace_id(1);
+ let first_tab_id = server.app.public_tab_id(0, 0).unwrap();
+ let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap();
+
+ let (first_control, _) = connect_test_shell(&mut server, 41, 100, 30);
+ let (second_control, _) = connect_test_shell(&mut server, 42, 80, 24);
+ let _ = first_control.recv().expect("first snapshot");
+ let _ = second_control.recv().expect("second snapshot");
+ assert!(server.focus_shell_client_on_tab(41, &second_tab_id));
+
+ let (respond_to, _response_rx) = std::sync::mpsc::channel();
+ server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage {
+ request: crate::api::schema::Request {
+ id: "focus-second-workspace".into(),
+ method: crate::api::schema::Method::WorkspaceFocus(
+ crate::api::schema::WorkspaceTarget {
+ workspace_id: second_workspace_id.clone(),
+ },
+ ),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+
+ let first_location = server.clients[&41].shell_location.as_ref().unwrap();
+ let second_location = server.clients[&42].shell_location.as_ref().unwrap();
+ assert_eq!(
+ first_location.focused_workspace_id.as_deref(),
+ Some(second_workspace_id.as_str())
+ );
+ assert_eq!(
+ second_location.focused_workspace_id.as_deref(),
+ Some(second_workspace_id.as_str())
+ );
+ assert_eq!(
+ first_location.active_tab_ids[&first_workspace_id],
+ second_tab_id
+ );
+ assert_eq!(
+ second_location.active_tab_ids[&first_workspace_id],
+ first_tab_id
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn public_api_focus_replaces_every_client_shell_projection() {
let mut server = test_headless_server();
let first = crate::workspace::Workspace::test_new("first");
let second = crate::workspace::Workspace::test_new("second");
@@ -1233,11 +1821,19 @@ async fn client_shell_replaces_projection_and_focuses_stable_ids() {
))
.revision;
- 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(),
- }),
+ let (respond_to, _response_rx) = std::sync::mpsc::channel();
+ server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage {
+ 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(),
+ },
+ ),
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
});
assert_eq!(server.app.state.active, Some(1));
server.render_and_stream();
@@ -1483,6 +2079,7 @@ async fn client_shell_streams_and_targets_popup_terminal_content() {
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!(popup.frame.cursor.is_some());
assert_eq!(surface.graphics.assets.len(), 1);
assert_eq!(surface.graphics.placements.len(), 1);
assert!(matches!(
@@ -1574,6 +2171,152 @@ async fn client_shell_streams_and_targets_popup_terminal_content() {
}
#[tokio::test]
+async fn terminal_popup_is_visible_and_modal_only_on_its_owning_tab() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("tab-popup");
+ let first_pane = workspace.tabs[0].root_pane;
+ let second_tab = workspace.test_add_tab(Some("second"));
+ let second_pane = workspace.tabs[second_tab].root_pane;
+ workspace.insert_test_runtime(
+ first_pane,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"FIRST"),
+ );
+ let (second_runtime, mut second_input) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80, 23, 0, b"SECOND", 4,
+ );
+ workspace.insert_test_runtime(second_pane, second_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;
+ let first_tab_id = server.app.public_tab_id(0, 0).unwrap();
+ let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap();
+ let second_pane_id = server.app.public_pane_id(0, second_pane).unwrap();
+
+ let (first_control, first_render) = connect_matching_test_shell(&mut server, 31);
+ let (second_control, second_render) = connect_matching_test_shell(&mut server, 32);
+ let _ = first_control.recv().expect("first snapshot");
+ let _ = second_control.recv().expect("second snapshot");
+ assert!(server.focus_shell_client_on_tab(32, &second_tab_id));
+ assert!(server.claim_shell_tab_geometry(32, false));
+
+ let (popup_runtime, mut popup_input) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 40,
+ 12,
+ 0,
+ b"POPUP\x1b[>3u",
+ 4,
+ );
+ let (_, popup_terminal_id) = server.app.install_test_popup_runtime(popup_runtime);
+ server.popup_owner_tab_id = Some(first_tab_id);
+ assert!(server.apply_shell_tab_geometry(31, false));
+ let popup_size = server
+ .app
+ .terminal_runtimes
+ .get(&popup_terminal_id)
+ .unwrap()
+ .current_size();
+ connect_pending_terminal_client(&mut server, 33);
+ assert!(
+ server.handle_server_event(ServerEvent::ClientAttachTerminal {
+ client_id: 33,
+ terminal_id: popup_terminal_id.to_string(),
+ takeover: false,
+ })
+ );
+ assert_ne!(
+ server
+ .app
+ .terminal_runtimes
+ .get(&popup_terminal_id)
+ .unwrap()
+ .current_size(),
+ popup_size
+ );
+ assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 33 }));
+ assert_eq!(
+ server
+ .app
+ .terminal_runtimes
+ .get(&popup_terminal_id)
+ .unwrap()
+ .current_size(),
+ popup_size
+ );
+ server.render_and_stream();
+ assert!(recv_pane_surface(&first_render, "popup owner surface")
+ .popup
+ .is_some());
+ assert!(recv_pane_surface(&second_render, "other tab surface")
+ .popup
+ .is_none());
+
+ server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 32,
+ pane_id: second_pane_id,
+ events: vec![crate::protocol::ClientPaneInputEvent::TextCommit(
+ "typed".into(),
+ )],
+ });
+ assert_eq!(
+ second_input.try_recv().expect("other tab input"),
+ Bytes::from_static(b"typed")
+ );
+
+ let popup_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".into()),
+ tracks_release: true,
+ physical_key_id: Some(0x2d),
+ windows_record: None,
+ };
+ server.handle_server_event(ServerEvent::ClientShellPopupInput {
+ client_id: 31,
+ terminal_id: popup_terminal_id.to_string(),
+ events: vec![popup_key(crate::protocol::ClientKeyKind::Press)],
+ });
+ assert!(
+ !tokio::time::timeout(Duration::from_secs(1), popup_input.recv())
+ .await
+ .expect("popup press timed out")
+ .expect("popup press")
+ .is_empty()
+ );
+ assert!(server.focus_shell_client_on_tab(31, &second_tab_id));
+ server.handle_server_event(ServerEvent::ClientShellPopupInput {
+ client_id: 31,
+ terminal_id: popup_terminal_id.to_string(),
+ events: vec![popup_key(crate::protocol::ClientKeyKind::Release)],
+ });
+ assert!(
+ !tokio::time::timeout(Duration::from_secs(1), popup_input.recv())
+ .await
+ .expect("popup release timed out")
+ .expect("popup release after navigation")
+ .is_empty()
+ );
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientShellPopupInput {
+ client_id: 32,
+ terminal_id: popup_terminal_id.to_string(),
+ events: vec![crate::protocol::ClientPaneInputEvent::TextCommit(
+ "wrong".into()
+ )],
+ })
+ );
+ assert!(popup_input.try_recv().is_err());
+
+ 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");
@@ -1724,6 +2467,7 @@ async fn client_shell_mouse_motion_delivers_without_render_when_foreground() {
),
);
server.foreground_client_id = Some(11);
+ assert!(server.claim_unowned_shell_tab_geometry(11, false));
let render_impact =
server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput {
@@ -1839,7 +2583,7 @@ fn retained_test_server(
);
server.foreground_client_id = Some(1);
server.sync_foreground_client_state();
- server.resize_shared_runtime_to_effective_size();
+ assert!(server.claim_unowned_shell_tab_geometry(1, true));
(server, client_rx, pane_id)
}
@@ -2150,7 +2894,8 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() {
.expect("test runtime");
let _runtime_guard = rt.enter();
let mut server = test_headless_server();
- let workspace = crate::workspace::Workspace::test_new("test");
+ let mut workspace = crate::workspace::Workspace::test_new("test");
+ let second_tab = workspace.test_add_tab(Some("second"));
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();
@@ -2158,6 +2903,10 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() {
server.app.state.ensure_test_terminals();
server.app.state.active = Some(0);
server.app.state.selected = 0;
+ let second_tab_id = server
+ .app
+ .public_tab_id(0, second_tab)
+ .expect("second tab id");
server.app.terminal_runtimes.insert(
terminal_id.clone(),
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
@@ -2174,7 +2923,8 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() {
);
server.foreground_client_id = Some(1);
server.sync_foreground_client_state();
- server.resize_shared_runtime_to_effective_size();
+ server.reconcile_client_shell_locations();
+ assert!(server.claim_unowned_shell_tab_geometry(1, true));
let expected_shell_size = server
.app
.terminal_runtimes
@@ -2206,6 +2956,7 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() {
(30, 100)
);
+ assert!(server.focus_shell_client_on_tab(1, &second_tab_id));
assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 }));
assert!(!server
.app
@@ -2659,6 +3410,63 @@ fn changed_git_refresh_requests_headless_render() {
assert!(changed);
}
+#[tokio::test]
+async fn pane_death_reconciles_each_client_view_and_focus() {
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("pane-death-views");
+ let dead_pane = workspace.tabs[0].root_pane;
+ let second_tab = workspace.test_add_tab(Some("second"));
+ let second_pane = workspace.tabs[second_tab].root_pane;
+ let (second_runtime, mut second_input) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1004h",
+ 4,
+ );
+ workspace.insert_test_runtime(second_pane, second_runtime);
+ 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;
+ let second_tab_id = server
+ .app
+ .public_tab_id(0, second_tab)
+ .expect("second tab id");
+
+ let (first_control, _) = connect_matching_test_shell(&mut server, 71);
+ let (second_control, _) = connect_matching_test_shell(&mut server, 72);
+ let _ = first_control.recv().expect("first snapshot");
+ let _ = second_control.recv().expect("second snapshot");
+ assert!(server.focus_shell_client_on_tab(72, &second_tab_id));
+ server.clients.get_mut(&71).unwrap().outer_terminal_focus = Some(true);
+ server.clients.get_mut(&72).unwrap().outer_terminal_focus = Some(false);
+
+ assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id: dead_pane }));
+
+ assert_eq!(
+ server.shell_tab_id_for_client(71).as_deref(),
+ Some(second_tab_id.as_str())
+ );
+ assert_eq!(
+ server.shell_tab_id_for_client(72).as_deref(),
+ Some(second_tab_id.as_str())
+ );
+ assert_eq!(
+ second_input
+ .try_recv()
+ .expect("fallback focus gained input"),
+ Bytes::from_static(b"\x1b[I")
+ );
+ assert!(
+ second_input.try_recv().is_err(),
+ "focus gain was duplicated"
+ );
+ shutdown_test_runtimes(&mut server);
+}
+
#[test]
fn terminal_attach_client_exits_when_attached_pane_dies() {
let mut server = test_headless_server();
@@ -3515,7 +4323,7 @@ fn pending_terminal_resize_does_not_take_shell_foreground_or_geometry() {
),
);
server.foreground_client_id = Some(1);
- server.resize_shared_runtime_to_effective_size();
+ server.sync_foreground_client_state();
let shell_size = server.effective_size;
assert!(server.handle_server_event(ServerEvent::ClientResize {
@@ -3712,7 +4520,7 @@ fn client_shell_focus_promotes_and_reaches_reporting_pane() {
);
server.foreground_client_id = Some(2);
server.sync_foreground_client_state();
- server.resize_shared_runtime_to_effective_size();
+ assert!(server.claim_unowned_shell_tab_geometry(2, true));
assert_eq!(
server
.app
@@ -3744,12 +4552,28 @@ fn client_shell_focus_promotes_and_reaches_reporting_pane() {
);
assert!(server.handle_server_event(ServerEvent::ClientShellFocus {
+ client_id: 2,
+ focused: true,
+ }));
+ assert!(
+ input_rx.try_recv().is_err(),
+ "second viewer duplicated focus gain"
+ );
+ assert!(server.handle_server_event(ServerEvent::ClientShellFocus {
client_id: 1,
focused: false,
}));
+ assert!(
+ input_rx.try_recv().is_err(),
+ "remaining viewer lost tab focus"
+ );
+ assert!(server.handle_server_event(ServerEvent::ClientShellFocus {
+ client_id: 2,
+ focused: false,
+ }));
assert_eq!(server.app.state.outer_terminal_focus, Some(false));
assert_eq!(
- input_rx.try_recv().expect("focus lost input"),
+ input_rx.try_recv().expect("last viewer focus lost input"),
Bytes::from_static(b"\x1b[O")
);
});
diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs
index ecdbc2b7..b5938d56 100644
--- a/src/server/headless/tests/pane_graphics.rs
+++ b/src/server/headless/tests/pane_graphics.rs
@@ -665,6 +665,10 @@ async fn client_shell_direct_graphics_uploads_without_server_authored_coordinate
&[],
&[],
None,
+ Some(crate::ui::TabSurfaceTarget {
+ workspace_index: 0,
+ tab_index: 0,
+ }),
crate::kitty_graphics::HostCellSize {
width_px: 10,
height_px: 20,
@@ -700,6 +704,10 @@ async fn client_shell_direct_graphics_uploads_without_server_authored_coordinate
&[],
&[],
None,
+ Some(crate::ui::TabSurfaceTarget {
+ workspace_index: 0,
+ tab_index: 0,
+ }),
crate::kitty_graphics::HostCellSize {
width_px: 10,
height_px: 20,