aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/composition.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/composition.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/composition.rs')
-rw-r--r--src/client/shell/composition.rs539
1 files changed, 539 insertions, 0 deletions
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs
new file mode 100644
index 00000000..6432f3e1
--- /dev/null
+++ b/src/client/shell/composition.rs
@@ -0,0 +1,539 @@
+use super::*;
+
+fn restore_mode_bar(
+ frame: &mut FrameData,
+ bar: Option<Rect>,
+ cells: Option<&[crate::protocol::CellData]>,
+) {
+ let (Some(bar), Some(cells)) = (bar, cells) else {
+ return;
+ };
+ let start = usize::from(bar.y) * usize::from(frame.width) + usize::from(bar.x);
+ frame.cells[start..start + usize::from(bar.width)].clone_from_slice(cells);
+ if frame
+ .cursor
+ .as_ref()
+ .is_some_and(|cursor| cursor.y == bar.y)
+ {
+ frame.cursor = None;
+ }
+}
+
+impl ClientShellState {
+ pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> {
+ self.last_composed_size = Some((cols, rows));
+ let snapshot = self.snapshot.as_deref()?;
+ let surface = self.pane_surface.as_ref()?;
+ if snapshot.revision != surface.projection_revision {
+ return None;
+ }
+ let layout = self.layout(cols, rows);
+ if self.last_tab_bar_width != Some(layout.tab_bar.width) {
+ self.last_tab_bar_width = Some(layout.tab_bar.width);
+ self.reveal_focused_tab = true;
+ }
+ let tab_drag_insert_index = match &self.chrome_drag {
+ Some(ClientChromeDrag::Tab { insert_index, .. }) => *insert_index,
+ _ => None,
+ };
+ let (dragged_workspace_id, workspace_drop_indicator_row) = match &self.chrome_drag {
+ Some(ClientChromeDrag::Workspace {
+ source_workspace_id,
+ target,
+ }) => (
+ Some(source_workspace_id.as_str()),
+ target.as_ref().map(|(_, row)| *row),
+ ),
+ _ => (None, None),
+ };
+ let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows));
+ self.hits = render::render_shell(
+ &mut buffer,
+ layout,
+ snapshot,
+ &self.config,
+ render::ShellRenderState {
+ 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_tab: &mut self.reveal_focused_tab,
+ sidebar_collapsed: self.sidebar_collapsed,
+ sidebar_section_split: self.sidebar_section_split,
+ tab_drag_insert_index,
+ selected_workspace_id: (self.mode == ClientShellMode::Navigate)
+ .then_some(self.navigate_workspace_id.as_deref())
+ .flatten(),
+ dragged_workspace_id,
+ workspace_drop_indicator_row,
+ },
+ );
+ self.hits.panes = surface
+ .panes
+ .iter()
+ .map(|pane| PaneHit {
+ rect: Rect::new(
+ layout.pane_surface.x.saturating_add(pane.rect.x),
+ layout.pane_surface.y.saturating_add(pane.rect.y),
+ pane.rect.width,
+ pane.rect.height,
+ ),
+ inner_rect: Rect::new(
+ layout.pane_surface.x.saturating_add(pane.inner_rect.x),
+ layout.pane_surface.y.saturating_add(pane.inner_rect.y),
+ pane.inner_rect.width,
+ pane.inner_rect.height,
+ ),
+ scrollbar_rect: pane.scrollbar_rect.map(|rect| {
+ Rect::new(
+ layout.pane_surface.x.saturating_add(rect.x),
+ layout.pane_surface.y.saturating_add(rect.y),
+ rect.width,
+ rect.height,
+ )
+ }),
+ scroll: pane.scroll.map(|metrics| crate::pane::ScrollMetrics {
+ offset_from_bottom: usize::try_from(metrics.offset_from_bottom)
+ .unwrap_or(usize::MAX),
+ max_offset_from_bottom: usize::try_from(metrics.max_offset_from_bottom)
+ .unwrap_or(usize::MAX),
+ viewport_rows: usize::try_from(metrics.viewport_rows).unwrap_or(usize::MAX),
+ }),
+ pane_id: pane.pane_id.clone(),
+ popup: false,
+ mouse_reporting: pane.mouse_reporting,
+ sgr_pixel_mouse: pane.sgr_pixel_mouse,
+ pixel_width: pane.pixel_width,
+ pixel_height: pane.pixel_height,
+ })
+ .collect();
+ let topology_signature = pane_surface_topology_signature(surface);
+ self.hits.pane_splits = surface
+ .splits
+ .iter()
+ .map(|split| PaneSplitHit {
+ direction: split.direction,
+ pos: match split.direction {
+ crate::protocol::PaneSurfaceSplitDirection::Horizontal => {
+ layout.pane_surface.x.saturating_add(split.pos)
+ }
+ crate::protocol::PaneSurfaceSplitDirection::Vertical => {
+ layout.pane_surface.y.saturating_add(split.pos)
+ }
+ },
+ area: Rect::new(
+ layout.pane_surface.x.saturating_add(split.area.x),
+ layout.pane_surface.y.saturating_add(split.area.y),
+ split.area.width,
+ split.area.height,
+ ),
+ hit_rect: Rect::new(
+ layout.pane_surface.x.saturating_add(split.hit_rect.x),
+ layout.pane_surface.y.saturating_add(split.hit_rect.y),
+ split.hit_rect.width,
+ split.hit_rect.height,
+ ),
+ path: split.path.clone(),
+ topology_signature,
+ })
+ .collect();
+ if !self.config.mouse_capture {
+ self.hits.pane_splits.clear();
+ }
+ let mode_bar_area = if layout.mobile_header.is_empty()
+ && self.config.tab_bar_position == TabBarPositionConfig::Bottom
+ && !layout.tab_bar.is_empty()
+ {
+ layout.tab_bar
+ } else {
+ layout.pane_surface
+ };
+ let mobile_navigate_panel = !layout.mobile_header.is_empty()
+ && self.mode == ClientShellMode::Navigate
+ && self.endpoint_error.is_none();
+ let mode_bar = if mobile_navigate_panel || self.overlay.is_some() {
+ None
+ } else {
+ render::render_mode_bar(
+ &mut buffer,
+ mode_bar_area,
+ self.mode,
+ self.copy_mode.as_ref(),
+ self.endpoint_error.as_deref(),
+ snapshot.update_available.is_some(),
+ &self.config.keybinds,
+ &self.config.palette,
+ )
+ };
+ if mode_bar == Some(layout.tab_bar) {
+ self.hits.tabs.clear();
+ self.hits.new_tab = Rect::default();
+ self.hits.tab_scroll_left = Rect::default();
+ self.hits.tab_scroll_right = Rect::default();
+ }
+ let mut frame = FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]);
+ let mode_bar_cells = mode_bar.map(|bar| {
+ let start = usize::from(bar.y) * usize::from(frame.width) + usize::from(bar.x);
+ frame.cells[start..start + usize::from(bar.width)].to_vec()
+ });
+ blit_pane_surface(&mut frame, &surface.frame, layout.pane_surface);
+ restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref());
+ let has_selection = self
+ .selection
+ .as_ref()
+ .is_some_and(|selection| selection.is_visible());
+ let has_search = self
+ .copy_mode
+ .as_ref()
+ .is_some_and(|copy_mode| !copy_mode.search_matches.is_empty());
+ if has_selection || has_search {
+ let cursor = frame.cursor.clone();
+ let mut composed = frame.to_ratatui_buffer()?;
+ for hit in &self.hits.panes {
+ let copy_surface_coherent =
+ client_copy_surface_coherent(self.copy_mode.as_ref(), hit);
+ if copy_surface_coherent {
+ render_client_copy_search_highlights(
+ &mut composed,
+ self.copy_mode.as_ref(),
+ hit,
+ &self.config.palette,
+ false,
+ );
+ }
+ let selection_is_stale_copy_projection = !copy_surface_coherent
+ && self.copy_mode.as_ref().is_some_and(|copy_mode| {
+ copy_mode.pane_id == hit.pane_id
+ && self
+ .selection
+ .as_ref()
+ .is_some_and(|selection| selection.pane_id == hit.pane_id)
+ });
+ if !selection_is_stale_copy_projection {
+ crate::ui::render_selection_highlight(
+ self.selection.as_ref(),
+ &mut composed,
+ &hit.pane_id,
+ hit.inner_rect,
+ hit.scroll,
+ &self.config.palette,
+ crate::terminal_theme::TerminalTheme::default(),
+ );
+ }
+ if copy_surface_coherent {
+ render_client_copy_search_highlights(
+ &mut composed,
+ self.copy_mode.as_ref(),
+ hit,
+ &self.config.palette,
+ true,
+ );
+ }
+ }
+ frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor);
+ }
+ if self.mode == ClientShellMode::Copy {
+ frame.cursor = None;
+ if let Some(copy_mode) = self.copy_mode.as_ref() {
+ if let Some(hit) = self.hits.panes.iter().find(|hit| {
+ hit.pane_id == copy_mode.pane_id
+ && client_copy_surface_coherent(Some(copy_mode), hit)
+ }) {
+ let viewport_top = copy_mode
+ .max_offset_from_bottom
+ .saturating_sub(copy_mode.offset_from_bottom)
+ .min(u32::MAX as usize) as u32;
+ let viewport_row = copy_mode.cursor.row.saturating_sub(viewport_top);
+ if viewport_row < u32::from(hit.inner_rect.height)
+ && copy_mode.cursor.col < hit.inner_rect.width
+ {
+ let mut composed = frame.to_ratatui_buffer()?;
+ let x = hit.inner_rect.x + copy_mode.cursor.col;
+ let y = hit.inner_rect.y + viewport_row as u16;
+ composed[(x, y)].set_style(
+ Style::default()
+ .fg(match self.config.palette.panel_bg {
+ ratatui::style::Color::Reset => self.config.palette.surface_dim,
+ color => color,
+ })
+ .bg(self.config.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ frame.replace_from_ratatui_buffer_preserving_effects(&composed, None);
+ } else {
+ frame.cursor = None;
+ }
+ }
+ }
+ }
+ 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();
+ if has_config_diagnostic || self.visible_notification.is_some() {
+ let cursor = frame.cursor.clone();
+ let mut composed = frame.to_ratatui_buffer()?;
+ if let Some(diagnostic) = self.config_diagnostic.as_deref() {
+ let diagnostic_area = if layout.mobile_header.is_empty() {
+ Rect::new(0, 0, cols, rows)
+ } else {
+ layout.pane_surface
+ };
+ crate::ui::render_config_diagnostic_buffer(
+ &mut composed,
+ diagnostic_area,
+ diagnostic,
+ &self.config.palette,
+ );
+ }
+ if let Some(notification) = self.visible_notification.as_ref() {
+ self.hits.notification_toast = if layout.mobile_header.is_empty() {
+ notifications::render_visible_notification(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ notification,
+ self.config.toast_position,
+ u16::from(has_config_diagnostic),
+ &self.config.palette,
+ )
+ } else {
+ notifications::render_mobile_notification_banner(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ notification,
+ has_config_diagnostic,
+ &self.config.palette,
+ )
+ };
+ }
+ frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor);
+ }
+ if let Some(feedback) = self.copy_feedback.as_ref() {
+ let cursor = frame.cursor.clone();
+ let mut composed = frame.to_ratatui_buffer()?;
+ let base_offset = u16::from(has_config_diagnostic);
+ let feedback_area = if layout.mobile_header.is_empty() {
+ layout.pane_surface
+ } else {
+ Rect::new(0, 0, cols, rows)
+ };
+ let offset = crate::ui::copy_feedback_offset_for_toast(
+ feedback_area,
+ feedback,
+ base_offset,
+ self.config.clipboard_toast_position,
+ self.hits.notification_toast,
+ );
+ crate::ui::render_copy_feedback_buffer(
+ &mut composed,
+ feedback_area,
+ feedback,
+ offset,
+ self.config.clipboard_toast_position,
+ &self.config.palette,
+ );
+ frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor);
+ }
+ self.hits.popup = None;
+ if let Some(popup) = surface.popup.as_deref() {
+ let width = popup.width.map(client_popup_size);
+ let height = popup.height.map(client_popup_size);
+ if let Some(geometry) =
+ crate::popup_size::resolve_popup_geometry(width, height, layout.pane_surface)
+ {
+ let mut composed = frame.to_ratatui_buffer()?;
+ let block = ratatui::widgets::Block::default()
+ .borders(ratatui::widgets::Borders::ALL)
+ .border_style(ratatui::style::Style::default().fg(self.config.palette.accent))
+ .title(popup.title.clone())
+ .style(ratatui::style::Style::default().bg(self.config.palette.panel_bg));
+ ratatui::widgets::Widget::render(
+ ratatui::widgets::Clear,
+ geometry.outer,
+ &mut composed,
+ );
+ ratatui::widgets::Widget::render(block, geometry.outer, &mut composed);
+ frame.replace_from_ratatui_buffer_preserving_effects(&composed, None);
+ blit_pane_surface(&mut frame, &popup.frame, geometry.inner);
+ self.hits.popup = Some(PaneHit {
+ rect: geometry.outer,
+ inner_rect: geometry.inner,
+ scrollbar_rect: None,
+ scroll: None,
+ pane_id: popup.terminal_id.clone(),
+ popup: true,
+ mouse_reporting: popup.mouse_reporting,
+ sgr_pixel_mouse: popup.sgr_pixel_mouse,
+ pixel_width: popup.pixel_width,
+ pixel_height: popup.pixel_height,
+ });
+ }
+ }
+ if !layout.mobile_header.is_empty()
+ && self.mode == ClientShellMode::Navigate
+ && self.overlay.is_none()
+ {
+ let mut composed = frame.to_ratatui_buffer()?;
+ super::mobile::render_mobile_switcher(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ snapshot,
+ &self.config,
+ self.navigate_workspace_id.as_deref(),
+ &mut self.mobile_switcher_scroll,
+ &mut self.reveal_mobile_workspace,
+ &mut self.hits,
+ );
+ frame.replace_from_ratatui_buffer_preserving_effects(&composed, None);
+ self.hits.panes.clear();
+ self.hits.pane_splits.clear();
+ self.hits.popup = None;
+ }
+ restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref());
+ if let Some(overlay) = self.overlay.as_ref() {
+ let mut composed = frame.to_ratatui_buffer()?;
+ let cursor = if let ClientShellOverlay::ContextMenu(menu) = overlay {
+ self.hits.context_menu_rows =
+ render::render_context_menu(&mut composed, menu, &self.config.palette)?;
+ None
+ } else if let ClientShellOverlay::GlobalMenu(menu) = overlay {
+ self.hits.global_menu_rows = render::render_global_menu(
+ &mut composed,
+ self.hits.global_launcher,
+ menu,
+ snapshot,
+ &self.config.palette,
+ )?;
+ None
+ } else {
+ let rendered = render::render_client_overlay(
+ &mut composed,
+ overlay,
+ snapshot,
+ &self.config.keybinds,
+ &self.config.palette,
+ )?;
+ self.hits.overlay_primary = rendered.primary;
+ self.hits.overlay_clear = rendered.clear;
+ self.hits.overlay_cancel = rendered.cancel;
+ self.hits.navigator_popup = rendered.navigator_popup;
+ self.hits.navigator_search = rendered.navigator_search;
+ self.hits.navigator_rows = rendered.navigator_rows;
+ self.hits.worktree_search = rendered.worktree_search;
+ self.hits.worktree_rows = rendered.worktree_rows;
+ self.hits.help_popup = rendered.help_popup;
+ self.hits.help_scrollbar = rendered.help_scrollbar;
+ self.hits.help_scroll_metrics = rendered.help_scroll_metrics;
+ self.hits.help_max_scroll = rendered.help_max_scroll;
+ self.hits.settings_popup = rendered.settings_popup;
+ self.hits.settings_tabs = rendered.settings_tabs;
+ self.hits.settings_choices = rendered.settings_choices;
+ self.hits.product_announcement_scrollbar = rendered.product_announcement_scrollbar;
+ self.hits.product_announcement_scroll_metrics =
+ rendered.product_announcement_scroll_metrics;
+ self.hits.product_announcement_max_scroll =
+ rendered.product_announcement_max_scroll;
+ self.hits.release_notes_scrollbar = rendered.release_notes_scrollbar;
+ self.hits.release_notes_scroll_metrics = rendered.release_notes_scroll_metrics;
+ self.hits.release_notes_max_scroll = rendered.release_notes_max_scroll;
+ rendered.cursor
+ };
+ frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor);
+ }
+ if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() {
+ help.scroll = help.scroll.min(self.hits.help_max_scroll);
+ }
+ if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() {
+ announcement.scroll = announcement
+ .scroll
+ .min(u16::try_from(self.hits.product_announcement_max_scroll).unwrap_or(u16::MAX));
+ }
+ if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() {
+ notes.scroll = notes
+ .scroll
+ .min(u16::try_from(self.hits.release_notes_max_scroll).unwrap_or(u16::MAX));
+ }
+ self.compose_graphics(&mut frame, layout);
+ Some(frame)
+ }
+}
+
+fn client_copy_surface_coherent(copy_mode: Option<&ClientCopyModeState>, hit: &PaneHit) -> bool {
+ copy_mode
+ .filter(|copy_mode| copy_mode.pane_id == hit.pane_id)
+ .is_none_or(|copy_mode| {
+ copy_mode.geometry == (hit.inner_rect.width, hit.inner_rect.height)
+ && hit.scroll.is_some_and(|scroll| {
+ scroll.offset_from_bottom == copy_mode.offset_from_bottom
+ && scroll.max_offset_from_bottom == copy_mode.max_offset_from_bottom
+ })
+ })
+}
+
+fn render_client_copy_search_highlights(
+ buffer: &mut Buffer,
+ copy_mode: Option<&ClientCopyModeState>,
+ hit: &PaneHit,
+ palette: &Palette,
+ current_only: bool,
+) {
+ let Some(copy_mode) = copy_mode.filter(|copy_mode| copy_mode.pane_id == hit.pane_id) else {
+ return;
+ };
+ if hit.inner_rect.is_empty() {
+ return;
+ }
+ let top = copy_mode
+ .max_offset_from_bottom
+ .saturating_sub(copy_mode.offset_from_bottom)
+ .min(u32::MAX as usize) as u32;
+ let bottom = top.saturating_add(u32::from(hit.inner_rect.height.saturating_sub(1)));
+ let style = if current_only {
+ Style::default()
+ .fg(panel_contrast_fg(palette))
+ .bg(palette.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(palette.text).bg(palette.surface1)
+ };
+ for (index, text_match) in copy_mode.search_matches.iter().enumerate() {
+ if (copy_mode.search_current == Some(index)) != current_only
+ || text_match.end.row < top
+ || text_match.start.row > bottom
+ {
+ continue;
+ }
+ let start_row = text_match.start.row.max(top);
+ let end_row = text_match.end.row.min(bottom);
+ for absolute_row in start_row..=end_row {
+ let viewport_row = absolute_row.saturating_sub(top) as u16;
+ let start_col = if absolute_row == text_match.start.row {
+ text_match.start.col
+ } else {
+ 0
+ };
+ let end_col = if absolute_row == text_match.end.row {
+ text_match.end.col
+ } else {
+ hit.inner_rect.width.saturating_sub(1)
+ };
+ for col in start_col..=end_col.min(hit.inner_rect.width.saturating_sub(1)) {
+ buffer[(
+ hit.inner_rect.x.saturating_add(col),
+ hit.inner_rect.y.saturating_add(viewport_row),
+ )]
+ .set_style(style);
+ }
+ }
+ }
+}
+
+fn client_popup_size(size: crate::protocol::ClientShellPopupSize) -> crate::popup_size::PopupSize {
+ match size {
+ crate::protocol::ClientShellPopupSize::Cells(cells) => {
+ crate::popup_size::PopupSize::Cells(cells)
+ }
+ crate::protocol::ClientShellPopupSize::Percent(percent) => {
+ crate::popup_size::PopupSize::Percent(percent)
+ }
+ }
+}