aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-02 14:24:33 +0300
committerGitHub <noreply@github.com>2026-09-02 14:24:33 +0300
commit6c0bb273d5d5405a00985621b17e36f8b4d64609 (patch)
treeceef5ea8adf337bf3c1e977b133e76cb763c6f1b /src/server
parentcc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (diff)
feat: support independent multi-client tab views (#3526)
Diffstat (limited to 'src/server')
-rw-r--r--src/server/client_commands.rs30
-rw-r--r--src/server/client_shell.rs143
-rw-r--r--src/server/client_shell_graphics.rs2
-rw-r--r--src/server/clients.rs77
-rw-r--r--src/server/headless.rs311
-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
-rw-r--r--src/server/render_scale_benchmark.rs13
-rw-r--r--src/server/render_stream.rs12
13 files changed, 2261 insertions, 287 deletions
diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs
index 86243e31..34be0519 100644
--- a/src/server/client_commands.rs
+++ b/src/server/client_commands.rs
@@ -92,6 +92,20 @@ pub(crate) fn error_message(
}
}
+fn correlate_response_id(response: String, request_id: &str) -> String {
+ let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&response) else {
+ return response;
+ };
+ let Some(id) = value.get_mut("id") else {
+ return response;
+ };
+ if id.as_str() == Some(request_id) {
+ return response;
+ }
+ *id = serde_json::Value::String(request_id.to_owned());
+ serde_json::to_string(&value).unwrap_or(response)
+}
+
pub(crate) fn spawn_response_waiter(
client_id: u64,
boot_id: String,
@@ -109,7 +123,7 @@ pub(crate) fn spawn_response_waiter(
"endpoint command ended without a response",
)
});
- let response = response.into_bytes();
+ let response = correlate_response_id(response, &request_id).into_bytes();
if response.is_empty() {
let _ = server_event_tx.blocking_send(
ServerEvent::ClientShellEndpointResponseChunkReady {
@@ -319,6 +333,20 @@ mod tests {
}
#[test]
+ fn endpoint_response_uses_the_client_request_id() {
+ let response = serde_json::json!({
+ "id": "endpoint:boot-a:7:client-shell:1",
+ "result": { "type": "ok" }
+ })
+ .to_string();
+
+ let correlated = correlate_response_id(response, "client-shell:1");
+ let decoded: serde_json::Value = serde_json::from_str(&correlated).expect("response json");
+
+ assert_eq!(decoded["id"], "client-shell:1");
+ }
+
+ #[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);
diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs
index 8f0a90ee..f511b641 100644
--- a/src/server/client_shell.rs
+++ b/src/server/client_shell.rs
@@ -8,8 +8,30 @@ pub(super) fn snapshot(
boot_id: &str,
revision: u64,
config_diagnostic: Option<&str>,
+ location: Option<&crate::server::clients::ClientShellLocation>,
) -> protocol::ClientShellSnapshot {
let snapshot = app.session_snapshot();
+ let focused_workspace_id = location
+ .and_then(|location| location.focused_workspace_id.clone())
+ .or_else(|| snapshot.focused_workspace_id.clone());
+ let focused_tab_id = location
+ .and_then(|location| location.focused_tab_id().map(str::to_owned))
+ .or_else(|| snapshot.focused_tab_id.clone());
+ let focused_pane_id = focused_tab_id
+ .as_deref()
+ .and_then(|tab_id| app.parse_tab_id(tab_id))
+ .and_then(|(workspace_index, tab_index)| {
+ let pane_id = app
+ .state
+ .workspaces
+ .get(workspace_index)?
+ .tabs
+ .get(tab_index)?
+ .layout
+ .focused();
+ app.public_pane_id(workspace_index, pane_id)
+ })
+ .or_else(|| snapshot.focused_pane_id.clone());
let workspaces = snapshot
.workspaces
.into_iter()
@@ -18,11 +40,22 @@ pub(super) fn snapshot(
.map(|(workspace_index, (workspace, state))| {
let mut tokens = workspace.tokens.into_iter().collect::<Vec<_>>();
tokens.sort_by(|left, right| left.0.cmp(&right.0));
+ let workspace_id = workspace.workspace_id;
+ let active_tab_id = location
+ .and_then(|location| location.active_tab_ids.get(&workspace_id))
+ .cloned()
+ .unwrap_or(workspace.active_tab_id);
+ let active_tab_index =
+ app.parse_tab_id(&active_tab_id)
+ .and_then(|(tab_workspace_index, tab_index)| {
+ (tab_workspace_index == workspace_index).then_some(tab_index)
+ });
protocol::ClientShellWorkspace {
- workspace_id: workspace.workspace_id,
- active_tab_id: workspace.active_tab_id,
+ focused: focused_workspace_id.as_deref() == Some(workspace_id.as_str()),
+ workspace_id,
+ active_tab_id,
new_workspace_cwd: app
- .resolved_new_workspace_cwd_from(workspace_index)
+ .resolved_new_workspace_cwd_from_tab(workspace_index, active_tab_index)
.display()
.to_string(),
number: workspace.number,
@@ -38,7 +71,6 @@ pub(super) fn snapshot(
label: worktree.repo_name,
is_linked_worktree: worktree.is_linked_worktree,
}),
- focused: workspace.focused,
agent_status: workspace.agent_status,
}
})
@@ -52,23 +84,28 @@ pub(super) fn snapshot(
.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,
+ .map(|(tab, state)| {
+ let tab_id = tab.tab_id;
+ protocol::ClientShellTab {
+ focused: focused_tab_id.as_deref() == Some(tab_id.as_str()),
+ tab_id,
+ workspace_id: tab.workspace_id,
+ number: tab.number,
+ label: tab.label,
+ custom_label: !state.is_auto_named(),
+ zoomed: state.zoomed,
+ agent_status: tab.agent_status,
+ }
})
.collect();
let panes = snapshot
.panes
.into_iter()
.map(|pane| {
+ let pane_id = pane.pane_id;
+ let focused = focused_pane_id.as_deref() == Some(pane_id.as_str());
let right_click_passthrough = app
- .parse_pane_id(&pane.pane_id)
+ .parse_pane_id(&pane_id)
.and_then(|(workspace_index, pane_id)| {
app.state
.workspaces
@@ -77,13 +114,13 @@ pub(super) fn snapshot(
})
.is_some_and(|pane| pane.right_click_passthrough);
protocol::ClientShellPane {
- pane_id: pane.pane_id,
+ 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,
+ focused,
right_click_passthrough,
}
})
@@ -92,12 +129,14 @@ pub(super) fn snapshot(
.agents
.into_iter()
.map(|agent| {
+ let pane_id = agent.pane_id;
+ let focused = focused_pane_id.as_deref() == Some(pane_id.as_str());
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,
+ pane_id,
workspace_id: agent.workspace_id,
tab_id: agent.tab_id,
name: agent.name,
@@ -110,7 +149,7 @@ pub(super) fn snapshot(
state_change_seq: agent.state_change_seq,
state_labels,
tokens,
- focused: agent.focused,
+ focused,
}
})
.collect();
@@ -125,11 +164,17 @@ pub(super) fn snapshot(
.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 zoomed = focused_tab_id
+ .as_deref()
+ .and_then(|tab_id| app.parse_tab_id(tab_id))
+ .and_then(|(workspace_index, tab_index)| {
+ app.state
+ .workspaces
+ .get(workspace_index)?
+ .tabs
+ .get(tab_index)
+ })
+ .is_some_and(|tab| tab.zoomed);
let tab_bar_right = app
.state
.tab_bar_right
@@ -183,9 +228,9 @@ pub(super) fn snapshot(
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,
+ focused_workspace_id,
+ focused_tab_id,
+ focused_pane_id,
tab_bar_right,
tab_bar_right_separator: app.state.tab_bar_right_separator.clone(),
agent_view_label,
@@ -209,18 +254,18 @@ pub(super) struct RenderedPaneSurface {
pub(super) fn render_pane_surface(
app: &mut app::App,
+ target: Option<crate::ui::TabSurfaceTarget>,
area: Rect,
- is_foreground: bool,
+ resize_panes: bool,
+ show_popup: 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)?;
+ let content_revisions_before = target
+ .and_then(|target| {
+ let workspace = app.state.workspaces.get(target.workspace_index)?;
+ let tab = workspace.tabs.get(target.tab_index)?;
Some(
tab.layout
.pane_ids()
@@ -229,7 +274,7 @@ pub(super) fn render_pane_surface(
app.state
.runtime_for_pane_in_workspace(
&app.terminal_runtimes,
- workspace_index,
+ target.workspace_index,
pane_id,
)
.map(|runtime| (pane_id, runtime.content_seq()))
@@ -242,14 +287,14 @@ pub(super) fn render_pane_surface(
crate::server::render_stream::render_tab_surface_virtual(
&app.state,
&app.terminal_runtimes,
+ target,
area,
- is_foreground,
+ resize_panes,
cell_size,
);
- let panes = app
- .state
- .active
- .map(|workspace_index| {
+ let panes = target
+ .map(|target| {
+ let workspace_index = target.workspace_index;
layout
.pane_infos
.iter()
@@ -340,12 +385,15 @@ pub(super) fn render_pane_surface(
})
})
.collect();
- let popup = render_popup_surface(app, area, is_foreground, cell_size);
+ let popup = show_popup
+ .then(|| render_popup_surface(app, area, resize_panes, cell_size))
+ .flatten();
let (graphics, next_graphics_delivery) = crate::server::client_shell_graphics::collect(
app,
&layout.pane_infos,
&layout.split_borders,
popup.as_deref(),
+ target,
cell_size,
graphics_delivery,
client_id,
@@ -363,22 +411,19 @@ pub(super) fn render_pane_surface(
fn render_popup_surface(
app: &app::App,
area: Rect,
- is_foreground: bool,
+ resize_runtime: bool,
cell_size: crate::kitty_graphics::HostCellSize,
) -> Option<Box<protocol::ClientShellPopupSurface>> {
let popup = app.state.popup_pane.as_ref()?;
- let geometry = if is_foreground {
+ let geometry = if resize_runtime {
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) =
+ let (buffer, 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
@@ -518,7 +563,7 @@ mod tests {
preview: true,
});
- let snapshot = snapshot(&app, "boot", 7, None);
+ let snapshot = snapshot(&app, "boot", 7, None, None);
assert_eq!(snapshot.update_available.as_deref(), Some("0.8.3"));
assert_eq!(snapshot.update_install_command, "herdr update");
@@ -554,11 +599,11 @@ mod tests {
state: crate::integration::IntegrationStatusKind::NotInstalled,
}];
- assert!(!snapshot(&app, "boot", 1, None).integration_updates_available);
+ assert!(!snapshot(&app, "boot", 1, None, None).integration_updates_available);
app.state.integration_recommendations[0].state =
crate::integration::IntegrationStatusKind::Outdated;
- assert!(snapshot(&app, "boot", 2, None).integration_updates_available);
+ assert!(snapshot(&app, "boot", 2, None, None).integration_updates_available);
}
#[test]
diff --git a/src/server/client_shell_graphics.rs b/src/server/client_shell_graphics.rs
index b16001cf..578350b2 100644
--- a/src/server/client_shell_graphics.rs
+++ b/src/server/client_shell_graphics.rs
@@ -6,6 +6,7 @@ pub(crate) fn collect(
pane_infos: &[crate::layout::PaneInfo],
split_borders: &[crate::layout::SplitBorder],
popup: Option<&ClientShellPopupSurface>,
+ target: Option<crate::ui::TabSurfaceTarget>,
cell_size: crate::kitty_graphics::HostCellSize,
delivered: &DeliveryCache,
client_id: u64,
@@ -14,6 +15,7 @@ pub(crate) fn collect(
crate::kitty_graphics::surface::collect_scene(
app,
crate::ui::TabSurfaceView {
+ target,
pane_infos,
split_borders,
},
diff --git a/src/server/clients.rs b/src/server/clients.rs
index 3860f826..4f1baadb 100644
--- a/src/server/clients.rs
+++ b/src/server/clients.rs
@@ -60,6 +60,75 @@ pub(crate) enum DeferredRender {
Full,
}
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub(crate) struct ClientShellLocation {
+ pub(crate) focused_workspace_id: Option<String>,
+ pub(crate) active_tab_ids: HashMap<String, String>,
+}
+
+pub(crate) struct ClientShellTopology {
+ pub(crate) focused_workspace_id: Option<String>,
+ pub(crate) fallback_workspace_id: Option<String>,
+ pub(crate) active_tab_ids: HashMap<String, String>,
+ pub(crate) tab_workspace_ids: HashMap<String, String>,
+}
+
+impl ClientShellLocation {
+ pub(crate) fn from_snapshot(snapshot: &crate::protocol::ClientShellSnapshot) -> Self {
+ Self {
+ focused_workspace_id: snapshot.focused_workspace_id.clone(),
+ active_tab_ids: snapshot
+ .workspaces
+ .iter()
+ .map(|workspace| {
+ (
+ workspace.workspace_id.clone(),
+ workspace.active_tab_id.clone(),
+ )
+ })
+ .collect(),
+ }
+ }
+
+ pub(crate) fn focused_tab_id(&self) -> Option<&str> {
+ self.focused_workspace_id
+ .as_deref()
+ .and_then(|workspace_id| self.active_tab_ids.get(workspace_id))
+ .map(String::as_str)
+ }
+
+ pub(crate) fn focus_workspace(&mut self, workspace_id: String) {
+ self.focused_workspace_id = Some(workspace_id);
+ }
+
+ pub(crate) fn focus_tab(&mut self, workspace_id: String, tab_id: String) {
+ self.focused_workspace_id = Some(workspace_id.clone());
+ self.active_tab_ids.insert(workspace_id, tab_id);
+ }
+
+ pub(crate) fn reconcile(&mut self, topology: &ClientShellTopology) {
+ self.active_tab_ids.retain(|workspace_id, tab_id| {
+ topology.active_tab_ids.contains_key(workspace_id)
+ && topology.tab_workspace_ids.get(tab_id) == Some(workspace_id)
+ });
+ for (workspace_id, tab_id) in &topology.active_tab_ids {
+ self.active_tab_ids
+ .entry(workspace_id.clone())
+ .or_insert_with(|| tab_id.clone());
+ }
+ if self
+ .focused_workspace_id
+ .as_ref()
+ .is_none_or(|workspace_id| !topology.active_tab_ids.contains_key(workspace_id))
+ {
+ self.focused_workspace_id = topology
+ .focused_workspace_id
+ .clone()
+ .or_else(|| topology.fallback_workspace_id.clone());
+ }
+ }
+}
+
/// A connected client tracked by the server.
pub(crate) struct ClientConnection {
/// Whether this connection owns the Herdr shell or one direct terminal stream.
@@ -102,12 +171,17 @@ pub(crate) struct ClientConnection {
shell_held_inputs: HashMap<ClientShellPressId, ClientShellHeldInput>,
/// Temporary files staged from this client's local clipboard image pastes.
pub(crate) staged_clipboard_files: Vec<PathBuf>,
+ /// Connection-local workspace and tab projection for a client-owned shell.
+ pub(crate) shell_location: Option<ClientShellLocation>,
/// 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,
+ /// Request id and buffered response for a deferred worktree-created navigation.
+ pub(crate) shell_deferred_navigation_request_id: Option<String>,
+ pub(crate) shell_deferred_navigation_response: Option<Vec<u8>>,
/// 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.
@@ -162,9 +236,12 @@ impl ClientConnection {
host_keyboard_protocol_active: None,
shell_held_inputs: HashMap::new(),
staged_clipboard_files: Vec::new(),
+ shell_location: None,
shell_snapshot: None,
shell_projection_revision: 0,
shell_endpoint_command_in_flight: false,
+ shell_deferred_navigation_request_id: None,
+ shell_deferred_navigation_response: None,
shell_uses_endpoint_keybindings: false,
writer,
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 1cf8ebd4..c77462ab 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -51,8 +51,7 @@ 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,
+ render_pane_surface as render_client_shell_pane_surface, snapshot as client_shell_snapshot,
};
use crate::server::client_transport::ServerEvent;
use crate::server::clients::{
@@ -73,6 +72,7 @@ use crate::server::socket_paths::{
use crate::server::terminal_attach::paste_payload_for_runtime;
mod bootstrap;
+mod client_views;
mod lifecycle;
mod notifications;
mod pane_graphics;
@@ -204,8 +204,12 @@ pub struct HeadlessServer {
clients: HashMap<u64, ClientConnection>,
#[cfg(unix)]
next_client_id: u64,
- /// The client currently driving the shared pane runtime size, theme, and input keybindings.
+ /// The client currently driving session-wide host presentation and side effects.
foreground_client_id: Option<u64>,
+ /// Ephemeral shell connection controlling PTY geometry for each stable tab id.
+ tab_geometry_controllers: HashMap<String, u64>,
+ /// Stable tab id whose viewers may see and interact with the one terminal popup.
+ popup_owner_tab_id: Option<String>,
/// 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.
@@ -345,6 +349,8 @@ impl HeadlessServer {
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
+ tab_geometry_controllers: HashMap::new(),
+ popup_owner_tab_id: None,
client_shell_boot_id: format!(
"{}-{}",
std::process::id(),
@@ -726,6 +732,7 @@ impl HeadlessServer {
stamp
}
+ #[cfg(unix)]
fn resize_shared_runtime_to_effective_size(&mut self) {
self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true);
}
@@ -738,35 +745,20 @@ impl HeadlessServer {
&mut self,
start_pending_agent_resumes: bool,
) {
- if self.foreground_client_id.is_none() {
- return;
- }
let Some(client_id) = self.foreground_client_id else {
return;
};
let Some(client) = self.clients.get(&client_id) else {
return;
};
- let client_shell = matches!(client.mode, ClientConnectionMode::ClientShell);
+ if matches!(client.mode, ClientConnectionMode::ClientShell) {
+ self.resize_shell_tab_if_controller(client_id, start_pending_agent_resumes);
+ return;
+ }
let cell_size = client.cell_size;
let (cols, rows) = self.effective_size;
let area = Rect::new(0, 0, cols, rows);
- 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() {
+ 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,
@@ -959,8 +951,24 @@ impl HeadlessServer {
fn remove_client(&mut self, client_id: u64) -> bool {
self.retire_direct_graphics_for_client(client_id);
+ let disconnected_focus = self
+ .clients
+ .get(&client_id)
+ .filter(|client| client.is_shell_client() && client.outer_terminal_focus == Some(true))
+ .and_then(|_| self.shell_focus_target(client_id));
+ let should_release_focus = disconnected_focus.as_ref().is_some_and(|target| {
+ !self.clients.iter().any(|(&other_id, client)| {
+ other_id != client_id
+ && client.is_shell_client()
+ && client.outer_terminal_focus == Some(true)
+ && self.shell_tab_id_for_client(other_id).as_deref()
+ == Some(target.tab_id.as_str())
+ })
+ });
let was_foreground = self.foreground_client_id == Some(client_id);
let removed = self.clients.remove(&client_id);
+ self.tab_geometry_controllers
+ .retain(|_, controller_id| *controller_id != client_id);
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);
@@ -974,6 +982,11 @@ impl HeadlessServer {
}
}
}
+ if should_release_focus {
+ if let Some(target) = disconnected_focus.as_ref() {
+ self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Lost);
+ }
+ }
if was_foreground {
self.promote_latest_remaining_client()
} else {
@@ -1011,24 +1024,18 @@ impl HeadlessServer {
}
}
- fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool {
- if self.foreground_client_id == Some(client_id) {
- return true;
- }
- matches!(
- self.clients.get(&client_id).map(|client| &client.mode),
- Some(
- ClientConnectionMode::TerminalAttach { .. }
- | ClientConnectionMode::TerminalObserve { .. }
- )
- ) && self.foreground_client_id.is_some()
- }
-
fn remove_client_and_resize_if_needed(&mut self, client_id: u64) {
- let needs_shared_resize = self.client_removal_needs_shared_resize(client_id);
- let foreground_changed = self.remove_client(client_id);
- if needs_shared_resize || foreground_changed {
- self.resize_shared_runtime_to_effective_size();
+ let restore_shell_controller = self.clients.get(&client_id).and_then(|client| {
+ let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else {
+ return None;
+ };
+ self.shell_geometry_controller_for_terminal(terminal_id)
+ });
+ self.remove_client(client_id);
+ if let Some((controller_id, target)) = restore_shell_controller {
+ self.restore_shell_tab_geometry(controller_id, target);
+ } else {
+ self.resize_tabs_for_only_shell_client(true);
}
}
@@ -1191,7 +1198,6 @@ impl HeadlessServer {
}
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)
})
@@ -1202,16 +1208,21 @@ impl HeadlessServer {
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 popup_blocks_input = self.app.state.popup_pane.is_some()
+ && self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id);
+ if popup_blocks_input
+ || !self.shell_client_views_pane(client_id, workspace_index, runtime_pane_id)
+ {
+ return false;
}
+ let foreground_changed = self.promote_client_to_foreground(client_id);
+ let geometry_changed = self.claim_shell_tab_geometry(client_id, false);
let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
&self.app.terminal_runtimes,
workspace_index,
runtime_pane_id,
) else {
- return foreground_changed;
+ return foreground_changed | geometry_changed;
};
if let Err(err) = apply_client_pane_input_events(
runtime,
@@ -1238,15 +1249,15 @@ impl HeadlessServer {
else {
return false;
};
- if popup_terminal_id.as_str() != terminal_id {
+ if popup_terminal_id.as_str() != terminal_id
+ || self.popup_owner_tab_id != self.shell_tab_id_for_client(client_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 geometry_changed = self.claim_shell_tab_geometry(client_id, false);
let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else {
- return foreground_changed;
+ return foreground_changed | geometry_changed;
};
if let Err(err) = apply_client_popup_input_events(
runtime,
@@ -1928,29 +1939,36 @@ impl HeadlessServer {
} else {
self.server_config_diagnostic_without_keybindings.as_deref()
};
- let snapshot = client_shell_snapshot(
+ let seed_snapshot = client_shell_snapshot(
&self.app,
&self.client_shell_boot_id,
connection.shell_projection_revision,
config_diagnostic,
+ None,
);
- let snapshot_message = match crate::protocol::endpoint::snapshot_message(&snapshot)
- {
- Ok(message) => message,
- Err(err) => {
- warn!(client_id, err = %err, "failed to encode endpoint snapshot");
- return false;
- }
- };
- connection.shell_snapshot = Some(snapshot);
+ let location =
+ crate::server::clients::ClientShellLocation::from_snapshot(&seed_snapshot);
+ let snapshot_message =
+ match crate::protocol::endpoint::snapshot_message(&seed_snapshot) {
+ Ok(message) => message,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to encode endpoint snapshot");
+ return false;
+ }
+ };
+ connection.shell_location = Some(location);
+ connection.shell_snapshot = Some(seed_snapshot);
self.clients.insert(client_id, connection);
+ if self.app.state.popup_pane.is_some() && self.popup_owner_tab_id.is_none() {
+ self.popup_owner_tab_id = self.shell_tab_id_for_client(client_id);
+ }
self.send_to_client(client_id, snapshot_message);
self.foreground_client_id = Some(client_id);
if first_app_client {
self.app.mark_git_status_refresh_due(Instant::now());
}
self.sync_foreground_client_state();
- self.resize_shared_runtime_to_effective_size();
+ self.claim_unowned_shell_tab_geometry(client_id, true);
self.nudge_handoff_panes_on_first_client_attach();
true
}
@@ -2169,7 +2187,7 @@ impl HeadlessServer {
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();
+ self.resize_shell_tab_if_controller(client_id, true);
true
}
ServerEvent::ClientShellHostTheme { client_id, update } => {
@@ -2196,7 +2214,7 @@ impl HeadlessServer {
changed
}
ServerEvent::ClientShellFocus { client_id, focused } => {
- let Some(client) = self.clients.get_mut(&client_id) else {
+ let Some(client) = self.clients.get(&client_id) else {
return false;
};
if !matches!(client.mode, ClientConnectionMode::ClientShell)
@@ -2204,20 +2222,38 @@ impl HeadlessServer {
{
return false;
}
- client.outer_terminal_focus = Some(focused);
+ let tab_id = self.shell_tab_id_for_client(client_id);
+ let another_focused_viewer = self.clients.iter().any(|(&other_id, client)| {
+ other_id != client_id
+ && client.is_shell_client()
+ && client.outer_terminal_focus == Some(true)
+ && self.shell_tab_id_for_client(other_id) == tab_id
+ });
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ 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);
+ self.claim_shell_tab_geometry(client_id, false);
+ if !another_focused_viewer {
+ if let Some(target) = self.shell_focus_target(client_id) {
+ self.send_shell_focus_target(
+ &target,
+ crate::ghostty::FocusEvent::Gained,
+ );
+ }
+ }
true
} else {
- false
+ if self.foreground_client_id == Some(client_id) {
+ self.app.state.outer_terminal_focus = Some(false);
+ }
+ if !another_focused_viewer {
+ if let Some(target) = self.shell_focus_target(client_id) {
+ self.send_shell_focus_target(&target, crate::ghostty::FocusEvent::Lost);
+ }
+ }
+ true
}
}
ServerEvent::ClientShellMouseCapture { client_id, enabled } => {
@@ -2266,11 +2302,10 @@ impl HeadlessServer {
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 popup_blocks_input = self.app.state.popup_pane.is_some()
+ && self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id);
+ if popup_blocks_input
+ || !self.shell_client_views_pane(client_id, workspace_index, runtime_pane_id)
{
let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
&self.app.terminal_runtimes,
@@ -2305,21 +2340,20 @@ impl HeadlessServer {
}
let foreground_changed =
interaction && self.promote_client_to_foreground(client_id);
- if foreground_changed {
- self.resize_shared_runtime_to_effective_size_before_input();
- }
+ let geometry_changed =
+ interaction && self.claim_shell_tab_geometry(client_id, false);
let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
&self.app.terminal_runtimes,
workspace_index,
runtime_pane_id,
) else {
- return foreground_changed;
+ return foreground_changed | geometry_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
+ foreground_changed | geometry_changed || runtime.scroll_metrics() != scroll_before
}
ServerEvent::ClientShellPopupInput {
client_id,
@@ -2358,6 +2392,26 @@ impl HeadlessServer {
runtime.current_size(),
runtime.pixel_size(),
);
+ if self.popup_owner_tab_id != self.shell_tab_id_for_client(client_id) {
+ 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::Popup(terminal_id.clone()),
+ &releases,
+ );
+ }
+ let scroll_before = runtime.scroll_metrics();
+ if let Err(err) = apply_client_popup_input_events(runtime, &releases) {
+ warn!(client_id, terminal_id, err = %err, "targeted client popup 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(
@@ -2367,17 +2421,16 @@ impl HeadlessServer {
}
let foreground_changed =
interaction && self.promote_client_to_foreground(client_id);
- if foreground_changed {
- self.resize_shared_runtime_to_effective_size_before_input();
- }
+ let geometry_changed =
+ interaction && self.claim_shell_tab_geometry(client_id, false);
let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else {
- return foreground_changed;
+ return foreground_changed | geometry_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
+ foreground_changed | geometry_changed || runtime.scroll_metrics() != scroll_before
}
ServerEvent::ClientShellEndpointRequestError {
client_id,
@@ -2402,7 +2455,7 @@ impl HeadlessServer {
ServerEvent::ClientShellEndpointRequest {
client_id,
boot_id,
- request,
+ mut request,
} => {
let Some(client) = self.clients.get(&client_id) else {
return false;
@@ -2443,6 +2496,11 @@ impl HeadlessServer {
return true;
}
+ let api_request_id = format!(
+ "endpoint:{}:{client_id}:{request_id}",
+ self.client_shell_boot_id
+ );
+ request.id = api_request_id.clone();
let (respond_to, response_rx) = std::sync::mpsc::channel();
if let Err(err) = crate::server::client_commands::spawn_response_waiter(
client_id,
@@ -2462,18 +2520,30 @@ impl HeadlessServer {
}
if let Some(client) = self.clients.get_mut(&client_id) {
client.shell_endpoint_command_in_flight = true;
+ let deferred_worktree = matches!(
+ &request.method,
+ api::schema::Method::WorktreeCreate(_)
+ | api::schema::Method::WorktreeRemove(_)
+ );
+ let deferred_navigation = matches!(
+ &request.method,
+ api::schema::Method::WorktreeCreate(params) if params.focus
+ );
+ client.shell_deferred_navigation_request_id =
+ deferred_worktree.then(|| api_request_id.clone());
+ client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new);
}
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,
- })
+ | self.handle_client_shell_api_request(
+ client_id,
+ api::ApiRequestMessage {
+ request: *request,
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ },
+ )
}
ServerEvent::ClientShellEndpointResponseChunkReady {
client_id,
@@ -2490,11 +2560,42 @@ impl HeadlessServer {
if !valid {
return false;
}
+ let completed_deferred_response =
+ self.clients.get_mut(&client_id).and_then(|client| {
+ let response = client.shell_deferred_navigation_response.as_mut()?;
+ response.extend_from_slice(&data);
+ final_chunk
+ .then(|| client.shell_deferred_navigation_response.take())
+ .flatten()
+ });
if final_chunk {
if let Some(client) = self.clients.get_mut(&client_id) {
client.shell_endpoint_command_in_flight = false;
+ client.shell_deferred_navigation_request_id = None;
}
}
+ let deferred_tab_id = completed_deferred_response
+ .as_deref()
+ .and_then(Self::deferred_endpoint_navigation_tab_id);
+ let focus_before = self.shell_focus_target(client_id);
+ let focused_tabs_before = self.focused_shell_tabs();
+ let navigation_changed = deferred_tab_id
+ .as_deref()
+ .is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, tab_id));
+ let geometry_changed =
+ navigation_changed && self.claim_shell_tab_geometry(client_id, false);
+ if navigation_changed {
+ self.reconcile_client_shell_locations();
+ 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,
+ );
+ }
self.send_to_client(
client_id,
ServerMessage::ClientShellEndpointResponseChunk {
@@ -2504,7 +2605,7 @@ impl HeadlessServer {
data,
},
);
- false
+ navigation_changed | geometry_changed
}
ServerEvent::ClientDetach { client_id } => {
info!(client_id, "client detached");
@@ -2768,16 +2869,6 @@ impl HeadlessServer {
impact
}
- /// Handles a single API request with shutdown awareness.
- ///
- /// Also forwards any toast/sound notifications that result from the API
- /// request to connected clients. API methods like `pane.report_agent`
- /// trigger internal events that may set toast state or would normally
- /// play sounds — in headless mode we forward these to clients instead.
- fn handle_api_request_with_shutdown_check(&mut self, msg: api::ApiRequestMessage) -> bool {
- self.handle_api_request_with_shutdown_check_inner(msg, false)
- }
-
fn handle_api_request_with_render_impact(
&mut self,
msg: api::ApiRequestMessage,
@@ -2789,7 +2880,7 @@ impl HeadlessServer {
) {
return self.handle_pane_graphics_stream_frame(msg);
}
- if self.handle_api_request_with_shutdown_check_inner(msg, false) {
+ if self.handle_api_request_with_shutdown_check(msg) {
RenderImpact::Full
} else {
RenderImpact::None
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,
diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs
index 15b23bf8..b8bb2e89 100644
--- a/src/server/render_scale_benchmark.rs
+++ b/src/server/render_scale_benchmark.rs
@@ -59,6 +59,7 @@ impl RenderPipeline {
"bench-boot",
1,
None,
+ None,
)));
Self {
@@ -71,10 +72,20 @@ impl RenderPipeline {
fn render_once(&mut self) -> (Duration, Duration) {
let surface_size = self.client.surface_size(COLS, ROWS);
let started = Instant::now();
+ let target = self
+ .app
+ .state
+ .active
+ .map(|workspace_index| crate::ui::TabSurfaceTarget {
+ workspace_index,
+ tab_index: self.app.state.workspaces[workspace_index].active_tab_index(),
+ });
let rendered = super::client_shell::render_pane_surface(
&mut self.app,
+ target,
Rect::new(0, 0, surface_size.cols, surface_size.rows),
true,
+ true,
HostCellSize {
width_px: 1,
height_px: 1,
@@ -219,7 +230,7 @@ fn profile_snapshot_encoding(
let pipeline = RenderPipeline::new(build(count));
let run = || {
let started = Instant::now();
- let template = super::client_shell::snapshot(&pipeline.app, "bench-boot", 1, None);
+ let template = super::client_shell::snapshot(&pipeline.app, "bench-boot", 1, None, None);
for client_index in 0..client_count {
let mut snapshot = template.clone();
snapshot.revision = client_index as u64 + 1;
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index 3d9e1141..41684c26 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -350,13 +350,21 @@ pub(crate) type RenderedTabSurface = (
pub(crate) fn render_tab_surface_virtual(
app_state: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
+ target: Option<crate::ui::TabSurfaceTarget>,
area: Rect,
resize_panes: bool,
cell_size: crate::kitty_graphics::HostCellSize,
) -> RenderedTabSurface {
- let layout =
- crate::ui::compute_tab_surface(app_state, terminal_runtimes, area, resize_panes, cell_size);
+ let layout = crate::ui::compute_tab_surface_for(
+ app_state,
+ terminal_runtimes,
+ target,
+ area,
+ resize_panes,
+ cell_size,
+ );
let surface = crate::ui::TabSurfaceView {
+ target: layout.target,
pane_infos: &layout.pane_infos,
split_borders: &layout.split_borders,
};