aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/shell')
-rw-r--r--src/client/shell/actions.rs75
-rw-r--r--src/client/shell/agent_sidebar.rs106
-rw-r--r--src/client/shell/aggregate_navigation.rs262
-rw-r--r--src/client/shell/composition.rs148
-rw-r--r--src/client/shell/endpoint_agent_state.rs263
-rw-r--r--src/client/shell/endpoint_agents.rs129
-rw-r--r--src/client/shell/endpoint_navigation.rs290
-rw-r--r--src/client/shell/endpoint_notices.rs80
-rw-r--r--src/client/shell/endpoint_sidebar.rs452
-rw-r--r--src/client/shell/endpoints.rs506
-rw-r--r--src/client/shell/graphics.rs5
-rw-r--r--src/client/shell/input.rs69
-rw-r--r--src/client/shell/input_source.rs41
-rw-r--r--src/client/shell/mobile.rs399
-rw-r--r--src/client/shell/mouse.rs81
-rw-r--r--src/client/shell/notification_policy.rs319
-rw-r--r--src/client/shell/notifications.rs417
-rw-r--r--src/client/shell/overlay_input.rs183
-rw-r--r--src/client/shell/overlays.rs223
-rw-r--r--src/client/shell/render.rs51
-rw-r--r--src/client/shell/settings.rs6
-rw-r--r--src/client/shell/sidebar.rs33
-rw-r--r--src/client/shell/state.rs328
-rw-r--r--src/client/shell/tests/agents_worktrees_notifications.rs8
-rw-r--r--src/client/shell/tests/copy.rs10
-rw-r--r--src/client/shell/tests/endpoint_requests.rs298
-rw-r--r--src/client/shell/tests/endpoints.rs1205
-rw-r--r--src/client/shell/tests/keybindings_settings.rs5
-rw-r--r--src/client/shell/tests/mobile.rs95
-rw-r--r--src/client/shell/tests/mod.rs4
-rw-r--r--src/client/shell/tests/popup_focus_projection.rs2
-rw-r--r--src/client/shell/tests/startup_overlays.rs1
-rw-r--r--src/client/shell/worktrees.rs61
33 files changed, 5292 insertions, 863 deletions
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs
index 1ebd4e29..f99d8f3a 100644
--- a/src/client/shell/actions.rs
+++ b/src/client/shell/actions.rs
@@ -147,6 +147,9 @@ impl ClientShellState {
}
return;
}
+ if self.handle_endpoint_navigation(action, outcome) {
+ return;
+ }
if let Some(method) = self.endpoint_method_for_action(action) {
self.push_endpoint_method(method, outcome);
return;
@@ -408,7 +411,9 @@ impl ClientShellState {
kind: PendingEndpointKind,
outcome: &mut ClientShellInput,
) -> bool {
- if self.snapshot.is_none() {
+ if !self.endpoint_is_online(&self.active_endpoint_id) {
+ let label = self.active_endpoint_label().to_owned();
+ outcome.repaint |= self.receive_endpoint_unavailable(format!("{label} is not ready"));
return false;
}
let method_name = crate::api::api_method_name(&method).to_owned();
@@ -423,7 +428,9 @@ impl ClientShellState {
);
return false;
}
- let snapshot = self.snapshot.as_deref().expect("checked snapshot");
+ let Some(snapshot) = self.snapshot.as_deref() else {
+ return false;
+ };
let confirmation_workspace_id = match &method {
crate::api::schema::Method::TabClose(target) => snapshot
.tabs
@@ -450,6 +457,7 @@ impl ClientShellState {
},
);
outcome.actions.push(ClientShellAction::Endpoint {
+ endpoint_id: self.active_endpoint_id.clone(),
boot_id: snapshot.boot_id.clone(),
request: Box::new(crate::api::schema::Request {
id: request_id,
@@ -468,6 +476,58 @@ impl ClientShellState {
)
}
+ pub(crate) fn receive_endpoint_unavailable(&mut self, message: String) -> bool {
+ self.push_endpoint_notice(
+ ClientEndpointNoticeKind::Unavailable,
+ message.clone(),
+ "Endpoint unavailable",
+ message,
+ )
+ }
+
+ pub(crate) fn focus_endpoint_target(
+ &mut self,
+ target: ClientEndpointFocusTarget,
+ ) -> Vec<ClientShellAction> {
+ let method = match target {
+ ClientEndpointFocusTarget::Workspace(workspace_id) => {
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id,
+ })
+ }
+ ClientEndpointFocusTarget::Tab(tab_id) => {
+ crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id })
+ }
+ ClientEndpointFocusTarget::Pane(pane_id) => {
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id })
+ }
+ };
+ let mut outcome = ClientShellInput::default();
+ self.push_endpoint_method(method, &mut outcome);
+ outcome.actions
+ }
+
+ pub(crate) fn cancel_endpoint_request(&mut self, request_id: &str) -> bool {
+ let Some(pending) = self.pending_requests.get(request_id) else {
+ return false;
+ };
+ let boot_id = pending.boot_id.clone();
+ let (repaint, actions) = self.handle_endpoint_result(
+ &boot_id,
+ request_id,
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_cancelled".into()),
+ message: "This server action was interrupted. Check its state before retrying."
+ .into(),
+ }),
+ );
+ debug_assert!(
+ actions.is_empty(),
+ "cancellation must not start another action"
+ );
+ repaint
+ }
+
pub(crate) fn handle_endpoint_result(
&mut self,
boot_id: &str,
@@ -506,6 +566,12 @@ impl ClientShellState {
"Server timed out",
format!("This server did not respond to {}.", pending.method_name),
),
+ "endpoint_cancelled" => (
+ ClientEndpointNoticeKind::Unavailable,
+ "cancelled".to_owned(),
+ "Action interrupted",
+ error.message.clone(),
+ ),
"server_unavailable" => (
ClientEndpointNoticeKind::Unavailable,
"server".to_owned(),
@@ -613,6 +679,9 @@ impl ClientShellState {
Some("endpoint returned an unexpected selection result".to_owned());
(true, fallback())
}
+ Err(error) if error.code.as_deref() == Some("endpoint_cancelled") => {
+ (true, Vec::new())
+ }
Err(_) => (true, fallback()),
};
}
@@ -724,7 +793,7 @@ impl ClientShellState {
Err(error)
if matches!(
error.code.as_deref(),
- Some("stale_content" | "stale_target")
+ Some("stale_content" | "stale_target" | "endpoint_cancelled")
) =>
{
self.url_click_consumes_until_up = completed_before_release;
diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs
index 8f87b6ad..d6fe3352 100644
--- a/src/client/shell/agent_sidebar.rs
+++ b/src/client/shell/agent_sidebar.rs
@@ -10,11 +10,11 @@ use ratatui::{
use super::*;
-struct AgentRow {
- pane_id: String,
- status: crate::api::schema::AgentStatus,
- focused: bool,
- rows: Vec<Vec<crate::ui::ResolvedToken>>,
+pub(super) struct AgentRow {
+ pub(super) pane_id: String,
+ pub(super) status: crate::api::schema::AgentStatus,
+ pub(super) focused: bool,
+ pub(super) rows: Vec<Vec<crate::ui::ResolvedToken>>,
}
pub(super) fn ordered_agent_pane_ids(
@@ -57,9 +57,46 @@ pub(super) fn render_agent_panel(
agent_scroll: &mut usize,
hits: &mut ShellHitMap,
) {
- if area.height == 0 {
+ if !render_agent_panel_header(
+ buffer,
+ area,
+ snapshot.agent_view_label.as_deref(),
+ config,
+ hits,
+ ) {
return;
}
+
+ let rows = agent_rows(snapshot, config, None);
+ render_agent_list(
+ buffer,
+ area,
+ &rows,
+ snapshot
+ .agent_view_label
+ .as_ref()
+ .map(|_| " no matching agents"),
+ config,
+ agent_scroll,
+ hits,
+ |row| row.rows.len(),
+ |buffer, rect, row, hits| {
+ hits.agents.push((rect, row.pane_id.clone()));
+ render_agent_row(buffer, rect, row, config);
+ },
+ );
+}
+
+pub(super) fn render_agent_panel_header(
+ buffer: &mut Buffer,
+ area: Rect,
+ agent_view_label: Option<&str>,
+ config: &ClientShellConfig,
+ hits: &mut ShellHitMap,
+) -> bool {
+ if area.height == 0 {
+ return false;
+ }
put_text(
buffer,
area.x,
@@ -69,7 +106,7 @@ pub(super) fn render_agent_panel(
Style::default().fg(config.palette.surface_dim),
);
if area.height < 2 {
- return;
+ return false;
}
put_text(
buffer,
@@ -81,14 +118,10 @@ pub(super) fn render_agent_panel(
.fg(config.palette.overlay0)
.add_modifier(Modifier::BOLD),
);
- let sort_label =
- snapshot
- .agent_view_label
- .as_deref()
- .unwrap_or(match config.agent_panel_sort {
- crate::config::AgentPanelSortConfig::Spaces => "grouped",
- crate::config::AgentPanelSortConfig::Priority => "priority",
- });
+ let sort_label = agent_view_label.unwrap_or(match config.agent_panel_sort {
+ crate::config::AgentPanelSortConfig::Spaces => "grouped",
+ crate::config::AgentPanelSortConfig::Priority => "priority",
+ });
let sort_width = display_width(sort_label).min(area.width as usize) as u16;
let sort_rect = Rect::new(
area.right().saturating_sub(sort_width),
@@ -96,7 +129,7 @@ pub(super) fn render_agent_panel(
sort_width,
1,
);
- hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() {
+ hits.agent_sort_toggle = if config.mouse_capture && agent_view_label.is_none() {
sort_rect
} else {
Rect::default()
@@ -108,15 +141,27 @@ pub(super) fn render_agent_panel(
sort_rect.width,
sort_label,
Style::default()
- .fg(if snapshot.agent_view_label.is_some() {
+ .fg(if agent_view_label.is_some() {
config.palette.accent
} else {
config.palette.overlay0
})
.add_modifier(Modifier::BOLD),
);
+ true
+}
- let rows = agent_rows(snapshot, config);
+pub(super) fn render_agent_list<T>(
+ buffer: &mut Buffer,
+ area: Rect,
+ rows: &[T],
+ empty_message: Option<&str>,
+ config: &ClientShellConfig,
+ agent_scroll: &mut usize,
+ hits: &mut ShellHitMap,
+ row_lines: impl Fn(&T) -> usize,
+ mut render_row: impl FnMut(&mut Buffer, Rect, &T, &mut ShellHitMap),
+) {
let body = Rect::new(
area.x,
area.y.saturating_add(3),
@@ -126,13 +171,13 @@ pub(super) fn render_agent_panel(
hits.agent_body = body;
if body.is_empty() || rows.is_empty() {
*agent_scroll = 0;
- if !body.is_empty() && snapshot.agent_view_label.is_some() {
+ if let Some(message) = empty_message.filter(|_| !body.is_empty()) {
put_text(
buffer,
body.x,
body.y,
body.width,
- " no matching agents",
+ message,
Style::default()
.fg(config.palette.overlay0)
.add_modifier(Modifier::DIM),
@@ -143,7 +188,7 @@ pub(super) fn render_agent_panel(
let row_heights = rows
.iter()
- .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16)
+ .map(|row| row_lines(row).max(1).min(u16::MAX as usize) as u16)
.collect::<Vec<_>>();
let gaps = rows
.iter()
@@ -167,13 +212,12 @@ pub(super) fn render_agent_panel(
let content_width = body.width.saturating_sub(u16::from(show_scrollbar));
let mut y = body.y;
for (index, row) in rows.iter().enumerate().skip(*agent_scroll) {
- let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height);
+ let height = row_heights[index].min(body.height);
if y.saturating_add(height) > body.bottom() {
break;
}
let rect = Rect::new(body.x, y, content_width, height);
- hits.agents.push((rect, row.pane_id.clone()));
- render_agent_row(buffer, rect, row, config);
+ render_row(buffer, rect, row, hits);
y = y
.saturating_add(height)
.saturating_add(if index + 1 < rows.len() {
@@ -190,7 +234,11 @@ pub(super) fn render_agent_panel(
}
}
-fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec<AgentRow> {
+pub(super) fn agent_rows(
+ snapshot: &ClientShellSnapshot,
+ config: &ClientShellConfig,
+ machine: Option<&str>,
+) -> Vec<AgentRow> {
ordered_agent_pane_ids(snapshot, config.agent_panel_sort)
.into_iter()
.filter_map(|pane_id| {
@@ -238,6 +286,7 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec
let rows = crate::ui::sidebar_agent_rows(
&config.agents,
crate::ui::AgentTokenContext {
+ machine,
workspace: &workspace.label,
tab: tab_label,
pane: agent
@@ -262,7 +311,12 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec
.collect()
}
-fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) {
+pub(super) fn render_agent_row(
+ buffer: &mut Buffer,
+ rect: Rect,
+ row: &AgentRow,
+ config: &ClientShellConfig,
+) {
let palette = &config.palette;
let row_style = if row.focused {
Style::default().bg(palette.active_row_bg)
diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs
new file mode 100644
index 00000000..5c826bf3
--- /dev/null
+++ b/src/client/shell/aggregate_navigation.rs
@@ -0,0 +1,262 @@
+//! Endpoint-qualified rows shared by aggregate navigation surfaces.
+
+use super::*;
+use crate::protocol::ClientShellAgent;
+
+#[derive(Clone, Copy)]
+pub(super) struct CachedEndpointSnapshot<'a> {
+ pub(super) endpoint_id: &'a ClientEndpointId,
+ pub(super) label: &'a str,
+ pub(super) status: ClientEndpointStatus,
+ pub(super) snapshot: &'a ClientShellSnapshot,
+ pub(super) agent_recency: &'a HashMap<String, u64>,
+}
+
+impl CachedEndpointSnapshot<'_> {
+ pub(super) fn stale(self) -> bool {
+ self.status != ClientEndpointStatus::Online
+ }
+}
+
+pub(super) fn cached_endpoint_snapshots(
+ endpoints: &[ClientShellEndpoint],
+) -> impl Iterator<Item = CachedEndpointSnapshot<'_>> {
+ endpoints.iter().filter_map(|endpoint| {
+ endpoint
+ .snapshot
+ .as_deref()
+ .map(|snapshot| CachedEndpointSnapshot {
+ endpoint_id: &endpoint.endpoint_id,
+ label: &endpoint.label,
+ status: endpoint.status,
+ snapshot,
+ agent_recency: &endpoint.agent_recency,
+ })
+ })
+}
+
+pub(super) struct AggregateAgentRow<'a> {
+ pub(super) endpoint: CachedEndpointSnapshot<'a>,
+ pub(super) agent: &'a ClientShellAgent,
+ pub(super) recency: u64,
+}
+
+pub(super) struct AggregateAgentTarget {
+ pub(super) endpoint_id: ClientEndpointId,
+ pub(super) pane_id: String,
+}
+
+pub(super) fn aggregate_agent_rows(
+ endpoints: &[ClientShellEndpoint],
+ sort: crate::config::AgentPanelSortConfig,
+) -> Vec<AggregateAgentRow<'_>> {
+ let mut rows = cached_endpoint_snapshots(endpoints)
+ .flat_map(|endpoint| {
+ super::agent_sidebar::ordered_agent_pane_ids(endpoint.snapshot, sort)
+ .into_iter()
+ .filter_map(move |pane_id| {
+ let agent = endpoint
+ .snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane_id)?;
+ Some(AggregateAgentRow {
+ recency: endpoint
+ .agent_recency
+ .get(&pane_id)
+ .copied()
+ .unwrap_or_default(),
+ endpoint,
+ agent,
+ })
+ })
+ })
+ .collect::<Vec<_>>();
+ if sort == crate::config::AgentPanelSortConfig::Priority {
+ rows.sort_by_key(|row| {
+ (
+ row.endpoint.stale(),
+ std::cmp::Reverse(status_priority(row.agent.agent_status)),
+ std::cmp::Reverse(row.recency),
+ )
+ });
+ }
+ rows
+}
+
+pub(super) fn online_agent_targets(
+ endpoints: &[ClientShellEndpoint],
+ sort: crate::config::AgentPanelSortConfig,
+) -> Vec<AggregateAgentTarget> {
+ aggregate_agent_rows(endpoints, sort)
+ .into_iter()
+ .filter(|row| !row.endpoint.stale())
+ .map(|row| AggregateAgentTarget {
+ endpoint_id: row.endpoint.endpoint_id.clone(),
+ pane_id: row.agent.pane_id.clone(),
+ })
+ .collect()
+}
+
+pub(super) fn navigator_rows(
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
+ navigator: &ClientNavigatorOverlay,
+) -> Vec<ClientNavigatorRow> {
+ let query = navigator.query.trim().to_lowercase();
+ let filter = |status| match navigator.filter {
+ Some(ClientNavigatorFilter::Blocked) => status == crate::api::schema::AgentStatus::Blocked,
+ Some(ClientNavigatorFilter::Working) => status == crate::api::schema::AgentStatus::Working,
+ Some(ClientNavigatorFilter::Idle) => status == crate::api::schema::AgentStatus::Idle,
+ Some(ClientNavigatorFilter::Done) => status == crate::api::schema::AgentStatus::Done,
+ None => true,
+ };
+ let text = |value: &str| query.is_empty() || value.to_lowercase().contains(&query);
+ let filtering = navigator.filter.is_some() || !query.is_empty();
+ let federated = endpoints.len() > 1;
+ let depth_offset = u8::from(federated);
+ let mut rows = Vec::new();
+
+ for endpoint in endpoints {
+ let stale = endpoint.status != ClientEndpointStatus::Online;
+ let endpoint_query_matches = !query.is_empty() && text(&endpoint.label);
+ let mut endpoint_rows = Vec::new();
+ if let Some(snapshot) = endpoint.snapshot.as_deref() {
+ for workspace in &snapshot.workspaces {
+ let workspace_meta = workspace.branch.clone().unwrap_or_default();
+ let mut children = Vec::new();
+ for tab in snapshot
+ .tabs
+ .iter()
+ .filter(|tab| tab.workspace_id == workspace.workspace_id)
+ {
+ let mut panes = Vec::new();
+ for (index, pane) in snapshot
+ .panes
+ .iter()
+ .filter(|pane| pane.tab_id == tab.tab_id)
+ .enumerate()
+ {
+ let agent = snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane.pane_id);
+ let status = agent
+ .map_or(crate::api::schema::AgentStatus::Unknown, |agent| {
+ agent.agent_status
+ });
+ let label = pane
+ .label
+ .clone()
+ .or_else(|| agent.and_then(|agent| agent.name.clone()))
+ .or_else(|| agent.and_then(|agent| agent.display_agent.clone()))
+ .or_else(|| agent.and_then(|agent| agent.title.clone()))
+ .unwrap_or_else(|| format!("pane {}", index + 1));
+ let meta = pane
+ .foreground_cwd
+ .clone()
+ .or_else(|| pane.cwd.clone())
+ .unwrap_or_default();
+ if !filtering
+ || filter(status)
+ && (endpoint_query_matches || text(&label) || text(&meta))
+ {
+ panes.push(ClientNavigatorRow {
+ depth: 2 + depth_offset,
+ label,
+ meta,
+ status: Some(status),
+ stale,
+ current: endpoint.endpoint_id == *active_endpoint_id
+ && snapshot.focused_pane_id.as_deref() == Some(&pane.pane_id),
+ target: ClientNavigatorTarget::Pane {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ pane_id: pane.pane_id.clone(),
+ },
+ });
+ }
+ }
+ if !filtering
+ || filter(tab.agent_status) && (endpoint_query_matches || text(&tab.label))
+ || !panes.is_empty()
+ {
+ children.push(ClientNavigatorRow {
+ depth: 1 + depth_offset,
+ label: tab.label.clone(),
+ meta: format!(
+ "{} panes",
+ snapshot
+ .panes
+ .iter()
+ .filter(|pane| pane.tab_id == tab.tab_id)
+ .count()
+ ),
+ status: None,
+ stale,
+ current: false,
+ target: ClientNavigatorTarget::Tab {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ tab_id: tab.tab_id.clone(),
+ },
+ });
+ children.extend(panes);
+ }
+ }
+ let workspace_matches = filter(workspace.agent_status)
+ && (endpoint_query_matches || text(&workspace.label) || text(&workspace_meta));
+ if !filtering || workspace_matches || !children.is_empty() {
+ let key = (endpoint.endpoint_id.clone(), workspace.workspace_id.clone());
+ endpoint_rows.push(ClientNavigatorRow {
+ depth: depth_offset,
+ label: workspace.label.clone(),
+ meta: workspace_meta,
+ status: None,
+ stale,
+ current: false,
+ target: ClientNavigatorTarget::Workspace {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ workspace_id: workspace.workspace_id.clone(),
+ },
+ });
+ if navigator.expanded_workspaces.contains(&key) || filtering {
+ endpoint_rows.extend(children);
+ }
+ }
+ }
+ }
+ if !filtering || endpoint_query_matches || !endpoint_rows.is_empty() {
+ if federated {
+ rows.push(ClientNavigatorRow {
+ depth: 0,
+ label: endpoint.label.to_owned(),
+ meta: String::new(),
+ status: None,
+ stale,
+ current: false,
+ target: ClientNavigatorTarget::Machine {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ },
+ });
+ }
+ rows.extend(endpoint_rows);
+ }
+ }
+ rows
+}
+
+pub(super) fn navigator_selected_index(
+ rows: &[ClientNavigatorRow],
+ navigator: &ClientNavigatorOverlay,
+) -> Option<usize> {
+ match navigator.selected.as_ref() {
+ Some(target) => rows.iter().position(|row| row.target == *target),
+ None => (!rows.is_empty()).then_some(0),
+ }
+}
+
+pub(super) fn selected_navigator_target(
+ rows: &[ClientNavigatorRow],
+ navigator: &ClientNavigatorOverlay,
+) -> Option<ClientNavigatorTarget> {
+ navigator_selected_index(rows, navigator).map(|index| rows[index].target.clone())
+}
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs
index bcbf92fa..a80ebb3f 100644
--- a/src/client/shell/composition.rs
+++ b/src/client/shell/composition.rs
@@ -20,9 +20,95 @@ fn restore_mode_bar(
}
impl ClientShellState {
+ fn compose_unavailable(&mut self, cols: u16, rows: u16) -> FrameData {
+ let layout = self.layout(cols, rows);
+ let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows));
+ buffer.set_style(
+ buffer.area,
+ Style::default()
+ .fg(self.config.palette.text)
+ .bg(self.config.palette.panel_bg),
+ );
+ self.hits = ShellHitMap::default();
+ let sidebar = if layout.sidebar.width > 0 {
+ layout.sidebar
+ } else {
+ Rect::new(0, 1, cols, rows.saturating_sub(2))
+ };
+ super::endpoint_sidebar::render_expanded(
+ &mut buffer,
+ sidebar,
+ self.snapshot.as_deref(),
+ &self.config,
+ &mut render::ShellRenderState {
+ endpoints: &self.endpoints,
+ active_endpoint_id: &self.active_endpoint_id,
+ collapsed_endpoints: &self.collapsed_endpoints,
+ collapsed_groups: &self.collapsed_groups,
+ workspace_scroll: &mut self.workspace_scroll,
+ agent_scroll: &mut self.agent_scroll,
+ tab_scroll: &mut self.tab_scroll,
+ reveal_focused_workspace: &mut self.reveal_focused_workspace,
+ reveal_focused_tab: &mut self.reveal_focused_tab,
+ sidebar_collapsed: false,
+ sidebar_section_split: self.sidebar_section_split,
+ tab_drag_insert_index: None,
+ selected_workspace_id: self.navigate_workspace_id.as_deref(),
+ dragged_workspace_id: None,
+ workspace_drop_indicator_row: None,
+ },
+ &mut self.hits,
+ );
+ if !self.config.mouse_capture {
+ self.hits = ShellHitMap::default();
+ }
+ let message = self.endpoint_error.clone().unwrap_or_else(|| {
+ let status = self
+ .endpoint_status(&self.active_endpoint_id)
+ .unwrap_or(ClientEndpointStatus::Connecting);
+ let (_, label, _) = endpoint_status_presentation(status, &self.config.palette);
+ format!(
+ "{}: {label}. Select a connected machine.",
+ self.active_endpoint_label()
+ )
+ });
+ let message_area = if layout.sidebar.width > 0 {
+ layout.pane_surface
+ } else {
+ Rect::new(0, 0, cols, 1)
+ };
+ render::put_text(
+ &mut buffer,
+ message_area.x,
+ message_area.y,
+ message_area.width,
+ &message,
+ Style::default().fg(self.config.palette.overlay0),
+ );
+ render::render_mode_bar(
+ &mut buffer,
+ Rect::new(0, 0, cols, rows),
+ self.mode,
+ None,
+ self.endpoint_error.as_deref(),
+ false,
+ &self.config.keybinds,
+ &self.config.palette,
+ );
+ FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[])
+ }
+
pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> {
self.last_composed_size = Some((cols, rows));
+ if self.snapshot.is_none() || self.pane_surface.is_none() {
+ return Some(self.compose_unavailable(cols, rows));
+ }
let snapshot = self.snapshot.as_deref()?;
+ // A one-step successor is retained separately until its exact snapshot arrives; do not
+ // keep composing the now-superseded current pair while it is pending.
+ if self.pending_pane_surface.is_some() {
+ return None;
+ }
let surface = self.pane_surface.as_ref()?;
if snapshot.revision != surface.projection_revision {
return None;
@@ -53,6 +139,9 @@ impl ClientShellState {
snapshot,
&self.config,
render::ShellRenderState {
+ endpoints: &self.endpoints,
+ active_endpoint_id: &self.active_endpoint_id,
+ collapsed_endpoints: &self.collapsed_endpoints,
collapsed_groups: &self.collapsed_groups,
workspace_scroll: &mut self.workspace_scroll,
agent_scroll: &mut self.agent_scroll,
@@ -270,7 +359,14 @@ impl ClientShellState {
restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref());
self.hits.notification_toast = Rect::default();
let has_config_diagnostic = self.config_diagnostic.is_some();
+ let active_lifecycle = self
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id)
+ .filter(|endpoint| endpoint.status != ClientEndpointStatus::Online)
+ .map(|endpoint| (endpoint.label.clone(), endpoint.status));
if has_config_diagnostic
+ || active_lifecycle.is_some()
|| self.visible_endpoint_notice.is_some()
|| self.visible_notification.is_some()
{
@@ -289,21 +385,32 @@ impl ClientShellState {
&self.config.palette,
);
}
+ let lifecycle_offset = active_lifecycle.as_ref().map_or(0, |(label, status)| {
+ let _ = endpoint_notices::render_lifecycle_banner(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ label,
+ *status,
+ u16::from(has_config_diagnostic) + layout.mobile_header.height,
+ &self.config.palette,
+ );
+ 1
+ });
if let Some(notice) = self.visible_endpoint_notice.as_ref() {
self.hits.notification_toast = if layout.mobile_header.is_empty() {
- notifications::render_endpoint_notice(
+ endpoint_notices::render_notice(
&mut composed,
Rect::new(0, 0, cols, rows),
notice,
- u16::from(has_config_diagnostic),
+ u16::from(has_config_diagnostic) + lifecycle_offset,
&self.config.palette,
)
} else {
- notifications::render_mobile_endpoint_notice_banner(
+ endpoint_notices::render_mobile_banner(
&mut composed,
Rect::new(0, 0, cols, rows),
notice,
- has_config_diagnostic,
+ has_config_diagnostic || lifecycle_offset > 0,
&self.config.palette,
)
};
@@ -314,7 +421,7 @@ impl ClientShellState {
Rect::new(0, 0, cols, rows),
notification,
self.config.toast_position,
- u16::from(has_config_diagnostic),
+ u16::from(has_config_diagnostic) + lifecycle_offset,
&self.config.palette,
)
} else {
@@ -322,7 +429,7 @@ impl ClientShellState {
&mut composed,
Rect::new(0, 0, cols, rows),
notification,
- has_config_diagnostic,
+ has_config_diagnostic || lifecycle_offset > 0,
&self.config.palette,
)
};
@@ -399,12 +506,33 @@ impl ClientShellState {
&mut composed,
Rect::new(0, 0, cols, rows),
snapshot,
+ &self.endpoints,
+ &self.active_endpoint_id,
&self.config,
self.navigate_workspace_id.as_deref(),
&mut self.mobile_switcher_scroll,
&mut self.reveal_mobile_workspace,
&mut self.hits,
);
+ if let Some((label, status)) = active_lifecycle.as_ref() {
+ let _ = endpoint_notices::render_lifecycle_banner(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ label,
+ *status,
+ 2,
+ &self.config.palette,
+ );
+ }
+ if let Some(notice) = self.visible_endpoint_notice.as_ref() {
+ self.hits.notification_toast = endpoint_notices::render_mobile_banner(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ notice,
+ active_lifecycle.is_some(),
+ &self.config.palette,
+ );
+ }
frame.replace_from_ratatui_buffer_preserving_effects(&composed, None);
self.hits.panes.clear();
self.hits.pane_splits.clear();
@@ -431,6 +559,8 @@ impl ClientShellState {
&mut composed,
overlay,
snapshot,
+ &self.endpoints,
+ &self.active_endpoint_id,
&self.config.keybinds,
&self.config.palette,
)?;
@@ -474,6 +604,12 @@ impl ClientShellState {
.scroll
.min(u16::try_from(self.hits.release_notes_max_scroll).unwrap_or(u16::MAX));
}
+ if self.endpoint_status(&self.active_endpoint_id) != Some(ClientEndpointStatus::Online) {
+ frame.cursor = None;
+ self.hits.panes.clear();
+ self.hits.pane_splits.clear();
+ self.hits.popup = None;
+ }
self.compose_graphics(&mut frame, layout);
Some(frame)
}
diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs
new file mode 100644
index 00000000..fdcd7478
--- /dev/null
+++ b/src/client/shell/endpoint_agent_state.rs
@@ -0,0 +1,263 @@
+use std::collections::HashMap;
+
+use crate::api::schema::AgentStatus;
+use crate::protocol::{ClientShellAgent, ClientShellSnapshot, PaneSurfaceFrame};
+
+#[derive(Clone, Debug, Default)]
+pub(super) struct EndpointAgentPresentation {
+ boot_id: Option<String>,
+ acknowledged: HashMap<String, u64>,
+}
+
+impl EndpointAgentPresentation {
+ pub(super) fn project_snapshot(&mut self, snapshot: &mut ClientShellSnapshot) {
+ if self.boot_id.as_deref() != Some(snapshot.boot_id.as_str()) {
+ self.boot_id = Some(snapshot.boot_id.clone());
+ self.acknowledged.clear();
+ self.acknowledged.extend(
+ snapshot
+ .agents
+ .iter()
+ .map(|agent| (agent.pane_id.clone(), agent.state_change_seq)),
+ );
+ }
+ self.acknowledged.retain(|pane_id, _| {
+ snapshot
+ .agents
+ .iter()
+ .any(|agent| &agent.pane_id == pane_id)
+ });
+ for agent in &mut snapshot.agents {
+ agent.agent_status = self.projected_status(agent);
+ }
+ project_aggregate_status(snapshot);
+ }
+
+ pub(super) fn acknowledge_surface(
+ &mut self,
+ snapshot: &mut ClientShellSnapshot,
+ surface: &PaneSurfaceFrame,
+ outer_focused: Option<bool>,
+ ) -> bool {
+ if outer_focused == Some(false)
+ || self.boot_id.as_deref() != Some(surface.boot_id.as_str())
+ || snapshot.boot_id != surface.boot_id
+ || snapshot.revision != surface.projection_revision
+ {
+ return false;
+ }
+
+ let mut changed = false;
+ for pane in &surface.panes {
+ let Some(agent) = snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane.pane_id)
+ else {
+ continue;
+ };
+ let acknowledged = self.acknowledged.entry(agent.pane_id.clone()).or_default();
+ if *acknowledged < agent.state_change_seq {
+ *acknowledged = agent.state_change_seq;
+ changed = true;
+ }
+ }
+ if changed {
+ for agent in &mut snapshot.agents {
+ agent.agent_status = self.projected_status(agent);
+ }
+ project_aggregate_status(snapshot);
+ }
+ changed
+ }
+
+ fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus {
+ match agent.agent_status {
+ AgentStatus::Idle | AgentStatus::Done => {
+ if self
+ .acknowledged
+ .get(&agent.pane_id)
+ .is_some_and(|sequence| *sequence >= agent.state_change_seq)
+ {
+ AgentStatus::Idle
+ } else {
+ AgentStatus::Done
+ }
+ }
+ status => status,
+ }
+ }
+}
+
+fn project_aggregate_status(snapshot: &mut ClientShellSnapshot) {
+ for tab in &mut snapshot.tabs {
+ if let Some(status) = snapshot
+ .agents
+ .iter()
+ .filter(|agent| agent.tab_id == tab.tab_id)
+ .map(|agent| agent.agent_status)
+ .max_by_key(|status| super::status_priority(*status))
+ {
+ tab.agent_status = status;
+ }
+ }
+ for workspace in &mut snapshot.workspaces {
+ if let Some(status) = snapshot
+ .agents
+ .iter()
+ .filter(|agent| agent.workspace_id == workspace.workspace_id)
+ .map(|agent| agent.agent_status)
+ .max_by_key(|status| super::status_priority(*status))
+ {
+ workspace.agent_status = status;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::protocol::{FrameData, PaneSurfacePane, SurfaceRect};
+
+ fn agent(status: AgentStatus, sequence: u64) -> ClientShellAgent {
+ ClientShellAgent {
+ pane_id: "agent-pane".into(),
+ workspace_id: "workspace".into(),
+ tab_id: "tab".into(),
+ name: None,
+ display_agent: None,
+ agent: None,
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: status,
+ state_change_seq: sequence,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: true,
+ }
+ }
+
+ fn snapshot(status: AgentStatus, sequence: u64, revision: u64) -> ClientShellSnapshot {
+ let mut snapshot = crate::client::shell::tests::snapshot();
+ snapshot.boot_id = "endpoint-boot".into();
+ snapshot.revision = revision;
+ snapshot.agents = vec![agent(status, sequence)];
+ snapshot
+ }
+
+ fn surface(revision: u64) -> PaneSurfaceFrame {
+ let rect = SurfaceRect {
+ x: 0,
+ y: 0,
+ width: 1,
+ height: 1,
+ };
+ PaneSurfaceFrame {
+ boot_id: "endpoint-boot".into(),
+ projection_revision: revision,
+ surface_revision: 1,
+ frame: FrameData {
+ cells: Vec::new(),
+ width: 0,
+ height: 0,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ },
+ panes: vec![PaneSurfacePane {
+ pane_id: "agent-pane".into(),
+ content_revision: 1,
+ rect,
+ inner_rect: rect,
+ scrollbar_rect: None,
+ scroll: None,
+ focused: true,
+ mouse_reporting: false,
+ sgr_pixel_mouse: false,
+ alternate_screen_active: false,
+ pixel_width: 0,
+ pixel_height: 0,
+ }],
+ splits: Vec::new(),
+ popup: None,
+ graphics: Default::default(),
+ }
+ }
+
+ #[test]
+ fn first_snapshot_establishes_an_idle_baseline_without_server_seen_authority() {
+ let mut presentation = EndpointAgentPresentation::default();
+ let mut snapshot = snapshot(AgentStatus::Done, 4, 1);
+
+ presentation.project_snapshot(&mut snapshot);
+
+ assert_eq!(snapshot.agents[0].agent_status, AgentStatus::Idle);
+ }
+
+ #[test]
+ fn unpresented_working_completion_projects_done() {
+ let mut presentation = EndpointAgentPresentation::default();
+ let mut initial = snapshot(AgentStatus::Working, 4, 1);
+ presentation.project_snapshot(&mut initial);
+ let mut completed = snapshot(AgentStatus::Idle, 5, 2);
+
+ presentation.project_snapshot(&mut completed);
+
+ assert_eq!(completed.agents[0].agent_status, AgentStatus::Done);
+ }
+
+ #[test]
+ fn coherent_presented_surface_acknowledges_completion() {
+ let mut presentation = EndpointAgentPresentation::default();
+ let mut initial = snapshot(AgentStatus::Working, 4, 1);
+ presentation.project_snapshot(&mut initial);
+ let mut completed = snapshot(AgentStatus::Idle, 5, 2);
+ presentation.project_snapshot(&mut completed);
+
+ assert!(presentation.acknowledge_surface(&mut completed, &surface(2), Some(true)));
+ assert_eq!(completed.agents[0].agent_status, AgentStatus::Idle);
+ }
+
+ #[test]
+ fn clients_acknowledge_the_same_endpoint_completion_independently() {
+ let mut viewing_client = EndpointAgentPresentation::default();
+ let mut background_client = EndpointAgentPresentation::default();
+ let mut initial_for_viewer = snapshot(AgentStatus::Working, 4, 1);
+ let mut initial_for_background = initial_for_viewer.clone();
+ viewing_client.project_snapshot(&mut initial_for_viewer);
+ background_client.project_snapshot(&mut initial_for_background);
+ let mut completed_for_viewer = snapshot(AgentStatus::Idle, 5, 2);
+ let mut completed_for_background = completed_for_viewer.clone();
+ viewing_client.project_snapshot(&mut completed_for_viewer);
+ background_client.project_snapshot(&mut completed_for_background);
+
+ assert!(viewing_client.acknowledge_surface(
+ &mut completed_for_viewer,
+ &surface(2),
+ Some(true)
+ ));
+
+ assert_eq!(
+ completed_for_viewer.agents[0].agent_status,
+ AgentStatus::Idle
+ );
+ assert_eq!(
+ completed_for_background.agents[0].agent_status,
+ AgentStatus::Done
+ );
+ }
+
+ #[test]
+ fn stale_or_unfocused_surface_does_not_acknowledge_completion() {
+ let mut presentation = EndpointAgentPresentation::default();
+ let mut initial = snapshot(AgentStatus::Working, 4, 1);
+ presentation.project_snapshot(&mut initial);
+ let mut completed = snapshot(AgentStatus::Idle, 5, 2);
+ presentation.project_snapshot(&mut completed);
+
+ assert!(!presentation.acknowledge_surface(&mut completed, &surface(1), Some(true)));
+ assert!(!presentation.acknowledge_surface(&mut completed, &surface(2), Some(false)));
+ assert_eq!(completed.agents[0].agent_status, AgentStatus::Done);
+ }
+}
diff --git a/src/client/shell/endpoint_agents.rs b/src/client/shell/endpoint_agents.rs
new file mode 100644
index 00000000..a49fccf9
--- /dev/null
+++ b/src/client/shell/endpoint_agents.rs
@@ -0,0 +1,129 @@
+use super::render::put_text;
+use super::*;
+
+pub(super) fn render_collapsed(
+ buffer: &mut Buffer,
+ area: Rect,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
+ config: &ClientShellConfig,
+ hits: &mut ShellHitMap,
+) {
+ let rows = agent_rows(endpoints, active_endpoint_id, config);
+ for (index, row) in rows.into_iter().take(area.height as usize).enumerate() {
+ let rect = Rect::new(area.x, area.y + index as u16, area.width, 1);
+ if row.agent.focused {
+ buffer.set_style(rect, Style::default().bg(config.palette.active_row_bg));
+ }
+ let initial = row.machine_label.chars().next().unwrap_or('?');
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ rect.width,
+ &format!(
+ "{initial}{}",
+ status_icon(row.agent.status, config.status_indicators)
+ ),
+ Style::default()
+ .fg(if row.stale {
+ config.palette.overlay0
+ } else {
+ status_color(row.agent.status, &config.palette)
+ })
+ .add_modifier(if row.stale {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ }),
+ );
+ hits.endpoint_agents
+ .push((rect, row.endpoint_id, row.agent.pane_id));
+ }
+}
+
+pub(super) fn render_expanded(
+ buffer: &mut Buffer,
+ area: Rect,
+ agent_view_label: Option<&str>,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
+ config: &ClientShellConfig,
+ agent_scroll: &mut usize,
+ hits: &mut ShellHitMap,
+) {
+ if !super::agent_sidebar::render_agent_panel_header(
+ buffer,
+ area,
+ agent_view_label,
+ config,
+ hits,
+ ) {
+ return;
+ }
+ let rows = agent_rows(endpoints, active_endpoint_id, config);
+ super::agent_sidebar::render_agent_list(
+ buffer,
+ area,
+ &rows,
+ agent_view_label.map(|_| " no matching agents"),
+ config,
+ agent_scroll,
+ hits,
+ |row| row.agent.rows.len(),
+ |buffer, rect, row, hits| {
+ super::agent_sidebar::render_agent_row(buffer, rect, &row.agent, config);
+ if row.stale {
+ buffer.set_style(
+ rect,
+ Style::default()
+ .fg(config.palette.overlay0)
+ .add_modifier(Modifier::DIM),
+ );
+ }
+ hits.endpoint_agents
+ .push((rect, row.endpoint_id.clone(), row.agent.pane_id.clone()));
+ },
+ );
+}
+
+struct EndpointAgentRow {
+ endpoint_id: ClientEndpointId,
+ machine_label: String,
+ stale: bool,
+ agent: super::agent_sidebar::AgentRow,
+}
+
+fn agent_rows(
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
+ config: &ClientShellConfig,
+) -> Vec<EndpointAgentRow> {
+ let mut rendered_rows = endpoints
+ .iter()
+ .filter_map(|endpoint| {
+ endpoint.snapshot.as_deref().map(|snapshot| {
+ super::agent_sidebar::agent_rows(snapshot, config, Some(&endpoint.label))
+ .into_iter()
+ .map(|agent| ((endpoint.endpoint_id.clone(), agent.pane_id.clone()), agent))
+ .collect::<Vec<_>>()
+ })
+ })
+ .flatten()
+ .collect::<HashMap<_, _>>();
+
+ super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort)
+ .into_iter()
+ .filter_map(|row| {
+ let key = (row.endpoint.endpoint_id.clone(), row.agent.pane_id.clone());
+ let mut agent = rendered_rows.remove(&key)?;
+ agent.focused &= row.endpoint.endpoint_id == active_endpoint_id;
+ Some(EndpointAgentRow {
+ endpoint_id: row.endpoint.endpoint_id.clone(),
+ machine_label: row.endpoint.label.to_owned(),
+ stale: row.endpoint.stale(),
+ agent,
+ })
+ })
+ .collect()
+}
diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs
new file mode 100644
index 00000000..adb29c93
--- /dev/null
+++ b/src/client/shell/endpoint_navigation.rs
@@ -0,0 +1,290 @@
+use super::*;
+
+impl ClientShellState {
+ pub(super) fn active_endpoint_workspace_at(&self, point: (u16, u16)) -> Option<String> {
+ self.hits
+ .workspaces
+ .iter()
+ .find(|hit| {
+ hit.endpoint_id == self.active_endpoint_id && super::contains(hit.rect, point)
+ })
+ .map(|hit| hit.workspace_id.clone())
+ }
+
+ pub(super) fn endpoint_workspace_is_draggable(&self, press: &ClientWorkspacePress) -> bool {
+ press.endpoint_id == self.active_endpoint_id
+ && self
+ .snapshot
+ .as_deref()
+ .and_then(|snapshot| {
+ snapshot
+ .workspaces
+ .iter()
+ .find(|workspace| workspace.workspace_id == press.workspace_id)
+ })
+ .is_some_and(|workspace| {
+ !workspace
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| worktree.is_linked_worktree)
+ })
+ }
+
+ pub(super) fn finish_endpoint_workspace_press(
+ &mut self,
+ press: ClientWorkspacePress,
+ outcome: &mut ClientShellInput,
+ ) {
+ if press.endpoint_id == self.active_endpoint_id {
+ self.push_endpoint_method(
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id: press.workspace_id,
+ }),
+ outcome,
+ );
+ } else if self.endpoint_is_online(&press.endpoint_id) {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id: press.endpoint_id,
+ target: Some(ClientEndpointFocusTarget::Workspace(press.workspace_id)),
+ });
+ } else {
+ let label = self.endpoint_label(&press.endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ outcome.repaint = true;
+ }
+ }
+
+ pub(super) fn handle_endpoint_machine_click(
+ &mut self,
+ point: (u16, u16),
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ let Some(endpoint_id) = self
+ .hits
+ .machines
+ .iter()
+ .find(|hit| super::contains(hit.rect, point))
+ .map(|hit| hit.endpoint_id.clone())
+ else {
+ return false;
+ };
+ if endpoint_id == self.active_endpoint_id {
+ if !self.collapsed_endpoints.remove(&endpoint_id) {
+ self.collapsed_endpoints.insert(endpoint_id);
+ }
+ outcome.repaint = true;
+ } else if self.endpoint_is_online(&endpoint_id) {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: None,
+ });
+ } else {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ outcome.repaint = true;
+ }
+ true
+ }
+
+ pub(super) fn handle_endpoint_agent_click(
+ &mut self,
+ point: (u16, u16),
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ let Some((endpoint_id, pane_id)) = self
+ .hits
+ .endpoint_agents
+ .iter()
+ .find(|(rect, _, _)| super::contains(*rect, point))
+ .map(|(_, endpoint_id, pane_id)| (endpoint_id.clone(), pane_id.clone()))
+ else {
+ return false;
+ };
+ if !self.endpoint_is_online(&endpoint_id) {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is reconnecting"));
+ outcome.repaint = true;
+ } else if endpoint_id == self.active_endpoint_id {
+ self.push_endpoint_method(
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }),
+ outcome,
+ );
+ } else {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ });
+ }
+ true
+ }
+
+ pub(super) fn handle_endpoint_navigation(
+ &mut self,
+ action: crate::input::KeybindAction,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ use crate::input::KeybindAction;
+ if !self.multi_endpoint_active() {
+ return false;
+ }
+ if matches!(
+ action,
+ KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace
+ ) {
+ let workspaces = self
+ .endpoints
+ .iter()
+ .filter(|endpoint| endpoint.status == ClientEndpointStatus::Online)
+ .flat_map(|endpoint| {
+ endpoint
+ .snapshot
+ .as_deref()
+ .map_or_else(Vec::new, |snapshot| {
+ render::workspace_entries(snapshot, &HashSet::new())
+ .into_iter()
+ .filter_map(|entry| {
+ snapshot.workspaces.get(entry.index).map(|workspace| {
+ (
+ endpoint.endpoint_id.clone(),
+ workspace.workspace_id.clone(),
+ )
+ })
+ })
+ .collect()
+ })
+ })
+ .collect::<Vec<_>>();
+ if workspaces.is_empty() {
+ return true;
+ }
+ let focused = self
+ .snapshot
+ .as_deref()
+ .and_then(|snapshot| snapshot.focused_workspace_id.as_deref());
+ let current = workspaces.iter().position(|(endpoint_id, workspace_id)| {
+ endpoint_id == &self.active_endpoint_id && Some(workspace_id.as_str()) == focused
+ });
+ let next = match (current, action) {
+ (Some(index), KeybindAction::PreviousWorkspace) => {
+ (index + workspaces.len() - 1) % workspaces.len()
+ }
+ (Some(index), KeybindAction::NextWorkspace) => (index + 1) % workspaces.len(),
+ (None, KeybindAction::PreviousWorkspace) => workspaces.len() - 1,
+ (None, KeybindAction::NextWorkspace) => 0,
+ _ => unreachable!("endpoint workspace navigation"),
+ };
+ let (endpoint_id, workspace_id) = workspaces[next].clone();
+ self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Workspace(workspace_id),
+ outcome,
+ );
+ return true;
+ }
+ if matches!(
+ action,
+ KeybindAction::PreviousAgent | KeybindAction::NextAgent | KeybindAction::FocusAgent(_)
+ ) {
+ let agents = super::aggregate_navigation::online_agent_targets(
+ &self.endpoints,
+ self.config.agent_panel_sort,
+ );
+ if agents.is_empty() {
+ return true;
+ }
+ let next = match action {
+ KeybindAction::FocusAgent(index) => {
+ if index >= agents.len() {
+ return true;
+ }
+ index
+ }
+ KeybindAction::PreviousAgent | KeybindAction::NextAgent => {
+ let focused = self
+ .snapshot
+ .as_deref()
+ .and_then(|snapshot| snapshot.focused_pane_id.as_deref());
+ let current = agents.iter().position(|target| {
+ target.endpoint_id == self.active_endpoint_id
+ && Some(target.pane_id.as_str()) == focused
+ });
+ match (current, action) {
+ (Some(index), KeybindAction::PreviousAgent) => {
+ (index + agents.len() - 1) % agents.len()
+ }
+ (Some(index), KeybindAction::NextAgent) => (index + 1) % agents.len(),
+ (None, KeybindAction::PreviousAgent) => agents.len() - 1,
+ _ => 0,
+ }
+ }
+ _ => unreachable!("endpoint agent navigation"),
+ };
+ let target = &agents[next];
+ self.focus_or_activate(
+ target.endpoint_id.clone(),
+ ClientEndpointFocusTarget::Pane(target.pane_id.clone()),
+ outcome,
+ );
+ return true;
+ }
+ false
+ }
+
+ pub(super) fn activate_endpoint(
+ &mut self,
+ endpoint_id: ClientEndpointId,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ if !self.endpoint_is_online(&endpoint_id) {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ outcome.repaint = true;
+ return false;
+ }
+ if endpoint_id != self.active_endpoint_id {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: None,
+ });
+ }
+ true
+ }
+
+ pub(super) fn focus_or_activate(
+ &mut self,
+ endpoint_id: ClientEndpointId,
+ target: ClientEndpointFocusTarget,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ if !self.endpoint_is_online(&endpoint_id) {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ outcome.repaint = true;
+ return false;
+ }
+ if endpoint_id == self.active_endpoint_id {
+ let method = match target {
+ ClientEndpointFocusTarget::Workspace(workspace_id) => {
+ crate::api::schema::Method::WorkspaceFocus(
+ crate::api::schema::WorkspaceTarget { workspace_id },
+ )
+ }
+ ClientEndpointFocusTarget::Tab(tab_id) => {
+ crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id })
+ }
+ ClientEndpointFocusTarget::Pane(pane_id) => {
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget {
+ pane_id,
+ })
+ }
+ };
+ self.push_endpoint_method(method, outcome);
+ } else {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: Some(target),
+ });
+ }
+ true
+ }
+}
diff --git a/src/client/shell/endpoint_notices.rs b/src/client/shell/endpoint_notices.rs
new file mode 100644
index 00000000..e796d2c5
--- /dev/null
+++ b/src/client/shell/endpoint_notices.rs
@@ -0,0 +1,80 @@
+use super::*;
+use ratatui::widgets::{Clear, Widget};
+
+pub(super) fn render_mobile_banner(
+ buffer: &mut Buffer,
+ area: Rect,
+ notice: &ClientVisibleEndpointNotice,
+ offset_for_warning: bool,
+ palette: &Palette,
+) -> Rect {
+ super::notifications::render_mobile_notice_banner(
+ buffer,
+ area,
+ &notice.title,
+ Some(&notice.body),
+ palette.red,
+ offset_for_warning,
+ palette,
+ )
+}
+
+pub(super) fn render_lifecycle_banner(
+ buffer: &mut Buffer,
+ area: Rect,
+ label: &str,
+ status: ClientEndpointStatus,
+ top_offset: u16,
+ palette: &Palette,
+) -> Rect {
+ if area.is_empty() || status == ClientEndpointStatus::Online {
+ return Rect::default();
+ }
+ let (symbol, state, color) = endpoint_status_presentation(status, palette);
+ let text = format!("{symbol} {label} · {state}");
+ let width = u16::try_from(unicode_width::UnicodeWidthStr::width(text.as_str()) + 2)
+ .unwrap_or(u16::MAX)
+ .min(area.width);
+ let y = area
+ .y
+ .saturating_add(top_offset)
+ .min(area.bottom().saturating_sub(1));
+ let rect = Rect::new(area.right().saturating_sub(width), y, width, 1);
+ Clear.render(rect, buffer);
+ buffer.set_style(rect, Style::default().bg(palette.surface0));
+ super::render::put_text(
+ buffer,
+ rect.x.saturating_add(1),
+ rect.y,
+ rect.width.saturating_sub(2),
+ &text,
+ Style::default().fg(color).bg(palette.surface0),
+ );
+ rect
+}
+
+pub(super) fn render_notice(
+ buffer: &mut Buffer,
+ area: Rect,
+ notice: &ClientVisibleEndpointNotice,
+ top_offset: u16,
+ palette: &Palette,
+) -> Rect {
+ super::notifications::render_notification_card(
+ buffer,
+ area,
+ &notice.title,
+ &notice.body,
+ crate::config::ToastHerdrPosition::TopRight,
+ top_offset,
+ match notice.key.kind {
+ ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => {
+ palette.red
+ }
+ ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => {
+ palette.yellow
+ }
+ },
+ palette,
+ )
+}
diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs
new file mode 100644
index 00000000..51c940cd
--- /dev/null
+++ b/src/client/shell/endpoint_sidebar.rs
@@ -0,0 +1,452 @@
+use super::render::{display_width, put_right_text, put_text, ShellRenderState};
+use super::*;
+
+pub(super) fn render_collapsed(
+ buffer: &mut Buffer,
+ area: Rect,
+ config: &ClientShellConfig,
+ state: &mut ShellRenderState<'_>,
+ hits: &mut ShellHitMap,
+) {
+ let palette = &config.palette;
+ super::render::render_sidebar_background(buffer, area, palette);
+ let (workspace_area, divider_y, detail_area) = super::sidebar::collapsed_sidebar_sections(area);
+ let mut y = workspace_area.y;
+ for (index, endpoint) in state.endpoints.iter().enumerate() {
+ if y >= workspace_area.bottom() {
+ break;
+ }
+ let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1);
+ let active = &endpoint.endpoint_id == state.active_endpoint_id;
+ let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id);
+ if active && collapsed {
+ buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ }
+ let label = if endpoint.endpoint_id.is_local() {
+ "L".to_owned()
+ } else {
+ (index + 1).to_string()
+ };
+ let marker = if collapsed { "▸" } else { "▾" };
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ rect.width.saturating_sub(1),
+ &format!("{marker}{label}"),
+ Style::default().fg(if endpoint.status == ClientEndpointStatus::Online {
+ palette.text
+ } else {
+ palette.overlay0
+ }),
+ );
+ if !endpoint.endpoint_id.is_local() {
+ let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette);
+ put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color));
+ }
+ hits.machines.push(MachineHit {
+ rect,
+ endpoint_id: endpoint.endpoint_id.clone(),
+ });
+ y = y.saturating_add(1);
+ if collapsed {
+ continue;
+ }
+ let Some(snapshot) = endpoint.snapshot.as_deref() else {
+ continue;
+ };
+ for workspace in &snapshot.workspaces {
+ if y >= workspace_area.bottom() {
+ break;
+ }
+ let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1);
+ let focused = active && workspace.focused;
+ if focused {
+ buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ }
+ let stale = endpoint.status != ClientEndpointStatus::Online;
+ let number = format!(" {}", workspace.number);
+ let number_width = super::render::display_width(&number).min(rect.width);
+ let dim = if stale {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ };
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ number_width,
+ &number,
+ Style::default()
+ .fg(if focused && !stale {
+ palette.text
+ } else {
+ palette.overlay0
+ })
+ .add_modifier(dim),
+ );
+ put_text(
+ buffer,
+ rect.x.saturating_add(number_width),
+ rect.y,
+ rect.width.saturating_sub(number_width),
+ status_icon(workspace.agent_status, config.status_indicators),
+ Style::default()
+ .fg(if stale {
+ palette.overlay0
+ } else {
+ status_color(workspace.agent_status, palette)
+ })
+ .add_modifier(dim),
+ );
+ hits.workspaces.push(WorkspaceHit {
+ rect,
+ endpoint_id: endpoint.endpoint_id.clone(),
+ workspace_id: workspace.workspace_id.clone(),
+ indented: false,
+ group_toggle: None,
+ });
+ y = y.saturating_add(1);
+ }
+ }
+ if let Some(divider_y) = divider_y {
+ put_text(
+ buffer,
+ workspace_area.x,
+ divider_y,
+ workspace_area.width,
+ &"─".repeat(workspace_area.width as usize),
+ Style::default().fg(palette.surface_dim),
+ );
+ }
+ super::endpoint_agents::render_collapsed(
+ buffer,
+ detail_area,
+ state.endpoints,
+ state.active_endpoint_id,
+ config,
+ hits,
+ );
+ hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 {
+ Rect::default()
+ } else {
+ Rect::new(
+ workspace_area.x + workspace_area.width / 2,
+ area.bottom().saturating_sub(1),
+ 1,
+ 1,
+ )
+ };
+ put_text(
+ buffer,
+ hits.sidebar_toggle.x,
+ hits.sidebar_toggle.y,
+ hits.sidebar_toggle.width,
+ "»",
+ Style::default().fg(palette.overlay0),
+ );
+}
+
+pub(super) fn render_expanded(
+ buffer: &mut Buffer,
+ area: Rect,
+ active_snapshot: Option<&ClientShellSnapshot>,
+ config: &ClientShellConfig,
+ state: &mut ShellRenderState<'_>,
+ hits: &mut ShellHitMap,
+) {
+ let palette = &config.palette;
+ super::render::render_sidebar_background(buffer, area, palette);
+ hits.sidebar_divider = if area.is_empty() {
+ Rect::default()
+ } else {
+ Rect::new(area.right().saturating_sub(1), area.y, 1, area.height)
+ };
+ let (workspace_area, detail_area) =
+ crate::ui::expanded_sidebar_sections(area, state.sidebar_section_split);
+ hits.sidebar_section_divider =
+ crate::ui::sidebar_section_divider_rect(area, state.sidebar_section_split);
+ put_text(
+ buffer,
+ workspace_area.x,
+ workspace_area.y,
+ workspace_area.width,
+ " machines",
+ Style::default()
+ .fg(palette.overlay0)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ enum Row {
+ Endpoint(usize),
+ Workspace {
+ endpoint: usize,
+ entry: WorkspaceEntry,
+ },
+ }
+ let mut rows = Vec::new();
+ for (endpoint_index, endpoint) in state.endpoints.iter().enumerate() {
+ rows.push(Row::Endpoint(endpoint_index));
+ if state.collapsed_endpoints.contains(&endpoint.endpoint_id) {
+ continue;
+ }
+ if let Some(snapshot) = endpoint.snapshot.as_deref() {
+ rows.extend(
+ super::sidebar::workspace_entries(snapshot, &HashSet::new())
+ .into_iter()
+ .map(|entry| Row::Workspace {
+ endpoint: endpoint_index,
+ entry,
+ }),
+ );
+ }
+ }
+ let body = Rect::new(
+ workspace_area.x,
+ workspace_area.y.saturating_add(WORKSPACE_HEADER_ROWS),
+ workspace_area.width,
+ workspace_area
+ .height
+ .saturating_sub(WORKSPACE_HEADER_ROWS + 1),
+ );
+ hits.workspace_body = body;
+ let row_heights = rows
+ .iter()
+ .map(|row| match row {
+ Row::Endpoint(_) => 1,
+ Row::Workspace { endpoint, entry } => state.endpoints[*endpoint]
+ .snapshot
+ .as_deref()
+ .and_then(|snapshot| snapshot.workspaces.get(entry.index))
+ .map(|workspace| {
+ super::sidebar::workspace_rows(
+ workspace,
+ workspace.agent_status,
+ entry.indented,
+ &config.spaces,
+ )
+ .len()
+ .max(1)
+ .min(u16::MAX as usize) as u16
+ })
+ .unwrap_or(1),
+ })
+ .collect::<Vec<_>>();
+ let gaps = vec![0; rows.len()];
+ let metrics = super::scroll::list_scroll_metrics(
+ &row_heights,
+ &gaps,
+ body.height,
+ *state.workspace_scroll,
+ );
+ hits.workspace_max_scroll = metrics.max_offset_from_bottom;
+ hits.workspace_scroll_metrics = Some(metrics);
+ *state.workspace_scroll = metrics
+ .max_offset_from_bottom
+ .saturating_sub(metrics.offset_from_bottom);
+ let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1;
+ let content_width = body.width.saturating_sub(u16::from(show_scrollbar));
+ let mut y = body.y;
+ for row in rows.iter().skip(*state.workspace_scroll) {
+ match row {
+ Row::Endpoint(index) => {
+ if y >= body.bottom() {
+ break;
+ }
+ let endpoint = &state.endpoints[*index];
+ let rect = Rect::new(body.x, y, content_width, 1);
+ let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id);
+ let marker = if collapsed { "▸" } else { "▾" };
+ render_endpoint_row(
+ buffer,
+ rect,
+ marker,
+ endpoint,
+ collapsed && &endpoint.endpoint_id == state.active_endpoint_id,
+ palette,
+ );
+ hits.machines.push(MachineHit {
+ rect,
+ endpoint_id: endpoint.endpoint_id.clone(),
+ });
+ y = y.saturating_add(1);
+ }
+ Row::Workspace { endpoint, entry } => {
+ let endpoint = &state.endpoints[*endpoint];
+ let Some(snapshot) = endpoint.snapshot.as_deref() else {
+ continue;
+ };
+ let Some(workspace) = snapshot.workspaces.get(entry.index) else {
+ continue;
+ };
+ let tokens = super::sidebar::workspace_rows(
+ workspace,
+ workspace.agent_status,
+ entry.indented,
+ &config.spaces,
+ );
+ let height = (tokens.len().max(1).min(u16::MAX as usize) as u16).min(body.height);
+ if y.saturating_add(height) > body.bottom() {
+ break;
+ }
+ let rect = Rect::new(body.x, y, content_width, height);
+ let nested = Rect::new(
+ rect.x.saturating_add(2),
+ rect.y,
+ rect.width.saturating_sub(2),
+ rect.height,
+ );
+ let endpoint_active = &endpoint.endpoint_id == state.active_endpoint_id;
+ super::sidebar::render_workspace_rows(
+ buffer,
+ nested,
+ workspace,
+ workspace.agent_status,
+ config.status_indicators,
+ entry,
+ tokens,
+ endpoint_active,
+ false,
+ false,
+ palette,
+ );
+ if endpoint.status != ClientEndpointStatus::Online {
+ buffer.set_style(
+ rect,
+ Style::default()
+ .fg(palette.overlay0)
+ .add_modifier(Modifier::DIM),
+ );
+ }
+ hits.workspaces.push(WorkspaceHit {
+ rect,
+ endpoint_id: endpoint.endpoint_id.clone(),
+ workspace_id: workspace.workspace_id.clone(),
+ indented: entry.indented,
+ group_toggle: None,
+ });
+ y = y.saturating_add(height);
+ }
+ }
+ }
+ if show_scrollbar {
+ let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height);
+ hits.workspace_scrollbar = track;
+ super::scroll::render_list_scrollbar(buffer, track, metrics, palette);
+ }
+
+ let footer_y = workspace_area.bottom().saturating_sub(1);
+ if config.mouse_capture {
+ let label = format!(" new · {}", active_endpoint_label(state));
+ hits.new_workspace = Rect::new(
+ workspace_area.x,
+ footer_y,
+ display_width(&label).min(workspace_area.width),
+ u16::from(workspace_area.height > 0),
+ );
+ put_text(
+ buffer,
+ workspace_area.x,
+ footer_y,
+ workspace_area.width,
+ &label,
+ Style::default().fg(palette.overlay0),
+ );
+ let attention = active_snapshot.is_some_and(super::global_menu::global_menu_attention);
+ let width = if attention { 8 } else { 6 }.min(workspace_area.width);
+ hits.global_launcher = Rect::new(
+ workspace_area.right().saturating_sub(width),
+ footer_y,
+ width,
+ 1,
+ );
+ put_right_text(
+ buffer,
+ workspace_area,
+ footer_y,
+ if attention { "● menu" } else { "menu" },
+ Style::default().fg(if attention {
+ palette.accent
+ } else {
+ palette.overlay0
+ }),
+ );
+ }
+ super::endpoint_agents::render_expanded(
+ buffer,
+ detail_area,
+ active_snapshot.and_then(|snapshot| snapshot.agent_view_label.as_deref()),
+ state.endpoints,
+ state.active_endpoint_id,
+ config,
+ state.agent_scroll,
+ hits,
+ );
+ hits.sidebar_toggle = Rect::new(
+ area.right().saturating_sub(2),
+ area.bottom().saturating_sub(1),
+ u16::from(area.width > 1),
+ u16::from(area.height > 0),
+ );
+ put_text(
+ buffer,
+ hits.sidebar_toggle.x,
+ hits.sidebar_toggle.y,
+ hits.sidebar_toggle.width,
+ "«",
+ Style::default().fg(palette.overlay0),
+ );
+}
+
+fn active_endpoint_label<'a>(state: &'a ShellRenderState<'_>) -> &'a str {
+ state
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == state.active_endpoint_id)
+ .map_or("Local", |endpoint| endpoint.label.as_str())
+}
+
+fn render_endpoint_row(
+ buffer: &mut Buffer,
+ rect: Rect,
+ marker: &str,
+ endpoint: &ClientShellEndpoint,
+ highlighted: bool,
+ palette: &Palette,
+) {
+ if highlighted {
+ buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ }
+ let (glyph, state, color) = endpoint_status_presentation(endpoint.status, palette);
+ let state = if endpoint.status == ClientEndpointStatus::Online {
+ ""
+ } else {
+ state
+ };
+ let signal = if endpoint.endpoint_id.is_local() {
+ String::new()
+ } else if state.is_empty() {
+ glyph.to_owned()
+ } else {
+ format!("{glyph} {state}")
+ };
+ let signal_width = display_width(&signal).min(rect.width);
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ rect.width.saturating_sub(signal_width.saturating_add(1)),
+ &format!(" {marker} {}", endpoint.label),
+ Style::default()
+ .fg(
+ if matches!(endpoint.status, ClientEndpointStatus::Disabled) {
+ palette.overlay0
+ } else {
+ palette.text
+ },
+ )
+ .add_modifier(Modifier::BOLD),
+ );
+ put_right_text(buffer, rect, rect.y, &signal, Style::default().fg(color));
+}
diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs
new file mode 100644
index 00000000..2f5a8e46
--- /dev/null
+++ b/src/client/shell/endpoints.rs
@@ -0,0 +1,506 @@
+use super::*;
+
+#[derive(Clone, Debug)]
+pub(crate) struct ClientShellEndpoint {
+ pub(crate) endpoint_id: ClientEndpointId,
+ pub(crate) label: String,
+ pub(crate) status: ClientEndpointStatus,
+ pub(crate) snapshot: Option<Box<ClientShellSnapshot>>,
+ /// Connection generation that produced `snapshot`. `None` is reserved for local tests.
+ pub(crate) snapshot_generation: Option<u64>,
+ pub(crate) agent_recency: HashMap<String, u64>,
+ pub(super) agent_presentation: super::endpoint_agent_state::EndpointAgentPresentation,
+ pub(crate) methods: Option<HashSet<String>>,
+}
+
+pub(super) struct MachineHit {
+ pub(super) rect: Rect,
+ pub(super) endpoint_id: ClientEndpointId,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum ClientEndpointFocusTarget {
+ Workspace(String),
+ Tab(String),
+ Pane(String),
+}
+
+impl ClientShellState {
+ pub(crate) fn set_endpoint_catalog(&mut self, profiles: &[SavedSshEndpoint]) {
+ let mut next = Vec::with_capacity(profiles.len().saturating_add(1));
+ let local = self
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id.is_local())
+ .cloned()
+ .unwrap_or_else(local_endpoint);
+ next.push(local);
+ for profile in profiles {
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ let previous = self
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .filter(|endpoint| {
+ profile.enabled && endpoint.status != ClientEndpointStatus::Disabled
+ });
+ next.push(ClientShellEndpoint {
+ endpoint_id,
+ label: profile.label.clone(),
+ status: previous.map_or(
+ if profile.enabled {
+ ClientEndpointStatus::Connecting
+ } else {
+ ClientEndpointStatus::Disabled
+ },
+ |endpoint| endpoint.status,
+ ),
+ snapshot: previous.and_then(|endpoint| endpoint.snapshot.clone()),
+ snapshot_generation: previous.and_then(|endpoint| endpoint.snapshot_generation),
+ agent_recency: previous
+ .map(|endpoint| endpoint.agent_recency.clone())
+ .unwrap_or_default(),
+ agent_presentation: previous
+ .map(|endpoint| endpoint.agent_presentation.clone())
+ .unwrap_or_default(),
+ methods: previous.and_then(|endpoint| endpoint.methods.clone()),
+ });
+ }
+
+ if !next
+ .iter()
+ .any(|endpoint| endpoint.endpoint_id == self.active_endpoint_id)
+ {
+ self.select_unavailable_local();
+ }
+ self.collapsed_endpoints.retain(|endpoint_id| {
+ next.iter()
+ .any(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ });
+ self.endpoints = next;
+ }
+
+ pub(crate) fn select_unavailable_local(&mut self) {
+ self.reset_endpoint_projection();
+ self.active_endpoint_id = ClientEndpointId::Local;
+ self.mode = ClientShellMode::Terminal;
+ self.snapshot = None;
+ self.graphics.set_scope("local:unavailable");
+ self.reconcile_input_source();
+ }
+
+ pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) {
+ self.retire_endpoint_notifications(endpoint_id);
+ if let Some(endpoint) = self
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ {
+ endpoint.status = ClientEndpointStatus::Disabled;
+ endpoint.snapshot = None;
+ endpoint.snapshot_generation = None;
+ endpoint.methods = None;
+ endpoint.agent_recency.clear();
+ endpoint.agent_presentation = Default::default();
+ }
+ }
+
+ pub(crate) fn set_endpoint_status(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ status: ClientEndpointStatus,
+ ) {
+ if let Some(endpoint) = self
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ {
+ endpoint.status = status;
+ }
+ }
+
+ pub(crate) fn mark_endpoint_disconnected(&mut self, endpoint_id: &ClientEndpointId) {
+ self.set_endpoint_status(endpoint_id, ClientEndpointStatus::Reconnecting);
+ if endpoint_id == &self.active_endpoint_id {
+ let pending = self.pending_requests.keys().cloned().collect::<Vec<_>>();
+ for request_id in pending {
+ self.cancel_endpoint_request(&request_id);
+ }
+ self.pending_integration_installs = 0;
+ self.pane_scroll_in_flight.clear();
+ self.pane_scroll_queued.clear();
+ }
+ }
+
+ pub(crate) fn set_endpoint_methods_for(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ methods: Option<Vec<String>>,
+ ) {
+ let methods = methods.map(|methods| methods.into_iter().collect::<HashSet<_>>());
+ if let Some(endpoint) = self
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ {
+ endpoint.methods = methods;
+ }
+ }
+
+ pub(crate) fn endpoint_projection_available(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.endpoints.iter().any(|endpoint| {
+ &endpoint.endpoint_id == endpoint_id
+ && endpoint.status == ClientEndpointStatus::Online
+ && endpoint.snapshot.is_some()
+ })
+ }
+
+ pub(crate) fn activate_endpoint_projection(&mut self, endpoint_id: &ClientEndpointId) -> bool {
+ let Some(endpoint) = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ else {
+ return false;
+ };
+ if endpoint.status != ClientEndpointStatus::Online {
+ return false;
+ }
+ let Some(snapshot) = endpoint.snapshot.clone() else {
+ return false;
+ };
+ if endpoint_id != &self.active_endpoint_id {
+ self.active_endpoint_id = endpoint_id.clone();
+ self.pane_surface = None;
+ self.pending_pane_surface = None;
+ }
+ self.apply_active_snapshot(snapshot);
+ true
+ }
+
+ pub(crate) fn endpoint_status(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ ) -> Option<ClientEndpointStatus> {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .map(|endpoint| endpoint.status)
+ }
+
+ pub(crate) fn endpoint_has_snapshot(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .is_some_and(|endpoint| endpoint.snapshot.is_some())
+ }
+
+ pub(crate) fn endpoint_is_online(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.endpoint_status(endpoint_id) == Some(ClientEndpointStatus::Online)
+ && self.endpoint_has_snapshot(endpoint_id)
+ }
+
+ pub(crate) fn endpoint_boot_id(&self, endpoint_id: &ClientEndpointId) -> Option<&str> {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)?
+ .snapshot
+ .as_deref()
+ .map(|snapshot| snapshot.boot_id.as_str())
+ }
+
+ pub(crate) fn endpoint_snapshot_matches(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ boot_id: &str,
+ revision: u64,
+ ) -> bool {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .is_some_and(|endpoint| {
+ endpoint
+ .snapshot_generation
+ .is_none_or(|snapshot_generation| snapshot_generation == generation)
+ && endpoint.snapshot.as_deref().is_some_and(|snapshot| {
+ snapshot.boot_id == boot_id && snapshot.revision == revision
+ })
+ })
+ }
+
+ pub(crate) fn endpoint_snapshot_identity(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ ) -> Option<(&str, u64)> {
+ let endpoint = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)?;
+ if endpoint
+ .snapshot_generation
+ .is_some_and(|snapshot_generation| snapshot_generation != generation)
+ {
+ return None;
+ }
+ endpoint
+ .snapshot
+ .as_deref()
+ .map(|snapshot| (snapshot.boot_id.as_str(), snapshot.revision))
+ }
+
+ /// A terminal normally starts focused. `None` means this host cannot report focus events,
+ /// not that the endpoint has no viewer; activation therefore sends an explicit true baseline.
+ pub(crate) fn host_focus_baseline(&self) -> bool {
+ self.outer_focused.unwrap_or(true)
+ }
+
+ pub(crate) fn endpoint_label(&self, endpoint_id: &ClientEndpointId) -> &str {
+ self.endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .map_or("Unknown endpoint", |endpoint| endpoint.label.as_str())
+ }
+
+ pub(crate) fn active_endpoint_label(&self) -> &str {
+ self.endpoint_label(&self.active_endpoint_id)
+ }
+
+ pub(crate) fn endpoint_is_active(&self, endpoint_id: &ClientEndpointId) -> bool {
+ &self.active_endpoint_id == endpoint_id
+ }
+
+ pub(crate) fn multi_endpoint_active(&self) -> bool {
+ self.endpoints.len() > 1
+ }
+
+ #[cfg(test)]
+ pub(crate) fn set_snapshot(&mut self, snapshot: Box<ClientShellSnapshot>) {
+ let endpoint_id = self.active_endpoint_id.clone();
+ self.set_endpoint_snapshot(&endpoint_id, snapshot);
+ }
+
+ #[cfg(test)]
+ pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) {
+ let endpoint_id = self.active_endpoint_id.clone();
+ self.set_endpoint_methods_for(&endpoint_id, methods);
+ }
+
+ pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool {
+ self.endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id)
+ .and_then(|endpoint| endpoint.methods.as_ref())
+ .is_none_or(|methods| methods.contains(crate::api::api_method_name(method)))
+ }
+
+ pub(super) fn focused_tab_count(&self) -> usize {
+ let Some(snapshot) = self.snapshot.as_deref() else {
+ return 0;
+ };
+ snapshot
+ .tabs
+ .iter()
+ .filter(|tab| {
+ Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref()
+ })
+ .count()
+ }
+
+ #[cfg(test)]
+ pub(crate) fn cache_endpoint_snapshot(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot_with_surface(endpoint_id, None, snapshot, true);
+ }
+
+ pub(crate) fn cache_endpoint_snapshot_for_generation(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, true);
+ }
+
+ /// Metadata delivered while an endpoint surface is inactive must never advance this
+ /// aggregate client's viewed watermark, even if the frozen source frame still exists.
+ pub(crate) fn cache_endpoint_snapshot_inactive_for_generation(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, false);
+ }
+
+ fn cache_endpoint_snapshot_with_surface(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: Option<u64>,
+ mut snapshot: Box<ClientShellSnapshot>,
+ acknowledge_surface: bool,
+ ) {
+ snapshot
+ .commands
+ .retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown);
+ let Some(index) = self
+ .endpoints
+ .iter()
+ .position(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ else {
+ return;
+ };
+ if self.endpoints[index].snapshot_generation == generation
+ && self.endpoints[index]
+ .snapshot
+ .as_deref()
+ .is_some_and(|previous| {
+ previous.boot_id == snapshot.boot_id && previous.revision > snapshot.revision
+ })
+ {
+ return;
+ }
+ let boot_changed = self.endpoints[index]
+ .snapshot
+ .as_deref()
+ .is_some_and(|previous| previous.boot_id != snapshot.boot_id);
+ if boot_changed {
+ self.retire_endpoint_notifications(endpoint_id);
+ }
+ self.endpoints[index]
+ .agent_presentation
+ .project_snapshot(&mut snapshot);
+ let presented_surface = if acknowledge_surface && endpoint_id == &self.active_endpoint_id {
+ self.pane_surface.as_ref()
+ } else {
+ None
+ };
+ if let Some(surface) = presented_surface {
+ self.endpoints[index]
+ .agent_presentation
+ .acknowledge_surface(&mut snapshot, surface, self.outer_focused);
+ }
+ let previous = self.endpoints[index].snapshot.as_deref();
+ let mut next_recency = self
+ .endpoints
+ .iter()
+ .flat_map(|endpoint| endpoint.agent_recency.values())
+ .copied()
+ .max()
+ .unwrap_or_default();
+ let mut agents = snapshot.agents.iter().collect::<Vec<_>>();
+ agents.sort_by_key(|agent| agent.state_change_seq);
+ let mut recency = self.endpoints[index].agent_recency.clone();
+ for agent in agents {
+ let changed = previous
+ .and_then(|snapshot| {
+ snapshot
+ .agents
+ .iter()
+ .find(|previous| previous.pane_id == agent.pane_id)
+ })
+ .is_none_or(|previous| previous.state_change_seq != agent.state_change_seq);
+ if changed {
+ next_recency = next_recency.saturating_add(1);
+ recency.insert(agent.pane_id.clone(), next_recency);
+ }
+ }
+ recency.retain(|pane_id, _| {
+ snapshot
+ .agents
+ .iter()
+ .any(|agent| &agent.pane_id == pane_id)
+ });
+ let endpoint = &mut self.endpoints[index];
+ endpoint.agent_recency = recency;
+ endpoint.snapshot_generation = generation;
+ endpoint.snapshot = Some(snapshot);
+ }
+
+ pub(crate) fn acknowledge_active_surface_agents(&mut self, surface: &PaneSurfaceFrame) -> bool {
+ let Some(index) = self
+ .endpoints
+ .iter()
+ .position(|endpoint| endpoint.endpoint_id == self.active_endpoint_id)
+ else {
+ return false;
+ };
+ let changed = {
+ let endpoint = &mut self.endpoints[index];
+ let Some(snapshot) = endpoint.snapshot.as_deref_mut() else {
+ return false;
+ };
+ endpoint
+ .agent_presentation
+ .acknowledge_surface(snapshot, surface, self.outer_focused)
+ };
+ if changed {
+ self.snapshot = self.endpoints[index].snapshot.clone();
+ }
+ changed
+ }
+
+ #[cfg(test)]
+ pub(crate) fn set_endpoint_snapshot(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot(endpoint_id, snapshot);
+ self.apply_cached_endpoint_snapshot(endpoint_id);
+ }
+
+ pub(crate) fn set_endpoint_snapshot_for_generation(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ snapshot: Box<ClientShellSnapshot>,
+ ) {
+ self.cache_endpoint_snapshot_for_generation(endpoint_id, generation, snapshot);
+ self.apply_cached_endpoint_snapshot(endpoint_id);
+ }
+
+ fn apply_cached_endpoint_snapshot(&mut self, endpoint_id: &ClientEndpointId) {
+ let Some(snapshot) = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.clone())
+ else {
+ return;
+ };
+ if endpoint_id == &self.active_endpoint_id {
+ self.apply_active_snapshot(snapshot);
+ }
+ }
+}
+
+pub(super) fn endpoint_status_presentation(
+ status: ClientEndpointStatus,
+ palette: &Palette,
+) -> (&'static str, &'static str, ratatui::style::Color) {
+ match status {
+ ClientEndpointStatus::Connecting => ("◐", "connecting", palette.yellow),
+ ClientEndpointStatus::Online => ("●", "online", palette.green),
+ ClientEndpointStatus::Reconnecting => ("◐", "reconnecting", palette.yellow),
+ ClientEndpointStatus::Attention => ("!", "attention", palette.red),
+ ClientEndpointStatus::Disabled => ("·", "disabled", palette.overlay0),
+ }
+}
+
+pub(super) fn local_endpoint() -> ClientShellEndpoint {
+ ClientShellEndpoint {
+ endpoint_id: ClientEndpointId::Local,
+ label: "Local".into(),
+ status: ClientEndpointStatus::Online,
+ snapshot: None,
+ snapshot_generation: None,
+ agent_recency: HashMap::new(),
+ agent_presentation: Default::default(),
+ methods: None,
+ }
+}
diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs
index 8a512d3a..31061c9b 100644
--- a/src/client/shell/graphics.rs
+++ b/src/client/shell/graphics.rs
@@ -3,10 +3,7 @@ use super::*;
impl ClientShellState {
#[cfg(unix)]
pub(crate) fn graphics_scope(&self) -> &str {
- self.snapshot
- .as_deref()
- .map(|snapshot| snapshot.boot_id.as_str())
- .unwrap_or_default()
+ self.graphics.scope()
}
#[cfg(unix)]
diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs
index d2ca3a82..f8826136 100644
--- a/src/client/shell/input.rs
+++ b/src/client/shell/input.rs
@@ -236,6 +236,9 @@ impl ClientShellState {
self.outer_focused = Some(true);
outcome.query_host_appearance = true;
outcome.repaint |= self.config.redraw_on_focus_gained;
+ if let Some(surface) = self.pane_surface.clone() {
+ outcome.repaint |= self.acknowledge_active_surface_agents(&surface);
+ }
outcome
.requests
.push(ClientMessage::ClientShellFocus { focused: true });
@@ -798,7 +801,36 @@ impl ClientShellState {
) {
use crate::input::{KeybindAction, KeybindMatch};
- let indexed_target_exists = match &binding {
+ if !self.indexed_navigation_target_exists(&binding) {
+ return;
+ }
+
+ if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding {
+ self.cycle_pane(false, outcome);
+ } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding {
+ self.cycle_pane(true, outcome);
+ } else {
+ if !preserve_navigate {
+ self.mode = ClientShellMode::Terminal;
+ }
+ self.record_binding(binding, outcome);
+ }
+ if !preserve_navigate {
+ if self.mode == ClientShellMode::Navigate {
+ self.mode = self.copy_or_terminal_mode();
+ }
+ self.navigate_workspace_id = None;
+ }
+ outcome.repaint = true;
+ }
+
+ pub(super) fn indexed_navigation_target_exists(
+ &self,
+ binding: &crate::input::KeybindMatch,
+ ) -> bool {
+ use crate::input::{KeybindAction, KeybindMatch};
+
+ match binding {
KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => {
self.snapshot.as_deref().is_some_and(|snapshot| {
self.navigation_workspace_entries(snapshot)
@@ -819,38 +851,15 @@ impl ClientShellState {
})
.is_some(),
KeybindMatch::Action(KeybindAction::FocusAgent(index)) => {
- self.snapshot.as_deref().is_some_and(|snapshot| {
- super::agent_sidebar::ordered_agent_pane_ids(
- snapshot,
- self.config.agent_panel_sort,
- )
- .get(*index)
- .is_some()
- })
+ super::aggregate_navigation::online_agent_targets(
+ &self.endpoints,
+ self.config.agent_panel_sort,
+ )
+ .get(*index)
+ .is_some()
}
_ => true,
- };
- if !indexed_target_exists {
- return;
}
-
- if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding {
- self.cycle_pane(false, outcome);
- } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding {
- self.cycle_pane(true, outcome);
- } else {
- if !preserve_navigate {
- self.mode = ClientShellMode::Terminal;
- }
- self.record_binding(binding, outcome);
- }
- if !preserve_navigate {
- if self.mode == ClientShellMode::Navigate {
- self.mode = self.copy_or_terminal_mode();
- }
- self.navigate_workspace_id = None;
- }
- outcome.repaint = true;
}
fn move_navigate_workspace(&mut self, delta: isize) {
diff --git a/src/client/shell/input_source.rs b/src/client/shell/input_source.rs
new file mode 100644
index 00000000..7997c72f
--- /dev/null
+++ b/src/client/shell/input_source.rs
@@ -0,0 +1,41 @@
+use super::*;
+
+impl ClientShellState {
+ fn wants_ascii_input(&self) -> bool {
+ if let Some(overlay) = self.overlay.as_ref() {
+ return matches!(
+ overlay,
+ ClientShellOverlay::ConfirmClose(_)
+ | ClientShellOverlay::Help(_)
+ | ClientShellOverlay::Navigator(_)
+ | ClientShellOverlay::WorktreeRemove(_)
+ | ClientShellOverlay::ContextMenu(_)
+ | ClientShellOverlay::GlobalMenu(_)
+ );
+ }
+ matches!(
+ self.mode,
+ ClientShellMode::Prefix
+ | ClientShellMode::Navigate
+ | ClientShellMode::Resize
+ | ClientShellMode::Copy
+ )
+ }
+
+ pub(crate) fn reconcile_input_source(&mut self) {
+ // Keep the platform restore token while another window has focus. Restoring
+ // through a global key injection is only safe after this client regains focus.
+ if self.outer_focused == Some(false) {
+ return;
+ }
+ let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input();
+ if desired != self.ascii_input_source_active {
+ self.ascii_input_source_active = desired;
+ self.pending_input_source_changes.push(desired);
+ }
+ }
+
+ pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> {
+ std::mem::take(&mut self.pending_input_source_changes)
+ }
+}
diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs
index 014f51a0..abe50590 100644
--- a/src/client/shell/mobile.rs
+++ b/src/client/shell/mobile.rs
@@ -202,6 +202,16 @@ fn render_header_button(
}
}
+fn mobile_endpoint_state(status: ClientEndpointStatus) -> &'static str {
+ match status {
+ ClientEndpointStatus::Connecting => "connecting",
+ ClientEndpointStatus::Online => "online",
+ ClientEndpointStatus::Reconnecting => "reconnecting",
+ ClientEndpointStatus::Attention => "attention",
+ ClientEndpointStatus::Disabled => "disabled",
+ }
+}
+
fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String {
let tabs = snapshot
.tabs
@@ -354,6 +364,8 @@ pub(super) fn render_mobile_switcher(
buffer: &mut Buffer,
area: Rect,
snapshot: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
config: &ClientShellConfig,
selected_workspace_id: Option<&str>,
scroll: &mut usize,
@@ -426,6 +438,8 @@ pub(super) fn render_mobile_switcher(
let items = mobile_items(
snapshot,
+ endpoints,
+ active_endpoint_id,
config,
selected_workspace_id,
viewport.width.saturating_sub(1),
@@ -440,8 +454,10 @@ pub(super) fn render_mobile_switcher(
let end = start.saturating_add(item.lines.len());
if matches!(
item.target.as_ref(),
- Some(ClientMobileTarget::Workspace(workspace_id))
- if workspace_id == selected_workspace_id
+ Some(ClientMobileTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ }) if endpoint_id == active_endpoint_id && workspace_id == selected_workspace_id
) {
if start < *scroll {
*scroll = start;
@@ -558,22 +574,51 @@ fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) {
fn mobile_items(
snapshot: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
config: &ClientShellConfig,
selected_workspace_id: Option<&str>,
content_width: u16,
) -> Vec<MobileItem> {
let palette = &config.palette;
let mut items = Vec::new();
- let ordered_agents =
- super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort);
- if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() {
- let title = snapshot
- .agent_view_label
- .as_deref()
+ if endpoints.len() > 1 {
+ items.push(MobileItem::section("machines", palette));
+ for endpoint in endpoints {
+ let background = palette.panel_bg;
+ let (symbol, state, color) = endpoint_status_presentation(endpoint.status, palette);
+ items.push(MobileItem {
+ lines: vec![
+ Line::from(vec![
+ Span::styled(" ", Style::default().bg(background)),
+ Span::styled(symbol, Style::default().fg(color).bg(background)),
+ Span::styled(
+ format!(" {}", endpoint.label),
+ Style::default()
+ .fg(palette.text)
+ .bg(background)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ]),
+ Line::from(Span::styled(
+ format!(" {state}"),
+ Style::default().fg(palette.overlay0).bg(background),
+ )),
+ ],
+ background,
+ target: Some(ClientMobileTarget::Machine(endpoint.endpoint_id.clone())),
+ });
+ }
+ }
+ let agents =
+ super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort);
+ let agent_view_label = snapshot.agent_view_label.as_deref();
+ if !agents.is_empty() || agent_view_label.is_some() {
+ let title = agent_view_label
.map(|label| format!("agents · {label}"))
.unwrap_or_else(|| "agents".to_owned());
items.push(MobileItem::section(title, palette));
- if ordered_agents.is_empty() {
+ if agents.is_empty() {
items.push(MobileItem {
lines: vec![Line::from(Span::styled(
" no matching agents",
@@ -586,19 +631,19 @@ fn mobile_items(
target: None,
});
}
- for pane_id in ordered_agents {
- let Some(agent) = snapshot
- .agents
- .iter()
- .find(|agent| agent.pane_id == pane_id)
- else {
- continue;
- };
- let workspace = snapshot
+ for row in agents {
+ let endpoint = row.endpoint;
+ let agent = row.agent;
+ let workspace = endpoint
+ .snapshot
.workspaces
.iter()
.find(|workspace| workspace.workspace_id == agent.workspace_id);
- let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id);
+ let tab = endpoint
+ .snapshot
+ .tabs
+ .iter()
+ .find(|tab| tab.tab_id == agent.tab_id);
let agent_label = agent
.display_agent
.as_deref()
@@ -609,7 +654,8 @@ fn mobile_items(
.map(|workspace| workspace.label.as_str())
.unwrap_or(agent_label);
let mut detail = Vec::new();
- let workspace_tab_count = snapshot
+ let workspace_tab_count = endpoint
+ .snapshot
.tabs
.iter()
.filter(|candidate| candidate.workspace_id == agent.workspace_id)
@@ -633,11 +679,24 @@ fn mobile_items(
}),
);
detail.push(agent_label.to_owned());
- let background = if agent.focused {
+ if endpoint.stale() {
+ detail.push(mobile_endpoint_state(endpoint.status).to_owned());
+ }
+ let background = if endpoint.endpoint_id == active_endpoint_id && agent.focused {
palette.surface_dim
} else {
palette.panel_bg
};
+ let foreground = if endpoint.stale() {
+ palette.overlay0
+ } else {
+ palette.text
+ };
+ let dim = if endpoint.stale() {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ };
items.push(MobileItem {
lines: vec![
Line::from(vec![
@@ -645,19 +704,24 @@ fn mobile_items(
Span::styled(
status_icon(agent.agent_status, config.status_indicators),
Style::default()
- .fg(status_color(agent.agent_status, palette))
- .bg(background),
+ .fg(if endpoint.stale() {
+ palette.overlay0
+ } else {
+ status_color(agent.agent_status, palette)
+ })
+ .bg(background)
+ .add_modifier(dim),
),
Span::styled(" ", Style::default().bg(background)),
Span::styled(
crate::ui::truncate_end(
- primary,
+ &format!("{} · {primary}", endpoint.label),
usize::from(content_width.saturating_sub(5)),
),
Style::default()
- .fg(palette.text)
+ .fg(foreground)
.bg(background)
- .add_modifier(Modifier::BOLD),
+ .add_modifier(Modifier::BOLD | dim),
),
]),
Line::from(Span::styled(
@@ -665,11 +729,17 @@ fn mobile_items(
&format!(" {}", detail.join(" · ")),
usize::from(content_width),
),
- Style::default().fg(palette.overlay0).bg(background),
+ Style::default()
+ .fg(palette.overlay0)
+ .bg(background)
+ .add_modifier(dim),
)),
],
background,
- target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())),
+ target: Some(ClientMobileTarget::Agent {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ pane_id: agent.pane_id.clone(),
+ }),
});
}
}
@@ -680,91 +750,119 @@ fn mobile_items(
ClientMobileTarget::NewWorkspace,
palette,
));
- for entry in super::render::workspace_entries(snapshot, &HashSet::new()) {
- let Some(workspace) = snapshot.workspaces.get(entry.index) else {
- continue;
- };
- let selected = selected_workspace_id == Some(workspace.workspace_id.as_str());
- let background = if selected {
- palette.surface0
- } else if workspace.focused {
- palette.surface_dim
- } else {
- palette.panel_bg
- };
- let connector = if entry.indented {
- if entry.last_child {
- "└─ "
+ for endpoint in super::aggregate_navigation::cached_endpoint_snapshots(endpoints) {
+ for entry in super::render::workspace_entries(endpoint.snapshot, &HashSet::new()) {
+ let Some(workspace) = endpoint.snapshot.workspaces.get(entry.index) else {
+ continue;
+ };
+ let selected = endpoint.endpoint_id == active_endpoint_id
+ && selected_workspace_id == Some(workspace.workspace_id.as_str());
+ let background = if selected {
+ palette.surface0
+ } else if endpoint.endpoint_id == active_endpoint_id && workspace.focused {
+ palette.surface_dim
} else {
- "├─ "
- }
- } else {
- ""
- };
- let name = if entry.indented && !workspace.custom_label {
- workspace
- .branch
- .as_deref()
- .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch)))
- .unwrap_or(&workspace.label)
- } else {
- &workspace.label
- };
- let branch = workspace.branch.as_deref().unwrap_or("shell");
- let detail_prefix = if entry.indented {
- if entry.last_child {
- " "
+ palette.panel_bg
+ };
+ let connector = if entry.indented {
+ if entry.last_child {
+ "└─ "
+ } else {
+ "├─ "
+ }
} else {
- " │ "
- }
- } else {
- " "
- };
- items.push(MobileItem {
- lines: vec![
- Line::from(vec![
- Span::styled(
- format!(" {connector}"),
- Style::default().fg(palette.overlay0).bg(background),
- ),
- Span::styled(
- status_icon(workspace.agent_status, config.status_indicators),
- Style::default()
- .fg(status_color(workspace.agent_status, palette))
- .bg(background),
- ),
- Span::styled(" ", Style::default().bg(background)),
- Span::styled(
+ ""
+ };
+ let name = if entry.indented && !workspace.custom_label {
+ workspace
+ .branch
+ .as_deref()
+ .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch)))
+ .unwrap_or(&workspace.label)
+ } else {
+ &workspace.label
+ };
+ let branch = workspace.branch.as_deref().unwrap_or("shell");
+ let detail_prefix = if entry.indented {
+ if entry.last_child {
+ " "
+ } else {
+ " │ "
+ }
+ } else {
+ " "
+ };
+ let dim = if endpoint.stale() {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ };
+ let foreground = if endpoint.stale() {
+ palette.overlay0
+ } else {
+ palette.text
+ };
+ let status = if endpoint.stale() {
+ palette.overlay0
+ } else {
+ status_color(workspace.agent_status, palette)
+ };
+ let stale_detail = if endpoint.stale() {
+ format!(" · {}", mobile_endpoint_state(endpoint.status))
+ } else {
+ String::new()
+ };
+ items.push(MobileItem {
+ lines: vec![
+ Line::from(vec![
+ Span::styled(
+ format!(" {connector}"),
+ Style::default()
+ .fg(palette.overlay0)
+ .bg(background)
+ .add_modifier(dim),
+ ),
+ Span::styled(
+ status_icon(workspace.agent_status, config.status_indicators),
+ Style::default().fg(status).bg(background).add_modifier(dim),
+ ),
+ Span::styled(" ", Style::default().bg(background)),
+ Span::styled(
+ crate::ui::truncate_end(
+ &format!("{} · {name}", endpoint.label),
+ usize::from(content_width.saturating_sub(if entry.indented {
+ 8
+ } else {
+ 5
+ })),
+ ),
+ Style::default()
+ .fg(foreground)
+ .bg(background)
+ .add_modifier(Modifier::BOLD | dim),
+ ),
+ ]),
+ Line::from(Span::styled(
crate::ui::truncate_end(
- name,
- usize::from(content_width.saturating_sub(if entry.indented {
- 8
- } else {
- 5
- })),
+ &format!(
+ "{detail_prefix}{branch} · {}{stale_detail}",
+ compact_tab_status(endpoint.snapshot, workspace)
+ ),
+ usize::from(content_width),
),
Style::default()
- .fg(palette.text)
+ .fg(palette.overlay0)
.bg(background)
- .add_modifier(Modifier::BOLD),
- ),
- ]),
- Line::from(Span::styled(
- crate::ui::truncate_end(
- &format!(
- "{detail_prefix}{branch} · {}",
- compact_tab_status(snapshot, workspace)
- ),
- usize::from(content_width),
- ),
- Style::default().fg(palette.overlay0).bg(background),
- )),
- ],
- background,
- target: Some(ClientMobileTarget::Workspace(
- workspace.workspace_id.clone(),
- )),
- });
+ .add_modifier(dim),
+ )),
+ ],
+ background,
+ target: Some(ClientMobileTarget::Workspace {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ workspace_id: workspace.workspace_id.clone(),
+ }),
+ });
+ }
}
if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() {
@@ -803,7 +901,10 @@ fn mobile_items(
.add_modifier(Modifier::BOLD),
))],
background,
- target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())),
+ target: Some(ClientMobileTarget::Tab {
+ endpoint_id: active_endpoint_id.clone(),
+ tab_id: tab.tab_id.clone(),
+ }),
});
}
}
@@ -890,6 +991,22 @@ impl ClientShellState {
.find(|(rect, _)| super::contains(*rect, point))
.map(|(_, target)| target.clone());
match target {
+ Some(ClientMobileTarget::Machine(endpoint_id)) => {
+ if endpoint_id == self.active_endpoint_id {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ } else if self.endpoint_is_online(&endpoint_id) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: None,
+ });
+ } else {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ }
+ }
Some(ClientMobileTarget::NewWorkspace) => {
self.mobile_switcher_suspended = true;
self.record_binding(
@@ -897,34 +1014,44 @@ impl ClientShellState {
outcome,
);
}
- Some(
- target @ (ClientMobileTarget::Workspace(_)
- | ClientMobileTarget::Tab(_)
- | ClientMobileTarget::Agent(_)),
- ) => {
- let method = match target {
- ClientMobileTarget::Workspace(workspace_id) => {
- crate::api::schema::Method::WorkspaceFocus(
- crate::api::schema::WorkspaceTarget { workspace_id },
- )
- }
- ClientMobileTarget::Tab(tab_id) => {
- crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget {
- tab_id,
- })
- }
- ClientMobileTarget::Agent(pane_id) => {
- crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget {
- pane_id,
- })
- }
- ClientMobileTarget::NewWorkspace
- | ClientMobileTarget::NewTab
- | ClientMobileTarget::Menu(_) => return true,
- };
- self.mode = ClientShellMode::Terminal;
- self.navigate_workspace_id = None;
- self.push_endpoint_method(method, outcome);
+ Some(ClientMobileTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ }) => {
+ if self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Workspace(workspace_id),
+ outcome,
+ ) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ }
+ }
+ Some(ClientMobileTarget::Agent {
+ endpoint_id,
+ pane_id,
+ }) => {
+ if self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Pane(pane_id),
+ outcome,
+ ) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ }
+ }
+ Some(ClientMobileTarget::Tab {
+ endpoint_id,
+ tab_id,
+ }) => {
+ if self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Tab(tab_id),
+ outcome,
+ ) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ }
}
Some(ClientMobileTarget::NewTab) => {
self.mobile_switcher_suspended = true;
diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs
index 612319e2..48646f28 100644
--- a/src/client/shell/mouse.rs
+++ b/src/client/shell/mouse.rs
@@ -466,6 +466,9 @@ impl ClientShellState {
if self.hits.workspace_body.height == 0
|| point.1 < self.hits.workspace_body.y.saturating_sub(1)
|| point.1 >= self.hits.new_workspace.y
+ || self.hits.workspaces.iter().any(|hit| {
+ hit.endpoint_id != self.active_endpoint_id && super::contains(hit.rect, point)
+ })
{
return None;
}
@@ -473,12 +476,17 @@ impl ClientShellState {
.hits
.workspaces
.iter()
- .filter(|hit| !hit.indented)
+ .filter(|hit| hit.endpoint_id == self.active_endpoint_id && !hit.indented)
.map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1)))
.collect::<Vec<_>>();
let snapshot = self.snapshot.as_deref()?;
let entries = render::workspace_entries(snapshot, &self.collapsed_groups);
- let last_hit = self.hits.workspaces.last()?;
+ let last_hit = self
+ .hits
+ .workspaces
+ .iter()
+ .rev()
+ .find(|hit| hit.endpoint_id == self.active_endpoint_id)?;
let last_position = entries.iter().position(|entry| {
snapshot
.workspaces
@@ -1109,21 +1117,7 @@ impl ClientShellState {
.max(mouse.row.abs_diff(press.start_row));
if delta >= 1 {
let source_workspace_id = press.workspace_id.clone();
- let draggable = self
- .snapshot
- .as_deref()
- .and_then(|snapshot| {
- snapshot
- .workspaces
- .iter()
- .find(|workspace| workspace.workspace_id == source_workspace_id)
- })
- .is_some_and(|workspace| {
- !workspace
- .worktree
- .as_ref()
- .is_some_and(|worktree| worktree.is_linked_worktree)
- });
+ let draggable = self.endpoint_workspace_is_draggable(press);
if draggable {
if let Some(target) = self.workspace_drop_target_at(point) {
self.chrome_drag = Some(ClientChromeDrag::Workspace {
@@ -1268,14 +1262,7 @@ impl ClientShellState {
return;
}
if let Some(press) = self.workspace_press.take() {
- self.push_endpoint_method(
- crate::api::schema::Method::WorkspaceFocus(
- crate::api::schema::WorkspaceTarget {
- workspace_id: press.workspace_id,
- },
- ),
- outcome,
- );
+ self.finish_endpoint_workspace_press(press, outcome);
return;
}
if let Some(press) = self.tab_press.take() {
@@ -1552,14 +1539,14 @@ impl ClientShellState {
.navigator_rows
.iter()
.find(|(rect, _)| super::contains(*rect, point))
- .copied();
+ .cloned();
match mouse.kind {
MouseEventKind::Moved => {
- if let Some((_, index)) = row_hit {
+ if let Some((_, target)) = row_hit {
if let Some(ClientShellOverlay::Navigator(navigator)) =
self.overlay.as_mut()
{
- navigator.selected = index;
+ navigator.selected = Some(target);
}
outcome.repaint = true;
}
@@ -1573,30 +1560,13 @@ impl ClientShellState {
navigator.filter = None;
}
outcome.repaint = true;
- } else if let Some((rect, index)) = row_hit {
+ } else if let Some((rect, target)) = row_hit {
if let Some(ClientShellOverlay::Navigator(navigator)) =
self.overlay.as_mut()
{
- navigator.selected = index;
+ navigator.selected = Some(target.clone());
}
- let workspace = self
- .snapshot
- .as_deref()
- .zip(self.overlay.as_ref())
- .and_then(|(snapshot, overlay)| match overlay {
- ClientShellOverlay::Navigator(navigator) => {
- render::client_navigator_rows(snapshot, navigator)
- .get(index)
- .map(|row| {
- matches!(
- row.target,
- ClientNavigatorTarget::Workspace(_)
- )
- })
- }
- _ => None,
- })
- .unwrap_or(false);
+ let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. });
if workspace && mouse.column <= rect.x.saturating_add(3) {
self.toggle_selected_navigator_workspace();
outcome.repaint = true;
@@ -1749,13 +1719,7 @@ impl ClientShellState {
return;
}
let workspace_id = (!self.sidebar_collapsed)
- .then(|| {
- self.hits
- .workspaces
- .iter()
- .find(|hit| super::contains(hit.rect, point))
- .map(|hit| hit.workspace_id.clone())
- })
+ .then(|| self.active_endpoint_workspace_at(point))
.flatten();
if let Some(workspace_id) = workspace_id {
self.open_workspace_context_menu(workspace_id, mouse.column, mouse.row);
@@ -1949,6 +1913,9 @@ impl ClientShellState {
outcome.repaint = true;
return;
}
+ if self.handle_endpoint_machine_click(point, outcome) {
+ return;
+ }
if super::contains(self.hits.global_launcher, point) {
self.toggle_global_menu();
outcome.repaint = true;
@@ -2023,6 +1990,7 @@ impl ClientShellState {
.iter()
.find(|hit| super::contains(hit.rect, point))
.map(|hit| ClientWorkspacePress {
+ endpoint_id: hit.endpoint_id.clone(),
workspace_id: hit.workspace_id.clone(),
start_column: mouse.column,
start_row: mouse.row,
@@ -2059,6 +2027,9 @@ impl ClientShellState {
self.tab_press = Some(tab_press);
return;
}
+ if self.handle_endpoint_agent_click(point, outcome) {
+ return;
+ }
let agent_pane_id = self
.hits
.agents
diff --git a/src/client/shell/notification_policy.rs b/src/client/shell/notification_policy.rs
new file mode 100644
index 00000000..f3eeeb3c
--- /dev/null
+++ b/src/client/shell/notification_policy.rs
@@ -0,0 +1,319 @@
+use super::*;
+
+const MAX_QUEUED_NOTIFICATIONS: usize = 8;
+const COMPLETION_EVIDENCE_GRACE: std::time::Duration = std::time::Duration::from_secs(1);
+pub(super) const COMPLETION_RECHECK_INTERVAL: std::time::Duration =
+ std::time::Duration::from_millis(50);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum NotificationValidation {
+ Current,
+ AwaitingSnapshot,
+ Stale,
+}
+
+fn notification_duration(kind: SemanticNotificationKind) -> std::time::Duration {
+ std::time::Duration::from_secs(match kind {
+ SemanticNotificationKind::NeedsAttention => 8,
+ SemanticNotificationKind::Finished => 5,
+ SemanticNotificationKind::UpdateInstalled => 3,
+ SemanticNotificationKind::Custom => 5,
+ })
+}
+
+impl ClientShellState {
+ pub(super) fn retire_endpoint_notifications(&mut self, endpoint_id: &ClientEndpointId) {
+ self.pending_notifications
+ .retain(|pending| &pending.endpoint_id != endpoint_id);
+ self.queued_notifications
+ .retain(|queued| &queued.endpoint_id != endpoint_id);
+ if self
+ .visible_notification
+ .as_ref()
+ .is_some_and(|visible| &visible.endpoint_id == endpoint_id)
+ {
+ self.visible_notification = None;
+ self.promote_queued_notification(std::time::Instant::now());
+ }
+ }
+
+ fn queue_visible_notification(
+ &mut self,
+ mut notification: ClientVisibleNotification,
+ now: std::time::Instant,
+ ) {
+ if self.visible_notification.is_none() {
+ notification.deadline = now + notification_duration(notification.event.kind);
+ self.visible_notification = Some(notification);
+ return;
+ }
+ if self.queued_notifications.len() == MAX_QUEUED_NOTIFICATIONS {
+ self.queued_notifications.pop_front();
+ }
+ self.queued_notifications.push_back(notification);
+ }
+
+ fn promote_queued_notification(&mut self, now: std::time::Instant) -> bool {
+ let Some(mut notification) = self.queued_notifications.pop_front() else {
+ return false;
+ };
+ notification.deadline = now + notification_duration(notification.event.kind);
+ self.visible_notification = Some(notification);
+ true
+ }
+
+ pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) {
+ let Some(notification) = self.visible_notification.as_ref() else {
+ return;
+ };
+ if notification.event.pane_id.is_some()
+ && !self.endpoint_is_online(&notification.endpoint_id)
+ {
+ let label = self.endpoint_label(&notification.endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is unavailable"));
+ outcome.repaint = true;
+ return;
+ }
+ let notification = self
+ .visible_notification
+ .take()
+ .expect("checked visible notification");
+ self.promote_queued_notification(std::time::Instant::now());
+ outcome.repaint = true;
+ let Some(pane_id) = notification.event.pane_id else {
+ return;
+ };
+ if notification.endpoint_id == self.active_endpoint_id {
+ self.push_endpoint_method(
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }),
+ outcome,
+ );
+ } else if self.endpoint_is_online(&notification.endpoint_id) {
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id: notification.endpoint_id,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ });
+ }
+ }
+
+ pub(crate) fn receive_notification(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ event: SemanticNotification,
+ now: std::time::Instant,
+ ) -> (Vec<ClientShellNotificationEffect>, bool) {
+ let delay = if event.kind == SemanticNotificationKind::Custom {
+ 0
+ } else {
+ self.config.toast_delay_seconds
+ };
+ let deadline = now
+ .checked_add(std::time::Duration::from_secs(delay))
+ .unwrap_or(now);
+ let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| {
+ self.visible_notification.as_ref().is_some_and(|visible| {
+ visible.endpoint_id == *endpoint_id
+ && visible.event.pane_id.as_deref() == Some(pane_id)
+ })
+ });
+ if let Some(pane_id) = event.pane_id.as_deref() {
+ self.pending_notifications.retain(|pending| {
+ pending.endpoint_id != *endpoint_id
+ || pending.event.pane_id.as_deref() != Some(pane_id)
+ });
+ self.queued_notifications.retain(|queued| {
+ queued.endpoint_id != *endpoint_id
+ || queued.event.pane_id.as_deref() != Some(pane_id)
+ });
+ if cleared_visible {
+ self.visible_notification = None;
+ self.promote_queued_notification(now);
+ }
+ }
+ // Completion evidence is advisory. A Finished effect is valid only while the
+ // client-projected pane remains Done, even when delivery is immediate.
+ let validate_state = delay > 0 || event.kind == SemanticNotificationKind::Finished;
+ self.pending_notifications.push(ClientPendingNotification {
+ endpoint_id: endpoint_id.clone(),
+ event,
+ deadline,
+ expires_at: now.checked_add(COMPLETION_EVIDENCE_GRACE).unwrap_or(now),
+ validate_state,
+ });
+ let (effects, repaint) = self.tick_notifications(now);
+ (effects, repaint || cleared_visible)
+ }
+
+ pub(crate) fn tick_notifications(
+ &mut self,
+ now: std::time::Instant,
+ ) -> (Vec<ClientShellNotificationEffect>, bool) {
+ let mut repaint = false;
+ if self
+ .visible_notification
+ .as_ref()
+ .is_some_and(|visible| now >= visible.deadline)
+ {
+ self.visible_notification = None;
+ self.promote_queued_notification(now);
+ repaint = true;
+ }
+ if self
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|visible| now >= visible.deadline)
+ {
+ self.visible_endpoint_notice = None;
+ repaint = true;
+ }
+
+ let pending = std::mem::take(&mut self.pending_notifications);
+ let mut effects = Vec::new();
+ for pending in pending {
+ if pending.deadline > now {
+ self.pending_notifications.push(pending);
+ continue;
+ }
+ if pending.validate_state {
+ match self.notification_validation(&pending.endpoint_id, &pending.event) {
+ NotificationValidation::Current => {}
+ NotificationValidation::AwaitingSnapshot if now < pending.expires_at => {
+ let mut pending = pending;
+ pending.deadline = now
+ .checked_add(COMPLETION_RECHECK_INTERVAL)
+ .unwrap_or(now)
+ .min(pending.expires_at);
+ self.pending_notifications.push(pending);
+ continue;
+ }
+ NotificationValidation::AwaitingSnapshot | NotificationValidation::Stale => {
+ continue;
+ }
+ }
+ }
+ let target_active =
+ self.notification_target_is_active(&pending.endpoint_id, &pending.event);
+ let suppress_external = target_active && self.outer_focused != Some(false);
+ if let Some(sound) = pending.event.sound {
+ let suppress_sound =
+ pending.event.kind == SemanticNotificationKind::Finished && suppress_external;
+ if !suppress_sound {
+ effects.push(ClientShellNotificationEffect::Sound {
+ sound: match sound {
+ SemanticNotificationSound::Done => crate::sound::Sound::Done,
+ SemanticNotificationSound::Request => crate::sound::Sound::Request,
+ },
+ agent: pending.event.agent.clone(),
+ });
+ }
+ }
+
+ match self.config.toast_delivery {
+ crate::config::ToastDelivery::Off => {}
+ crate::config::ToastDelivery::Herdr if !target_active => {
+ self.queue_visible_notification(
+ ClientVisibleNotification {
+ endpoint_id: pending.endpoint_id,
+ event: pending.event,
+ deadline: now,
+ },
+ now,
+ );
+ repaint = true;
+ }
+ crate::config::ToastDelivery::Herdr => {}
+ crate::config::ToastDelivery::Terminal if !suppress_external => {
+ effects.push(ClientShellNotificationEffect::Terminal {
+ title: pending.event.title,
+ body: pending.event.body,
+ });
+ }
+ crate::config::ToastDelivery::System if !suppress_external => {
+ effects.push(ClientShellNotificationEffect::System {
+ title: pending.event.title,
+ body: pending.event.body,
+ });
+ }
+ crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {}
+ }
+ }
+ (effects, repaint)
+ }
+
+ fn notification_target_is_active(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ event: &SemanticNotification,
+ ) -> bool {
+ if endpoint_id != &self.active_endpoint_id {
+ return false;
+ }
+ let Some(snapshot) = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ else {
+ return false;
+ };
+ if let Some(tab_id) = event.tab_id.as_deref() {
+ return snapshot.focused_tab_id.as_deref() == Some(tab_id);
+ }
+ event.workspace_id.as_deref().is_some_and(|workspace_id| {
+ snapshot.focused_workspace_id.as_deref() == Some(workspace_id)
+ })
+ }
+
+ fn notification_validation(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ event: &SemanticNotification,
+ ) -> NotificationValidation {
+ let Some(pane_id) = event.pane_id.as_deref() else {
+ // Finished notifications carry no independently trustworthy completion state. Without
+ // a projected pane to verify as Done, do not emit completion chrome or sound.
+ return if event.kind == SemanticNotificationKind::Finished {
+ NotificationValidation::Stale
+ } else {
+ NotificationValidation::Current
+ };
+ };
+ let Some(agent) = self
+ .endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ .and_then(|snapshot| {
+ snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane_id)
+ })
+ else {
+ return NotificationValidation::AwaitingSnapshot;
+ };
+ match event.kind {
+ SemanticNotificationKind::NeedsAttention
+ if agent.agent_status == crate::api::schema::AgentStatus::Blocked =>
+ {
+ NotificationValidation::Current
+ }
+ SemanticNotificationKind::Finished
+ if agent.agent_status == crate::api::schema::AgentStatus::Done =>
+ {
+ NotificationValidation::Current
+ }
+ SemanticNotificationKind::Finished
+ if agent.agent_status == crate::api::schema::AgentStatus::Working =>
+ {
+ NotificationValidation::AwaitingSnapshot
+ }
+ SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => {
+ NotificationValidation::Current
+ }
+ SemanticNotificationKind::NeedsAttention | SemanticNotificationKind::Finished => {
+ NotificationValidation::Stale
+ }
+ }
+ }
+}
diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs
index 43fe566a..b8437ea7 100644
--- a/src/client/shell/notifications.rs
+++ b/src/client/shell/notifications.rs
@@ -1,3 +1,5 @@
+#[cfg(test)]
+use super::notification_policy::COMPLETION_RECHECK_INTERVAL;
use super::*;
use ratatui::{
style::Color,
@@ -5,7 +7,7 @@ use ratatui::{
widgets::{Block, Borders, Clear, Paragraph, Widget},
};
-fn render_mobile_notice_banner(
+pub(super) fn render_mobile_notice_banner(
buffer: &mut Buffer,
area: Rect,
title: &str,
@@ -102,25 +104,7 @@ pub(super) fn render_mobile_notification_banner(
)
}
-pub(super) fn render_mobile_endpoint_notice_banner(
- buffer: &mut Buffer,
- area: Rect,
- notice: &ClientVisibleEndpointNotice,
- offset_for_warning: bool,
- palette: &Palette,
-) -> Rect {
- render_mobile_notice_banner(
- buffer,
- area,
- &notice.title,
- Some(&notice.body),
- palette.red,
- offset_for_warning,
- palette,
- )
-}
-
-fn render_notification_card(
+pub(super) fn render_notification_card(
buffer: &mut Buffer,
area: Rect,
title: &str,
@@ -221,205 +205,13 @@ pub(super) fn render_visible_notification(
)
}
-pub(super) fn render_endpoint_notice(
- buffer: &mut Buffer,
- area: Rect,
- notice: &ClientVisibleEndpointNotice,
- top_offset: u16,
- palette: &Palette,
-) -> Rect {
- render_notification_card(
- buffer,
- area,
- &notice.title,
- &notice.body,
- crate::config::ToastHerdrPosition::TopRight,
- top_offset,
- match notice.key.kind {
- ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => {
- palette.red
- }
- ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => {
- palette.yellow
- }
- },
- palette,
- )
-}
-
-impl ClientShellState {
- pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) {
- let Some(notification) = self.visible_notification.take() else {
- return;
- };
- outcome.repaint = true;
- if let Some(pane_id) = notification.event.pane_id {
- self.push_endpoint_method(
- crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }),
- outcome,
- );
- }
- }
-
- pub(crate) fn receive_notification(
- &mut self,
- event: SemanticNotification,
- now: std::time::Instant,
- ) -> (Vec<ClientShellNotificationEffect>, bool) {
- let delay = if event.kind == SemanticNotificationKind::Custom {
- 0
- } else {
- self.config.toast_delay_seconds
- };
- let deadline = now
- .checked_add(std::time::Duration::from_secs(delay))
- .unwrap_or(now);
- let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| {
- self.visible_notification
- .as_ref()
- .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id))
- });
- if let Some(pane_id) = event.pane_id.as_deref() {
- self.pending_notifications
- .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id));
- if cleared_visible {
- self.visible_notification = None;
- }
- }
- self.pending_notifications.push(ClientPendingNotification {
- event,
- deadline,
- validate_state: delay > 0,
- });
- let (effects, repaint) = self.tick_notifications(now);
- (effects, repaint || cleared_visible)
- }
-
- pub(crate) fn tick_notifications(
- &mut self,
- now: std::time::Instant,
- ) -> (Vec<ClientShellNotificationEffect>, bool) {
- let mut repaint = false;
- if self
- .visible_notification
- .as_ref()
- .is_some_and(|visible| now >= visible.deadline)
- {
- self.visible_notification = None;
- repaint = true;
- }
- if self
- .visible_endpoint_notice
- .as_ref()
- .is_some_and(|visible| now >= visible.deadline)
- {
- self.visible_endpoint_notice = None;
- repaint = true;
- }
-
- let pending = std::mem::take(&mut self.pending_notifications);
- let mut effects = Vec::new();
- for pending in pending {
- if pending.deadline > now {
- self.pending_notifications.push(pending);
- continue;
- }
- if pending.validate_state && !self.notification_still_current(&pending.event) {
- continue;
- }
- let target_active = self.notification_target_is_active(&pending.event);
- let suppress_external = target_active && self.outer_focused != Some(false);
- if let Some(sound) = pending.event.sound {
- let suppress_sound =
- pending.event.kind == SemanticNotificationKind::Finished && suppress_external;
- if !suppress_sound {
- effects.push(ClientShellNotificationEffect::Sound {
- sound: match sound {
- SemanticNotificationSound::Done => crate::sound::Sound::Done,
- SemanticNotificationSound::Request => crate::sound::Sound::Request,
- },
- agent: pending.event.agent.clone(),
- });
- }
- }
-
- match self.config.toast_delivery {
- crate::config::ToastDelivery::Off => {}
- crate::config::ToastDelivery::Herdr if !target_active => {
- let duration = match pending.event.kind {
- SemanticNotificationKind::NeedsAttention => 8,
- SemanticNotificationKind::Finished => 5,
- SemanticNotificationKind::UpdateInstalled => 3,
- SemanticNotificationKind::Custom => 5,
- };
- self.visible_notification = Some(ClientVisibleNotification {
- event: pending.event,
- deadline: now + std::time::Duration::from_secs(duration),
- });
- repaint = true;
- }
- crate::config::ToastDelivery::Herdr => {}
- crate::config::ToastDelivery::Terminal if !suppress_external => {
- effects.push(ClientShellNotificationEffect::Terminal {
- title: pending.event.title,
- body: pending.event.body,
- });
- }
- crate::config::ToastDelivery::System if !suppress_external => {
- effects.push(ClientShellNotificationEffect::System {
- title: pending.event.title,
- body: pending.event.body,
- });
- }
- crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {}
- }
- }
- (effects, repaint)
- }
-
- fn notification_target_is_active(&self, event: &SemanticNotification) -> bool {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return false;
- };
- if let Some(tab_id) = event.tab_id.as_deref() {
- return snapshot.focused_tab_id.as_deref() == Some(tab_id);
- }
- event.workspace_id.as_deref().is_some_and(|workspace_id| {
- snapshot.focused_workspace_id.as_deref() == Some(workspace_id)
- })
- }
-
- fn notification_still_current(&self, event: &SemanticNotification) -> bool {
- let Some(pane_id) = event.pane_id.as_deref() else {
- return true;
- };
- let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| {
- snapshot
- .agents
- .iter()
- .find(|agent| agent.pane_id == pane_id)
- }) else {
- return false;
- };
- match event.kind {
- SemanticNotificationKind::NeedsAttention => {
- agent.agent_status == crate::api::schema::AgentStatus::Blocked
- }
- SemanticNotificationKind::Finished => matches!(
- agent.agent_status,
- crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done
- ),
- SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true,
- }
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
fn notification() -> ClientVisibleNotification {
ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
event: SemanticNotification {
kind: SemanticNotificationKind::Custom,
title: "notice".into(),
@@ -463,6 +255,205 @@ mod tests {
}
#[test]
+ fn unavailable_notification_target_stays_visible_and_reports_the_machine() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.visible_notification = Some(ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
+ event: SemanticNotification {
+ kind: SemanticNotificationKind::NeedsAttention,
+ title: "agent needs attention".into(),
+ body: None,
+ sound: None,
+ agent: Some("agent".into()),
+ workspace_id: Some("workspace".into()),
+ tab_id: Some("tab".into()),
+ pane_id: Some("pane".into()),
+ position: None,
+ },
+ deadline: std::time::Instant::now() + std::time::Duration::from_secs(5),
+ });
+ let mut outcome = ClientShellInput::default();
+
+ state.focus_visible_notification(&mut outcome);
+
+ assert!(state.visible_notification.is_some());
+ assert!(state
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|notice| notice.body.contains("Local is unavailable")));
+ assert!(outcome.repaint);
+ }
+
+ #[test]
+ fn immediate_finished_evidence_for_acknowledged_idle_never_toasts_or_sounds() {
+ let mut config = ClientShellConfig::from_config(&Config::default());
+ config.toast_delivery = crate::config::ToastDelivery::Herdr;
+ config.toast_delay_seconds = 0;
+ let mut state = ClientShellState::new(config);
+ let mut snapshot = super::super::tests::snapshot();
+ snapshot.agents.push(crate::protocol::ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some("agent".into()),
+ display_agent: None,
+ agent: Some("agent".into()),
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: crate::api::schema::AgentStatus::Idle,
+ state_change_seq: 2,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: true,
+ });
+ state.set_snapshot(Box::new(snapshot));
+
+ let (effects, repaint) = state.receive_notification(
+ &ClientEndpointId::Local,
+ SemanticNotification {
+ kind: SemanticNotificationKind::Finished,
+ title: "agent finished".into(),
+ body: None,
+ sound: Some(SemanticNotificationSound::Done),
+ agent: Some("agent".into()),
+ workspace_id: Some("ws_1".into()),
+ tab_id: Some("tab_1".into()),
+ pane_id: Some("pane_1".into()),
+ position: None,
+ },
+ std::time::Instant::now(),
+ );
+
+ assert!(effects.is_empty());
+ assert!(!repaint);
+ assert!(state.visible_notification.is_none());
+ }
+
+ #[test]
+ fn finished_hint_waits_for_the_projected_completion_snapshot() {
+ let mut config = ClientShellConfig::from_config(&Config::default());
+ config.toast_delivery = crate::config::ToastDelivery::Terminal;
+ config.toast_delay_seconds = 0;
+ let mut state = ClientShellState::new(config);
+ let mut snapshot = super::super::tests::snapshot();
+ snapshot.agents.push(crate::protocol::ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some("agent".into()),
+ display_agent: None,
+ agent: Some("agent".into()),
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: crate::api::schema::AgentStatus::Working,
+ state_change_seq: 1,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: false,
+ });
+ state.set_snapshot(Box::new(snapshot.clone()));
+ let now = std::time::Instant::now();
+ let event = SemanticNotification {
+ kind: SemanticNotificationKind::Finished,
+ title: "agent finished".into(),
+ body: None,
+ sound: None,
+ agent: Some("agent".into()),
+ workspace_id: Some("ws_1".into()),
+ tab_id: Some("background-tab".into()),
+ pane_id: Some("pane_1".into()),
+ position: None,
+ };
+
+ let (effects, _) = state.receive_notification(&ClientEndpointId::Local, event, now);
+ assert!(effects.is_empty());
+ assert_eq!(state.pending_notifications.len(), 1);
+
+ snapshot.revision = snapshot.revision.saturating_add(1);
+ snapshot.agents[0].agent_status = crate::api::schema::AgentStatus::Idle;
+ snapshot.agents[0].state_change_seq = 2;
+ state.set_snapshot(Box::new(snapshot));
+ let (effects, _) = state.tick_notifications(now + COMPLETION_RECHECK_INTERVAL);
+ assert!(matches!(
+ effects.as_slice(),
+ [ClientShellNotificationEffect::Terminal { title, .. }] if title == "agent finished"
+ ));
+ assert!(state.pending_notifications.is_empty());
+ }
+
+ #[test]
+ fn retiring_one_endpoint_preserves_other_endpoint_notifications() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let mut local = notification();
+ local.event.title = "local".into();
+ let remote_id = ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
+ let mut remote = notification();
+ remote.endpoint_id = remote_id.clone();
+ remote.event.title = "remote".into();
+ state.visible_notification = Some(local);
+ state.queued_notifications.push_back(remote);
+
+ state.retire_endpoint_notifications(&ClientEndpointId::Local);
+
+ assert_eq!(
+ state
+ .visible_notification
+ .as_ref()
+ .map(|notification| (&notification.endpoint_id, notification.event.title.as_str())),
+ Some((&remote_id, "remote"))
+ );
+ }
+
+ #[test]
+ fn concurrent_endpoint_notifications_are_queued_in_arrival_order() {
+ let mut config = Config::default();
+ config.ui.toast.delivery = crate::config::ToastDelivery::Herdr;
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
+ let now = std::time::Instant::now();
+ for title in ["first", "second"] {
+ state.receive_notification(
+ &ClientEndpointId::Local,
+ SemanticNotification {
+ kind: SemanticNotificationKind::Custom,
+ title: title.into(),
+ body: None,
+ sound: None,
+ agent: None,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: Some(title.into()),
+ position: None,
+ },
+ now,
+ );
+ }
+
+ assert_eq!(
+ state
+ .visible_notification
+ .as_ref()
+ .map(|notification| notification.event.title.as_str()),
+ Some("first")
+ );
+ assert_eq!(state.queued_notifications.len(), 1);
+
+ state.tick_notifications(now + std::time::Duration::from_secs(5));
+
+ assert_eq!(
+ state
+ .visible_notification
+ .as_ref()
+ .map(|notification| notification.event.title.as_str()),
+ Some("second")
+ );
+ assert!(state.queued_notifications.is_empty());
+ }
+
+ #[test]
fn notification_rect_stays_inside_short_nonzero_area() {
let palette = crate::app::client_palette_from_config(&Config::default());
for height in [1, 2] {
diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs
index 154b00b6..db089f4e 100644
--- a/src/client/shell/overlay_input.rs
+++ b/src/client/shell/overlay_input.rs
@@ -191,98 +191,123 @@ impl ClientShellState {
}
pub(super) fn open_navigator_overlay(&mut self) {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return;
- };
- let expanded_workspaces = snapshot
- .workspaces
- .iter()
- .map(|workspace| workspace.workspace_id.clone())
- .collect();
+ let expanded_workspaces =
+ super::aggregate_navigation::cached_endpoint_snapshots(&self.endpoints)
+ .flat_map(|endpoint| {
+ endpoint.snapshot.workspaces.iter().map(move |workspace| {
+ (endpoint.endpoint_id.clone(), workspace.workspace_id.clone())
+ })
+ })
+ .collect();
let mut navigator = ClientNavigatorOverlay {
query: String::new(),
search_focused: false,
- selected: 0,
+ selected: None,
scroll: 0,
filter: None,
expanded_workspaces,
};
- let rows = render::client_navigator_rows(snapshot, &navigator);
- navigator.selected = rows.iter().position(|row| row.current).unwrap_or(0);
+ let rows =
+ render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, &navigator);
+ navigator.selected = rows
+ .iter()
+ .find(|row| row.current)
+ .map(|row| row.target.clone());
self.overlay = Some(ClientShellOverlay::Navigator(navigator));
}
pub(super) fn move_navigator_selection(&mut self, delta: isize) {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return;
- };
let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else {
return;
};
- let rows = render::client_navigator_rows(snapshot, navigator);
+ let rows =
+ render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator);
if rows.is_empty() {
- navigator.selected = 0;
+ navigator.selected = None;
return;
}
- navigator.selected = (navigator.selected as isize + delta)
- .clamp(0, rows.len().saturating_sub(1) as isize) as usize;
+ let selected =
+ super::aggregate_navigation::navigator_selected_index(&rows, navigator).unwrap_or(0);
+ let next =
+ (selected as isize + delta).clamp(0, rows.len().saturating_sub(1) as isize) as usize;
+ navigator.selected = Some(rows[next].target.clone());
}
pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) {
- let target = {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return;
- };
- let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else {
- return;
- };
- render::client_navigator_rows(snapshot, navigator)
- .get(navigator.selected)
- .map(|row| row.target.clone())
- };
+ let target = self.overlay.as_ref().and_then(|overlay| match overlay {
+ ClientShellOverlay::Navigator(navigator) => {
+ let rows = render::client_navigator_rows(
+ &self.endpoints,
+ &self.active_endpoint_id,
+ navigator,
+ );
+ super::aggregate_navigation::selected_navigator_target(&rows, navigator)
+ }
+ _ => None,
+ });
let Some(target) = target else {
return;
};
- self.overlay = None;
- let method = match target {
- ClientNavigatorTarget::Workspace(workspace_id) => {
- crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
- workspace_id,
- })
- }
- ClientNavigatorTarget::Tab(tab_id) => {
- crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id })
+ let activated = match target {
+ ClientNavigatorTarget::Machine { endpoint_id } => {
+ self.activate_endpoint(endpoint_id, outcome)
}
- ClientNavigatorTarget::Pane(pane_id) => {
- crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id })
+ ClientNavigatorTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ } => self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Workspace(workspace_id),
+ outcome,
+ ),
+ ClientNavigatorTarget::Tab {
+ endpoint_id,
+ tab_id,
+ } => {
+ self.focus_or_activate(endpoint_id, ClientEndpointFocusTarget::Tab(tab_id), outcome)
}
+ ClientNavigatorTarget::Pane {
+ endpoint_id,
+ pane_id,
+ } => self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Pane(pane_id),
+ outcome,
+ ),
};
- self.push_endpoint_method(method, outcome);
+ if activated {
+ self.overlay = None;
+ }
outcome.repaint = true;
}
pub(super) fn toggle_selected_navigator_workspace(&mut self) {
- let workspace_id = {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return;
- };
- let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else {
- return;
- };
- render::client_navigator_rows(snapshot, navigator)
- .get(navigator.selected)
- .and_then(|row| match &row.target {
- ClientNavigatorTarget::Workspace(workspace_id) => Some(workspace_id.clone()),
- _ => None,
- })
- };
- if let (Some(workspace_id), Some(ClientShellOverlay::Navigator(navigator))) =
- (workspace_id, self.overlay.as_mut())
+ let workspace_key = self.overlay.as_ref().and_then(|overlay| match overlay {
+ ClientShellOverlay::Navigator(navigator) => {
+ let rows = render::client_navigator_rows(
+ &self.endpoints,
+ &self.active_endpoint_id,
+ navigator,
+ );
+ super::aggregate_navigation::selected_navigator_target(&rows, navigator).and_then(
+ |target| match target {
+ ClientNavigatorTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ } => Some((endpoint_id, workspace_id)),
+ _ => None,
+ },
+ )
+ }
+ _ => None,
+ });
+ if let (Some(workspace_key), Some(ClientShellOverlay::Navigator(navigator))) =
+ (workspace_key, self.overlay.as_mut())
{
- if !navigator.expanded_workspaces.remove(&workspace_id) {
- navigator.expanded_workspaces.insert(workspace_id);
+ if !navigator.expanded_workspaces.remove(&workspace_key) {
+ navigator.expanded_workspaces.insert(workspace_key);
}
- navigator.selected = 0;
+ navigator.selected = None;
navigator.scroll = 0;
}
}
@@ -433,7 +458,7 @@ impl ClientShellState {
Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => {
navigator.query.push_str(text);
navigator.filter = None;
- navigator.selected = 0;
+ navigator.selected = None;
true
}
_ => false,
@@ -641,11 +666,11 @@ impl ClientShellState {
if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) {
navigator.query.clear();
navigator.filter = None;
- navigator.selected = 0;
+ navigator.selected = None;
} else if code == KeyCode::Backspace {
navigator.query.pop();
navigator.filter = None;
- navigator.selected = 0;
+ navigator.selected = None;
} else if let KeyCode::Char(character) = code {
if modifiers.difference(KeyModifiers::SHIFT).is_empty() {
navigator.filter = None;
@@ -654,7 +679,7 @@ impl ClientShellState {
} else {
navigator.query.push(character);
}
- navigator.selected = 0;
+ navigator.selected = None;
}
}
outcome.repaint = true;
@@ -664,7 +689,7 @@ impl ClientShellState {
if code == KeyCode::Backspace && modifiers.is_empty() {
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
if navigator.filter.take().is_some() {
- navigator.selected = 0;
+ navigator.selected = None;
}
}
outcome.repaint = true;
@@ -672,25 +697,23 @@ impl ClientShellState {
}
if code == KeyCode::Home && modifiers.is_empty() {
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
- navigator.selected = 0;
+ navigator.selected = None;
navigator.scroll = 0;
}
outcome.repaint = true;
return;
}
if matches!(code, KeyCode::End | KeyCode::Char('G')) && modifiers.is_empty() {
- let last = self
- .snapshot
- .as_deref()
- .zip(self.overlay.as_ref())
- .and_then(|(snapshot, overlay)| match overlay {
- ClientShellOverlay::Navigator(navigator) => {
- Some(render::client_navigator_rows(snapshot, navigator).len())
- }
- _ => None,
- })
- .unwrap_or(0)
- .saturating_sub(1);
+ let last = self.overlay.as_ref().and_then(|overlay| match overlay {
+ ClientShellOverlay::Navigator(navigator) => render::client_navigator_rows(
+ &self.endpoints,
+ &self.active_endpoint_id,
+ navigator,
+ )
+ .last()
+ .map(|row| row.target.clone()),
+ _ => None,
+ });
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
navigator.selected = last;
}
@@ -735,7 +758,7 @@ impl ClientShellState {
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
navigator.query.clear();
navigator.filter = Some(filter);
- navigator.selected = 0;
+ navigator.selected = None;
}
outcome.repaint = true;
return;
@@ -744,7 +767,7 @@ impl ClientShellState {
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
navigator.query.clear();
navigator.filter = None;
- navigator.selected = 0;
+ navigator.selected = None;
}
outcome.repaint = true;
return;
diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs
index 30ac78b9..faf252a8 100644
--- a/src/client/shell/overlays.rs
+++ b/src/client/shell/overlays.rs
@@ -10,7 +10,7 @@ pub(crate) struct OverlayRender {
pub(crate) cancel: Rect,
pub(crate) navigator_popup: Rect,
pub(crate) navigator_search: Rect,
- pub(crate) navigator_rows: Vec<(Rect, usize)>,
+ pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
pub(crate) worktree_search: Rect,
pub(crate) worktree_rows: Vec<(Rect, usize)>,
pub(crate) help_popup: Rect,
@@ -33,6 +33,8 @@ pub(crate) fn render_client_overlay(
b: &mut Buffer,
o: &ClientShellOverlay,
s: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
k: &LiveKeybindConfig,
p: &Palette,
) -> Option<OverlayRender> {
@@ -58,7 +60,9 @@ pub(crate) fn render_client_overlay(
ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p),
ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p),
ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p),
- ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p),
+ ClientShellOverlay::Navigator(v) => {
+ render_navigator_overlay(b, v, endpoints, active_endpoint_id, p)
+ }
ClientShellOverlay::Settings(v) => {
settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p)
}
@@ -669,91 +673,11 @@ fn render_rename_overlay(
})
}
-pub(crate) fn client_navigator_rows(
- s: &ClientShellSnapshot,
- n: &ClientNavigatorOverlay,
-) -> Vec<ClientNavigatorRow> {
- let q = n.query.trim().to_lowercase();
- let filter = |st| match n.filter {
- Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked,
- Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working,
- Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle,
- Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done,
- None => true,
- };
- let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q);
- let filtering = n.filter.is_some() || !q.is_empty();
- let mut out = Vec::new();
- for w in &s.workspaces {
- let mut children = Vec::new();
- for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) {
- let mut panes = Vec::new();
- for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() {
- let a = s.agents.iter().find(|a| a.pane_id == p.pane_id);
- let st = a
- .map(|a| a.agent_status)
- .unwrap_or(crate::api::schema::AgentStatus::Unknown);
- let label = p
- .label
- .clone()
- .or_else(|| a.and_then(|a| a.name.clone()))
- .or_else(|| a.and_then(|a| a.display_agent.clone()))
- .or_else(|| a.and_then(|a| a.title.clone()))
- .unwrap_or_else(|| format!("pane {}", ix + 1));
- let meta = p
- .foreground_cwd
- .clone()
- .or_else(|| p.cwd.clone())
- .unwrap_or_default();
- if !filtering || filter(st) && (text(&label) || text(&meta)) {
- panes.push(ClientNavigatorRow {
- depth: 2,
- label,
- meta,
- status: st,
- current: s.focused_pane_id.as_deref() == Some(&p.pane_id),
- target: ClientNavigatorTarget::Pane(p.pane_id.clone()),
- })
- }
- }
- if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() {
- children.push(ClientNavigatorRow {
- depth: 1,
- label: t.label.clone(),
- meta: format!(
- "{} panes",
- s.panes.iter().filter(|p| p.tab_id == t.tab_id).count()
- ),
- status: t.agent_status,
- current: s.focused_tab_id.as_deref() == Some(&t.tab_id),
- target: ClientNavigatorTarget::Tab(t.tab_id.clone()),
- });
- children.extend(panes)
- }
- }
- let wm =
- filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text));
- if !filtering || wm || !children.is_empty() {
- out.push(ClientNavigatorRow {
- depth: 0,
- label: w.label.clone(),
- meta: w.branch.clone().unwrap_or_default(),
- status: w.agent_status,
- current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id),
- target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()),
- });
- if n.expanded_workspaces.contains(&w.workspace_id) || filtering {
- out.extend(children)
- }
- }
- }
- out
-}
-
fn render_navigator_overlay(
b: &mut Buffer,
n: &ClientNavigatorOverlay,
- s: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
p: &Palette,
) -> Option<OverlayRender> {
let a = b.area;
@@ -766,7 +690,7 @@ fn render_navigator_overlay(
a.height.saturating_sub(my * 2).max(4),
);
let i = panel(b, q, p.accent, p.panel_bg)?;
- let rows = client_navigator_rows(s, n);
+ let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n);
let search = if n.search_focused {
format!(" / {}", n.query)
} else if let Some(f) = n.filter {
@@ -798,7 +722,12 @@ fn render_navigator_overlay(
b,
i,
i.y,
- &format!("{} panes", s.panes.len()),
+ &format!(
+ "{} panes",
+ rows.iter()
+ .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .count()
+ ),
Style::default().fg(p.overlay0).bg(p.panel_bg),
);
put_text(
@@ -810,14 +739,12 @@ fn render_navigator_overlay(
Style::default().fg(p.surface1).bg(p.panel_bg),
);
let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4));
+ let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0);
let max = rows.len().saturating_sub(body.height as usize);
let scroll = n
.scroll
- .max(
- n.selected
- .saturating_sub(body.height.saturating_sub(1) as usize),
- )
- .min(n.selected)
+ .max(selected.saturating_sub(body.height.saturating_sub(1) as usize))
+ .min(selected)
.min(max);
let mut row_hits = Vec::new();
for (vis, (ix, r)) in rows
@@ -828,8 +755,17 @@ fn render_navigator_overlay(
.enumerate()
{
let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1);
- row_hits.push((rect, ix));
- let st = if ix == n.selected {
+ row_hits.push((rect, r.target.clone()));
+ let st = if r.stale {
+ Style::default()
+ .fg(p.overlay0)
+ .bg(if ix == selected {
+ p.surface0
+ } else {
+ p.panel_bg
+ })
+ .add_modifier(Modifier::DIM)
+ } else if ix == selected {
Style::default()
.fg(contrast(p))
.bg(p.accent)
@@ -840,23 +776,92 @@ fn render_navigator_overlay(
.bg(p.panel_bg)
};
b.set_style(rect, st);
- let tree = match r.depth {
- 0 => match &r.target {
- ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾",
- _ => "▸",
- },
- 1 => "└──",
- _ => " └──",
+ let tree = match &r.target {
+ ClientNavigatorTarget::Machine { .. } => "▾",
+ ClientNavigatorTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ } if n
+ .expanded_workspaces
+ .contains(&(endpoint_id.clone(), workspace_id.clone())) =>
+ {
+ if r.depth == 0 {
+ "▾"
+ } else {
+ " ▾"
+ }
+ }
+ ClientNavigatorTarget::Workspace { .. } => {
+ if r.depth == 0 {
+ "▸"
+ } else {
+ " ▸"
+ }
+ }
+ ClientNavigatorTarget::Tab { .. } => {
+ if r.depth == 1 {
+ "└──"
+ } else {
+ " └──"
+ }
+ }
+ ClientNavigatorTarget::Pane { .. } => {
+ if r.depth == 2 {
+ " └──"
+ } else {
+ " └──"
+ }
+ }
};
- let label = format!(
- " {} {} {} {}",
- if r.current { "◆" } else { " " },
- tree,
- status_dot(r.status),
- r.label
- );
+ let current = if r.current { "◆ " } else { "" };
+ let status = r.status.map(status_dot).unwrap_or_default();
+ let status_separator = if status.is_empty() { "" } else { " " };
+ let label = format!(" {tree} {current}{status}{status_separator}{}", r.label);
put_text(b, rect.x, rect.y, rect.width, &label, st);
- if !r.meta.is_empty() {
+ if let Some(status) = r.status {
+ let prefix = format!(" {tree} {current}");
+ let status_style = if r.stale || ix == selected {
+ st
+ } else {
+ Style::default().fg(status_color(status, p)).bg(p.panel_bg)
+ };
+ put_text(
+ b,
+ rect.x.saturating_add(display_width(&prefix)),
+ rect.y,
+ display_width(status_dot(status)),
+ status_dot(status),
+ status_style,
+ );
+ }
+ let machine_status = match &r.target {
+ ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints
+ .iter()
+ .find(|endpoint| &endpoint.endpoint_id == endpoint_id)
+ .map(|endpoint| endpoint.status),
+ _ => None,
+ };
+ if let Some(status) = machine_status {
+ let (glyph, state, color) = endpoint_status_presentation(status, p);
+ let signal = if status == ClientEndpointStatus::Online {
+ glyph.to_owned()
+ } else {
+ format!("{glyph} {state}")
+ };
+ let signal_style = if ix == selected {
+ st
+ } else {
+ Style::default()
+ .fg(color)
+ .bg(p.panel_bg)
+ .add_modifier(if r.stale {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ })
+ };
+ put_right_text(b, rect, rect.y, &signal, signal_style);
+ } else if !r.meta.is_empty() {
let label_width = display_width(&label).min(rect.width);
let meta = Rect::new(
rect.x.saturating_add(label_width).saturating_add(1),
@@ -868,7 +873,7 @@ fn render_navigator_overlay(
}
}
let dy = i.bottom() - 2;
- if let Some(r) = rows.get(n.selected) {
+ if let Some(r) = rows.get(selected) {
put_text(
b,
i.x,
diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs
index 387695ec..ad9ac8b1 100644
--- a/src/client/shell/render.rs
+++ b/src/client/shell/render.rs
@@ -3,16 +3,31 @@ use super::*;
#[path = "../shell/overlays.rs"]
mod overlays;
#[path = "../shell/sidebar.rs"]
-mod sidebar;
+pub(in crate::client::shell) mod sidebar;
#[path = "../shell/tabs.rs"]
mod tabs;
pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel};
-pub(super) use overlays::{
- client_navigator_rows, render_client_overlay, render_context_menu, render_global_menu,
-};
+pub(super) use super::aggregate_navigation::navigator_rows as client_navigator_rows;
+pub(super) use overlays::{render_client_overlay, render_context_menu, render_global_menu};
pub(super) use sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries};
pub(super) use tabs::{render_tab_bar, tab_bar_status_width};
+
+pub(in crate::client::shell) fn render_sidebar_background(
+ buffer: &mut Buffer,
+ area: Rect,
+ palette: &Palette,
+) {
+ buffer.set_style(area, Style::default().bg(palette.sidebar_bg));
+ let separator_x = area.right().saturating_sub(1);
+ for y in area.y..area.bottom() {
+ if let Some(cell) = buffer.cell_mut((separator_x, y)) {
+ cell.set_symbol("│");
+ cell.set_style(Style::default().fg(palette.surface_dim));
+ }
+ }
+}
+
pub(super) fn render_mode_bar(
buffer: &mut Buffer,
pane_area: Rect,
@@ -196,6 +211,9 @@ pub(super) fn render_mode_bar(
}
pub(super) struct ShellRenderState<'a> {
+ pub(super) endpoints: &'a [ClientShellEndpoint],
+ pub(super) active_endpoint_id: &'a ClientEndpointId,
+ pub(super) collapsed_endpoints: &'a HashSet<ClientEndpointId>,
pub(super) collapsed_groups: &'a HashSet<String>,
pub(super) workspace_scroll: &'a mut usize,
pub(super) agent_scroll: &'a mut usize,
@@ -228,7 +246,26 @@ pub(super) fn render_shell(
);
}
if layout.sidebar.width > 0 {
- if state.sidebar_collapsed {
+ if state.endpoints.len() > 1 {
+ if state.sidebar_collapsed {
+ super::endpoint_sidebar::render_collapsed(
+ buffer,
+ layout.sidebar,
+ config,
+ &mut state,
+ &mut hits,
+ );
+ } else {
+ super::endpoint_sidebar::render_expanded(
+ buffer,
+ layout.sidebar,
+ Some(snapshot),
+ config,
+ &mut state,
+ &mut hits,
+ );
+ }
+ } else if state.sidebar_collapsed {
render_collapsed_sidebar(
buffer,
layout.sidebar,
@@ -267,8 +304,10 @@ pub(super) fn render_shell(
hits.agent_scrollbar = Rect::default();
hits.agent_sort_toggle = Rect::default();
hits.new_workspace = Rect::default();
+ hits.machines.clear();
hits.workspaces.clear();
hits.agents.clear();
+ hits.endpoint_agents.clear();
hits.tab_scroll_left = Rect::default();
hits.tab_scroll_right = Rect::default();
hits.new_tab = Rect::default();
@@ -277,7 +316,7 @@ pub(super) fn render_shell(
hits
}
-fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) {
+pub(super) fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) {
let width = display_width(text).min(area.width);
put_text(
buffer,
diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs
index d90d371f..200201c5 100644
--- a/src/client/shell/settings.rs
+++ b/src/client/shell/settings.rs
@@ -313,6 +313,9 @@ impl ClientShellState {
(true, Vec::new())
}
PendingEndpointKind::IntegrationInstall => {
+ let cancelled = result
+ .as_ref()
+ .is_err_and(|error| error.code.as_deref() == Some("endpoint_cancelled"));
self.pending_integration_installs =
self.pending_integration_installs.saturating_sub(1);
if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
@@ -328,7 +331,8 @@ impl ClientShellState {
}
settings.installing_integrations = self.pending_integration_installs > 0;
}
- let actions = if self.pending_integration_installs == 0
+ let actions = if !cancelled
+ && self.pending_integration_installs == 0
&& matches!(self.overlay, Some(ClientShellOverlay::Settings(_)))
{
let mut deferred = ClientShellInput::default();
diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs
index 2a71e297..0aaa0d51 100644
--- a/src/client/shell/sidebar.rs
+++ b/src/client/shell/sidebar.rs
@@ -4,7 +4,9 @@ use ratatui::{
widgets::{Paragraph, Widget},
};
-fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) {
+pub(in crate::client::shell) fn collapsed_sidebar_sections(
+ area: Rect,
+) -> (Rect, Option<u16>, Rect) {
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
if content.is_empty() {
return (Rect::default(), None, Rect::default());
@@ -85,6 +87,7 @@ pub(crate) fn render_collapsed_sidebar(
);
hits.workspaces.push(WorkspaceHit {
rect,
+ endpoint_id: ClientEndpointId::Local,
workspace_id: workspace.workspace_id.clone(),
indented: false,
group_toggle: None,
@@ -308,6 +311,7 @@ pub(crate) fn render_sidebar(
config.status_indicators,
entry,
rows,
+ true,
selected,
dragged,
palette,
@@ -330,6 +334,7 @@ pub(crate) fn render_sidebar(
});
hits.workspaces.push(WorkspaceHit {
rect,
+ endpoint_id: ClientEndpointId::Local,
workspace_id: workspace.workspace_id.clone(),
indented: entry.indented,
group_toggle,
@@ -440,17 +445,6 @@ pub(crate) fn render_sidebar(
);
}
-pub(crate) fn render_sidebar_background(buffer: &mut Buffer, area: Rect, palette: &Palette) {
- buffer.set_style(area, Style::default().bg(palette.sidebar_bg));
- let separator_x = area.right().saturating_sub(1);
- for y in area.y..area.bottom() {
- if let Some(cell) = buffer.cell_mut((separator_x, y)) {
- cell.set_symbol("│");
- cell.set_style(Style::default().fg(palette.surface_dim));
- }
- }
-}
-
pub(crate) fn workspace_entries(
snapshot: &ClientShellSnapshot,
collapsed_groups: &HashSet<String>,
@@ -540,7 +534,7 @@ pub(crate) fn workspace_entries(
entries
}
-fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> {
+pub(super) fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> {
let workspace = snapshot.workspaces.get(index)?;
let worktree = workspace.worktree.as_ref()?;
if worktree.is_linked_worktree {
@@ -560,7 +554,7 @@ fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<Stri
.then(|| worktree.key.clone())
}
-fn displayed_workspace_status(
+pub(super) fn displayed_workspace_status(
snapshot: &ClientShellSnapshot,
workspace: &ClientShellWorkspace,
collapsed_groups: &HashSet<String>,
@@ -589,7 +583,7 @@ fn displayed_workspace_status(
.unwrap_or(workspace.agent_status)
}
-fn workspace_rows(
+pub(in crate::client::shell) fn workspace_rows(
workspace: &ClientShellWorkspace,
status: crate::api::schema::AgentStatus,
indented: bool,
@@ -618,7 +612,7 @@ fn workspace_rows(
)
}
-fn render_workspace_rows(
+pub(in crate::client::shell) fn render_workspace_rows(
buffer: &mut Buffer,
area: Rect,
workspace: &ClientShellWorkspace,
@@ -626,6 +620,7 @@ fn render_workspace_rows(
indicators: crate::config::StatusIndicatorStyle,
entry: &WorkspaceEntry,
rows: Vec<Vec<crate::ui::ResolvedToken>>,
+ endpoint_active: bool,
selected: bool,
dragged: bool,
palette: &Palette,
@@ -661,7 +656,7 @@ fn render_workspace_rows(
} else {
x = x.saturating_add(3);
}
- let highlighted = workspace.focused || dragged;
+ let highlighted = endpoint_active && workspace.focused || dragged;
let workspace_style = Style::default()
.fg(if highlighted {
palette.text
@@ -673,7 +668,7 @@ fn render_workspace_rows(
} else {
Modifier::empty()
});
- let secondary_style = Style::default().fg(if workspace.focused {
+ let secondary_style = Style::default().fg(if endpoint_active && workspace.focused {
palette.mauve
} else {
palette.overlay0
@@ -703,7 +698,7 @@ fn render_workspace_rows(
Some(palette.selection_bg)
} else if dragged {
Some(palette.surface1)
- } else if workspace.focused {
+ } else if endpoint_active && workspace.focused {
Some(palette.active_row_bg)
} else {
None
diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs
index 60307f66..67bdff91 100644
--- a/src/client/shell/state.rs
+++ b/src/client/shell/state.rs
@@ -119,16 +119,27 @@ pub(super) struct ClientShellLayout {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ClientMobileTarget {
+ Machine(ClientEndpointId),
NewWorkspace,
- Workspace(String),
+ Workspace {
+ endpoint_id: ClientEndpointId,
+ workspace_id: String,
+ },
NewTab,
- Tab(String),
- Agent(String),
+ Tab {
+ endpoint_id: ClientEndpointId,
+ tab_id: String,
+ },
+ Agent {
+ endpoint_id: ClientEndpointId,
+ pane_id: String,
+ },
Menu(usize),
}
#[derive(Default)]
pub(super) struct ShellHitMap {
+ pub(super) machines: Vec<MachineHit>,
pub(super) workspaces: Vec<WorkspaceHit>,
pub(super) workspace_body: Rect,
pub(super) workspace_scrollbar: Rect,
@@ -139,6 +150,7 @@ pub(super) struct ShellHitMap {
pub(super) popup: Option<PaneHit>,
pub(super) pane_splits: Vec<PaneSplitHit>,
pub(super) agents: Vec<(Rect, String)>,
+ pub(super) endpoint_agents: Vec<(Rect, ClientEndpointId, String)>,
pub(super) agent_body: Rect,
pub(super) agent_scrollbar: Rect,
pub(super) agent_scroll_metrics: Option<crate::pane::ScrollMetrics>,
@@ -164,7 +176,7 @@ pub(super) struct ShellHitMap {
pub(super) overlay_cancel: Rect,
pub(super) navigator_popup: Rect,
pub(super) navigator_search: Rect,
- pub(super) navigator_rows: Vec<(Rect, usize)>,
+ pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
pub(super) worktree_search: Rect,
pub(super) worktree_rows: Vec<(Rect, usize)>,
pub(super) help_popup: Rect,
@@ -215,6 +227,7 @@ pub(super) struct ClientPaneMouseGesture {
}
pub(super) struct ClientWorkspacePress {
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) workspace_id: String,
pub(super) start_column: u16,
pub(super) start_row: u16,
@@ -271,6 +284,7 @@ pub(super) enum ClientChromeDrag {
pub(super) struct WorkspaceHit {
pub(super) rect: Rect,
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) workspace_id: String,
pub(super) indented: bool,
pub(super) group_toggle: Option<(Rect, String)>,
@@ -279,12 +293,17 @@ pub(super) struct WorkspaceHit {
#[derive(Debug)]
pub(crate) enum ClientShellAction {
Endpoint {
+ endpoint_id: ClientEndpointId,
boot_id: String,
request: Box<crate::api::schema::Request>,
},
ClipboardWrite(Vec<u8>),
Request(ClientMessage),
OpenSafeWebUrl(String),
+ ActivateEndpoint {
+ endpoint_id: ClientEndpointId,
+ target: Option<ClientEndpointFocusTarget>,
+ },
ReplayMouse(Vec<crossterm::event::MouseEvent>),
Keybind(crate::input::KeybindAction),
}
@@ -366,11 +385,23 @@ pub(super) enum ClientNavigatorFilter {
Done,
}
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum ClientNavigatorTarget {
- Workspace(String),
- Tab(String),
- Pane(String),
+ Machine {
+ endpoint_id: ClientEndpointId,
+ },
+ Workspace {
+ endpoint_id: ClientEndpointId,
+ workspace_id: String,
+ },
+ Tab {
+ endpoint_id: ClientEndpointId,
+ tab_id: String,
+ },
+ Pane {
+ endpoint_id: ClientEndpointId,
+ pane_id: String,
+ },
}
#[derive(Clone, Debug)]
@@ -378,7 +409,8 @@ pub(super) struct ClientNavigatorRow {
pub(super) depth: u8,
pub(super) label: String,
pub(super) meta: String,
- pub(super) status: crate::api::schema::AgentStatus,
+ pub(super) status: Option<crate::api::schema::AgentStatus>,
+ pub(super) stale: bool,
pub(super) current: bool,
pub(super) target: ClientNavigatorTarget,
}
@@ -387,10 +419,10 @@ pub(super) struct ClientNavigatorRow {
pub(super) struct ClientNavigatorOverlay {
pub(super) query: String,
pub(super) search_focused: bool,
- pub(super) selected: usize,
+ pub(super) selected: Option<ClientNavigatorTarget>,
pub(super) scroll: usize,
pub(super) filter: Option<ClientNavigatorFilter>,
- pub(super) expanded_workspaces: HashSet<String>,
+ pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>,
}
#[derive(Debug)]
@@ -741,12 +773,15 @@ pub(crate) enum ClientShellNotificationEffect {
}
pub(super) struct ClientPendingNotification {
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) event: SemanticNotification,
pub(super) deadline: std::time::Instant,
+ pub(super) expires_at: std::time::Instant,
pub(super) validate_state: bool,
}
pub(super) struct ClientVisibleNotification {
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) event: SemanticNotification,
pub(super) deadline: std::time::Instant,
}
@@ -861,6 +896,9 @@ pub(crate) struct ClientShellState {
pub(super) config: ClientShellConfig,
pub(super) snapshot: Option<Box<ClientShellSnapshot>>,
pub(super) pane_surface: Option<PaneSurfaceFrame>,
+ /// A future projection surface waits here until its matching snapshot arrives. The visible
+ /// pane surface always remains an exact snapshot pair.
+ pub(super) pending_pane_surface: Option<PaneSurfaceFrame>,
pub(super) graphics: crate::kitty_graphics::surface::ClientState,
pub(super) graphics_cell_size: crate::kitty_graphics::HostCellSize,
pub(super) popup_terminal_id: Option<String>,
@@ -887,6 +925,9 @@ pub(crate) struct ClientShellState {
pub(super) last_tab_bar_width: Option<u16>,
pub(super) last_composed_size: Option<(u16, u16)>,
pub(super) hits: ShellHitMap,
+ pub(super) endpoints: Vec<ClientShellEndpoint>,
+ pub(super) active_endpoint_id: ClientEndpointId,
+ pub(super) collapsed_endpoints: HashSet<ClientEndpointId>,
pub(super) mode: ClientShellMode,
pub(super) navigate_workspace_id: Option<String>,
pub(super) overlay: Option<ClientShellOverlay>,
@@ -917,14 +958,11 @@ pub(crate) struct ClientShellState {
pub(super) popup_pending: bool,
pub(super) popup_pending_deadline: Option<std::time::Instant>,
pub(super) next_request_id: u64,
- /// Methods advertised by this endpoint. `None` is used only by local tests
- /// and legacy construction paths; negotiated endpoint connections always
- /// install an explicit set.
- pub(super) endpoint_methods: Option<HashSet<String>>,
pub(super) pending_requests: HashMap<String, PendingEndpointRequest>,
pub(super) pending_integration_installs: usize,
pub(super) pending_notifications: Vec<ClientPendingNotification>,
pub(super) visible_notification: Option<ClientVisibleNotification>,
+ pub(super) queued_notifications: VecDeque<ClientVisibleNotification>,
pub(super) endpoint_notice_seen: HashSet<ClientEndpointNoticeKey>,
pub(super) visible_endpoint_notice: Option<ClientVisibleEndpointNotice>,
pub(super) outer_focused: Option<bool>,
@@ -1000,6 +1038,7 @@ impl ClientShellState {
config,
snapshot: None,
pane_surface: None,
+ pending_pane_surface: None,
graphics: crate::kitty_graphics::surface::ClientState::default(),
graphics_cell_size: crate::kitty_graphics::HostCellSize {
width_px: 1,
@@ -1029,6 +1068,9 @@ impl ClientShellState {
last_tab_bar_width: None,
last_composed_size: None,
hits: ShellHitMap::default(),
+ endpoints: vec![local_endpoint()],
+ active_endpoint_id: ClientEndpointId::Local,
+ collapsed_endpoints: HashSet::new(),
mode: ClientShellMode::Terminal,
navigate_workspace_id: None,
overlay,
@@ -1059,11 +1101,11 @@ impl ClientShellState {
popup_pending: false,
popup_pending_deadline: None,
next_request_id: 1,
- endpoint_methods: None,
pending_requests: HashMap::new(),
pending_integration_installs: 0,
pending_notifications: Vec::new(),
visible_notification: None,
+ queued_notifications: VecDeque::new(),
endpoint_notice_seen: HashSet::new(),
visible_endpoint_notice: None,
outer_focused: None,
@@ -1078,29 +1120,6 @@ impl ClientShellState {
}
}
- pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) {
- self.endpoint_methods = methods.map(|methods| methods.into_iter().collect());
- }
-
- pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool {
- self.endpoint_methods
- .as_ref()
- .is_none_or(|methods| methods.contains(crate::api::api_method_name(method)))
- }
-
- fn focused_tab_count(&self) -> usize {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return 0;
- };
- snapshot
- .tabs
- .iter()
- .filter(|tab| {
- Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref()
- })
- .count()
- }
-
pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool {
if !self.mobile_switcher_suspended || self.overlay.is_some() {
return false;
@@ -1173,16 +1192,80 @@ impl ClientShellState {
}
}
- pub(crate) fn set_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) {
+ pub(super) fn reset_endpoint_projection(&mut self) {
+ self.hits = ShellHitMap::default();
+ self.pane_surface = None;
+ self.pending_pane_surface = None;
+ self.input_leases = ClientInputLeases::default();
+ self.popup_terminal_id = None;
+ self.chrome_drag = None;
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.workspace_scroll = 0;
+ self.agent_scroll = 0;
+ self.tab_scroll = 0;
+ self.mobile_switcher_scroll = 0;
+ self.reveal_focused_workspace = true;
+ self.reveal_mobile_workspace = false;
+ self.mobile_switcher_suspended = false;
+ self.reveal_focused_tab = true;
+ self.last_tab_bar_width = None;
+ self.last_composed_size = None;
+ self.pending_requests.clear();
+ self.pane_scroll_in_flight.clear();
+ self.pane_scroll_queued.clear();
+ self.pane_scroll_targets.clear();
+ self.popup_pending = false;
+ self.popup_pending_deadline = None;
+ self.pending_integration_installs = 0;
+ self.endpoint_notice_seen.clear();
+ self.visible_endpoint_notice = None;
+ self.endpoint_error = None;
+ self.navigate_workspace_id = None;
+ self.overlay = self
+ .config
+ .startup_onboarding
+ .then_some(ClientShellOverlay::Onboarding);
+ self.previous_pane_id = None;
+ self.pane_mouse_gesture = None;
+ self.url_click_consumes_until_up = false;
+ self.replaying_url_click = false;
+ self.selection = None;
+ self.last_pane_click = None;
+ self.selection_autoscroll = None;
+ self.selection_autoscroll_deadline = None;
+ self.selection_highlight_clear_deadline = None;
+ self.pending_word_selection = None;
+ self.copy_mode = None;
+ if self.mode == ClientShellMode::Copy {
+ self.mode = ClientShellMode::Terminal;
+ }
+ self.reset_copy_pipeline();
+ self.copy_feedback = None;
+ self.copy_feedback_deadline = None;
+ self.host_mouse_pixels = None;
+ self.dismissed_product_announcement = None;
+ }
+
+ pub(super) fn apply_active_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) {
snapshot
.commands
.retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown);
- if self.snapshot.as_ref().is_some_and(|current| {
- current.boot_id == snapshot.boot_id && snapshot.revision < current.revision
- }) {
+ let graphics_scope = format!(
+ "{}:{}",
+ self.active_endpoint_id.storage_key(),
+ snapshot.boot_id
+ );
+ let endpoint_boot_changed =
+ self.snapshot.is_some() && self.graphics.scope() != graphics_scope;
+ if !endpoint_boot_changed
+ && self.snapshot.as_ref().is_some_and(|current| {
+ current.boot_id == snapshot.boot_id && snapshot.revision < current.revision
+ })
+ {
return;
}
- self.graphics.set_scope(&snapshot.boot_id);
+ self.graphics.set_scope(&graphics_scope);
let command_bindings_changed = self.snapshot.as_ref().is_none_or(|current| {
current.commands.len() != snapshot.commands.len()
|| current
@@ -1221,10 +1304,11 @@ impl ClientShellState {
self.local_config_diagnostic.as_deref(),
snapshot.config_diagnostic.as_deref(),
);
- let boot_changed = self
- .snapshot
- .as_ref()
- .is_some_and(|current| current.boot_id != snapshot.boot_id);
+ let boot_changed = endpoint_boot_changed
+ || self
+ .snapshot
+ .as_ref()
+ .is_some_and(|current| current.boot_id != snapshot.boot_id);
if boot_changed
|| self
.pane_surface
@@ -1234,55 +1318,7 @@ impl ClientShellState {
self.hits = ShellHitMap::default();
}
if boot_changed {
- self.pane_surface = None;
- self.input_leases = ClientInputLeases::default();
- self.popup_terminal_id = None;
- self.chrome_drag = None;
- self.workspace_press = None;
- self.tab_press = None;
- self.workspace_scroll = 0;
- self.agent_scroll = 0;
- self.tab_scroll = 0;
- self.mobile_switcher_scroll = 0;
- self.reveal_focused_workspace = true;
- self.reveal_mobile_workspace = false;
- self.mobile_switcher_suspended = false;
- self.reveal_focused_tab = true;
- self.last_tab_bar_width = None;
- self.last_composed_size = None;
- self.pending_requests.clear();
- self.pane_scroll_in_flight.clear();
- self.pane_scroll_queued.clear();
- self.pane_scroll_targets.clear();
- self.popup_pending = false;
- self.popup_pending_deadline = None;
- self.pending_integration_installs = 0;
- self.pending_notifications.clear();
- self.visible_notification = None;
- self.endpoint_notice_seen.clear();
- self.visible_endpoint_notice = None;
- self.endpoint_error = None;
- self.navigate_workspace_id = None;
- self.overlay = self
- .config
- .startup_onboarding
- .then_some(ClientShellOverlay::Onboarding);
- self.previous_pane_id = None;
- self.pane_mouse_gesture = None;
- self.url_click_consumes_until_up = false;
- self.replaying_url_click = false;
- self.selection = None;
- self.last_pane_click = None;
- self.selection_autoscroll = None;
- self.selection_autoscroll_deadline = None;
- self.selection_highlight_clear_deadline = None;
- self.pending_word_selection = None;
- self.copy_mode = None;
- self.reset_copy_pipeline();
- self.copy_feedback = None;
- self.copy_feedback_deadline = None;
- self.host_mouse_pixels = None;
- self.dismissed_product_announcement = None;
+ self.reset_endpoint_projection();
} else if let Some(previous) = self
.snapshot
.as_deref()
@@ -1474,16 +1510,64 @@ impl ClientShellState {
}
}
self.snapshot = Some(snapshot);
+ let pending_surface = self.pending_pane_surface.take();
+ if let Some(surface) = pending_surface {
+ let matching = self.snapshot.as_ref().is_some_and(|snapshot| {
+ surface.boot_id == snapshot.boot_id
+ && surface.projection_revision == snapshot.revision
+ });
+ if matching {
+ self.install_pane_surface(surface, false);
+ } else if self.snapshot.as_ref().is_some_and(|snapshot| {
+ surface.boot_id == snapshot.boot_id
+ && surface.projection_revision > snapshot.revision
+ }) {
+ self.pending_pane_surface = Some(surface);
+ }
+ }
self.resume_mobile_switcher_if_ready();
self.reconcile_input_source();
}
- pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) {
+ pub(crate) fn has_presented_surface(&self) -> bool {
+ self.pane_surface.is_some()
+ }
+
+ pub(crate) fn set_pane_surface(&mut self, surface: PaneSurfaceFrame) {
+ let Some(snapshot) = self.snapshot.as_ref() else {
+ return;
+ };
+ if surface.boot_id != snapshot.boot_id || surface.projection_revision < snapshot.revision {
+ return;
+ }
+ if self.pane_surface.as_ref().is_some_and(|current| {
+ current.boot_id == surface.boot_id
+ && (surface.projection_revision < current.projection_revision
+ || (surface.projection_revision == current.projection_revision
+ && surface.surface_revision < current.surface_revision))
+ }) {
+ return;
+ }
+ if surface.projection_revision == snapshot.revision.saturating_add(1) {
+ // The next expected surface waits separately for its exact snapshot. Keeping the
+ // current pair avoids treating this speculative successor as presentation evidence.
+ self.pending_pane_surface = Some(surface);
+ self.hits = ShellHitMap::default();
+ return;
+ }
+ // A surface that skips one or more revisions supersedes any retained pair, but is still
+ // not rendered until its matching snapshot arrives. Retain it monotonically so delayed
+ // intermediate surfaces cannot replace it.
+ self.install_pane_surface(surface, true);
+ }
+
+ fn install_pane_surface(&mut self, mut surface: PaneSurfaceFrame, retain_future: bool) {
let Some(snapshot) = self.snapshot.as_ref() else {
return;
};
if surface.boot_id != snapshot.boot_id
|| surface.projection_revision < snapshot.revision
+ || (!retain_future && surface.projection_revision != snapshot.revision)
|| self.pane_surface.as_ref().is_some_and(|current| {
current.boot_id == surface.boot_id
&& (surface.projection_revision < current.projection_revision
@@ -1493,13 +1577,12 @@ impl ClientShellState {
{
return;
}
- if self
- .snapshot
- .as_ref()
- .is_none_or(|snapshot| snapshot.revision != surface.projection_revision)
- {
+ // A retained future surface is not presentable yet. Clear hit targets immediately; the
+ // exact-pair compose guard prevents it from replacing the visible frame.
+ if surface.projection_revision != snapshot.revision {
self.hits = ShellHitMap::default();
}
+ self.acknowledge_active_surface_agents(&surface);
let previous_popup = self.popup_terminal_id.clone();
let next_popup = surface
.popup
@@ -1696,45 +1779,8 @@ impl ClientShellState {
pub(crate) fn invalidate_pane_surface(&mut self) {
self.pane_surface = None;
+ self.pending_pane_surface = None;
self.hits = ShellHitMap::default();
self.host_mouse_pixels = None;
}
-
- fn wants_ascii_input(&self) -> bool {
- if let Some(overlay) = self.overlay.as_ref() {
- return matches!(
- overlay,
- ClientShellOverlay::ConfirmClose(_)
- | ClientShellOverlay::Help(_)
- | ClientShellOverlay::Navigator(_)
- | ClientShellOverlay::WorktreeRemove(_)
- | ClientShellOverlay::ContextMenu(_)
- | ClientShellOverlay::GlobalMenu(_)
- );
- }
- matches!(
- self.mode,
- ClientShellMode::Prefix
- | ClientShellMode::Navigate
- | ClientShellMode::Resize
- | ClientShellMode::Copy
- )
- }
-
- pub(crate) fn reconcile_input_source(&mut self) {
- // Keep the platform restore token while another window has focus. Restoring
- // through a global key injection is only safe after this client regains focus.
- if self.outer_focused == Some(false) {
- return;
- }
- let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input();
- if desired != self.ascii_input_source_active {
- self.ascii_input_source_active = desired;
- self.pending_input_source_changes.push(desired);
- }
- }
-
- pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> {
- std::mem::take(&mut self.pending_input_source_changes)
- }
}
diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs
index 2ccfe77b..10f97118 100644
--- a/src/client/shell/tests/agents_worktrees_notifications.rs
+++ b/src/client/shell/tests/agents_worktrees_notifications.rs
@@ -966,9 +966,7 @@ fn worktree_create_previews_the_endpoint_owned_checkout_path() {
&state.overlay,
Some(ClientShellOverlay::WorktreeCreate(create))
if create.checkout_path
- == std::path::Path::new("/tmp/herdr-worktrees")
- .join("repo")
- .join("feature-client-shell").to_string_lossy()
+ == "/tmp/herdr-worktrees/repo/feature-client-shell"
));
let submit = state.handle_input_bytes(b"\r");
let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else {
@@ -1154,6 +1152,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
state.set_pane_surface(surface());
let now = std::time::Instant::now();
let (effects, repaint) = state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "codex needs attention".into(),
@@ -1215,6 +1214,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
assert!(state.visible_notification.is_none());
state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "codex needs attention".into(),
@@ -1245,6 +1245,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
assert!(state.visible_notification.is_none());
state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "first".into(),
@@ -1261,6 +1262,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
assert!(state.visible_notification.is_some());
state.config.toast_delay_seconds = 1;
let (_, repaint) = state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "replacement".into(),
diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs
index e8555db6..7d71a981 100644
--- a/src/client/shell/tests/copy.rs
+++ b/src/client/shell/tests/copy.rs
@@ -766,22 +766,22 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() {
state.handle_input_bytes(b"\x1b");
state.handle_input_bytes(b"a");
state.compose(106, 30).expect("navigator rows");
- let pane_index = {
- let snapshot = state.snapshot.as_deref().expect("snapshot");
+ let pane_target = {
let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
- render::client_navigator_rows(snapshot, navigator)
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
.iter()
- .position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_)))
+ .find(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .map(|row| row.target.clone())
.expect("pane row")
};
let pane_rect = state
.hits
.navigator_rows
.iter()
- .find(|(_, index)| *index == pane_index)
+ .find(|(_, target)| *target == pane_target)
.map(|(rect, _)| *rect)
.expect("visible pane row");
let select =
diff --git a/src/client/shell/tests/endpoint_requests.rs b/src/client/shell/tests/endpoint_requests.rs
new file mode 100644
index 00000000..8149b530
--- /dev/null
+++ b/src/client/shell/tests/endpoint_requests.rs
@@ -0,0 +1,298 @@
+use super::*;
+use crate::client::endpoint::{ClientEndpointId, ClientEndpointStatus};
+
+fn pending_popup() -> (ClientShellState, Vec<ClientShellAction>) {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let binding = crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::prefix("t"),
+ label: "prefix+t".into(),
+ command: "popup-command".into(),
+ action: crate::config::CustomCommandAction::Popup,
+ description: None,
+ width: None,
+ height: None,
+ };
+ let mut projection = snapshot();
+ projection
+ .commands
+ .push(crate::protocol::ClientShellCommand {
+ command_id: "cmd_popup".into(),
+ binding_label: binding.label.clone(),
+ binding_labels: binding.bindings.labels(),
+ action: crate::protocol::ClientShellCommandAction::Popup,
+ description: None,
+ });
+ state.set_snapshot(Box::new(projection));
+ state.set_pane_surface(surface());
+ let mut outcome = ClientShellInput::default();
+ state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome);
+ assert!(state.popup_pending);
+ (state, outcome.actions)
+}
+
+fn pending_worktree() -> (ClientShellState, Vec<ClientShellAction>) {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut outcome = ClientShellInput::default();
+ state.record_binding(
+ crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree),
+ &mut outcome,
+ );
+ let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
+ panic!("expected worktree preparation");
+ };
+ state.handle_endpoint_result("boot-1", &request.id, Ok(worktree_list_result(None)));
+ state.handle_input_bytes(b"feature/reconnect");
+ let outcome = state.handle_input_bytes(b"\r");
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if create.creating
+ ));
+ (state, outcome.actions)
+}
+
+fn request_id(actions: &[ClientShellAction]) -> &str {
+ let [ClientShellAction::Endpoint { request, .. }] = actions else {
+ panic!("expected one endpoint request");
+ };
+ &request.id
+}
+
+#[test]
+fn cancelling_popup_request_unblocks_input_and_ignores_late_success() {
+ let (mut state, actions) = pending_popup();
+ let id = request_id(&actions);
+ assert!(state.cancel_endpoint_request(id));
+ assert!(!state.popup_pending);
+ assert!(state.pending_requests.is_empty());
+ assert!(state
+ .handle_endpoint_result("boot-1", id, Ok(crate::api::schema::ResponseResult::Ok {}))
+ .1
+ .is_empty());
+ assert!(!state.popup_pending);
+ assert!(!state.handle_input_bytes(b"x").requests.is_empty());
+}
+
+#[test]
+fn disconnect_cancels_worktree_dialog_before_same_server_reconnect() {
+ let (mut state, _) = pending_worktree();
+ state.mark_endpoint_disconnected(&ClientEndpointId::Local);
+ assert!(state.pending_requests.is_empty());
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating
+ ));
+ state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online);
+ state.set_endpoint_snapshot(&ClientEndpointId::Local, Box::new(snapshot()));
+ assert!(state.activate_endpoint_projection(&ClientEndpointId::Local));
+ state.set_pane_surface(surface());
+ state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
+ KeyCode::Esc,
+ KeyModifiers::NONE,
+ ))]);
+ assert!(state.overlay.is_none());
+ assert!(!state.handle_input_bytes(b"x").requests.is_empty());
+}
+
+struct TestTransport {
+ fail: bool,
+}
+
+impl crate::client::endpoint::EndpointTransport for TestTransport {
+ fn send(&mut self, _: &ClientMessage) -> std::io::Result<()> {
+ if self.fail {
+ Err(std::io::ErrorKind::BrokenPipe.into())
+ } else {
+ Ok(())
+ }
+ }
+}
+
+#[test]
+fn dispatcher_cancels_worktree_requests_on_frozen_surface_or_failed_send() {
+ use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry};
+ use crate::client::endpoint_commands::EndpointCommands;
+
+ for fail_send in [false, true] {
+ let (mut state, actions) = pending_worktree();
+ let mut endpoints = EndpointRegistry::new(
+ TestTransport { fail: fail_send },
+ 1,
+ EndpointNegotiation::default(),
+ );
+ endpoints.set_surface_active(&ClientEndpointId::Local, fail_send);
+ let mut commands = EndpointCommands::default();
+ let (tx, _rx) = tokio::sync::mpsc::channel(16);
+ let (replay, repaint) = crate::client::shell_runtime::dispatch_client_shell_actions(
+ actions,
+ &mut commands,
+ &mut endpoints,
+ Some(&mut state),
+ &mut Vec::new(),
+ &tx,
+ )
+ .unwrap();
+ assert!(repaint);
+ assert!(replay.is_empty());
+ assert!(state.pending_requests.is_empty());
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating
+ ));
+ assert!(state
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|notice| { notice.title == "Action interrupted" }));
+ assert!(commands.disconnect(&ClientEndpointId::Local).is_empty());
+ }
+}
+
+#[test]
+fn stale_queued_request_is_cancelled_without_blocking_the_current_generation() {
+ use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry};
+ use crate::client::endpoint_commands::EndpointCommands;
+
+ let (mut state, actions) = pending_popup();
+ let stale_id = request_id(&actions).to_owned();
+ let current = state.focus_endpoint_target(ClientEndpointFocusTarget::Workspace("ws_1".into()));
+ let current_id = request_id(&current).to_owned();
+ let mut commands = EndpointCommands::default();
+ for (generation, actions) in [(1, actions), (2, current)] {
+ for action in actions {
+ let ClientShellAction::Endpoint {
+ endpoint_id,
+ boot_id,
+ request,
+ } = action
+ else {
+ panic!("expected endpoint request");
+ };
+ commands.enqueue(endpoint_id, generation, boot_id, request);
+ }
+ }
+ let mut endpoints = EndpointRegistry::new(
+ TestTransport { fail: false },
+ 2,
+ EndpointNegotiation::default(),
+ );
+ let cancelled = commands.send_next(&ClientEndpointId::Local, &mut endpoints);
+ assert_eq!(cancelled, vec![stale_id.clone()]);
+ state.cancel_endpoint_request(&stale_id);
+ assert!(!state.popup_pending);
+ assert!(!commands.accepts_response(&ClientEndpointId::Local, 1, "boot-1", &stale_id));
+ assert!(commands.accepts_response(&ClientEndpointId::Local, 2, "boot-1", &current_id));
+ assert!(state.pending_requests.contains_key(&current_id));
+}
+
+#[test]
+fn cancelled_integration_install_does_not_queue_a_refresh() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.open_settings_overlay();
+ let Some(ClientShellOverlay::Settings(settings)) = state.overlay.as_mut() else {
+ panic!("settings overlay");
+ };
+ settings.installing_integrations = true;
+ state.pending_integration_installs = 1;
+ let mut outcome = ClientShellInput::default();
+ assert!(state.push_endpoint_method_with_kind(
+ crate::api::schema::Method::IntegrationInstall(
+ crate::api::schema::IntegrationInstallParams {
+ target: crate::api::schema::IntegrationTarget::Pi,
+ }
+ ),
+ PendingEndpointKind::IntegrationInstall,
+ &mut outcome,
+ ));
+ assert!(state.cancel_endpoint_request(request_id(&outcome.actions)));
+ assert!(state.pending_requests.is_empty());
+ assert_eq!(state.pending_integration_installs, 0);
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::Settings(settings))
+ if !settings.installing_integrations && !settings.loading_integrations
+ ));
+}
+
+#[test]
+fn cancelled_selection_does_not_replay_its_fallback_key() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.selection = Some(crate::selection::Selection::absolute_range(
+ "pane_1".into(),
+ (0, 0),
+ (0, 2),
+ ));
+ let mut outcome = ClientShellInput::default();
+ state.request_selection_copy_with_fallback(
+ &mut outcome,
+ Some(crate::input::TerminalKey::new(
+ KeyCode::Char('c'),
+ KeyModifiers::CONTROL,
+ )),
+ );
+ let (_, actions) = state.handle_endpoint_result(
+ "boot-1",
+ request_id(&outcome.actions),
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_cancelled".into()),
+ message: "cancelled".into(),
+ }),
+ );
+ assert!(actions.is_empty());
+ assert!(state.pending_requests.is_empty());
+}
+
+#[test]
+fn cancelled_link_activation_does_not_replay_mouse_input() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.compose(100, 28).unwrap();
+ let pane_id = state.hits.panes[0].pane_id.clone();
+ let inner_rect = state.hits.panes[0].inner_rect;
+ let mut outcome = ClientShellInput::default();
+ state.push_endpoint_method_with_kind(
+ crate::api::schema::Method::PaneLinkActivate(crate::api::schema::PaneLinkActivateParams {
+ pane_id: pane_id.clone(),
+ viewport_row: 0,
+ col: 0,
+ content_revision: None,
+ offset_from_bottom: None,
+ }),
+ PendingEndpointKind::PaneLinkActivate {
+ pane_id,
+ inner_rect,
+ fallback_events: vec![MouseEvent {
+ kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
+ column: inner_rect.x,
+ row: inner_rect.y,
+ modifiers: KeyModifiers::NONE,
+ }],
+ },
+ &mut outcome,
+ );
+ let (_, actions) = state.handle_endpoint_result(
+ "boot-1",
+ request_id(&outcome.actions),
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_cancelled".into()),
+ message: "cancelled".into(),
+ }),
+ );
+ assert!(actions.is_empty());
+ assert!(state.url_click_consumes_until_up);
+}
+
+#[test]
+fn another_machine_disconnect_does_not_cancel_active_popup() {
+ let (mut state, _) = pending_popup();
+ let remote = ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
+ state.mark_endpoint_disconnected(&remote);
+ assert!(state.popup_pending);
+ assert_eq!(state.pending_requests.len(), 1);
+}
diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs
new file mode 100644
index 00000000..52c71923
--- /dev/null
+++ b/src/client/shell/tests/endpoints.rs
@@ -0,0 +1,1205 @@
+use super::*;
+use crate::client::endpoint::{
+ ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint,
+};
+use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
+
+fn remote_profile() -> SavedSshEndpoint {
+ SavedSshEndpoint {
+ id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Build".into(),
+ target: "dev@build.example".into(),
+ session: "agents".into(),
+ enabled: true,
+ }
+}
+
+fn agent(
+ name: &str,
+ status: crate::api::schema::AgentStatus,
+ state_change_seq: u64,
+) -> ClientShellAgent {
+ ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some(name.into()),
+ display_agent: None,
+ agent: Some("pi".into()),
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: status,
+ state_change_seq,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: true,
+ }
+}
+
+fn state_with_remote() -> (ClientShellState, ClientEndpointId) {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let profile = remote_profile();
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.workspaces[0].label = "remote-workspace".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ (state, endpoint_id)
+}
+
+#[test]
+fn switching_machines_from_copy_mode_restores_terminal_input() {
+ let (mut state, remote) = state_with_remote();
+ let mut local_surface = surface();
+ local_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 20,
+ viewport_rows: 2,
+ });
+ state.set_pane_surface(local_surface);
+ state.compose(100, 28).unwrap();
+ assert!(state.enter_copy_mode(&mut ClientShellInput::default()));
+ assert_eq!(state.mode, ClientShellMode::Copy);
+
+ assert!(state.activate_endpoint_projection(&remote));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+ state.compose(100, 28).unwrap();
+
+ assert!(state.copy_mode.is_none());
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ let input = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
+ KeyCode::Char('x'),
+ KeyModifiers::NONE,
+ ))]);
+ assert!(matches!(
+ input.requests.as_slice(),
+ [ClientMessage::ClientShellPaneInput { pane_id, events }]
+ if pane_id == "pane_1" && events.len() == 1
+ ));
+}
+
+#[test]
+fn live_catalog_rename_preserves_snapshot_and_disable_reenable_clears_it() {
+ let (mut state, remote) = state_with_remote();
+ let mut profile = remote_profile();
+ profile.label = "Renamed".into();
+ state.set_endpoint_catalog(&[profile.clone()]);
+ assert_eq!(state.endpoint_label(&remote), "Renamed");
+ assert!(state.endpoint_is_online(&remote));
+ assert_eq!(state.endpoint_boot_id(&remote), Some("remote-boot"));
+ profile.enabled = false;
+ state.set_endpoint_catalog(&[profile.clone()]);
+ assert_eq!(
+ state.endpoint_status(&remote),
+ Some(ClientEndpointStatus::Disabled)
+ );
+ assert!(!state.endpoint_has_snapshot(&remote));
+ profile.enabled = true;
+ state.set_endpoint_catalog(&[profile]);
+ assert_eq!(
+ state.endpoint_status(&remote),
+ Some(ClientEndpointStatus::Connecting)
+ );
+ assert!(!state.endpoint_has_snapshot(&remote));
+}
+
+#[test]
+fn live_catalog_active_removal_does_not_retain_remote_projection_or_input() {
+ let (mut state, remote) = state_with_remote();
+ assert!(state.activate_endpoint_projection(&remote));
+ state.set_pane_surface(surface());
+ state.mode = ClientShellMode::Prefix;
+ state.overlay = Some(ClientShellOverlay::Onboarding);
+ state.select_unavailable_local();
+ state.retire_endpoint(&remote);
+ state.set_endpoint_catalog(&[]);
+ assert!(state.endpoint_is_active(&ClientEndpointId::Local));
+ assert!(state.snapshot.is_none());
+ assert!(state.pane_surface.is_none());
+ assert!(state.pending_pane_surface.is_none());
+ assert!(state.overlay.is_none());
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ assert!(state.endpoint_has_snapshot(&ClientEndpointId::Local));
+ let frame = state.compose(100, 30).unwrap();
+ let buffer = frame.to_ratatui_buffer().unwrap();
+ let text = buffer
+ .content()
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+ assert!(!text.contains("remote-workspace"));
+}
+
+#[test]
+fn machine_navigation_does_not_require_a_local_snapshot_or_surface() {
+ for (cols, rows) in [(100, 28), (36, 18)] {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let profile = remote_profile();
+ let remote = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting);
+ state.set_endpoint_status(&remote, ClientEndpointStatus::Online);
+ state.set_endpoint_snapshot(&remote, Box::new(snapshot()));
+ assert!(state.snapshot.is_none());
+ assert!(state.pane_surface.is_none());
+ let frame = state
+ .compose(cols, rows)
+ .expect("connection chrome without Local");
+ let local = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .unwrap()
+ .rect;
+ let buffer = frame.to_ratatui_buffer().unwrap();
+ let local_row = (local.x..local.right())
+ .map(|x| buffer[(x, local.y)].symbol())
+ .collect::<String>();
+ assert!(!local_row.contains("reconnecting"));
+ assert!(
+ !local_row.contains('◐'),
+ "Local never gets a connection badge"
+ );
+ let hit = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == remote)
+ .unwrap()
+ .rect;
+ let mut outcome = ClientShellInput::default();
+ state.handle_mouse(
+ crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: hit.x + 5,
+ row: hit.y,
+ modifiers: KeyModifiers::NONE,
+ },
+ &mut outcome,
+ );
+ assert!(
+ matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint { endpoint_id, .. }] if endpoint_id == &remote)
+ );
+ assert!(
+ state.snapshot.is_none(),
+ "selection is committed only by coherent activation"
+ );
+ }
+}
+
+#[test]
+fn sidebar_renders_local_and_saved_ssh_endpoints_with_status() {
+ let (mut state, _) = state_with_remote();
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ let text = frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ assert!(text.contains("Local"));
+ assert!(text.contains("Build"));
+ assert!(text.contains("remote-workspace"));
+ let local = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("local machine row")
+ .rect;
+ let remote = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| !hit.endpoint_id.is_local())
+ .expect("remote machine row")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●");
+ assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●");
+ assert_eq!(
+ buffer[(remote.right() - 1, remote.y)].fg,
+ state.config.palette.green
+ );
+
+ state.sidebar_collapsed = true;
+ let frame = state.compose(100, 28).expect("collapsed endpoint frame");
+ let local = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("collapsed local machine row")
+ .rect;
+ let remote = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| !hit.endpoint_id.is_local())
+ .expect("collapsed remote machine row")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●");
+ assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●");
+}
+
+#[test]
+fn active_workspace_is_the_only_highlight_when_machine_is_expanded() {
+ let (mut state, endpoint_id) = state_with_remote();
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ let machine = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote machine hit")
+ .rect;
+ let workspace = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace hit")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_ne!(
+ buffer[(machine.x, machine.y)].bg,
+ state.config.palette.active_row_bg
+ );
+ assert_eq!(
+ buffer[(workspace.x + 2, workspace.y)].bg,
+ state.config.palette.active_row_bg
+ );
+
+ state.collapsed_endpoints.insert(endpoint_id.clone());
+ let frame = state.compose(100, 28).expect("collapsed endpoint frame");
+ let machine = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote machine hit")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_eq!(
+ buffer[(machine.x, machine.y)].bg,
+ state.config.palette.active_row_bg
+ );
+}
+
+#[test]
+fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() {
+ use crate::api::schema::AgentStatus;
+ use crate::config::{AgentSidebarToken, StatusIndicatorStyle};
+
+ let mut config = Config::default();
+ config.ui.status_indicators = StatusIndicatorStyle::Symbols;
+ config.ui.sidebar.agents.rows = vec![vec![
+ AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Machine,
+ AgentSidebarToken::Agent,
+ ]];
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
+ let profile = remote_profile();
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+
+ let mut local = snapshot();
+ local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
+ state.set_snapshot(Box::new(local));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.agents = vec![agent("remote agent", AgentStatus::Blocked, 1)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ let text = frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ assert!(text.contains("○ Local · local agent"), "frame: {text}");
+ assert!(text.contains("× Build · remote agent"), "frame: {text}");
+ assert!(text.contains("grouped"), "frame: {text}");
+ let toggle = state.hits.agent_sort_toggle;
+ assert!(!toggle.is_empty());
+ let click = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: toggle.x,
+ row: toggle.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert_eq!(
+ state.config.agent_panel_sort,
+ crate::config::AgentPanelSortConfig::Priority
+ );
+ assert!(click.actions.is_empty());
+
+ let buffer = frame
+ .to_ratatui_buffer()
+ .expect("aggregate frame should reconstruct");
+ assert!(buffer
+ .content()
+ .iter()
+ .any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red));
+}
+
+#[test]
+fn aggregate_priority_uses_client_observed_recency_across_machines() {
+ use crate::api::schema::AgentStatus;
+ use crate::config::AgentSidebarToken;
+
+ let mut config = Config::default();
+ config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority;
+ config.ui.sidebar.agents.rows =
+ vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
+ let profile = remote_profile();
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+
+ let mut local = snapshot();
+ local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
+ state.set_snapshot(Box::new(local));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
+
+ let mut local = snapshot();
+ local.agents = vec![agent("local agent", AgentStatus::Idle, 2)];
+ state.set_snapshot(Box::new(local));
+ let frame_text = |state: &mut ClientShellState| {
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n")
+ };
+ let text = frame_text(&mut state);
+ assert!(
+ text.find("Local · local agent").expect("local agent")
+ < text.find("Build · remote agent").expect("remote agent")
+ );
+
+ remote.agents = vec![agent("remote agent", AgentStatus::Working, 2)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
+ let text = frame_text(&mut state);
+ assert!(
+ text.find("Build · remote agent").expect("remote agent")
+ < text.find("Local · local agent").expect("local agent")
+ );
+
+ remote.agents = vec![agent("remote agent", AgentStatus::Idle, 3)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ let text = frame_text(&mut state);
+ assert!(
+ text.find("Build · remote agent").expect("remote agent")
+ < text.find("Local · local agent").expect("local agent")
+ );
+ let mut outcome = ClientShellInput::default();
+ assert!(
+ state.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(0), &mut outcome,)
+ );
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ }] if activated == &endpoint_id && pane_id == "pane_1"
+ ));
+}
+
+#[test]
+fn unselected_endpoint_completion_projects_done_client_side() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.agents = vec![agent("background agent", AgentStatus::Working, 2)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
+ remote.revision = 2;
+ remote.agents = vec![agent("background agent", AgentStatus::Idle, 3)];
+
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+
+ let status = state
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ .and_then(|snapshot| snapshot.agents.first())
+ .map(|agent| agent.agent_status);
+ assert_eq!(status, Some(AgentStatus::Done));
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+}
+
+#[test]
+fn clicking_remote_endpoint_requests_activation_without_mutating_projection() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.compose(100, 28).expect("combined endpoint frame");
+ let hit = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote endpoint hit")
+ .rect;
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: hit.x,
+ row: hit.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: None,
+ }] if activated == &endpoint_id
+ ));
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+ assert_eq!(
+ state
+ .snapshot
+ .as_deref()
+ .map(|snapshot| snapshot.boot_id.as_str()),
+ Some("boot-1")
+ );
+}
+
+#[test]
+fn context_menu_lookup_ignores_inactive_endpoint_workspaces() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.compose(100, 28).expect("combined endpoint frame");
+ let remote = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace")
+ .rect;
+ let local = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("local workspace")
+ .rect;
+
+ assert_eq!(
+ state.active_endpoint_workspace_at((remote.x, remote.y)),
+ None
+ );
+ assert_eq!(
+ state.active_endpoint_workspace_at((local.x, local.y)),
+ Some("ws_1".into())
+ );
+}
+
+#[test]
+fn future_surface_waits_for_its_exact_snapshot_revision() {
+ let (mut state, _) = state_with_remote();
+ let mut future = surface();
+ future.projection_revision = 2;
+ future.surface_revision = 2;
+ state.set_pane_surface(future);
+ assert_eq!(
+ state
+ .pane_surface
+ .as_ref()
+ .map(|surface| surface.projection_revision),
+ Some(1)
+ );
+ assert_eq!(
+ state
+ .pending_pane_surface
+ .as_ref()
+ .map(|surface| surface.projection_revision),
+ Some(2)
+ );
+
+ let mut next = snapshot();
+ next.revision = 2;
+ state.set_snapshot(Box::new(next));
+ assert_eq!(
+ state
+ .pane_surface
+ .as_ref()
+ .map(|surface| surface.projection_revision),
+ Some(2)
+ );
+ assert!(state.pending_pane_surface.is_none());
+}
+
+#[test]
+fn inactive_endpoint_snapshot_cache_never_regresses_revision() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let mut newest = snapshot();
+ newest.boot_id = "remote-boot".into();
+ newest.revision = 3;
+ newest.workspaces[0].label = "newest".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(newest));
+ let mut delayed = snapshot();
+ delayed.boot_id = "remote-boot".into();
+ delayed.revision = 2;
+ delayed.workspaces[0].label = "delayed".into();
+
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(delayed));
+
+ let label = state
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ .and_then(|snapshot| snapshot.workspaces.first())
+ .map(|workspace| workspace.label.as_str());
+ assert_eq!(label, Some("newest"));
+}
+
+#[test]
+fn new_connection_generation_accepts_a_lower_same_boot_projection_revision() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let mut previous = snapshot();
+ previous.boot_id = "shared-server-boot".into();
+ previous.revision = 9;
+ previous.workspaces[0].label = "old connection".into();
+ state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 4, Box::new(previous));
+ let mut reconnected = snapshot();
+ reconnected.boot_id = "shared-server-boot".into();
+ reconnected.revision = 1;
+ reconnected.workspaces[0].label = "new connection".into();
+
+ state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 5, Box::new(reconnected));
+
+ let endpoint = state
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint");
+ assert_eq!(endpoint.snapshot_generation, Some(5));
+ assert_eq!(endpoint.snapshot.as_ref().unwrap().revision, 1);
+ assert_eq!(
+ endpoint.snapshot.as_ref().unwrap().workspaces[0].label,
+ "new connection"
+ );
+}
+
+#[test]
+fn reconnect_snapshot_waits_for_coherent_activation_before_replacing_projection() {
+ let (mut state, endpoint_id) = state_with_remote();
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot");
+
+ state.mark_endpoint_disconnected(&endpoint_id);
+ let mut replacement = snapshot();
+ replacement.boot_id = "replacement-boot".into();
+ state.cache_endpoint_snapshot(&endpoint_id, Box::new(replacement));
+ assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot");
+
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ assert_eq!(
+ state.snapshot.as_deref().unwrap().boot_id,
+ "replacement-boot"
+ );
+}
+
+#[test]
+fn disconnected_active_endpoint_freezes_surface_and_marks_cached_ui_stale() {
+ use crate::api::schema::AgentStatus;
+ use crate::config::{AgentSidebarToken, StatusIndicatorStyle};
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state.config.status_indicators = StatusIndicatorStyle::Symbols;
+ state.config.agents.rows = vec![vec![
+ AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Machine,
+ AgentSidebarToken::Agent,
+ ]];
+ let endpoint = state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint");
+ endpoint.snapshot.as_mut().expect("remote snapshot").agents =
+ vec![agent("remote agent", AgentStatus::Blocked, 1)];
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+ state.pending_integration_installs = 2;
+
+ state.mark_endpoint_disconnected(&endpoint_id);
+ let frame = state.compose(100, 28).expect("frozen endpoint frame");
+ let text = frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ assert_eq!(state.pending_integration_installs, 0);
+ assert_eq!(
+ state.endpoint_status(&endpoint_id),
+ Some(ClientEndpointStatus::Reconnecting)
+ );
+ assert!(text.contains("◐ reconnecting"), "frame: {text}");
+ assert!(text.contains("Build · remote agent"), "frame: {text}");
+ assert!(
+ text.contains("LIVE"),
+ "frozen surface should remain: {text}"
+ );
+ assert!(state.hits.panes.is_empty());
+ assert!(frame.cursor.is_none());
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ let stale_icon = buffer
+ .content()
+ .iter()
+ .find(|cell| cell.symbol() == "×")
+ .expect("stale blocked icon");
+ assert_eq!(stale_icon.fg, state.config.palette.overlay0);
+}
+
+#[cfg(unix)]
+#[test]
+fn graphics_scope_qualifies_colliding_boot_ids_by_endpoint() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let local_scope = state.graphics_scope().to_owned();
+ let mut remote = snapshot();
+ remote.boot_id = "boot-1".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let remote_scope = state.graphics_scope();
+ assert_ne!(local_scope, remote_scope);
+ assert!(remote_scope.starts_with("ssh:0123456789abcdef0123456789abcdef:"));
+}
+
+#[test]
+fn navigator_uses_machine_parents_only_for_federated_clients() {
+ let (mut state, _) = state_with_remote();
+ state.open_navigator_overlay();
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+ let machines = rows
+ .iter()
+ .filter(|row| matches!(row.target, ClientNavigatorTarget::Machine { .. }))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ machines
+ .iter()
+ .map(|row| row.label.as_str())
+ .collect::<Vec<_>>(),
+ vec!["Local", "Build"]
+ );
+ assert!(rows.iter().all(|row| {
+ matches!(row.target, ClientNavigatorTarget::Machine { .. })
+ || (!row.label.contains("Local ·") && !row.label.contains("Build ·"))
+ }));
+ assert!(rows.iter().all(|row| match row.target {
+ ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(),
+ ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(),
+ ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(),
+ ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(),
+ }));
+ assert_eq!(rows.iter().filter(|row| row.current).count(), 1);
+
+ let mut local = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ local.set_snapshot(Box::new(snapshot()));
+ local.set_pane_surface(surface());
+ let frame = local.compose(100, 28).expect("local-only sidebar");
+ assert!(local.hits.machines.is_empty());
+ assert!(!frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n")
+ .contains(" machines"));
+ local.open_navigator_overlay();
+ let ClientShellOverlay::Navigator(navigator) = local.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&local.endpoints, &local.active_endpoint_id, navigator);
+ assert!(rows
+ .iter()
+ .all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. })));
+ assert!(rows.iter().all(|row| match row.target {
+ ClientNavigatorTarget::Workspace { .. } => row.depth == 0,
+ ClientNavigatorTarget::Tab { .. } => row.depth == 1,
+ ClientNavigatorTarget::Pane { .. } => row.depth == 2,
+ ClientNavigatorTarget::Machine { .. } => false,
+ }));
+}
+
+#[test]
+fn navigator_keeps_saved_machine_visible_before_metadata_arrives() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let endpoint = state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("saved remote endpoint");
+ endpoint.snapshot = None;
+ endpoint.status = ClientEndpointStatus::Connecting;
+ state.open_navigator_overlay();
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+
+ assert!(rows.iter().any(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
+ ) && row.label == "Build"
+ && row.stale
+ }));
+ assert!(!rows.iter().any(|row| match &row.target {
+ ClientNavigatorTarget::Machine { .. } => false,
+ ClientNavigatorTarget::Workspace {
+ endpoint_id: target,
+ ..
+ }
+ | ClientNavigatorTarget::Tab {
+ endpoint_id: target,
+ ..
+ }
+ | ClientNavigatorTarget::Pane {
+ endpoint_id: target,
+ ..
+ } => target == &endpoint_id,
+ }));
+}
+
+#[test]
+fn navigator_machine_selection_opens_its_remembered_view() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
+ .into_iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
+ )
+ })
+ .map(|row| row.target)
+ .expect("remote machine row")
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+
+ let mut outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut outcome);
+
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: None,
+ }] if activated == &endpoint_id
+ ));
+ assert!(state.overlay.is_none());
+}
+
+#[test]
+fn navigator_foreign_pane_selection_activates_its_endpoint() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Pane {
+ endpoint_id: target_endpoint,
+ pane_id,
+ } if target_endpoint == &endpoint_id && pane_id == "pane_1"
+ )
+ })
+ .map(|row| row.target.clone())
+ .expect("remote pane row")
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+ let mut local = snapshot();
+ let mut inserted = local.workspaces[0].clone();
+ inserted.workspace_id = "ws_2".into();
+ inserted.focused = false;
+ local.workspaces.push(inserted);
+ state.set_snapshot(Box::new(local));
+
+ let mut outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut outcome);
+
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ }] if activated == &endpoint_id && pane_id == "pane_1"
+ ));
+ assert!(state.overlay.is_none());
+}
+
+#[test]
+fn mobile_foreign_agent_and_workspace_targets_activate_their_endpoint() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .agents = vec![agent("remote agent", AgentStatus::Working, 2)];
+ state.mode = ClientShellMode::Navigate;
+ state.compose(44, 30).expect("mobile switcher");
+ let remote_agent = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(
+ target,
+ ClientMobileTarget::Agent {
+ endpoint_id: target_endpoint,
+ pane_id,
+ } if target_endpoint == &endpoint_id && pane_id == "pane_1"
+ )
+ .then_some(*rect)
+ })
+ .expect("remote agent target");
+ let click = |rect: Rect| {
+ RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: rect.x,
+ row: rect.y,
+ modifiers: KeyModifiers::empty(),
+ })
+ };
+ let outcome = state.handle_raw_events(vec![click(remote_agent)]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ }] if activated == &endpoint_id && pane_id == "pane_1"
+ ));
+
+ state.mode = ClientShellMode::Navigate;
+ state.compose(44, 30).expect("mobile switcher");
+ let remote_workspace = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(
+ target,
+ ClientMobileTarget::Workspace {
+ endpoint_id: target_endpoint,
+ workspace_id,
+ } if target_endpoint == &endpoint_id && workspace_id == "ws_1"
+ )
+ .then_some(*rect)
+ })
+ .expect("remote workspace target");
+ let outcome = state.handle_raw_events(vec![click(remote_workspace)]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)),
+ }] if activated == &endpoint_id && workspace_id == "ws_1"
+ ));
+}
+
+#[test]
+fn cached_offline_navigator_and_mobile_targets_are_dimmed_and_disabled() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .agents = vec![agent("remote agent", AgentStatus::Blocked, 2)];
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting);
+
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+ let machine = rows
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
+ )
+ })
+ .expect("cached remote machine row");
+ assert!(machine.stale);
+ let row = rows
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Pane {
+ endpoint_id: target_endpoint,
+ pane_id,
+ } if target_endpoint == &endpoint_id && pane_id == "pane_1"
+ )
+ })
+ .expect("cached remote pane row");
+ assert!(row.stale);
+ assert!(!row.meta.contains("reconnecting"));
+ row.target.clone()
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+ let mut navigator_outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut navigator_outcome);
+ assert!(navigator_outcome.actions.is_empty());
+ assert!(matches!(
+ state.overlay,
+ Some(ClientShellOverlay::Navigator(_))
+ ));
+
+ state.overlay = None;
+ state.mode = ClientShellMode::Navigate;
+ let frame = state.compose(44, 30).expect("offline mobile switcher");
+ let mobile_target = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(
+ target,
+ ClientMobileTarget::Workspace {
+ endpoint_id: target_endpoint,
+ workspace_id,
+ } if target_endpoint == &endpoint_id && workspace_id == "ws_1"
+ )
+ .then_some(*rect)
+ })
+ .expect("cached remote workspace target");
+ let buffer = frame.to_ratatui_buffer().expect("mobile frame buffer");
+ assert_eq!(
+ buffer[(mobile_target.x, mobile_target.y)].fg,
+ state.config.palette.overlay0
+ );
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: mobile_target.x,
+ row: mobile_target.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(outcome.actions.is_empty());
+ assert_eq!(state.mode, ClientShellMode::Navigate);
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+}
+
+#[test]
+fn focus_agent_index_uses_online_aggregate_rows() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .agents = vec![agent("remote agent", AgentStatus::Working, 2)];
+ let focus_agent =
+ |index| crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(index));
+
+ assert!(state.indexed_navigation_target_exists(&focus_agent(0)));
+ assert!(!state.indexed_navigation_target_exists(&focus_agent(1)));
+
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting);
+ assert!(!state.indexed_navigation_target_exists(&focus_agent(0)));
+}
+
+#[test]
+fn workspace_drag_rejects_foreign_endpoint_slots() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.compose(100, 28).expect("aggregate sidebar");
+ let local = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("local workspace")
+ .rect;
+ let remote = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace")
+ .rect;
+ let mouse = |kind, rect: Rect| {
+ RawInputEvent::Mouse(MouseEvent {
+ kind,
+ column: rect.x.saturating_add(1),
+ row: rect.y,
+ modifiers: KeyModifiers::empty(),
+ })
+ };
+
+ state.handle_raw_events(vec![mouse(MouseEventKind::Down(MouseButton::Left), local)]);
+ state.handle_raw_events(vec![mouse(MouseEventKind::Drag(MouseButton::Left), remote)]);
+
+ assert!(state.chrome_drag.is_none());
+}
+
+#[test]
+fn collapsed_aggregate_workspace_status_uses_its_status_color() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .workspaces[0]
+ .agent_status = AgentStatus::Blocked;
+ state.sidebar_collapsed = true;
+
+ let frame = state.compose(100, 28).expect("collapsed aggregate sidebar");
+ let workspace = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame buffer");
+ assert_eq!(
+ buffer[(workspace.x.saturating_add(2), workspace.y)].fg,
+ state.config.palette.red
+ );
+}
+
+#[test]
+fn navigator_foreign_tab_selection_keeps_the_tab_target() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Tab {
+ endpoint_id: target_endpoint,
+ tab_id,
+ ..
+ } if target_endpoint == &endpoint_id && tab_id == "tab_1"
+ )
+ })
+ .map(|row| row.target.clone())
+ .expect("remote tab row")
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+
+ let mut outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut outcome);
+
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Tab(tab_id)),
+ }] if activated == &endpoint_id && tab_id == "tab_1"
+ ));
+}
diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs
index d20485b3..b8f17e57 100644
--- a/src/client/shell/tests/keybindings_settings.rs
+++ b/src/client/shell/tests/keybindings_settings.rs
@@ -196,7 +196,10 @@ fn prefix_endpoint_action_uses_public_api_with_stable_ids() {
assert!(state.handle_input_bytes(&[0x02]).actions.is_empty());
let create = state.handle_input_bytes(b"c");
- let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else {
+ let [ClientShellAction::Endpoint {
+ boot_id, request, ..
+ }] = &create.actions[..]
+ else {
panic!("expected one endpoint action: {:?}", create.actions);
};
assert_eq!(boot_id, "boot-1");
diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs
index 094b141f..b7cc2a47 100644
--- a/src/client/shell/tests/mobile.rs
+++ b/src/client/shell/tests/mobile.rs
@@ -30,6 +30,7 @@ fn navigate_update_status_uses_released_desktop_and_mobile_placement() {
state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top;
state.visible_notification = Some(ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
event: SemanticNotification {
kind: SemanticNotificationKind::Custom,
title: "bottom notification".into(),
@@ -69,6 +70,74 @@ fn mobile_layout_reserves_only_client_header() {
}
#[test]
+fn mobile_switcher_can_activate_an_online_saved_machine() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let profile = crate::client::endpoint::SavedSshEndpoint {
+ id: crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Build".into(),
+ target: "build".into(),
+ session: "agents".into(),
+ enabled: true,
+ };
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ state.mode = ClientShellMode::Navigate;
+
+ let frame = state.compose(44, 30).expect("mobile machine switcher");
+ let text = frame
+ .cells
+ .iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>();
+ assert!(text.contains("machines"));
+ assert!(text.contains("Build"));
+ let machine = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(target, ClientMobileTarget::Machine(id) if id == &endpoint_id).then_some(*rect)
+ })
+ .expect("saved machine target");
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: machine.x,
+ row: machine.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: None,
+ }] if activated == &endpoint_id
+ ));
+
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+ state.mark_endpoint_disconnected(&endpoint_id);
+ state.receive_endpoint_unavailable("network lost".into());
+ state.mode = ClientShellMode::Navigate;
+ let stale = state.compose(44, 30).expect("stale mobile switcher");
+ let text = stale
+ .cells
+ .iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>();
+ assert!(text.contains("reconnecting"), "frame: {text}");
+ assert!(text.contains("network lost"), "frame: {text}");
+}
+
+#[test]
fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() {
let mut config = ClientShellConfig::from_config(&Config::default());
config.mouse_capture = false;
@@ -175,7 +244,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
.mobile_targets
.iter()
.find_map(|(rect, target)| {
- matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect)
+ matches!(
+ target,
+ ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_1"
+ )
+ .then_some(*rect)
})
.expect("workspace hit");
let focused = state.handle_raw_events(vec![click(workspace_hit)]);
@@ -199,7 +272,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
.mobile_targets
.iter()
.find_map(|(rect, target)| {
- matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect)
+ matches!(
+ target,
+ ClientMobileTarget::Agent { pane_id, .. } if pane_id == "pane_1"
+ )
+ .then_some(*rect)
})
.expect("agent hit");
let focused = state.handle_raw_events(vec![click(agent_hit)]);
@@ -221,7 +298,14 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
.mobile_targets
.iter()
.find_map(|(rect, target)| {
- matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect)
+ matches!(
+ target,
+ ClientMobileTarget::Tab {
+ endpoint_id,
+ tab_id,
+ } if endpoint_id.is_local() && tab_id == "tab_1"
+ )
+ .then_some(*rect)
})
.expect("tab hit");
let focused = state.handle_raw_events(vec![click(tab_hit)]);
@@ -534,7 +618,10 @@ fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() {
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8"));
assert!(state.mobile_switcher_scroll > 2);
assert!(state.hits.mobile_targets.iter().any(|(_, target)| {
- matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8")
+ matches!(
+ target,
+ ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_8"
+ )
}));
let close = state.hits.mobile_close;
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs
index 4cf97152..497ce2c6 100644
--- a/src/client/shell/tests/mod.rs
+++ b/src/client/shell/tests/mod.rs
@@ -6,7 +6,7 @@ use crate::protocol::{
};
use crossterm::event::MouseEvent;
-fn snapshot() -> ClientShellSnapshot {
+pub(super) fn snapshot() -> ClientShellSnapshot {
ClientShellSnapshot {
boot_id: "boot-1".into(),
revision: 1,
@@ -211,6 +211,8 @@ fn surface_with_popup() -> PaneSurfaceFrame {
mod agents_worktrees_notifications;
mod chrome_context;
mod copy;
+mod endpoint_requests;
+mod endpoints;
#[path = "input.rs"]
mod input_domain;
mod keybindings_settings;
diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs
index 6ccdcf6e..044ef145 100644
--- a/src/client/shell/tests/popup_focus_projection.rs
+++ b/src/client/shell/tests/popup_focus_projection.rs
@@ -82,7 +82,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay {
query: String::new(),
search_focused: false,
- selected: 0,
+ selected: None,
scroll: 0,
filter: None,
expanded_workspaces: HashSet::new(),
diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs
index 2e2901e8..7107aa71 100644
--- a/src/client/shell/tests/startup_overlays.rs
+++ b/src/client/shell/tests/startup_overlays.rs
@@ -541,6 +541,7 @@ fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() {
state.set_snapshot(Box::new(endpoint_snapshot));
state.set_pane_surface(surface());
state.visible_notification = Some(ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
event: SemanticNotification {
kind: SemanticNotificationKind::Custom,
title: "notification".into(),
diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs
index bcadf1c8..ea2e8f24 100644
--- a/src/client/shell/worktrees.rs
+++ b/src/client/shell/worktrees.rs
@@ -1,10 +1,24 @@
use super::*;
+fn checkout_path_preview(root: &str, repo: &str, branch: &str) -> String {
+ // This is an endpoint path, not a path on the machine drawing the dialog.
+ let separator = if !root.starts_with('/') && root.contains('\\') {
+ '\\'
+ } else {
+ '/'
+ };
+ format!(
+ "{}{separator}{repo}{separator}{}",
+ root.trim_end_matches(separator),
+ crate::worktree::branch_to_path_slug(branch)
+ )
+}
+
impl ClientShellState {
- fn endpoint_worktree_directory(&self) -> Option<std::path::PathBuf> {
+ fn endpoint_worktree_directory(&self) -> Option<String> {
self.snapshot
.as_deref()
- .map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory))
+ .map(|snapshot| snapshot.worktree_directory.clone())
}
pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool {
@@ -241,13 +255,8 @@ impl ClientShellState {
let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else {
return;
};
- create.checkout_path = crate::worktree::default_checkout_path(
- &worktree_directory,
- &create.repo_name,
- &create.branch,
- )
- .display()
- .to_string();
+ create.checkout_path =
+ checkout_path_preview(&worktree_directory, &create.repo_name, &create.branch);
create.error = None;
}
@@ -270,9 +279,7 @@ impl ClientShellState {
create.branch = branch.clone();
create.replace_on_type = false;
create.checkout_path =
- crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch)
- .display()
- .to_string();
+ checkout_path_preview(&worktree_directory, &create.repo_name, &branch);
create.creating = true;
create.error = None;
let workspace_id = create.source_workspace_id.clone();
@@ -399,13 +406,8 @@ impl ClientShellState {
let Some(worktree_directory) = self.endpoint_worktree_directory() else {
return false;
};
- let checkout_path = crate::worktree::default_checkout_path(
- &worktree_directory,
- &source.repo_name,
- &branch,
- )
- .display()
- .to_string();
+ let checkout_path =
+ checkout_path_preview(&worktree_directory, &source.repo_name, &branch);
self.overlay = Some(ClientShellOverlay::WorktreeCreate(
ClientWorktreeCreateOverlay {
source_workspace_id: workspace_id,
@@ -549,3 +551,24 @@ impl ClientShellState {
}
}
}
+
+#[cfg(test)]
+mod path_tests {
+ use super::checkout_path_preview;
+
+ #[test]
+ fn endpoint_path_style_is_independent_of_the_client_os() {
+ assert_eq!(
+ checkout_path_preview("/worktrees/", "repo", "feature/a"),
+ "/worktrees/repo/feature-a"
+ );
+ assert_eq!(
+ checkout_path_preview(r"C:\worktrees\", "repo", "feature/a"),
+ r"C:\worktrees\repo\feature-a"
+ );
+ assert_eq!(
+ checkout_path_preview(r"\\server\share", "repo", "feature/a"),
+ r"\\server\share\repo\feature-a"
+ );
+ }
+}