use super::*; #[path = "../shell/overlays.rs"] mod overlays; #[path = "../shell/sidebar.rs"] 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 sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries}; pub(super) use tabs::{render_tab_bar, tab_bar_status_width}; pub(super) fn render_mode_bar( buffer: &mut Buffer, pane_area: Rect, mode: ClientShellMode, copy_mode: Option<&ClientCopyModeState>, endpoint_error: Option<&str>, update_available: bool, keybinds: &LiveKeybindConfig, palette: &Palette, ) -> Option { if (mode == ClientShellMode::Terminal && endpoint_error.is_none()) || pane_area.is_empty() { return None; } let bar = Rect::new( pane_area.x, pane_area.y + pane_area.height.saturating_sub(1), pane_area.width, 1, ); let base = Style::default().fg(palette.overlay0).bg(palette.panel_bg); for x in bar.x..bar.x + bar.width { buffer[(x, bar.y)].set_symbol(" ").set_style(base); } let key = Style::default() .fg(palette.accent) .bg(palette.panel_bg) .add_modifier(Modifier::BOLD); let mode_style = Style::default() .fg(match palette.panel_bg { ratatui::style::Color::Reset => palette.surface_dim, color => color, }) .bg(if mode == ClientShellMode::Resize { palette.mauve } else { palette.accent }) .add_modifier(Modifier::BOLD); let prefix = crate::config::format_key_combo(keybinds.prefix); let prefix_rhs = |bindings: &crate::config::ActionKeybinds| { bindings .prefix_rhs_label() .unwrap_or_else(|| "unset".to_owned()) }; let mut segments = Vec::<(String, Style)>::new(); if let Some(error) = endpoint_error { segments.extend([ (" ERROR ".to_owned(), mode_style), (format!(" {error}"), base), ]); } else { match mode { ClientShellMode::Prefix => { segments.extend([ (" PREFIX ".to_owned(), mode_style), (" ".to_owned(), base), ("esc".to_owned(), key), (" cancel ".to_owned(), base), (prefix, key), (" send prefix ".to_owned(), base), (prefix_rhs(&keybinds.keybinds.workspace_picker), key), (" workspace nav ".to_owned(), base), (prefix_rhs(&keybinds.keybinds.help), key), (" keybinds".to_owned(), base), ]); } ClientShellMode::Navigate => { segments.extend([ (" NAVIGATE ".to_owned(), mode_style), (" esc back ".to_owned(), base), ("↑/↓".to_owned(), key), (" workspace ".to_owned(), base), ("tab".to_owned(), key), (" pane ".to_owned(), base), (prefix_rhs(&keybinds.keybinds.help), key), (" keybinds".to_owned(), base), ]); } ClientShellMode::Resize => { segments.extend([ (" RESIZE ".to_owned(), mode_style), (" ".to_owned(), base), ("h/l".to_owned(), key), (" width ".to_owned(), base), ("j/k".to_owned(), key), (" height ".to_owned(), base), ("esc".to_owned(), key), (" done".to_owned(), base), ]); } ClientShellMode::Copy => { let copy_mode = copy_mode?; if let Some(prompt) = copy_mode.search_prompt.as_ref() { let marker = match prompt.direction { crate::api::schema::PaneCopySearchDirection::Forward => "/", crate::api::schema::PaneCopySearchDirection::Backward => "?", }; segments.extend([ (" COPY ".to_owned(), mode_style), (" ".to_owned(), base), (marker.to_owned(), key), ( prompt.query.clone(), Style::default().fg(palette.text).bg(palette.panel_bg), ), ("█".to_owned(), key), (" enter search esc cancel".to_owned(), base), ]); } else { let select = if copy_mode.selection.is_some() { "selecting" } else { "select" }; let match_status = copy_mode .search_current_global .map(|current| format!(" {}/{}", current + 1, copy_mode.search_total)) .or_else(|| (!copy_mode.search_query.is_empty()).then(|| " 0/0".to_owned())) .unwrap_or_default(); let (exit_keys, exit_label) = if copy_mode.search_query.is_empty() && copy_mode.selection.is_none() { ("q/esc", " exit") } else { ("esc", " clear q exit") }; segments.extend([ (" COPY ".to_owned(), mode_style), (" ".to_owned(), base), ("h/j/k/l w/b/e { }".to_owned(), key), (" move ".to_owned(), base), ("/ ?".to_owned(), key), (" search ".to_owned(), base), ("n/N".to_owned(), key), (format!(" repeat{match_status} "), base), ("v/space".to_owned(), key), (format!(" {select} "), base), ("y/enter".to_owned(), key), (" copy ".to_owned(), base), (exit_keys.to_owned(), key), (exit_label.to_owned(), base), ]); } } ClientShellMode::Terminal => unreachable!(), } } let mut x = bar.x; let end = bar.x + bar.width; for (text, style) in segments { if x >= end { break; } let remaining = end - x; buffer.set_stringn(x, bar.y, &text, usize::from(remaining), style); x = x.saturating_add( u16::try_from(UnicodeWidthStr::width(text.as_str())) .unwrap_or(u16::MAX) .min(remaining), ); } if update_available && mode == ClientShellMode::Navigate { let width = 13.min(bar.width); let area = Rect::new(bar.right().saturating_sub(width), bar.y, width, 1); buffer.set_style(area, Style::default().bg(palette.panel_bg)); put_right_text( buffer, area, area.y, " update ready", Style::default() .fg(palette.accent) .bg(palette.panel_bg) .add_modifier(Modifier::BOLD), ); } Some(bar) } pub(super) struct ShellRenderState<'a> { pub(super) collapsed_groups: &'a HashSet, pub(super) workspace_scroll: &'a mut usize, pub(super) agent_scroll: &'a mut usize, pub(super) tab_scroll: &'a mut usize, pub(super) reveal_focused_workspace: &'a mut bool, pub(super) reveal_focused_tab: &'a mut bool, pub(super) sidebar_collapsed: bool, pub(super) sidebar_section_split: f32, pub(super) tab_drag_insert_index: Option, pub(super) selected_workspace_id: Option<&'a str>, pub(super) dragged_workspace_id: Option<&'a str>, pub(super) workspace_drop_indicator_row: Option, } pub(super) fn render_shell( buffer: &mut Buffer, layout: ClientShellLayout, snapshot: &ClientShellSnapshot, config: &ClientShellConfig, mut state: ShellRenderState<'_>, ) -> ShellHitMap { let mut hits = ShellHitMap::default(); if layout.mobile_header.height > 0 { super::mobile::render_mobile_header( buffer, layout.mobile_header, snapshot, config, &mut hits, ); } if layout.sidebar.width > 0 { if state.sidebar_collapsed { render_collapsed_sidebar( buffer, layout.sidebar, snapshot, config, state.selected_workspace_id, &mut hits, ); } else { render_sidebar( buffer, layout.sidebar, snapshot, config, &mut state, &mut hits, ); } } if layout.tab_bar.height > 0 { render_tab_bar( buffer, layout.tab_bar, snapshot, config, state.tab_scroll, state.reveal_focused_tab, state.tab_drag_insert_index, &mut hits, ); } if !config.mouse_capture { hits.sidebar_divider = Rect::default(); hits.sidebar_section_divider = Rect::default(); hits.workspace_scrollbar = Rect::default(); hits.agent_scrollbar = Rect::default(); hits.agent_sort_toggle = Rect::default(); hits.new_workspace = Rect::default(); hits.workspaces.clear(); hits.agents.clear(); hits.tab_scroll_left = Rect::default(); hits.tab_scroll_right = Rect::default(); hits.new_tab = Rect::default(); hits.pane_splits.clear(); } hits } 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, area.right().saturating_sub(width), y, width, text, style, ); } pub(super) fn put_segment( buffer: &mut Buffer, x: u16, y: u16, right: u16, text: &str, style: Style, ) -> u16 { let width = display_width(text).min(right.saturating_sub(x)); put_text(buffer, x, y, width, text, style); x.saturating_add(width) } pub(super) fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { if width == 0 || y >= buffer.area.bottom() || x >= buffer.area.right() { return; } buffer.set_stringn(x, y, text, width as usize, style); } pub(super) fn display_width(text: &str) -> u16 { UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16 }