From 0b24a21779410116bdd417e77d72fb6aeba162cf Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Tue, 15 Sep 2026 02:27:20 +0200 Subject: [PATCH] perf(ui): patch only visible spinner cells --- src/client/shell/agent_sidebar.rs | 25 +++++- src/client/shell/composition.rs | 69 ++++------------ src/client/shell/endpoint_agents.rs | 53 +++++++----- src/client/shell/endpoint_sidebar.rs | 16 ++++ src/client/shell/render.rs | 81 +++++-------------- src/client/shell/sidebar.rs | 44 +++++++++- src/client/shell/state.rs | 10 ++- .../tests/agents_worktrees_notifications.rs | 2 + src/client/shell/tests/endpoints.rs | 30 +++++++ src/ui/sidebar.rs | 20 ++++- src/ui/sidebar/tokens.rs | 1 + 11 files changed, 208 insertions(+), 143 deletions(-) diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs index c480efe3..2013e813 100644 --- a/src/client/shell/agent_sidebar.rs +++ b/src/client/shell/agent_sidebar.rs @@ -83,7 +83,7 @@ pub(super) fn render_agent_panel( |row| row.rows.len(), |buffer, rect, row, hits| { hits.agents.push((rect, row.pane_id.clone())); - render_agent_row(buffer, rect, row, config, spinner_frame); + render_agent_row(buffer, rect, row, config, spinner_frame, hits); }, ); } @@ -325,6 +325,7 @@ pub(super) fn render_agent_row( row: &AgentRow, config: &ClientShellConfig, spinner_frame: Option, + hits: &mut ShellHitMap, ) { let palette = &config.palette; let row_style = if row.focused { @@ -355,9 +356,11 @@ pub(super) fn render_agent_row( } else { row.rows.clone() }; + let mut spinner_positions = Vec::new(); for (index, tokens) in rows.iter().take(rect.height as usize).enumerate() { let indent = if index == 0 { 1 } else { 3 }; let mut spans = vec![ratatui::text::Span::raw(" ".repeat(indent))]; + let mut state_icon_offsets = Vec::new(); spans.extend(crate::ui::resolved_token_spans( tokens, icon, @@ -367,12 +370,32 @@ pub(super) fn render_agent_row( secondary, palette, rect.width.saturating_sub(indent as u16) as usize, + Some(&mut state_icon_offsets), )); + spinner_positions.extend( + state_icon_offsets + .into_iter() + .filter_map(|offset| u16::try_from(offset).ok()) + .map(|offset| { + ( + rect.x.saturating_add(indent as u16).saturating_add(offset), + rect.y + index as u16, + ) + }), + ); Paragraph::new(Line::from(spans)).style(row_style).render( Rect::new(rect.x, rect.y + index as u16, rect.width, 1), buffer, ); } + super::render::record_animated_status_cells( + buffer, + hits, + row.status, + config.status_indicators, + spinner_frame, + spinner_positions, + ); } fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index b7332b4f..8005dd87 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -42,61 +42,26 @@ impl ClientShellState { } let snapshot = self.snapshot.as_deref()?; let surface = self.pane_surface.as_ref()?; - if snapshot.revision != surface.projection_revision { + if snapshot.revision != surface.projection_revision + || self.hits.animated_status_cells.is_empty() + { return None; } - let sidebar = self.layout(cols, rows).sidebar; - if sidebar.is_empty() { - return None; - } - - let mut buffer = Buffer::empty(sidebar); - let mut hits = ShellHitMap::default(); - let mut render_state = render::ShellRenderState { - endpoints: &self.endpoints, - active_endpoint_id: &self.active_endpoint_id, - collapsed_endpoints: &self.collapsed_endpoints, - collapsed_groups: &self.collapsed_groups, - remote_collapsed_groups: &self.remote_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: self.sidebar_collapsed, - sidebar_section_split: self.sidebar_section_split, - tab_drag_insert_index: None, - selected_workspace_id: None, - reveal_navigation_workspace: &mut self.reveal_navigation_workspace, - dragged_workspace_id: None, - workspace_drop_indicator_row: None, - spinner_frame: Some(self.spinner_frame), - }; - render::render_shell_sidebar( - &mut buffer, - sidebar, - snapshot, - &self.config, - &mut render_state, - &mut hits, - ); - - let rows = (sidebar.y..sidebar.bottom()) - .map(|y| { - let cells = (sidebar.x..sidebar.right()) - .map(|x| { - buffer - .cell((x, y)) - .map(crate::protocol::CellData::from_ratatui_cell) - }) - .collect::>>()?; - Some(crate::protocol::PaneSurfacePatchRow { - x: sidebar.x, - y, - cells, - }) + let symbol = STATUS_SPINNER_FRAMES.get(self.spinner_frame)?; + let rows = self + .hits + .animated_status_cells + .iter() + .map(|spinner| { + let mut cell = spinner.cell.clone(); + cell.symbol = (*symbol).to_owned(); + crate::protocol::PaneSurfacePatchRow { + x: spinner.x, + y: spinner.y, + cells: vec![cell], + } }) - .collect::>>()?; + .collect(); Some(ClientComposedSurfacePatch { rows, cursor: None, diff --git a/src/client/shell/endpoint_agents.rs b/src/client/shell/endpoint_agents.rs index 7dab7988..9c0de482 100644 --- a/src/client/shell/endpoint_agents.rs +++ b/src/client/shell/endpoint_agents.rs @@ -16,31 +16,41 @@ pub(super) fn render_collapsed( 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('?'); + let initial = row.machine_label.chars().next().unwrap_or('?').to_string(); + let initial_width = super::render::display_width(&initial) + .max(1) + .min(rect.width); + let style = 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() + }); + put_text(buffer, rect.x, rect.y, initial_width, &initial, style); put_text( buffer, - rect.x, + rect.x.saturating_add(initial_width), rect.y, - rect.width, - &format!( - "{initial}{}", - status_icon( - row.agent.status, - config.status_indicators, - spinner_frame.filter(|_| !row.stale), - ) + rect.width.saturating_sub(initial_width), + status_icon( + row.agent.status, + config.status_indicators, + spinner_frame.filter(|_| !row.stale), ), - 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() - }), + style, + ); + super::render::record_animated_status_cells( + buffer, + hits, + row.agent.status, + config.status_indicators, + spinner_frame.filter(|_| !row.stale), + (rect.width > initial_width).then_some((rect.x.saturating_add(initial_width), rect.y)), ); hits.endpoint_agents .push((rect, row.endpoint_id, row.agent.pane_id)); @@ -84,6 +94,7 @@ pub(super) fn render_expanded( &row.agent, config, spinner_frame.filter(|_| !row.stale), + hits, ); if row.stale { buffer.set_style( diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs index e2c5bac9..a5080985 100644 --- a/src/client/shell/endpoint_sidebar.rs +++ b/src/client/shell/endpoint_sidebar.rs @@ -170,6 +170,15 @@ pub(super) fn render_collapsed( }) .add_modifier(dim), ); + super::render::record_animated_status_cells( + buffer, + hits, + workspace.agent_status, + config.status_indicators, + state.spinner_frame.filter(|_| !stale), + (rect.width > number_width) + .then_some((rect.x.saturating_add(number_width), rect.y)), + ); hits.workspaces.push(WorkspaceHit { rect, endpoint_id: endpoint.endpoint_id.clone(), @@ -441,6 +450,7 @@ pub(super) fn render_expanded( let selected = state.selected_workspace_id.is_some_and(|target| { target.matches(&endpoint.endpoint_id, &workspace.workspace_id) }); + let spinner_start = hits.animated_status_cells.len(); super::sidebar::render_workspace_rows( buffer, nested, @@ -451,6 +461,7 @@ pub(super) fn render_expanded( state .spinner_frame .filter(|_| endpoint.status == ClientEndpointStatus::Online), + hits, ), entry, tokens, @@ -461,6 +472,11 @@ pub(super) fn render_expanded( ); if selected && palette.selection_bg == ratatui::style::Color::Reset { buffer.set_style(nested, Style::default().bg(palette.active_row_bg)); + for spinner in &mut hits.animated_status_cells[spinner_start..] { + if let Some(cell) = buffer.cell((spinner.x, spinner.y)) { + spinner.cell = crate::protocol::CellData::from_ratatui_cell(cell); + } + } } if endpoint.status != ClientEndpointStatus::Online { buffer.set_style( diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index 1b6e1502..17e6f57b 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -288,66 +288,28 @@ pub(super) fn render_shell_sidebar( } } -fn animated_status_visible( - snapshot: &ClientShellSnapshot, - state: &ShellRenderState<'_>, - hits: &ShellHitMap, -) -> bool { - let working = crate::api::schema::AgentStatus::Working; - let empty_collapsed_groups = HashSet::new(); - let endpoint_snapshot = |endpoint_id: &ClientEndpointId| { - state - .endpoints - .iter() - .find(|endpoint| { - &endpoint.endpoint_id == endpoint_id - && endpoint.status == ClientEndpointStatus::Online +pub(super) fn record_animated_status_cells( + buffer: &Buffer, + hits: &mut ShellHitMap, + status: crate::api::schema::AgentStatus, + indicators: crate::config::StatusIndicatorStyle, + spinner_frame: Option, + positions: impl IntoIterator, +) { + if status != crate::api::schema::AgentStatus::Working + || indicators != crate::config::StatusIndicatorStyle::Animated + || spinner_frame.is_none() + { + return; + } + hits.animated_status_cells + .extend(positions.into_iter().filter_map(|(x, y)| { + buffer.cell((x, y)).map(|cell| ClientAnimatedStatusCell { + x, + y, + cell: crate::protocol::CellData::from_ratatui_cell(cell), }) - .and_then(|endpoint| endpoint.snapshot.as_deref()) - }; - hits.workspaces.iter().any(|hit| { - endpoint_snapshot(&hit.endpoint_id).is_some_and(|snapshot| { - let collapsed_groups = if hit.endpoint_id.is_local() { - state.collapsed_groups - } else { - state - .remote_collapsed_groups - .get(&hit.endpoint_id) - .unwrap_or(&empty_collapsed_groups) - }; - snapshot - .workspaces - .iter() - .find(|workspace| workspace.workspace_id == hit.workspace_id) - .is_some_and(|workspace| { - let status = if state.sidebar_collapsed { - workspace.agent_status - } else { - super::sidebar::displayed_workspace_status( - snapshot, - workspace, - collapsed_groups, - ) - }; - status == working - }) - }) - }) || hits.agents.iter().any(|(_, pane_id)| { - snapshot - .agents - .iter() - .any(|agent| agent.pane_id == *pane_id && agent.agent_status == working) - }) || hits - .endpoint_agents - .iter() - .any(|(_, endpoint_id, pane_id)| { - endpoint_snapshot(endpoint_id).is_some_and(|snapshot| { - snapshot - .agents - .iter() - .any(|agent| agent.pane_id == *pane_id && agent.agent_status == working) - }) - }) + })); } pub(super) fn render_shell( @@ -389,7 +351,6 @@ pub(super) fn render_shell( &mut hits, ); } - hits.animated_status_visible = animated_status_visible(snapshot, &state, &hits); if !config.mouse_capture { hits.sidebar_divider = Rect::default(); hits.sidebar_section_divider = Rect::default(); diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index c95869f1..1d4f5257 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -86,6 +86,14 @@ pub(crate) fn render_collapsed_sidebar( status_icon(status, config.status_indicators, spinner_frame), Style::default().fg(status_color(status, palette)), ); + super::render::record_animated_status_cells( + buffer, + hits, + status, + config.status_indicators, + spinner_frame, + (rect.width > 2).then_some((rect.x + 2, rect.y)), + ); hits.workspaces.push(WorkspaceHit { rect, endpoint_id: ClientEndpointId::Local, @@ -153,6 +161,14 @@ pub(crate) fn render_collapsed_sidebar( status_icon(agent.agent_status, config.status_indicators, spinner_frame), Style::default().fg(status_color(agent.agent_status, palette)), ); + super::render::record_animated_status_cells( + buffer, + hits, + agent.agent_status, + config.status_indicators, + spinner_frame, + (rect.width > 2).then_some((rect.x + 2, rect.y)), + ); hits.agents.push((rect, pane_id)); } hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 { @@ -311,7 +327,7 @@ pub(crate) fn render_sidebar( rect, workspace, status, - (config.status_indicators, state.spinner_frame), + (config.status_indicators, state.spinner_frame, hits), entry, rows, true, @@ -643,7 +659,11 @@ pub(in crate::client::shell) fn render_workspace_rows( area: Rect, workspace: &ClientShellWorkspace, status: crate::api::schema::AgentStatus, - indicators: (crate::config::StatusIndicatorStyle, Option), + indicators: ( + crate::config::StatusIndicatorStyle, + Option, + &mut ShellHitMap, + ), entry: &WorkspaceEntry, rows: Vec>, endpoint_active: bool, @@ -651,6 +671,8 @@ pub(in crate::client::shell) fn render_workspace_rows( dragged: bool, palette: &Palette, ) { + let (indicator_style, spinner_frame, hits) = indicators; + let mut spinner_positions = Vec::new(); for (row_index, row) in rows.iter().enumerate() { let y = area.y + row_index as u16; if y >= area.bottom() { @@ -699,10 +721,11 @@ pub(in crate::client::shell) fn render_workspace_rows( } else { palette.overlay0 }); + let mut state_icon_offsets = Vec::new(); let spans = crate::ui::resolved_token_spans( row, ( - status_icon(status, indicators.0, indicators.1), + status_icon(status, indicator_style, spinner_frame), Style::default().fg(status_color(status, palette)), ), Style::default().fg(status_color(status, palette)), @@ -711,6 +734,13 @@ pub(in crate::client::shell) fn render_workspace_rows( Style::default().fg(palette.overlay1), palette, area.right().saturating_sub(2).saturating_sub(x) as usize, + Some(&mut state_icon_offsets), + ); + spinner_positions.extend( + state_icon_offsets + .into_iter() + .filter_map(|offset| u16::try_from(offset).ok()) + .map(|offset| (x.saturating_add(offset), y)), ); Paragraph::new(Line::from(spans)).render( Rect::new(x, y, area.right().saturating_sub(2).saturating_sub(x), 1), @@ -734,4 +764,12 @@ pub(in crate::client::shell) fn render_workspace_rows( } } } + super::render::record_animated_status_cells( + buffer, + hits, + status, + indicator_style, + spinner_frame, + spinner_positions, + ); } diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 38de43ad..046785a7 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -152,7 +152,7 @@ pub(super) struct ShellHitMap { pub(super) pane_splits: Vec, pub(super) agents: Vec<(Rect, String)>, pub(super) endpoint_agents: Vec<(Rect, ClientEndpointId, String)>, - pub(super) animated_status_visible: bool, + pub(super) animated_status_cells: Vec, pub(super) agent_body: Rect, pub(super) agent_scrollbar: Rect, pub(super) agent_scroll_metrics: Option, @@ -196,6 +196,12 @@ pub(super) struct ShellHitMap { pub(super) release_notes_max_scroll: usize, } +pub(super) struct ClientAnimatedStatusCell { + pub(super) x: u16, + pub(super) y: u16, + pub(super) cell: crate::protocol::CellData, +} + #[derive(Clone)] pub(super) struct PaneHit { pub(super) rect: Rect, @@ -1919,7 +1925,7 @@ impl ClientShellState { && self .last_composed_size .is_some_and(|(cols, rows)| self.layout(cols, rows).sidebar.width > 0) - && self.hits.animated_status_visible + && !self.hits.animated_status_cells.is_empty() } fn sync_spinner_deadline(&mut self, now: std::time::Instant) { diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index 8f3b0043..645b8f82 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -638,6 +638,7 @@ fn animated_status_advances_only_on_its_desktop_deadline() { projected.workspaces[0].agent_status = AgentStatus::Working; let mut config = Config::default(); config.ui.status_indicators = Animated; + config.ui.sidebar.agents.rows = vec![vec![crate::config::AgentSidebarToken::StateIcon; 16]]; let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); state.set_snapshot(Box::new(projected.clone())); state.set_pane_surface(surface()); @@ -663,6 +664,7 @@ fn animated_status_advances_only_on_its_desktop_deadline() { let patch = state .compose_spinner_patch(106, 30) .expect("animated sidebar patch"); + assert!(patch.rows.iter().all(|row| row.cells.len() == 1)); let patched = apply_composed_surface_patch(&first, patch).expect("applicable sidebar patch"); assert_eq!((state.workspace_scroll, state.agent_scroll), scroll_state); let second = full_state.compose(106, 30).expect("advanced full frame"); diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index b43b9a31..001d3049 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -109,6 +109,36 @@ fn state_with_scrollable_agents() -> (ClientShellState, ClientEndpointId) { (state, remote) } +#[test] +fn collapsed_wide_machine_initial_records_the_spinner_cell() { + let (mut state, remote) = state_with_remote(); + state.config.status_indicators = crate::config::StatusIndicatorStyle::Animated; + state.sidebar_collapsed = true; + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == remote) + .expect("remote endpoint"); + endpoint.label = "東京".into(); + let snapshot = endpoint.snapshot.as_mut().expect("remote snapshot"); + snapshot.workspaces[0].agent_status = AgentStatus::Working; + snapshot.agents = vec![agent("worker", AgentStatus::Working, 1)]; + + state.compose(100, 28).expect("collapsed endpoint sidebar"); + let rect = state + .hits + .endpoint_agents + .iter() + .find(|(_, endpoint_id, _)| endpoint_id == &remote) + .expect("remote agent row") + .0; + assert!(state + .hits + .animated_status_cells + .iter() + .any(|spinner| spinner.x == rect.x + 2 && spinner.y == rect.y)); +} + #[test] fn switching_machines_preserves_aggregate_agent_scroll_and_visible_rows() { let (mut state, remote) = state_with_scrollable_agents(); diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index cbf6f1a9..1819d01a 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -112,6 +112,7 @@ pub(crate) fn resolved_token_spans( custom_style: Style, palette: &Palette, max_width: usize, + mut state_icon_offsets: Option<&mut Vec>, ) -> Vec> { let fixed_widths = resolved .iter() @@ -223,10 +224,21 @@ pub(crate) fn resolved_token_spans( )); } match &token.kind { - ResolvedTokenKind::StateIcon => spans.push(Span::styled( - state_icon.0.to_string(), - apply_token_style(state_icon.1, token.style), - )), + ResolvedTokenKind::StateIcon => { + if let Some(offsets) = state_icon_offsets.as_deref_mut() { + let offset = spans + .iter() + .map(|span| display_width(span.content.as_ref())) + .sum::(); + if offset.saturating_add(fixed_widths[index]) <= max_width { + offsets.push(offset); + } + } + spans.push(Span::styled( + state_icon.0.to_string(), + apply_token_style(state_icon.1, token.style), + )); + } ResolvedTokenKind::StateText(text) => spans.push(Span::styled( truncate_end(text, budgets[index]), apply_token_style(state_text_style, token.style), diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index c1e38aa9..a244ce98 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -283,6 +283,7 @@ rows = [[{ token = "workspace", rules = [{ equals = "long-workspace-name", fg = theme, &super::super::Palette::catppuccin(), width, + None, ); assert_eq!(spans.len(), 1); assert!(super::super::display_width(&spans[0].content) <= width);