diff options
Diffstat (limited to 'src/client/shell')
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, + ¬ice.title, + Some(¬ice.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, + ¬ice.title, + ¬ice.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(¬ification.endpoint_id) + { + let label = self.endpoint_label(¬ification.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(¬ification.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, - ¬ice.title, - Some(¬ice.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, - ¬ice.title, - ¬ice.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| (¬ification.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(¤t).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", ¤t_id)); + assert!(state.pending_requests.contains_key(¤t_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" + ); + } +} |
